1use std::io::{BufRead, BufReader};
24use std::path::{Path, PathBuf};
25use std::process::{Command, Stdio};
26
27pub const DEFAULT_UNTRACKED_MAX: usize = 5_000;
28
29#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct GitChange {
35 pub file_path: String,
36 pub operation: String,
38 pub additions: Option<u32>,
39 pub deletions: Option<u32>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ReconcileOptions {
44 pub untracked_max: usize,
45}
46
47impl Default for ReconcileOptions {
48 fn default() -> Self {
49 Self {
50 untracked_max: DEFAULT_UNTRACKED_MAX,
51 }
52 }
53}
54
55#[derive(Debug, Clone, Default, PartialEq, Eq)]
56pub struct ReconcileSummary {
57 pub untracked_seen: usize,
58 pub untracked_cap: usize,
59 pub untracked_truncated: bool,
60 pub git_repo_present: bool,
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Eq)]
73pub struct ReconcileResult {
74 pub changes: Vec<GitChange>,
75 pub summary: ReconcileSummary,
76}
77
78fn git_capture(repo_dir: &Path, args: &[&str]) -> Option<String> {
83 let output = Command::new("git")
84 .arg("-C")
85 .arg(repo_dir)
86 .args(args)
87 .stdin(Stdio::null())
88 .stdout(Stdio::piped())
89 .stderr(Stdio::null())
90 .output()
91 .ok()?;
92 if !output.status.success() {
93 return None;
94 }
95 let mut s = String::from_utf8(output.stdout).ok()?;
96 while s.ends_with('\n') {
97 s.pop();
98 }
99 Some(s)
100}
101
102fn git_capture_lines_limited(
103 repo_dir: &Path,
104 args: &[&str],
105 limit: usize,
106) -> Option<(Vec<String>, bool)> {
107 let mut child = Command::new("git")
108 .arg("-C")
109 .arg(repo_dir)
110 .args(args)
111 .stdin(Stdio::null())
112 .stdout(Stdio::piped())
113 .stderr(Stdio::null())
114 .spawn()
115 .ok()?;
116 let stdout = child.stdout.take()?;
117 let reader = BufReader::new(stdout);
118 let mut lines = Vec::new();
119 let mut truncated = false;
120 for line in reader.lines() {
121 let line = line.ok()?;
122 if lines.len() >= limit {
123 truncated = true;
124 let _ = child.kill();
125 break;
126 }
127 lines.push(line);
128 }
129 let status = child.wait().ok()?;
130 if !truncated && !status.success() {
131 return None;
132 }
133 Some((lines, truncated))
134}
135
136fn is_git_repo(repo_dir: &Path) -> bool {
138 git_capture(repo_dir, &["rev-parse", "--is-inside-work-tree"]).as_deref() == Some("true")
139}
140
141pub fn git_toplevel(repo_dir: &Path) -> Option<PathBuf> {
142 git_capture(repo_dir, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
143}
144
145fn translate_status(code: &str) -> &'static str {
150 match code.chars().next().unwrap_or(' ') {
151 'A' => "created",
152 'D' => "deleted",
153 'R' => "renamed",
154 'C' => "created", 'T' => "modified",
156 '?' => "untracked",
157 _ => "modified", }
159}
160
161fn parse_name_status_line(line: &str) -> Option<(&'static str, String)> {
186 let mut parts = line.split('\t');
187 let code = parts.next()?;
188 if code.is_empty() {
189 return None;
190 }
191 let first_path = parts.next()?;
192 let op = translate_status(code);
193 let path = match code.chars().next().unwrap_or(' ') {
194 'R' | 'C' => {
195 parts
199 .next()
200 .map(|p| p.to_string())
201 .unwrap_or_else(|| first_path.to_string())
202 }
203 _ => first_path.to_string(),
204 };
205 Some((op, path))
206}
207
208fn parse_numstat_line(line: &str) -> Option<(String, Option<u32>, Option<u32>)> {
212 let mut parts = line.splitn(3, '\t');
213 let adds_s = parts.next()?;
214 let dels_s = parts.next()?;
215 let path = parts.next()?.to_string();
216 let adds = adds_s.parse::<u32>().ok();
217 let dels = dels_s.parse::<u32>().ok();
218 Some((path, adds, dels))
219}
220
221fn is_treeship_runtime_artifact(path: &str) -> bool {
236 let p = path.strip_prefix("./").unwrap_or(path);
238 if !p.starts_with(".treeship/") && p != ".treeship" {
239 return false;
240 }
241 p == ".treeship/session.closing"
243 || p == ".treeship/session.json"
244 || p.starts_with(".treeship/sessions/")
245 || p.starts_with(".treeship/artifacts/")
246 || p.starts_with(".treeship/tmp/")
247 || p.starts_with(".treeship/proof_queue/")
248}
249
250pub fn reconcile_changes(repo_dir: &Path, since_sha: Option<&str>) -> Vec<GitChange> {
263 reconcile_changes_with_options(repo_dir, since_sha, &ReconcileOptions::default()).changes
264}
265
266pub fn reconcile_changes_with_options(
267 repo_dir: &Path,
268 since_sha: Option<&str>,
269 options: &ReconcileOptions,
270) -> ReconcileResult {
271 let mut result = ReconcileResult {
272 summary: ReconcileSummary {
273 untracked_cap: options.untracked_max,
274 ..ReconcileSummary::default()
275 },
276 ..ReconcileResult::default()
277 };
278
279 if !is_git_repo(repo_dir) {
280 return result;
283 }
284 result.summary.git_repo_present = true;
285
286 use std::collections::BTreeMap;
287 let mut by_path: BTreeMap<String, GitChange> = BTreeMap::new();
290
291 let mut record = |path: String, op: &str| {
292 if is_treeship_runtime_artifact(&path) {
293 return;
294 }
295 by_path.entry(path.clone()).or_insert(GitChange {
296 file_path: path,
297 operation: op.to_string(),
298 additions: None,
299 deletions: None,
300 });
301 };
302
303 if let Some(out) = git_capture(repo_dir, &["diff", "HEAD", "--name-status"]) {
307 for line in out.lines() {
308 if let Some((op, path)) = parse_name_status_line(line) {
309 record(path, op);
310 }
311 }
312 }
313
314 if let Some(sha) = since_sha {
317 let range = format!("{sha}..HEAD");
318 if let Some(out) = git_capture(repo_dir, &["diff", &range, "--name-status"]) {
319 for line in out.lines() {
320 if let Some((op, path)) = parse_name_status_line(line) {
321 record(path, op);
322 }
323 }
324 }
325 }
326
327 if let Some((lines, truncated)) = git_capture_lines_limited(
331 repo_dir,
332 &["ls-files", "--others", "--exclude-standard"],
333 options.untracked_max.saturating_add(1),
334 ) {
335 result.summary.untracked_seen = lines.len();
336 result.summary.untracked_truncated = truncated || lines.len() > options.untracked_max;
337 if result.summary.untracked_truncated {
338 result.summary.untracked_seen = result
339 .summary
340 .untracked_seen
341 .max(options.untracked_max.saturating_add(1));
342 } else {
343 for path in lines.iter().filter(|l| !l.is_empty()) {
344 record(path.to_string(), "untracked");
345 }
346 }
347 }
348
349 if let Some(out) = git_capture(repo_dir, &["diff", "HEAD", "--numstat"]) {
353 for line in out.lines() {
354 if let Some((path, adds, dels)) = parse_numstat_line(line) {
355 if let Some(entry) = by_path.get_mut(&path) {
356 entry.additions = adds;
357 entry.deletions = dels;
358 }
359 }
360 }
361 }
362 if let Some(sha) = since_sha {
363 let range = format!("{sha}..HEAD");
364 if let Some(out) = git_capture(repo_dir, &["diff", &range, "--numstat"]) {
365 for line in out.lines() {
366 if let Some((path, adds, dels)) = parse_numstat_line(line) {
367 if let Some(entry) = by_path.get_mut(&path) {
368 entry.additions = adds;
369 entry.deletions = dels;
370 }
371 }
372 }
373 }
374 }
375
376 result.changes = by_path.into_values().collect();
377 result
378}
379
380pub fn current_head_sha(repo_dir: &Path) -> Option<String> {
385 if !is_git_repo(repo_dir) {
386 return None;
387 }
388 git_capture(repo_dir, &["rev-parse", "HEAD"])
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn translate_status_maps_known_codes() {
397 assert_eq!(translate_status("A"), "created");
398 assert_eq!(translate_status("M"), "modified");
399 assert_eq!(translate_status("D"), "deleted");
400 assert_eq!(translate_status("R100"), "renamed");
401 assert_eq!(translate_status("??"), "untracked");
402 assert_eq!(translate_status(""), "modified");
403 assert_eq!(translate_status("X"), "modified");
404 }
405
406 #[test]
407 fn parse_numstat_handles_text_and_binary() {
408 let (p, a, d) = parse_numstat_line("12\t3\tsrc/a.rs").unwrap();
409 assert_eq!(p, "src/a.rs");
410 assert_eq!(a, Some(12));
411 assert_eq!(d, Some(3));
412
413 let (p, a, d) = parse_numstat_line("-\t-\tassets/logo.png").unwrap();
415 assert_eq!(p, "assets/logo.png");
416 assert_eq!(a, None);
417 assert_eq!(d, None);
418 }
419
420 #[test]
421 fn reconcile_in_non_git_dir_returns_empty() {
422 let tmp =
423 std::env::temp_dir().join(format!("treeship-not-a-repo-{}", rand::random::<u32>()));
424 std::fs::create_dir_all(&tmp).unwrap();
425 let result = reconcile_changes(&tmp, None);
426 assert!(result.is_empty());
427 let _ = std::fs::remove_dir_all(&tmp);
428 }
429
430 #[test]
434 fn git_repo_present_false_outside_repo() {
435 let tmp = std::env::temp_dir().join(format!("treeship-nogit-{}", rand::random::<u32>()));
436 std::fs::create_dir_all(&tmp).unwrap();
437 let r = reconcile_changes_with_options(&tmp, None, &ReconcileOptions::default());
438 assert!(
439 !r.summary.git_repo_present,
440 "no git repo -> git_repo_present must be false"
441 );
442 let _ = std::fs::remove_dir_all(&tmp);
443 }
444
445 #[test]
446 fn git_repo_present_true_in_real_repo() {
447 let tmp = std::env::temp_dir().join(format!("treeship-git-{}", rand::random::<u32>()));
448 std::fs::create_dir_all(&tmp).unwrap();
449 let inited = Command::new("git")
450 .arg("-C")
451 .arg(&tmp)
452 .arg("init")
453 .stdout(Stdio::null())
454 .stderr(Stdio::null())
455 .status()
456 .map(|s| s.success())
457 .unwrap_or(false);
458 if inited {
459 let r = reconcile_changes_with_options(&tmp, None, &ReconcileOptions::default());
460 assert!(
461 r.summary.git_repo_present,
462 "real git repo -> git_repo_present must be true"
463 );
464 }
465 let _ = std::fs::remove_dir_all(&tmp);
466 }
467
468 #[test]
474 fn parse_name_status_modify_uses_single_path() {
475 let (op, path) = parse_name_status_line("M\tsrc/lib.rs").unwrap();
476 assert_eq!(op, "modified");
477 assert_eq!(path, "src/lib.rs");
478 }
479
480 #[test]
481 fn parse_name_status_added_uses_single_path() {
482 let (op, path) = parse_name_status_line("A\tsrc/new.rs").unwrap();
483 assert_eq!(op, "created");
484 assert_eq!(path, "src/new.rs");
485 }
486
487 #[test]
488 fn parse_name_status_deleted_uses_single_path() {
489 let (op, path) = parse_name_status_line("D\tsrc/gone.rs").unwrap();
490 assert_eq!(op, "deleted");
491 assert_eq!(path, "src/gone.rs");
492 }
493
494 #[test]
495 fn parse_name_status_rename_uses_destination() {
496 let (op, path) = parse_name_status_line("R100\tsrc/old.rs\tsrc/new.rs").unwrap();
500 assert_eq!(op, "renamed");
501 assert_eq!(
502 path, "src/new.rs",
503 "rename must record the destination, not the source"
504 );
505 }
506
507 #[test]
508 fn parse_name_status_copy_uses_destination() {
509 let (op, path) =
512 parse_name_status_line("C75\tsrc/template.rs\tsrc/new-from-template.rs").unwrap();
513 assert_eq!(op, "created");
514 assert_eq!(
515 path, "src/new-from-template.rs",
516 "copy must record the destination"
517 );
518 }
519
520 #[test]
521 fn parse_name_status_rename_falls_back_to_source_if_dest_missing() {
522 let (op, path) = parse_name_status_line("R100\tsrc/only-old.rs").unwrap();
527 assert_eq!(op, "renamed");
528 assert_eq!(path, "src/only-old.rs");
529 }
530
531 #[test]
532 fn parse_name_status_handles_empty_or_garbage_lines() {
533 assert!(parse_name_status_line("").is_none());
534 assert!(parse_name_status_line("\t\t").is_none()); assert!(parse_name_status_line("M").is_none());
537 }
538
539 #[test]
540 fn runtime_artifact_filter_excludes_generated_state() {
541 assert!(is_treeship_runtime_artifact(".treeship/session.closing"));
543 assert!(is_treeship_runtime_artifact(".treeship/session.json"));
544 assert!(is_treeship_runtime_artifact(
545 ".treeship/sessions/ssn_abc/events.jsonl"
546 ));
547 assert!(is_treeship_runtime_artifact(
548 ".treeship/sessions/ssn_abc/manifest.json"
549 ));
550 assert!(is_treeship_runtime_artifact(".treeship/artifacts/foo.json"));
551 assert!(is_treeship_runtime_artifact(".treeship/tmp/scratch"));
552 assert!(is_treeship_runtime_artifact(
553 ".treeship/proof_queue/pending.json"
554 ));
555
556 assert!(is_treeship_runtime_artifact("./.treeship/session.closing"));
558 assert!(is_treeship_runtime_artifact(
559 "./.treeship/sessions/ssn_x/events.jsonl"
560 ));
561 }
562
563 #[test]
564 fn runtime_artifact_filter_preserves_user_authored_files() {
565 assert!(!is_treeship_runtime_artifact(".treeship/config.yaml"));
568 assert!(!is_treeship_runtime_artifact(".treeship/config.json"));
569 assert!(!is_treeship_runtime_artifact(".treeship/declaration.json"));
570 assert!(!is_treeship_runtime_artifact(".treeship/policy.yaml"));
571 assert!(!is_treeship_runtime_artifact(
572 ".treeship/agents/coder.agent"
573 ));
574 assert!(!is_treeship_runtime_artifact(
575 ".treeship/agents/reviewer.json"
576 ));
577
578 assert!(!is_treeship_runtime_artifact("src/main.rs"));
580 assert!(!is_treeship_runtime_artifact("README.md"));
581 assert!(!is_treeship_runtime_artifact("treeship-notes.md"));
582 assert!(!is_treeship_runtime_artifact(".treeshiprc"));
583 }
584
585 #[test]
586 fn reconcile_filters_runtime_artifacts_end_to_end() {
587 let tmp =
592 std::env::temp_dir().join(format!("treeship-reconcile-{}", rand::random::<u32>()));
593 std::fs::create_dir_all(&tmp).unwrap();
594
595 let run = |args: &[&str]| {
596 std::process::Command::new("git")
597 .arg("-C")
598 .arg(&tmp)
599 .args(args)
600 .stdout(std::process::Stdio::null())
601 .stderr(std::process::Stdio::null())
602 .status()
603 .ok();
604 };
605
606 run(&["init", "-q"]);
607 run(&["config", "user.email", "test@example.com"]);
608 run(&["config", "user.name", "Test"]);
609 std::fs::write(tmp.join("README.md"), "hi\n").unwrap();
610 run(&["add", "."]);
611 run(&["commit", "-q", "-m", "init"]);
612
613 std::fs::create_dir_all(tmp.join(".treeship/sessions/ssn_x")).unwrap();
615 std::fs::create_dir_all(tmp.join(".treeship/artifacts")).unwrap();
616 std::fs::create_dir_all(tmp.join(".treeship/agents")).unwrap();
617 std::fs::write(tmp.join(".treeship/sessions/ssn_x/events.jsonl"), "{}\n").unwrap();
618 std::fs::write(tmp.join(".treeship/artifacts/foo.json"), "{}\n").unwrap();
619 std::fs::write(tmp.join(".treeship/session.closing"), "").unwrap();
620 std::fs::write(tmp.join(".treeship/agents/coder.agent"), "name: coder\n").unwrap();
621 std::fs::write(tmp.join(".treeship/declaration.json"), "{}\n").unwrap();
622 std::fs::write(tmp.join("src.rs"), "fn main() {}\n").unwrap();
623
624 let changes = reconcile_changes(&tmp, None);
625 let paths: Vec<&str> = changes.iter().map(|c| c.file_path.as_str()).collect();
626
627 assert!(paths.contains(&"src.rs"), "user file missing: {paths:?}");
629 assert!(
630 paths.contains(&".treeship/agents/coder.agent"),
631 "agent card missing: {paths:?}"
632 );
633 assert!(
634 paths.contains(&".treeship/declaration.json"),
635 "declaration missing: {paths:?}"
636 );
637
638 assert!(
640 !paths.contains(&".treeship/sessions/ssn_x/events.jsonl"),
641 "leaked: {paths:?}"
642 );
643 assert!(
644 !paths.contains(&".treeship/artifacts/foo.json"),
645 "leaked: {paths:?}"
646 );
647 assert!(
648 !paths.contains(&".treeship/session.closing"),
649 "leaked: {paths:?}"
650 );
651
652 let _ = std::fs::remove_dir_all(&tmp);
653 }
654
655 #[test]
656 fn reconcile_truncates_untracked_without_promoting_per_file_events() {
657 let tmp =
658 std::env::temp_dir().join(format!("treeship-reconcile-cap-{}", rand::random::<u32>()));
659 std::fs::create_dir_all(&tmp).unwrap();
660
661 let run = |args: &[&str]| {
662 std::process::Command::new("git")
663 .arg("-C")
664 .arg(&tmp)
665 .args(args)
666 .stdout(std::process::Stdio::null())
667 .stderr(std::process::Stdio::null())
668 .status()
669 .ok();
670 };
671
672 run(&["init", "-q"]);
673 run(&["config", "user.email", "test@example.com"]);
674 run(&["config", "user.name", "Test"]);
675 std::fs::write(tmp.join("README.md"), "hi\n").unwrap();
676 run(&["add", "."]);
677 run(&["commit", "-q", "-m", "init"]);
678
679 std::fs::write(tmp.join("a.txt"), "a\n").unwrap();
680 std::fs::write(tmp.join("b.txt"), "b\n").unwrap();
681 std::fs::write(tmp.join("c.txt"), "c\n").unwrap();
682
683 let result =
684 reconcile_changes_with_options(&tmp, None, &ReconcileOptions { untracked_max: 2 });
685
686 assert!(result.summary.untracked_truncated);
687 assert_eq!(result.summary.untracked_cap, 2);
688 assert!(result.summary.untracked_seen >= 3);
689 assert!(
690 result.changes.iter().all(|c| c.operation != "untracked"),
691 "truncated untracked files must not be emitted one-per-file: {:?}",
692 result.changes,
693 );
694
695 let _ = std::fs::remove_dir_all(&tmp);
696 }
697}