1use std::path::Path;
24use std::process::{Command, Stdio};
25
26#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct GitChange {
32 pub file_path: String,
33 pub operation: String,
35 pub additions: Option<u32>,
36 pub deletions: Option<u32>,
37}
38
39fn git_capture(repo_dir: &Path, args: &[&str]) -> Option<String> {
44 let output = Command::new("git")
45 .arg("-C").arg(repo_dir)
46 .args(args)
47 .stdin(Stdio::null())
48 .stdout(Stdio::piped())
49 .stderr(Stdio::null())
50 .output()
51 .ok()?;
52 if !output.status.success() {
53 return None;
54 }
55 let mut s = String::from_utf8(output.stdout).ok()?;
56 while s.ends_with('\n') {
57 s.pop();
58 }
59 Some(s)
60}
61
62fn is_git_repo(repo_dir: &Path) -> bool {
64 git_capture(repo_dir, &["rev-parse", "--is-inside-work-tree"])
65 .as_deref()
66 == Some("true")
67}
68
69fn translate_status(code: &str) -> &'static str {
74 match code.chars().next().unwrap_or(' ') {
75 'A' => "created",
76 'D' => "deleted",
77 'R' => "renamed",
78 'C' => "created", 'T' => "modified",
80 '?' => "untracked",
81 _ => "modified", }
83}
84
85fn parse_name_status_line(line: &str) -> Option<(&'static str, String)> {
110 let mut parts = line.split('\t');
111 let code = parts.next()?;
112 if code.is_empty() {
113 return None;
114 }
115 let first_path = parts.next()?;
116 let op = translate_status(code);
117 let path = match code.chars().next().unwrap_or(' ') {
118 'R' | 'C' => {
119 parts.next().map(|p| p.to_string()).unwrap_or_else(|| first_path.to_string())
123 }
124 _ => first_path.to_string(),
125 };
126 Some((op, path))
127}
128
129fn parse_numstat_line(line: &str) -> Option<(String, Option<u32>, Option<u32>)> {
133 let mut parts = line.splitn(3, '\t');
134 let adds_s = parts.next()?;
135 let dels_s = parts.next()?;
136 let path = parts.next()?.to_string();
137 let adds = adds_s.parse::<u32>().ok();
138 let dels = dels_s.parse::<u32>().ok();
139 Some((path, adds, dels))
140}
141
142fn is_treeship_runtime_artifact(path: &str) -> bool {
157 let p = path.strip_prefix("./").unwrap_or(path);
159 if !p.starts_with(".treeship/") && p != ".treeship" {
160 return false;
161 }
162 p == ".treeship/session.closing"
164 || p == ".treeship/session.json"
165 || p.starts_with(".treeship/sessions/")
166 || p.starts_with(".treeship/artifacts/")
167 || p.starts_with(".treeship/tmp/")
168 || p.starts_with(".treeship/proof_queue/")
169}
170
171pub fn reconcile_changes(repo_dir: &Path, since_sha: Option<&str>) -> Vec<GitChange> {
184 if !is_git_repo(repo_dir) {
185 return Vec::new();
186 }
187
188 use std::collections::BTreeMap;
189 let mut by_path: BTreeMap<String, GitChange> = BTreeMap::new();
192
193 let mut record = |path: String, op: &str| {
194 if is_treeship_runtime_artifact(&path) {
195 return;
196 }
197 by_path.entry(path.clone()).or_insert(GitChange {
198 file_path: path,
199 operation: op.to_string(),
200 additions: None,
201 deletions: None,
202 });
203 };
204
205 if let Some(out) = git_capture(repo_dir, &["diff", "HEAD", "--name-status"]) {
209 for line in out.lines() {
210 if let Some((op, path)) = parse_name_status_line(line) {
211 record(path, op);
212 }
213 }
214 }
215
216 if let Some(sha) = since_sha {
219 let range = format!("{sha}..HEAD");
220 if let Some(out) = git_capture(repo_dir, &["diff", &range, "--name-status"]) {
221 for line in out.lines() {
222 if let Some((op, path)) = parse_name_status_line(line) {
223 record(path, op);
224 }
225 }
226 }
227 }
228
229 if let Some(out) = git_capture(repo_dir, &["ls-files", "--others", "--exclude-standard"]) {
233 for path in out.lines().filter(|l| !l.is_empty()) {
234 record(path.to_string(), "untracked");
235 }
236 }
237
238 if let Some(out) = git_capture(repo_dir, &["diff", "HEAD", "--numstat"]) {
242 for line in out.lines() {
243 if let Some((path, adds, dels)) = parse_numstat_line(line) {
244 if let Some(entry) = by_path.get_mut(&path) {
245 entry.additions = adds;
246 entry.deletions = dels;
247 }
248 }
249 }
250 }
251 if let Some(sha) = since_sha {
252 let range = format!("{sha}..HEAD");
253 if let Some(out) = git_capture(repo_dir, &["diff", &range, "--numstat"]) {
254 for line in out.lines() {
255 if let Some((path, adds, dels)) = parse_numstat_line(line) {
256 if let Some(entry) = by_path.get_mut(&path) {
257 entry.additions = adds;
258 entry.deletions = dels;
259 }
260 }
261 }
262 }
263 }
264
265 by_path.into_values().collect()
266}
267
268pub fn current_head_sha(repo_dir: &Path) -> Option<String> {
273 if !is_git_repo(repo_dir) {
274 return None;
275 }
276 git_capture(repo_dir, &["rev-parse", "HEAD"])
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn translate_status_maps_known_codes() {
285 assert_eq!(translate_status("A"), "created");
286 assert_eq!(translate_status("M"), "modified");
287 assert_eq!(translate_status("D"), "deleted");
288 assert_eq!(translate_status("R100"), "renamed");
289 assert_eq!(translate_status("??"), "untracked");
290 assert_eq!(translate_status(""), "modified");
291 assert_eq!(translate_status("X"), "modified");
292 }
293
294 #[test]
295 fn parse_numstat_handles_text_and_binary() {
296 let (p, a, d) = parse_numstat_line("12\t3\tsrc/a.rs").unwrap();
297 assert_eq!(p, "src/a.rs");
298 assert_eq!(a, Some(12));
299 assert_eq!(d, Some(3));
300
301 let (p, a, d) = parse_numstat_line("-\t-\tassets/logo.png").unwrap();
303 assert_eq!(p, "assets/logo.png");
304 assert_eq!(a, None);
305 assert_eq!(d, None);
306 }
307
308 #[test]
309 fn reconcile_in_non_git_dir_returns_empty() {
310 let tmp = std::env::temp_dir().join(format!("treeship-not-a-repo-{}", rand::random::<u32>()));
311 std::fs::create_dir_all(&tmp).unwrap();
312 let result = reconcile_changes(&tmp, None);
313 assert!(result.is_empty());
314 let _ = std::fs::remove_dir_all(&tmp);
315 }
316
317 #[test]
323 fn parse_name_status_modify_uses_single_path() {
324 let (op, path) = parse_name_status_line("M\tsrc/lib.rs").unwrap();
325 assert_eq!(op, "modified");
326 assert_eq!(path, "src/lib.rs");
327 }
328
329 #[test]
330 fn parse_name_status_added_uses_single_path() {
331 let (op, path) = parse_name_status_line("A\tsrc/new.rs").unwrap();
332 assert_eq!(op, "created");
333 assert_eq!(path, "src/new.rs");
334 }
335
336 #[test]
337 fn parse_name_status_deleted_uses_single_path() {
338 let (op, path) = parse_name_status_line("D\tsrc/gone.rs").unwrap();
339 assert_eq!(op, "deleted");
340 assert_eq!(path, "src/gone.rs");
341 }
342
343 #[test]
344 fn parse_name_status_rename_uses_destination() {
345 let (op, path) = parse_name_status_line("R100\tsrc/old.rs\tsrc/new.rs").unwrap();
349 assert_eq!(op, "renamed");
350 assert_eq!(path, "src/new.rs", "rename must record the destination, not the source");
351 }
352
353 #[test]
354 fn parse_name_status_copy_uses_destination() {
355 let (op, path) = parse_name_status_line("C75\tsrc/template.rs\tsrc/new-from-template.rs").unwrap();
358 assert_eq!(op, "created");
359 assert_eq!(path, "src/new-from-template.rs", "copy must record the destination");
360 }
361
362 #[test]
363 fn parse_name_status_rename_falls_back_to_source_if_dest_missing() {
364 let (op, path) = parse_name_status_line("R100\tsrc/only-old.rs").unwrap();
369 assert_eq!(op, "renamed");
370 assert_eq!(path, "src/only-old.rs");
371 }
372
373 #[test]
374 fn parse_name_status_handles_empty_or_garbage_lines() {
375 assert!(parse_name_status_line("").is_none());
376 assert!(parse_name_status_line("\t\t").is_none()); assert!(parse_name_status_line("M").is_none());
379 }
380
381 #[test]
382 fn runtime_artifact_filter_excludes_generated_state() {
383 assert!(is_treeship_runtime_artifact(".treeship/session.closing"));
385 assert!(is_treeship_runtime_artifact(".treeship/session.json"));
386 assert!(is_treeship_runtime_artifact(".treeship/sessions/ssn_abc/events.jsonl"));
387 assert!(is_treeship_runtime_artifact(".treeship/sessions/ssn_abc/manifest.json"));
388 assert!(is_treeship_runtime_artifact(".treeship/artifacts/foo.json"));
389 assert!(is_treeship_runtime_artifact(".treeship/tmp/scratch"));
390 assert!(is_treeship_runtime_artifact(".treeship/proof_queue/pending.json"));
391
392 assert!(is_treeship_runtime_artifact("./.treeship/session.closing"));
394 assert!(is_treeship_runtime_artifact("./.treeship/sessions/ssn_x/events.jsonl"));
395 }
396
397 #[test]
398 fn runtime_artifact_filter_preserves_user_authored_files() {
399 assert!(!is_treeship_runtime_artifact(".treeship/config.yaml"));
402 assert!(!is_treeship_runtime_artifact(".treeship/config.json"));
403 assert!(!is_treeship_runtime_artifact(".treeship/declaration.json"));
404 assert!(!is_treeship_runtime_artifact(".treeship/policy.yaml"));
405 assert!(!is_treeship_runtime_artifact(".treeship/agents/coder.agent"));
406 assert!(!is_treeship_runtime_artifact(".treeship/agents/reviewer.json"));
407
408 assert!(!is_treeship_runtime_artifact("src/main.rs"));
410 assert!(!is_treeship_runtime_artifact("README.md"));
411 assert!(!is_treeship_runtime_artifact("treeship-notes.md"));
412 assert!(!is_treeship_runtime_artifact(".treeshiprc"));
413 }
414
415 #[test]
416 fn reconcile_filters_runtime_artifacts_end_to_end() {
417 let tmp = std::env::temp_dir().join(format!("treeship-reconcile-{}", rand::random::<u32>()));
422 std::fs::create_dir_all(&tmp).unwrap();
423
424 let run = |args: &[&str]| {
425 std::process::Command::new("git")
426 .arg("-C").arg(&tmp)
427 .args(args)
428 .stdout(std::process::Stdio::null())
429 .stderr(std::process::Stdio::null())
430 .status()
431 .ok();
432 };
433
434 run(&["init", "-q"]);
435 run(&["config", "user.email", "test@example.com"]);
436 run(&["config", "user.name", "Test"]);
437 std::fs::write(tmp.join("README.md"), "hi\n").unwrap();
438 run(&["add", "."]);
439 run(&["commit", "-q", "-m", "init"]);
440
441 std::fs::create_dir_all(tmp.join(".treeship/sessions/ssn_x")).unwrap();
443 std::fs::create_dir_all(tmp.join(".treeship/artifacts")).unwrap();
444 std::fs::create_dir_all(tmp.join(".treeship/agents")).unwrap();
445 std::fs::write(tmp.join(".treeship/sessions/ssn_x/events.jsonl"), "{}\n").unwrap();
446 std::fs::write(tmp.join(".treeship/artifacts/foo.json"), "{}\n").unwrap();
447 std::fs::write(tmp.join(".treeship/session.closing"), "").unwrap();
448 std::fs::write(tmp.join(".treeship/agents/coder.agent"), "name: coder\n").unwrap();
449 std::fs::write(tmp.join(".treeship/declaration.json"), "{}\n").unwrap();
450 std::fs::write(tmp.join("src.rs"), "fn main() {}\n").unwrap();
451
452 let changes = reconcile_changes(&tmp, None);
453 let paths: Vec<&str> = changes.iter().map(|c| c.file_path.as_str()).collect();
454
455 assert!(paths.contains(&"src.rs"), "user file missing: {paths:?}");
457 assert!(paths.contains(&".treeship/agents/coder.agent"), "agent card missing: {paths:?}");
458 assert!(paths.contains(&".treeship/declaration.json"), "declaration missing: {paths:?}");
459
460 assert!(!paths.contains(&".treeship/sessions/ssn_x/events.jsonl"), "leaked: {paths:?}");
462 assert!(!paths.contains(&".treeship/artifacts/foo.json"), "leaked: {paths:?}");
463 assert!(!paths.contains(&".treeship/session.closing"), "leaked: {paths:?}");
464
465 let _ = std::fs::remove_dir_all(&tmp);
466 }
467}