//! Local checkout associations ("remember" tier of the PR suggestions): //! which local folders are checkouts of which announced repositories. //! //! Two sources feed the resolution: //! //! - **Remembered records** (settings, [`settings::CheckoutRecord`]): //! recorded when the user clones a repository from the app or picks a //! folder in the New PR panel. //! - **Implicit matches** over the local scan ([`LocalReposStore`]): a //! scanned repository whose `origin` URL matches an announcement `clone` //! URL (scheme-insensitive), or whose root commit equals an announcement //! EUC, is a checkout of that announced repository. //! //! 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. use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::Error; use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; 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; /// Delay between a refresh request and the actual re-computation, so bursts /// of notifications (settings edits, rescan ticks) collapse into one pass. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); /// How often the statuses of open repository panels are refreshed, so a /// checkout committed to or pulled in external git surfaces in the banner /// 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; struct GlobalCheckoutsStore(Entity); impl Global for GlobalCheckoutsStore {} /// One associated local checkout of a repository, with the git facts needed /// to suggest a pull request. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CheckoutStatus { /// The checkout folder. pub path: PathBuf, /// The branch checked out (`None`-less: detached checkouts are idle). pub branch: String, /// Commit the branch points at, for tip-based PR dedupe. pub head: String, /// 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, } /// A remembered record, with the address already parsed. struct Remembered { path: PathBuf, addr: RepoAddr, last_used: u64, } /// Global store of local-checkout associations and per-checkout statuses. pub struct CheckoutsStore { /// Checkout paths per announced repository: remembered records /// (freshest first) plus scanned repos matched implicitly, deduplicated /// by path. Missing directories are dropped before publishing. by_repo: Arc>>, /// Ready-to-contribute statuses of the requested repositories. statuses: Arc>>, /// 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>, refreshing: bool, refresh_dirty: bool, /// A refresh is waiting out [`REFRESH_DEBOUNCE`]. debouncing: bool, _subscriptions: Vec, tasks: Vec>>, } impl CheckoutsStore { /// Retrieve the global checkouts store. pub fn global(cx: &App) -> Entity { cx.global::().0.clone() } pub(crate) fn set_global(entity: Entity, cx: &mut App) { cx.set_global(GlobalCheckoutsStore(entity)); } /// Create the store: observe the inputs (settings records, the local /// scan, the announcement list, signer changes) and resolve the /// associations right away. pub fn new(cx: &mut Context) -> Self { let mut subscriptions = Vec::new(); if !cfg!(target_arch = "wasm32") { 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); })); subscriptions.push(cx.observe(&local, |this, _local, cx| { this.refresh(cx); })); 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, debouncing: false, _subscriptions: subscriptions, tasks: Vec::new(), }; if !cfg!(target_arch = "wasm32") { store.refresh(cx); } store } /// Remember a successful local-checkout use: (re)insert the record with /// a fresh timestamp, so freshest-first ordering follows actual use. pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context) { if cfg!(target_arch = "wasm32") { return; } let last_used = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let addr_str = addr.to_string(); let settings = SettingsStore::global(cx); settings.update(cx, |settings, cx| { settings.edit( |s| { s.checkouts .records .retain(|r| !(r.path == path && r.addr == addr_str)); s.checkouts.records.push(CheckoutRecord { path, addr: addr_str, last_used, }); }, cx, ); }); } /// The associated checkouts of `addr`, freshest first. Empty when none /// are known (or the resolution has not run yet). pub fn associations_of(&self, addr: &RepoAddr) -> Vec { self.by_repo.get(addr).cloned().unwrap_or_default() } /// Ask for the "ready to contribute" statuses of `addr` to be kept /// current (called while the repository's detail panel is open). /// `announced_head` is the announced HEAD branch of the repository /// (from its state announcement), used to default the base. pub fn request_statuses( &mut self, addr: &RepoAddr, announced_head: Option, cx: &mut Context, ) { self.status_requested.insert(addr.clone()); if announced_head != self.requested_head.get(addr).cloned().flatten() { self.requested_head.insert(addr.clone(), announced_head); } self.refresh(cx); } /// The ready-to-contribute statuses of `addr`; empty while none are /// known or nothing is ahead. pub fn statuses_of(&self, addr: &RepoAddr) -> Vec { 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. pub fn refresh(&mut self, cx: &mut Context) { if self.refreshing { self.refresh_dirty = true; return; } if self.debouncing { return; } self.debouncing = true; let task = cx.spawn(async move |this, cx| { cx.background_executor().timer(REFRESH_DEBOUNCE).await; this.update(cx, |this, cx| { this.debouncing = false; this.run_refresh(cx); }) }); self.tasks.push(task); } /// One resolve + apply cycle (debounced entry point). fn run_refresh(&mut self, cx: &mut Context) { self.refreshing = true; // Inputs snapshot, all cheap shared reads. let records = { let settings = SettingsStore::global(cx); settings.read(cx).settings().checkouts.records.clone() }; let remembered: Vec = records .into_iter() .filter_map(|record| { let addr = record.addr.parse::().ok()?; Some(Remembered { path: record.path, addr, last_used: record.last_used, }) }) .collect(); let announcements = RepoListStore::global(cx).read(cx).announcements.clone(); let scanned = LocalReposStore::global(cx).read(cx).repos.clone(); let cache_root = GitStore::global(cx).cache().root().canonicalize().ok(); let requested: Vec<(RepoAddr, Option)> = self .status_requested .iter() .map(|addr| { ( addr.clone(), self.requested_head.get(addr).cloned().flatten(), ) }) .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 // thread: origin URL and root commit (both CLI reads). let mut facts: Vec<(PathBuf, Option, Option)> = Vec::new(); for path in scanned.iter() { // The browser's mirror clones share the announce URLs and // EUCs; they are not user checkouts. if cache_root .as_ref() .is_some_and(|root| path.starts_with(root)) { continue; } let origin = signed_git::origin_url(path).ok().flatten(); let root = signed_git::root_commit(path).ok().flatten(); facts.push((path.clone(), origin, root)); } let associations = resolve_associations(&remembered, &facts, announcements.iter()); // Missing directories are stale records; drop them. let associations: HashMap> = associations .into_iter() .map(|(addr, paths)| (addr, paths.into_iter().filter(|p| p.is_dir()).collect())) .collect(); let mut statuses: HashMap> = HashMap::new(); for (addr, announced_head) in &requested { let Some(paths) = associations.get(addr) else { continue; }; let list: Vec = paths .iter() .take(MAX_STATUS_CHECKOUTS) .filter_map(|path| checkout_status(path, announced_head.as_deref())) .collect(); if !list.is_empty() { statuses.insert(addr.clone(), list); } } 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, push_statuses) = match work.await { Ok(results) => results, Err(_) => { // Git reads are best-effort; keep the last results. return this.update(cx, |this, _cx| { this.refreshing = false; }); } }; 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; if this.refresh_dirty { this.refresh_dirty = false; true } else { false } })?; if again { this.update(cx, |this, cx| this.refresh(cx))?; } // 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 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(delay).await; this.update(cx, |this, cx| { this.debouncing = false; this.run_refresh(cx); }) }); this.tasks.push(task); } })?; Ok(()) })); } } /// The identity of a repository URL: host, explicit port and path with a /// trailing `.git` (and slashes) stripped. Scheme-insensitive, so /// `ws`/`wss`/`http`/`https`/`grasp` are equivalent transports of the same /// grasp server. `None` for URLs that cannot be parsed (e.g. `git@`-style /// or plain paths), which then compare by raw string. fn url_identity(url: &str) -> Option<(String, Option, String)> { let parsed = Url::parse(url).ok()?; let host = parsed.host_str()?.to_ascii_lowercase(); let mut path = parsed.path().trim_matches('/').to_owned(); if let Some(stripped) = path.strip_suffix(".git") { path = stripped.to_owned(); } Some((host, parsed.port(), path)) } /// Whether two repository URLs point at the same repository, ignoring the /// transport scheme (see [`url_identity`]). fn same_repo_url(a: &str, b: &str) -> bool { match (url_identity(a), url_identity(b)) { (Some(a), Some(b)) => a == b, _ => a == b, } } /// Resolve the associations between local checkouts and announced /// repositories: remembered records (freshest first per repository), /// followed by scanned repositories matched by origin URL or EUC. /// Deduplicated by path, keeping the first (remembered) occurrence. fn resolve_associations<'a>( remembered: &[Remembered], scanned: &[(PathBuf, Option, Option)], announcements: impl IntoIterator, ) -> HashMap> { let announcements: Vec<&Announcement> = announcements.into_iter().collect(); let mut out: HashMap> = HashMap::new(); let mut sorted: Vec<&Remembered> = remembered.iter().collect(); sorted.sort_by_key(|record| std::cmp::Reverse(record.last_used)); for record in sorted { let paths = out.entry(record.addr.clone()).or_default(); if !paths.contains(&record.path) { paths.push(record.path.clone()); } } for (path, origin, root) in scanned { for announcement in &announcements { let url_match = origin.as_deref().is_some_and(|origin| { announcement .clone .iter() .any(|url| same_repo_url(origin, url.as_str())) }); let euc_match = root .as_deref() .is_some_and(|root| announcement.euc.as_deref() == Some(root)); if url_match || euc_match { let paths = out.entry(announcement.addr()).or_default(); if !paths.contains(path) { paths.push(path.clone()); } } } } out } /// Whether the worktree of `path` has uncommitted changes (a dirty /// checkout is never suggested: the proposal should cover committed work). fn worktree_dirty(path: &Path) -> bool { let output = Command::new("git") .arg("-C") .arg(path) .args(["status", "--porcelain"]) .env("GIT_TERMINAL_PROMPT", "0") .output(); match output { Ok(output) => !String::from_utf8_lossy(&output.stdout).trim().is_empty(), Err(_) => false, } } /// Commits in `base..branch` of the checkout at `path` (`git rev-list /// --count`); `0` when the range is empty or cannot be computed. fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 { let output = Command::new("git") .arg("-C") .arg(path) .args(["rev-list", "--count", &format!("{base}..{branch}")]) .env("GIT_TERMINAL_PROMPT", "0") .output(); match output { Ok(output) => String::from_utf8_lossy(&output.stdout) .trim() .parse() .unwrap_or(0), Err(_) => 0, } } /// The branch checked out at `path` (`git branch --show-current`), `None` /// when detached. fn current_branch_of(path: &Path) -> Option { let output = Command::new("git") .arg("-C") .arg(path) .args(["branch", "--show-current"]) .env("GIT_TERMINAL_PROMPT", "0") .output() .ok()?; let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned(); (!branch.is_empty()).then_some(branch) } /// The ready-to-contribute status of one checkout, or `None` when it is /// idle: detached HEAD, no branches, a dirty worktree, or nothing ahead of /// its base. The base defaults like the New PR panel: the announced HEAD /// branch when the checkout has it, else `main`, else the first branch. fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option { let branches = signed_git::worktree_branches(path).ok()?; if branches.is_empty() || worktree_dirty(path) { return None; } let branch = current_branch_of(path)?; let head = signed_git::head_commit_id(path).ok().flatten()?; let base = announced_head .filter(|name| branches.iter().any(|b| b == name)) .map(str::to_owned) .or_else(|| branches.iter().find(|b| *b == "main").cloned()) .or_else(|| branches.first().cloned())?; if base == branch { 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 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 { 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 /// `c` tip tag matching the checkout's HEAD commit. pub fn pr_proposes_checkout( pr: &Event, open: bool, user: PublicKey, checkout: &CheckoutStatus, ) -> bool { if pr.kind != Kind::GitPullRequest || !open || pr.pubkey != user { return false; } let branch_matches = pr .tags .iter() .find(|t| t.kind() == "branch-name") .and_then(|t| t.content()) .is_some_and(|name| name == checkout.branch); // A renamed branch falls back to the proposed tip commit. let tip_matches = pr .tags .iter() .find(|t| t.kind() == "c") .and_then(|t| t.content()) .is_some_and(|tip| tip == checkout.head); branch_matches || tip_matches } #[cfg(test)] mod tests { use signed_core::{RepoAddr, repo_addr}; use super::*; #[test] fn same_repo_url_ignores_the_transport_scheme() { // grasp announce vs https origin, with and without `.git`. assert!(same_repo_url( "grasp://relay.ngit.dev/npub1test/repo", "https://relay.ngit.dev/npub1test/repo.git" )); assert!(same_repo_url( "ws://localhost:8080/npub1test/repo", "http://localhost:8080/npub1test/repo" )); // The port and the path matter. assert!(!same_repo_url( "wss://localhost:8081/npub1test/repo", "wss://localhost:8080/npub1test/repo" )); assert!(!same_repo_url( "wss://host/npub1test/repo", "wss://host/npub1other/repo" )); // Unparseable URLs compare literally. assert!(same_repo_url("/local/path", "/local/path")); assert!(!same_repo_url("/local/path", "/local/other")); } fn remembered(path: &str, id: &str, last_used: u64) -> Remembered { Remembered { path: PathBuf::from(path), addr: addr(id), last_used, } } fn scanned( path: &str, origin: Option<&str>, root: Option<&str>, ) -> (PathBuf, Option, Option) { ( PathBuf::from(path), origin.map(str::to_owned), root.map(str::to_owned), ) } const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; fn owner() -> PublicKey { Keys::new(SecretKey::from_hex(KEY).expect("secret")).public_key() } fn addr(id: &str) -> RepoAddr { repo_addr(owner(), id) } /// Build one announcement by the fixed test owner with `clone` URLs and /// an EUC. fn announcement(id: &str, clones: &[&str], euc: Option<&str>) -> Announcement { let keys = Keys::new(SecretKey::from_hex(KEY).expect("secret")); let mut tags = vec![Tag::parse(vec!["d", id]).expect("tag")]; for url in clones { tags.push(Tag::parse(vec!["clone", *url]).expect("tag")); } if let Some(euc) = euc { tags.push(Tag::parse(vec!["r", euc, "euc"]).expect("tag")); } let event = EventBuilder::new(Kind::GitRepoAnnouncement, "") .tags(tags) .finalize(&keys) .expect("signed"); Announcement::from_event(&event).expect("parsed") } #[test] fn resolve_orders_remembered_freshest_first() { let announcements = vec![announcement("repo", &[], None)]; let base = addr("repo"); let resolved = resolve_associations( &[ remembered("/old", "repo", 100), remembered("/fresh", "repo", 200), remembered("/other", "unrelated", 300), ], &[], &announcements, ); let paths = resolved.get(&base).expect("associations"); assert_eq!(paths, &vec![PathBuf::from("/fresh"), PathBuf::from("/old")]); // Records for repositories without announcements stay inert. assert_eq!(resolved.len(), 2); } #[test] fn resolve_matches_scanned_repos_by_origin_and_euc() { let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; let announcements = vec![ announcement("repo", &["grasp://host/npub1x/repo"], None), announcement("family", &[], Some(euc)), ]; let repo = addr("repo"); let family = addr("family"); let resolved = resolve_associations( &[], &[ // Origin matches modulo scheme and the `.git` suffix. scanned("/clone", Some("https://host/npub1x/repo.git"), None), // Root commit matches the family EUC. scanned("/family-checkout", None, Some(euc)), // Neither matches anything. scanned("/unrelated", Some("https://elsewhere/x.git"), None), ], &announcements, ); assert_eq!( resolved.get(&repo).expect("repo matches"), &vec![PathBuf::from("/clone")] ); assert_eq!( resolved.get(&family).expect("family matches"), &vec![PathBuf::from("/family-checkout")] ); assert_eq!(resolved.len(), 2); } #[test] fn resolve_deduplicates_paths_remembering_first() { let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; let announcements = vec![announcement( "repo", &["https://host/npub1x/repo.git"], Some(euc), )]; let base = addr("repo"); // The same path is both remembered and scanned (its origin matches); // the remembered occurrence wins and it is listed once. let resolved = resolve_associations( &[remembered("/shared", "repo", 100)], &[ scanned("/shared", Some("https://host/npub1x/repo"), None), scanned("/scanned-only", Some("https://host/npub1x/repo.git"), None), ], &announcements, ); let paths = resolved.get(&base).expect("associations"); assert_eq!( paths, &vec![PathBuf::from("/shared"), PathBuf::from("/scanned-only")] ); } #[test] fn checkout_status_reports_ahead_branches_only() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("repo"); let _initial = signed_git::init_repository(&path, "My Repo", "").expect("init"); let run = |args: &[&str]| { let status = Command::new("git") .current_dir(&path) .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"); }; let commit = |message: &str| { run(&["add", "-A"]); run(&["commit", "-m", message]); }; // A feature branch ahead of main: ready to contribute. run(&["checkout", "-b", "feature"]); std::fs::write(path.join("feature.txt"), "x\n").expect("write"); commit("feature work"); let status = checkout_status(&path, Some("main")).expect("status"); assert_eq!(status.branch, "feature"); assert_eq!(status.base, "main"); assert_eq!(status.ahead, 1); assert_eq!(status.head.len(), 40); // Dirty worktrees are never suggested. std::fs::write(path.join("uncommitted.txt"), "y\n").expect("write"); assert!(checkout_status(&path, Some("main")).is_none()); run(&["checkout", "--", "."]); // Even with main: nothing to propose. run(&["checkout", "main"]); 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 .iter() .map(|t| Tag::parse(t.to_vec()).expect("valid tag")) .collect(); EventBuilder::new(Kind::GitPullRequest, "") .tags(tags) .finalize(&keys) .expect("signed event") } fn status(branch: &str, head: &str) -> CheckoutStatus { CheckoutStatus { path: PathBuf::from("/checkout"), branch: branch.to_owned(), head: head.to_owned(), base: "main".to_owned(), ahead: 1, } } #[test] fn pr_proposes_checkout_matches_branch_or_tip() { let author = "0000000000000000000000000000000000000000000000000000000000000002"; let tip = "aa231c4c6a5777dc89b42207b499891a344add5c"; // A matching `branch-name` covers the proposal. let pr = pr_event(author, &[&["branch-name", "feature"], &["c", tip]]); let status = status("feature", "bb231c4c6a5777dc89b42207b499891a344add5c"); assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status)); // Without the branch name (renamed), the `c` tip still matches. let pr = pr_event( author, &[&["c", "bb231c4c6a5777dc89b42207b499891a344add5c"]], ); assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status)); // Someone else's PR, a closed PR, a different branch and a missing // tip all leave the checkout uncovered. let pr = pr_event(author, &[&["branch-name", "feature"]]); assert!(!pr_proposes_checkout(&pr, false, pr.pubkey, &status)); let other = pr_event( "0000000000000000000000000000000000000000000000000000000000000003", &[&["branch-name", "feature"]], ); assert!(!pr_proposes_checkout(&pr, true, other.pubkey, &status)); let other_branch = pr_event(author, &[&["branch-name", "other"]]); assert!(!pr_proposes_checkout( &other_branch, true, other_branch.pubkey, &status )); } }