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