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/// Run `git`, failing on a non-zero exit status.
54pub async fn git(cwd: &Path, args: &[&str]) -> Result<String> {
55    let out = git_raw(cwd, args).await?;
56    if !out.ok() {
57        bail!(
58            "git {} failed in {} (exit {:?}): {}",
59            args.join(" "),
60            cwd.display(),
61            out.code,
62            if out.stderr.is_empty() {
63                out.stdout.as_str()
64            } else {
65                out.stderr.as_str()
66            }
67        );
68    }
69    Ok(out.stdout)
70}
71
72/// Absolute path to the top level of the working tree containing `path`.
73pub async fn toplevel(path: &Path) -> Result<PathBuf> {
74    let out = git(path, &["rev-parse", "--show-toplevel"]).await?;
75    Ok(PathBuf::from(out))
76}
77
78/// Resolve a revision to a full object id.
79pub async fn rev_parse(repo: &Path, rev: &str) -> Result<String> {
80    git(repo, &["rev-parse", rev]).await
81}
82
83/// Currently checked-out branch, or `None` when detached.
84pub async fn current_branch(repo: &Path) -> Result<Option<String>> {
85    let out = git_raw(repo, &["symbolic-ref", "--quiet", "--short", "HEAD"]).await?;
86    Ok(if out.ok() && !out.stdout.is_empty() {
87        Some(out.stdout)
88    } else {
89        None
90    })
91}
92
93/// Is the working tree free of tracked modifications and untracked files?
94pub async fn is_clean(repo: &Path) -> Result<bool> {
95    Ok(git(repo, &["status", "--porcelain"]).await?.is_empty())
96}
97
98/// `git status --porcelain`, for reporting what is dirty.
99pub async fn status_porcelain(repo: &Path) -> Result<String> {
100    git(repo, &["status", "--porcelain"]).await
101}
102
103/// Create a worktree at `path` with a fresh branch `branch` starting at `base`.
104pub async fn worktree_add_branch(repo: &Path, path: &Path, branch: &str, base: &str) -> Result<()> {
105    if let Some(parent) = path.parent() {
106        tokio::fs::create_dir_all(parent).await.ok();
107    }
108    let path_s = path.to_string_lossy().to_string();
109    git(repo, &["worktree", "add", "-b", branch, &path_s, base])
110        .await
111        .map(|_| ())
112}
113
114/// Create a worktree at `path` with a detached HEAD at `rev`.
115pub async fn worktree_add_detached(repo: &Path, path: &Path, rev: &str) -> Result<()> {
116    if let Some(parent) = path.parent() {
117        tokio::fs::create_dir_all(parent).await.ok();
118    }
119    let path_s = path.to_string_lossy().to_string();
120    git(repo, &["worktree", "add", "--detach", &path_s, rev])
121        .await
122        .map(|_| ())
123}
124
125/// Move an existing detached worktree to `rev`, discarding local state.
126pub async fn reset_detached(worktree: &Path, rev: &str) -> Result<()> {
127    git(worktree, &["checkout", "--detach", rev]).await?;
128    git(worktree, &["reset", "--hard", rev]).await?;
129    git(worktree, &["clean", "-fdx"]).await?;
130    Ok(())
131}
132
133/// Remove a worktree. Returns `Ok(false)` when git refused (e.g. the path is
134/// already gone), so callers can keep folding the rest of a run.
135pub async fn worktree_remove(repo: &Path, path: &Path) -> Result<bool> {
136    let path_s = path.to_string_lossy().to_string();
137    let out = git_raw(repo, &["worktree", "remove", "--force", &path_s]).await?;
138    if out.ok() {
139        return Ok(true);
140    }
141    // A worktree whose directory was deleted by hand only needs pruning.
142    git_raw(repo, &["worktree", "prune"]).await?;
143    Ok(false)
144}
145
146/// Unregister a linked worktree whose directory is about to be deleted by
147/// hand, so the path can be `worktree add`-ed again.
148///
149/// A linked worktree's `.git` is a file whose `gitdir:` line names the
150/// bookkeeping entry inside its repository's admin directory; pruning from
151/// there removes the registration without touching the directory. No-op when
152/// `dir` is not a registered worktree (`.git` missing or not a `gitdir:`
153/// link): nothing was registered, nothing survives removal.
154pub async fn remove_worktree_from_linked(dir: &Path) {
155    let Ok(link) = std::fs::read_to_string(dir.join(".git")) else {
156        return;
157    };
158    let Some(admin) = link.strip_prefix("gitdir:").map(str::trim) else {
159        return;
160    };
161    // `<repo>/.git/worktrees/<name>`, so the repository's git dir is two
162    // levels up from here.
163    let admin = Path::new(admin);
164    let Some(common) = admin.parent().and_then(Path::parent) else {
165        return;
166    };
167    let common_s = common.to_string_lossy();
168    let _ = git_raw(dir, &["--git-dir", &common_s, "worktree", "prune"]).await;
169}
170
171/// Drop registrations for worktrees whose directory is already gone.
172///
173/// `git worktree remove` already does this for the path it just removed, but
174/// a directory deleted by hand - [`crate::clean::fold_orphaned_worktrees`], or
175/// an operator's own `rm -rf` - leaves the registration behind, and a
176/// registered path refuses a fresh `worktree add` until something prunes it.
177/// The operator's own machine had 31 such registrations sitting in one
178/// repository, all of them for directories that no longer existed.
179pub async fn worktree_prune(repo: &Path) -> Result<()> {
180    git(repo, &["worktree", "prune"]).await.map(|_| ())
181}
182
183/// Delete a branch, ignoring "not found".
184pub async fn branch_delete(repo: &Path, branch: &str) -> Result<bool> {
185    Ok(git_raw(repo, &["branch", "-D", branch]).await?.ok())
186}
187
188/// Does `branch` exist?
189pub async fn branch_exists(repo: &Path, branch: &str) -> Result<bool> {
190    let refname = format!("refs/heads/{branch}");
191    Ok(
192        git_raw(repo, &["show-ref", "--verify", "--quiet", &refname])
193            .await?
194            .ok(),
195    )
196}
197
198/// Patch of `head` against the merge base with `base`.
199pub async fn diff(worktree: &Path, base: &str, head: &str) -> Result<String> {
200    let range = format!("{base}...{head}");
201    git(
202        worktree,
203        &["diff", "--no-color", "--no-ext-diff", "-M", &range],
204    )
205    .await
206}
207
208/// `--stat` summary of `base...head`.
209pub async fn diff_stat(worktree: &Path, base: &str, head: &str) -> Result<String> {
210    let range = format!("{base}...{head}");
211    git(worktree, &["diff", "--no-color", "--stat", &range]).await
212}
213
214/// Number of files touched by `base...head`.
215pub async fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
216    let range = format!("{base}...{head}");
217    let out = git(worktree, &["diff", "--name-only", &range]).await?;
218    Ok(out.lines().map(str::to_owned).collect())
219}
220
221/// One-line log of `base..head`, oldest first.
222pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
223    let range = format!("{base}..{head}");
224    git(
225        worktree,
226        &["log", "--reverse", "--format=%s%n%b%n--", &range],
227    )
228    .await
229}
230
231/// How many commits `head` is ahead of `base`.
232pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
233    let range = format!("{base}..{head}");
234    let out = git(worktree, &["rev-list", "--count", &range]).await?;
235    Ok(out.trim().parse().unwrap_or(0))
236}
237
238/// Stage everything and commit under a neutral identity.
239///
240/// Used to rescue an agent that edited files but never committed: without this
241/// its candidate would silently be empty. The neutral identity is part of the
242/// blindness contract — a real `user.name` in a candidate's history would name
243/// the operator, and an agent-configured one would name the vendor.
244pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
245    if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
246        return Ok(false);
247    }
248    git(worktree, &["add", "-A"]).await?;
249    let out = git_raw(
250        worktree,
251        &[
252            "-c",
253            "user.name=magi candidate",
254            "-c",
255            "user.email=magi@localhost",
256            "commit",
257            "--no-verify",
258            "-m",
259            message,
260        ],
261    )
262    .await?;
263    if !out.ok() {
264        bail!("rescue commit failed: {}", out.stderr);
265    }
266    Ok(true)
267}
268
269/// A freshly created lockfile that belongs to a package manager the directory
270/// does not use, and was therefore left out of a rescue commit.
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct Stray {
273    /// Repo-relative path, forward slashes.
274    pub path: String,
275    /// The package manager the file belongs to (`pnpm`, `cargo`, ...).
276    pub manager: String,
277    /// What made it foreign: the tracked lockfile (or `Cargo.toml`'s absence)
278    /// that says which manager the directory really uses.
279    pub kept_by: String,
280}
281
282/// What [`rescue_commit`] did.
283#[derive(Debug, Default)]
284pub struct Rescue {
285    /// Whether a commit was made.
286    pub committed: bool,
287    /// Files left untracked in the worktree instead of being committed.
288    pub withheld: Vec<Stray>,
289}
290
291/// `(ecosystem, manager)` for a lockfile's file name.
292fn lock_kind(name: &str) -> Option<(&'static str, &'static str)> {
293    Some(match name {
294        "package-lock.json" | "npm-shrinkwrap.json" => ("node", "npm"),
295        "yarn.lock" => ("node", "yarn"),
296        "pnpm-lock.yaml" => ("node", "pnpm"),
297        "bun.lock" | "bun.lockb" => ("node", "bun"),
298        "poetry.lock" => ("python", "poetry"),
299        "uv.lock" => ("python", "uv"),
300        "Pipfile.lock" => ("python", "pipenv"),
301        "pdm.lock" => ("python", "pdm"),
302        "Cargo.lock" => ("rust", "cargo"),
303        _ => return None,
304    })
305}
306
307fn split_dir(path: &str) -> (&str, &str) {
308    path.rsplit_once('/').unwrap_or(("", path))
309}
310
311/// Which of the newly created `untracked` files are lockfiles of a manager the
312/// repo does not use in that directory.
313///
314/// Foreign means: a lockfile of the same ecosystem but another manager is
315/// already tracked *in the same directory* (no recursion — a workspace root and
316/// a sub-package may legitimately differ), or, for `Cargo.lock`, there is no
317/// `Cargo.toml` beside it. A first lockfile in a directory with none is normal.
318pub fn stray_lockfiles(untracked: &[String], tracked: &[String]) -> Vec<Stray> {
319    let mut out = Vec::new();
320    for path in untracked {
321        let (dir, name) = split_dir(path);
322        let Some((eco, manager)) = lock_kind(name) else {
323            continue;
324        };
325        let beside = |other: &String| split_dir(other).0 == dir;
326        let kept_by = if manager == "cargo" {
327            let has_manifest = tracked
328                .iter()
329                .chain(untracked)
330                .any(|p| beside(p) && split_dir(p).1 == "Cargo.toml");
331            if has_manifest {
332                continue;
333            }
334            "no Cargo.toml in the directory".to_owned()
335        } else {
336            let Some(other) = tracked.iter().find(|p| {
337                beside(p)
338                    && lock_kind(split_dir(p).1).is_some_and(|(e, m)| e == eco && m != manager)
339            }) else {
340                continue;
341            };
342            other.clone()
343        };
344        out.push(Stray {
345            path: path.clone(),
346            manager: manager.to_owned(),
347            kept_by,
348        });
349    }
350    out
351}
352
353async fn nul_list(worktree: &Path, args: &[&str]) -> Result<Vec<String>> {
354    let out = git(worktree, args).await?;
355    Ok(out
356        .split('\0')
357        .filter(|s| !s.is_empty())
358        .map(str::to_owned)
359        .collect())
360}
361
362/// [`commit_all`] for an agent's leftover work, minus stray foreign lockfiles.
363///
364/// The withheld files stay untracked in the worktree (nothing is deleted) and
365/// are returned so the caller can record them: silently dropping them could
366/// lose a file the task really asked for.
367pub async fn rescue_commit(worktree: &Path, message: &str) -> Result<Rescue> {
368    if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
369        return Ok(Rescue::default());
370    }
371    let untracked = nul_list(
372        worktree,
373        &["ls-files", "-z", "--others", "--exclude-standard"],
374    )
375    .await?;
376    let tracked = nul_list(worktree, &["ls-files", "-z"]).await?;
377    let withheld = stray_lockfiles(&untracked, &tracked);
378
379    git(worktree, &["add", "-A"]).await?;
380    if !withheld.is_empty() {
381        let mut args = vec!["reset", "-q", "--"];
382        args.extend(withheld.iter().map(|s| s.path.as_str()));
383        git(worktree, &args).await?;
384    }
385    if git_raw(worktree, &["diff", "--cached", "--quiet"])
386        .await?
387        .ok()
388    {
389        return Ok(Rescue {
390            committed: false,
391            withheld,
392        });
393    }
394    let out = git_raw(
395        worktree,
396        &[
397            "-c",
398            "user.name=magi candidate",
399            "-c",
400            "user.email=magi@localhost",
401            "commit",
402            "--no-verify",
403            "-m",
404            message,
405        ],
406    )
407    .await?;
408    if !out.ok() {
409        bail!("rescue commit failed: {}", out.stderr);
410    }
411    Ok(Rescue {
412        committed: true,
413        withheld,
414    })
415}
416
417/// Enable `extensions.worktreeConfig` if it is not already on.
418///
419/// Returns `true` when magi turned it on, so the caller can turn it back off
420/// during cleanup and leave the repo exactly as it found it.
421pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
422    let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
423    if out.ok() && out.stdout.trim() == "true" {
424        return Ok(false);
425    }
426    git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
427    Ok(true)
428}
429
430/// Undo [`enable_worktree_config`].
431pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
432    git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
433    Ok(())
434}
435
436/// How many runs currently want `extensions.worktreeConfig` on for one
437/// repository, and whether magi is the one that turned it on.
438struct WorktreeConfigRef {
439    /// Runs holding a reference, via [`acquire_worktree_config`].
440    count: usize,
441    /// Did *this process* flip the setting from off to on? If not - it was
442    /// already `true` when the first run in this process asked - nothing
443    /// here ever turns it off either; that is what [`enable_worktree_config`]
444    /// already decided for the single-run case, and the ref-counted version
445    /// must not second-guess it.
446    we_enabled: bool,
447}
448
449/// One entry per repository, each guarded by its own `tokio::sync::Mutex` so
450/// that two repositories' acquisitions never wait on each other - only two
451/// runs in the *same* repository do, which is the point.
452///
453/// A `std::sync::Mutex` guards the map itself, held only long enough to find
454/// or insert an entry and clone its `Arc`, never across an `.await`.
455static WORKTREE_CONFIG: std::sync::LazyLock<
456    std::sync::Mutex<
457        std::collections::HashMap<PathBuf, std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>>>,
458    >,
459> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
460
461/// The per-repository slot, creating it if this is the first run to ask.
462fn worktree_config_slot(repo: &Path) -> std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>> {
463    let mut map = WORKTREE_CONFIG
464        .lock()
465        .unwrap_or_else(std::sync::PoisonError::into_inner);
466    map.entry(repo.to_path_buf())
467        .or_insert_with(|| {
468            std::sync::Arc::new(tokio::sync::Mutex::new(WorktreeConfigRef {
469                count: 0,
470                we_enabled: false,
471            }))
472        })
473        .clone()
474}
475
476/// Take a reference on `extensions.worktreeConfig` being on for `repo`.
477///
478/// [`enable_worktree_config`] alone is only safe for one run in a repository
479/// at a time: it is a plain get-then-set, so a second run's "already true?"
480/// check can see the first run's write and conclude it owns nothing to turn
481/// back off, while the first run's own cleanup turns the setting off under
482/// the second run's feet the moment *it* finishes - the exact race that let a
483/// finished run's fold disable the hook a still-running sibling in the same
484/// repository depended on. This ref-counts instead: the setting is turned on
485/// once, by whichever caller is first, and turned off only once every caller
486/// has released it via [`release_worktree_config`].
487///
488/// The per-repository lock is held across the `git config` call for the
489/// first acquire, so a second, concurrent acquire for the same repository
490/// waits for it rather than racing it - without that, both could observe
491/// "not yet counted" and both try to flip the setting on.
492pub async fn acquire_worktree_config(repo: &Path) -> Result<()> {
493    let slot = worktree_config_slot(repo);
494    let mut entry = slot.lock().await;
495    entry.count += 1;
496    if entry.count == 1 {
497        entry.we_enabled = enable_worktree_config(repo).await?;
498    }
499    Ok(())
500}
501
502/// Release a reference taken by [`acquire_worktree_config`].
503///
504/// Only the last release for a repository actually calls
505/// [`disable_worktree_config`], and only when this process was the one that
506/// turned the setting on in the first place.
507pub async fn release_worktree_config(repo: &Path) -> Result<()> {
508    let slot = worktree_config_slot(repo);
509    let mut entry = slot.lock().await;
510    entry.count = entry.count.saturating_sub(1);
511    if entry.count == 0 && entry.we_enabled {
512        disable_worktree_config(repo).await?;
513        entry.we_enabled = false;
514    }
515    Ok(())
516}
517
518/// Point a single worktree at its own hooks directory.
519///
520/// `core.hooksPath` is normally repo-wide; scoping it with `--worktree` keeps
521/// the operator's own hooks untouched in the primary worktree, and the setting
522/// disappears together with the worktree.
523pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
524    let dir = hooks_dir.to_string_lossy().replace('\\', "/");
525    git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
526        .await
527        .map(|_| ())
528}
529
530/// Exclude a path from a worktree's status without touching `.gitignore`.
531pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
532    let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
533    let path = worktree.join(git_dir);
534    if let Some(parent) = path.parent() {
535        tokio::fs::create_dir_all(parent).await.ok();
536    }
537    let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
538    if body.lines().any(|l| l.trim() == pattern) {
539        return Ok(());
540    }
541    if !body.is_empty() && !body.ends_with('\n') {
542        body.push('\n');
543    }
544    body.push_str(pattern);
545    body.push('\n');
546    tokio::fs::write(&path, body)
547        .await
548        .with_context(|| format!("write {}", path.display()))?;
549    Ok(())
550}
551
552/// `git merge --no-ff` of `branch` into the currently checked-out branch.
553///
554/// One of three ways to land a branch driven by [`crate::config::MergeStyle`]
555/// — see [`merge_squash`] and [`merge_ff_only`] for the other two, and that
556/// enum's own doc for why the choice between them lives in configuration.
557pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
558    git_raw(
559        repo,
560        &["merge", "--no-ff", "--no-edit", "-m", message, branch],
561    )
562    .await
563}
564
565/// `git merge --squash` of `branch`, followed by a commit under `message`.
566///
567/// Two `git` calls because `--squash` only stages the result — unlike
568/// [`merge_no_ff`] there is no merge commit for `--no-edit` to write, and
569/// skipping the second call is exactly the trap `land`'s module doc warns
570/// about: a squash that inherits `branch`'s own single-commit subject
571/// (`magi: candidate A (uncommitted work)`) instead of `message`. Returns the
572/// `--squash` step's own output, unrun `commit` included, when staging itself
573/// fails (a conflict), so a caller sees what actually went wrong rather than
574/// a `git commit` complaint about nothing being staged.
575pub async fn merge_squash(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
576    let staged = git_raw(repo, &["merge", "--squash", branch]).await?;
577    if !staged.ok() {
578        return Ok(staged);
579    }
580    git_raw(repo, &["commit", "-m", message]).await
581}
582
583/// Fast-forward `branch` into the currently checked-out branch, refusing to
584/// create a merge commit.
585///
586/// Only ever fast-forwards because the winner was already rebased onto the
587/// tracked base tip before this runs (`Runner::sync_to_base`); at that point
588/// `--ff-only` is indistinguishable from GitHub's "rebase and merge" button.
589/// If the base moved again in the meantime this fails rather than falling
590/// back to a real rebase, the same way `merge_no_ff` fails rather than
591/// resolving a conflict — landing is not the place to improvise.
592pub async fn merge_ff_only(repo: &Path, branch: &str) -> Result<GitOut> {
593    git_raw(repo, &["merge", "--ff-only", branch]).await
594}
595
596/// Push a branch to `remote`.
597pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
598    git_raw(repo, &["push", "-u", remote, branch]).await
599}
600
601/// Force-push a branch that has been rewritten, refusing to clobber work
602/// pushed since this side last looked.
603///
604/// `--force-with-lease` rather than `--force`: a rebase replaces the branch's
605/// commits, so a plain push is rejected, but a blind force would also throw
606/// away anything a person pushed to the same branch meanwhile. The lease
607/// turns that case into a failure instead of a loss.
608pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
609    git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
610}
611
612/// Rebase a branch onto `onto`, inside a throwaway worktree.
613///
614/// A worktree of its own for two reasons. The repository magi runs in may be
615/// jj-colocated, where git `HEAD` is detached and a rebase in the primary
616/// tree would move it under the operator; and a rebase that hits a conflict
617/// leaves state behind, which is far easier to discard with the whole
618/// directory than to unpick in a tree somebody is using.
619///
620/// `Ok(None)` means it applied and the branch now points at the rebased
621/// commits. `Ok(Some(why))` means it did not: the branch is untouched, and
622/// the string is what git said - a person has to decide.
623pub async fn rebase_branch_in_temp(
624    repo: &Path,
625    scratch: &Path,
626    branch: &str,
627    onto: &str,
628) -> Result<Option<String>> {
629    // Removed first so a leftover from an interrupted attempt cannot make
630    // `worktree add` fail on a path that already exists.
631    worktree_remove(repo, scratch).await.ok();
632    git_raw(
633        repo,
634        &[
635            "worktree",
636            "add",
637            "--force",
638            &scratch.to_string_lossy(),
639            branch,
640        ],
641    )
642    .await?;
643
644    let out = git_raw(scratch, &["rebase", onto]).await?;
645    if out.ok() {
646        worktree_remove(repo, scratch).await.ok();
647        return Ok(None);
648    }
649    // Leave nothing half-rebased behind: abort, then drop the tree entirely.
650    git_raw(scratch, &["rebase", "--abort"]).await.ok();
651    let why = if out.stderr.trim().is_empty() {
652        out.stdout.trim().to_owned()
653    } else {
654        out.stderr.trim().to_owned()
655    };
656    worktree_remove(repo, scratch).await.ok();
657    Ok(Some(why))
658}
659
660/// Bring an *attached* worktree's index and files in line with wherever its
661/// branch now points.
662///
663/// [`rebase_branch_in_temp`] moves a branch from a throwaway worktree on
664/// purpose - the whole point is never touching the tree someone else has
665/// checked out. But a worktree that already had that branch checked out
666/// shares the same ref: its `HEAD` resolves to the new commit the moment the
667/// rebase lands elsewhere, while its index and working directory keep
668/// whatever the old commit put there until something says otherwise. Left
669/// alone, the next `git status` there reads as the whole rebase turning up
670/// as an unstaged diff, and the next commit would be staged against stale
671/// content.
672pub async fn sync_to_head(worktree: &Path) -> Result<()> {
673    git(worktree, &["reset", "--hard", "HEAD"]).await?;
674    git(worktree, &["clean", "-fdx"]).await?;
675    Ok(())
676}
677
678/// Fetch one branch from `remote`, updating its remote-tracking ref.
679///
680/// The refspec is spelled out rather than left to `git fetch <remote>
681/// <branch>`, which writes `FETCH_HEAD` and updates
682/// `refs/remotes/<remote>/<branch>` only as a side effect of the remote's
683/// configured refspec. Naming the destination makes the thing this function
684/// exists for - a tracking ref that moved - the operation rather than a
685/// consequence of configuration magi does not own.
686///
687/// Honest note: a CI failure was first read as proof that some git versions do
688/// not update the tracking ref here. That was wrong - the fetch had nothing to
689/// update because the test had pushed to the wrong branch - so this is
690/// determinism, not a fix for a demonstrated portability bug.
691///
692/// Refs, not the working copy: nothing is checked out and no local branch
693/// moves, so this is safe to run while the operator has uncommitted work.
694/// Returned as a [`GitOut`] rather than an error so the caller can decide - a
695/// machine with no network must still be able to start a run.
696pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
697    let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
698    git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
699}
700
701/// Does this ref resolve?
702pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
703    git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
704        .await
705        .is_ok_and(|o| o.ok())
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711
712    async fn scratch() -> (tempfile::TempDir, PathBuf) {
713        let dir = tempfile::tempdir().unwrap();
714        let repo = dir.path().join("repo");
715        tokio::fs::create_dir_all(&repo).await.unwrap();
716        git(&repo, &["init", "-b", "main"]).await.unwrap();
717        git(&repo, &["config", "user.name", "test"]).await.unwrap();
718        git(&repo, &["config", "user.email", "test@example.com"])
719            .await
720            .unwrap();
721        tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
722        git(&repo, &["add", "-A"]).await.unwrap();
723        git(&repo, &["commit", "-m", "init"]).await.unwrap();
724        (dir, repo)
725    }
726
727    #[tokio::test]
728    async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
729        let (_g, repo) = scratch().await;
730
731        // A side branch touching a different file: rebases cleanly.
732        git(&repo, &["checkout", "-b", "side"]).await.unwrap();
733        tokio::fs::write(repo.join("b.txt"), "side\n")
734            .await
735            .unwrap();
736        git(&repo, &["add", "-A"]).await.unwrap();
737        git(&repo, &["commit", "-m", "side work"]).await.unwrap();
738
739        // main moves under it, which is what a repository merging other
740        // pull requests does to a competition that took two hours.
741        git(&repo, &["checkout", "main"]).await.unwrap();
742        tokio::fs::write(repo.join("c.txt"), "main\n")
743            .await
744            .unwrap();
745        git(&repo, &["add", "-A"]).await.unwrap();
746        git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
747
748        let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
749        let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
750            .await
751            .unwrap();
752        assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
753        assert_eq!(
754            commits_ahead(&repo, "main", "side").await.unwrap(),
755            1,
756            "one commit, replayed onto the new base"
757        );
758        assert!(
759            !scratch_tree.exists(),
760            "the throwaway worktree is not left behind"
761        );
762
763        // A real conflict: both sides edit the same line.
764        git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
765        tokio::fs::write(repo.join("a.txt"), "clash\n")
766            .await
767            .unwrap();
768        git(&repo, &["add", "-A"]).await.unwrap();
769        git(&repo, &["commit", "-m", "clash"]).await.unwrap();
770        git(&repo, &["checkout", "main"]).await.unwrap();
771        tokio::fs::write(repo.join("a.txt"), "main edit\n")
772            .await
773            .unwrap();
774        git(&repo, &["add", "-A"]).await.unwrap();
775        git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
776
777        let before = rev_parse(&repo, "clash").await.unwrap();
778        let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
779            .await
780            .unwrap()
781            .expect("a same-line clash cannot be rebased silently");
782        assert!(
783            why.to_lowercase().contains("conflict"),
784            "the reason is what git said, which is what a person needs: {why}"
785        );
786        assert_eq!(
787            rev_parse(&repo, "clash").await.unwrap(),
788            before,
789            "a failed rebase leaves the branch exactly where it was"
790        );
791        assert!(!scratch_tree.exists(), "and cleans up after itself");
792    }
793
794    #[tokio::test]
795    async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
796        let (_g, repo) = scratch().await;
797        git(&repo, &["checkout", "-b", "side"]).await.unwrap();
798        for name in ["b.txt", "c.txt"] {
799            tokio::fs::write(repo.join(name), "side\n").await.unwrap();
800            git(&repo, &["add", "-A"]).await.unwrap();
801            git(
802                &repo,
803                &["commit", "-m", "magi: candidate A (uncommitted work)"],
804            )
805            .await
806            .unwrap();
807        }
808        git(&repo, &["checkout", "main"]).await.unwrap();
809        let before = rev_parse(&repo, "main").await.unwrap();
810
811        let out = merge_squash(&repo, "side", "an explicit subject")
812            .await
813            .unwrap();
814        assert!(out.ok(), "{}", out.stderr);
815        assert_eq!(
816            commits_ahead(&repo, &before, "main").await.unwrap(),
817            1,
818            "squash adds exactly one commit onto the tip, not one per candidate commit"
819        );
820        let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
821        assert_eq!(
822            subject, "an explicit subject",
823            "the candidate's own placeholder subject must not survive: {subject}"
824        );
825    }
826
827    #[tokio::test]
828    async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
829        let (_g, repo) = scratch().await;
830        git(&repo, &["checkout", "-b", "side"]).await.unwrap();
831        tokio::fs::write(repo.join("b.txt"), "side\n")
832            .await
833            .unwrap();
834        git(&repo, &["add", "-A"]).await.unwrap();
835        git(&repo, &["commit", "-m", "side work"]).await.unwrap();
836        git(&repo, &["checkout", "main"]).await.unwrap();
837
838        let before = rev_parse(&repo, "side").await.unwrap();
839        let out = merge_ff_only(&repo, "side").await.unwrap();
840        assert!(out.ok(), "{}", out.stderr);
841        assert_eq!(
842            rev_parse(&repo, "main").await.unwrap(),
843            before,
844            "a fast-forward moves the base tip to the branch, no merge commit"
845        );
846    }
847
848    #[tokio::test]
849    async fn merge_ff_only_refuses_to_write_a_merge_commit() {
850        let (_g, repo) = scratch().await;
851        git(&repo, &["checkout", "-b", "side"]).await.unwrap();
852        tokio::fs::write(repo.join("b.txt"), "side\n")
853            .await
854            .unwrap();
855        git(&repo, &["add", "-A"]).await.unwrap();
856        git(&repo, &["commit", "-m", "side work"]).await.unwrap();
857
858        // main diverges, so a fast-forward is no longer possible.
859        git(&repo, &["checkout", "main"]).await.unwrap();
860        tokio::fs::write(repo.join("c.txt"), "main\n")
861            .await
862            .unwrap();
863        git(&repo, &["add", "-A"]).await.unwrap();
864        git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
865
866        let before = rev_parse(&repo, "main").await.unwrap();
867        let out = merge_ff_only(&repo, "side").await.unwrap();
868        assert!(!out.ok(), "a divergent branch cannot fast-forward");
869        assert_eq!(
870            rev_parse(&repo, "main").await.unwrap(),
871            before,
872            "a refused fast-forward must not touch main"
873        );
874    }
875
876    #[tokio::test]
877    async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
878        let (guard, repo) = scratch().await;
879
880        // An attached worktree of an existing branch - the shape a winner's
881        // worktree keeps in `graph::Runner`, not the detached checkouts used
882        // for judges and reviewers.
883        git(&repo, &["branch", "side"]).await.unwrap();
884        let side_wt = guard.path().join("side-wt");
885        git(
886            &repo,
887            &["worktree", "add", &side_wt.to_string_lossy(), "side"],
888        )
889        .await
890        .unwrap();
891        tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
892            .await
893            .unwrap();
894        git(&side_wt, &["add", "-A"]).await.unwrap();
895        git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
896
897        // main moves under it.
898        git(&repo, &["checkout", "main"]).await.unwrap();
899        tokio::fs::write(repo.join("c.txt"), "main\n")
900            .await
901            .unwrap();
902        git(&repo, &["add", "-A"]).await.unwrap();
903        git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
904
905        // Rebase from a throwaway worktree, never from `side_wt` itself.
906        let scratch_tree = guard.path().join("rebase-scratch");
907        let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
908            .await
909            .unwrap();
910        assert!(clean.is_none());
911
912        // `HEAD` in the sibling worktree already resolves to the rebased
913        // commit - the ref is shared - but nothing has told its index or its
914        // files, which still hold the pre-rebase checkout.
915        assert_eq!(
916            rev_parse(&side_wt, "HEAD").await.unwrap(),
917            rev_parse(&repo, "side").await.unwrap(),
918            "HEAD follows the moved ref"
919        );
920        assert!(
921            !side_wt.join("c.txt").exists(),
922            "stale until synced: main's new file has not reached this worktree's disk"
923        );
924
925        sync_to_head(&side_wt).await.unwrap();
926        assert!(side_wt.join("c.txt").is_file(), "synced now");
927        assert!(
928            side_wt.join("b.txt").is_file(),
929            "the worktree's own committed work survives the sync"
930        );
931        assert!(is_clean(&side_wt).await.unwrap());
932    }
933
934    #[tokio::test]
935    async fn clean_repo_reports_clean_then_dirty() {
936        let (_g, repo) = scratch().await;
937        assert!(is_clean(&repo).await.unwrap());
938        tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
939        assert!(!is_clean(&repo).await.unwrap());
940    }
941
942    async fn track(repo: &Path, name: &str, body: &str) {
943        let p = repo.join(name);
944        if let Some(d) = p.parent() {
945            tokio::fs::create_dir_all(d).await.unwrap();
946        }
947        tokio::fs::write(&p, body).await.unwrap();
948        git(repo, &["add", name]).await.unwrap();
949        git(
950            repo,
951            &[
952                "-c",
953                "user.name=t",
954                "-c",
955                "user.email=t@localhost",
956                "commit",
957                "-q",
958                "-m",
959                "seed",
960            ],
961        )
962        .await
963        .unwrap();
964    }
965
966    #[tokio::test]
967    async fn rescue_withholds_a_foreign_lockfile() {
968        let (_g, repo) = scratch().await;
969        track(&repo, "web/bun.lock", "a\n").await;
970        tokio::fs::write(repo.join("web/pnpm-lock.yaml"), "x\n")
971            .await
972            .unwrap();
973        tokio::fs::write(repo.join("web/app.ts"), "real\n")
974            .await
975            .unwrap();
976
977        let r = rescue_commit(&repo, "rescue").await.unwrap();
978        assert!(r.committed);
979        assert_eq!(
980            r.withheld,
981            [Stray {
982                path: "web/pnpm-lock.yaml".to_owned(),
983                manager: "pnpm".to_owned(),
984                kept_by: "web/bun.lock".to_owned(),
985            }]
986        );
987        let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
988            .await
989            .unwrap();
990        assert!(files.contains("web/app.ts"), "{files}");
991        assert!(!files.contains("pnpm-lock"), "{files}");
992        assert!(repo.join("web/pnpm-lock.yaml").is_file(), "not deleted");
993    }
994
995    #[tokio::test]
996    async fn rescue_with_only_a_stray_commits_nothing() {
997        let (_g, repo) = scratch().await;
998        track(&repo, "bun.lock", "a\n").await;
999        tokio::fs::write(repo.join("yarn.lock"), "x\n")
1000            .await
1001            .unwrap();
1002        let r = rescue_commit(&repo, "rescue").await.unwrap();
1003        assert!(!r.committed);
1004        assert_eq!(r.withheld.len(), 1);
1005    }
1006
1007    #[tokio::test]
1008    async fn rescue_keeps_a_same_manager_lockfile_update() {
1009        let (_g, repo) = scratch().await;
1010        track(&repo, "bun.lock", "a\n").await;
1011        tokio::fs::write(repo.join("bun.lock"), "b\n")
1012            .await
1013            .unwrap();
1014        let r = rescue_commit(&repo, "rescue").await.unwrap();
1015        assert!(r.committed);
1016        assert!(r.withheld.is_empty());
1017        let files = git(&repo, &["show", "--name-only", "--format=", "HEAD"])
1018            .await
1019            .unwrap();
1020        assert_eq!(files, "bun.lock");
1021    }
1022
1023    #[tokio::test]
1024    async fn rescue_keeps_the_first_lockfile_in_a_bare_directory() {
1025        let (_g, repo) = scratch().await;
1026        track(&repo, "other/bun.lock", "a\n").await;
1027        tokio::fs::create_dir_all(repo.join("web")).await.unwrap();
1028        tokio::fs::write(repo.join("web/package-lock.json"), "{}\n")
1029            .await
1030            .unwrap();
1031        let r = rescue_commit(&repo, "rescue").await.unwrap();
1032        assert!(r.committed);
1033        assert!(r.withheld.is_empty());
1034    }
1035
1036    #[test]
1037    fn a_cargo_lock_is_foreign_only_without_a_cargo_toml() {
1038        let s = |v: &[&str]| v.iter().map(|x| (*x).to_owned()).collect::<Vec<_>>();
1039        assert_eq!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&[])).len(), 1);
1040        assert!(stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["a/Cargo.toml"])).is_empty());
1041        assert!(stray_lockfiles(&s(&["a/Cargo.lock", "a/Cargo.toml"]), &s(&[])).is_empty());
1042        // A manifest in another directory does not count.
1043        assert_eq!(
1044            stray_lockfiles(&s(&["a/Cargo.lock"]), &s(&["Cargo.toml"])).len(),
1045            1
1046        );
1047    }
1048
1049    #[tokio::test]
1050    async fn worktree_lifecycle_and_diff() {
1051        let (guard, repo) = scratch().await;
1052        let base = rev_parse(&repo, "HEAD").await.unwrap();
1053        let wt = guard.path().join("wt-a");
1054        worktree_add_branch(&repo, &wt, "magi/test/a", &base)
1055            .await
1056            .unwrap();
1057        tokio::fs::write(wt.join("b.txt"), "candidate\n")
1058            .await
1059            .unwrap();
1060
1061        assert!(commit_all(&wt, "candidate work").await.unwrap());
1062        assert!(!commit_all(&wt, "nothing left").await.unwrap());
1063
1064        assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
1065        let patch = diff(&wt, &base, "HEAD").await.unwrap();
1066        assert!(patch.contains("b.txt"), "patch was: {patch}");
1067        assert_eq!(
1068            changed_files(&wt, &base, "HEAD").await.unwrap(),
1069            ["b.txt".to_owned()]
1070        );
1071
1072        // The rescue commit must not carry the operator's identity.
1073        let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
1074            .await
1075            .unwrap();
1076        assert_eq!(author, "magi candidate <magi@localhost>");
1077
1078        assert!(worktree_remove(&repo, &wt).await.unwrap());
1079        assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
1080        assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
1081        assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
1082    }
1083
1084    #[tokio::test]
1085    async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
1086        let (guard, repo) = scratch().await;
1087        let base = rev_parse(&repo, "HEAD").await.unwrap();
1088        let wt = guard.path().join("wt-h");
1089        worktree_add_branch(&repo, &wt, "magi/test/h", &base)
1090            .await
1091            .unwrap();
1092        let hooks = guard.path().join("hooks");
1093        tokio::fs::create_dir_all(&hooks).await.unwrap();
1094
1095        assert!(enable_worktree_config(&repo).await.unwrap());
1096        set_worktree_hooks_path(&wt, &hooks).await.unwrap();
1097
1098        let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
1099            .await
1100            .unwrap();
1101        assert!(!in_wt.is_empty());
1102        let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
1103            .await
1104            .unwrap();
1105        assert!(
1106            !in_primary.ok(),
1107            "primary worktree must keep its own hooks: {in_primary:?}"
1108        );
1109
1110        disable_worktree_config(&repo).await.unwrap();
1111    }
1112
1113    #[tokio::test]
1114    async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
1115        let (_g, repo) = scratch().await;
1116
1117        // Two runs in the same repository, as `Config::daemon.max_concurrent_runs`
1118        // now allows: both acquire before either is done.
1119        acquire_worktree_config(&repo).await.unwrap();
1120        acquire_worktree_config(&repo).await.unwrap();
1121
1122        let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1123            .await
1124            .unwrap();
1125        assert_eq!(on, "true");
1126
1127        // The first run to finish releases its own reference. A plain
1128        // `disable_worktree_config` here is exactly the bug: it would turn
1129        // the setting off while the second run still depends on it.
1130        release_worktree_config(&repo).await.unwrap();
1131        let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1132            .await
1133            .unwrap();
1134        assert_eq!(
1135            still_on, "true",
1136            "a sibling run's release must not disable the setting for the one still working"
1137        );
1138
1139        // Only the last release actually turns it back off.
1140        release_worktree_config(&repo).await.unwrap();
1141        let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
1142            .await
1143            .unwrap();
1144        assert!(
1145            !after.ok(),
1146            "the last release must turn the setting back off: {after:?}"
1147        );
1148    }
1149
1150    #[tokio::test]
1151    async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
1152        let (_g, repo) = scratch().await;
1153        git(&repo, &["config", "extensions.worktreeConfig", "true"])
1154            .await
1155            .unwrap();
1156
1157        // magi did not turn this on, so even after every acquire is released,
1158        // it must not turn it off - that is what a bare `enable_worktree_config`
1159        // already promised for the single-run case, and the ref-counted
1160        // version must keep that promise.
1161        acquire_worktree_config(&repo).await.unwrap();
1162        release_worktree_config(&repo).await.unwrap();
1163
1164        let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
1165            .await
1166            .unwrap();
1167        assert_eq!(still_on, "true");
1168    }
1169
1170    #[tokio::test]
1171    async fn local_exclude_is_idempotent() {
1172        let (_g, repo) = scratch().await;
1173        local_exclude(&repo, "/.magi/").await.unwrap();
1174        local_exclude(&repo, "/.magi/").await.unwrap();
1175        let path = repo.join(".git/info/exclude");
1176        let body = tokio::fs::read_to_string(&path).await.unwrap();
1177        assert_eq!(body.matches("/.magi/").count(), 1);
1178    }
1179}