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