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/// Delete a branch, ignoring "not found".
172pub async fn branch_delete(repo: &Path, branch: &str) -> Result<bool> {
173    Ok(git_raw(repo, &["branch", "-D", branch]).await?.ok())
174}
175
176/// Does `branch` exist?
177pub async fn branch_exists(repo: &Path, branch: &str) -> Result<bool> {
178    let refname = format!("refs/heads/{branch}");
179    Ok(
180        git_raw(repo, &["show-ref", "--verify", "--quiet", &refname])
181            .await?
182            .ok(),
183    )
184}
185
186/// Patch of `head` against the merge base with `base`.
187pub async fn diff(worktree: &Path, base: &str, head: &str) -> Result<String> {
188    let range = format!("{base}...{head}");
189    git(
190        worktree,
191        &["diff", "--no-color", "--no-ext-diff", "-M", &range],
192    )
193    .await
194}
195
196/// `--stat` summary of `base...head`.
197pub async fn diff_stat(worktree: &Path, base: &str, head: &str) -> Result<String> {
198    let range = format!("{base}...{head}");
199    git(worktree, &["diff", "--no-color", "--stat", &range]).await
200}
201
202/// Number of files touched by `base...head`.
203pub async fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
204    let range = format!("{base}...{head}");
205    let out = git(worktree, &["diff", "--name-only", &range]).await?;
206    Ok(out.lines().map(str::to_owned).collect())
207}
208
209/// One-line log of `base..head`, oldest first.
210pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
211    let range = format!("{base}..{head}");
212    git(
213        worktree,
214        &["log", "--reverse", "--format=%s%n%b%n--", &range],
215    )
216    .await
217}
218
219/// How many commits `head` is ahead of `base`.
220pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
221    let range = format!("{base}..{head}");
222    let out = git(worktree, &["rev-list", "--count", &range]).await?;
223    Ok(out.trim().parse().unwrap_or(0))
224}
225
226/// Stage everything and commit under a neutral identity.
227///
228/// Used to rescue an agent that edited files but never committed: without this
229/// its candidate would silently be empty. The neutral identity is part of the
230/// blindness contract — a real `user.name` in a candidate's history would name
231/// the operator, and an agent-configured one would name the vendor.
232pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
233    if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
234        return Ok(false);
235    }
236    git(worktree, &["add", "-A"]).await?;
237    let out = git_raw(
238        worktree,
239        &[
240            "-c",
241            "user.name=magi candidate",
242            "-c",
243            "user.email=magi@localhost",
244            "commit",
245            "--no-verify",
246            "-m",
247            message,
248        ],
249    )
250    .await?;
251    if !out.ok() {
252        bail!("rescue commit failed: {}", out.stderr);
253    }
254    Ok(true)
255}
256
257/// Enable `extensions.worktreeConfig` if it is not already on.
258///
259/// Returns `true` when magi turned it on, so the caller can turn it back off
260/// during cleanup and leave the repo exactly as it found it.
261pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
262    let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
263    if out.ok() && out.stdout.trim() == "true" {
264        return Ok(false);
265    }
266    git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
267    Ok(true)
268}
269
270/// Undo [`enable_worktree_config`].
271pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
272    git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
273    Ok(())
274}
275
276/// How many runs currently want `extensions.worktreeConfig` on for one
277/// repository, and whether magi is the one that turned it on.
278struct WorktreeConfigRef {
279    /// Runs holding a reference, via [`acquire_worktree_config`].
280    count: usize,
281    /// Did *this process* flip the setting from off to on? If not - it was
282    /// already `true` when the first run in this process asked - nothing
283    /// here ever turns it off either; that is what [`enable_worktree_config`]
284    /// already decided for the single-run case, and the ref-counted version
285    /// must not second-guess it.
286    we_enabled: bool,
287}
288
289/// One entry per repository, each guarded by its own `tokio::sync::Mutex` so
290/// that two repositories' acquisitions never wait on each other - only two
291/// runs in the *same* repository do, which is the point.
292///
293/// A `std::sync::Mutex` guards the map itself, held only long enough to find
294/// or insert an entry and clone its `Arc`, never across an `.await`.
295static WORKTREE_CONFIG: std::sync::LazyLock<
296    std::sync::Mutex<
297        std::collections::HashMap<PathBuf, std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>>>,
298    >,
299> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
300
301/// The per-repository slot, creating it if this is the first run to ask.
302fn worktree_config_slot(repo: &Path) -> std::sync::Arc<tokio::sync::Mutex<WorktreeConfigRef>> {
303    let mut map = WORKTREE_CONFIG
304        .lock()
305        .unwrap_or_else(std::sync::PoisonError::into_inner);
306    map.entry(repo.to_path_buf())
307        .or_insert_with(|| {
308            std::sync::Arc::new(tokio::sync::Mutex::new(WorktreeConfigRef {
309                count: 0,
310                we_enabled: false,
311            }))
312        })
313        .clone()
314}
315
316/// Take a reference on `extensions.worktreeConfig` being on for `repo`.
317///
318/// [`enable_worktree_config`] alone is only safe for one run in a repository
319/// at a time: it is a plain get-then-set, so a second run's "already true?"
320/// check can see the first run's write and conclude it owns nothing to turn
321/// back off, while the first run's own cleanup turns the setting off under
322/// the second run's feet the moment *it* finishes - the exact race that let a
323/// finished run's fold disable the hook a still-running sibling in the same
324/// repository depended on. This ref-counts instead: the setting is turned on
325/// once, by whichever caller is first, and turned off only once every caller
326/// has released it via [`release_worktree_config`].
327///
328/// The per-repository lock is held across the `git config` call for the
329/// first acquire, so a second, concurrent acquire for the same repository
330/// waits for it rather than racing it - without that, both could observe
331/// "not yet counted" and both try to flip the setting on.
332pub async fn acquire_worktree_config(repo: &Path) -> Result<()> {
333    let slot = worktree_config_slot(repo);
334    let mut entry = slot.lock().await;
335    entry.count += 1;
336    if entry.count == 1 {
337        entry.we_enabled = enable_worktree_config(repo).await?;
338    }
339    Ok(())
340}
341
342/// Release a reference taken by [`acquire_worktree_config`].
343///
344/// Only the last release for a repository actually calls
345/// [`disable_worktree_config`], and only when this process was the one that
346/// turned the setting on in the first place.
347pub async fn release_worktree_config(repo: &Path) -> Result<()> {
348    let slot = worktree_config_slot(repo);
349    let mut entry = slot.lock().await;
350    entry.count = entry.count.saturating_sub(1);
351    if entry.count == 0 && entry.we_enabled {
352        disable_worktree_config(repo).await?;
353        entry.we_enabled = false;
354    }
355    Ok(())
356}
357
358/// Point a single worktree at its own hooks directory.
359///
360/// `core.hooksPath` is normally repo-wide; scoping it with `--worktree` keeps
361/// the operator's own hooks untouched in the primary worktree, and the setting
362/// disappears together with the worktree.
363pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
364    let dir = hooks_dir.to_string_lossy().replace('\\', "/");
365    git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
366        .await
367        .map(|_| ())
368}
369
370/// Exclude a path from a worktree's status without touching `.gitignore`.
371pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
372    let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
373    let path = worktree.join(git_dir);
374    if let Some(parent) = path.parent() {
375        tokio::fs::create_dir_all(parent).await.ok();
376    }
377    let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
378    if body.lines().any(|l| l.trim() == pattern) {
379        return Ok(());
380    }
381    if !body.is_empty() && !body.ends_with('\n') {
382        body.push('\n');
383    }
384    body.push_str(pattern);
385    body.push('\n');
386    tokio::fs::write(&path, body)
387        .await
388        .with_context(|| format!("write {}", path.display()))?;
389    Ok(())
390}
391
392/// `git merge --no-ff` of `branch` into the currently checked-out branch.
393///
394/// One of three ways to land a branch driven by [`crate::config::MergeStyle`]
395/// — see [`merge_squash`] and [`merge_ff_only`] for the other two, and that
396/// enum's own doc for why the choice between them lives in configuration.
397pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
398    git_raw(
399        repo,
400        &["merge", "--no-ff", "--no-edit", "-m", message, branch],
401    )
402    .await
403}
404
405/// `git merge --squash` of `branch`, followed by a commit under `message`.
406///
407/// Two `git` calls because `--squash` only stages the result — unlike
408/// [`merge_no_ff`] there is no merge commit for `--no-edit` to write, and
409/// skipping the second call is exactly the trap `land`'s module doc warns
410/// about: a squash that inherits `branch`'s own single-commit subject
411/// (`magi: candidate A (uncommitted work)`) instead of `message`. Returns the
412/// `--squash` step's own output, unrun `commit` included, when staging itself
413/// fails (a conflict), so a caller sees what actually went wrong rather than
414/// a `git commit` complaint about nothing being staged.
415pub async fn merge_squash(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
416    let staged = git_raw(repo, &["merge", "--squash", branch]).await?;
417    if !staged.ok() {
418        return Ok(staged);
419    }
420    git_raw(repo, &["commit", "-m", message]).await
421}
422
423/// Fast-forward `branch` into the currently checked-out branch, refusing to
424/// create a merge commit.
425///
426/// Only ever fast-forwards because the winner was already rebased onto the
427/// tracked base tip before this runs (`Runner::sync_to_base`); at that point
428/// `--ff-only` is indistinguishable from GitHub's "rebase and merge" button.
429/// If the base moved again in the meantime this fails rather than falling
430/// back to a real rebase, the same way `merge_no_ff` fails rather than
431/// resolving a conflict — landing is not the place to improvise.
432pub async fn merge_ff_only(repo: &Path, branch: &str) -> Result<GitOut> {
433    git_raw(repo, &["merge", "--ff-only", branch]).await
434}
435
436/// Push a branch to `remote`.
437pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
438    git_raw(repo, &["push", "-u", remote, branch]).await
439}
440
441/// Force-push a branch that has been rewritten, refusing to clobber work
442/// pushed since this side last looked.
443///
444/// `--force-with-lease` rather than `--force`: a rebase replaces the branch's
445/// commits, so a plain push is rejected, but a blind force would also throw
446/// away anything a person pushed to the same branch meanwhile. The lease
447/// turns that case into a failure instead of a loss.
448pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
449    git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
450}
451
452/// Rebase a branch onto `onto`, inside a throwaway worktree.
453///
454/// A worktree of its own for two reasons. The repository magi runs in may be
455/// jj-colocated, where git `HEAD` is detached and a rebase in the primary
456/// tree would move it under the operator; and a rebase that hits a conflict
457/// leaves state behind, which is far easier to discard with the whole
458/// directory than to unpick in a tree somebody is using.
459///
460/// `Ok(None)` means it applied and the branch now points at the rebased
461/// commits. `Ok(Some(why))` means it did not: the branch is untouched, and
462/// the string is what git said - a person has to decide.
463pub async fn rebase_branch_in_temp(
464    repo: &Path,
465    scratch: &Path,
466    branch: &str,
467    onto: &str,
468) -> Result<Option<String>> {
469    // Removed first so a leftover from an interrupted attempt cannot make
470    // `worktree add` fail on a path that already exists.
471    worktree_remove(repo, scratch).await.ok();
472    git_raw(
473        repo,
474        &[
475            "worktree",
476            "add",
477            "--force",
478            &scratch.to_string_lossy(),
479            branch,
480        ],
481    )
482    .await?;
483
484    let out = git_raw(scratch, &["rebase", onto]).await?;
485    if out.ok() {
486        worktree_remove(repo, scratch).await.ok();
487        return Ok(None);
488    }
489    // Leave nothing half-rebased behind: abort, then drop the tree entirely.
490    git_raw(scratch, &["rebase", "--abort"]).await.ok();
491    let why = if out.stderr.trim().is_empty() {
492        out.stdout.trim().to_owned()
493    } else {
494        out.stderr.trim().to_owned()
495    };
496    worktree_remove(repo, scratch).await.ok();
497    Ok(Some(why))
498}
499
500/// Bring an *attached* worktree's index and files in line with wherever its
501/// branch now points.
502///
503/// [`rebase_branch_in_temp`] moves a branch from a throwaway worktree on
504/// purpose - the whole point is never touching the tree someone else has
505/// checked out. But a worktree that already had that branch checked out
506/// shares the same ref: its `HEAD` resolves to the new commit the moment the
507/// rebase lands elsewhere, while its index and working directory keep
508/// whatever the old commit put there until something says otherwise. Left
509/// alone, the next `git status` there reads as the whole rebase turning up
510/// as an unstaged diff, and the next commit would be staged against stale
511/// content.
512pub async fn sync_to_head(worktree: &Path) -> Result<()> {
513    git(worktree, &["reset", "--hard", "HEAD"]).await?;
514    git(worktree, &["clean", "-fdx"]).await?;
515    Ok(())
516}
517
518/// Fetch one branch from `remote`, updating its remote-tracking ref.
519///
520/// The refspec is spelled out rather than left to `git fetch <remote>
521/// <branch>`, which writes `FETCH_HEAD` and updates
522/// `refs/remotes/<remote>/<branch>` only as a side effect of the remote's
523/// configured refspec. Naming the destination makes the thing this function
524/// exists for - a tracking ref that moved - the operation rather than a
525/// consequence of configuration magi does not own.
526///
527/// Honest note: a CI failure was first read as proof that some git versions do
528/// not update the tracking ref here. That was wrong - the fetch had nothing to
529/// update because the test had pushed to the wrong branch - so this is
530/// determinism, not a fix for a demonstrated portability bug.
531///
532/// Refs, not the working copy: nothing is checked out and no local branch
533/// moves, so this is safe to run while the operator has uncommitted work.
534/// Returned as a [`GitOut`] rather than an error so the caller can decide - a
535/// machine with no network must still be able to start a run.
536pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
537    let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
538    git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
539}
540
541/// Does this ref resolve?
542pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
543    git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
544        .await
545        .is_ok_and(|o| o.ok())
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    async fn scratch() -> (tempfile::TempDir, PathBuf) {
553        let dir = tempfile::tempdir().unwrap();
554        let repo = dir.path().join("repo");
555        tokio::fs::create_dir_all(&repo).await.unwrap();
556        git(&repo, &["init", "-b", "main"]).await.unwrap();
557        git(&repo, &["config", "user.name", "test"]).await.unwrap();
558        git(&repo, &["config", "user.email", "test@example.com"])
559            .await
560            .unwrap();
561        tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
562        git(&repo, &["add", "-A"]).await.unwrap();
563        git(&repo, &["commit", "-m", "init"]).await.unwrap();
564        (dir, repo)
565    }
566
567    #[tokio::test]
568    async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
569        let (_g, repo) = scratch().await;
570
571        // A side branch touching a different file: rebases cleanly.
572        git(&repo, &["checkout", "-b", "side"]).await.unwrap();
573        tokio::fs::write(repo.join("b.txt"), "side\n")
574            .await
575            .unwrap();
576        git(&repo, &["add", "-A"]).await.unwrap();
577        git(&repo, &["commit", "-m", "side work"]).await.unwrap();
578
579        // main moves under it, which is what a repository merging other
580        // pull requests does to a competition that took two hours.
581        git(&repo, &["checkout", "main"]).await.unwrap();
582        tokio::fs::write(repo.join("c.txt"), "main\n")
583            .await
584            .unwrap();
585        git(&repo, &["add", "-A"]).await.unwrap();
586        git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
587
588        let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
589        let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
590            .await
591            .unwrap();
592        assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
593        assert_eq!(
594            commits_ahead(&repo, "main", "side").await.unwrap(),
595            1,
596            "one commit, replayed onto the new base"
597        );
598        assert!(
599            !scratch_tree.exists(),
600            "the throwaway worktree is not left behind"
601        );
602
603        // A real conflict: both sides edit the same line.
604        git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
605        tokio::fs::write(repo.join("a.txt"), "clash\n")
606            .await
607            .unwrap();
608        git(&repo, &["add", "-A"]).await.unwrap();
609        git(&repo, &["commit", "-m", "clash"]).await.unwrap();
610        git(&repo, &["checkout", "main"]).await.unwrap();
611        tokio::fs::write(repo.join("a.txt"), "main edit\n")
612            .await
613            .unwrap();
614        git(&repo, &["add", "-A"]).await.unwrap();
615        git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
616
617        let before = rev_parse(&repo, "clash").await.unwrap();
618        let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
619            .await
620            .unwrap()
621            .expect("a same-line clash cannot be rebased silently");
622        assert!(
623            why.to_lowercase().contains("conflict"),
624            "the reason is what git said, which is what a person needs: {why}"
625        );
626        assert_eq!(
627            rev_parse(&repo, "clash").await.unwrap(),
628            before,
629            "a failed rebase leaves the branch exactly where it was"
630        );
631        assert!(!scratch_tree.exists(), "and cleans up after itself");
632    }
633
634    #[tokio::test]
635    async fn merge_squash_folds_the_branch_into_one_commit_under_the_given_message() {
636        let (_g, repo) = scratch().await;
637        git(&repo, &["checkout", "-b", "side"]).await.unwrap();
638        for name in ["b.txt", "c.txt"] {
639            tokio::fs::write(repo.join(name), "side\n").await.unwrap();
640            git(&repo, &["add", "-A"]).await.unwrap();
641            git(
642                &repo,
643                &["commit", "-m", "magi: candidate A (uncommitted work)"],
644            )
645            .await
646            .unwrap();
647        }
648        git(&repo, &["checkout", "main"]).await.unwrap();
649        let before = rev_parse(&repo, "main").await.unwrap();
650
651        let out = merge_squash(&repo, "side", "an explicit subject")
652            .await
653            .unwrap();
654        assert!(out.ok(), "{}", out.stderr);
655        assert_eq!(
656            commits_ahead(&repo, &before, "main").await.unwrap(),
657            1,
658            "squash adds exactly one commit onto the tip, not one per candidate commit"
659        );
660        let subject = git(&repo, &["log", "-1", "--format=%s"]).await.unwrap();
661        assert_eq!(
662            subject, "an explicit subject",
663            "the candidate's own placeholder subject must not survive: {subject}"
664        );
665    }
666
667    #[tokio::test]
668    async fn merge_ff_only_fast_forwards_a_branch_already_rebased_onto_the_tip() {
669        let (_g, repo) = scratch().await;
670        git(&repo, &["checkout", "-b", "side"]).await.unwrap();
671        tokio::fs::write(repo.join("b.txt"), "side\n")
672            .await
673            .unwrap();
674        git(&repo, &["add", "-A"]).await.unwrap();
675        git(&repo, &["commit", "-m", "side work"]).await.unwrap();
676        git(&repo, &["checkout", "main"]).await.unwrap();
677
678        let before = rev_parse(&repo, "side").await.unwrap();
679        let out = merge_ff_only(&repo, "side").await.unwrap();
680        assert!(out.ok(), "{}", out.stderr);
681        assert_eq!(
682            rev_parse(&repo, "main").await.unwrap(),
683            before,
684            "a fast-forward moves the base tip to the branch, no merge commit"
685        );
686    }
687
688    #[tokio::test]
689    async fn merge_ff_only_refuses_to_write_a_merge_commit() {
690        let (_g, repo) = scratch().await;
691        git(&repo, &["checkout", "-b", "side"]).await.unwrap();
692        tokio::fs::write(repo.join("b.txt"), "side\n")
693            .await
694            .unwrap();
695        git(&repo, &["add", "-A"]).await.unwrap();
696        git(&repo, &["commit", "-m", "side work"]).await.unwrap();
697
698        // main diverges, so a fast-forward is no longer possible.
699        git(&repo, &["checkout", "main"]).await.unwrap();
700        tokio::fs::write(repo.join("c.txt"), "main\n")
701            .await
702            .unwrap();
703        git(&repo, &["add", "-A"]).await.unwrap();
704        git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
705
706        let before = rev_parse(&repo, "main").await.unwrap();
707        let out = merge_ff_only(&repo, "side").await.unwrap();
708        assert!(!out.ok(), "a divergent branch cannot fast-forward");
709        assert_eq!(
710            rev_parse(&repo, "main").await.unwrap(),
711            before,
712            "a refused fast-forward must not touch main"
713        );
714    }
715
716    #[tokio::test]
717    async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
718        let (guard, repo) = scratch().await;
719
720        // An attached worktree of an existing branch - the shape a winner's
721        // worktree keeps in `graph::Runner`, not the detached checkouts used
722        // for judges and reviewers.
723        git(&repo, &["branch", "side"]).await.unwrap();
724        let side_wt = guard.path().join("side-wt");
725        git(
726            &repo,
727            &["worktree", "add", &side_wt.to_string_lossy(), "side"],
728        )
729        .await
730        .unwrap();
731        tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
732            .await
733            .unwrap();
734        git(&side_wt, &["add", "-A"]).await.unwrap();
735        git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
736
737        // main moves under it.
738        git(&repo, &["checkout", "main"]).await.unwrap();
739        tokio::fs::write(repo.join("c.txt"), "main\n")
740            .await
741            .unwrap();
742        git(&repo, &["add", "-A"]).await.unwrap();
743        git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
744
745        // Rebase from a throwaway worktree, never from `side_wt` itself.
746        let scratch_tree = guard.path().join("rebase-scratch");
747        let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
748            .await
749            .unwrap();
750        assert!(clean.is_none());
751
752        // `HEAD` in the sibling worktree already resolves to the rebased
753        // commit - the ref is shared - but nothing has told its index or its
754        // files, which still hold the pre-rebase checkout.
755        assert_eq!(
756            rev_parse(&side_wt, "HEAD").await.unwrap(),
757            rev_parse(&repo, "side").await.unwrap(),
758            "HEAD follows the moved ref"
759        );
760        assert!(
761            !side_wt.join("c.txt").exists(),
762            "stale until synced: main's new file has not reached this worktree's disk"
763        );
764
765        sync_to_head(&side_wt).await.unwrap();
766        assert!(side_wt.join("c.txt").is_file(), "synced now");
767        assert!(
768            side_wt.join("b.txt").is_file(),
769            "the worktree's own committed work survives the sync"
770        );
771        assert!(is_clean(&side_wt).await.unwrap());
772    }
773
774    #[tokio::test]
775    async fn clean_repo_reports_clean_then_dirty() {
776        let (_g, repo) = scratch().await;
777        assert!(is_clean(&repo).await.unwrap());
778        tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
779        assert!(!is_clean(&repo).await.unwrap());
780    }
781
782    #[tokio::test]
783    async fn worktree_lifecycle_and_diff() {
784        let (guard, repo) = scratch().await;
785        let base = rev_parse(&repo, "HEAD").await.unwrap();
786        let wt = guard.path().join("wt-a");
787        worktree_add_branch(&repo, &wt, "magi/test/a", &base)
788            .await
789            .unwrap();
790        tokio::fs::write(wt.join("b.txt"), "candidate\n")
791            .await
792            .unwrap();
793
794        assert!(commit_all(&wt, "candidate work").await.unwrap());
795        assert!(!commit_all(&wt, "nothing left").await.unwrap());
796
797        assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
798        let patch = diff(&wt, &base, "HEAD").await.unwrap();
799        assert!(patch.contains("b.txt"), "patch was: {patch}");
800        assert_eq!(
801            changed_files(&wt, &base, "HEAD").await.unwrap(),
802            ["b.txt".to_owned()]
803        );
804
805        // The rescue commit must not carry the operator's identity.
806        let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
807            .await
808            .unwrap();
809        assert_eq!(author, "magi candidate <magi@localhost>");
810
811        assert!(worktree_remove(&repo, &wt).await.unwrap());
812        assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
813        assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
814        assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
815    }
816
817    #[tokio::test]
818    async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
819        let (guard, repo) = scratch().await;
820        let base = rev_parse(&repo, "HEAD").await.unwrap();
821        let wt = guard.path().join("wt-h");
822        worktree_add_branch(&repo, &wt, "magi/test/h", &base)
823            .await
824            .unwrap();
825        let hooks = guard.path().join("hooks");
826        tokio::fs::create_dir_all(&hooks).await.unwrap();
827
828        assert!(enable_worktree_config(&repo).await.unwrap());
829        set_worktree_hooks_path(&wt, &hooks).await.unwrap();
830
831        let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
832            .await
833            .unwrap();
834        assert!(!in_wt.is_empty());
835        let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
836            .await
837            .unwrap();
838        assert!(
839            !in_primary.ok(),
840            "primary worktree must keep its own hooks: {in_primary:?}"
841        );
842
843        disable_worktree_config(&repo).await.unwrap();
844    }
845
846    #[tokio::test]
847    async fn worktree_config_stays_on_while_a_sibling_run_still_holds_it() {
848        let (_g, repo) = scratch().await;
849
850        // Two runs in the same repository, as `Config::daemon.max_concurrent_runs`
851        // now allows: both acquire before either is done.
852        acquire_worktree_config(&repo).await.unwrap();
853        acquire_worktree_config(&repo).await.unwrap();
854
855        let on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
856            .await
857            .unwrap();
858        assert_eq!(on, "true");
859
860        // The first run to finish releases its own reference. A plain
861        // `disable_worktree_config` here is exactly the bug: it would turn
862        // the setting off while the second run still depends on it.
863        release_worktree_config(&repo).await.unwrap();
864        let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
865            .await
866            .unwrap();
867        assert_eq!(
868            still_on, "true",
869            "a sibling run's release must not disable the setting for the one still working"
870        );
871
872        // Only the last release actually turns it back off.
873        release_worktree_config(&repo).await.unwrap();
874        let after = git_raw(&repo, &["config", "--get", "extensions.worktreeConfig"])
875            .await
876            .unwrap();
877        assert!(
878            !after.ok(),
879            "the last release must turn the setting back off: {after:?}"
880        );
881    }
882
883    #[tokio::test]
884    async fn worktree_config_already_on_before_magi_touched_it_is_left_alone() {
885        let (_g, repo) = scratch().await;
886        git(&repo, &["config", "extensions.worktreeConfig", "true"])
887            .await
888            .unwrap();
889
890        // magi did not turn this on, so even after every acquire is released,
891        // it must not turn it off - that is what a bare `enable_worktree_config`
892        // already promised for the single-run case, and the ref-counted
893        // version must keep that promise.
894        acquire_worktree_config(&repo).await.unwrap();
895        release_worktree_config(&repo).await.unwrap();
896
897        let still_on = git(&repo, &["config", "--get", "extensions.worktreeConfig"])
898            .await
899            .unwrap();
900        assert_eq!(still_on, "true");
901    }
902
903    #[tokio::test]
904    async fn local_exclude_is_idempotent() {
905        let (_g, repo) = scratch().await;
906        local_exclude(&repo, "/.magi/").await.unwrap();
907        local_exclude(&repo, "/.magi/").await.unwrap();
908        let path = repo.join(".git/info/exclude");
909        let body = tokio::fs::read_to_string(&path).await.unwrap();
910        assert_eq!(body.matches("/.magi/").count(), 1);
911    }
912}