Skip to main content

atman_runtime/
git.rs

1use std::path::{Path, PathBuf};
2use std::process::Output;
3
4pub type Result<T> = std::result::Result<T, GitError>;
5
6#[derive(Debug, thiserror::Error)]
7pub enum GitError {
8    #[error("git binary not available: {0}")]
9    NotAvailable(String),
10    #[error("git spawn failed: {0}")]
11    Spawn(#[from] std::io::Error),
12    #[error("git {args} exit {code}: {stderr}")]
13    ExitNonZero {
14        args: String,
15        code: i32,
16        stderr: String,
17    },
18    #[error("libgit2: {0}")]
19    Libgit2(#[from] git2::Error),
20    #[error("not a git repository at {0}")]
21    NotARepo(PathBuf),
22}
23
24pub fn discover_toplevel(start: &Path) -> Result<PathBuf> {
25    let repo =
26        git2::Repository::discover(start).map_err(|_| GitError::NotARepo(start.to_path_buf()))?;
27    let workdir = repo
28        .workdir()
29        .ok_or_else(|| GitError::NotARepo(start.to_path_buf()))?;
30    Ok(workdir.to_path_buf())
31}
32
33pub fn diff_range(cwd: &Path, range: &str, paths: &[String]) -> Result<DiffResult> {
34    let repo = git2::Repository::open(cwd).map_err(|_| GitError::NotARepo(cwd.to_path_buf()))?;
35    let revspec = repo.revparse(range)?;
36    let from = revspec
37        .from()
38        .ok_or_else(|| GitError::Libgit2(git2::Error::from_str("revspec missing 'from'")))?
39        .peel_to_commit()?
40        .tree()?;
41    let to = revspec
42        .to()
43        .map(|t| t.peel_to_commit().and_then(|c| c.tree()))
44        .transpose()?;
45
46    let mut opts = git2::DiffOptions::new();
47    for p in paths {
48        opts.pathspec(p);
49    }
50    let diff = match to {
51        Some(to_tree) => repo.diff_tree_to_tree(Some(&from), Some(&to_tree), Some(&mut opts))?,
52        None => repo.diff_tree_to_workdir_with_index(Some(&from), Some(&mut opts))?,
53    };
54
55    let mut files = Vec::new();
56    diff.foreach(
57        &mut |delta, _| {
58            let path = delta
59                .new_file()
60                .path()
61                .or_else(|| delta.old_file().path())
62                .map(|p| p.to_string_lossy().into_owned());
63            if let Some(p) = path {
64                if !files.contains(&p) {
65                    files.push(p);
66                }
67            }
68            true
69        },
70        None,
71        None,
72        None,
73    )?;
74
75    let mut body = String::new();
76    diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
77        match line.origin() {
78            'F' | 'H' => body.push_str(&String::from_utf8_lossy(line.content())),
79            '+' | '-' | ' ' => {
80                body.push(line.origin());
81                body.push_str(&String::from_utf8_lossy(line.content()));
82            }
83            _ => body.push_str(&String::from_utf8_lossy(line.content())),
84        }
85        true
86    })?;
87
88    Ok(DiffResult { body, files })
89}
90
91pub struct DiffResult {
92    pub body: String,
93    pub files: Vec<String>,
94}
95
96pub fn status_porcelain(cwd: &Path) -> Result<String> {
97    let repo = git2::Repository::open(cwd).map_err(|_| GitError::NotARepo(cwd.to_path_buf()))?;
98    let mut opts = git2::StatusOptions::new();
99    opts.include_untracked(true).include_ignored(false);
100    let statuses = repo.statuses(Some(&mut opts))?;
101    let mut out = String::new();
102    for s in statuses.iter() {
103        let bits = s.status();
104        let (index_c, wt_c) = if bits.contains(git2::Status::WT_NEW)
105            && !bits.intersects(
106                git2::Status::INDEX_NEW
107                    | git2::Status::INDEX_MODIFIED
108                    | git2::Status::INDEX_DELETED
109                    | git2::Status::INDEX_RENAMED
110                    | git2::Status::INDEX_TYPECHANGE,
111            ) {
112            ('?', '?')
113        } else {
114            (index_flag(bits), worktree_flag(bits))
115        };
116        let path = s.path().unwrap_or("").to_string();
117        out.push(index_c);
118        out.push(wt_c);
119        out.push(' ');
120        out.push_str(&path);
121        out.push('\n');
122    }
123    Ok(out)
124}
125
126pub fn has_changes(cwd: &Path) -> Result<bool> {
127    Ok(!status_porcelain(cwd)?.trim().is_empty())
128}
129
130pub fn current_branch(cwd: &Path) -> Result<String> {
131    let repo = git2::Repository::open(cwd).map_err(|_| GitError::NotARepo(cwd.to_path_buf()))?;
132    let head_ref = repo.find_reference("HEAD")?;
133    let sym = head_ref
134        .symbolic_target()
135        .ok_or_else(|| GitError::Libgit2(git2::Error::from_str("HEAD is not symbolic")))?;
136    Ok(sym.strip_prefix("refs/heads/").unwrap_or(sym).to_string())
137}
138
139fn index_flag(s: git2::Status) -> char {
140    if s.contains(git2::Status::INDEX_NEW) {
141        'A'
142    } else if s.contains(git2::Status::INDEX_MODIFIED) {
143        'M'
144    } else if s.contains(git2::Status::INDEX_DELETED) {
145        'D'
146    } else if s.contains(git2::Status::INDEX_RENAMED) {
147        'R'
148    } else if s.contains(git2::Status::INDEX_TYPECHANGE) {
149        'T'
150    } else {
151        ' '
152    }
153}
154
155fn worktree_flag(s: git2::Status) -> char {
156    if s.contains(git2::Status::WT_NEW) {
157        '?'
158    } else if s.contains(git2::Status::WT_MODIFIED) {
159        'M'
160    } else if s.contains(git2::Status::WT_DELETED) {
161        'D'
162    } else if s.contains(git2::Status::WT_RENAMED) {
163        'R'
164    } else if s.contains(git2::Status::WT_TYPECHANGE) {
165        'T'
166    } else {
167        ' '
168    }
169}
170
171pub struct GitCli {
172    cwd: PathBuf,
173}
174
175impl GitCli {
176    pub fn at(cwd: impl Into<PathBuf>) -> Self {
177        Self { cwd: cwd.into() }
178    }
179
180    pub fn cwd(&self) -> &Path {
181        &self.cwd
182    }
183
184    pub fn ensure_available() -> Result<()> {
185        let out = std::process::Command::new("git").arg("--version").output();
186        match out {
187            Ok(o) if o.status.success() => Ok(()),
188            Ok(o) => Err(GitError::NotAvailable(format!(
189                "git --version exit {}",
190                o.status
191            ))),
192            Err(e) => Err(GitError::NotAvailable(format!("spawn: {e}"))),
193        }
194    }
195
196    pub fn run(&self, args: &[&str]) -> Result<String> {
197        let out = self.spawn(args)?;
198        if !out.status.success() {
199            return Err(GitError::ExitNonZero {
200                args: args.join(" "),
201                code: out.status.code().unwrap_or(-1),
202                stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),
203            });
204        }
205        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
206    }
207
208    fn spawn(&self, args: &[&str]) -> Result<Output> {
209        let out = std::process::Command::new("git")
210            .args(args)
211            .current_dir(&self.cwd)
212            .output()?;
213        Ok(out)
214    }
215
216    pub fn init(&self, branch: &str) -> Result<()> {
217        std::fs::create_dir_all(&self.cwd)?;
218        self.run(&["init"])?;
219        self.run(&["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")])?;
220        Ok(())
221    }
222
223    pub fn add_all(&self) -> Result<()> {
224        self.run(&["add", "."]).map(|_| ())
225    }
226
227    pub fn commit(&self, message: &str) -> Result<()> {
228        self.run(&["commit", "-m", message]).map(|_| ())
229    }
230
231    pub fn push(&self, remote: &str, branch: &str) -> Result<String> {
232        self.run(&["push", "-u", remote, branch])
233    }
234
235    pub fn pull_rebase(&self, remote: &str, branch: &str) -> Result<String> {
236        self.run(&["pull", "--rebase", remote, branch])
237    }
238
239    pub fn fetch(&self, remote: &str, branch: &str) -> Result<()> {
240        self.run(&["fetch", remote, branch]).map(|_| ())
241    }
242
243    pub fn reset_hard(&self, target: &str) -> Result<()> {
244        self.run(&["reset", "--hard", target]).map(|_| ())
245    }
246
247    pub fn ref_exists(&self, refname: &str) -> Result<bool> {
248        match self.spawn(&["show-ref", "--verify", refname])? {
249            o if o.status.success() => Ok(true),
250            _ => Ok(false),
251        }
252    }
253
254    pub fn remote_exists(&self, name: &str) -> Result<bool> {
255        let text = self.run(&["remote"])?;
256        Ok(text.lines().any(|l| l.trim() == name))
257    }
258
259    pub fn remote_add(&self, name: &str, url: &str) -> Result<()> {
260        self.run(&["remote", "add", name, url]).map(|_| ())
261    }
262
263    pub fn remote_set_url(&self, name: &str, url: &str) -> Result<()> {
264        self.run(&["remote", "set-url", name, url]).map(|_| ())
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    fn have_git() -> bool {
273        GitCli::ensure_available().is_ok()
274    }
275
276    fn seed_two_commits(dir: &Path) {
277        let cli = GitCli::at(dir);
278        cli.init("main").unwrap();
279        for (k, v) in [
280            ("user.email", "t@atman.local"),
281            ("user.name", "atman test"),
282            ("commit.gpgsign", "false"),
283        ] {
284            cli.run(&["config", k, v]).unwrap();
285        }
286        std::fs::write(dir.join("a.txt"), "line one\n").unwrap();
287        std::fs::write(dir.join("b.txt"), "b\n").unwrap();
288        cli.add_all().unwrap();
289        cli.commit("initial").unwrap();
290        std::fs::write(dir.join("a.txt"), "line one\nline two\n").unwrap();
291        std::fs::write(dir.join("c.txt"), "new file\n").unwrap();
292        cli.add_all().unwrap();
293        cli.commit("second").unwrap();
294    }
295
296    #[test]
297    fn discover_toplevel_finds_repo_root_from_subdir() {
298        if !have_git() {
299            eprintln!("skip: git not on PATH");
300            return;
301        }
302        let tmp = tempfile::tempdir().unwrap();
303        seed_two_commits(tmp.path());
304        let sub = tmp.path().join("nested/deep");
305        std::fs::create_dir_all(&sub).unwrap();
306        let root = discover_toplevel(&sub).unwrap();
307        assert_eq!(
308            std::fs::canonicalize(&root).unwrap(),
309            std::fs::canonicalize(tmp.path()).unwrap()
310        );
311    }
312
313    #[test]
314    fn discover_toplevel_outside_repo_errors() {
315        let tmp = tempfile::tempdir().unwrap();
316        let err = discover_toplevel(tmp.path()).unwrap_err();
317        assert!(matches!(err, GitError::NotARepo(_)), "got {err:?}");
318    }
319
320    #[test]
321    fn diff_range_reports_files_and_body() {
322        if !have_git() {
323            eprintln!("skip: git not on PATH");
324            return;
325        }
326        let tmp = tempfile::tempdir().unwrap();
327        seed_two_commits(tmp.path());
328        let out = diff_range(tmp.path(), "HEAD~1..HEAD", &[]).unwrap();
329        assert!(
330            out.body.contains("+line two"),
331            "want addition, got:\n{}",
332            out.body
333        );
334        assert!(
335            out.body.contains("+new file"),
336            "want new file body:\n{}",
337            out.body
338        );
339        assert!(
340            out.files.contains(&"a.txt".to_string()),
341            "files={:?}",
342            out.files
343        );
344        assert!(
345            out.files.contains(&"c.txt".to_string()),
346            "files={:?}",
347            out.files
348        );
349    }
350
351    #[test]
352    fn diff_range_paths_filter_narrows() {
353        if !have_git() {
354            eprintln!("skip");
355            return;
356        }
357        let tmp = tempfile::tempdir().unwrap();
358        seed_two_commits(tmp.path());
359        let out = diff_range(tmp.path(), "HEAD~1..HEAD", &["a.txt".to_string()]).unwrap();
360        assert_eq!(
361            out.files,
362            vec!["a.txt".to_string()],
363            "files={:?}",
364            out.files
365        );
366    }
367
368    #[test]
369    fn status_porcelain_reflects_worktree_changes() {
370        if !have_git() {
371            eprintln!("skip");
372            return;
373        }
374        let tmp = tempfile::tempdir().unwrap();
375        seed_two_commits(tmp.path());
376        assert!(!has_changes(tmp.path()).unwrap(), "clean tree");
377        std::fs::write(tmp.path().join("a.txt"), "changed\n").unwrap();
378        std::fs::write(tmp.path().join("d.txt"), "new\n").unwrap();
379        let text = status_porcelain(tmp.path()).unwrap();
380        assert!(text.contains(" M a.txt"), "want dirty a.txt: {text}");
381        assert!(text.contains("?? d.txt"), "want untracked d.txt: {text}");
382        assert!(has_changes(tmp.path()).unwrap());
383    }
384
385    #[test]
386    fn current_branch_after_first_commit_is_main() {
387        if !have_git() {
388            eprintln!("skip");
389            return;
390        }
391        let tmp = tempfile::tempdir().unwrap();
392        seed_two_commits(tmp.path());
393        assert_eq!(current_branch(tmp.path()).unwrap(), "main");
394    }
395
396    #[test]
397    fn git_cli_remote_add_and_lookup() {
398        if !have_git() {
399            eprintln!("skip");
400            return;
401        }
402        let tmp = tempfile::tempdir().unwrap();
403        let cli = GitCli::at(tmp.path());
404        cli.init("main").unwrap();
405        assert!(!cli.remote_exists("origin").unwrap());
406        cli.remote_add("origin", "https://example.invalid/repo.git")
407            .unwrap();
408        assert!(cli.remote_exists("origin").unwrap());
409        cli.remote_set_url("origin", "https://example.invalid/other.git")
410            .unwrap();
411        let list = cli.run(&["remote", "get-url", "origin"]).unwrap();
412        assert!(list.contains("other.git"), "want reset url: {list}");
413    }
414
415    #[test]
416    fn git_cli_run_maps_exit_code_to_error() {
417        if !have_git() {
418            eprintln!("skip");
419            return;
420        }
421        let tmp = tempfile::tempdir().unwrap();
422        let cli = GitCli::at(tmp.path());
423        let err = cli.run(&["diff", "HEAD"]).unwrap_err();
424        match err {
425            GitError::ExitNonZero { code, .. } => assert_ne!(code, 0),
426            other => panic!("want ExitNonZero, got {other:?}"),
427        }
428    }
429}