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 anyhow::{Context as _, Result, bail};
10use tokio::process::Command;
11
12/// Output of a completed `git` invocation.
13#[derive(Debug)]
14pub struct GitOut {
15    /// Exit status code, if the process was not killed by a signal.
16    pub code: Option<i32>,
17    /// Captured stdout, trailing newline trimmed.
18    pub stdout: String,
19    /// Captured stderr, trailing newline trimmed.
20    pub stderr: String,
21}
22
23impl GitOut {
24    /// Did the command succeed?
25    pub fn ok(&self) -> bool {
26        self.code == Some(0)
27    }
28}
29
30/// Run `git` in `cwd` with `args`, returning the captured output regardless of
31/// exit status.
32pub async fn git_raw(cwd: &Path, args: &[&str]) -> Result<GitOut> {
33    let out = Command::new("git")
34        .args(args)
35        .current_dir(cwd)
36        // A hook that opens an editor or a credential prompt would hang a
37        // headless run forever.
38        .env("GIT_TERMINAL_PROMPT", "0")
39        .env("GIT_EDITOR", "true")
40        .stdin(Stdio::null())
41        .output()
42        .await
43        .with_context(|| format!("spawn git {}", args.join(" ")))?;
44    Ok(GitOut {
45        code: out.status.code(),
46        stdout: String::from_utf8_lossy(&out.stdout).trim_end().to_owned(),
47        stderr: String::from_utf8_lossy(&out.stderr).trim_end().to_owned(),
48    })
49}
50
51/// Run `git`, failing on a non-zero exit status.
52pub async fn git(cwd: &Path, args: &[&str]) -> Result<String> {
53    let out = git_raw(cwd, args).await?;
54    if !out.ok() {
55        bail!(
56            "git {} failed in {} (exit {:?}): {}",
57            args.join(" "),
58            cwd.display(),
59            out.code,
60            if out.stderr.is_empty() {
61                out.stdout.as_str()
62            } else {
63                out.stderr.as_str()
64            }
65        );
66    }
67    Ok(out.stdout)
68}
69
70/// Absolute path to the top level of the working tree containing `path`.
71pub async fn toplevel(path: &Path) -> Result<PathBuf> {
72    let out = git(path, &["rev-parse", "--show-toplevel"]).await?;
73    Ok(PathBuf::from(out))
74}
75
76/// Resolve a revision to a full object id.
77pub async fn rev_parse(repo: &Path, rev: &str) -> Result<String> {
78    git(repo, &["rev-parse", rev]).await
79}
80
81/// Currently checked-out branch, or `None` when detached.
82pub async fn current_branch(repo: &Path) -> Result<Option<String>> {
83    let out = git_raw(repo, &["symbolic-ref", "--quiet", "--short", "HEAD"]).await?;
84    Ok(if out.ok() && !out.stdout.is_empty() {
85        Some(out.stdout)
86    } else {
87        None
88    })
89}
90
91/// Is the working tree free of tracked modifications and untracked files?
92pub async fn is_clean(repo: &Path) -> Result<bool> {
93    Ok(git(repo, &["status", "--porcelain"]).await?.is_empty())
94}
95
96/// `git status --porcelain`, for reporting what is dirty.
97pub async fn status_porcelain(repo: &Path) -> Result<String> {
98    git(repo, &["status", "--porcelain"]).await
99}
100
101/// Create a worktree at `path` with a fresh branch `branch` starting at `base`.
102pub async fn worktree_add_branch(repo: &Path, path: &Path, branch: &str, base: &str) -> Result<()> {
103    if let Some(parent) = path.parent() {
104        tokio::fs::create_dir_all(parent).await.ok();
105    }
106    let path_s = path.to_string_lossy().to_string();
107    git(repo, &["worktree", "add", "-b", branch, &path_s, base])
108        .await
109        .map(|_| ())
110}
111
112/// Create a worktree at `path` with a detached HEAD at `rev`.
113pub async fn worktree_add_detached(repo: &Path, path: &Path, rev: &str) -> Result<()> {
114    if let Some(parent) = path.parent() {
115        tokio::fs::create_dir_all(parent).await.ok();
116    }
117    let path_s = path.to_string_lossy().to_string();
118    git(repo, &["worktree", "add", "--detach", &path_s, rev])
119        .await
120        .map(|_| ())
121}
122
123/// Move an existing detached worktree to `rev`, discarding local state.
124pub async fn reset_detached(worktree: &Path, rev: &str) -> Result<()> {
125    git(worktree, &["checkout", "--detach", rev]).await?;
126    git(worktree, &["reset", "--hard", rev]).await?;
127    git(worktree, &["clean", "-fdx"]).await?;
128    Ok(())
129}
130
131/// Remove a worktree. Returns `Ok(false)` when git refused (e.g. the path is
132/// already gone), so callers can keep folding the rest of a run.
133pub async fn worktree_remove(repo: &Path, path: &Path) -> Result<bool> {
134    let path_s = path.to_string_lossy().to_string();
135    let out = git_raw(repo, &["worktree", "remove", "--force", &path_s]).await?;
136    if out.ok() {
137        return Ok(true);
138    }
139    // A worktree whose directory was deleted by hand only needs pruning.
140    git_raw(repo, &["worktree", "prune"]).await?;
141    Ok(false)
142}
143
144/// Delete a branch, ignoring "not found".
145pub async fn branch_delete(repo: &Path, branch: &str) -> Result<bool> {
146    Ok(git_raw(repo, &["branch", "-D", branch]).await?.ok())
147}
148
149/// Does `branch` exist?
150pub async fn branch_exists(repo: &Path, branch: &str) -> Result<bool> {
151    let refname = format!("refs/heads/{branch}");
152    Ok(
153        git_raw(repo, &["show-ref", "--verify", "--quiet", &refname])
154            .await?
155            .ok(),
156    )
157}
158
159/// Patch of `head` against the merge base with `base`.
160pub async fn diff(worktree: &Path, base: &str, head: &str) -> Result<String> {
161    let range = format!("{base}...{head}");
162    git(
163        worktree,
164        &["diff", "--no-color", "--no-ext-diff", "-M", &range],
165    )
166    .await
167}
168
169/// `--stat` summary of `base...head`.
170pub async fn diff_stat(worktree: &Path, base: &str, head: &str) -> Result<String> {
171    let range = format!("{base}...{head}");
172    git(worktree, &["diff", "--no-color", "--stat", &range]).await
173}
174
175/// Number of files touched by `base...head`.
176pub async fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
177    let range = format!("{base}...{head}");
178    let out = git(worktree, &["diff", "--name-only", &range]).await?;
179    Ok(out.lines().map(str::to_owned).collect())
180}
181
182/// One-line log of `base..head`, oldest first.
183pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
184    let range = format!("{base}..{head}");
185    git(
186        worktree,
187        &["log", "--reverse", "--format=%s%n%b%n--", &range],
188    )
189    .await
190}
191
192/// How many commits `head` is ahead of `base`.
193pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
194    let range = format!("{base}..{head}");
195    let out = git(worktree, &["rev-list", "--count", &range]).await?;
196    Ok(out.trim().parse().unwrap_or(0))
197}
198
199/// Stage everything and commit under a neutral identity.
200///
201/// Used to rescue an agent that edited files but never committed: without this
202/// its candidate would silently be empty. The neutral identity is part of the
203/// blindness contract — a real `user.name` in a candidate's history would name
204/// the operator, and an agent-configured one would name the vendor.
205pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
206    if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
207        return Ok(false);
208    }
209    git(worktree, &["add", "-A"]).await?;
210    let out = git_raw(
211        worktree,
212        &[
213            "-c",
214            "user.name=magi candidate",
215            "-c",
216            "user.email=magi@localhost",
217            "commit",
218            "--no-verify",
219            "-m",
220            message,
221        ],
222    )
223    .await?;
224    if !out.ok() {
225        bail!("rescue commit failed: {}", out.stderr);
226    }
227    Ok(true)
228}
229
230/// Enable `extensions.worktreeConfig` if it is not already on.
231///
232/// Returns `true` when magi turned it on, so the caller can turn it back off
233/// during cleanup and leave the repo exactly as it found it.
234pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
235    let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
236    if out.ok() && out.stdout.trim() == "true" {
237        return Ok(false);
238    }
239    git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
240    Ok(true)
241}
242
243/// Undo [`enable_worktree_config`].
244pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
245    git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
246    Ok(())
247}
248
249/// Point a single worktree at its own hooks directory.
250///
251/// `core.hooksPath` is normally repo-wide; scoping it with `--worktree` keeps
252/// the operator's own hooks untouched in the primary worktree, and the setting
253/// disappears together with the worktree.
254pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
255    let dir = hooks_dir.to_string_lossy().replace('\\', "/");
256    git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
257        .await
258        .map(|_| ())
259}
260
261/// Exclude a path from a worktree's status without touching `.gitignore`.
262pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
263    let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
264    let path = worktree.join(git_dir);
265    if let Some(parent) = path.parent() {
266        tokio::fs::create_dir_all(parent).await.ok();
267    }
268    let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
269    if body.lines().any(|l| l.trim() == pattern) {
270        return Ok(());
271    }
272    if !body.is_empty() && !body.ends_with('\n') {
273        body.push('\n');
274    }
275    body.push_str(pattern);
276    body.push('\n');
277    tokio::fs::write(&path, body)
278        .await
279        .with_context(|| format!("write {}", path.display()))?;
280    Ok(())
281}
282
283/// `git merge --no-ff` of `branch` into the currently checked-out branch.
284pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
285    git_raw(
286        repo,
287        &["merge", "--no-ff", "--no-edit", "-m", message, branch],
288    )
289    .await
290}
291
292/// Push a branch to `remote`.
293pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
294    git_raw(repo, &["push", "-u", remote, branch]).await
295}
296
297/// Force-push a branch that has been rewritten, refusing to clobber work
298/// pushed since this side last looked.
299///
300/// `--force-with-lease` rather than `--force`: a rebase replaces the branch's
301/// commits, so a plain push is rejected, but a blind force would also throw
302/// away anything a person pushed to the same branch meanwhile. The lease
303/// turns that case into a failure instead of a loss.
304pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
305    git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
306}
307
308/// Rebase a branch onto `onto`, inside a throwaway worktree.
309///
310/// A worktree of its own for two reasons. The repository magi runs in may be
311/// jj-colocated, where git `HEAD` is detached and a rebase in the primary
312/// tree would move it under the operator; and a rebase that hits a conflict
313/// leaves state behind, which is far easier to discard with the whole
314/// directory than to unpick in a tree somebody is using.
315///
316/// `Ok(None)` means it applied and the branch now points at the rebased
317/// commits. `Ok(Some(why))` means it did not: the branch is untouched, and
318/// the string is what git said - a person has to decide.
319pub async fn rebase_branch_in_temp(
320    repo: &Path,
321    scratch: &Path,
322    branch: &str,
323    onto: &str,
324) -> Result<Option<String>> {
325    // Removed first so a leftover from an interrupted attempt cannot make
326    // `worktree add` fail on a path that already exists.
327    worktree_remove(repo, scratch).await.ok();
328    git_raw(
329        repo,
330        &[
331            "worktree",
332            "add",
333            "--force",
334            &scratch.to_string_lossy(),
335            branch,
336        ],
337    )
338    .await?;
339
340    let out = git_raw(scratch, &["rebase", onto]).await?;
341    if out.ok() {
342        worktree_remove(repo, scratch).await.ok();
343        return Ok(None);
344    }
345    // Leave nothing half-rebased behind: abort, then drop the tree entirely.
346    git_raw(scratch, &["rebase", "--abort"]).await.ok();
347    let why = if out.stderr.trim().is_empty() {
348        out.stdout.trim().to_owned()
349    } else {
350        out.stderr.trim().to_owned()
351    };
352    worktree_remove(repo, scratch).await.ok();
353    Ok(Some(why))
354}
355
356/// Fetch one branch from `remote`, updating its remote-tracking ref.
357///
358/// The refspec is spelled out rather than left to `git fetch <remote>
359/// <branch>`, which writes `FETCH_HEAD` and updates
360/// `refs/remotes/<remote>/<branch>` only as a side effect of the remote's
361/// configured refspec. Naming the destination makes the thing this function
362/// exists for - a tracking ref that moved - the operation rather than a
363/// consequence of configuration magi does not own.
364///
365/// Honest note: a CI failure was first read as proof that some git versions do
366/// not update the tracking ref here. That was wrong - the fetch had nothing to
367/// update because the test had pushed to the wrong branch - so this is
368/// determinism, not a fix for a demonstrated portability bug.
369///
370/// Refs, not the working copy: nothing is checked out and no local branch
371/// moves, so this is safe to run while the operator has uncommitted work.
372/// Returned as a [`GitOut`] rather than an error so the caller can decide - a
373/// machine with no network must still be able to start a run.
374pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
375    let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
376    git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
377}
378
379/// Does this ref resolve?
380pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
381    git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
382        .await
383        .is_ok_and(|o| o.ok())
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    async fn scratch() -> (tempfile::TempDir, PathBuf) {
391        let dir = tempfile::tempdir().unwrap();
392        let repo = dir.path().join("repo");
393        tokio::fs::create_dir_all(&repo).await.unwrap();
394        git(&repo, &["init", "-b", "main"]).await.unwrap();
395        git(&repo, &["config", "user.name", "test"]).await.unwrap();
396        git(&repo, &["config", "user.email", "test@example.com"])
397            .await
398            .unwrap();
399        tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
400        git(&repo, &["add", "-A"]).await.unwrap();
401        git(&repo, &["commit", "-m", "init"]).await.unwrap();
402        (dir, repo)
403    }
404
405    #[tokio::test]
406    async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
407        let (_g, repo) = scratch().await;
408
409        // A side branch touching a different file: rebases cleanly.
410        git(&repo, &["checkout", "-b", "side"]).await.unwrap();
411        tokio::fs::write(repo.join("b.txt"), "side\n")
412            .await
413            .unwrap();
414        git(&repo, &["add", "-A"]).await.unwrap();
415        git(&repo, &["commit", "-m", "side work"]).await.unwrap();
416
417        // main moves under it, which is what a repository merging other
418        // pull requests does to a competition that took two hours.
419        git(&repo, &["checkout", "main"]).await.unwrap();
420        tokio::fs::write(repo.join("c.txt"), "main\n")
421            .await
422            .unwrap();
423        git(&repo, &["add", "-A"]).await.unwrap();
424        git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
425
426        let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
427        let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
428            .await
429            .unwrap();
430        assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
431        assert_eq!(
432            commits_ahead(&repo, "main", "side").await.unwrap(),
433            1,
434            "one commit, replayed onto the new base"
435        );
436        assert!(
437            !scratch_tree.exists(),
438            "the throwaway worktree is not left behind"
439        );
440
441        // A real conflict: both sides edit the same line.
442        git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
443        tokio::fs::write(repo.join("a.txt"), "clash\n")
444            .await
445            .unwrap();
446        git(&repo, &["add", "-A"]).await.unwrap();
447        git(&repo, &["commit", "-m", "clash"]).await.unwrap();
448        git(&repo, &["checkout", "main"]).await.unwrap();
449        tokio::fs::write(repo.join("a.txt"), "main edit\n")
450            .await
451            .unwrap();
452        git(&repo, &["add", "-A"]).await.unwrap();
453        git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
454
455        let before = rev_parse(&repo, "clash").await.unwrap();
456        let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
457            .await
458            .unwrap()
459            .expect("a same-line clash cannot be rebased silently");
460        assert!(
461            why.to_lowercase().contains("conflict"),
462            "the reason is what git said, which is what a person needs: {why}"
463        );
464        assert_eq!(
465            rev_parse(&repo, "clash").await.unwrap(),
466            before,
467            "a failed rebase leaves the branch exactly where it was"
468        );
469        assert!(!scratch_tree.exists(), "and cleans up after itself");
470    }
471
472    #[tokio::test]
473    async fn clean_repo_reports_clean_then_dirty() {
474        let (_g, repo) = scratch().await;
475        assert!(is_clean(&repo).await.unwrap());
476        tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
477        assert!(!is_clean(&repo).await.unwrap());
478    }
479
480    #[tokio::test]
481    async fn worktree_lifecycle_and_diff() {
482        let (guard, repo) = scratch().await;
483        let base = rev_parse(&repo, "HEAD").await.unwrap();
484        let wt = guard.path().join("wt-a");
485        worktree_add_branch(&repo, &wt, "magi/test/a", &base)
486            .await
487            .unwrap();
488        tokio::fs::write(wt.join("b.txt"), "candidate\n")
489            .await
490            .unwrap();
491
492        assert!(commit_all(&wt, "candidate work").await.unwrap());
493        assert!(!commit_all(&wt, "nothing left").await.unwrap());
494
495        assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
496        let patch = diff(&wt, &base, "HEAD").await.unwrap();
497        assert!(patch.contains("b.txt"), "patch was: {patch}");
498        assert_eq!(
499            changed_files(&wt, &base, "HEAD").await.unwrap(),
500            ["b.txt".to_owned()]
501        );
502
503        // The rescue commit must not carry the operator's identity.
504        let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
505            .await
506            .unwrap();
507        assert_eq!(author, "magi candidate <magi@localhost>");
508
509        assert!(worktree_remove(&repo, &wt).await.unwrap());
510        assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
511        assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
512        assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
513    }
514
515    #[tokio::test]
516    async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
517        let (guard, repo) = scratch().await;
518        let base = rev_parse(&repo, "HEAD").await.unwrap();
519        let wt = guard.path().join("wt-h");
520        worktree_add_branch(&repo, &wt, "magi/test/h", &base)
521            .await
522            .unwrap();
523        let hooks = guard.path().join("hooks");
524        tokio::fs::create_dir_all(&hooks).await.unwrap();
525
526        assert!(enable_worktree_config(&repo).await.unwrap());
527        set_worktree_hooks_path(&wt, &hooks).await.unwrap();
528
529        let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
530            .await
531            .unwrap();
532        assert!(!in_wt.is_empty());
533        let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
534            .await
535            .unwrap();
536        assert!(
537            !in_primary.ok(),
538            "primary worktree must keep its own hooks: {in_primary:?}"
539        );
540
541        disable_worktree_config(&repo).await.unwrap();
542    }
543
544    #[tokio::test]
545    async fn local_exclude_is_idempotent() {
546        let (_g, repo) = scratch().await;
547        local_exclude(&repo, "/.magi/").await.unwrap();
548        local_exclude(&repo, "/.magi/").await.unwrap();
549        let path = repo.join(".git/info/exclude");
550        let body = tokio::fs::read_to_string(&path).await.unwrap();
551        assert_eq!(body.matches("/.magi/").count(), 1);
552    }
553}