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/// Remove a worktree. Returns `Ok(false)` when git refused (e.g. the path is
296/// already gone), so callers can keep folding the rest of a run.
297pub async fn worktree_remove(repo: &Path, path: &Path) -> Result<bool> {
298    let path_s = path.to_string_lossy().to_string();
299    let out = git_raw(repo, &["worktree", "remove", "--force", &path_s]).await?;
300    if out.ok() {
301        return Ok(true);
302    }
303    // A worktree whose directory was deleted by hand only needs pruning.
304    git_raw(repo, &["worktree", "prune"]).await?;
305    Ok(false)
306}
307
308/// Remove a worktree only if git finds it clean: no `--force`, so a change
309/// made after the caller last looked is refused rather than thrown away.
310/// Ignored files (`target/`) do not count as changes and go with it.
311pub async fn worktree_remove_clean(repo: &Path, path: &Path) -> Result<bool> {
312    let path_s = path.to_string_lossy().to_string();
313    Ok(git_raw(repo, &["worktree", "remove", &path_s]).await?.ok())
314}
315
316/// Unregister a linked worktree whose directory is about to be deleted by
317/// hand, so the path can be `worktree add`-ed again.
318///
319/// A linked worktree's `.git` is a file whose `gitdir:` line names the
320/// bookkeeping entry inside its repository's admin directory; pruning from
321/// there removes the registration without touching the directory. No-op when
322/// `dir` is not a registered worktree (`.git` missing or not a `gitdir:`
323/// link): nothing was registered, nothing survives removal.
324pub async fn remove_worktree_from_linked(dir: &Path) {
325    let Ok(link) = std::fs::read_to_string(dir.join(".git")) else {
326        return;
327    };
328    let Some(admin) = link.strip_prefix("gitdir:").map(str::trim) else {
329        return;
330    };
331    // `<repo>/.git/worktrees/<name>`, so the repository's git dir is two
332    // levels up from here.
333    let admin = Path::new(admin);
334    let Some(common) = admin.parent().and_then(Path::parent) else {
335        return;
336    };
337    let common_s = common.to_string_lossy();
338    let _ = git_raw(dir, &["--git-dir", &common_s, "worktree", "prune"]).await;
339}
340
341/// Drop registrations for worktrees whose directory is already gone.
342///
343/// `git worktree remove` already does this for the path it just removed, but
344/// a directory deleted by hand - [`crate::clean::fold_orphaned_worktrees`], or
345/// an operator's own `rm -rf` - leaves the registration behind, and a
346/// registered path refuses a fresh `worktree add` until something prunes it.
347/// The operator's own machine had 31 such registrations sitting in one
348/// repository, all of them for directories that no longer existed.
349pub async fn worktree_prune(repo: &Path) -> Result<()> {
350    git(repo, &["worktree", "prune"]).await.map(|_| ())
351}
352
353/// Delete a branch, ignoring "not found".
354pub async fn branch_delete(repo: &Path, branch: &str) -> Result<bool> {
355    Ok(git_raw(repo, &["branch", "-D", branch]).await?.ok())
356}
357
358/// Does `branch` exist?
359pub async fn branch_exists(repo: &Path, branch: &str) -> Result<bool> {
360    let refname = format!("refs/heads/{branch}");
361    Ok(
362        git_raw(repo, &["show-ref", "--verify", "--quiet", &refname])
363            .await?
364            .ok(),
365    )
366}
367
368/// Does `remote` have `branch`? `Err` when that cannot be told (unreachable
369/// remote), which callers must not read as "no".
370pub async fn remote_has_branch(repo: &Path, remote: &str, branch: &str) -> Result<bool> {
371    let refname = format!("refs/heads/{branch}");
372    let out = git_raw(repo, &["ls-remote", "--exit-code", remote, &refname]).await?;
373    match out.code {
374        Some(0) => Ok(true),
375        Some(2) => Ok(false),
376        code => bail!(
377            "git ls-remote {remote} failed ({}): {}",
378            crate::notices::exit_status(code),
379            out.stderr
380        ),
381    }
382}
383
384/// Patch of `head` against the merge base with `base`.
385pub async fn diff(worktree: &Path, base: &str, head: &str) -> Result<String> {
386    let range = format!("{base}...{head}");
387    git(
388        worktree,
389        &["diff", "--no-color", "--no-ext-diff", "-M", &range],
390    )
391    .await
392}
393
394/// `--stat` summary of `base...head`.
395pub async fn diff_stat(worktree: &Path, base: &str, head: &str) -> Result<String> {
396    let range = format!("{base}...{head}");
397    git(worktree, &["diff", "--no-color", "--stat", &range]).await
398}
399
400/// Total changed lines (added + removed) between two revisions, from
401/// `--numstat`. `None` when git could not say, or a binary file changed
402/// (numstat prints `-` for it): a size that cannot be measured is not small.
403pub async fn changed_lines(worktree: &Path, base: &str, head: &str) -> Option<usize> {
404    let out = git_raw(worktree, &["diff", "--numstat", base, head])
405        .await
406        .ok()?;
407    if !out.ok() {
408        return None;
409    }
410    let mut total = 0usize;
411    for line in out.stdout.lines().filter(|l| !l.trim().is_empty()) {
412        let mut cols = line.split('\t');
413        let added: usize = cols.next()?.parse().ok()?;
414        let removed: usize = cols.next()?.parse().ok()?;
415        total = total.saturating_add(added).saturating_add(removed);
416    }
417    Some(total)
418}
419
420/// Put the worktree back on `rev`: tracked files reset, untracked files
421/// removed. Ignored paths (a `[worktree] setup`'s products are hidden through
422/// the per-worktree excludes) are left alone.
423pub async fn restore_to(worktree: &Path, rev: &str) -> Result<()> {
424    git(worktree, &["reset", "--hard", "-q", rev]).await?;
425    // `-ff`: a single `-f` skips untracked nested git repositories, which is
426    // what a submodule or clone added by a rejected change leaves behind.
427    git(worktree, &["clean", "-ffdq"]).await?;
428    // The parent's reset leaves an initialised submodule's own tree alone
429    // (`submodule.recurse` defaults to false): put each back on the commit the
430    // parent records (`--checkout` overrides a configured
431    // `submodule.<name>.update` such as `none`), and drop what is untracked inside. No-ops without
432    // submodules; an error is an unconfirmed restore.
433    git(
434        worktree,
435        &[
436            "submodule",
437            "--quiet",
438            "update",
439            "--recursive",
440            "--force",
441            "--checkout",
442        ],
443    )
444    .await?;
445    git(
446        worktree,
447        &[
448            "submodule",
449            "--quiet",
450            "foreach",
451            "--recursive",
452            "git clean -ffdq",
453        ],
454    )
455    .await?;
456    Ok(())
457}
458
459/// Number of files touched by `base...head`.
460pub async fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
461    let range = format!("{base}...{head}");
462    let out = git(worktree, &["diff", "--name-only", &range]).await?;
463    Ok(out.lines().map(str::to_owned).collect())
464}
465
466/// Subject and body of every commit in `base..head`, oldest first. Fields are
467/// split on control characters git never prints in a message, so an empty
468/// subject or a body full of separators cannot shift a record.
469pub async fn commit_log(worktree: &Path, base: &str, head: &str) -> Result<Vec<(String, String)>> {
470    let range = format!("{base}..{head}");
471    let out = git(
472        worktree,
473        &[
474            "log",
475            "--reverse",
476            "--no-color",
477            "--format=%s%x1f%b%x00",
478            &range,
479        ],
480    )
481    .await?;
482    Ok(out
483        .split('\0')
484        .filter_map(|rec| {
485            let rec = rec.trim_start_matches('\n');
486            if rec.trim().is_empty() {
487                return None;
488            }
489            let (subject, body) = rec.split_once('\x1f').unwrap_or((rec, ""));
490            Some((subject.trim().to_owned(), body.trim().to_owned()))
491        })
492        .collect())
493}
494
495/// One-line log of `base..head`, oldest first.
496pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
497    let range = format!("{base}..{head}");
498    git(
499        worktree,
500        &["log", "--reverse", "--format=%s%n%b%n--", &range],
501    )
502    .await
503}
504
505/// Subjects of the commits in `base..head`, oldest first. NUL-terminated so an
506/// empty subject keeps its place instead of shifting the later ones forward.
507pub async fn subjects(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
508    let range = format!("{base}..{head}");
509    let out = git(worktree, &["log", "--reverse", "--format=%s%x00", &range]).await?;
510    let mut parts: Vec<String> = out.split('\0').map(|s| s.trim().to_owned()).collect();
511    // Whatever follows the last terminator is just the trailing newline.
512    parts.pop();
513    Ok(parts)
514}
515
516/// How many commits `head` is ahead of `base`.
517pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
518    let range = format!("{base}..{head}");
519    let out = git(worktree, &["rev-list", "--count", &range]).await?;
520    Ok(out.trim().parse().unwrap_or(0))
521}
522
523/// Stage everything and commit under a neutral identity.
524///
525/// Used to rescue an agent that edited files but never committed: without this
526/// its candidate would silently be empty. The neutral identity is part of the
527/// blindness contract — a real `user.name` in a candidate's history would name
528/// the operator, and an agent-configured one would name the vendor.
529pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
530    if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
531        return Ok(false);
532    }
533    git(worktree, &["add", "-A"]).await?;
534    // What `[worktree] setup` produced is not the agent's work.
535    if !crate::worktree_setup::withheld_paths(worktree)
536        .await
537        .is_empty()
538    {
539        crate::worktree_setup::unstage_products(worktree).await?;
540        if git_raw(worktree, &["diff", "--cached", "--quiet"])
541            .await?
542            .ok()
543        {
544            return Ok(false);
545        }
546    }
547    let out = git_raw(
548        worktree,
549        &[
550            "-c",
551            "user.name=magi candidate",
552            "-c",
553            "user.email=magi@localhost",
554            "commit",
555            "--no-verify",
556            "-m",
557            message,
558        ],
559    )
560    .await?;
561    if !out.ok() {
562        bail!("rescue commit failed: {}", out.stderr);
563    }
564    Ok(true)
565}
566
567/// A freshly created lockfile that belongs to a package manager the directory
568/// does not use, and was therefore left out of a rescue commit.
569#[derive(Debug, Clone, PartialEq, Eq)]
570pub struct Stray {
571    /// Repo-relative path, forward slashes.
572    pub path: String,
573    /// The package manager the file belongs to (`pnpm`, `cargo`, ...).
574    pub manager: String,
575    /// What made it foreign: the tracked lockfile (or `Cargo.toml`'s absence)
576    /// that says which manager the directory really uses.
577    pub kept_by: String,
578}
579
580/// What [`rescue_commit`] did.
581#[derive(Debug, Default)]
582pub struct Rescue {
583    /// Whether a commit was made.
584    pub committed: bool,
585    /// Files left untracked in the worktree instead of being committed.
586    pub withheld: Vec<Stray>,
587}
588
589/// `(ecosystem, manager)` for a lockfile's file name.
590fn lock_kind(name: &str) -> Option<(&'static str, &'static str)> {
591    Some(match name {
592        "package-lock.json" | "npm-shrinkwrap.json" => ("node", "npm"),
593        "yarn.lock" => ("node", "yarn"),
594        "pnpm-lock.yaml" => ("node", "pnpm"),
595        "bun.lock" | "bun.lockb" => ("node", "bun"),
596        "poetry.lock" => ("python", "poetry"),
597        "uv.lock" => ("python", "uv"),
598        "Pipfile.lock" => ("python", "pipenv"),
599        "pdm.lock" => ("python", "pdm"),
600        "Cargo.lock" => ("rust", "cargo"),
601        _ => return None,
602    })
603}
604
605fn split_dir(path: &str) -> (&str, &str) {
606    path.rsplit_once('/').unwrap_or(("", path))
607}
608
609/// Which of the newly created `untracked` files are lockfiles of a manager the
610/// repo does not use in that directory.
611///
612/// Foreign means: a lockfile of the same ecosystem but another manager is
613/// already tracked *in the same directory* (no recursion — a workspace root and
614/// a sub-package may legitimately differ), or, for `Cargo.lock`, there is no
615/// `Cargo.toml` beside it. A first lockfile in a directory with none is normal.
616pub fn stray_lockfiles(untracked: &[String], tracked: &[String]) -> Vec<Stray> {
617    let mut out = Vec::new();
618    for path in untracked {
619        let (dir, name) = split_dir(path);
620        let Some((eco, manager)) = lock_kind(name) else {
621            continue;
622        };
623        let beside = |other: &String| split_dir(other).0 == dir;
624        let kept_by = if manager == "cargo" {
625            let has_manifest = tracked
626                .iter()
627                .chain(untracked)
628                .any(|p| beside(p) && split_dir(p).1 == "Cargo.toml");
629            if has_manifest {
630                continue;
631            }
632            "no Cargo.toml in the directory".to_owned()
633        } else {
634            let Some(other) = tracked.iter().find(|p| {
635                beside(p)
636                    && lock_kind(split_dir(p).1).is_some_and(|(e, m)| e == eco && m != manager)
637            }) else {
638                continue;
639            };
640            other.clone()
641        };
642        out.push(Stray {
643            path: path.clone(),
644            manager: manager.to_owned(),
645            kept_by,
646        });
647    }
648    out
649}
650
651async fn nul_list(worktree: &Path, args: &[&str]) -> Result<Vec<String>> {
652    let out = git(worktree, args).await?;
653    Ok(out
654        .split('\0')
655        .filter(|s| !s.is_empty())
656        .map(str::to_owned)
657        .collect())
658}
659
660/// [`commit_all`] for an agent's leftover work, minus stray foreign lockfiles.
661///
662/// The withheld files stay untracked in the worktree (nothing is deleted) and
663/// are returned so the caller can record them: silently dropping them could
664/// lose a file the task really asked for.
665pub async fn rescue_commit(worktree: &Path, message: &str) -> Result<Rescue> {
666    if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
667        return Ok(Rescue::default());
668    }
669    let untracked = nul_list(
670        worktree,
671        &["ls-files", "-z", "--others", "--exclude-standard"],
672    )
673    .await?;
674    let tracked = nul_list(worktree, &["ls-files", "-z"]).await?;
675    let withheld = stray_lockfiles(&untracked, &tracked);
676
677    git(worktree, &["add", "-A"]).await?;
678    if !withheld.is_empty() {
679        let mut args = vec!["reset", "-q", "--"];
680        args.extend(withheld.iter().map(|s| s.path.as_str()));
681        git(worktree, &args).await?;
682    }
683    crate::worktree_setup::unstage_products(worktree).await?;
684    if git_raw(worktree, &["diff", "--cached", "--quiet"])
685        .await?
686        .ok()
687    {
688        return Ok(Rescue {
689            committed: false,
690            withheld,
691        });
692    }
693    let out = git_raw(
694        worktree,
695        &[
696            "-c",
697            "user.name=magi candidate",
698            "-c",
699            "user.email=magi@localhost",
700            "commit",
701            "--no-verify",
702            "-m",
703            message,
704        ],
705    )
706    .await?;
707    if !out.ok() {
708        bail!("rescue commit failed: {}", out.stderr);
709    }
710    Ok(Rescue {
711        committed: true,
712        withheld,
713    })
714}
715
716/// Enable `extensions.worktreeConfig` if it is not already on.
717///
718/// Returns `true` when magi turned it on, so the caller can turn it back off
719/// during cleanup and leave the repo exactly as it found it.
720pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
721    let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
722    if out.ok() && out.stdout.trim() == "true" {
723        return Ok(false);
724    }
725    git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
726    Ok(true)
727}
728
729/// Undo [`enable_worktree_config`].
730pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
731    git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
732    Ok(())
733}
734
735/// How many runs currently want `extensions.worktreeConfig` on for one
736/// repository, and whether magi is the one that turned it on.
737struct WorktreeConfigRef {
738    /// Runs holding a reference, via [`acquire_worktree_config`].
739    count: usize,
740    /// Did *this process* flip the setting from off to on? If not - it was
741    /// already `true` when the first run in this process asked - nothing
742    /// here ever turns it off either; that is what [`enable_worktree_config`]
743    /// already decided for the single-run case, and the ref-counted version
744    /// must not second-guess it.
745    we_enabled: bool,
746}
747
748/// One entry per repository, each guarded by its own `tokio::sync::Mutex` so
749/// that two repositories' acquisitions never wait on each other - only two
750/// runs in the *same* repository do, which is the point.
751///
752/// A `std::sync::Mutex` guards the map itself, held only long enough to find
753/// or insert an entry and clone its `Arc`, never across an `.await`.
754static WORKTREE_CONFIG: std::sync::LazyLock<
755    std::sync::Mutex<
756        std::collections::HashMap<PathBuf, std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>>>,
757    >,
758> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
759
760/// The per-repository slot, creating it if this is the first run to ask.
761fn worktree_config_slot(repo: &Path) -> std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>> {
762    let mut map = WORKTREE_CONFIG
763        .lock()
764        .unwrap_or_else(std::sync::PoisonError::into_inner);
765    map.entry(repo.to_path_buf())
766        .or_insert_with(|| {
767            std::sync::Arc::new(tokio::sync::Mutex::new(WorktreeConfigRef {
768                count: 0,
769                we_enabled: false,
770            }))
771        })
772        .clone()
773}
774
775/// Take a reference on `extensions.worktreeConfig` being on for `repo`.
776///
777/// [`enable_worktree_config`] alone is only safe for one run in a repository
778/// at a time: it is a plain get-then-set, so a second run's "already true?"
779/// check can see the first run's write and conclude it owns nothing to turn
780/// back off, while the first run's own cleanup turns the setting off under
781/// the second run's feet the moment *it* finishes - the exact race that let a
782/// finished run's fold disable the hook a still-running sibling in the same
783/// repository depended on. This ref-counts instead: the setting is turned on
784/// once, by whichever caller is first, and turned off only once every caller
785/// has released it via [`release_worktree_config`].
786///
787/// The per-repository lock is held across the `git config` call for the
788/// first acquire, so a second, concurrent acquire for the same repository
789/// waits for it rather than racing it - without that, both could observe
790/// "not yet counted" and both try to flip the setting on.
791pub async fn acquire_worktree_config(repo: &Path) -> Result<()> {
792    let slot = worktree_config_slot(repo);
793    let mut entry = slot.lock().await;
794    entry.count += 1;
795    if entry.count == 1 {
796        entry.we_enabled = enable_worktree_config(repo).await?;
797    }
798    Ok(())
799}
800
801/// Release a reference taken by [`acquire_worktree_config`].
802///
803/// Only the last release for a repository actually calls
804/// [`disable_worktree_config`], and only when this process was the one that
805/// turned the setting on in the first place.
806pub async fn release_worktree_config(repo: &Path) -> Result<()> {
807    let slot = worktree_config_slot(repo);
808    let mut entry = slot.lock().await;
809    entry.count = entry.count.saturating_sub(1);
810    if entry.count == 0 && entry.we_enabled {
811        disable_worktree_config(repo).await?;
812        entry.we_enabled = false;
813    }
814    Ok(())
815}
816
817/// Point a single worktree at its own hooks directory.
818///
819/// `core.hooksPath` is normally repo-wide; scoping it with `--worktree` keeps
820/// the operator's own hooks untouched in the primary worktree, and the setting
821/// disappears together with the worktree.
822pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
823    let dir = hooks_dir.to_string_lossy().replace('\\', "/");
824    git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
825        .await
826        .map(|_| ())
827}
828
829/// Exclude a path from a worktree's status without touching `.gitignore`.
830pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
831    let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
832    let path = worktree.join(git_dir);
833    if let Some(parent) = path.parent() {
834        tokio::fs::create_dir_all(parent).await.ok();
835    }
836    let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
837    if body.lines().any(|l| l.trim() == pattern) {
838        return Ok(());
839    }
840    if !body.is_empty() && !body.ends_with('\n') {
841        body.push('\n');
842    }
843    body.push_str(pattern);
844    body.push('\n');
845    tokio::fs::write(&path, body)
846        .await
847        .with_context(|| format!("write {}", path.display()))?;
848    Ok(())
849}
850
851/// `git merge --no-ff` of `branch` into the currently checked-out branch.
852///
853/// One of three ways to land a branch driven by [`crate::config::MergeStyle`]
854/// — see [`merge_squash`] and [`merge_ff_only`] for the other two, and that
855/// enum's own doc for why the choice between them lives in configuration.
856pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
857    git_raw(
858        repo,
859        &["merge", "--no-ff", "--no-edit", "-m", message, branch],
860    )
861    .await
862}
863
864/// `git merge --squash` of `branch`, followed by a commit under `message`.
865///
866/// Two `git` calls because `--squash` only stages the result — unlike
867/// [`merge_no_ff`] there is no merge commit for `--no-edit` to write, and
868/// skipping the second call is exactly the trap `land`'s module doc warns
869/// about: a squash that inherits `branch`'s own single-commit subject
870/// (`magi: candidate A (uncommitted work)`) instead of `message`. Returns the
871/// `--squash` step's own output, unrun `commit` included, when staging itself
872/// fails (a conflict), so a caller sees what actually went wrong rather than
873/// a `git commit` complaint about nothing being staged.
874pub async fn merge_squash(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
875    let staged = git_raw(repo, &["merge", "--squash", branch]).await?;
876    if !staged.ok() {
877        return Ok(staged);
878    }
879    git_raw(repo, &["commit", "-m", message]).await
880}
881
882/// Fast-forward `branch` into the currently checked-out branch, refusing to
883/// create a merge commit.
884///
885/// Only ever fast-forwards because the winner was already rebased onto the
886/// tracked base tip before this runs (`Runner::sync_to_base`); at that point
887/// `--ff-only` is indistinguishable from GitHub's "rebase and merge" button.
888/// If the base moved again in the meantime this fails rather than falling
889/// back to a real rebase, the same way `merge_no_ff` fails rather than
890/// resolving a conflict — landing is not the place to improvise.
891pub async fn merge_ff_only(repo: &Path, branch: &str) -> Result<GitOut> {
892    git_raw(repo, &["merge", "--ff-only", branch]).await
893}
894
895/// Push a branch to `remote`.
896pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
897    git_raw(repo, &["push", "-u", remote, branch]).await
898}
899
900/// Push a local branch to a separate public head with an explicit lease.
901/// An empty expected SHA requires the remote branch to be absent.
902pub async fn push_as(
903    repo: &Path,
904    remote: &str,
905    local: &str,
906    head: &str,
907    expected: &str,
908) -> Result<GitOut> {
909    let lease = format!("--force-with-lease=refs/heads/{head}:{expected}");
910    let refspec = format!("refs/heads/{local}:refs/heads/{head}");
911    git_raw(repo, &["push", &lease, remote, &refspec]).await
912}
913
914/// Read a remote head without moving a tracking ref. Unreadable is an error.
915pub async fn remote_head(repo: &Path, remote: &str, branch: &str) -> Result<Option<String>> {
916    let reference = format!("refs/heads/{branch}");
917    let out = git(repo, &["ls-remote", "--refs", remote, &reference]).await?;
918    Ok(out.split_whitespace().next().map(str::to_owned))
919}
920
921/// Force-push a branch that has been rewritten, refusing to clobber work
922/// pushed since this side last looked.
923///
924/// `--force-with-lease` rather than `--force`: a rebase replaces the branch's
925/// commits, so a plain push is rejected, but a blind force would also throw
926/// away anything a person pushed to the same branch meanwhile. The lease
927/// turns that case into a failure instead of a loss.
928pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
929    git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
930}
931
932/// Force-push `branch` only if `remote` still has it at `expected`.
933///
934/// The lease is pinned to a sha the caller read itself, not to whatever the
935/// remote-tracking ref says at push time: a `fetch` in between moves the
936/// tracking ref, and a bare lease would then be measured against the very
937/// commit it was meant to protect a person's push from. A remote that has
938/// moved on refuses the push, so a concurrent push fails the step instead of
939/// being lost.
940pub async fn push_pinned(
941    repo: &Path,
942    remote: &str,
943    branch: &str,
944    expected: &str,
945) -> Result<GitOut> {
946    let lease = format!("--force-with-lease=refs/heads/{branch}:{expected}");
947    let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
948    git_raw(repo, &["push", &lease, remote, &refspec]).await
949}
950
951/// `git cherry <upstream> <head>`, split into the commits of `head` that have
952/// no patch-id twin in `upstream` (`+`) and those that do (`-`).
953pub async fn cherry(repo: &Path, upstream: &str, head: &str) -> Result<(Vec<String>, Vec<String>)> {
954    let out = git(repo, &["cherry", upstream, head]).await?;
955    let (mut unmatched, mut matched) = (Vec::new(), Vec::new());
956    for line in out.lines() {
957        if let Some(sha) = line.strip_prefix("+ ") {
958            unmatched.push(sha.trim().to_owned());
959        } else if let Some(sha) = line.strip_prefix("- ") {
960            matched.push(sha.trim().to_owned());
961        }
962    }
963    Ok((unmatched, matched))
964}
965
966/// One commit as a rebase preserves it: the sha plus the attributes git keeps
967/// when it replays a commit (author name, email, author date, subject). A
968/// replayed commit changes sha and patch-id, but not these.
969#[derive(Debug, Clone, PartialEq, Eq)]
970pub struct CommitKey {
971    /// The commit id.
972    pub sha: String,
973    /// Author name, email, author date and subject, joined.
974    pub key: String,
975}
976
977/// The non-merge commits in `range`, oldest first, with their [`CommitKey`].
978pub async fn commit_keys(repo: &Path, range: &str) -> Result<Vec<CommitKey>> {
979    let out = git(
980        repo,
981        &[
982            "log",
983            "--no-merges",
984            "--reverse",
985            "--date=raw",
986            "--format=%H%x1f%an%x1f%ae%x1f%ad%x1f%s",
987            range,
988        ],
989    )
990    .await?;
991    Ok(out
992        .lines()
993        .filter_map(|l| l.split_once('\u{1f}'))
994        .map(|(sha, key)| CommitKey {
995            sha: sha.to_owned(),
996            key: key.to_owned(),
997        })
998        .collect())
999}
1000
1001/// How [`rebase_start`] ended.
1002#[derive(Debug, Clone, PartialEq, Eq)]
1003pub enum RebaseStart {
1004    /// It applied; the branch points at the rebased commits and the throwaway
1005    /// worktree is gone.
1006    Applied,
1007    /// It stopped on a conflict and the throwaway worktree was **kept**
1008    /// mid-rebase, for someone to resolve. Carries what git said.
1009    Conflicted(String),
1010    /// It failed without leaving a rebase in progress; the worktree is gone
1011    /// and the branch is untouched. Carries what git said.
1012    Failed(String),
1013}
1014
1015/// Start rebasing `branch` onto `onto` inside a throwaway worktree, and leave
1016/// a conflict standing.
1017///
1018/// A worktree of its own for two reasons. The repository magi runs in may be
1019/// jj-colocated, where git `HEAD` is detached and a rebase in the primary
1020/// tree would move it under the operator; and a rebase that hits a conflict
1021/// leaves state behind, which is far easier to discard with the whole
1022/// directory than to unpick in a tree somebody is using.
1023///
1024/// Unlike [`rebase_branch_in_temp`] this does not abort on a conflict: the
1025/// caller decides whether to hand the conflicted tree to a fixer or to call
1026/// [`rebase_abort`]. Any leftover at `scratch` from an earlier attempt is
1027/// removed first, so callers re-entering a rebase already in progress must
1028/// check [`rebase_in_progress`] before calling this.
1029pub async fn rebase_start(
1030    repo: &Path,
1031    scratch: &Path,
1032    branch: &str,
1033    onto: &str,
1034) -> Result<RebaseStart> {
1035    // Removed first so a leftover from an interrupted attempt cannot make
1036    // `worktree add` fail on a path that already exists.
1037    worktree_remove(repo, scratch).await.ok();
1038    git_raw(
1039        repo,
1040        &[
1041            "worktree",
1042            "add",
1043            "--force",
1044            &scratch.to_string_lossy(),
1045            branch,
1046        ],
1047    )
1048    .await?;
1049
1050    let out = git_raw(scratch, &["rebase", onto]).await?;
1051    if out.ok() {
1052        worktree_remove(repo, scratch).await.ok();
1053        return Ok(RebaseStart::Applied);
1054    }
1055    let why = if out.stderr.trim().is_empty() {
1056        out.stdout.trim().to_owned()
1057    } else {
1058        out.stderr.trim().to_owned()
1059    };
1060    if rebase_in_progress(scratch).await {
1061        return Ok(RebaseStart::Conflicted(why));
1062    }
1063    worktree_remove(repo, scratch).await.ok();
1064    Ok(RebaseStart::Failed(why))
1065}
1066
1067/// Is a rebase (merge or apply backend) in progress in `worktree`?
1068pub async fn rebase_in_progress(worktree: &Path) -> bool {
1069    for name in ["rebase-merge", "rebase-apply"] {
1070        let Ok(p) = git(worktree, &["rev-parse", "--git-path", name]).await else {
1071            continue;
1072        };
1073        let p = Path::new(p.trim());
1074        let full = if p.is_absolute() {
1075            p.to_path_buf()
1076        } else {
1077            worktree.join(p)
1078        };
1079        if full.exists() {
1080            return true;
1081        }
1082    }
1083    false
1084}
1085
1086/// Paths git reports as unmerged in `worktree`.
1087pub async fn unmerged_paths(worktree: &Path) -> Result<Vec<String>> {
1088    let out = git(worktree, &["diff", "--name-only", "--diff-filter=U"]).await?;
1089    Ok(out
1090        .lines()
1091        .map(str::trim)
1092        .filter(|l| !l.is_empty())
1093        .map(str::to_owned)
1094        .collect())
1095}
1096
1097/// Abandon a rebase left standing by [`rebase_start`] and drop its worktree.
1098/// The branch is exactly where it was before the rebase began.
1099pub async fn rebase_abort(repo: &Path, scratch: &Path) {
1100    git_raw(scratch, &["rebase", "--abort"]).await.ok();
1101    worktree_remove(repo, scratch).await.ok();
1102}
1103
1104/// Rebase a branch onto `onto`, inside a throwaway worktree.
1105///
1106/// `Ok(None)` means it applied and the branch now points at the rebased
1107/// commits. `Ok(Some(why))` means it did not: the branch is untouched, and
1108/// the string is what git said - a person has to decide. Nothing half-rebased
1109/// is left behind; see [`rebase_start`] for the variant that keeps a conflict
1110/// standing.
1111pub async fn rebase_branch_in_temp(
1112    repo: &Path,
1113    scratch: &Path,
1114    branch: &str,
1115    onto: &str,
1116) -> Result<Option<String>> {
1117    match rebase_start(repo, scratch, branch, onto).await? {
1118        RebaseStart::Applied => Ok(None),
1119        RebaseStart::Failed(why) => Ok(Some(why)),
1120        RebaseStart::Conflicted(why) => {
1121            rebase_abort(repo, scratch).await;
1122            Ok(Some(why))
1123        }
1124    }
1125}
1126
1127/// Bring an *attached* worktree's index and files in line with wherever its
1128/// branch now points.
1129///
1130/// [`rebase_branch_in_temp`] moves a branch from a throwaway worktree on
1131/// purpose - the whole point is never touching the tree someone else has
1132/// checked out. But a worktree that already had that branch checked out
1133/// shares the same ref: its `HEAD` resolves to the new commit the moment the
1134/// rebase lands elsewhere, while its index and working directory keep
1135/// whatever the old commit put there until something says otherwise. Left
1136/// alone, the next `git status` there reads as the whole rebase turning up
1137/// as an unstaged diff, and the next commit would be staged against stale
1138/// content.
1139pub async fn sync_to_head(worktree: &Path) -> Result<()> {
1140    git(worktree, &["reset", "--hard", "HEAD"]).await?;
1141    git(worktree, &["clean", "-fdx"]).await?;
1142    Ok(())
1143}
1144
1145/// Fetch one branch from `remote`, updating its remote-tracking ref.
1146///
1147/// The refspec is spelled out rather than left to `git fetch <remote>
1148/// <branch>`, which writes `FETCH_HEAD` and updates
1149/// `refs/remotes/<remote>/<branch>` only as a side effect of the remote's
1150/// configured refspec. Naming the destination makes the thing this function
1151/// exists for - a tracking ref that moved - the operation rather than a
1152/// consequence of configuration magi does not own.
1153///
1154/// Honest note: a CI failure was first read as proof that some git versions do
1155/// not update the tracking ref here. That was wrong - the fetch had nothing to
1156/// update because the test had pushed to the wrong branch - so this is
1157/// determinism, not a fix for a demonstrated portability bug.
1158///
1159/// Refs, not the working copy: nothing is checked out and no local branch
1160/// moves, so this is safe to run while the operator has uncommitted work.
1161/// Returned as a [`GitOut`] rather than an error so the caller can decide - a
1162/// machine with no network must still be able to start a run.
1163pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
1164    let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
1165    git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
1166}
1167
1168/// The best common ancestor of `a` and `b`, or an error when there is none.
1169pub async fn merge_base(repo: &Path, a: &str, b: &str) -> Result<String> {
1170    Ok(git(repo, &["merge-base", a, b]).await?.trim().to_owned())
1171}
1172
1173/// Is `ancestor` an ancestor of (or equal to) `of`?
1174pub async fn is_ancestor(repo: &Path, ancestor: &str, of: &str) -> bool {
1175    git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of])
1176        .await
1177        .is_ok_and(|o| o.ok())
1178}
1179
1180/// [`is_ancestor`] that keeps "no" apart from "could not tell": `merge-base
1181/// --is-ancestor` exits 0 for yes, 1 for no and anything else for an error
1182/// (an unknown object, a shallow clone), and reading an error as "no" would
1183/// report a merged change as unmerged.
1184pub async fn ancestry(repo: &Path, ancestor: &str, of: &str) -> Result<bool> {
1185    let out = git_raw(repo, &["merge-base", "--is-ancestor", ancestor, of]).await?;
1186    match out.code {
1187        Some(0) => Ok(true),
1188        Some(1) => Ok(false),
1189        _ => bail!(
1190            "git merge-base --is-ancestor {ancestor} {of} failed ({}): {}",
1191            crate::notices::exit_status(out.code),
1192            out.stderr
1193        ),
1194    }
1195}
1196
1197/// The commit `rev` names, or `None` when it names no commit here.
1198pub async fn commit_of(repo: &Path, rev: &str) -> Option<String> {
1199    let spec = format!("{rev}^{{commit}}");
1200    let out = git_raw(repo, &["rev-parse", "--verify", "--quiet", &spec])
1201        .await
1202        .ok()?;
1203    (out.ok() && !out.stdout.is_empty()).then_some(out.stdout)
1204}
1205
1206/// Local and remote-tracking branches that contain `commit`.
1207pub async fn branches_containing(repo: &Path, commit: &str) -> Result<Vec<String>> {
1208    let out = git(
1209        repo,
1210        &[
1211            "branch",
1212            "-a",
1213            "--contains",
1214            commit,
1215            "--format=%(refname:short)",
1216        ],
1217    )
1218    .await?;
1219    Ok(out
1220        .lines()
1221        .map(str::trim)
1222        .filter(|l| !l.is_empty() && !l.ends_with("/HEAD") && !l.contains("HEAD detached"))
1223        .map(str::to_owned)
1224        .collect())
1225}
1226
1227/// Cherry-pick `commit` onto the worktree's HEAD under a neutral committer.
1228/// On a conflict the pick is aborted, so the worktree is left as it was, and
1229/// git's own words come back as the error.
1230pub async fn cherry_pick(worktree: &Path, commit: &str) -> Result<()> {
1231    let out = git_raw(
1232        worktree,
1233        &[
1234            "-c",
1235            "user.name=magi candidate",
1236            "-c",
1237            "user.email=magi@localhost",
1238            "cherry-pick",
1239            "-x",
1240            commit,
1241        ],
1242    )
1243    .await?;
1244    if out.ok() {
1245        return Ok(());
1246    }
1247    let _ = git_raw(worktree, &["cherry-pick", "--abort"]).await;
1248    bail!(
1249        "cherry-pick of {commit} failed: {}",
1250        if out.stderr.is_empty() {
1251            out.stdout
1252        } else {
1253            out.stderr
1254        }
1255    )
1256}
1257
1258/// The tree object id of `rev`.
1259pub async fn tree_of(repo: &Path, rev: &str) -> Result<String> {
1260    git(repo, &["rev-parse", &format!("{rev}^{{tree}}")]).await
1261}
1262
1263/// Does this ref resolve?
1264pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
1265    git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
1266        .await
1267        .is_ok_and(|o| o.ok())
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272    use super::*;
1273
1274    #[test]
1275    fn worktree_holder_is_read_from_the_porcelain_listing() {
1276        let listing = "worktree /repo\nHEAD aaa\nbranch refs/heads/main\n\n\
1277                       worktree /wt/cand-A\nHEAD bbb\nbranch refs/heads/magi/f82f/A\n\n\
1278                       worktree /wt/detached\nHEAD ccc\ndetached\n";
1279        assert_eq!(
1280            parse_worktree_holder(listing, "magi/f82f/A"),
1281            Some(PathBuf::from("/wt/cand-A"))
1282        );
1283        assert_eq!(parse_worktree_holder(listing, "magi/f82f"), None);
1284        assert_eq!(parse_worktree_holder(listing, "other"), None);
1285    }
1286
1287    async fn scratch() -> (tempfile::TempDir, PathBuf) {
1288        let dir = tempfile::tempdir().unwrap();
1289        let repo = dir.path().join("repo");
1290        tokio::fs::create_dir_all(&repo).await.unwrap();
1291        git(&repo, &["init", "-b", "main"]).await.unwrap();
1292        git(&repo, &["config", "user.name", "test"]).await.unwrap();
1293        git(&repo, &["config", "user.email", "test@example.com"])
1294            .await
1295            .unwrap();
1296        tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
1297        git(&repo, &["add", "-A"]).await.unwrap();
1298        git(&repo, &["commit", "-m", "init"]).await.unwrap();
1299        (dir, repo)
1300    }
1301
1302    #[tokio::test]
1303    async fn restore_to_removes_nested_repositories_inside_submodules() {
1304        let (_parent_dir, repo) = scratch().await;
1305        let (_source_dir, source) = scratch().await;
1306        sh(
1307            &repo,
1308            &[
1309                "-c",
1310                "protocol.file.allow=always",
1311                "submodule",
1312                "add",
1313                &source.to_string_lossy(),
1314                "sub",
1315            ],
1316        );
1317        sh(&repo, &["commit", "-am", "add submodule"]);
1318        let pre = rev_parse(&repo, "HEAD").await.unwrap();
1319        let sub = repo.join("sub");
1320        sh(&sub, &["init", "nested"]);
1321        tokio::fs::write(sub.join("nested/rejected.txt"), "rejected\n")
1322            .await
1323            .unwrap();
1324
1325        restore_to(&repo, &pre).await.unwrap();
1326
1327        assert!(!sub.join("nested").exists());
1328        assert_eq!(rev_parse(&repo, "HEAD").await.unwrap(), pre);
1329        assert_eq!(
1330            rev_parse(&sub, "HEAD").await.unwrap(),
1331            rev_parse(&source, "HEAD").await.unwrap()
1332        );
1333        assert!(
1334            git(&repo, &["status", "--porcelain"])
1335                .await
1336                .unwrap()
1337                .is_empty()
1338        );
1339    }
1340
1341    #[tokio::test]
1342    async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
1343        let (_g, repo) = scratch().await;
1344
1345        // A side branch touching a different file: rebases cleanly.
1346        git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1347        tokio::fs::write(repo.join("b.txt"), "side\n")
1348            .await
1349            .unwrap();
1350        git(&repo, &["add", "-A"]).await.unwrap();
1351        git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1352
1353        // main moves under it, which is what a repository merging other
1354        // pull requests does to a competition that took two hours.
1355        git(&repo, &["checkout", "main"]).await.unwrap();
1356        tokio::fs::write(repo.join("c.txt"), "main\n")
1357            .await
1358            .unwrap();
1359        git(&repo, &["add", "-A"]).await.unwrap();
1360        git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1361
1362        let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
1363        let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1364            .await
1365            .unwrap();
1366        assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
1367        assert_eq!(
1368            commits_ahead(&repo, "main", "side").await.unwrap(),
1369            1,
1370            "one commit, replayed onto the new base"
1371        );
1372        assert!(
1373            !scratch_tree.exists(),
1374            "the throwaway worktree is not left behind"
1375        );
1376
1377        // A real conflict: both sides edit the same line.
1378        git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
1379        tokio::fs::write(repo.join("a.txt"), "clash\n")
1380            .await
1381            .unwrap();
1382        git(&repo, &["add", "-A"]).await.unwrap();
1383        git(&repo, &["commit", "-m", "clash"]).await.unwrap();
1384        git(&repo, &["checkout", "main"]).await.unwrap();
1385        tokio::fs::write(repo.join("a.txt"), "main edit\n")
1386            .await
1387            .unwrap();
1388        git(&repo, &["add", "-A"]).await.unwrap();
1389        git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
1390
1391        let before = rev_parse(&repo, "clash").await.unwrap();
1392        let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
1393            .await
1394            .unwrap()
1395            .expect("a same-line clash cannot be rebased silently");
1396        assert!(
1397            why.to_lowercase().contains("conflict"),
1398            "the reason is what git said, which is what a person needs: {why}"
1399        );
1400        assert_eq!(
1401            rev_parse(&repo, "clash").await.unwrap(),
1402            before,
1403            "a failed rebase leaves the branch exactly where it was"
1404        );
1405        assert!(!scratch_tree.exists(), "and cleans up after itself");
1406    }
1407
1408    #[tokio::test]
1409    async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
1410        let (_g, repo) = scratch().await;
1411        git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1412        for name in ["b.txt", "c.txt"] {
1413            tokio::fs::write(repo.join(name), "side\n").await.unwrap();
1414            git(&repo, &["add", "-A"]).await.unwrap();
1415            git(
1416                &repo,
1417                &["commit", "-m", "magi: candidate A (uncommitted work)"],
1418            )
1419            .await
1420            .unwrap();
1421        }
1422        git(&repo, &["checkout", "main"]).await.unwrap();
1423        let before = rev_parse(&repo, "main").await.unwrap();
1424
1425        let out = merge_squash(&repo, "side", "an explicit subject")
1426            .await
1427            .unwrap();
1428        assert!(out.ok(), "{}", out.stderr);
1429        assert_eq!(
1430            commits_ahead(&repo, &before, "main").await.unwrap(),
1431            1,
1432            "squash adds exactly one commit onto the tip, not one per candidate commit"
1433        );
1434        let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
1435        assert_eq!(
1436            subject, "an explicit subject",
1437            "the candidate's own placeholder subject must not survive: {subject}"
1438        );
1439    }
1440
1441    #[tokio::test]
1442    async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
1443        let (_g, repo) = scratch().await;
1444        git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1445        tokio::fs::write(repo.join("b.txt"), "side\n")
1446            .await
1447            .unwrap();
1448        git(&repo, &["add", "-A"]).await.unwrap();
1449        git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1450        git(&repo, &["checkout", "main"]).await.unwrap();
1451
1452        let before = rev_parse(&repo, "side").await.unwrap();
1453        let out = merge_ff_only(&repo, "side").await.unwrap();
1454        assert!(out.ok(), "{}", out.stderr);
1455        assert_eq!(
1456            rev_parse(&repo, "main").await.unwrap(),
1457            before,
1458            "a fast-forward moves the base tip to the branch, no merge commit"
1459        );
1460    }
1461
1462    #[tokio::test]
1463    async fn merge_ff_only_refuses_to_write_a_merge_commit() {
1464        let (_g, repo) = scratch().await;
1465        git(&repo, &["checkout", "-b", "side"]).await.unwrap();
1466        tokio::fs::write(repo.join("b.txt"), "side\n")
1467            .await
1468            .unwrap();
1469        git(&repo, &["add", "-A"]).await.unwrap();
1470        git(&repo, &["commit", "-m", "side work"]).await.unwrap();
1471
1472        // main diverges, so a fast-forward is no longer possible.
1473        git(&repo, &["checkout", "main"]).await.unwrap();
1474        tokio::fs::write(repo.join("c.txt"), "main\n")
1475            .await
1476            .unwrap();
1477        git(&repo, &["add", "-A"]).await.unwrap();
1478        git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1479
1480        let before = rev_parse(&repo, "main").await.unwrap();
1481        let out = merge_ff_only(&repo, "side").await.unwrap();
1482        assert!(!out.ok(), "a divergent branch cannot fast-forward");
1483        assert_eq!(
1484            rev_parse(&repo, "main").await.unwrap(),
1485            before,
1486            "a refused fast-forward must not touch main"
1487        );
1488    }
1489
1490    #[tokio::test]
1491    async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
1492        let (guard, repo) = scratch().await;
1493
1494        // An attached worktree of an existing branch - the shape a winner's
1495        // worktree keeps in `graph::Runner`, not the detached checkouts used
1496        // for judges and reviewers.
1497        git(&repo, &["branch", "side"]).await.unwrap();
1498        let side_wt = guard.path().join("side-wt");
1499        git(
1500            &repo,
1501            &["worktree", "add", &side_wt.to_string_lossy(), "side"],
1502        )
1503        .await
1504        .unwrap();
1505        tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
1506            .await
1507            .unwrap();
1508        git(&side_wt, &["add", "-A"]).await.unwrap();
1509        git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
1510
1511        // main moves under it.
1512        git(&repo, &["checkout", "main"]).await.unwrap();
1513        tokio::fs::write(repo.join("c.txt"), "main\n")
1514            .await
1515            .unwrap();
1516        git(&repo, &["add", "-A"]).await.unwrap();
1517        git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
1518
1519        // Rebase from a throwaway worktree, never from `side_wt` itself.
1520        let scratch_tree = guard.path().join("rebase-scratch");
1521        let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
1522            .await
1523            .unwrap();
1524        assert!(clean.is_none());
1525
1526        // `HEAD` in the sibling worktree already resolves to the rebased
1527        // commit - the ref is shared - but nothing has told its index or its
1528        // files, which still hold the pre-rebase checkout.
1529        assert_eq!(
1530            rev_parse(&side_wt, "HEAD").await.unwrap(),
1531            rev_parse(&repo, "side").await.unwrap(),
1532            "HEAD follows the moved ref"
1533        );
1534        assert!(
1535            !side_wt.join("c.txt").exists(),
1536            "stale until synced: main's new file has not reached this worktree's disk"
1537        );
1538
1539        sync_to_head(&side_wt).await.unwrap();
1540        assert!(side_wt.join("c.txt").is_file(), "synced now");
1541        assert!(
1542            side_wt.join("b.txt").is_file(),
1543            "the worktree's own committed work survives the sync"
1544        );
1545        assert!(is_clean(&side_wt).await.unwrap());
1546    }
1547
1548    #[tokio::test]
1549    async fn clean_repo_reports_clean_then_dirty() {
1550        let (_g, repo) = scratch().await;
1551        assert!(is_clean(&repo).await.unwrap());
1552        tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
1553        assert!(!is_clean(&repo).await.unwrap());
1554    }
1555
1556    async fn track(repo: &Path, name: &str, body: &str) {
1557        let p = repo.join(name);
1558        if let Some(d) = p.parent() {
1559            tokio::fs::create_dir_all(d).await.unwrap();
1560        }
1561        tokio::fs::write(&p, body).await.unwrap();
1562        git(repo, &["add", name]).await.unwrap();
1563        git(
1564            repo,
1565            &[
1566                "-c",
1567                "user.name=t",
1568                "-c",
1569                "user.email=t@localhost",
1570                "commit",
1571                "-q",
1572                "-m",
1573                "seed",
1574            ],
1575        )
1576        .await
1577        .unwrap();
1578    }
1579
1580    #[tokio::test]
1581    async fn rescue_withholds_a_foreign_lockfile() {
1582        let (_g, repo) = scratch().await;
1583        track(&repo, "web/bun.lock", "a\n").await;
1584        tokio::fs::write(repo.join("web/pnpm-lock.yaml"), "x\n")
1585            .await
1586            .unwrap();
1587        tokio::fs::write(repo.join("web/app.ts"), "real\n")
1588            .await
1589            .unwrap();
1590
1591        let r = rescue_commit(&repo, "rescue").await.unwrap();
1592        assert!(r.committed);
1593        assert_eq!(
1594            r.withheld,
1595            [Stray {
1596                path: "web/pnpm-lock.yaml".to_owned(),
1597                manager: "pnpm".to_owned(),
1598                kept_by: "web/bun.lock".to_owned(),
1599            }]
1600        );
1601        let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1602            .await
1603            .unwrap();
1604        assert!(files.contains("web/app.ts"), "{files}");
1605        assert!(!files.contains("pnpm-lock"), "{files}");
1606        assert!(repo.join("web/pnpm-lock.yaml").is_file(), "not deleted");
1607    }
1608
1609    #[tokio::test]
1610    async fn rescue_with_only_a_stray_commits_nothing() {
1611        let (_g, repo) = scratch().await;
1612        track(&repo, "bun.lock", "a\n").await;
1613        tokio::fs::write(repo.join("yarn.lock"), "x\n")
1614            .await
1615            .unwrap();
1616        let r = rescue_commit(&repo, "rescue").await.unwrap();
1617        assert!(!r.committed);
1618        assert_eq!(r.withheld.len(), 1);
1619    }
1620
1621    #[tokio::test]
1622    async fn rescue_keeps_a_same_manager_lockfile_update() {
1623        let (_g, repo) = scratch().await;
1624        track(&repo, "bun.lock", "a\n").await;
1625        tokio::fs::write(repo.join("bun.lock"), "b\n")
1626            .await
1627            .unwrap();
1628        let r = rescue_commit(&repo, "rescue").await.unwrap();
1629        assert!(r.committed);
1630        assert!(r.withheld.is_empty());
1631        let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1632            .await
1633            .unwrap();
1634        assert_eq!(files, "bun.lock");
1635    }
1636
1637    #[tokio::test]
1638    async fn rescue_keeps_the_first_lockfile_in_a_bare_directory() {
1639        let (_g, repo) = scratch().await;
1640        track(&repo, "other/bun.lock", "a\n").await;
1641        tokio::fs::create_dir_all(repo.join("web")).await.unwrap();
1642        tokio::fs::write(repo.join("web/package-lock.json"), "{}\n")
1643            .await
1644            .unwrap();
1645        let r = rescue_commit(&repo, "rescue").await.unwrap();
1646        assert!(r.committed);
1647        assert!(r.withheld.is_empty());
1648    }
1649
1650    #[test]
1651    fn a_cargo_lock_is_foreign_only_without_a_cargo_toml() {
1652        let s = |v: &[&str]| v.iter().map(|x| (*x).to_owned()).collect::<Vec<_>>();
1653        assert_eq!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&[])).len(), 1);
1654        assert!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["a/Cargo.toml"])).is_empty());
1655        assert!(stray_lockfiles(&s(&["a/Cargo.lock", "a/Cargo.toml"]), &s(&[])).is_empty());
1656        // A manifest in another directory does not count.
1657        assert_eq!(
1658            stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["Cargo.toml"])).len(),
1659            1
1660        );
1661    }
1662
1663    #[tokio::test]
1664    async fn worktree_lifecycle_and_diff() {
1665        let (guard, repo) = scratch().await;
1666        let base = rev_parse(&repo, "HEAD").await.unwrap();
1667        let wt = guard.path().join("wt-a");
1668        worktree_add_branch(&repo, &wt, "magi/test/a", &base)
1669            .await
1670            .unwrap();
1671        tokio::fs::write(wt.join("b.txt"), "candidate\n")
1672            .await
1673            .unwrap();
1674
1675        assert!(commit_all(&wt, "candidate work").await.unwrap());
1676        assert!(!commit_all(&wt, "nothing left").await.unwrap());
1677
1678        assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
1679        let patch = diff(&wt, &base, "HEAD").await.unwrap();
1680        assert!(patch.contains("b.txt"), "patch was: {patch}");
1681        assert_eq!(
1682            changed_files(&wt, &base, "HEAD").await.unwrap(),
1683            ["b.txt".to_owned()]
1684        );
1685
1686        // The rescue commit must not carry the operator's identity.
1687        let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
1688            .await
1689            .unwrap();
1690        assert_eq!(author, "magi candidate <magi@localhost>");
1691
1692        assert!(worktree_remove(&repo, &wt).await.unwrap());
1693        assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
1694        assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
1695        assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
1696    }
1697
1698    #[tokio::test]
1699    async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
1700        let (guard, repo) = scratch().await;
1701        let base = rev_parse(&repo, "HEAD").await.unwrap();
1702        let wt = guard.path().join("wt-h");
1703        worktree_add_branch(&repo, &wt, "magi/test/h", &base)
1704            .await
1705            .unwrap();
1706        let hooks = guard.path().join("hooks");
1707        tokio::fs::create_dir_all(&hooks).await.unwrap();
1708
1709        assert!(enable_worktree_config(&repo).await.unwrap());
1710        set_worktree_hooks_path(&wt, &hooks).await.unwrap();
1711
1712        let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
1713            .await
1714            .unwrap();
1715        assert!(!in_wt.is_empty());
1716        let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
1717            .await
1718            .unwrap();
1719        assert!(
1720            !in_primary.ok(),
1721            "primary worktree must keep its own hooks: {in_primary:?}"
1722        );
1723
1724        disable_worktree_config(&repo).await.unwrap();
1725    }
1726
1727    #[tokio::test]
1728    async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
1729        let (_g, repo) = scratch().await;
1730
1731        // Two runs in the same repository, as `Config::daemon.max_concurrent_runs`
1732        // now allows: both acquire before either is done.
1733        acquire_worktree_config(&repo).await.unwrap();
1734        acquire_worktree_config(&repo).await.unwrap();
1735
1736        let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1737            .await
1738            .unwrap();
1739        assert_eq!(on, "true");
1740
1741        // The first run to finish releases its own reference. A plain
1742        // `disable_worktree_config` here is exactly the bug: it would turn
1743        // the setting off while the second run still depends on it.
1744        release_worktree_config(&repo).await.unwrap();
1745        let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1746            .await
1747            .unwrap();
1748        assert_eq!(
1749            still_on, "true",
1750            "a sibling run's release must not disable the setting for the one still working"
1751        );
1752
1753        // Only the last release actually turns it back off.
1754        release_worktree_config(&repo).await.unwrap();
1755        let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
1756            .await
1757            .unwrap();
1758        assert!(
1759            !after.ok(),
1760            "the last release must turn the setting back off: {after:?}"
1761        );
1762    }
1763
1764    #[tokio::test]
1765    async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
1766        let (_g, repo) = scratch().await;
1767        git(&repo, &["config", "extensions.worktreeConfig", "true"])
1768            .await
1769            .unwrap();
1770
1771        // magi did not turn this on, so even after every acquire is released,
1772        // it must not turn it off - that is what a bare `enable_worktree_config`
1773        // already promised for the single-run case, and the ref-counted
1774        // version must keep that promise.
1775        acquire_worktree_config(&repo).await.unwrap();
1776        release_worktree_config(&repo).await.unwrap();
1777
1778        let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1779            .await
1780            .unwrap();
1781        assert_eq!(still_on, "true");
1782    }
1783
1784    #[tokio::test]
1785    async fn local_exclude_is_idempotent() {
1786        let (_g, repo) = scratch().await;
1787        local_exclude(&repo, "/.magi/").await.unwrap();
1788        local_exclude(&repo, "/.magi/").await.unwrap();
1789        let path = repo.join(".git/info/exclude");
1790        let body = tokio::fs::read_to_string(&path).await.unwrap();
1791        assert_eq!(body.matches("/.magi/").count(), 1);
1792    }
1793
1794    fn sh(cwd: &Path, args: &[&str]) {
1795        let out = std::process::Command::new("git")
1796            .quiet()
1797            .args(args)
1798            .current_dir(cwd)
1799            .output()
1800            .unwrap();
1801        assert!(
1802            out.status.success(),
1803            "git {args:?}: {}",
1804            String::from_utf8_lossy(&out.stderr)
1805        );
1806    }
1807
1808    /// A checkout whose `origin` is a local bare repository holding `trunk`,
1809    /// with `origin/HEAD` unset (as after `git remote add` + push).
1810    async fn with_remote() -> (tempfile::TempDir, PathBuf) {
1811        let (g, repo) = scratch().await;
1812        let bare = g.path().join("bare.git");
1813        sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
1814        sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
1815        sh(&repo, &["push", "origin", "HEAD:refs/heads/trunk"]);
1816        sh(&repo, &["fetch", "origin"]);
1817        (g, repo)
1818    }
1819
1820    #[test]
1821    fn remote_head_branch_strips_only_the_matching_remote() {
1822        assert_eq!(
1823            remote_head_branch("origin", "refs/remotes/origin/main\n").as_deref(),
1824            Some("main")
1825        );
1826        assert_eq!(remote_head_branch("up", "refs/remotes/origin/main"), None);
1827        assert_eq!(remote_head_branch("origin", "refs/remotes/origin/"), None);
1828    }
1829
1830    #[tokio::test]
1831    async fn merge_base_branch_reads_origin_head_when_present() {
1832        let (_g, repo) = with_remote().await;
1833        sh(&repo, &["remote", "set-head", "origin", "trunk"]);
1834        assert_eq!(
1835            merge_base_branch(&repo, "origin", None).await.unwrap(),
1836            "trunk"
1837        );
1838    }
1839
1840    #[tokio::test]
1841    async fn merge_base_branch_recovers_a_missing_origin_head() {
1842        let (_g, repo) = with_remote().await;
1843        // Newer git sets it on fetch; make the precondition true everywhere.
1844        sh(&repo, &["remote", "set-head", "origin", "-d"]);
1845        let head = git_raw(
1846            &repo,
1847            &["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
1848        )
1849        .await
1850        .unwrap();
1851        assert!(!head.ok(), "precondition: no origin/HEAD");
1852        assert_eq!(
1853            merge_base_branch(&repo, "origin", None).await.unwrap(),
1854            "trunk"
1855        );
1856    }
1857
1858    #[tokio::test]
1859    async fn merge_base_branch_errors_without_a_remote_and_never_uses_head() {
1860        let (_g, repo) = scratch().await;
1861        let err = merge_base_branch(&repo, "origin", None).await.unwrap_err();
1862        assert!(format!("{err:#}").contains("[merge] base"), "{err:#}");
1863    }
1864
1865    #[tokio::test]
1866    async fn merge_base_branch_prefers_the_explicit_base() {
1867        let (_g, repo) = with_remote().await;
1868        assert_eq!(
1869            merge_base_branch(&repo, "origin", Some("release"))
1870                .await
1871                .unwrap(),
1872            "release"
1873        );
1874    }
1875
1876    #[tokio::test]
1877    async fn merge_base_branch_names_an_unfetched_default_branch() {
1878        // A remote added after the fact, never fetched: no tracking refs, so
1879        // `set-head -a` cannot work, but the remote's own HEAD still names it.
1880        let (g, repo) = scratch().await;
1881        let bare = g.path().join("bare.git");
1882        sh(g.path(), &["init", "--bare", "-b", "trunk", "bare.git"]);
1883        sh(
1884            &repo,
1885            &["push", &bare.to_string_lossy(), "HEAD:refs/heads/trunk"],
1886        );
1887        sh(&repo, &["remote", "add", "origin", &bare.to_string_lossy()]);
1888        assert!(!rev_exists(&repo, "refs/remotes/origin/trunk").await);
1889        assert_eq!(
1890            merge_base_branch(&repo, "origin", None).await.unwrap(),
1891            "trunk"
1892        );
1893    }
1894
1895    #[test]
1896    fn symref_branch_reads_ls_remote_output() {
1897        assert_eq!(
1898            symref_branch("ref: refs/heads/main\tHEAD\nabc\tHEAD\n").as_deref(),
1899            Some("main")
1900        );
1901        assert_eq!(symref_branch("abc\tHEAD\n"), None);
1902    }
1903}