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}