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 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}