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