This commit is contained in:
2026-09-03 15:22:37 +07:00
parent c0f06d095e
commit 018395d0c5
6 changed files with 767 additions and 42 deletions
+136 -2
View File
@@ -517,14 +517,24 @@ pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
.filter(|id| id.len() == 40))
}
/// Add `origin` pointing at `url` when the repository has no remote yet.
/// No-op if `origin` already exists.
/// Add `origin` pointing at `url` when the repository has no remote yet,
/// with the standard fetch mapping so later `git fetch origin` (and the
/// cache's `fetch_all`) updates `refs/remotes/origin/*`. No-op if `origin`
/// already exists.
pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
// `git remote get-url origin` exits non-zero when the remote is absent.
if git_in(repo_path, &["remote", "get-url", "origin"]).is_ok() {
return Ok(());
}
git_in(repo_path, &["remote", "add", "origin", url])?;
git_in(
repo_path,
&[
"config",
"remote.origin.fetch",
"+refs/heads/*:refs/remotes/origin/*",
],
)?;
Ok(())
}
@@ -667,6 +677,55 @@ pub fn origin_url(workdir: &Path) -> Result<Option<String>> {
Ok((!url.trim().is_empty()).then(|| url.trim().to_owned()))
}
/// Fast-forward every local branch of the repository at `workdir` that is
/// behind its remote-tracking counterpart (`refs/remotes/origin/<name>`),
/// like a `git pull --ff-only` on each branch, so a mirror clone used for
/// browsing catches up with the remote without ever rewriting history.
///
/// The checked-out branch is moved with a merge so its worktree follows
/// (a dirty worktree fails the merge cleanly and is left for the next
/// refresh); other branches are updated directly. Branches without a
/// remote-tracking counterpart, or with local commits of their own, are
/// left alone. Returns whether any branch moved.
pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
let current = git_in(workdir, &["branch", "--show-current"]).unwrap_or_default();
let heads = refs_with_prefix(workdir, "refs/heads")?;
let mut moved = false;
for head in heads {
let Some(branch) = head.strip_prefix("refs/heads/") else {
continue;
};
let remote = format!("refs/remotes/origin/{branch}");
// No remote-tracking counterpart: the remote does not have it.
let Ok(remote_oid) = git_in(workdir, &["rev-parse", "--verify", "--quiet", &remote]) else {
continue;
};
let Ok(local_oid) = git_in(workdir, &["rev-parse", "--verify", "--quiet", &head]) else {
continue;
};
if local_oid == remote_oid {
continue;
}
// Only fast-forward: local-only commits (or diverged history) must
// never be rewritten by a refresh.
if git_in(workdir, &["merge-base", "--is-ancestor", &head, &remote]).is_err() {
continue;
}
if current == branch {
// Merge so the checked-out worktree follows the branch.
if git_in(workdir, &["merge", "--ff-only", &remote]).is_ok() {
moved = true;
}
} else {
git_in(workdir, &["update-ref", &head, &remote_oid])?;
moved = true;
}
}
Ok(moved)
}
/// Run a git command in `dir`, returning trimmed stdout. The terminal prompt
/// is disabled so a credential request fails instead of hanging.
fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
@@ -2456,6 +2515,12 @@ mod tests {
git_in(&path, &["remote", "get-url", "origin"]).expect("url"),
"https://gitnostr.com/npub1test/repo.git"
);
// The standard fetch mapping is configured with the remote, so a
// later `git fetch origin` updates `refs/remotes/origin/*`.
assert_eq!(
git_in(&path, &["config", "remote.origin.fetch"]).expect("refspec"),
"+refs/heads/*:refs/remotes/origin/*"
);
// A second call must not override the existing remote.
ensure_origin(&path, "https://other.example/repo.git").expect("keep");
@@ -2533,6 +2598,75 @@ mod tests {
assert!(destination.join("README.md").is_file());
}
#[test]
fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() {
// A bare "server" like a grasp server's `{base}/{owner}/{repo}.git`
// layout.
let dir = tempfile::tempdir().expect("tempdir");
let base_server = dir.path().join("npub1test").join("repo.git");
std::fs::create_dir_all(base_server.parent().unwrap()).unwrap();
let init_status = Command::new("git")
.args(["init", "--bare", "-q"])
.arg(&base_server)
.status()
.expect("spawn git init --bare");
assert!(init_status.success());
// The owner's working repo pushes the initial commit.
let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]);
commit_all(&work_repo, "initial");
let work = work_dir.path();
let base_url = format!("file://{}", dir.path().display());
push_all(work, &base_url, "npub1test", "repo").expect("push");
// A mirror clone, like the app's GitCache clones.
let mirror = dir.path().join("mirror");
git_run(
dir.path(),
&[
"clone",
"-q",
&format!("{base_url}/npub1test/repo.git"),
mirror.to_str().unwrap(),
],
);
let initial = git_in(&mirror, &["rev-parse", "HEAD"]).expect("initial");
// The owner pushes a new commit; the mirror fetches it but its
// local `main` (and worktree) stay behind.
std::fs::write(work.join("new.txt"), b"new\n").expect("write");
commit_all(&gix::open(work).expect("open"), "new commit");
push_all(work, &base_url, "npub1test", "repo").expect("push");
git_run(&mirror, &["fetch", "origin"]);
let remote = git_in(&mirror, &["rev-parse", "refs/remotes/origin/main"]).expect("remote");
assert_eq!(
git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"),
initial
);
assert_ne!(remote, initial);
// Fast-forwarding catches the branch and its worktree up; the
// second call has nothing left to move.
assert!(fast_forward_branches(&mirror).expect("ff"));
assert_eq!(
git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"),
remote
);
assert!(mirror.join("new.txt").is_file());
assert!(!fast_forward_branches(&mirror).expect("idle"));
// A branch with local commits of its own is never touched.
git_run(&mirror, &["checkout", "-b", "wip"]);
std::fs::write(mirror.join("wip.txt"), b"wip\n").expect("write");
commit_all(&gix::open(&mirror).expect("open"), "local wip");
let wip = git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip");
assert!(!fast_forward_branches(&mirror).expect("wip skipped"));
assert_eq!(
git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip kept"),
wip
);
}
#[test]
fn fetch_repo_refs_imports_heads_under_a_prefix() {
let dir = tempfile::tempdir().expect("tempdir");
+101 -8
View File
@@ -1,8 +1,9 @@
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use anyhow::{Context as AnyhowContext, Error, anyhow, bail};
@@ -96,6 +97,13 @@ pub struct Backend {
/// so duplicate requests within [`FETCH_DEDUP_WINDOW`] collapse into
/// one. Entries are pruned lazily on the next request.
recent_fetches: HashMap<u64, Instant>,
/// Repositories a push (mirror or checkout) is currently in flight
/// for. Concurrent pushes of the same refs — two panels of the same
/// repository, or the banner push racing the header's Republish — make
/// the losing push fail server-side with a compare-and-swap rejection
/// ("cannot lock ref … is at … but expected …"), so pushes are
/// single-flight per repository.
pushing_repos: Arc<Mutex<HashSet<RepoAddr>>>,
tasks: Vec<Task<Result<(), Error>>>,
}
@@ -103,6 +111,22 @@ struct GlobalBackend(Entity<Backend>);
impl Global for GlobalBackend {}
/// Removes its repository from the in-flight push set when dropped, so a
/// push task that is cancelled (e.g. its panel closed mid-push) can never
/// leave the repository locked for the rest of the session.
struct PushGuard {
repos: Arc<Mutex<HashSet<RepoAddr>>>,
addr: RepoAddr,
}
impl Drop for PushGuard {
fn drop(&mut self) {
if let Ok(mut repos) = self.repos.lock() {
repos.remove(&self.addr);
}
}
}
impl EventEmitter<BackendEvent> for Backend {}
impl Backend {
@@ -147,6 +171,7 @@ impl Backend {
sync_progress: None,
passphrase_required: false,
recent_fetches: HashMap::new(),
pushing_repos: Arc::new(Mutex::new(HashSet::new())),
tasks: vec![pump],
};
@@ -758,9 +783,56 @@ impl Backend {
announcement: Announcement,
cx: &mut Context<Self>,
) -> Task<Result<(), Error>> {
let addr = announcement.addr();
let cache = GitStore::global(cx).cache().clone();
let path = cache.repo_path(&addr);
let path = cache.repo_path(&announcement.addr());
self.push_repo_from(announcement, path, None, cx)
}
/// Push the refs of a local checkout (the working copy of the user's
/// own repository) to the grasp servers announced in the `relays` tag:
/// publishes a fresh state event, then pushes every branch and tag of
/// the checkout, like the init flow. `announced_head` keeps the state
/// event's `HEAD` on the repository's announced default branch when the
/// checkout is on a different branch.
pub fn push_checkout(
&mut self,
announcement: Announcement,
checkout: PathBuf,
announced_head: Option<String>,
cx: &mut Context<Self>,
) -> Task<Result<(), Error>> {
self.push_repo_from(announcement, checkout, announced_head, cx)
}
/// Shared body of the mirror-based and checkout-based pushes: publish
/// the repository state (the push authorization), then push every
/// branch and tag of `path` to each announced grasp server. Pushes are
/// single-flight per repository: two concurrent pushes of the same refs
/// (e.g. two panels of the same repository) make the losing push fail
/// server-side with a compare-and-swap rejection.
fn push_repo_from(
&mut self,
announcement: Announcement,
path: PathBuf,
announced_head: Option<String>,
cx: &mut Context<Self>,
) -> Task<Result<(), Error>> {
let addr = announcement.addr();
let guard = {
let mut pushing = self
.pushing_repos
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if !pushing.insert(addr.clone()) {
return Task::ready(Err(anyhow!(
"A push to this repository is already in progress"
)));
}
PushGuard {
repos: self.pushing_repos.clone(),
addr: addr.clone(),
}
};
let owner = announcement
.owner
.to_bech32()
@@ -769,11 +841,32 @@ impl Backend {
let relays = announcement.relays.clone();
cx.spawn(async move |this, cx| {
let work = cx.background_spawn({
let path = path.clone();
async move { signed_git::worktree_ref_state(&path) }
});
let state = work.await?;
// Held for the whole task; dropped (and the lock released) on
// completion, on error and on cancellation alike.
let _guard = guard;
let mut state = {
let work = cx.background_spawn({
let path = path.clone();
async move { signed_git::worktree_ref_state(&path) }
});
work.await?
};
// The state event announces the pushed refs. When the source is
// a checkout on a side branch, keep the repository's announced
// default branch (its `HEAD`) when that branch is among the
// pushed refs; otherwise the checkout's current branch.
let heads: Vec<&str> = state
.refs
.iter()
.filter_map(|(name, _)| name.strip_prefix("refs/heads/"))
.collect();
if let Some(head) = announced_head
&& heads.iter().any(|branch| *branch == head)
{
state.head = Some(head);
}
// Grasp servers authorize a push by the state they have seen.
let refs = state.refs.clone();
+226 -12
View File
@@ -11,8 +11,15 @@
//! URL (scheme-insensitive), or whose root commit equals an announcement
//! EUC, is a checkout of that announced repository.
//!
//! The store also computes per-checkout "ready to contribute" statuses
//! (branch, base and commits ahead) for the pull-request list banner.
//! The store also computes per-checkout statuses for two surfaces:
//!
//! - **"Ready to contribute"** (pull-request banner of repositories the
//! user does not own): branch, base and commits ahead of the base.
//! - **"Ready to push"** (sidebar badge and banner of the user's own
//! repositories): the checked-out branch has commits the grasp servers
//! do not have yet (counted against the refreshed remote-tracking
//! refs), so the user can push their local work from the app.
//!
//! Everything is resolved on background threads and swapped in as
//! [`Arc`]s; the UI never waits for git.
@@ -28,6 +35,7 @@ use nostr::prelude::*;
use settings::{CheckoutRecord, SettingsStore};
use signed_core::{Announcement, RepoAddr};
use crate::backend::{Backend, BackendEvent};
use crate::git_store::GitStore;
use crate::local_repos::LocalReposStore;
use crate::repo_list::RepoListStore;
@@ -41,6 +49,11 @@ const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// without reopening the panel.
const STATUS_POLL: Duration = Duration::from_secs(15);
/// Background poll interval for the "ready to push" badges of the user's
/// own repositories when no repository panel is open (each cycle refreshes
/// the remote view of the checkouts with a git fetch).
const PUSH_POLL: Duration = Duration::from_secs(60);
/// Maximum checkouts considered per repository when computing statuses.
const MAX_STATUS_CHECKOUTS: usize = 8;
@@ -58,8 +71,12 @@ pub struct CheckoutStatus {
pub branch: String,
/// Commit the branch points at, for tip-based PR dedupe.
pub head: String,
/// The branch this checkout is compared against (announced HEAD branch,
/// else `main`, else the first local branch).
/// What the branch is compared against. For ready-to-contribute
/// statuses: the announced HEAD branch (else `main`, else the first
/// local branch). For ready-to-push statuses: the remote-tracking ref
/// the unpushed commits are counted against
/// (`refs/remotes/origin/<branch>`, or `origin/HEAD` for branches the
/// remote does not have yet).
pub base: String,
/// Commits in `base..branch`; always > 0 (even checkouts are dropped).
pub ahead: u32,
@@ -83,6 +100,12 @@ pub struct CheckoutsStore {
/// Repositories whose statuses are recomputed whenever the inputs
/// change (the repository detail panels currently open).
status_requested: HashSet<RepoAddr>,
/// Repositories whose "ready to push" statuses are recomputed on the
/// same cycle (the sidebar rows of the user's own repositories, plus
/// the detail panels of those repositories).
push_requested: HashSet<RepoAddr>,
/// Ready-to-push statuses of the requested own repositories.
push_statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
/// Announced head branch last provided per requested repository, so a
/// recompute defaults the base the same way.
requested_head: HashMap<RepoAddr, Option<String>>,
@@ -105,7 +128,8 @@ impl CheckoutsStore {
}
/// Create the store: observe the inputs (settings records, the local
/// scan, the announcement list) and resolve the associations right away.
/// scan, the announcement list, signer changes) and resolve the
/// associations right away.
pub fn new(cx: &mut Context<Self>) -> Self {
let mut subscriptions = Vec::new();
@@ -113,6 +137,7 @@ impl CheckoutsStore {
let settings = SettingsStore::global(cx);
let local = LocalReposStore::global(cx);
let repos = RepoListStore::global(cx);
let backend = Backend::global(cx);
subscriptions.push(cx.observe(&settings, |this, _settings, cx| {
this.refresh(cx);
@@ -123,12 +148,26 @@ impl CheckoutsStore {
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
this.refresh(cx);
}));
// Another identity's repositories must not keep the previous
// user's statuses (or polls) alive.
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
if matches!(event, BackendEvent::SignerChanged) {
this.status_requested.clear();
this.push_requested.clear();
this.requested_head.clear();
this.statuses = Arc::new(HashMap::new());
this.push_statuses = Arc::new(HashMap::new());
this.refresh(cx);
}
}));
}
let mut store = Self {
by_repo: Arc::new(HashMap::new()),
statuses: Arc::new(HashMap::new()),
status_requested: HashSet::new(),
push_requested: HashSet::new(),
push_statuses: Arc::new(HashMap::new()),
requested_head: HashMap::new(),
refreshing: false,
refresh_dirty: false,
@@ -202,6 +241,24 @@ impl CheckoutsStore {
self.statuses.get(addr).cloned().unwrap_or_default()
}
/// Ask for the "ready to push" statuses of `addr` to be kept current
/// (called by the sidebar for the signed-in user's own repositories and
/// by the detail panels of those repositories). Recomputed on every
/// input change and on a background poll; each cycle refreshes the
/// remote view of the checkouts first, so a commit made in external
/// git surfaces within one poll interval.
pub fn request_push_statuses(&mut self, addr: &RepoAddr, cx: &mut Context<Self>) {
self.push_requested.insert(addr.clone());
self.refresh(cx);
}
/// The ready-to-push statuses of `addr` (only meaningful for
/// repositories announced by the signed-in user); empty while none are
/// known or nothing is unpushed.
pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
self.push_statuses.get(addr).cloned().unwrap_or_default()
}
/// Re-resolve associations (and the requested statuses). Debounced:
/// bursts of notifications collapse into one pass; requests arriving
/// while a pass runs are folded into a follow-up.
@@ -260,6 +317,8 @@ impl CheckoutsStore {
)
})
.collect();
let push_requested: Vec<RepoAddr> = self.push_requested.iter().cloned().collect();
let poll = !self.status_requested.is_empty() || !self.push_requested.is_empty();
let work = cx.background_spawn(async move {
// Read the git facts of every scanned repository off the main
@@ -301,11 +360,26 @@ impl CheckoutsStore {
}
}
Ok::<_, Error>((associations, statuses))
let mut push_statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
for addr in &push_requested {
let Some(paths) = associations.get(addr) else {
continue;
};
let list: Vec<CheckoutStatus> = paths
.iter()
.take(MAX_STATUS_CHECKOUTS)
.filter_map(|path| checkout_push_status(path))
.collect();
if !list.is_empty() {
push_statuses.insert(addr.clone(), list);
}
}
Ok::<_, Error>((associations, statuses, push_statuses))
});
self.tasks.push(cx.spawn(async move |this, cx| {
let (associations, statuses) = match work.await {
let (associations, statuses, push_statuses) = match work.await {
Ok(results) => results,
Err(_) => {
// Git reads are best-effort; keep the last results.
@@ -318,6 +392,7 @@ impl CheckoutsStore {
let again = this.update(cx, |this, cx| {
this.by_repo = Arc::new(associations);
this.statuses = Arc::new(statuses);
this.push_statuses = Arc::new(push_statuses);
cx.notify();
this.refreshing = false;
@@ -333,14 +408,23 @@ impl CheckoutsStore {
this.update(cx, |this, cx| this.refresh(cx))?;
}
// While any repository panel is open, keep its statuses
// current: local commits, pulls and branch switches happen
// outside the app and are not otherwise observable.
// While any repository panel is open (or any of the user's own
// repositories is watched for the sidebar badge), keep the
// statuses current: local commits, pulls and branch switches
// happen outside the app and are not otherwise observable.
this.update(cx, |this, cx| {
if !this.status_requested.is_empty() && !this.debouncing && !this.refreshing {
if poll && !this.debouncing && !this.refreshing {
this.debouncing = true;
// Open panels get the fast cadence; the sidebar badges
// alone poll less aggressively (each cycle fetches
// every watched checkout's remote).
let delay = if this.status_requested.is_empty() {
PUSH_POLL
} else {
STATUS_POLL
};
let task = cx.spawn(async move |this, cx| {
cx.background_executor().timer(STATUS_POLL).await;
cx.background_executor().timer(delay).await;
this.update(cx, |this, cx| {
this.debouncing = false;
this.run_refresh(cx);
@@ -499,6 +583,57 @@ fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<Checkout
})
}
/// Whether the reference `name` (e.g. `refs/remotes/origin/main`) exists
/// in the checkout at `path`.
fn ref_exists(path: &Path, name: &str) -> bool {
let output = Command::new("git")
.arg("-C")
.arg(path)
.args(["rev-parse", "--verify", "--quiet", name])
.env("GIT_TERMINAL_PROMPT", "0")
.output();
matches!(output, Ok(output) if output.status.success())
}
/// The "ready to push" status of one checkout of the user's own
/// repository: the checked-out branch has commits the grasp servers do not
/// have yet. The remote view is refreshed first (best-effort: offline, the
/// last known remote state still counts the commits made since). Detached
/// checkouts, dirty worktrees and branches with no remote state at all
/// (the remote HEAD is unknown) are never suggested; branches the remote
/// does not have yet are counted against the remote HEAD.
fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
if worktree_dirty(path) {
return None;
}
let branch = current_branch_of(path)?;
let head = signed_git::head_commit_id(path).ok().flatten()?;
let origin = signed_git::origin_url(path).ok().flatten()?;
// Refresh the remote heads so a commit made elsewhere (or pushed from
// another machine) does not show as "to push" forever.
signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok();
let remote = format!("refs/remotes/origin/{branch}");
// A branch that has never been fetched/pushed yet is compared against
// the remote HEAD (its fork point in practice).
let base = if ref_exists(path, &remote) {
remote
} else if ref_exists(path, "refs/remotes/origin/HEAD") {
"refs/remotes/origin/HEAD".to_owned()
} else {
return None;
};
let ahead = commits_ahead(path, &base, &branch);
(ahead > 0).then_some(CheckoutStatus {
path: path.to_path_buf(),
branch,
head,
base,
ahead,
})
}
/// Whether the pull request `pr` (a kind-1618 root, resolved `open` by the
/// caller) already proposes the same change as `checkout`: authored by
/// `user`, with a matching `branch-name` tag, or — for renamed branches — a
@@ -733,6 +868,85 @@ mod tests {
assert_eq!(checkout_status(&path, Some("main")), None);
}
#[test]
fn checkout_push_status_counts_unpushed_commits_only() {
// The "grasp remote": a plain repository the checkout clones from
// (origin URL = local path, so the whole cycle runs offline). Git
// refuses pushes to its checked-out branch by default; act like a
// grasp server and allow them.
let dir = tempfile::tempdir().expect("tempdir");
let remote = dir.path().join("remote");
signed_git::init_repository(&remote, "My Repo", "").expect("init");
let config = Command::new("git")
.args(["config", "receive.denyCurrentBranch", "ignore"])
.current_dir(&remote)
.status()
.expect("git config");
assert!(config.success(), "git config failed");
let checkout = dir.path().join("checkout");
let status = Command::new("git")
.args([
"clone",
"-q",
remote.to_str().unwrap(),
checkout.to_str().unwrap(),
])
.status()
.expect("git clone");
assert!(status.success(), "git clone failed");
let run = |args: &[&str]| {
let status = Command::new("git")
.current_dir(&checkout)
.env("GIT_AUTHOR_NAME", "Test Author")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test Author")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.env("GIT_EDITOR", "true")
.args(args)
.status()
.expect("git");
assert!(status.success(), "git {args:?} failed");
};
// A fresh clone has nothing to push.
assert_eq!(checkout_push_status(&checkout), None);
// One local commit: ready to push, counted against the remote.
std::fs::write(checkout.join("work.txt"), "x\n").expect("write");
run(&["add", "-A"]);
run(&["commit", "-m", "local work"]);
let status = checkout_push_status(&checkout).expect("status");
assert_eq!(status.branch, "main");
assert_eq!(status.base, "refs/remotes/origin/main");
assert_eq!(status.ahead, 1);
assert_eq!(status.head.len(), 40);
// After the push the same commit is on the remote: idle again.
run(&["push", "origin", "main"]);
assert_eq!(checkout_push_status(&checkout), None);
// A commit made by someone else on the remote must not count as
// local work (it is behind, not ahead).
let remote_run = |args: &[&str]| {
let status = Command::new("git")
.current_dir(&remote)
.env("GIT_AUTHOR_NAME", "Other Author")
.env("GIT_AUTHOR_EMAIL", "other@example.com")
.env("GIT_COMMITTER_NAME", "Other Author")
.env("GIT_COMMITTER_EMAIL", "other@example.com")
.args(args)
.status()
.expect("git");
assert!(status.success(), "git {args:?} failed");
};
std::fs::write(remote.join("other.txt"), "y\n").expect("write");
remote_run(&["add", "-A"]);
remote_run(&["commit", "-m", "remote work"]);
assert_eq!(checkout_push_status(&checkout), None);
}
fn pr_event(author: &str, tags: &[&[&str]]) -> Event {
let keys = Keys::new(SecretKey::from_hex(author).expect("secret"));
let tags: Vec<Tag> = tags
+195 -14
View File
@@ -462,6 +462,18 @@ impl RepoDetailView {
// cached state, which is already shown.
signed_git::fetch_all(&repo).ok();
let worktree = repo.workdir().map(Path::to_path_buf);
// A fetch never moves a mirror's local branches, so a
// push landing on the grasp servers (own repo pushed
// from a checkout, or an update fetched here) would
// never show up. Fast-forward them from the remote,
// like `git pull --ff-only` on every branch; only the
// checked-out branch's worktree can change on disk.
let moved = match &worktree {
Some(worktree) => {
signed_git::fast_forward_branches(worktree).unwrap_or(false)
}
None => false,
};
let (branches, tags) = match &worktree {
Some(_) => (
signed_git::repo_branches(&repo).unwrap_or_default(),
@@ -471,7 +483,7 @@ impl RepoDetailView {
};
let current_branch = signed_git::current_branch(&repo).unwrap_or(None);
let head_commit = signed_git::head_commit(&repo).unwrap_or(None);
Ok::<_, Error>(Some((branches, tags, current_branch, head_commit)))
Ok::<_, Error>(Some((moved, branches, tags, current_branch, head_commit)))
})
}
.await;
@@ -480,7 +492,17 @@ impl RepoDetailView {
if refresh_generation != this.ref_generation {
return;
}
if let Ok(Some((branches, tags, current_branch, head_commit))) = refresh {
if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh {
if moved {
// The mirror caught up with the remote (e.g. the
// push of an owned checkout just landed): rebuild
// the explorer, previews and commit list from the
// updated worktree.
this.reload_worktree(cx);
cx.notify();
return;
}
let branches: Vec<SharedString> = branches.iter().map(Into::into).collect();
let tags: Vec<SharedString> = tags.iter().map(Into::into).collect();
@@ -938,6 +960,70 @@ impl RepoDetailView {
}));
}
/// Push the unpushed commits of the local checkout at
/// `path` (an owned repository's working copy) to the announced grasp servers,
/// failures appear in the panel's error banner,
/// and on success the push statuses are recomputed so the banner clears.
fn push_unpushed_checkout(
&mut self,
path: PathBuf,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.pushing {
return;
}
let Some(announcement) = self.announcement(cx).cloned() else {
return;
};
// Keep the repository's announced default branch as the state
// event's `HEAD` when the checkout is on a side branch.
let head = self
.store
.as_ref()
.and_then(|store| store.read(cx).head.clone());
let addr = announcement.addr();
self.pushing = true;
self.error = None;
cx.notify();
let backend = Backend::global(cx);
let checkout = CheckoutsStore::global(cx);
let task = backend.update(cx, |backend, cx| {
backend.push_checkout(announcement, path.clone(), head, cx)
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(()) => {
// The remote moved; recompute the push statuses so
// the banner disappears, and refresh the mirror so
// the pushed commits appear in the panel right away
// (fetch + fast-forward + explorer reload).
checkout.update(cx, |store, cx| {
store.request_push_statuses(&addr, cx);
});
this.load_repo(window, cx);
}
Err(error) => {
this.error = Some(format!("Push failed: {error}").into());
}
}
this.pushing = false;
cx.notify();
})?;
Ok(())
}));
}
/// Delete the repository from nostr (announcement, state and activity);
/// only offered to the repository owner. The sidebar list updates when
/// the deletion events arrive.
@@ -1840,33 +1926,57 @@ impl RepoDetailView {
self.refresh_ready_statuses(cx);
}
/// (Re)request the ready statuses of this repository when the announced
/// HEAD the base the checkouts are compared against changed since
/// the last request.
/// (Re)request the statuses of this repository when the announced
/// HEAD - the base the checkouts are compared against, changed since the last request.
/// Repositories the user owns are watched for unpushed commits,
/// other repositories for ready-to-contribute checkouts.
fn refresh_ready_statuses(&mut self, cx: &mut Context<Self>) {
let Some(store) = self.store.clone() else {
let Some(entity) = self.store.clone() else {
return;
};
let head = store.read(cx).head.clone();
let head = entity.read(cx).head.clone();
if self.ready_requested && self.ready_head == head {
return;
}
self.ready_requested = true;
self.ready_head = head.clone();
let addr = store.read(cx).addr().clone();
CheckoutsStore::global(cx).update(cx, |store, cx| {
let addr = entity.read(cx).addr().clone();
let backend = Backend::global(cx);
let checkout = CheckoutsStore::global(cx);
let owned = backend
.read(cx)
.current_user()
.is_some_and(|user| entity.read(cx).is_author(&user));
checkout.update(cx, |store, cx| {
// The ready statuses also keep the fast poll running while the
// panel is open (the sidebar's push watch alone polls slower).
store.request_statuses(&addr, head, cx);
if owned {
store.request_push_statuses(&addr, cx);
}
});
}
/// The first checkout ready for a pull request on this repository,
/// not covered by an open PR of the signed-in user and not dismissed in this panel.
/// not covered by an open PR of the signed-in user and not dismissed in
/// this panel. The repository's own checkouts are not suggested here:
/// their work is pushed (see [`Self::push_suggestion`]).
fn ready_suggestion(&self, cx: &App) -> Option<CheckoutStatus> {
let store = self.store.as_ref()?;
let addr = store.read(cx).addr().clone();
let user = Backend::global(cx).read(cx).current_user()?;
if store.read(cx).is_author(&user) {
return None;
}
let statuses = CheckoutsStore::global(cx).read(cx).statuses_of(&addr);
let user = Backend::global(cx).read(cx).current_user()?;
'status: for status in statuses {
if self
@@ -1888,6 +1998,75 @@ impl RepoDetailView {
None
}
/// The first checkout of this owned repository with unpushed commits,
/// not dismissed in this panel.
fn push_suggestion(&self, cx: &App) -> Option<CheckoutStatus> {
let entity = self.store.as_ref()?;
let user = Backend::global(cx).read(cx).current_user()?;
if !entity.read(cx).is_author(&user) {
return None;
}
let addr = entity.read(cx).addr().clone();
let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(&addr);
statuses.into_iter().find(|status| {
!self
.banner_dismissed
.contains(&(status.path.clone(), status.branch.clone()))
})
}
/// The "ready to push" banner of an owned repository: a local checkout
/// has unpushed commits, with a Push action and a dismiss control.
fn render_push_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let status = self.push_suggestion(cx)?;
let commits = if status.ahead == 1 {
"1 commit".to_owned()
} else {
format!("{} commits", status.ahead)
};
let message = SharedString::from(format!(
"{} has {} ready to push in {}",
status.branch,
commits,
status.path.display()
));
let key = (status.path.clone(), status.branch.clone());
let view = cx.entity().clone();
let path = status.path.clone();
let pushing = self.pushing;
Some(
h_flex()
.gap_2()
.px_4()
.pt_1()
.w_full()
.items_center()
.child(
Alert::info("repo-unpushed", message)
.banner()
.flex_1()
.on_close(move |_event, _window, cx| {
view.update(cx, |this, _| {
this.banner_dismissed.insert(key.clone());
});
}),
)
.child(
Button::new("push-checkout-banner")
.small()
.icon(CustomIconName::Init)
.label("Push")
.loading(pushing)
.disabled(pushing)
.on_click(cx.listener(move |this, _event, window, cx| {
this.push_unpushed_checkout(path.clone(), window, cx);
})),
)
.into_any_element(),
)
}
/// The "ready to contribute" banner of the repository panel: message,
/// a Create action opening the prefilled New PR panel, and a dismiss
/// control.
@@ -2165,14 +2344,16 @@ impl Render for RepoDetailView {
.or_else(|| self.readme_name.clone())
.unwrap_or_else(|| "Overview".into());
let banner = self
.render_ready_banner(cx)
.or_else(|| self.render_push_banner(cx));
v_flex()
.image_cache(image_cache("repo", MAX_IMAGES))
.id("repo")
.size_full()
.child(self.render_header(cx))
.when_some(self.render_ready_banner(cx), |this, banner| {
this.child(banner)
})
.when_some(banner, |this, banner| this.child(banner))
.when_some(self.error.clone(), |this, error| {
this.child(
Alert::error("repo-error", error)
+50 -6
View File
@@ -15,9 +15,11 @@ use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::InputState;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use signed_core::{Announcement, identifier_from_name};
use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore, RepoListStore};
use signed_state::{
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore,
};
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
use signed_ui::{CountBadge, NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
use super::{RepoDetailView, RepoListView, open_repo_panel};
@@ -47,6 +49,9 @@ pub struct SidebarPanel {
banner: SharedString,
/// Observes the local-repository scan so new discoveries re-render.
_local_repos_subscription: Subscription,
/// Observes the checkouts store, whose ready-to-push statuses feed the
/// badges on the user's repository rows.
_checkouts_subscription: Subscription,
_subscription: Subscription,
}
@@ -77,6 +82,11 @@ impl SidebarPanel {
cx.notify();
});
let checkouts_store = CheckoutsStore::global(cx);
let checkouts_subscription = cx.observe(&checkouts_store, |_, _, cx| {
cx.notify();
});
let mut panel = Self {
focus_handle: cx.focus_handle(),
dock_area,
@@ -86,6 +96,7 @@ impl SidebarPanel {
my_repos_subscription: None,
banner: pick_banner(),
_local_repos_subscription: local_repos_subscription,
_checkouts_subscription: checkouts_subscription,
_subscription: subscription,
};
@@ -96,7 +107,8 @@ impl SidebarPanel {
panel
}
/// (Re)create the store listing the current user's repositories.
/// (Re)create the store listing the current user's repositories,
/// and watch each of them for unpushed local work.
fn refresh_my_repos(&mut self, cx: &mut Context<Self>) {
self.my_repos_subscription = None;
@@ -105,7 +117,24 @@ impl SidebarPanel {
self.my_repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx)));
if let Some(store) = self.my_repos.as_ref() {
self.my_repos_subscription = Some(cx.observe(store, |_, _, cx| cx.notify()));
self.my_repos_subscription = Some(cx.observe(store, |_this, store, cx| {
cx.notify();
// These are the signed-in user's own repositories; request
// their ready-to-push statuses (deduplicated per repo) so
// the rows carry a badge while local work is unpushed.
let addrs: Vec<_> = store
.read(cx)
.announcements
.iter()
.map(|a| a.addr())
.collect();
let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |checkouts, cx| {
for addr in addrs {
checkouts.request_push_statuses(&addr, cx);
}
});
}));
}
}
@@ -328,9 +357,24 @@ impl SidebarPanel {
.clone()
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
let announcement = announcement.clone();
NavItem::new(format!("my-repo:{}", announcement.id), name, avatar).on_click(
// A small badge with the unpushed commit count of the repository's
// local checkouts (ready to push to the grasp servers).
let unpushed: usize = CheckoutsStore::global(cx)
.read(cx)
.push_statuses_of(&announcement.addr())
.iter()
.map(|status| status.ahead as usize)
.sum();
let announcement = announcement.clone();
let mut row = NavItem::new(format!("my-repo:{}", announcement.id), name, avatar);
if unpushed > 0 {
row = row.suffix(CountBadge::new(unpushed));
}
row.on_click(
cx.listener(move |this, _ev, window, cx| this.open_repo(&announcement, window, cx)),
)
}
+59
View File
@@ -596,3 +596,62 @@ The manual e2e checklist above still needs a real GRASP-06 server run.
so a failure aborts creation cleanly with nothing announced. Two new
`signed_git` tests (`set_origin_creates_or_replaces_the_remote`,
`working_copy_cloned_from_the_mirror_matches_head_and_origin`).
- **Add (after e2e, user request)** — "ready to push" watch for the
user's own repositories: local commits made in a checkout (external
git) surface as a **sidebar badge** on the repository row (a
`CountBadge` with the unpushed commit count) and, when the repository
panel is open, as an info **banner with a Push button**. The
`CheckoutsStore` gains a second status family (`request_push_statuses`/
`push_statuses_of`): per checked-out branch it refreshes the remote
view (`git fetch` of the checkout's origin, offline-tolerant) and
counts `origin/<branch>..<branch>` (`origin/HEAD` for branches the
remote does not have yet); dirty/detached checkouts are skipped like
the PR suggestions. Poll cadence: 15 s while a repository panel is
open (`status_requested`), 60 s for the sidebar-only background watch;
request sets are cleared on signer change. The repo panel's
ready-to-contribute banner now applies only to repositories of other
authors — owned repositories get the push banner instead, whose Push
action calls the new `Backend::push_checkout` (shared body with the
existing mirror-based `push_repository`): publishes a fresh 30618
state event (keeping the announced `HEAD` branch when the checkout is
on a side branch) then pushes every branch and tag to the announced
grasp servers. New test
`checkout_push_status_counts_unpushed_commits_only`. The mirror's file
browser stays a snapshot (new commits appear after a branch switch),
like the rest of the browser.
- **Fix (after e2e, user report)** — a push warning "cannot lock ref
'refs/heads/main': is at X but expected Y" (server-side compare-and-
swap rejection, `incorrect old value provided`). Reproduced locally:
two concurrent plain pushes of the *same* ref from the same base make
the loser fail exactly this way — the app can race itself when two
push sources for one repository run at once (two panels of the same
repo, or the banner Push racing the header's Republish; each guard was
per-view only). Fix: pushes are now single-flight per repository in
`Backend::push_repo_from` via an `Arc<Mutex<HashSet<RepoAddr>>>` guard
(`PushGuard`, RAII: the lock is released on completion, on error and on
task cancellation alike); a second concurrent push fails fast with
"A push to this repository is already in progress" instead of racing.
Racing an external `git push` against the same server remains possible
(benign: the ref converges; the loser logs a warning only).
- **Fix (after e2e, user report)** — after a successful push the
repository panel's commit list stayed on the old commit (even across
restarts): the browser reads the GitCache mirror, and a fetch never
moves a mirror's *local* branches — `origin/main` advanced while local
`main` (what the commit list walks) stayed behind. ngit/nak never hit
this because they operate on real clones the user `git pull`s; nak also
publishes the updated 30618 state *before* each push, which Signed
already did. Fixes, mirroring a `git pull --ff-only` on the browser
clone: new `signed_git::fast_forward_branches(workdir)` (per local
branch, when it is an ancestor of its `refs/remotes/origin/*`
counterpart: the checked-out branch is merged so its worktree follows,
dirty worktrees and local-only commits are never touched; returns
whether anything moved); `RepoDetailView::load_repo`'s background
refresh fast-forwards after `fetch_all` and rebuilds the explorer,
previews and commit list (`reload_worktree`) when anything moved;
`push_unpushed_checkout` reloads the mirror on success so an owned
repo's pushed commit appears immediately; `ensure_origin` now also
configures the standard `remote.origin.fetch` refspec (create-flow
mirrors otherwise never map heads on fetch). New test
`fast_forward_branches_moves_the_mirror_and_keeps_local_work`; the
remote-only-branch limitation stays (a branch the mirror has never
checked out is not listed), as documented.