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 { untracked_max: DEFAULT_UNTRACKED_MAX }
50 }
51}
52
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub struct ReconcileSummary {
55 pub untracked_seen: usize,
56 pub untracked_cap: usize,
57 pub untracked_truncated: bool,
58 pub git_repo_present: bool,
68}
69
70#[derive(Debug, Clone, Default, PartialEq, Eq)]
71pub struct ReconcileResult {
72 pub changes: Vec<GitChange>,
73 pub summary: ReconcileSummary,
74}
75
76fn git_capture(repo_dir: &Path, args: &[&str]) -> Option<String> {
81 let output = Command::new("git")
82 .arg("-C").arg(repo_dir)
83 .args(args)
84 .stdin(Stdio::null())
85 .stdout(Stdio::piped())
86 .stderr(Stdio::null())
87 .output()
88 .ok()?;
89 if !output.status.success() {
90 return None;
91 }
92 let mut s = String::from_utf8(output.stdout).ok()?;
93 while s.ends_with('\n') {
94 s.pop();
95 }
96 Some(s)
97}
98
99fn git_capture_lines_limited(repo_dir: &Path, args: &[&str], limit: usize) -> Option<(Vec<String>, bool)> {
100 let mut child = Command::new("git")
101 .arg("-C").arg(repo_dir)
102 .args(args)
103 .stdin(Stdio::null())
104 .stdout(Stdio::piped())
105 .stderr(Stdio::null())
106 .spawn()
107 .ok()?;
108 let stdout = child.stdout.take()?;
109 let reader = BufReader::new(stdout);
110 let mut lines = Vec::new();
111 let mut truncated = false;
112 for line in reader.lines() {
113 let line = line.ok()?;
114 if lines.len() >= limit {
115 truncated = true;
116 let _ = child.kill();
117 break;
118 }
119 lines.push(line);
120 }
121 let status = child.wait().ok()?;
122 if !truncated && !status.success() {
123 return None;
124 }
125 Some((lines, truncated))
126}
127
128fn is_git_repo(repo_dir: &Path) -> bool {
130 git_capture(repo_dir, &["rev-parse", "--is-inside-work-tree"])
131 .as_deref()
132 == Some("true")
133}
134
135pub fn git_toplevel(repo_dir: &Path) -> Option<PathBuf> {
136 git_capture(repo_dir, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
137}
138
139fn translate_status(code: &str) -> &'static str {
144 match code.chars().next().unwrap_or(' ') {
145 'A' => "created",
146 'D' => "deleted",
147 'R' => "renamed",
148 'C' => "created", 'T' => "modified",
150 '?' => "untracked",
151 _ => "modified", }
153}
154
155fn parse_name_status_line(line: &str) -> Option<(&'static str, String)> {
180 let mut parts = line.split('\t');
181 let code = parts.next()?;
182 if code.is_empty() {
183 return None;
184 }
185 let first_path = parts.next()?;
186 let op = translate_status(code);
187 let path = match code.chars().next().unwrap_or(' ') {
188 'R' | 'C' => {
189 parts.next().map(|p| p.to_string()).unwrap_or_else(|| first_path.to_string())
193 }
194 _ => first_path.to_string(),
195 };
196 Some((op, path))
197}
198
199fn parse_numstat_line(line: &str) -> Option<(String, Option<u32>, Option<u32>)> {
203 let mut parts = line.splitn(3, '\t');
204 let adds_s = parts.next()?;
205 let dels_s = parts.next()?;
206 let path = parts.next()?.to_string();
207 let adds = adds_s.parse::<u32>().ok();
208 let dels = dels_s.parse::<u32>().ok();
209 Some((path, adds, dels))
210}
211
212fn is_treeship_runtime_artifact(path: &str) -> bool {
227 let p = path.strip_prefix("./").unwrap_or(path);
229 if !p.starts_with(".treeship/") && p != ".treeship" {
230 return false;
231 }
232 p == ".treeship/session.closing"
234 || p == ".treeship/session.json"
235 || p.starts_with(".treeship/sessions/")
236 || p.starts_with(".treeship/artifacts/")
237 || p.starts_with(".treeship/tmp/")
238 || p.starts_with(".treeship/proof_queue/")
239}
240
241pub fn reconcile_changes(repo_dir: &Path, since_sha: Option<&str>) -> Vec<GitChange> {
254 reconcile_changes_with_options(repo_dir, since_sha, &ReconcileOptions::default()).changes
255}
256
257pub fn reconcile_changes_with_options(
258 repo_dir: &Path,
259 since_sha: Option<&str>,
260 options: &ReconcileOptions,
261) -> ReconcileResult {
262 let mut result = ReconcileResult {
263 summary: ReconcileSummary {
264 untracked_cap: options.untracked_max,
265 ..ReconcileSummary::default()
266 },
267 ..ReconcileResult::default()
268 };
269
270 if !is_git_repo(repo_dir) {
271 return result;
274 }
275 result.summary.git_repo_present = true;
276
277 use std::collections::BTreeMap;
278 let mut by_path: BTreeMap<String, GitChange> = BTreeMap::new();
281
282 let mut record = |path: String, op: &str| {
283 if is_treeship_runtime_artifact(&path) {
284 return;
285 }
286 by_path.entry(path.clone()).or_insert(GitChange {
287 file_path: path,
288 operation: op.to_string(),
289 additions: None,
290 deletions: None,
291 });
292 };
293
294 if let Some(out) = git_capture(repo_dir, &["diff", "HEAD", "--name-status"]) {
298 for line in out.lines() {
299 if let Some((op, path)) = parse_name_status_line(line) {
300 record(path, op);
301 }
302 }
303 }
304
305 if let Some(sha) = since_sha {
308 let range = format!("{sha}..HEAD");
309 if let Some(out) = git_capture(repo_dir, &["diff", &range, "--name-status"]) {
310 for line in out.lines() {
311 if let Some((op, path)) = parse_name_status_line(line) {
312 record(path, op);
313 }
314 }
315 }
316 }
317
318 if let Some((lines, truncated)) = git_capture_lines_limited(
322 repo_dir,
323 &["ls-files", "--others", "--exclude-standard"],
324 options.untracked_max.saturating_add(1),
325 ) {
326 result.summary.untracked_seen = lines.len();
327 result.summary.untracked_truncated = truncated || lines.len() > options.untracked_max;
328 if result.summary.untracked_truncated {
329 result.summary.untracked_seen = result.summary.untracked_seen.max(options.untracked_max.saturating_add(1));
330 } else {
331 for path in lines.iter().filter(|l| !l.is_empty()) {
332 record(path.to_string(), "untracked");
333 }
334 }
335 }
336
337 if let Some(out) = git_capture(repo_dir, &["diff", "HEAD", "--numstat"]) {
341 for line in out.lines() {
342 if let Some((path, adds, dels)) = parse_numstat_line(line) {
343 if let Some(entry) = by_path.get_mut(&path) {
344 entry.additions = adds;
345 entry.deletions = dels;
346 }
347 }
348 }
349 }
350 if let Some(sha) = since_sha {
351 let range = format!("{sha}..HEAD");
352 if let Some(out) = git_capture(repo_dir, &["diff", &range, "--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 }
363
364 result.changes = by_path.into_values().collect();
365 result
366}
367
368pub fn current_head_sha(repo_dir: &Path) -> Option<String> {
373 if !is_git_repo(repo_dir) {
374 return None;
375 }
376 git_capture(repo_dir, &["rev-parse", "HEAD"])
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn translate_status_maps_known_codes() {
385 assert_eq!(translate_status("A"), "created");
386 assert_eq!(translate_status("M"), "modified");
387 assert_eq!(translate_status("D"), "deleted");
388 assert_eq!(translate_status("R100"), "renamed");
389 assert_eq!(translate_status("??"), "untracked");
390 assert_eq!(translate_status(""), "modified");
391 assert_eq!(translate_status("X"), "modified");
392 }
393
394 #[test]
395 fn parse_numstat_handles_text_and_binary() {
396 let (p, a, d) = parse_numstat_line("12\t3\tsrc/a.rs").unwrap();
397 assert_eq!(p, "src/a.rs");
398 assert_eq!(a, Some(12));
399 assert_eq!(d, Some(3));
400
401 let (p, a, d) = parse_numstat_line("-\t-\tassets/logo.png").unwrap();
403 assert_eq!(p, "assets/logo.png");
404 assert_eq!(a, None);
405 assert_eq!(d, None);
406 }
407
408 #[test]
409 fn reconcile_in_non_git_dir_returns_empty() {
410 let tmp = std::env::temp_dir().join(format!("treeship-not-a-repo-{}", rand::random::<u32>()));
411 std::fs::create_dir_all(&tmp).unwrap();
412 let result = reconcile_changes(&tmp, None);
413 assert!(result.is_empty());
414 let _ = std::fs::remove_dir_all(&tmp);
415 }
416
417 #[test]
421 fn git_repo_present_false_outside_repo() {
422 let tmp = std::env::temp_dir().join(format!("treeship-nogit-{}", rand::random::<u32>()));
423 std::fs::create_dir_all(&tmp).unwrap();
424 let r = reconcile_changes_with_options(&tmp, None, &ReconcileOptions::default());
425 assert!(!r.summary.git_repo_present, "no git repo -> git_repo_present must be false");
426 let _ = std::fs::remove_dir_all(&tmp);
427 }
428
429 #[test]
430 fn git_repo_present_true_in_real_repo() {
431 let tmp = std::env::temp_dir().join(format!("treeship-git-{}", rand::random::<u32>()));
432 std::fs::create_dir_all(&tmp).unwrap();
433 let inited = Command::new("git")
434 .arg("-C").arg(&tmp).arg("init")
435 .stdout(Stdio::null()).stderr(Stdio::null())
436 .status().map(|s| s.success()).unwrap_or(false);
437 if inited {
438 let r = reconcile_changes_with_options(&tmp, None, &ReconcileOptions::default());
439 assert!(r.summary.git_repo_present, "real git repo -> git_repo_present must be true");
440 }
441 let _ = std::fs::remove_dir_all(&tmp);
442 }
443
444 #[test]
450 fn parse_name_status_modify_uses_single_path() {
451 let (op, path) = parse_name_status_line("M\tsrc/lib.rs").unwrap();
452 assert_eq!(op, "modified");
453 assert_eq!(path, "src/lib.rs");
454 }
455
456 #[test]
457 fn parse_name_status_added_uses_single_path() {
458 let (op, path) = parse_name_status_line("A\tsrc/new.rs").unwrap();
459 assert_eq!(op, "created");
460 assert_eq!(path, "src/new.rs");
461 }
462
463 #[test]
464 fn parse_name_status_deleted_uses_single_path() {
465 let (op, path) = parse_name_status_line("D\tsrc/gone.rs").unwrap();
466 assert_eq!(op, "deleted");
467 assert_eq!(path, "src/gone.rs");
468 }
469
470 #[test]
471 fn parse_name_status_rename_uses_destination() {
472 let (op, path) = parse_name_status_line("R100\tsrc/old.rs\tsrc/new.rs").unwrap();
476 assert_eq!(op, "renamed");
477 assert_eq!(path, "src/new.rs", "rename must record the destination, not the source");
478 }
479
480 #[test]
481 fn parse_name_status_copy_uses_destination() {
482 let (op, path) = parse_name_status_line("C75\tsrc/template.rs\tsrc/new-from-template.rs").unwrap();
485 assert_eq!(op, "created");
486 assert_eq!(path, "src/new-from-template.rs", "copy must record the destination");
487 }
488
489 #[test]
490 fn parse_name_status_rename_falls_back_to_source_if_dest_missing() {
491 let (op, path) = parse_name_status_line("R100\tsrc/only-old.rs").unwrap();
496 assert_eq!(op, "renamed");
497 assert_eq!(path, "src/only-old.rs");
498 }
499
500 #[test]
501 fn parse_name_status_handles_empty_or_garbage_lines() {
502 assert!(parse_name_status_line("").is_none());
503 assert!(parse_name_status_line("\t\t").is_none()); assert!(parse_name_status_line("M").is_none());
506 }
507
508 #[test]
509 fn runtime_artifact_filter_excludes_generated_state() {
510 assert!(is_treeship_runtime_artifact(".treeship/session.closing"));
512 assert!(is_treeship_runtime_artifact(".treeship/session.json"));
513 assert!(is_treeship_runtime_artifact(".treeship/sessions/ssn_abc/events.jsonl"));
514 assert!(is_treeship_runtime_artifact(".treeship/sessions/ssn_abc/manifest.json"));
515 assert!(is_treeship_runtime_artifact(".treeship/artifacts/foo.json"));
516 assert!(is_treeship_runtime_artifact(".treeship/tmp/scratch"));
517 assert!(is_treeship_runtime_artifact(".treeship/proof_queue/pending.json"));
518
519 assert!(is_treeship_runtime_artifact("./.treeship/session.closing"));
521 assert!(is_treeship_runtime_artifact("./.treeship/sessions/ssn_x/events.jsonl"));
522 }
523
524 #[test]
525 fn runtime_artifact_filter_preserves_user_authored_files() {
526 assert!(!is_treeship_runtime_artifact(".treeship/config.yaml"));
529 assert!(!is_treeship_runtime_artifact(".treeship/config.json"));
530 assert!(!is_treeship_runtime_artifact(".treeship/declaration.json"));
531 assert!(!is_treeship_runtime_artifact(".treeship/policy.yaml"));
532 assert!(!is_treeship_runtime_artifact(".treeship/agents/coder.agent"));
533 assert!(!is_treeship_runtime_artifact(".treeship/agents/reviewer.json"));
534
535 assert!(!is_treeship_runtime_artifact("src/main.rs"));
537 assert!(!is_treeship_runtime_artifact("README.md"));
538 assert!(!is_treeship_runtime_artifact("treeship-notes.md"));
539 assert!(!is_treeship_runtime_artifact(".treeshiprc"));
540 }
541
542 #[test]
543 fn reconcile_filters_runtime_artifacts_end_to_end() {
544 let tmp = std::env::temp_dir().join(format!("treeship-reconcile-{}", rand::random::<u32>()));
549 std::fs::create_dir_all(&tmp).unwrap();
550
551 let run = |args: &[&str]| {
552 std::process::Command::new("git")
553 .arg("-C").arg(&tmp)
554 .args(args)
555 .stdout(std::process::Stdio::null())
556 .stderr(std::process::Stdio::null())
557 .status()
558 .ok();
559 };
560
561 run(&["init", "-q"]);
562 run(&["config", "user.email", "test@example.com"]);
563 run(&["config", "user.name", "Test"]);
564 std::fs::write(tmp.join("README.md"), "hi\n").unwrap();
565 run(&["add", "."]);
566 run(&["commit", "-q", "-m", "init"]);
567
568 std::fs::create_dir_all(tmp.join(".treeship/sessions/ssn_x")).unwrap();
570 std::fs::create_dir_all(tmp.join(".treeship/artifacts")).unwrap();
571 std::fs::create_dir_all(tmp.join(".treeship/agents")).unwrap();
572 std::fs::write(tmp.join(".treeship/sessions/ssn_x/events.jsonl"), "{}\n").unwrap();
573 std::fs::write(tmp.join(".treeship/artifacts/foo.json"), "{}\n").unwrap();
574 std::fs::write(tmp.join(".treeship/session.closing"), "").unwrap();
575 std::fs::write(tmp.join(".treeship/agents/coder.agent"), "name: coder\n").unwrap();
576 std::fs::write(tmp.join(".treeship/declaration.json"), "{}\n").unwrap();
577 std::fs::write(tmp.join("src.rs"), "fn main() {}\n").unwrap();
578
579 let changes = reconcile_changes(&tmp, None);
580 let paths: Vec<&str> = changes.iter().map(|c| c.file_path.as_str()).collect();
581
582 assert!(paths.contains(&"src.rs"), "user file missing: {paths:?}");
584 assert!(paths.contains(&".treeship/agents/coder.agent"), "agent card missing: {paths:?}");
585 assert!(paths.contains(&".treeship/declaration.json"), "declaration missing: {paths:?}");
586
587 assert!(!paths.contains(&".treeship/sessions/ssn_x/events.jsonl"), "leaked: {paths:?}");
589 assert!(!paths.contains(&".treeship/artifacts/foo.json"), "leaked: {paths:?}");
590 assert!(!paths.contains(&".treeship/session.closing"), "leaked: {paths:?}");
591
592 let _ = std::fs::remove_dir_all(&tmp);
593 }
594
595 #[test]
596 fn reconcile_truncates_untracked_without_promoting_per_file_events() {
597 let tmp = std::env::temp_dir().join(format!("treeship-reconcile-cap-{}", rand::random::<u32>()));
598 std::fs::create_dir_all(&tmp).unwrap();
599
600 let run = |args: &[&str]| {
601 std::process::Command::new("git")
602 .arg("-C").arg(&tmp)
603 .args(args)
604 .stdout(std::process::Stdio::null())
605 .stderr(std::process::Stdio::null())
606 .status()
607 .ok();
608 };
609
610 run(&["init", "-q"]);
611 run(&["config", "user.email", "test@example.com"]);
612 run(&["config", "user.name", "Test"]);
613 std::fs::write(tmp.join("README.md"), "hi\n").unwrap();
614 run(&["add", "."]);
615 run(&["commit", "-q", "-m", "init"]);
616
617 std::fs::write(tmp.join("a.txt"), "a\n").unwrap();
618 std::fs::write(tmp.join("b.txt"), "b\n").unwrap();
619 std::fs::write(tmp.join("c.txt"), "c\n").unwrap();
620
621 let result = reconcile_changes_with_options(
622 &tmp,
623 None,
624 &ReconcileOptions { untracked_max: 2 },
625 );
626
627 assert!(result.summary.untracked_truncated);
628 assert_eq!(result.summary.untracked_cap, 2);
629 assert!(result.summary.untracked_seen >= 3);
630 assert!(
631 result.changes.iter().all(|c| c.operation != "untracked"),
632 "truncated untracked files must not be emitted one-per-file: {:?}",
633 result.changes,
634 );
635
636 let _ = std::fs::remove_dir_all(&tmp);
637 }
638}