From 018395d0c517051d14fef2ed7678fa3d6932357e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 3 Sep 2026 15:22:37 +0700 Subject: [PATCH] add push --- crates/signed_git/src/lib.rs | 138 +++++++++- crates/signed_state/src/backend.rs | 109 +++++++- crates/signed_state/src/checkouts.rs | 238 +++++++++++++++++- crates/workspace/src/views/repo_detail/mod.rs | 209 +++++++++++++-- crates/workspace/src/views/sidebar/mod.rs | 56 ++++- docs/PLAN.md | 59 +++++ 6 files changed, 767 insertions(+), 42 deletions(-) diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index c091450..05bb5d4 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -517,14 +517,24 @@ pub fn root_commit(repo_path: &Path) -> Result> { .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> { 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/`), +/// 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 { + 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 { @@ -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"); diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 307a69c..507b0b0 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -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, + /// 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>>, tasks: Vec>>, } @@ -103,6 +111,22 @@ struct GlobalBackend(Entity); 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>>, + addr: RepoAddr, +} + +impl Drop for PushGuard { + fn drop(&mut self) { + if let Ok(mut repos) = self.repos.lock() { + repos.remove(&self.addr); + } + } +} + impl EventEmitter 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, ) -> Task> { - 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, + cx: &mut Context, + ) -> Task> { + 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, + cx: &mut Context, + ) -> Task> { + 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(); diff --git a/crates/signed_state/src/checkouts.rs b/crates/signed_state/src/checkouts.rs index bd48f1e..72d1bce 100644 --- a/crates/signed_state/src/checkouts.rs +++ b/crates/signed_state/src/checkouts.rs @@ -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/`, 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, + /// 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, + /// Ready-to-push statuses of the requested own repositories. + push_statuses: Arc>>, /// Announced head branch last provided per requested repository, so a /// recompute defaults the base the same way. requested_head: HashMap>, @@ -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 { 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.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 { + 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 = 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> = HashMap::new(); + for addr in &push_requested { + let Some(paths) = associations.get(addr) else { + continue; + }; + let list: Vec = 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 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 { + 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 = tags diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 91b696a..c726fc2 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -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 = branches.iter().map(Into::into).collect(); let tags: Vec = 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, + ) { + 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) { - 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 { 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 { + 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) -> Option { + 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) diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index e9973f9..7c9db02 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -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.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)), ) } diff --git a/docs/PLAN.md b/docs/PLAN.md index ee98bfd..546347e 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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/..` (`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>>` 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.