use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::process::Command; 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::refresh::{RefreshGate, RefreshRequest}; use crate::repo_list::RepoListStore; /// Delay between a refresh request and the actual re-computation. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); /// How often the statuses of open repository panels are refreshed. const STATUS_POLL: Duration = Duration::from_secs(15); /// Background poll interval for the `ready to push` badges of the user's own repositories. 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. /// /// Carries 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. A detached checkout is idle and yields no status. 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. /// The fallbacks are `main`, then the first local branch. /// For ready-to-push statuses, the remote-tracking ref. /// Unpushed commits are counted against it. /// /// It is `refs/remotes/origin/`, else `origin/HEAD` for new branches. pub base: String, /// Commits in `base..branch`. /// /// Zero-ahead checkouts are dropped, so this is always above zero. 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. by_repo: HashMap>, /// Ready-to-contribute statuses of the requested repositories. statuses: HashMap>, /// Repositories whose statuses are recomputed on every input change. /// /// Those are 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 and their detail panels. push_requested: HashSet, /// Ready-to-push statuses of the requested own repositories. push_statuses: HashMap>, /// Last announced head branch per requested repository. /// /// A recompute defaults the base the same way. requested_head: HashMap>, /// Refresh coalescing, see [`RefreshGate`]. refresh: RefreshGate, _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. 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 old statuses 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 = HashMap::new(); this.push_statuses = HashMap::new(); this.refresh(cx); } })); } let mut store = Self { by_repo: HashMap::new(), statuses: HashMap::new(), status_requested: HashSet::new(), push_requested: HashSet::new(), push_statuses: HashMap::new(), requested_head: HashMap::new(), refresh: RefreshGate::default(), _subscriptions: subscriptions, tasks: Vec::new(), }; if !cfg!(target_arch = "wasm32") { store.refresh(cx); } store } /// Track a spawned task, pruning finished tasks first. /// /// Keeps the store's task list bounded by the number of in-flight tasks. fn push_task(&mut self, task: Task>) { self.tasks.retain(|task| !task.is_ready()); self.tasks.push(task); } /// Remember a successful local-checkout 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 stay current. /// Called while the repository's detail panel is open. /// /// `announced_head` is the announced HEAD branch, 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 stay current. 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 the associations and the requested statuses. /// /// Requests arriving while a pass runs fold into a follow-up. pub fn refresh(&mut self, cx: &mut Context) { if self.refresh.request() != RefreshRequest::Schedule { return; } let task = cx.spawn(async move |this, cx| { cx.background_executor().timer(REFRESH_DEBOUNCE).await; this.update(cx, |this, cx| this.run_refresh(cx)) }); self.push_task(task); } /// One resolve and apply cycle, the debounced entry point. fn run_refresh(&mut self, cx: &mut Context) { self.refresh.begin(); // 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. // // The facts are the origin URL and the 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.push_task(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.refresh.abort(); }); } }; let again = this.update(cx, |this, cx| { this.by_repo = associations; this.statuses = statuses; this.push_statuses = push_statuses; cx.notify(); this.refresh.finish() })?; if again { this.update(cx, |this, cx| this.refresh(cx))?; } // Keep the statuses current while any repository panel is open. this.update(cx, |this, cx| { if poll && this.refresh.idle() { this.refresh.debounce(); // Open panels get the fast cadence. // 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.run_refresh(cx)) }); this.push_task(task); } })?; Ok(()) })); } } /// Identity of a repository URL. 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. 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. 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. 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`. 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`, read via `git branch --show-current`. 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. 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` exists in the checkout at `path`. /// /// Example, `refs/remotes/origin/main`. 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. 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 first. // Commits made elsewhere or pushed from another machine must not linger as `to push`. signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok(); let remote = format!("refs/remotes/origin/{branch}"); // A branch never fetched or pushed yet compares against the remote HEAD. // The remote HEAD is the 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` already proposes the same change as `checkout`. 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. /// Takes `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 the path 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 on 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` is a plain repository the checkout clones from. // Its origin URL is a local path, so the whole cycle runs offline. // Git refuses pushes to a 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 a branch-name tag, the `c` tip still matches for a renamed branch. 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. // They 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 )); } }