Skip to main content

git_slop/
git.rs

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}