Skip to main content

aristo_core/git/
mod.rs

1//! Thin git shell-out helpers.
2//!
3//! Aristo deliberately avoids pulling in `git2` / `gix` — those are
4//! heavy dependencies for a handful of operations. We invoke the
5//! user's `git` binary on the system PATH and parse its stdout. The
6//! invocations here are intentionally narrow:
7//!
8//! - [`rev_parse_head`] resolves `HEAD` to a 40-char SHA.
9//! - [`commit_present_on_remote`] checks whether a commit SHA appears
10//!   on any `origin/*` ref via `git branch -r --contains <sha>`. Used
11//!   by the canon-verify push-first precheck (per WORKFLOW.md §4 +
12//!   §7c "Push-first; mutagen later").
13//!
14//! Tests use real `git init` against a tempdir — the small extra
15//! cost (≈ tens of ms per test) buys actual coverage of the
16//! shell-out path. There's no point mocking `Command::output()` here.
17
18use std::path::Path;
19use std::process::Command;
20
21/// Errors surfaced by the git shell-outs.
22#[derive(Debug)]
23pub enum GitError {
24    /// `git` not on PATH.
25    NotInstalled(String),
26    /// `git` exited non-zero. Carries the command name + captured
27    /// stderr for the SDK to surface verbatim.
28    CommandFailed {
29        command: &'static str,
30        stderr: String,
31    },
32    /// Output couldn't be parsed against our expected shape.
33    Parse(String),
34}
35
36impl std::fmt::Display for GitError {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            GitError::NotInstalled(msg) => write!(f, "git not available: {msg}"),
40            GitError::CommandFailed { command, stderr } => {
41                write!(f, "`git {command}` failed: {}", stderr.trim())
42            }
43            GitError::Parse(msg) => write!(f, "git output parse error: {msg}"),
44        }
45    }
46}
47
48impl std::error::Error for GitError {}
49
50/// Run `git rev-parse HEAD` in `workspace_root` and return the
51/// resolved 40-char (or 64-char SHA-256) SHA.
52pub fn rev_parse_head(workspace_root: &Path) -> Result<String, GitError> {
53    let out = Command::new("git")
54        .args(["rev-parse", "HEAD"])
55        .current_dir(workspace_root)
56        .output()
57        .map_err(|e| GitError::NotInstalled(format!("spawn `git rev-parse HEAD`: {e}")))?;
58    if !out.status.success() {
59        return Err(GitError::CommandFailed {
60            command: "rev-parse HEAD",
61            stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
62        });
63    }
64    let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
65    if !looks_like_sha(&sha) {
66        return Err(GitError::Parse(format!(
67            "expected 40- or 64-char hex SHA, got `{sha}`"
68        )));
69    }
70    Ok(sha)
71}
72
73/// True iff `commit_sha` appears on at least one `origin/*` remote-
74/// tracking branch (the SDK's push-first precheck).
75///
76/// Does NOT run `git fetch` first — we want the precheck to be
77/// honest about the user's local state. The SDK surfaces a "push
78/// your branch first" message that's actionable, not a "ran fetch
79/// and still couldn't find it" indirection. Users who routinely
80/// fetch via `git pull --rebase` etc. will never hit this; users
81/// who didn't will see the precheck reject and run `git push`.
82pub fn commit_present_on_remote(workspace_root: &Path, commit_sha: &str) -> Result<bool, GitError> {
83    if !looks_like_sha(commit_sha) {
84        return Err(GitError::Parse(format!(
85            "commit_sha `{commit_sha}` is not a hex SHA"
86        )));
87    }
88    let out = Command::new("git")
89        .args(["branch", "-r", "--contains", commit_sha])
90        .current_dir(workspace_root)
91        .output()
92        .map_err(|e| GitError::NotInstalled(format!("spawn `git branch -r --contains`: {e}")))?;
93    if !out.status.success() {
94        // Non-zero exit usually means "no such commit" — surface as
95        // the precheck-rejected case, not a command failure.
96        let stderr = String::from_utf8_lossy(&out.stderr);
97        if stderr.contains("malformed object name")
98            || stderr.contains("unknown revision")
99            || stderr.contains("no such commit")
100            || stderr.contains("bad object")
101        {
102            return Ok(false);
103        }
104        return Err(GitError::CommandFailed {
105            command: "branch -r --contains",
106            stderr: stderr.into_owned(),
107        });
108    }
109    // Each output line is a ref like `  origin/main` or `  origin/HEAD -> origin/main`.
110    // We only care that at least one starts with `origin/`.
111    let stdout = String::from_utf8_lossy(&out.stdout);
112    Ok(stdout
113        .lines()
114        .map(|l| l.trim())
115        .any(|l| l.starts_with("origin/")))
116}
117
118fn looks_like_sha(s: &str) -> bool {
119    (s.len() == 40 || s.len() == 64) && s.bytes().all(|b| b.is_ascii_hexdigit())
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use std::fs;
126    use std::process::Command;
127    use tempfile::TempDir;
128
129    fn run_git(repo: &Path, args: &[&str]) {
130        let out = Command::new("git")
131            .args(args)
132            .current_dir(repo)
133            .output()
134            .expect("git");
135        assert!(
136            out.status.success(),
137            "git {args:?} failed: {}",
138            String::from_utf8_lossy(&out.stderr)
139        );
140    }
141
142    fn init_repo() -> TempDir {
143        let tmp = TempDir::new().unwrap();
144        run_git(tmp.path(), &["init", "-q", "-b", "main"]);
145        // Pinned identity so commits don't fail on CI machines without
146        // a global config.
147        run_git(tmp.path(), &["config", "user.email", "t@x"]);
148        run_git(tmp.path(), &["config", "user.name", "t"]);
149        run_git(tmp.path(), &["config", "commit.gpgsign", "false"]);
150        fs::write(tmp.path().join("README"), b"x").unwrap();
151        run_git(tmp.path(), &["add", "."]);
152        run_git(tmp.path(), &["commit", "-q", "-m", "init"]);
153        tmp
154    }
155
156    #[test]
157    fn looks_like_sha_accepts_40_and_64_chars() {
158        assert!(looks_like_sha(&"a".repeat(40)));
159        assert!(looks_like_sha(&"f".repeat(40)));
160        assert!(looks_like_sha(&"0".repeat(64)));
161        assert!(!looks_like_sha("abc"));
162        assert!(!looks_like_sha(&"g".repeat(40))); // non-hex
163        assert!(!looks_like_sha(&"a".repeat(41))); // wrong length
164    }
165
166    #[test]
167    fn rev_parse_head_returns_40_char_sha_for_fresh_repo() {
168        let repo = init_repo();
169        let sha = rev_parse_head(repo.path()).expect("rev-parse");
170        assert!(looks_like_sha(&sha), "got: {sha}");
171        assert_eq!(sha.len(), 40);
172    }
173
174    #[test]
175    fn commit_present_on_remote_false_for_local_only_repo() {
176        // No `origin` remote configured — push-first precheck must
177        // report "not pushed" so the SDK error message kicks in.
178        let repo = init_repo();
179        let sha = rev_parse_head(repo.path()).unwrap();
180        let present = commit_present_on_remote(repo.path(), &sha).expect("branch -r");
181        assert!(!present, "local-only repo must report not-pushed");
182    }
183
184    #[test]
185    fn commit_present_on_remote_true_after_simulated_push() {
186        // Simulate `origin` having the commit by adding a bare
187        // upstream and pushing. This mirrors the "user ran git push"
188        // path; the precheck should pass.
189        let repo = init_repo();
190        let upstream = TempDir::new().unwrap();
191        run_git(upstream.path(), &["init", "--bare", "-q"]);
192        run_git(
193            repo.path(),
194            &["remote", "add", "origin", upstream.path().to_str().unwrap()],
195        );
196        run_git(repo.path(), &["push", "-q", "origin", "main"]);
197        let sha = rev_parse_head(repo.path()).unwrap();
198        let present = commit_present_on_remote(repo.path(), &sha).expect("branch -r");
199        assert!(
200            present,
201            "after `git push origin main`, commit must be on origin/*"
202        );
203    }
204
205    #[test]
206    fn commit_present_on_remote_rejects_non_sha_input() {
207        let repo = init_repo();
208        let err = commit_present_on_remote(repo.path(), "not-a-sha").unwrap_err();
209        assert!(matches!(err, GitError::Parse(_)));
210    }
211
212    #[test]
213    fn commit_present_on_remote_false_for_unknown_sha() {
214        let repo = init_repo();
215        // 40-char hex that doesn't exist in the repo.
216        let bogus = "0".repeat(40);
217        let present = commit_present_on_remote(repo.path(), &bogus).expect("branch -r");
218        assert!(!present, "unknown SHA must report not-on-remote");
219    }
220}