1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use anyhow::{Context, Result, bail};
5
6use crate::model::RepoMetadata;
7
8fn git_output(repo_root: Option<&Path>, args: &[&str]) -> Result<String> {
9 let mut command = Command::new("git");
10 if let Some(root) = repo_root {
11 command.current_dir(root);
12 }
13 let output = command
14 .args(args)
15 .output()
16 .context("failed to execute git")?;
17 if !output.status.success() {
18 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
19 bail!(
20 "git {} failed{}",
21 args.join(" "),
22 if stderr.is_empty() {
23 String::new()
24 } else {
25 format!(": {stderr}")
26 }
27 );
28 }
29 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
30}
31
32pub fn resolve_repo_root() -> Result<PathBuf> {
33 resolve_repo_root_from(None)
34}
35
36pub fn resolve_repo_root_from(start: Option<&Path>) -> Result<PathBuf> {
37 let root = git_output(start, &["rev-parse", "--show-toplevel"])?;
38 Ok(PathBuf::from(root))
39}
40
41pub fn list_tracked_files(repo_root: &Path) -> Result<Vec<String>> {
42 let output = Command::new("git")
43 .current_dir(repo_root)
44 .args(["ls-files", "-z"])
45 .output()
46 .context("failed to list tracked files")?;
47 if !output.status.success() {
48 bail!(
49 "git ls-files failed: {}",
50 String::from_utf8_lossy(&output.stderr).trim()
51 );
52 }
53 let mut paths: Vec<String> = output
54 .stdout
55 .split(|byte| *byte == 0)
56 .filter(|raw| !raw.is_empty())
57 .map(|raw| String::from_utf8_lossy(raw).replace('\\', "/"))
58 .collect();
59 paths.sort();
60 Ok(paths)
61}
62
63fn optional_git_output(repo_root: &Path, args: &[&str]) -> Option<String> {
64 git_output(Some(repo_root), args)
65 .ok()
66 .filter(|value| !value.is_empty())
67}
68
69pub fn repo_metadata(repo_root: &Path) -> Result<RepoMetadata> {
70 let canonical = repo_root
71 .canonicalize()
72 .unwrap_or_else(|_| repo_root.to_path_buf());
73 let repo_name = canonical
74 .file_name()
75 .and_then(|name| name.to_str())
76 .unwrap_or("repository")
77 .to_string();
78 let branch = optional_git_output(repo_root, &["symbolic-ref", "--short", "-q", "HEAD"]);
79 let head_commit = optional_git_output(repo_root, &["rev-parse", "HEAD"]);
80 let head_commit_timestamp = head_commit
81 .as_ref()
82 .and_then(|_| optional_git_output(repo_root, &["show", "-s", "--format=%cI", "HEAD"]));
83 let git_remote_url = optional_git_output(repo_root, &["config", "--get", "remote.origin.url"]);
84 let is_shallow = optional_git_output(repo_root, &["rev-parse", "--is-shallow-repository"])
85 .is_some_and(|value| value == "true");
86 Ok(RepoMetadata {
87 repo_name,
88 repo_root: canonical.to_string_lossy().into_owned(),
89 branch,
90 head_commit,
91 head_commit_timestamp,
92 git_remote_url,
93 is_shallow,
94 })
95}
96
97pub fn changed_files(repo_root: &Path, base: &str, head: &str) -> Result<Vec<String>> {
98 let output = git_output(
99 Some(repo_root),
100 &["diff", "--name-only", "--diff-filter=ACMR", base, head],
101 )?;
102 let mut paths: Vec<String> = output
103 .lines()
104 .map(str::trim)
105 .filter(|line| !line.is_empty())
106 .map(ToOwned::to_owned)
107 .collect();
108 paths.sort();
109 paths.dedup();
110 Ok(paths)
111}