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/// Delete a branch, ignoring "not found".
147pub async fn branch_delete(repo: &Path, branch: &str) -> Result<bool> {
148    Ok(git_raw(repo, &["branch", "-D", branch]).await?.ok())
149}
150
151/// Does `branch` exist?
152pub async fn branch_exists(repo: &Path, branch: &str) -> Result<bool> {
153    let refname = format!("refs/heads/{branch}");
154    Ok(
155        git_raw(repo, &["show-ref", "--verify", "--quiet", &refname])
156            .await?
157            .ok(),
158    )
159}
160
161/// Patch of `head` against the merge base with `base`.
162pub async fn diff(worktree: &Path, base: &str, head: &str) -> Result<String> {
163    let range = format!("{base}...{head}");
164    git(
165        worktree,
166        &["diff", "--no-color", "--no-ext-diff", "-M", &range],
167    )
168    .await
169}
170
171/// `--stat` summary of `base...head`.
172pub async fn diff_stat(worktree: &Path, base: &str, head: &str) -> Result<String> {
173    let range = format!("{base}...{head}");
174    git(worktree, &["diff", "--no-color", "--stat", &range]).await
175}
176
177/// Number of files touched by `base...head`.
178pub async fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
179    let range = format!("{base}...{head}");
180    let out = git(worktree, &["diff", "--name-only", &range]).await?;
181    Ok(out.lines().map(str::to_owned).collect())
182}
183
184/// One-line log of `base..head`, oldest first.
185pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
186    let range = format!("{base}..{head}");
187    git(
188        worktree,
189        &["log", "--reverse", "--format=%s%n%b%n--", &range],
190    )
191    .await
192}
193
194/// How many commits `head` is ahead of `base`.
195pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
196    let range = format!("{base}..{head}");
197    let out = git(worktree, &["rev-list", "--count", &range]).await?;
198    Ok(out.trim().parse().unwrap_or(0))
199}
200
201/// Stage everything and commit under a neutral identity.
202///
203/// Used to rescue an agent that edited files but never committed: without this
204/// its candidate would silently be empty. The neutral identity is part of the
205/// blindness contract — a real `user.name` in a candidate's history would name
206/// the operator, and an agent-configured one would name the vendor.
207pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
208    if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
209        return Ok(false);
210    }
211    git(worktree, &["add", "-A"]).await?;
212    let out = git_raw(
213        worktree,
214        &[
215            "-c",
216            "user.name=magi candidate",
217            "-c",
218            "user.email=magi@localhost",
219            "commit",
220            "--no-verify",
221            "-m",
222            message,
223        ],
224    )
225    .await?;
226    if !out.ok() {
227        bail!("rescue commit failed: {}", out.stderr);
228    }
229    Ok(true)
230}
231
232/// Enable `extensions.worktreeConfig` if it is not already on.
233///
234/// Returns `true` when magi turned it on, so the caller can turn it back off
235/// during cleanup and leave the repo exactly as it found it.
236pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
237    let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
238    if out.ok() && out.stdout.trim() == "true" {
239        return Ok(false);
240    }
241    git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
242    Ok(true)
243}
244
245/// Undo [`enable_worktree_config`].
246pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
247    git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
248    Ok(())
249}
250
251/// Point a single worktree at its own hooks directory.
252///
253/// `core.hooksPath` is normally repo-wide; scoping it with `--worktree` keeps
254/// the operator's own hooks untouched in the primary worktree, and the setting
255/// disappears together with the worktree.
256pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
257    let dir = hooks_dir.to_string_lossy().replace('\\', "/");
258    git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
259        .await
260        .map(|_| ())
261}
262
263/// Exclude a path from a worktree's status without touching `.gitignore`.
264pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
265    let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
266    let path = worktree.join(git_dir);
267    if let Some(parent) = path.parent() {
268        tokio::fs::create_dir_all(parent).await.ok();
269    }
270    let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
271    if body.lines().any(|l| l.trim() == pattern) {
272        return Ok(());
273    }
274    if !body.is_empty() && !body.ends_with('\n') {
275        body.push('\n');
276    }
277    body.push_str(pattern);
278    body.push('\n');
279    tokio::fs::write(&path, body)
280        .await
281        .with_context(|| format!("write {}", path.display()))?;
282    Ok(())
283}
284
285/// `git merge --no-ff` of `branch` into the currently checked-out branch.
286pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
287    git_raw(
288        repo,
289        &["merge", "--no-ff", "--no-edit", "-m", message, branch],
290    )
291    .await
292}
293
294/// Push a branch to `remote`.
295pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
296    git_raw(repo, &["push", "-u", remote, branch]).await
297}
298
299/// Force-push a branch that has been rewritten, refusing to clobber work
300/// pushed since this side last looked.
301///
302/// `--force-with-lease` rather than `--force`: a rebase replaces the branch's
303/// commits, so a plain push is rejected, but a blind force would also throw
304/// away anything a person pushed to the same branch meanwhile. The lease
305/// turns that case into a failure instead of a loss.
306pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
307    git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
308}
309
310/// Rebase a branch onto `onto`, inside a throwaway worktree.
311///
312/// A worktree of its own for two reasons. The repository magi runs in may be
313/// jj-colocated, where git `HEAD` is detached and a rebase in the primary
314/// tree would move it under the operator; and a rebase that hits a conflict
315/// leaves state behind, which is far easier to discard with the whole
316/// directory than to unpick in a tree somebody is using.
317///
318/// `Ok(None)` means it applied and the branch now points at the rebased
319/// commits. `Ok(Some(why))` means it did not: the branch is untouched, and
320/// the string is what git said - a person has to decide.
321pub async fn rebase_branch_in_temp(
322    repo: &Path,
323    scratch: &Path,
324    branch: &str,
325    onto: &str,
326) -> Result<Option<String>> {
327    // Removed first so a leftover from an interrupted attempt cannot make
328    // `worktree add` fail on a path that already exists.
329    worktree_remove(repo, scratch).await.ok();
330    git_raw(
331        repo,
332        &[
333            "worktree",
334            "add",
335            "--force",
336            &scratch.to_string_lossy(),
337            branch,
338        ],
339    )
340    .await?;
341
342    let out = git_raw(scratch, &["rebase", onto]).await?;
343    if out.ok() {
344        worktree_remove(repo, scratch).await.ok();
345        return Ok(None);
346    }
347    // Leave nothing half-rebased behind: abort, then drop the tree entirely.
348    git_raw(scratch, &["rebase", "--abort"]).await.ok();
349    let why = if out.stderr.trim().is_empty() {
350        out.stdout.trim().to_owned()
351    } else {
352        out.stderr.trim().to_owned()
353    };
354    worktree_remove(repo, scratch).await.ok();
355    Ok(Some(why))
356}
357
358/// Fetch one branch from `remote`, updating its remote-tracking ref.
359///
360/// The refspec is spelled out rather than left to `git fetch <remote>
361/// <branch>`, which writes `FETCH_HEAD` and updates
362/// `refs/remotes/<remote>/<branch>` only as a side effect of the remote's
363/// configured refspec. Naming the destination makes the thing this function
364/// exists for - a tracking ref that moved - the operation rather than a
365/// consequence of configuration magi does not own.
366///
367/// Honest note: a CI failure was first read as proof that some git versions do
368/// not update the tracking ref here. That was wrong - the fetch had nothing to
369/// update because the test had pushed to the wrong branch - so this is
370/// determinism, not a fix for a demonstrated portability bug.
371///
372/// Refs, not the working copy: nothing is checked out and no local branch
373/// moves, so this is safe to run while the operator has uncommitted work.
374/// Returned as a [`GitOut`] rather than an error so the caller can decide - a
375/// machine with no network must still be able to start a run.
376pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
377    let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
378    git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
379}
380
381/// Does this ref resolve?
382pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
383    git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
384        .await
385        .is_ok_and(|o| o.ok())
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    async fn scratch() -> (tempfile::TempDir, PathBuf) {
393        let dir = tempfile::tempdir().unwrap();
394        let repo = dir.path().join("repo");
395        tokio::fs::create_dir_all(&repo).await.unwrap();
396        git(&repo, &["init", "-b", "main"]).await.unwrap();
397        git(&repo, &["config", "user.name", "test"]).await.unwrap();
398        git(&repo, &["config", "user.email", "test@example.com"])
399            .await
400            .unwrap();
401        tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
402        git(&repo, &["add", "-A"]).await.unwrap();
403        git(&repo, &["commit", "-m", "init"]).await.unwrap();
404        (dir, repo)
405    }
406
407    #[tokio::test]
408    async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
409        let (_g, repo) = scratch().await;
410
411        // A side branch touching a different file: rebases cleanly.
412        git(&repo, &["checkout", "-b", "side"]).await.unwrap();
413        tokio::fs::write(repo.join("b.txt"), "side\n")
414            .await
415            .unwrap();
416        git(&repo, &["add", "-A"]).await.unwrap();
417        git(&repo, &["commit", "-m", "side work"]).await.unwrap();
418
419        // main moves under it, which is what a repository merging other
420        // pull requests does to a competition that took two hours.
421        git(&repo, &["checkout", "main"]).await.unwrap();
422        tokio::fs::write(repo.join("c.txt"), "main\n")
423            .await
424            .unwrap();
425        git(&repo, &["add", "-A"]).await.unwrap();
426        git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
427
428        let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
429        let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
430            .await
431            .unwrap();
432        assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
433        assert_eq!(
434            commits_ahead(&repo, "main", "side").await.unwrap(),
435            1,
436            "one commit, replayed onto the new base"
437        );
438        assert!(
439            !scratch_tree.exists(),
440            "the throwaway worktree is not left behind"
441        );
442
443        // A real conflict: both sides edit the same line.
444        git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
445        tokio::fs::write(repo.join("a.txt"), "clash\n")
446            .await
447            .unwrap();
448        git(&repo, &["add", "-A"]).await.unwrap();
449        git(&repo, &["commit", "-m", "clash"]).await.unwrap();
450        git(&repo, &["checkout", "main"]).await.unwrap();
451        tokio::fs::write(repo.join("a.txt"), "main edit\n")
452            .await
453            .unwrap();
454        git(&repo, &["add", "-A"]).await.unwrap();
455        git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
456
457        let before = rev_parse(&repo, "clash").await.unwrap();
458        let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
459            .await
460            .unwrap()
461            .expect("a same-line clash cannot be rebased silently");
462        assert!(
463            why.to_lowercase().contains("conflict"),
464            "the reason is what git said, which is what a person needs: {why}"
465        );
466        assert_eq!(
467            rev_parse(&repo, "clash").await.unwrap(),
468            before,
469            "a failed rebase leaves the branch exactly where it was"
470        );
471        assert!(!scratch_tree.exists(), "and cleans up after itself");
472    }
473
474    #[tokio::test]
475    async fn clean_repo_reports_clean_then_dirty() {
476        let (_g, repo) = scratch().await;
477        assert!(is_clean(&repo).await.unwrap());
478        tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
479        assert!(!is_clean(&repo).await.unwrap());
480    }
481
482    #[tokio::test]
483    async fn worktree_lifecycle_and_diff() {
484        let (guard, repo) = scratch().await;
485        let base = rev_parse(&repo, "HEAD").await.unwrap();
486        let wt = guard.path().join("wt-a");
487        worktree_add_branch(&repo, &wt, "magi/test/a", &base)
488            .await
489            .unwrap();
490        tokio::fs::write(wt.join("b.txt"), "candidate\n")
491            .await
492            .unwrap();
493
494        assert!(commit_all(&wt, "candidate work").await.unwrap());
495        assert!(!commit_all(&wt, "nothing left").await.unwrap());
496
497        assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
498        let patch = diff(&wt, &base, "HEAD").await.unwrap();
499        assert!(patch.contains("b.txt"), "patch was: {patch}");
500        assert_eq!(
501            changed_files(&wt, &base, "HEAD").await.unwrap(),
502            ["b.txt".to_owned()]
503        );
504
505        // The rescue commit must not carry the operator's identity.
506        let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
507            .await
508            .unwrap();
509        assert_eq!(author, "magi candidate <magi@localhost>");
510
511        assert!(worktree_remove(&repo, &wt).await.unwrap());
512        assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
513        assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
514        assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
515    }
516
517    #[tokio::test]
518    async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
519        let (guard, repo) = scratch().await;
520        let base = rev_parse(&repo, "HEAD").await.unwrap();
521        let wt = guard.path().join("wt-h");
522        worktree_add_branch(&repo, &wt, "magi/test/h", &base)
523            .await
524            .unwrap();
525        let hooks = guard.path().join("hooks");
526        tokio::fs::create_dir_all(&hooks).await.unwrap();
527
528        assert!(enable_worktree_config(&repo).await.unwrap());
529        set_worktree_hooks_path(&wt, &hooks).await.unwrap();
530
531        let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
532            .await
533            .unwrap();
534        assert!(!in_wt.is_empty());
535        let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
536            .await
537            .unwrap();
538        assert!(
539            !in_primary.ok(),
540            "primary worktree must keep its own hooks: {in_primary:?}"
541        );
542
543        disable_worktree_config(&repo).await.unwrap();
544    }
545
546    #[tokio::test]
547    async fn local_exclude_is_idempotent() {
548        let (_g, repo) = scratch().await;
549        local_exclude(&repo, "/.magi/").await.unwrap();
550        local_exclude(&repo, "/.magi/").await.unwrap();
551        let path = repo.join(".git/info/exclude");
552        let body = tokio::fs::read_to_string(&path).await.unwrap();
553        assert_eq!(body.matches("/.magi/").count(), 1);
554    }
555}