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/// Fetch one branch from `remote`, updating its remote-tracking ref.
298///
299/// The refspec is spelled out rather than left to `git fetch <remote>
300/// <branch>`, which writes `FETCH_HEAD` and updates
301/// `refs/remotes/<remote>/<branch>` only as a side effect of the remote's
302/// configured refspec. Naming the destination makes the thing this function
303/// exists for - a tracking ref that moved - the operation rather than a
304/// consequence of configuration magi does not own.
305///
306/// Honest note: a CI failure was first read as proof that some git versions do
307/// not update the tracking ref here. That was wrong - the fetch had nothing to
308/// update because the test had pushed to the wrong branch - so this is
309/// determinism, not a fix for a demonstrated portability bug.
310///
311/// Refs, not the working copy: nothing is checked out and no local branch
312/// moves, so this is safe to run while the operator has uncommitted work.
313/// Returned as a [`GitOut`] rather than an error so the caller can decide - a
314/// machine with no network must still be able to start a run.
315pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
316    let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
317    git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
318}
319
320/// Does this ref resolve?
321pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
322    git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
323        .await
324        .is_ok_and(|o| o.ok())
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    async fn scratch() -> (tempfile::TempDir, PathBuf) {
332        let dir = tempfile::tempdir().unwrap();
333        let repo = dir.path().join("repo");
334        tokio::fs::create_dir_all(&repo).await.unwrap();
335        git(&repo, &["init", "-b", "main"]).await.unwrap();
336        git(&repo, &["config", "user.name", "test"]).await.unwrap();
337        git(&repo, &["config", "user.email", "test@example.com"])
338            .await
339            .unwrap();
340        tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
341        git(&repo, &["add", "-A"]).await.unwrap();
342        git(&repo, &["commit", "-m", "init"]).await.unwrap();
343        (dir, repo)
344    }
345
346    #[tokio::test]
347    async fn clean_repo_reports_clean_then_dirty() {
348        let (_g, repo) = scratch().await;
349        assert!(is_clean(&repo).await.unwrap());
350        tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
351        assert!(!is_clean(&repo).await.unwrap());
352    }
353
354    #[tokio::test]
355    async fn worktree_lifecycle_and_diff() {
356        let (guard, repo) = scratch().await;
357        let base = rev_parse(&repo, "HEAD").await.unwrap();
358        let wt = guard.path().join("wt-a");
359        worktree_add_branch(&repo, &wt, "magi/test/a", &base)
360            .await
361            .unwrap();
362        tokio::fs::write(wt.join("b.txt"), "candidate\n")
363            .await
364            .unwrap();
365
366        assert!(commit_all(&wt, "candidate work").await.unwrap());
367        assert!(!commit_all(&wt, "nothing left").await.unwrap());
368
369        assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
370        let patch = diff(&wt, &base, "HEAD").await.unwrap();
371        assert!(patch.contains("b.txt"), "patch was: {patch}");
372        assert_eq!(
373            changed_files(&wt, &base, "HEAD").await.unwrap(),
374            ["b.txt".to_owned()]
375        );
376
377        // The rescue commit must not carry the operator's identity.
378        let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
379            .await
380            .unwrap();
381        assert_eq!(author, "magi candidate <magi@localhost>");
382
383        assert!(worktree_remove(&repo, &wt).await.unwrap());
384        assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
385        assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
386        assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
387    }
388
389    #[tokio::test]
390    async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
391        let (guard, repo) = scratch().await;
392        let base = rev_parse(&repo, "HEAD").await.unwrap();
393        let wt = guard.path().join("wt-h");
394        worktree_add_branch(&repo, &wt, "magi/test/h", &base)
395            .await
396            .unwrap();
397        let hooks = guard.path().join("hooks");
398        tokio::fs::create_dir_all(&hooks).await.unwrap();
399
400        assert!(enable_worktree_config(&repo).await.unwrap());
401        set_worktree_hooks_path(&wt, &hooks).await.unwrap();
402
403        let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
404            .await
405            .unwrap();
406        assert!(!in_wt.is_empty());
407        let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
408            .await
409            .unwrap();
410        assert!(
411            !in_primary.ok(),
412            "primary worktree must keep its own hooks: {in_primary:?}"
413        );
414
415        disable_worktree_config(&repo).await.unwrap();
416    }
417
418    #[tokio::test]
419    async fn local_exclude_is_idempotent() {
420        let (_g, repo) = scratch().await;
421        local_exclude(&repo, "/.magi/").await.unwrap();
422        local_exclude(&repo, "/.magi/").await.unwrap();
423        let path = repo.join(".git/info/exclude");
424        let body = tokio::fs::read_to_string(&path).await.unwrap();
425        assert_eq!(body.matches("/.magi/").count(), 1);
426    }
427}