Files
signed/crates/signed_state/src/checkouts.rs
T
2026-09-04 20:13:10 +07:00

948 lines
34 KiB
Rust

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<CheckoutsStore>);
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/<branch>`, 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<RepoAddr, Vec<PathBuf>>,
/// Ready-to-contribute statuses of the requested repositories.
statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
/// Repositories whose statuses are recomputed on every input change.
///
/// Those are the repository detail panels currently open.
status_requested: HashSet<RepoAddr>,
/// Repositories whose `ready to push` statuses are recomputed on the same cycle.
///
/// The sidebar rows of the user's own repositories and their detail panels.
push_requested: HashSet<RepoAddr>,
/// Ready-to-push statuses of the requested own repositories.
push_statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
/// Last announced head branch per requested repository.
///
/// A recompute defaults the base the same way.
requested_head: HashMap<RepoAddr, Option<String>>,
/// Refresh coalescing, see [`RefreshGate`].
refresh: RefreshGate,
_subscriptions: Vec<Subscription>,
tasks: Vec<Task<Result<(), Error>>>,
}
impl CheckoutsStore {
/// Retrieve the global checkouts store.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalCheckoutsStore>().0.clone()
}
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalCheckoutsStore(entity));
}
/// Create the store.
pub fn new(cx: &mut Context<Self>) -> 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<Result<(), Error>>) {
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<Self>) {
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<PathBuf> {
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<String>,
cx: &mut Context<Self>,
) {
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<CheckoutStatus> {
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>) {
self.push_requested.insert(addr.clone());
self.refresh(cx);
}
/// The ready-to-push statuses of `addr`.
/// Only meaningful for repositories announced by the signed-in user.
///
/// Empty while none are known or nothing is unpushed.
pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
self.push_statuses.get(addr).cloned().unwrap_or_default()
}
/// Re-resolve 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<Self>) {
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>) {
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<Remembered> = records
.into_iter()
.filter_map(|record| {
let addr = record.addr.parse::<RepoAddr>().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<String>)> = self
.status_requested
.iter()
.map(|addr| {
(
addr.clone(),
self.requested_head.get(addr).cloned().flatten(),
)
})
.collect();
let push_requested: Vec<RepoAddr> = self.push_requested.iter().cloned().collect();
let poll = !self.status_requested.is_empty() || !self.push_requested.is_empty();
let work = cx.background_spawn(async move {
// Read the git facts of every scanned repository off the main thread.
//
// The facts are the origin URL and the root commit, both CLI reads.
let mut facts: Vec<(PathBuf, Option<String>, Option<String>)> = 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<RepoAddr, Vec<PathBuf>> = associations
.into_iter()
.map(|(addr, paths)| (addr, paths.into_iter().filter(|p| p.is_dir()).collect()))
.collect();
let mut statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
for (addr, announced_head) in &requested {
let Some(paths) = associations.get(addr) else {
continue;
};
let list: Vec<CheckoutStatus> = 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<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
for addr in &push_requested {
let Some(paths) = associations.get(addr) else {
continue;
};
let list: Vec<CheckoutStatus> = paths
.iter()
.take(MAX_STATUS_CHECKOUTS)
.filter_map(|path| checkout_push_status(path))
.collect();
if !list.is_empty() {
push_statuses.insert(addr.clone(), list);
}
}
Ok::<_, Error>((associations, statuses, push_statuses))
});
self.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<u16>, 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<String>, Option<String>)],
announcements: impl IntoIterator<Item = &'a Announcement>,
) -> HashMap<RepoAddr, Vec<PathBuf>> {
let announcements: Vec<&Announcement> = announcements.into_iter().collect();
let mut out: HashMap<RepoAddr, Vec<PathBuf>> = 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<String> {
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<CheckoutStatus> {
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<CheckoutStatus> {
if worktree_dirty(path) {
return None;
}
let branch = current_branch_of(path)?;
let head = signed_git::head_commit_id(path).ok().flatten()?;
let origin = signed_git::origin_url(path).ok().flatten()?;
// Refresh the remote heads 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<String>, Option<String>) {
(
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<Tag> = 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
));
}
}