Skip to main content

git_slop/
git.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use anyhow::{Context, Result, bail};
5use sha2::{Digest, Sha256};
6
7use crate::model::RepoMetadata;
8
9fn git_output(repo_root: Option<&Path>, args: &[&str]) -> Result<String> {
10    let mut command = Command::new("git");
11    if let Some(root) = repo_root {
12        command.current_dir(root);
13    }
14    let output = command
15        .args(args)
16        .output()
17        .context("failed to execute git")?;
18    if !output.status.success() {
19        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
20        bail!(
21            "git {} failed{}",
22            args.join(" "),
23            if stderr.is_empty() {
24                String::new()
25            } else {
26                format!(": {stderr}")
27            }
28        );
29    }
30    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
31}
32
33pub fn resolve_repo_root() -> Result<PathBuf> {
34    resolve_repo_root_from(None)
35}
36
37pub fn resolve_repo_root_from(start: Option<&Path>) -> Result<PathBuf> {
38    let root = git_output(start, &["rev-parse", "--show-toplevel"])?;
39    Ok(PathBuf::from(root))
40}
41
42pub fn list_tracked_files(repo_root: &Path) -> Result<Vec<String>> {
43    let output = Command::new("git")
44        .current_dir(repo_root)
45        .args(["ls-files", "-z"])
46        .output()
47        .context("failed to list tracked files")?;
48    if !output.status.success() {
49        bail!(
50            "git ls-files failed: {}",
51            String::from_utf8_lossy(&output.stderr).trim()
52        );
53    }
54    let mut paths = Vec::new();
55    for raw in output
56        .stdout
57        .split(|byte| *byte == 0)
58        .filter(|raw| !raw.is_empty())
59    {
60        let path = std::str::from_utf8(raw).with_context(|| {
61            format!(
62                "tracked path is not valid UTF-8 (hex {}); report JSON cannot represent it losslessly",
63                hex::encode(raw)
64            )
65        })?;
66        paths.push(path.replace('\\', "/"));
67    }
68    paths.sort();
69    Ok(paths)
70}
71
72fn optional_git_output(repo_root: &Path, args: &[&str]) -> Option<String> {
73    git_output(Some(repo_root), args)
74        .ok()
75        .filter(|value| !value.is_empty())
76}
77
78fn sanitize_remote_url(remote: String) -> Option<String> {
79    let remote = remote
80        .trim()
81        .chars()
82        .filter(|character| !character.is_control())
83        .collect::<String>();
84    let mut sanitized = remote
85        .split(['?', '#'])
86        .next()
87        .unwrap_or_default()
88        .to_string();
89    let local_path = sanitized.starts_with("file://")
90        || Path::new(&sanitized).is_absolute()
91        || sanitized.starts_with("./")
92        || sanitized.starts_with("../");
93    if local_path {
94        return Some(format!(
95            "local:sha256:{}",
96            hex::encode(Sha256::digest(sanitized.as_bytes()))
97        ));
98    }
99    if let Some(scheme) = sanitized.find("://") {
100        let authority = scheme + 3;
101        if let Some(at) = sanitized[authority..].find('@') {
102            sanitized.replace_range(authority..authority + at + 1, "");
103        }
104        return (!sanitized.is_empty()).then_some(sanitized);
105    }
106    // SCP-like remotes commonly include a user name before the host.
107    if let (Some(at), Some(colon)) = (sanitized.find('@'), sanitized.find(':')) {
108        if at < colon {
109            sanitized.replace_range(..=at, "");
110        }
111    }
112    (!sanitized.is_empty()).then_some(sanitized)
113}
114
115fn normalized_remote_identity(remote: &str) -> Option<String> {
116    if remote.starts_with("local:sha256:") {
117        return None;
118    }
119    let without_scheme = remote
120        .split_once("://")
121        .map_or(remote, |(_, remainder)| remainder);
122    let normalized = if !remote.contains("://") {
123        if let Some((host, path)) = without_scheme.split_once(':') {
124            format!("{host}/{path}")
125        } else {
126            without_scheme.to_string()
127        }
128    } else {
129        without_scheme.to_string()
130    };
131    let mut parts = normalized.trim_matches('/').split('/');
132    let host = parts.next()?.to_ascii_lowercase();
133    let owner = parts.next()?.to_ascii_lowercase();
134    let name = parts.next()?.trim_end_matches(".git").to_ascii_lowercase();
135    if host.is_empty() || owner.is_empty() || name.is_empty() {
136        return None;
137    }
138    Some(format!("remote:{host}/{owner}/{name}"))
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct WorktreeState {
143    pub clean: bool,
144    pub staged_change_count: usize,
145    pub modified_tracked_file_count: usize,
146    pub untracked_file_count: usize,
147    pub digest: String,
148}
149
150fn porcelain_counts(raw: &[u8]) -> (usize, usize, usize) {
151    let mut staged = 0;
152    let mut modified = 0;
153    let mut untracked = 0;
154    let entries = raw.split(|byte| *byte == 0).collect::<Vec<_>>();
155    let mut index = 0;
156    while index < entries.len() {
157        let entry = entries[index];
158        index += 1;
159        if entry.len() < 3 {
160            continue;
161        }
162        if entry.starts_with(b"?? ") {
163            untracked += 1;
164            continue;
165        }
166        if entry[0] != b' ' && entry[0] != b'?' {
167            staged += 1;
168        }
169        if entry[1] != b' ' && entry[1] != b'?' {
170            modified += 1;
171        }
172        if matches!(entry[0], b'R' | b'C') || matches!(entry[1], b'R' | b'C') {
173            index = index.saturating_add(1);
174        }
175    }
176    (staged, modified, untracked)
177}
178
179pub fn worktree_state(repo_root: &Path) -> Result<WorktreeState> {
180    worktree_state_excluding(repo_root, &[])
181}
182
183pub fn worktree_state_excluding(
184    repo_root: &Path,
185    excluded_roots: &[String],
186) -> Result<WorktreeState> {
187    let mut arguments = vec![
188        "status".to_string(),
189        "--porcelain=v1".to_string(),
190        "-z".to_string(),
191        "--untracked-files=all".to_string(),
192    ];
193    if !excluded_roots.is_empty() {
194        arguments.extend(["--".to_string(), ".".to_string()]);
195        for path in excluded_roots {
196            let path = path.trim_matches('/');
197            arguments.push(format!(":(exclude,top){path}"));
198            arguments.push(format!(":(exclude,top){path}/**"));
199        }
200    }
201    let output = Command::new("git")
202        .current_dir(repo_root)
203        .args(&arguments)
204        .output()
205        .context("failed to inspect Git worktree state")?;
206    if !output.status.success() {
207        bail!(
208            "git status failed: {}",
209            String::from_utf8_lossy(&output.stderr).trim()
210        );
211    }
212    let (staged, modified, untracked) = porcelain_counts(&output.stdout);
213    Ok(WorktreeState {
214        clean: output.stdout.is_empty(),
215        staged_change_count: staged,
216        modified_tracked_file_count: modified,
217        untracked_file_count: untracked,
218        digest: hex::encode(Sha256::digest(&output.stdout)),
219    })
220}
221
222pub fn repo_metadata(repo_root: &Path) -> Result<RepoMetadata> {
223    let canonical = repo_root
224        .canonicalize()
225        .unwrap_or_else(|_| repo_root.to_path_buf());
226    let repo_name = canonical
227        .file_name()
228        .and_then(|name| name.to_str())
229        .unwrap_or("repository")
230        .to_string();
231    let symbolic_branch =
232        optional_git_output(repo_root, &["symbolic-ref", "--short", "-q", "HEAD"]);
233    let head_commit = optional_git_output(repo_root, &["rev-parse", "HEAD"]);
234    let detached_head = symbolic_branch.is_none() && head_commit.is_some();
235    let branch = symbolic_branch.or_else(|| {
236        optional_git_output(repo_root, &["describe", "--tags", "--exact-match", "HEAD"])
237    });
238    let head_commit_timestamp = head_commit
239        .as_ref()
240        .and_then(|_| optional_git_output(repo_root, &["show", "-s", "--format=%cI", "HEAD"]));
241    let git_remote_url = optional_git_output(repo_root, &["config", "--get", "remote.origin.url"])
242        .and_then(sanitize_remote_url);
243    let remote_identity = git_remote_url
244        .as_deref()
245        .and_then(normalized_remote_identity);
246    let root_commit = optional_git_output(repo_root, &["rev-list", "--max-parents=0", "HEAD"])
247        .and_then(|roots| roots.lines().min().map(ToOwned::to_owned));
248    let (repository_id, repository_identity_source) = if let Some(identity) = remote_identity {
249        (Some(identity), Some("normalized_remote".to_string()))
250    } else if let Some(root) = root_commit {
251        (
252            Some(format!("root:{root}")),
253            Some("root_commit".to_string()),
254        )
255    } else {
256        (None, None)
257    };
258    let is_shallow = optional_git_output(repo_root, &["rev-parse", "--is-shallow-repository"])
259        .is_some_and(|value| value == "true");
260    let worktree = worktree_state(repo_root)?;
261    Ok(RepoMetadata {
262        repo_name,
263        repo_root: canonical.to_string_lossy().into_owned(),
264        repository_id,
265        repository_identity_source,
266        branch,
267        head_commit,
268        head_commit_timestamp,
269        git_remote_url,
270        is_shallow,
271        detached_head,
272        worktree_clean: worktree.clean,
273        staged_change_count: worktree.staged_change_count,
274        modified_tracked_file_count: worktree.modified_tracked_file_count,
275        untracked_file_count: worktree.untracked_file_count,
276        worktree_state_digest: worktree.digest,
277        analyzed_content_digest: None,
278    })
279}
280
281#[cfg(test)]
282mod tests {
283    use super::{normalized_remote_identity, porcelain_counts, sanitize_remote_url};
284
285    #[test]
286    fn porcelain_rename_second_paths_are_not_counted_as_changes() {
287        assert_eq!(
288            porcelain_counts(b"R  new name.rs\0old name.rs\0 M tracked.rs\0?? new.rs\0"),
289            (1, 1, 1)
290        );
291    }
292
293    #[test]
294    fn provenance_remote_sanitization_removes_secrets_and_fragments() {
295        assert_eq!(
296            sanitize_remote_url(
297                "https://user:token@example.com/owner/repo.git?secret=yes#x".to_string()
298            ),
299            Some("https://example.com/owner/repo.git".to_string())
300        );
301        assert_eq!(
302            sanitize_remote_url("token@example.com:owner/repo.git".to_string()),
303            Some("example.com:owner/repo.git".to_string())
304        );
305        assert!(
306            sanitize_remote_url("file:///Users/person/private/repo".to_string())
307                .is_some_and(|value| value.starts_with("local:sha256:"))
308        );
309    }
310
311    #[test]
312    fn repository_identity_normalizes_transport_and_case() {
313        assert_eq!(
314            normalized_remote_identity("https://GitHub.com/CoreyCoto/git-slop.git"),
315            Some("remote:github.com/coreycoto/git-slop".to_string())
316        );
317        assert_eq!(
318            normalized_remote_identity("github.com:CoreyCoto/git-slop.git"),
319            Some("remote:github.com/coreycoto/git-slop".to_string())
320        );
321        assert_eq!(normalized_remote_identity("local:sha256:abc"), None);
322    }
323}