Skip to main content

magi/
git.rs

1//! Git plumbing.
2//!
3//! magi drives the `git` CLI rather than linking a library: every operation it
4//! needs is a one-liner, and shelling out keeps the behaviour identical to what
5//! the operator sees when they inspect a run by hand.
6use std::path::{Path, PathBuf};
7use std::process::Stdio;
8
9use crate::proc::Quiet as _;
10use anyhow::{Context as _, Result, bail};
11use tokio::process::Command;
12
13/// Output of a completed `git` invocation.
14#[derive(Debug)]
15pub struct GitOut {
16    /// Exit status code, if the process was not killed by a signal.
17    pub code: Option<i32>,
18    /// Captured stdout, trailing newline trimmed.
19    pub stdout: String,
20    /// Captured stderr, trailing newline trimmed.
21    pub stderr: String,
22}
23
24impl GitOut {
25    /// Did the command succeed?
26    pub fn ok(&self) -> bool {
27        self.code == Some(0)
28    }
29}
30
31/// Run `git` in `cwd` with `args`, returning the captured output regardless of
32/// exit status.
33pub async fn git_raw(cwd: &Path, args: &[&str]) -> Result<GitOut> {
34    #[cfg(test)]
35    crate::web::request_cost::git();
36    let out = Command::new("git")
37        .args(args)
38        .current_dir(cwd)
39        .quiet()
40        // A hook that opens an editor or a credential prompt would hang a
41        // headless run forever.
42        .env("GIT_TERMINAL_PROMPT", "0")
43        .env("GIT_EDITOR", "true")
44        .stdin(Stdio::null())
45        .output()
46        .await
47        .with_context(|| format!("spawn git {}", args.join(" ")))?;
48    Ok(GitOut {
49        code: out.status.code(),
50        stdout: String::from_utf8_lossy(&out.stdout).trim_end().to_owned(),
51        stderr: String::from_utf8_lossy(&out.stderr).trim_end().to_owned(),
52    })
53}
54
55/// Fetch `origin`'s branches into `refs/remotes/origin/*` in `cwd`, bounded by
56/// `timeout`.
57///
58/// The destination is fixed on the command line and the remote is addressed by
59/// its URL rather than its name. A plain `git fetch origin` follows the
60/// configured `remote.origin.fetch`, which can map onto local branches
61/// (`+refs/heads/main:refs/heads/main`) or a single branch, and even a fetch
62/// with an explicit refspec updates remote-tracking refs from that config
63/// when the remote is named. By URL, nothing but the refspec given here is
64/// written: no local branch, HEAD, index or working tree. A child still
65/// running at the deadline is killed on drop.
66pub async fn fetch_origin(cwd: &Path, timeout: std::time::Duration) -> Result<()> {
67    let url = git(cwd, &["remote", "get-url", "origin"]).await?;
68    #[cfg(test)]
69    crate::web::request_cost::git();
70    let fut = Command::new("git")
71        .args([
72            "fetch",
73            "--quiet",
74            "--no-tags",
75            "--no-recurse-submodules",
76            "--no-write-fetch-head",
77            "--",
78            url.as_str(),
79            "+refs/heads/*:refs/remotes/origin/*",
80        ])
81        .current_dir(cwd)
82        .quiet()
83        .env("GIT_TERMINAL_PROMPT", "0")
84        .stdin(Stdio::null())
85        .kill_on_drop(true)
86        .output();
87    let out = tokio::time::timeout(timeout, fut)
88        .await
89        .map_err(|_| anyhow::anyhow!("git fetch timed out after {}s", timeout.as_secs()))?
90        .context("spawn git fetch")?;
91    if !out.status.success() {
92        bail!(
93            "git fetch failed ({}): {}",
94            crate::notices::exit_status(out.status.code()),
95            String::from_utf8_lossy(&out.stderr).trim_end()
96        );
97    }
98    Ok(())
99}
100
101/// Run `git`, failing on a non-zero exit status.
102pub async fn git(cwd: &Path, args: &[&str]) -> Result<String> {
103    let out = git_raw(cwd, args).await?;
104    if !out.ok() {
105        bail!(
106            "git {} failed in {} ({}): {}",
107            args.join(" "),
108            cwd.display(),
109            crate::notices::exit_status(out.code),
110            if out.stderr.is_empty() {
111                out.stdout.as_str()
112            } else {
113                out.stderr.as_str()
114            }
115        );
116    }
117    Ok(out.stdout)
118}
119
120/// Absolute path to the top level of the working tree containing `path`.
121pub async fn toplevel(path: &Path) -> Result<PathBuf> {
122    let out = git(path, &["rev-parse", "--show-toplevel"]).await?;
123    Ok(PathBuf::from(out))
124}
125
126/// Resolve a revision to a full object id.
127pub async fn rev_parse(repo: &Path, rev: &str) -> Result<String> {
128    git(repo, &["rev-parse", rev]).await
129}
130
131/// Currently checked-out branch, or `None` when detached.
132pub async fn current_branch(repo: &Path) -> Result<Option<String>> {
133    let out = git_raw(repo, &["symbolic-ref", "--quiet", "--short", "HEAD"]).await?;
134    Ok(if out.ok() && !out.stdout.is_empty() {
135        Some(out.stdout)
136    } else {
137        None
138    })
139}
140
141/// The branch a `refs/remotes/<remote>/HEAD` symref names, e.g.
142/// `refs/remotes/origin/main` -> `main`. Pure, and shared with
143/// `Config::discover` so the branch magi reads its config from and the branch
144/// a run starts from can never come from two different readings.
145pub fn remote_head_branch(remote: &str, symref: &str) -> Option<String> {
146    symref
147        .trim()
148        .strip_prefix(&format!("refs/remotes/{remote}/"))
149        .filter(|b| !b.is_empty() && *b != "HEAD")
150        .map(str::to_owned)
151}
152
153/// Record `refs/remotes/<remote>/HEAD` -> `<remote>/<base>` when it is absent,
154/// so a later read of the remote's default (`Config::discover`) agrees with
155/// the branch just resolved even when git did not create the symref on fetch
156/// (`followRemoteHEAD=never`, old git). Moves only `refs/remotes`; an existing
157/// symref is left alone. Best effort.
158pub async fn ensure_remote_head(repo: &Path, remote: &str, base: &str) {
159    let head = format!("refs/remotes/{remote}/HEAD");
160    if git_raw(repo, &["symbolic-ref", "--quiet", &head])
161        .await
162        .is_ok_and(|o| o.ok())
163    {
164        return;
165    }
166    let target = format!("refs/remotes/{remote}/{base}");
167    let _ = git_raw(repo, &["symbolic-ref", &head, &target]).await;
168}
169
170/// The branch in `git ls-remote --symref <remote> HEAD` output
171/// (`ref: refs/heads/main\tHEAD`).
172fn symref_branch(out: &str) -> Option<String> {
173    out.lines()
174        .find_map(|l| l.strip_prefix("ref: refs/heads/"))
175        .and_then(|l| l.split_whitespace().next())
176        .map(str::to_owned)
177}
178
179/// Which branch of `remote` is the base: `explicit` (`[merge] base`) wins,
180/// else the remote's default branch as recorded in `refs/remotes/<remote>/HEAD`.
181/// When that ref is missing (a checkout that was never cloned from it), ask the
182/// remote once with `git remote set-head <remote> -a`, which moves only
183/// `refs/remotes`. Never the checked-out branch: a detached or stale primary
184/// checkout must not decide what a run branches off.
185pub async fn merge_base_branch(
186    repo: &Path,
187    remote: &str,
188    explicit: Option<&str>,
189) -> Result<String> {
190    if let Some(b) = explicit.map(str::trim).filter(|b| !b.is_empty()) {
191        return Ok(b.to_owned());
192    }
193    let head = format!("refs/remotes/{remote}/HEAD");
194    let read = || async {
195        let out = git_raw(repo, &["symbolic-ref", "--quiet", &head])
196            .await
197            .ok()?;
198        if out.ok() {
199            remote_head_branch(remote, &out.stdout)
200        } else {
201            None
202        }
203    };
204    if let Some(b) = read().await {
205        return Ok(b);
206    }
207    let set = git_raw(repo, &["remote", "set-head", remote, "-a"]).await;
208    if let Some(b) = read().await {
209        return Ok(b);
210    }
211    // `set-head -a` insists the tracking ref already exists, which it does not
212    // for a remote that was never fetched (or a single-branch clone of another
213    // branch). Ask the remote for its HEAD symref instead: that only names the
214    // branch, and the fetch that follows (`resolve_base`) creates the ref.
215    if let Ok(o) = git_raw(repo, &["ls-remote", "--symref", remote, "HEAD"]).await
216        && o.ok()
217        && let Some(b) = symref_branch(&o.stdout)
218    {
219        return Ok(b);
220    }
221    let why = match set {
222        Ok(o) if !o.ok() => o.stderr.lines().next().unwrap_or("").trim().to_owned(),
223        Ok(_) => "the remote reported no default branch".to_owned(),
224        Err(e) => e.to_string(),
225    };
226    bail!(
227        "cannot tell which branch of `{remote}` is the base ({why}): set [merge] base \
228         (and remote) in magi.toml, or run `git remote set-head {remote} -a`. magi does \
229         not fall back to the checked-out branch, which may be detached or stale"
230    )
231}
232
233/// Is the working tree free of tracked modifications and untracked files?
234pub async fn is_clean(repo: &Path) -> Result<bool> {
235    Ok(git(repo, &["status", "--porcelain"]).await?.is_empty())
236}
237
238/// `git status --porcelain`, for reporting what is dirty.
239pub async fn status_porcelain(repo: &Path) -> Result<String> {
240    git(repo, &["status", "--porcelain"]).await
241}
242
243/// Create a worktree at `path` with a fresh branch `branch` starting at `base`.
244pub async fn worktree_add_branch(repo: &Path, path: &Path, branch: &str, base: &str) -> Result<()> {
245    if let Some(parent) = path.parent() {
246        tokio::fs::create_dir_all(parent).await.ok();
247    }
248    let path_s = path.to_string_lossy().to_string();
249    git(repo, &["worktree", "add", "-b", branch, &path_s, base])
250        .await
251        .map(|_| ())
252}
253
254/// Create a worktree at `path` with a detached HEAD at `rev`.
255pub async fn worktree_add_detached(repo: &Path, path: &Path, rev: &str) -> Result<()> {
256    if let Some(parent) = path.parent() {
257        tokio::fs::create_dir_all(parent).await.ok();
258    }
259    let path_s = path.to_string_lossy().to_string();
260    git(repo, &["worktree", "add", "--detach", &path_s, rev])
261        .await
262        .map(|_| ())
263}
264
265/// Move an existing detached worktree to `rev`, discarding local state.
266pub async fn reset_detached(worktree: &Path, rev: &str) -> Result<()> {
267    git(worktree, &["checkout", "--detach", rev]).await?;
268    git(worktree, &["reset", "--hard", rev]).await?;
269    git(worktree, &["clean", "-fdx"]).await?;
270    Ok(())
271}
272
273/// Where `branch` is checked out, if some worktree holds it.
274///
275/// Read from `git worktree list --porcelain` rather than out of an error
276/// message, which varies with git's version and locale.
277pub async fn worktree_holding(repo: &Path, branch: &str) -> Result<Option<PathBuf>> {
278    let listing = git(repo, &["worktree", "list", "--porcelain"]).await?;
279    Ok(parse_worktree_holder(&listing, branch))
280}
281
282fn parse_worktree_holder(listing: &str, branch: &str) -> Option<PathBuf> {
283    let want = format!("refs/heads/{branch}");
284    let mut path: Option<PathBuf> = None;
285    for line in listing.lines() {
286        if let Some(p) = line.strip_prefix("worktree ") {
287            path = Some(PathBuf::from(p));
288        } else if line.strip_prefix("branch ") == Some(want.as_str()) {
289            return path;
290        }
291    }
292    None
293}
294
295/// Force removal of a disposable worktree, including build output or conflicts.
296/// Candidate work must use [`worktree_remove_clean`] instead.
297/// Returns `Ok(false)` on refusal; callers must retain their ownership record.
298pub async fn worktree_remove(repo: &Path, path: &Path) -> Result<bool> {
299    let path_s = path.to_string_lossy().to_string();
300    let out = git_raw(repo, &["worktree", "remove", "--force", &path_s]).await?;
301    if out.ok() {
302        return Ok(true);
303    }
304    // Do not prune metadata for an existing tree after any refusal.
305    if !path.exists() {
306        git_raw(repo, &["worktree", "prune"]).await?;
307    }
308    Ok(false)
309}
310
311/// Remove a worktree only if git finds it clean: no `--force`, so a change
312/// made after the caller last looked is refused rather than thrown away.
313/// Ignored files (`target/`) do not count as changes and go with it.
314pub async fn worktree_remove_clean(repo: &Path, path: &Path) -> Result<bool> {
315    let path_s = path.to_string_lossy().to_string();
316    Ok(git_raw(repo, &["worktree", "remove", &path_s]).await?.ok())
317}
318
319/// Unregister a linked worktree whose directory is about to be deleted by
320/// hand, so the path can be `worktree add`-ed again.
321///
322/// A linked worktree's `.git` is a file whose `gitdir:` line names the
323/// bookkeeping entry inside its repository's admin directory; pruning from
324/// there removes the registration without touching the directory. No-op when
325/// `dir` is not a registered worktree (`.git` missing or not a `gitdir:`
326/// link): nothing was registered, nothing survives removal.
327pub async fn remove_worktree_from_linked(dir: &Path) {
328    let Ok(link) = std::fs::read_to_string(dir.join(".git")) else {
329        return;
330    };
331    let Some(admin) = link.strip_prefix("gitdir:").map(str::trim) else {
332        return;
333    };
334    // `<repo>/.git/worktrees/<name>`, so the repository's git dir is two
335    // levels up from here.
336    let admin = Path::new(admin);
337    let Some(common) = admin.parent().and_then(Path::parent) else {
338        return;
339    };
340    let common_s = common.to_string_lossy();
341    let _ = git_raw(dir, &["--git-dir", &common_s, "worktree", "prune"]).await;
342}
343
344/// Drop registrations for worktrees whose directory is already gone.
345///
346/// `git worktree remove` already does this for the path it just removed, but
347/// a directory deleted by hand - [`crate::clean::fold_orphaned_worktrees`], or
348/// an operator's own `rm -rf` - leaves the registration behind, and a
349/// registered path refuses a fresh `worktree add` until something prunes it.
350/// The operator's own machine had 31 such registrations sitting in one
351/// repository, all of them for directories that no longer existed.
352pub async fn worktree_prune(repo: &Path) -> Result<()> {
353    git(repo, &["worktree", "prune"]).await.map(|_| ())
354}
355
356/// Delete a branch, ignoring "not found".
357pub async fn branch_delete(repo: &Path, branch: &str) -> Result<bool> {
358    Ok(git_raw(repo, &["branch", "-D", branch]).await?.ok())
359}
360
361/// Does `branch` exist?
362pub async fn branch_exists(repo: &Path, branch: &str) -> Result<bool> {
363    let refname = format!("refs/heads/{branch}");
364    Ok(
365        git_raw(repo, &["show-ref", "--verify", "--quiet", &refname])
366            .await?
367            .ok(),
368    )
369}
370
371/// Does `remote` have `branch`? `Err` when that cannot be told (unreachable
372/// remote), which callers must not read as "no".
373pub async fn remote_has_branch(repo: &Path, remote: &str, branch: &str) -> Result<bool> {
374    let refname = format!("refs/heads/{branch}");
375    let out = git_raw(repo, &["ls-remote", "--exit-code", remote, &refname]).await?;
376    match out.code {
377        Some(0) => Ok(true),
378        Some(2) => Ok(false),
379        code => bail!(
380            "git ls-remote {remote} failed ({}): {}",
381            crate::notices::exit_status(code),
382            out.stderr
383        ),
384    }
385}
386
387/// Patch of `head` against the merge base with `base`.
388pub async fn diff(worktree: &Path, base: &str, head: &str) -> Result<String> {
389    let range = format!("{base}...{head}");
390    git(
391        worktree,
392        &["diff", "--no-color", "--no-ext-diff", "-M", &range],
393    )
394    .await
395}
396
397/// `--stat` summary of `base...head`.
398pub async fn diff_stat(worktree: &Path, base: &str, head: &str) -> Result<String> {
399    let range = format!("{base}...{head}");
400    git(worktree, &["diff", "--no-color", "--stat", &range]).await
401}
402
403/// Total changed lines (added + removed) between two revisions, from
404/// `--numstat`. `None` when git could not say, or a binary file changed
405/// (numstat prints `-` for it). This measurement does not determine acceptance.
406pub async fn changed_lines(worktree: &Path, base: &str, head: &str) -> Option<usize> {
407    let out = git_raw(worktree, &["diff", "--numstat", base, head])
408        .await
409        .ok()?;
410    if !out.ok() {
411        return None;
412    }
413    let mut total = 0usize;
414    for line in out.stdout.lines().filter(|l| !l.trim().is_empty()) {
415        let mut cols = line.split('\t');
416        let added: usize = cols.next()?.parse().ok()?;
417        let removed: usize = cols.next()?.parse().ok()?;
418        total = total.saturating_add(added).saturating_add(removed);
419    }
420    Some(total)
421}
422
423/// Put the worktree back on `rev`: tracked files reset, untracked files
424/// removed. Ignored paths (a `[worktree] setup`'s products are hidden through
425/// the per-worktree excludes) are left alone.
426pub async fn restore_to(worktree: &Path, rev: &str) -> Result<()> {
427    git(worktree, &["reset", "--hard", "-q", rev]).await?;
428    // `-ff`: a single `-f` skips untracked nested git repositories, which is
429    // what a submodule or clone added by a rejected change leaves behind.
430    git(worktree, &["clean", "-ffdq"]).await?;
431    // The parent's reset leaves an initialised submodule's own tree alone
432    // (`submodule.recurse` defaults to false): put each back on the commit the
433    // parent records (`--checkout` overrides a configured
434    // `submodule.<name>.update` such as `none`), and drop what is untracked inside. No-ops without
435    // submodules; an error is an unconfirmed restore.
436    git(
437        worktree,
438        &[
439            "submodule",
440            "--quiet",
441            "update",
442            "--recursive",
443            "--force",
444            "--checkout",
445        ],
446    )
447    .await?;
448    git(
449        worktree,
450        &[
451            "submodule",
452            "--quiet",
453            "foreach",
454            "--recursive",
455            "git clean -ffdq",
456        ],
457    )
458    .await?;
459    Ok(())
460}
461
462/// Number of files touched by `base...head`.
463pub async fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
464    let range = format!("{base}...{head}");
465    let out = git(worktree, &["diff", "--name-only", &range]).await?;
466    Ok(out.lines().map(str::to_owned).collect())
467}
468
469/// Subject and body of every commit in `base..head`, oldest first. Fields are
470/// split on control characters git never prints in a message, so an empty
471/// subject or a body full of separators cannot shift a record.
472pub async fn commit_log(worktree: &Path, base: &str, head: &str) -> Result<Vec<(String, String)>> {
473    let range = format!("{base}..{head}");
474    let out = git(
475        worktree,
476        &[
477            "log",
478            "--reverse",
479            "--no-color",
480            "--format=%s%x1f%b%x00",
481            &range,
482        ],
483    )
484    .await?;
485    Ok(out
486        .split('\0')
487        .filter_map(|rec| {
488            let rec = rec.trim_start_matches('\n');
489            if rec.trim().is_empty() {
490                return None;
491            }
492            let (subject, body) = rec.split_once('\x1f').unwrap_or((rec, ""));
493            Some((subject.trim().to_owned(), body.trim().to_owned()))
494        })
495        .collect())
496}
497
498/// One-line log of `base..head`, oldest first.
499pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
500    let range = format!("{base}..{head}");
501    git(
502        worktree,
503        &["log", "--reverse", "--format=%s%n%b%n--", &range],
504    )
505    .await
506}
507
508/// Subjects of the commits in `base..head`, oldest first. NUL-terminated so an
509/// empty subject keeps its place instead of shifting the later ones forward.
510pub async fn subjects(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
511    let range = format!("{base}..{head}");
512    let out = git(worktree, &["log", "--reverse", "--format=%s%x00", &range]).await?;
513    let mut parts: Vec<String> = out.split('\0').map(|s| s.trim().to_owned()).collect();
514    // Whatever follows the last terminator is just the trailing newline.
515    parts.pop();
516    Ok(parts)
517}
518
519/// How many commits `head` is ahead of `base`.
520pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
521    let range = format!("{base}..{head}");
522    let out = git(worktree, &["rev-list", "--count", &range]).await?;
523    Ok(out.trim().parse().unwrap_or(0))
524}
525
526/// Stage everything and commit under a neutral identity.
527///
528/// Used to rescue an agent that edited files but never committed: without this
529/// its candidate would silently be empty. The neutral identity is part of the
530/// blindness contract — a real `user.name` in a candidate's history would name
531/// the operator, and an agent-configured one would name the vendor.
532pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
533    if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
534        return Ok(false);
535    }
536    git(worktree, &["add", "-A"]).await?;
537    // What `[worktree] setup` produced is not the agent's work.
538    if !crate::worktree_setup::withheld_paths(worktree)
539        .await
540        .is_empty()
541    {
542        crate::worktree_setup::unstage_products(worktree).await?;
543        if git_raw(worktree, &["diff", "--cached", "--quiet"])
544            .await?
545            .ok()
546        {
547            return Ok(false);
548        }
549    }
550    let out = git_raw(
551        worktree,
552        &[
553            "-c",
554            "user.name=magi candidate",
555            "-c",
556            "user.email=magi@localhost",
557            "commit",
558            "--no-verify",
559            "-m",
560            message,
561        ],
562    )
563    .await?;
564    if !out.ok() {
565        bail!("rescue commit failed: {}", out.stderr);
566    }
567    Ok(true)
568}
569
570/// A freshly created lockfile that belongs to a package manager the directory
571/// does not use, and was therefore left out of a rescue commit.
572#[derive(Debug, Clone, PartialEq, Eq)]
573pub struct Stray {
574    /// Repo-relative path, forward slashes.
575    pub path: String,
576    /// The package manager the file belongs to (`pnpm`, `cargo`, ...).
577    pub manager: String,
578    /// What made it foreign: the tracked lockfile (or `Cargo.toml`'s absence)
579    /// that says which manager the directory really uses.
580    pub kept_by: String,
581}
582
583/// What [`rescue_commit`] did.
584#[derive(Debug, Default)]
585pub struct Rescue {
586    /// Whether a commit was made.
587    pub committed: bool,
588    /// Files left untracked in the worktree instead of being committed.
589    pub withheld: Vec<Stray>,
590}
591
592/// `(ecosystem, manager)` for a lockfile's file name.
593fn lock_kind(name: &str) -> Option<(&'static str, &'static str)> {
594    Some(match name {
595        "package-lock.json" | "npm-shrinkwrap.json" => ("node", "npm"),
596        "yarn.lock" => ("node", "yarn"),
597        "pnpm-lock.yaml" => ("node", "pnpm"),
598        "bun.lock" | "bun.lockb" => ("node", "bun"),
599        "poetry.lock" => ("python", "poetry"),
600        "uv.lock" => ("python", "uv"),
601        "Pipfile.lock" => ("python", "pipenv"),
602        "pdm.lock" => ("python", "pdm"),
603        "Cargo.lock" => ("rust", "cargo"),
604        _ => return None,
605    })
606}
607
608fn split_dir(path: &str) -> (&str, &str) {
609    path.rsplit_once('/').unwrap_or(("", path))
610}
611
612/// Which of the newly created `untracked` files are lockfiles of a manager the
613/// repo does not use in that directory.
614///
615/// Foreign means: a lockfile of the same ecosystem but another manager is
616/// already tracked *in the same directory* (no recursion — a workspace root and
617/// a sub-package may legitimately differ), or, for `Cargo.lock`, there is no
618/// `Cargo.toml` beside it. A first lockfile in a directory with none is normal.
619pub fn stray_lockfiles(untracked: &[String], tracked: &[String]) -> Vec<Stray> {
620    let mut out = Vec::new();
621    for path in untracked {
622        let (dir, name) = split_dir(path);
623        let Some((eco, manager)) = lock_kind(name) else {
624            continue;
625        };
626        let beside = |other: &String| split_dir(other).0 == dir;
627        let kept_by = if manager == "cargo" {
628            let has_manifest = tracked
629                .iter()
630                .chain(untracked)
631                .any(|p| beside(p) && split_dir(p).1 == "Cargo.toml");
632            if has_manifest {
633                continue;
634            }
635            "no Cargo.toml in the directory".to_owned()
636        } else {
637            let Some(other) = tracked.iter().find(|p| {
638                beside(p)
639                    && lock_kind(split_dir(p).1).is_some_and(|(e, m)| e == eco && m != manager)
640            }) else {
641                continue;
642            };
643            other.clone()
644        };
645        out.push(Stray {
646            path: path.clone(),
647            manager: manager.to_owned(),
648            kept_by,
649        });
650    }
651    out
652}
653
654async fn nul_list(worktree: &Path, args: &[&str]) -> Result<Vec<String>> {
655    let out = git(worktree, args).await?;
656    Ok(out
657        .split('\0')
658        .filter(|s| !s.is_empty())
659        .map(str::to_owned)
660        .collect())
661}
662
663/// Index-independent recovery evidence. Never treats a snapshot as a commit.
664/// Preserve staged and working tree changes separately, plus untracked bytes.
665/// Failure leaves the original tree untouched and is reported by the caller.
666pub async fn snapshot_uncommitted(worktree: &Path, destination: &Path) -> Result<()> {
667    if destination.exists() {
668        if destination.join("complete").exists() {
669            return Ok(());
670        }
671        bail!("earlier recovery snapshot is incomplete; retain original worktree");
672    }
673    std::fs::create_dir_all(destination)?;
674    for (name, args) in [
675        ("status.txt", vec!["status", "--porcelain=v1"]),
676        ("staged.patch", vec!["diff", "--binary", "--cached"]),
677        ("unstaged.patch", vec!["diff", "--binary"]),
678    ] {
679        // GitOut trims stdout for display. Recovery patches need exact bytes,
680        // including trailing whitespace and no-final-newline markers.
681        let out = Command::new("git")
682            .args(&args)
683            .current_dir(worktree)
684            .quiet()
685            .stdin(Stdio::null())
686            .output()
687            .await?;
688        if !out.status.success() {
689            bail!(
690                "recovery snapshot failed: {}",
691                String::from_utf8_lossy(&out.stderr)
692            );
693        }
694        std::fs::write(destination.join(name), out.stdout)?;
695    }
696    let paths = nul_list(
697        worktree,
698        &["ls-files", "-z", "--others", "--exclude-standard"],
699    )
700    .await?;
701    for name in paths {
702        let relative = Path::new(&name);
703        if relative
704            .components()
705            .any(|c| !matches!(c, std::path::Component::Normal(_)))
706        {
707            bail!("unsafe recovery path");
708        }
709        let source = worktree.join(relative);
710        let target = destination.join("untracked").join(relative);
711        std::fs::create_dir_all(target.parent().context("untracked recovery parent")?)?;
712        if std::fs::symlink_metadata(&source)?.file_type().is_symlink() {
713            // Do not follow a link outside the checkout or silently flatten it.
714            bail!("untracked symlink requires manual recovery: {name}");
715        }
716        std::fs::copy(source, target)?;
717    }
718    std::fs::write(destination.join("complete"), "snapshot captured\n")?;
719    Ok(())
720}
721
722/// [`commit_all`] for an agent's leftover work, minus stray foreign lockfiles.
723///
724/// The withheld files stay untracked in the worktree (nothing is deleted) and
725/// are returned so the caller can record them: silently dropping them could
726/// lose a file the task really asked for.
727pub async fn rescue_commit(worktree: &Path, message: &str) -> Result<Rescue> {
728    if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
729        return Ok(Rescue::default());
730    }
731    let untracked = nul_list(
732        worktree,
733        &["ls-files", "-z", "--others", "--exclude-standard"],
734    )
735    .await?;
736    let tracked = nul_list(worktree, &["ls-files", "-z"]).await?;
737    let withheld = stray_lockfiles(&untracked, &tracked);
738
739    git(worktree, &["add", "-A"]).await?;
740    if !withheld.is_empty() {
741        let mut args = vec!["reset", "-q", "--"];
742        args.extend(withheld.iter().map(|s| s.path.as_str()));
743        git(worktree, &args).await?;
744    }
745    crate::worktree_setup::unstage_products(worktree).await?;
746    if git_raw(worktree, &["diff", "--cached", "--quiet"])
747        .await?
748        .ok()
749    {
750        return Ok(Rescue {
751            committed: false,
752            withheld,
753        });
754    }
755    let out = git_raw(
756        worktree,
757        &[
758            "-c",
759            "user.name=magi candidate",
760            "-c",
761            "user.email=magi@localhost",
762            "commit",
763            "--no-verify",
764            "-m",
765            message,
766        ],
767    )
768    .await?;
769    if !out.ok() {
770        bail!("rescue commit failed: {}", out.stderr);
771    }
772    Ok(Rescue {
773        committed: true,
774        withheld,
775    })
776}
777
778/// Enable `extensions.worktreeConfig` if it is not already on.
779///
780/// Returns `true` when magi turned it on, so the caller can turn it back off
781/// during cleanup and leave the repo exactly as it found it.
782pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
783    let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
784    if out.ok() && out.stdout.trim() == "true" {
785        return Ok(false);
786    }
787    git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
788    Ok(true)
789}
790
791/// Undo [`enable_worktree_config`].
792pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
793    git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
794    Ok(())
795}
796
797/// How many runs currently want `extensions.worktreeConfig` on for one
798/// repository, and whether magi is the one that turned it on.
799struct WorktreeConfigRef {
800    /// Runs holding a reference, via [`acquire_worktree_config`].
801    count: usize,
802    /// Did *this process* flip the setting from off to on? If not - it was
803    /// already `true` when the first run in this process asked - nothing
804    /// here ever turns it off either; that is what [`enable_worktree_config`]
805    /// already decided for the single-run case, and the ref-counted version
806    /// must not second-guess it.
807    we_enabled: bool,
808}
809
810/// One entry per repository, each guarded by its own `tokio::sync::Mutex` so
811/// that two repositories' acquisitions never wait on each other - only two
812/// runs in the *same* repository do, which is the point.
813///
814/// A `std::sync::Mutex` guards the map itself, held only long enough to find
815/// or insert an entry and clone its `Arc`, never across an `.await`.
816static WORKTREE_CONFIG: std::sync::LazyLock<
817    std::sync::Mutex<
818        std::collections::HashMap<PathBuf, std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>>>,
819    >,
820> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
821
822/// The per-repository slot, creating it if this is the first run to ask.
823fn worktree_config_slot(repo: &Path) -> std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>> {
824    let mut map = WORKTREE_CONFIG
825        .lock()
826        .unwrap_or_else(std::sync::PoisonError::into_inner);
827    map.entry(repo.to_path_buf())
828        .or_insert_with(|| {
829            std::sync::Arc::new(tokio::sync::Mutex::new(WorktreeConfigRef {
830                count: 0,
831                we_enabled: false,
832            }))
833        })
834        .clone()
835}
836
837/// Take a reference on `extensions.worktreeConfig` being on for `repo`.
838///
839/// [`enable_worktree_config`] alone is only safe for one run in a repository
840/// at a time: it is a plain get-then-set, so a second run's "already true?"
841/// check can see the first run's write and conclude it owns nothing to turn
842/// back off, while the first run's own cleanup turns the setting off under
843/// the second run's feet the moment *it* finishes - the exact race that let a
844/// finished run's fold disable the hook a still-running sibling in the same
845/// repository depended on. This ref-counts instead: the setting is turned on
846/// once, by whichever caller is first, and turned off only once every caller
847/// has released it via [`release_worktree_config`].
848///
849/// The per-repository lock is held across the `git config` call for the
850/// first acquire, so a second, concurrent acquire for the same repository
851/// waits for it rather than racing it - without that, both could observe
852/// "not yet counted" and both try to flip the setting on.
853pub async fn acquire_worktree_config(repo: &Path) -> Result<()> {
854    let slot = worktree_config_slot(repo);
855    let mut entry = slot.lock().await;
856    entry.count += 1;
857    if entry.count == 1 {
858        entry.we_enabled = enable_worktree_config(repo).await?;
859    }
860    Ok(())
861}
862
863/// Release a reference taken by [`acquire_worktree_config`].
864///
865/// Only the last release for a repository actually calls
866/// [`disable_worktree_config`], and only when this process was the one that
867/// turned the setting on in the first place.
868pub async fn release_worktree_config(repo: &Path) -> Result<()> {
869    let slot = worktree_config_slot(repo);
870    let mut entry = slot.lock().await;
871    entry.count = entry.count.saturating_sub(1);
872    if entry.count == 0 && entry.we_enabled {
873        disable_worktree_config(repo).await?;
874        entry.we_enabled = false;
875    }
876    Ok(())
877}
878
879/// Point a single worktree at its own hooks directory.
880///
881/// `core.hooksPath` is normally repo-wide; scoping it with `--worktree` keeps
882/// the operator's own hooks untouched in the primary worktree, and the setting
883/// disappears together with the worktree.
884pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
885    let dir = hooks_dir.to_string_lossy().replace('\\', "/");
886    git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
887        .await
888        .map(|_| ())
889}
890
891/// Exclude a path from a worktree's status without touching `.gitignore`.
892pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
893    let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
894    let path = worktree.join(git_dir);
895    if let Some(parent) = path.parent() {
896        tokio::fs::create_dir_all(parent).await.ok();
897    }
898    let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
899    if body.lines().any(|l| l.trim() == pattern) {
900        return Ok(());
901    }
902    if !body.is_empty() && !body.ends_with('\n') {
903        body.push('\n');
904    }
905    body.push_str(pattern);
906    body.push('\n');
907    tokio::fs::write(&path, body)
908        .await
909        .with_context(|| format!("write {}", path.display()))?;
910    Ok(())
911}
912
913/// `git merge --no-ff` of `branch` into the currently checked-out branch.
914///
915/// One of three ways to land a branch driven by [`crate::config::MergeStyle`]
916/// — see [`merge_squash`] and [`merge_ff_only`] for the other two, and that
917/// enum's own doc for why the choice between them lives in configuration.
918pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
919    git_raw(
920        repo,
921        &["merge", "--no-ff", "--no-edit", "-m", message, branch],
922    )
923    .await
924}
925
926/// `git merge --squash` of `branch`, followed by a commit under `message`.
927///
928/// Two `git` calls because `--squash` only stages the result — unlike
929/// [`merge_no_ff`] there is no merge commit for `--no-edit` to write, and
930/// skipping the second call is exactly the trap `land`'s module doc warns
931/// about: a squash that inherits `branch`'s own single-commit subject
932/// (`magi: candidate A (uncommitted work)`) instead of `message`. Returns the
933/// `--squash` step's own output, unrun `commit` included, when staging itself
934/// fails (a conflict), so a caller sees what actually went wrong rather than
935/// a `git commit` complaint about nothing being staged.
936pub async fn merge_squash(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
937    let staged = git_raw(repo, &["merge", "--squash", branch]).await?;
938    if !staged.ok() {
939        return Ok(staged);
940    }
941    git_raw(repo, &["commit", "-m", message]).await
942}
943
944/// Fast-forward `branch` into the currently checked-out branch, refusing to
945/// create a merge commit.
946///
947/// Only ever fast-forwards because the winner was already rebased onto the
948/// tracked base tip before this runs (`Runner::sync_to_base`); at that point
949/// `--ff-only` is indistinguishable from GitHub's "rebase and merge" button.
950/// If the base moved again in the meantime this fails rather than falling
951/// back to a real rebase, the same way `merge_no_ff` fails rather than
952/// resolving a conflict — landing is not the place to improvise.
953pub async fn merge_ff_only(repo: &Path, branch: &str) -> Result<GitOut> {
954    git_raw(repo, &["merge", "--ff-only", branch]).await
955}
956
957/// Push a branch to `remote`.
958pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
959    git_raw(repo, &["push", "-u", remote, branch]).await
960}
961
962/// Push a local branch to a separate public head with an explicit lease.
963/// An empty expected SHA requires the remote branch to be absent.
964pub async fn push_as(
965    repo: &Path,
966    remote: &str,
967    local: &str,
968    head: &str,
969    expected: &str,
970) -> Result<GitOut> {
971    let lease = format!("--force-with-lease=refs/heads/{head}:{expected}");
972    let refspec = format!("refs/heads/{local}:refs/heads/{head}");
973    git_raw(repo, &["push", &lease, remote, &refspec]).await
974}
975
976/// Read a remote head without moving a tracking ref. Unreadable is an error.
977pub async fn remote_head(repo: &Path, remote: &str, branch: &str) -> Result<Option<String>> {
978    let reference = format!("refs/heads/{branch}");
979    let out = git(repo, &["ls-remote", "--refs", remote, &reference]).await?;
980    Ok(out.split_whitespace().next().map(str::to_owned))
981}
982
983/// Force-push a branch that has been rewritten, refusing to clobber work
984/// pushed since this side last looked.
985///
986/// `--force-with-lease` rather than `--force`: a rebase replaces the branch's
987/// commits, so a plain push is rejected, but a blind force would also throw
988/// away anything a person pushed to the same branch meanwhile. The lease
989/// turns that case into a failure instead of a loss.
990pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
991    git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
992}
993
994/// Force-push `branch` only if `remote` still has it at `expected`.
995///
996/// The lease is pinned to a sha the caller read itself, not to whatever the
997/// remote-tracking ref says at push time: a `fetch` in between moves the
998/// tracking ref, and a bare lease would then be measured against the very
999/// commit it was meant to protect a person's push from. A remote that has
1000/// moved on refuses the push, so a concurrent push fails the step instead of
1001/// being lost.
1002pub async fn push_pinned(
1003    repo: &Path,
1004    remote: &str,
1005    branch: &str,
1006    expected: &str,
1007) -> Result<GitOut> {
1008    let lease = format!("--force-with-lease=refs/heads/{branch}:{expected}");
1009    let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
1010    git_raw(repo, &["push", &lease, remote, &refspec]).await
1011}
1012
1013/// `git cherry <upstream> <head>`, split into the commits of `head` that have
1014/// no patch-id twin in `upstream` (`+`) and those that do (`-`).
1015pub async fn cherry(repo: &Path, upstream: &str, head: &str) -> Result<(Vec<String>, Vec<String>)> {
1016    let out = git(repo, &["cherry", upstream, head]).await?;
1017    let (mut unmatched, mut matched) = (Vec::new(), Vec::new());
1018    for line in out.lines() {
1019        if let Some(sha) = line.strip_prefix("+ ") {
1020            unmatched.push(sha.trim().to_owned());
1021        } else if let Some(sha) = line.strip_prefix("- ") {
1022            matched.push(sha.trim().to_owned());
1023        }
1024    }
1025    Ok((unmatched, matched))
1026}
1027
1028/// Git's pick order and the HEAD observed by the exec following each pick.
1029#[derive(Debug)]
1030pub struct RebaseRecord {
1031    /// Full original commit ids in Git's replay order.
1032    pub picks: Vec<String>,
1033    /// Full HEAD ids observed immediately after those picks.
1034    pub heads: Vec<String>,
1035}
1036
1037const REBASE_EXEC: &str = "git rev-parse HEAD >> \"$(git rev-parse --git-path magi-rebase-heads)\"";
1038
1039async fn rebase_file(scratch: &Path, name: &str) -> Result<PathBuf> {
1040    let path = git(scratch, &["rev-parse", "--git-path", name]).await?;
1041    let path = Path::new(path.trim());
1042    Ok(if path.is_absolute() {
1043        path.to_path_buf()
1044    } else {
1045        scratch.join(path)
1046    })
1047}
1048
1049async fn full_commit(scratch: &Path, sha: &str) -> Result<String> {
1050    anyhow::ensure!(
1051        !sha.is_empty() && sha.bytes().all(|b| b.is_ascii_hexdigit()),
1052        "invalid rebase commit id: {sha}"
1053    );
1054    rev_parse(scratch, &format!("{sha}^{{commit}}")).await
1055}
1056
1057/// Save Git's exact picks before a fixer can change its todo list. Resolve
1058/// abbreviations while the sequencer still exists; ambiguity is an error.
1059async fn save_rebase_picks(scratch: &Path) -> Result<()> {
1060    let mut picks = Vec::new();
1061    for name in ["rebase-merge/done", "rebase-merge/git-rebase-todo"] {
1062        let text = std::fs::read_to_string(rebase_file(scratch, name).await?)?;
1063        for line in text.lines().map(str::trim) {
1064            if line.is_empty() || line.starts_with('#') {
1065                continue;
1066            }
1067            let mut words = line.split_whitespace();
1068            match words.next() {
1069                Some("pick" | "p") => {
1070                    picks.push(
1071                        full_commit(scratch, words.next().context("pick without commit")?).await?,
1072                    );
1073                }
1074                Some("exec" | "x")
1075                    if line
1076                        .split_once(' ')
1077                        .is_some_and(|(_, cmd)| cmd == REBASE_EXEC) => {}
1078                _ => bail!("unexpected rebase instruction: {line}"),
1079            }
1080        }
1081    }
1082    let path = rebase_file(scratch, "magi-rebase-picks").await?;
1083    let tmp = path.with_extension("tmp");
1084    std::fs::write(&tmp, picks.join("\n") + "\n")?;
1085    std::fs::rename(tmp, path)?;
1086    Ok(())
1087}
1088
1089/// Read a completed rebase's durable record, rejecting missing or corrupt data.
1090pub async fn rebase_record(scratch: &Path) -> Result<RebaseRecord> {
1091    let mut lists = Vec::new();
1092    for name in ["magi-rebase-picks", "magi-rebase-heads"] {
1093        let text = std::fs::read_to_string(rebase_file(scratch, name).await?)
1094            .with_context(|| format!("read {name}"))?;
1095        let mut commits = Vec::new();
1096        for sha in text.lines() {
1097            let full = full_commit(scratch, sha).await?;
1098            anyhow::ensure!(full == sha, "non-full commit id in {name}");
1099            commits.push(full);
1100        }
1101        lists.push(commits);
1102    }
1103    let heads = lists.pop().unwrap();
1104    let picks = lists.pop().unwrap();
1105    anyhow::ensure!(
1106        !picks.is_empty() && picks.len() == heads.len(),
1107        "rebase pick/HEAD count mismatch"
1108    );
1109    Ok(RebaseRecord { picks, heads })
1110}
1111
1112/// Strip only the diagnostics emitted by our recording exec.
1113pub fn rebase_said(text: &str) -> String {
1114    text.trim()
1115        .split(['\r', '\n'])
1116        .filter(|line| !line.trim_start().starts_with("Executing:"))
1117        .collect::<Vec<_>>()
1118        .join("\n")
1119}
1120
1121/// How [`rebase_start`] ended.
1122#[derive(Debug, Clone, PartialEq, Eq)]
1123pub enum RebaseStart {
1124    /// It applied; the branch points at the rebased commits and the throwaway
1125    /// worktree is gone.
1126    Applied,
1127    /// It stopped on a conflict and the throwaway worktree was **kept**
1128    /// mid-rebase, for someone to resolve. Carries what git said.
1129    Conflicted(String),
1130    /// It failed without leaving a rebase in progress; the worktree is gone
1131    /// and the branch is untouched. Carries what git said.
1132    Failed(String),
1133}
1134
1135/// Start rebasing `branch` onto `onto` inside a throwaway worktree, and leave
1136/// a conflict standing.
1137///
1138/// A worktree of its own for two reasons. The repository magi runs in may be
1139/// jj-colocated, where git `HEAD` is detached and a rebase in the primary
1140/// tree would move it under the operator; and a rebase that hits a conflict
1141/// leaves state behind, which is far easier to discard with the whole
1142/// directory than to unpick in a tree somebody is using.
1143///
1144/// Unlike [`rebase_branch_in_temp`] this does not abort on a conflict: the
1145/// caller decides whether to hand the conflicted tree to a fixer or to call
1146/// [`rebase_abort`]. Any leftover at `scratch` from an earlier attempt is
1147/// removed first, so callers re-entering a rebase already in progress must
1148/// check [`rebase_in_progress`] before calling this.
1149pub async fn rebase_start(
1150    repo: &Path,
1151    scratch: &Path,
1152    branch: &str,
1153    onto: &str,
1154) -> Result<RebaseStart> {
1155    // Removed first so a leftover from an interrupted attempt cannot make
1156    // `worktree add` fail on a path that already exists.
1157    worktree_remove(repo, scratch).await.ok();
1158    git_raw(
1159        repo,
1160        &[
1161            "worktree",
1162            "add",
1163            "--force",
1164            &scratch.to_string_lossy(),
1165            branch,
1166        ],
1167    )
1168    .await?;
1169
1170    std::fs::write(rebase_file(scratch, "magi-rebase-heads").await?, "")?;
1171    let out = git_raw(
1172        scratch,
1173        &["rebase", "--empty=drop", "--exec", REBASE_EXEC, onto],
1174    )
1175    .await?;
1176    if out.ok() {
1177        worktree_remove(repo, scratch).await.ok();
1178        return Ok(RebaseStart::Applied);
1179    }
1180    let why = rebase_said(&if out.stderr.trim().is_empty() {
1181        out.stdout.trim().to_owned()
1182    } else {
1183        out.stderr.trim().to_owned()
1184    });
1185    if rebase_in_progress(scratch).await {
1186        if let Err(error) = save_rebase_picks(scratch).await {
1187            rebase_abort(repo, scratch).await;
1188            return Ok(RebaseStart::Failed(format!(
1189                "cannot record rebase picks: {error:#}"
1190            )));
1191        }
1192        return Ok(RebaseStart::Conflicted(why));
1193    }
1194    worktree_remove(repo, scratch).await.ok();
1195    Ok(RebaseStart::Failed(why))
1196}
1197
1198/// Is a rebase (merge or apply backend) in progress in `worktree`?
1199pub async fn rebase_in_progress(worktree: &Path) -> bool {
1200    for name in ["rebase-merge", "rebase-apply"] {
1201        let Ok(p) = git(worktree, &["rev-parse", "--git-path", name]).await else {
1202            continue;
1203        };
1204        let p = Path::new(p.trim());
1205        let full = if p.is_absolute() {
1206            p.to_path_buf()
1207        } else {
1208            worktree.join(p)
1209        };
1210        if full.exists() {
1211            return true;
1212        }
1213    }
1214    false
1215}
1216
1217/// Paths git reports as unmerged in `worktree`.
1218pub async fn unmerged_paths(worktree: &Path) -> Result<Vec<String>> {
1219    let out = git(worktree, &["diff", "--name-only", "--diff-filter=U"]).await?;
1220    Ok(out
1221        .lines()
1222        .map(str::trim)
1223        .filter(|l| !l.is_empty())
1224        .map(str::to_owned)
1225        .collect())
1226}
1227
1228/// Abandon a rebase left standing by [`rebase_start`] and drop its worktree.
1229/// The branch is exactly where it was before the rebase began.
1230pub async fn rebase_abort(repo: &Path, scratch: &Path) {
1231    git_raw(scratch, &["rebase", "--abort"]).await.ok();
1232    worktree_remove(repo, scratch).await.ok();
1233}
1234
1235/// Rebase a branch onto `onto`, inside a throwaway worktree.
1236///
1237/// `Ok(None)` means it applied and the branch now points at the rebased
1238/// commits. `Ok(Some(why))` means it did not: the branch is untouched, and
1239/// the string is what git said - a person has to decide. Nothing half-rebased
1240/// is left behind; see [`rebase_start`] for the variant that keeps a conflict
1241/// standing.
1242pub async fn rebase_branch_in_temp(
1243    repo: &Path,
1244    scratch: &Path,
1245    branch: &str,
1246    onto: &str,
1247) -> Result<Option<String>> {
1248    match rebase_start(repo, scratch, branch, onto).await? {
1249        RebaseStart::Applied => Ok(None),
1250        RebaseStart::Failed(why) => Ok(Some(why)),
1251        RebaseStart::Conflicted(why) => {
1252            rebase_abort(repo, scratch).await;
1253            Ok(Some(why))
1254        }
1255    }
1256}
1257
1258/// Bring an *attached* worktree's index and files in line with wherever its
1259/// branch now points.
1260///
1261/// [`rebase_branch_in_temp`] moves a branch from a throwaway worktree on
1262/// purpose - the whole point is never touching the tree someone else has
1263/// checked out. But a worktree that already had that branch checked out
1264/// shares the same ref: its `HEAD` resolves to the new commit the moment the
1265/// rebase lands elsewhere, while its index and working directory keep
1266/// whatever the old commit put there until something says otherwise. Left
1267/// alone, the next `git status` there reads as the whole rebase turning up
1268/// as an unstaged diff, and the next commit would be staged against stale
1269/// content.
1270pub async fn sync_to_head(worktree: &Path) -> Result<()> {
1271    git(worktree, &["reset", "--hard", "HEAD"]).await?;
1272    git(worktree, &["clean", "-fdx"]).await?;
1273    Ok(())
1274}
1275
1276/// Fetch one branch from `remote`, updating its remote-tracking ref.
1277///
1278/// The refspec is spelled out rather than left to `git fetch <remote>
1279/// <branch>`, which writes `FETCH_HEAD` and updates
1280/// `refs/remotes/<remote>/<branch>` only as a side effect of the remote's
1281/// configured refspec. Naming the destination makes the thing this function
1282/// exists for - a tracking ref that moved - the operation rather than a
1283/// consequence of configuration magi does not own.
1284///
1285/// Honest note: a CI failure was first read as proof that some git versions do
1286/// not update the tracking ref here. That was wrong - the fetch had nothing to
1287/// update because the test had pushed to the wrong branch - so this is
1288/// determinism, not a fix for a demonstrated portability bug.
1289///
1290/// Refs, not the working copy: nothing is checked out and no local branch
1291/// moves, so this is safe to run while the operator has uncommitted work.
1292/// Returned as a [`GitOut`] rather than an error so the caller can decide - a
1293/// machine with no network must still be able to start a run.
1294pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
1295    let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
1296    git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
1297}
1298
1299/// The best common ancestor of `a` and `b`, or an error when there is none.
1300pub async fn merge_base(repo: &Path, a: &str, b: &str) -> Result<String> {
1301    Ok(git(repo, &["merge-base", a, b]).await?.trim().to_owned())
1302}
1303
1304/// Is `ancestor` an ancestor of (or equal to) `of`?
1305pub async fn is_ancestor(repo: &Path, ancestor: &str, of: &str) -> bool {
1306    git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of])
1307        .await
1308        .is_ok_and(|o| o.ok())
1309}
1310
1311/// [`is_ancestor`] that keeps "no" apart from "could not tell": `merge-base
1312/// --is-ancestor` exits 0 for yes, 1 for no and anything else for an error
1313/// (an unknown object, a shallow clone), and reading an error as "no" would
1314/// report a merged change as unmerged.
1315pub async fn ancestry(repo: &Path, ancestor: &str, of: &str) -> Result<bool> {
1316    let out = git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of]).await?;
1317    match out.code {
1318        Some(0) => Ok(true),
1319        Some(1) => Ok(false),
1320        _ => bail!(
1321            "git merge-base --is-ancestor {ancestor} {of} failed ({}): {}",
1322            crate::notices::exit_status(out.code),
1323            out.stderr
1324        ),
1325    }
1326}
1327
1328/// The commit `rev` names, or `None` when it names no commit here.
1329pub async fn commit_of(repo: &Path, rev: &str) -> Option<String> {
1330    let spec = format!("{rev}^{{commit}}");
1331    let out = git_raw(repo, &["rev-parse", "--verify", "--quiet", &spec])
1332        .await
1333        .ok()?;
1334    (out.ok() && !out.stdout.is_empty()).then_some(out.stdout)
1335}
1336
1337/// Local and remote-tracking branches that contain `commit`.
1338pub async fn branches_containing(repo: &Path, commit: &str) -> Result<Vec<String>> {
1339    let out = git(
1340        repo,
1341        &[
1342            "branch",
1343            "-a",
1344            "--contains",
1345            commit,
1346            "--format=%(refname:short)",
1347        ],
1348    )
1349    .await?;
1350    Ok(out
1351        .lines()
1352        .map(str::trim)
1353        .filter(|l| !l.is_empty() && !l.ends_with("/HEAD") && !l.contains("HEAD detached"))
1354        .map(str::to_owned)
1355        .collect())
1356}
1357
1358/// Cherry-pick `commit` onto the worktree's HEAD under a neutral committer.
1359/// On a conflict the pick is aborted, so the worktree is left as it was, and
1360/// git's own words come back as the error.
1361pub async fn cherry_pick(worktree: &Path, commit: &str) -> Result<()> {
1362    let out = git_raw(
1363        worktree,
1364        &[
1365            "-c",
1366            "user.name=magi candidate",
1367            "-c",
1368            "user.email=magi@localhost",
1369            "cherry-pick",
1370            "-x",
1371            commit,
1372        ],
1373    )
1374    .await?;
1375    if out.ok() {
1376        return Ok(());
1377    }
1378    let _ = git_raw(worktree, &["cherry-pick", "--abort"]).await;
1379    bail!(
1380        "cherry-pick of {commit} failed: {}",
1381        if out.stderr.is_empty() {
1382            out.stdout
1383        } else {
1384            out.stderr
1385        }
1386    )
1387}
1388
1389/// The tree object id of `rev`.
1390pub async fn tree_of(repo: &Path, rev: &str) -> Result<String> {
1391    git(repo, &["rev-parse", &format!("{rev}^{{tree}}")]).await
1392}
1393
1394/// Does this ref resolve?
1395pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
1396    git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
1397        .await
1398        .is_ok_and(|o| o.ok())
1399}
1400
1401#[cfg(test)]
1402mod tests {
1403    use super::*;
1404
1405    #[test]
1406    fn worktree_holder_is_read_from_the_porcelain_listing() {
1407        let listing = "worktree /repo\nHEAD aaa\nbranch refs/heads/main\n\n\
1408                       worktree /wt/cand-A\nHEAD bbb\nbranch refs/heads/magi/f82f/A\n\n\
1409                       worktree /wt/detached\nHEAD ccc\ndetached\n";
1410        assert_eq!(
1411            parse_worktree_holder(listing, "magi/f82f/A"),
1412            Some(PathBuf::from("/wt/cand-A"))
1413        );
1414        assert_eq!(parse_worktree_holder(listing, "magi/f82f"), None);
1415        assert_eq!(parse_worktree_holder(listing, "other"), None);
1416    }
1417
1418    async fn scratch() -> (tempfile::TempDir, PathBuf) {
1419        let dir = tempfile::tempdir().unwrap();
1420        let repo = dir.path().join("repo");
1421        tokio::fs::create_dir_all(&repo).await.unwrap();
1422        git(&repo, &["init", "-b", "main"]).await.unwrap();
1423        git(&repo, &["config", "user.name", "test"]).await.unwrap();
1424        git(&repo, &["config", "user.email", "test@example.com"])
1425            .await
1426            .unwrap();
1427        tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
1428        git(&repo, &["add", "-A"]).await.unwrap();
1429        git(&repo, &["commit", "-m", "init"]).await.unwrap();
1430        (dir, repo)
1431    }
1432
1433    #[tokio::test]
1434    async fn restore_to_removes_nested_repositories_inside_submodules() {
1435        let (_parent_dir, repo) = scratch().await;
1436        let (_source_dir, source) = scratch().await;
1437        sh(
1438            &repo,
1439            &[
1440                "-c",
1441                "protocol.file.allow=always",
1442                "submodule",
1443                "add",
1444                &source.to_string_lossy(),
1445                "sub",
1446            ],
1447        );
1448        sh(&repo, &["commit", "-am", "add submodule"]);
1449        let pre = rev_parse(&repo, "HEAD").await.unwrap();
1450        let sub = repo.join("sub");
1451        sh(&sub, &["init", "nested"]);
1452        tokio::fs::write(sub.join("nested/rejected.txt"), "rejected\n")
1453            .await
1454            .unwrap();
1455
1456        restore_to(&repo, &pre).await.unwrap();
1457
1458        assert!(!sub.join("nested").exists());
1459        assert_eq!(rev_parse(&repo, "HEAD").await.unwrap(), pre);
1460        assert_eq!(
1461            rev_parse(&sub, "HEAD").await.unwrap(),
1462            rev_parse(&source, "HEAD").await.unwrap()
1463        );
1464        assert!(
1465            git(&repo, &["status", "--porcelain"])
1466                .await
1467                .unwrap()
1468                .is_empty()
1469        );
1470    }
1471
1472    #[tokio::test]
1473    async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
1474        let (_g, repo) = scratch().await;
1475
1476        // A side branch touching a different file: rebases cleanly.
1477        git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1478        tokio::fs::write(repo.join("b.txt"), "side\n")
1479            .await
1480            .unwrap();
1481        git(&repo, &["add", "-A"]).await.unwrap();
1482        git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1483
1484        // main moves under it, which is what a repository merging other
1485        // pull requests does to a competition that took two hours.
1486        git(&repo, &["checkout", "main"]).await.unwrap();
1487        tokio::fs::write(repo.join("c.txt"), "main\n")
1488            .await
1489            .unwrap();
1490        git(&repo, &["add", "-A"]).await.unwrap();
1491        git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1492
1493        let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
1494        let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1495            .await
1496            .unwrap();
1497        assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
1498        assert_eq!(
1499            commits_ahead(&repo, "main", "side").await.unwrap(),
1500            1,
1501            "one commit, replayed onto the new base"
1502        );
1503        assert!(
1504            !scratch_tree.exists(),
1505            "the throwaway worktree is not left behind"
1506        );
1507
1508        // A real conflict: both sides edit the same line.
1509        git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
1510        tokio::fs::write(repo.join("a.txt"), "clash\n")
1511            .await
1512            .unwrap();
1513        git(&repo, &["add", "-A"]).await.unwrap();
1514        git(&repo, &["commit", "-m", "clash"]).await.unwrap();
1515        git(&repo, &["checkout", "main"]).await.unwrap();
1516        tokio::fs::write(repo.join("a.txt"), "main edit\n")
1517            .await
1518            .unwrap();
1519        git(&repo, &["add", "-A"]).await.unwrap();
1520        git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
1521
1522        let before = rev_parse(&repo, "clash").await.unwrap();
1523        let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
1524            .await
1525            .unwrap()
1526            .expect("a same-line clash cannot be rebased silently");
1527        assert!(
1528            why.to_lowercase().contains("conflict"),
1529            "the reason is what git said, which is what a person needs: {why}"
1530        );
1531        assert_eq!(
1532            rev_parse(&repo, "clash").await.unwrap(),
1533            before,
1534            "a failed rebase leaves the branch exactly where it was"
1535        );
1536        assert!(!scratch_tree.exists(), "and cleans up after itself");
1537    }
1538
1539    #[tokio::test]
1540    async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
1541        let (_g, repo) = scratch().await;
1542        git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1543        for name in ["b.txt", "c.txt"] {
1544            tokio::fs::write(repo.join(name), "side\n").await.unwrap();
1545            git(&repo, &["add", "-A"]).await.unwrap();
1546            git(
1547                &repo,
1548                &["commit", "-m", "magi: candidate A (uncommitted work)"],
1549            )
1550            .await
1551            .unwrap();
1552        }
1553        git(&repo, &["checkout", "main"]).await.unwrap();
1554        let before = rev_parse(&repo, "main").await.unwrap();
1555
1556        let out = merge_squash(&repo, "side", "an explicit subject")
1557            .await
1558            .unwrap();
1559        assert!(out.ok(), "{}", out.stderr);
1560        assert_eq!(
1561            commits_ahead(&repo, &before, "main").await.unwrap(),
1562            1,
1563            "squash adds exactly one commit onto the tip, not one per candidate commit"
1564        );
1565        let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
1566        assert_eq!(
1567            subject, "an explicit subject",
1568            "the candidate's own placeholder subject must not survive: {subject}"
1569        );
1570    }
1571
1572    #[tokio::test]
1573    async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
1574        let (_g, repo) = scratch().await;
1575        git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1576        tokio::fs::write(repo.join("b.txt"), "side\n")
1577            .await
1578            .unwrap();
1579        git(&repo, &["add", "-A"]).await.unwrap();
1580        git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1581        git(&repo, &["checkout", "main"]).await.unwrap();
1582
1583        let before = rev_parse(&repo, "side").await.unwrap();
1584        let out = merge_ff_only(&repo, "side").await.unwrap();
1585        assert!(out.ok(), "{}", out.stderr);
1586        assert_eq!(
1587            rev_parse(&repo, "main").await.unwrap(),
1588            before,
1589            "a fast-forward moves the base tip to the branch, no merge commit"
1590        );
1591    }
1592
1593    #[tokio::test]
1594    async fn merge_ff_only_refuses_to_write_a_merge_commit() {
1595        let (_g, repo) = scratch().await;
1596        git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1597        tokio::fs::write(repo.join("b.txt"), "side\n")
1598            .await
1599            .unwrap();
1600        git(&repo, &["add", "-A"]).await.unwrap();
1601        git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1602
1603        // main diverges, so a fast-forward is no longer possible.
1604        git(&repo, &["checkout", "main"]).await.unwrap();
1605        tokio::fs::write(repo.join("c.txt"), "main\n")
1606            .await
1607            .unwrap();
1608        git(&repo, &["add", "-A"]).await.unwrap();
1609        git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1610
1611        let before = rev_parse(&repo, "main").await.unwrap();
1612        let out = merge_ff_only(&repo, "side").await.unwrap();
1613        assert!(!out.ok(), "a divergent branch cannot fast-forward");
1614        assert_eq!(
1615            rev_parse(&repo, "main").await.unwrap(),
1616            before,
1617            "a refused fast-forward must not touch main"
1618        );
1619    }
1620
1621    #[tokio::test]
1622    async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
1623        let (guard, repo) = scratch().await;
1624
1625        // An attached worktree of an existing branch - the shape a winner's
1626        // worktree keeps in `graph::Runner`, not the detached checkouts used
1627        // for judges and reviewers.
1628        git(&repo, &["branch", "side"]).await.unwrap();
1629        let side_wt = guard.path().join("side-wt");
1630        git(
1631            &repo,
1632            &["worktree", "add", &side_wt.to_string_lossy(), "side"],
1633        )
1634        .await
1635        .unwrap();
1636        tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
1637            .await
1638            .unwrap();
1639        git(&side_wt, &["add", "-A"]).await.unwrap();
1640        git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
1641
1642        // main moves under it.
1643        git(&repo, &["checkout", "main"]).await.unwrap();
1644        tokio::fs::write(repo.join("c.txt"), "main\n")
1645            .await
1646            .unwrap();
1647        git(&repo, &["add", "-A"]).await.unwrap();
1648        git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1649
1650        // Rebase from a throwaway worktree, never from `side_wt` itself.
1651        let scratch_tree = guard.path().join("rebase-scratch");
1652        let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1653            .await
1654            .unwrap();
1655        assert!(clean.is_none());
1656
1657        // `HEAD` in the sibling worktree already resolves to the rebased
1658        // commit - the ref is shared - but nothing has told its index or its
1659        // files, which still hold the pre-rebase checkout.
1660        assert_eq!(
1661            rev_parse(&side_wt, "HEAD").await.unwrap(),
1662            rev_parse(&repo, "side").await.unwrap(),
1663            "HEAD follows the moved ref"
1664        );
1665        assert!(
1666            !side_wt.join("c.txt").exists(),
1667            "stale until synced: main's new file has not reached this worktree's disk"
1668        );
1669
1670        sync_to_head(&side_wt).await.unwrap();
1671        assert!(side_wt.join("c.txt").is_file(), "synced now");
1672        assert!(
1673            side_wt.join("b.txt").is_file(),
1674            "the worktree's own committed work survives the sync"
1675        );
1676        assert!(is_clean(&side_wt).await.unwrap());
1677    }
1678
1679    #[tokio::test]
1680    async fn clean_repo_reports_clean_then_dirty() {
1681        let (_g, repo) = scratch().await;
1682        assert!(is_clean(&repo).await.unwrap());
1683        tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
1684        assert!(!is_clean(&repo).await.unwrap());
1685    }
1686
1687    async fn track(repo: &Path, name: &str, body: &str) {
1688        let p = repo.join(name);
1689        if let Some(d) = p.parent() {
1690            tokio::fs::create_dir_all(d).await.unwrap();
1691        }
1692        tokio::fs::write(&p, body).await.unwrap();
1693        git(repo, &["add", name]).await.unwrap();
1694        git(
1695            repo,
1696            &[
1697                "-c",
1698                "user.name=t",
1699                "-c",
1700                "user.email=t@localhost",
1701                "commit",
1702                "-q",
1703                "-m",
1704                "seed",
1705            ],
1706        )
1707        .await
1708        .unwrap();
1709    }
1710
1711    #[tokio::test]
1712    async fn rescue_withholds_a_foreign_lockfile() {
1713        let (_g, repo) = scratch().await;
1714        track(&repo, "web/bun.lock", "a\n").await;
1715        tokio::fs::write(repo.join("web/pnpm-lock.yaml"), "x\n")
1716            .await
1717            .unwrap();
1718        tokio::fs::write(repo.join("web/app.ts"), "real\n")
1719            .await
1720            .unwrap();
1721
1722        let r = rescue_commit(&repo, "rescue").await.unwrap();
1723        assert!(r.committed);
1724        assert_eq!(
1725            r.withheld,
1726            [Stray {
1727                path: "web/pnpm-lock.yaml".to_owned(),
1728                manager: "pnpm".to_owned(),
1729                kept_by: "web/bun.lock".to_owned(),
1730            }]
1731        );
1732        let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1733            .await
1734            .unwrap();
1735        assert!(files.contains("web/app.ts"), "{files}");
1736        assert!(!files.contains("pnpm-lock"), "{files}");
1737        assert!(repo.join("web/pnpm-lock.yaml").is_file(), "not deleted");
1738    }
1739
1740    #[tokio::test]
1741    async fn rescue_with_only_a_stray_commits_nothing() {
1742        let (_g, repo) = scratch().await;
1743        track(&repo, "bun.lock", "a\n").await;
1744        tokio::fs::write(repo.join("yarn.lock"), "x\n")
1745            .await
1746            .unwrap();
1747        let r = rescue_commit(&repo, "rescue").await.unwrap();
1748        assert!(!r.committed);
1749        assert_eq!(r.withheld.len(), 1);
1750    }
1751
1752    #[tokio::test]
1753    async fn rescue_keeps_a_same_manager_lockfile_update() {
1754        let (_g, repo) = scratch().await;
1755        track(&repo, "bun.lock", "a\n").await;
1756        tokio::fs::write(repo.join("bun.lock"), "b\n")
1757            .await
1758            .unwrap();
1759        let r = rescue_commit(&repo, "rescue").await.unwrap();
1760        assert!(r.committed);
1761        assert!(r.withheld.is_empty());
1762        let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1763            .await
1764            .unwrap();
1765        assert_eq!(files, "bun.lock");
1766    }
1767
1768    #[tokio::test]
1769    async fn rescue_keeps_the_first_lockfile_in_a_bare_directory() {
1770        let (_g, repo) = scratch().await;
1771        track(&repo, "other/bun.lock", "a\n").await;
1772        tokio::fs::create_dir_all(repo.join("web")).await.unwrap();
1773        tokio::fs::write(repo.join("web/package-lock.json"), "{}\n")
1774            .await
1775            .unwrap();
1776        let r = rescue_commit(&repo, "rescue").await.unwrap();
1777        assert!(r.committed);
1778        assert!(r.withheld.is_empty());
1779    }
1780
1781    #[test]
1782    fn a_cargo_lock_is_foreign_only_without_a_cargo_toml() {
1783        let s = |v: &[&str]| v.iter().map(|x| (*x).to_owned()).collect::<Vec<_>>();
1784        assert_eq!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&[])).len(), 1);
1785        assert!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["a/Cargo.toml"])).is_empty());
1786        assert!(stray_lockfiles(&s(&["a/Cargo.lock", "a/Cargo.toml"]), &s(&[])).is_empty());
1787        // A manifest in another directory does not count.
1788        assert_eq!(
1789            stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["Cargo.toml"])).len(),
1790            1
1791        );
1792    }
1793
1794    #[tokio::test]
1795    async fn clean_removal_preserves_edits_while_scratch_removal_discards_them() {
1796        let (guard, repo) = scratch().await;
1797        let wt = guard.path().join("scratch-wt");
1798        worktree_add_detached(&repo, &wt, "HEAD").await.unwrap();
1799        tokio::fs::write(wt.join("built.txt"), "build output")
1800            .await
1801            .unwrap();
1802        assert!(!worktree_remove_clean(&repo, &wt).await.unwrap());
1803        assert!(wt.join("built.txt").exists());
1804        let listing = git(&repo, &["worktree", "list", "--porcelain"])
1805            .await
1806            .unwrap();
1807        let registered = listing
1808            .lines()
1809            .filter_map(|line| line.strip_prefix("worktree "))
1810            .map(PathBuf::from)
1811            .find(|path| path.canonicalize().unwrap() == wt.canonicalize().unwrap())
1812            .expect("the refused removal must retain the worktree registration");
1813        assert!(worktree_remove(&repo, &wt).await.unwrap());
1814        assert!(!wt.exists());
1815        let listing = git(&repo, &["worktree", "list", "--porcelain"])
1816            .await
1817            .unwrap();
1818        assert!(!listing.lines().any(|line| {
1819            line.strip_prefix("worktree ")
1820                .is_some_and(|path| Path::new(path) == registered)
1821        }));
1822    }
1823
1824    #[tokio::test]
1825    async fn worktree_lifecycle_and_diff() {
1826        let (guard, repo) = scratch().await;
1827        let base = rev_parse(&repo, "HEAD").await.unwrap();
1828        let wt = guard.path().join("wt-a");
1829        worktree_add_branch(&repo, &wt, "magi/test/a", &base)
1830            .await
1831            .unwrap();
1832        tokio::fs::write(wt.join("b.txt"), "candidate\n")
1833            .await
1834            .unwrap();
1835
1836        assert!(commit_all(&wt, "candidate work").await.unwrap());
1837        assert!(!commit_all(&wt, "nothing left").await.unwrap());
1838
1839        assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
1840        let patch = diff(&wt, &base, "HEAD").await.unwrap();
1841        assert!(patch.contains("b.txt"), "patch was: {patch}");
1842        assert_eq!(
1843            changed_files(&wt, &base, "HEAD").await.unwrap(),
1844            ["b.txt".to_owned()]
1845        );
1846
1847        // The rescue commit must not carry the operator's identity.
1848        let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
1849            .await
1850            .unwrap();
1851        assert_eq!(author, "magi candidate <magi@localhost>");
1852
1853        assert!(worktree_remove(&repo, &wt).await.unwrap());
1854        assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
1855        assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
1856        assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
1857    }
1858
1859    #[tokio::test]
1860    async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
1861        let (guard, repo) = scratch().await;
1862        let base = rev_parse(&repo, "HEAD").await.unwrap();
1863        let wt = guard.path().join("wt-h");
1864        worktree_add_branch(&repo, &wt, "magi/test/h", &base)
1865            .await
1866            .unwrap();
1867        let hooks = guard.path().join("hooks");
1868        tokio::fs::create_dir_all(&hooks).await.unwrap();
1869
1870        assert!(enable_worktree_config(&repo).await.unwrap());
1871        set_worktree_hooks_path(&wt, &hooks).await.unwrap();
1872
1873        let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
1874            .await
1875            .unwrap();
1876        assert!(!in_wt.is_empty());
1877        let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
1878            .await
1879            .unwrap();
1880        assert!(
1881            !in_primary.ok(),
1882            "primary worktree must keep its own hooks: {in_primary:?}"
1883        );
1884
1885        disable_worktree_config(&repo).await.unwrap();
1886    }
1887
1888    #[tokio::test]
1889    async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
1890        let (_g, repo) = scratch().await;
1891
1892        // Two runs in the same repository, as `Config::daemon.max_concurrent_runs`
1893        // now allows: both acquire before either is done.
1894        acquire_worktree_config(&repo).await.unwrap();
1895        acquire_worktree_config(&repo).await.unwrap();
1896
1897        let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1898            .await
1899            .unwrap();
1900        assert_eq!(on, "true");
1901
1902        // The first run to finish releases its own reference. A plain
1903        // `disable_worktree_config` here is exactly the bug: it would turn
1904        // the setting off while the second run still depends on it.
1905        release_worktree_config(&repo).await.unwrap();
1906        let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1907            .await
1908            .unwrap();
1909        assert_eq!(
1910            still_on, "true",
1911            "a sibling run's release must not disable the setting for the one still working"
1912        );
1913
1914        // Only the last release actually turns it back off.
1915        release_worktree_config(&repo).await.unwrap();
1916        let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
1917            .await
1918            .unwrap();
1919        assert!(
1920            !after.ok(),
1921            "the last release must turn the setting back off: {after:?}"
1922        );
1923    }
1924
1925    #[tokio::test]
1926    async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
1927        let (_g, repo) = scratch().await;
1928        git(&repo, &["config", "extensions.worktreeConfig", "true"])
1929            .await
1930            .unwrap();
1931
1932        // magi did not turn this on, so even after every acquire is released,
1933        // it must not turn it off - that is what a bare `enable_worktree_config`
1934        // already promised for the single-run case, and the ref-counted
1935        // version must keep that promise.
1936        acquire_worktree_config(&repo).await.unwrap();
1937        release_worktree_config(&repo).await.unwrap();
1938
1939        let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1940            .await
1941            .unwrap();
1942        assert_eq!(still_on, "true");
1943    }
1944
1945    #[tokio::test]
1946    async fn local_exclude_is_idempotent() {
1947        let (_g, repo) = scratch().await;
1948        local_exclude(&repo, "/.magi/").await.unwrap();
1949        local_exclude(&repo, "/.magi/").await.unwrap();
1950        let path = repo.join(".git/info/exclude");
1951        let body = tokio::fs::read_to_string(&path).await.unwrap();
1952        assert_eq!(body.matches("/.magi/").count(), 1);
1953    }
1954
1955    fn sh(cwd: &Path, args: &[&str]) {
1956        let out = std::process::Command::new("git")
1957            .quiet()
1958            .args(args)
1959            .current_dir(cwd)
1960            .output()
1961            .unwrap();
1962        assert!(
1963            out.status.success(),
1964            "git {args:?}: {}",
1965            String::from_utf8_lossy(&out.stderr)
1966        );
1967    }
1968
1969    /// A checkout whose `origin` is a local bare repository holding `trunk`,
1970    /// with `origin/HEAD` unset (as after `git remote add` + push).
1971    async fn with_remote() -> (tempfile::TempDir, PathBuf) {
1972        let (g, repo) = scratch().await;
1973        let bare = g.path().join("bare.git");
1974        sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
1975        sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
1976        sh(&repo, &["push", "origin", "HEAD:refs/heads/trunk"]);
1977        sh(&repo, &["fetch", "origin"]);
1978        (g, repo)
1979    }
1980
1981    #[test]
1982    fn remote_head_branch_strips_only_the_matching_remote() {
1983        assert_eq!(
1984            remote_head_branch("origin", "refs/remotes/origin/main\n").as_deref(),
1985            Some("main")
1986        );
1987        assert_eq!(remote_head_branch("up", "refs/remotes/origin/main"), None);
1988        assert_eq!(remote_head_branch("origin", "refs/remotes/origin/"), None);
1989    }
1990
1991    #[tokio::test]
1992    async fn merge_base_branch_reads_origin_head_when_present() {
1993        let (_g, repo) = with_remote().await;
1994        sh(&repo, &["remote", "set-head", "origin", "trunk"]);
1995        assert_eq!(
1996            merge_base_branch(&repo, "origin", None).await.unwrap(),
1997            "trunk"
1998        );
1999    }
2000
2001    #[tokio::test]
2002    async fn merge_base_branch_recovers_a_missing_origin_head() {
2003        let (_g, repo) = with_remote().await;
2004        // Newer git sets it on fetch; make the precondition true everywhere.
2005        sh(&repo, &["remote", "set-head", "origin", "-d"]);
2006        let head = git_raw(
2007            &repo,
2008            &["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
2009        )
2010        .await
2011        .unwrap();
2012        assert!(!head.ok(), "precondition: no origin/HEAD");
2013        assert_eq!(
2014            merge_base_branch(&repo, "origin", None).await.unwrap(),
2015            "trunk"
2016        );
2017    }
2018
2019    #[tokio::test]
2020    async fn merge_base_branch_errors_without_a_remote_and_never_uses_head() {
2021        let (_g, repo) = scratch().await;
2022        let err = merge_base_branch(&repo, "origin", None).await.unwrap_err();
2023        assert!(format!("{err:#}").contains("[merge] base"), "{err:#}");
2024    }
2025
2026    #[tokio::test]
2027    async fn merge_base_branch_prefers_the_explicit_base() {
2028        let (_g, repo) = with_remote().await;
2029        assert_eq!(
2030            merge_base_branch(&repo, "origin", Some("release"))
2031                .await
2032                .unwrap(),
2033            "release"
2034        );
2035    }
2036
2037    #[tokio::test]
2038    async fn merge_base_branch_names_an_unfetched_default_branch() {
2039        // A remote added after the fact, never fetched: no tracking refs, so
2040        // `set-head -a` cannot work, but the remote's own HEAD still names it.
2041        let (g, repo) = scratch().await;
2042        let bare = g.path().join("bare.git");
2043        sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
2044        sh(
2045            &repo,
2046            &["push", &bare.to_string_lossy(), "HEAD:refs/heads/trunk"],
2047        );
2048        sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
2049        assert!(!rev_exists(&repo, "refs/remotes/origin/trunk").await);
2050        assert_eq!(
2051            merge_base_branch(&repo, "origin", None).await.unwrap(),
2052            "trunk"
2053        );
2054    }
2055
2056    #[test]
2057    fn symref_branch_reads_ls_remote_output() {
2058        assert_eq!(
2059            symref_branch("ref: refs/heads/main\tHEAD\nabc\tHEAD\n").as_deref(),
2060            Some("main")
2061        );
2062        assert_eq!(symref_branch("abc\tHEAD\n"), None);
2063    }
2064}