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}
59
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct ReconcileResult {
62 pub changes: Vec<GitChange>,
63 pub summary: ReconcileSummary,
64}
65
66fn git_capture(repo_dir: &Path, args: &[&str]) -> Option<String> {
71 let output = Command::new("git")
72 .arg("-C").arg(repo_dir)
73 .args(args)
74 .stdin(Stdio::null())
75 .stdout(Stdio::piped())
76 .stderr(Stdio::null())
77 .output()
78 .ok()?;
79 if !output.status.success() {
80 return None;
81 }
82 let mut s = String::from_utf8(output.stdout).ok()?;
83 while s.ends_with('\n') {
84 s.pop();
85 }
86 Some(s)
87}
88
89fn git_capture_lines_limited(repo_dir: &Path, args: &[&str], limit: usize) -> Option<(Vec<String>, bool)> {
90 let mut child = Command::new("git")
91 .arg("-C").arg(repo_dir)
92 .args(args)
93 .stdin(Stdio::null())
94 .stdout(Stdio::piped())
95 .stderr(Stdio::null())
96 .spawn()
97 .ok()?;
98 let stdout = child.stdout.take()?;
99 let reader = BufReader::new(stdout);
100 let mut lines = Vec::new();
101 let mut truncated = false;
102 for line in reader.lines() {
103 let line = line.ok()?;
104 if lines.len() >= limit {
105 truncated = true;
106 let _ = child.kill();
107 break;
108 }
109 lines.push(line);
110 }
111 let status = child.wait().ok()?;
112 if !truncated && !status.success() {
113 return None;
114 }
115 Some((lines, truncated))
116}
117
118fn is_git_repo(repo_dir: &Path) -> bool {
120 git_capture(repo_dir, &["rev-parse", "--is-inside-work-tree"])
121 .as_deref()
122 == Some("true")
123}
124
125pub fn git_toplevel(repo_dir: &Path) -> Option<PathBuf> {
126 git_capture(repo_dir, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
127}
128
129fn translate_status(code: &str) -> &'static str {
134 match code.chars().next().unwrap_or(' ') {
135 'A' => "created",
136 'D' => "deleted",
137 'R' => "renamed",
138 'C' => "created", 'T' => "modified",
140 '?' => "untracked",
141 _ => "modified", }
143}
144
145fn parse_name_status_line(line: &str) -> Option<(&'static str, String)> {
170 let mut parts = line.split('\t');
171 let code = parts.next()?;
172 if code.is_empty() {
173 return None;
174 }
175 let first_path = parts.next()?;
176 let op = translate_status(code);
177 let path = match code.chars().next().unwrap_or(' ') {
178 'R' | 'C' => {
179 parts.next().map(|p| p.to_string()).unwrap_or_else(|| first_path.to_string())
183 }
184 _ => first_path.to_string(),
185 };
186 Some((op, path))
187}
188
189fn parse_numstat_line(line: &str) -> Option<(String, Option<u32>, Option<u32>)> {
193 let mut parts = line.splitn(3, '\t');
194 let adds_s = parts.next()?;
195 let dels_s = parts.next()?;
196 let path = parts.next()?.to_string();
197 let adds = adds_s.parse::<u32>().ok();
198 let dels = dels_s.parse::<u32>().ok();
199 Some((path, adds, dels))
200}
201
202fn is_treeship_runtime_artifact(path: &str) -> bool {
217 let p = path.strip_prefix("./").unwrap_or(path);
219 if !p.starts_with(".treeship/") && p != ".treeship" {
220 return false;
221 }
222 p == ".treeship/session.closing"
224 || p == ".treeship/session.json"
225 || p.starts_with(".treeship/sessions/")
226 || p.starts_with(".treeship/artifacts/")
227 || p.starts_with(".treeship/tmp/")
228 || p.starts_with(".treeship/proof_queue/")
229}
230
231pub fn reconcile_changes(repo_dir: &Path, since_sha: Option<&str>) -> Vec<GitChange> {
244 reconcile_changes_with_options(repo_dir, since_sha, &ReconcileOptions::default()).changes
245}
246
247pub fn reconcile_changes_with_options(
248 repo_dir: &Path,
249 since_sha: Option<&str>,
250 options: &ReconcileOptions,
251) -> ReconcileResult {
252 let mut result = ReconcileResult {
253 summary: ReconcileSummary {
254 untracked_cap: options.untracked_max,
255 ..ReconcileSummary::default()
256 },
257 ..ReconcileResult::default()
258 };
259
260 if !is_git_repo(repo_dir) {
261 return result;
262 }
263
264 use std::collections::BTreeMap;
265 let mut by_path: BTreeMap<String, GitChange> = BTreeMap::new();
268
269 let mut record = |path: String, op: &str| {
270 if is_treeship_runtime_artifact(&path) {
271 return;
272 }
273 by_path.entry(path.clone()).or_insert(GitChange {
274 file_path: path,
275 operation: op.to_string(),
276 additions: None,
277 deletions: None,
278 });
279 };
280
281 if let Some(out) = git_capture(repo_dir, &["diff", "HEAD", "--name-status"]) {
285 for line in out.lines() {
286 if let Some((op, path)) = parse_name_status_line(line) {
287 record(path, op);
288 }
289 }
290 }
291
292 if let Some(sha) = since_sha {
295 let range = format!("{sha}..HEAD");
296 if let Some(out) = git_capture(repo_dir, &["diff", &range, "--name-status"]) {
297 for line in out.lines() {
298 if let Some((op, path)) = parse_name_status_line(line) {
299 record(path, op);
300 }
301 }
302 }
303 }
304
305 if let Some((lines, truncated)) = git_capture_lines_limited(
309 repo_dir,
310 &["ls-files", "--others", "--exclude-standard"],
311 options.untracked_max.saturating_add(1),
312 ) {
313 result.summary.untracked_seen = lines.len();
314 result.summary.untracked_truncated = truncated || lines.len() > options.untracked_max;
315 if result.summary.untracked_truncated {
316 result.summary.untracked_seen = result.summary.untracked_seen.max(options.untracked_max.saturating_add(1));
317 } else {
318 for path in lines.iter().filter(|l| !l.is_empty()) {
319 record(path.to_string(), "untracked");
320 }
321 }
322 }
323
324 if let Some(out) = git_capture(repo_dir, &["diff", "HEAD", "--numstat"]) {
328 for line in out.lines() {
329 if let Some((path, adds, dels)) = parse_numstat_line(line) {
330 if let Some(entry) = by_path.get_mut(&path) {
331 entry.additions = adds;
332 entry.deletions = dels;
333 }
334 }
335 }
336 }
337 if let Some(sha) = since_sha {
338 let range = format!("{sha}..HEAD");
339 if let Some(out) = git_capture(repo_dir, &["diff", &range, "--numstat"]) {
340 for line in out.lines() {
341 if let Some((path, adds, dels)) = parse_numstat_line(line) {
342 if let Some(entry) = by_path.get_mut(&path) {
343 entry.additions = adds;
344 entry.deletions = dels;
345 }
346 }
347 }
348 }
349 }
350
351 result.changes = by_path.into_values().collect();
352 result
353}
354
355pub fn current_head_sha(repo_dir: &Path) -> Option<String> {
360 if !is_git_repo(repo_dir) {
361 return None;
362 }
363 git_capture(repo_dir, &["rev-parse", "HEAD"])
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 #[test]
371 fn translate_status_maps_known_codes() {
372 assert_eq!(translate_status("A"), "created");
373 assert_eq!(translate_status("M"), "modified");
374 assert_eq!(translate_status("D"), "deleted");
375 assert_eq!(translate_status("R100"), "renamed");
376 assert_eq!(translate_status("??"), "untracked");
377 assert_eq!(translate_status(""), "modified");
378 assert_eq!(translate_status("X"), "modified");
379 }
380
381 #[test]
382 fn parse_numstat_handles_text_and_binary() {
383 let (p, a, d) = parse_numstat_line("12\t3\tsrc/a.rs").unwrap();
384 assert_eq!(p, "src/a.rs");
385 assert_eq!(a, Some(12));
386 assert_eq!(d, Some(3));
387
388 let (p, a, d) = parse_numstat_line("-\t-\tassets/logo.png").unwrap();
390 assert_eq!(p, "assets/logo.png");
391 assert_eq!(a, None);
392 assert_eq!(d, None);
393 }
394
395 #[test]
396 fn reconcile_in_non_git_dir_returns_empty() {
397 let tmp = std::env::temp_dir().join(format!("treeship-not-a-repo-{}", rand::random::<u32>()));
398 std::fs::create_dir_all(&tmp).unwrap();
399 let result = reconcile_changes(&tmp, None);
400 assert!(result.is_empty());
401 let _ = std::fs::remove_dir_all(&tmp);
402 }
403
404 #[test]
410 fn parse_name_status_modify_uses_single_path() {
411 let (op, path) = parse_name_status_line("M\tsrc/lib.rs").unwrap();
412 assert_eq!(op, "modified");
413 assert_eq!(path, "src/lib.rs");
414 }
415
416 #[test]
417 fn parse_name_status_added_uses_single_path() {
418 let (op, path) = parse_name_status_line("A\tsrc/new.rs").unwrap();
419 assert_eq!(op, "created");
420 assert_eq!(path, "src/new.rs");
421 }
422
423 #[test]
424 fn parse_name_status_deleted_uses_single_path() {
425 let (op, path) = parse_name_status_line("D\tsrc/gone.rs").unwrap();
426 assert_eq!(op, "deleted");
427 assert_eq!(path, "src/gone.rs");
428 }
429
430 #[test]
431 fn parse_name_status_rename_uses_destination() {
432 let (op, path) = parse_name_status_line("R100\tsrc/old.rs\tsrc/new.rs").unwrap();
436 assert_eq!(op, "renamed");
437 assert_eq!(path, "src/new.rs", "rename must record the destination, not the source");
438 }
439
440 #[test]
441 fn parse_name_status_copy_uses_destination() {
442 let (op, path) = parse_name_status_line("C75\tsrc/template.rs\tsrc/new-from-template.rs").unwrap();
445 assert_eq!(op, "created");
446 assert_eq!(path, "src/new-from-template.rs", "copy must record the destination");
447 }
448
449 #[test]
450 fn parse_name_status_rename_falls_back_to_source_if_dest_missing() {
451 let (op, path) = parse_name_status_line("R100\tsrc/only-old.rs").unwrap();
456 assert_eq!(op, "renamed");
457 assert_eq!(path, "src/only-old.rs");
458 }
459
460 #[test]
461 fn parse_name_status_handles_empty_or_garbage_lines() {
462 assert!(parse_name_status_line("").is_none());
463 assert!(parse_name_status_line("\t\t").is_none()); assert!(parse_name_status_line("M").is_none());
466 }
467
468 #[test]
469 fn runtime_artifact_filter_excludes_generated_state() {
470 assert!(is_treeship_runtime_artifact(".treeship/session.closing"));
472 assert!(is_treeship_runtime_artifact(".treeship/session.json"));
473 assert!(is_treeship_runtime_artifact(".treeship/sessions/ssn_abc/events.jsonl"));
474 assert!(is_treeship_runtime_artifact(".treeship/sessions/ssn_abc/manifest.json"));
475 assert!(is_treeship_runtime_artifact(".treeship/artifacts/foo.json"));
476 assert!(is_treeship_runtime_artifact(".treeship/tmp/scratch"));
477 assert!(is_treeship_runtime_artifact(".treeship/proof_queue/pending.json"));
478
479 assert!(is_treeship_runtime_artifact("./.treeship/session.closing"));
481 assert!(is_treeship_runtime_artifact("./.treeship/sessions/ssn_x/events.jsonl"));
482 }
483
484 #[test]
485 fn runtime_artifact_filter_preserves_user_authored_files() {
486 assert!(!is_treeship_runtime_artifact(".treeship/config.yaml"));
489 assert!(!is_treeship_runtime_artifact(".treeship/config.json"));
490 assert!(!is_treeship_runtime_artifact(".treeship/declaration.json"));
491 assert!(!is_treeship_runtime_artifact(".treeship/policy.yaml"));
492 assert!(!is_treeship_runtime_artifact(".treeship/agents/coder.agent"));
493 assert!(!is_treeship_runtime_artifact(".treeship/agents/reviewer.json"));
494
495 assert!(!is_treeship_runtime_artifact("src/main.rs"));
497 assert!(!is_treeship_runtime_artifact("README.md"));
498 assert!(!is_treeship_runtime_artifact("treeship-notes.md"));
499 assert!(!is_treeship_runtime_artifact(".treeshiprc"));
500 }
501
502 #[test]
503 fn reconcile_filters_runtime_artifacts_end_to_end() {
504 let tmp = std::env::temp_dir().join(format!("treeship-reconcile-{}", rand::random::<u32>()));
509 std::fs::create_dir_all(&tmp).unwrap();
510
511 let run = |args: &[&str]| {
512 std::process::Command::new("git")
513 .arg("-C").arg(&tmp)
514 .args(args)
515 .stdout(std::process::Stdio::null())
516 .stderr(std::process::Stdio::null())
517 .status()
518 .ok();
519 };
520
521 run(&["init", "-q"]);
522 run(&["config", "user.email", "test@example.com"]);
523 run(&["config", "user.name", "Test"]);
524 std::fs::write(tmp.join("README.md"), "hi\n").unwrap();
525 run(&["add", "."]);
526 run(&["commit", "-q", "-m", "init"]);
527
528 std::fs::create_dir_all(tmp.join(".treeship/sessions/ssn_x")).unwrap();
530 std::fs::create_dir_all(tmp.join(".treeship/artifacts")).unwrap();
531 std::fs::create_dir_all(tmp.join(".treeship/agents")).unwrap();
532 std::fs::write(tmp.join(".treeship/sessions/ssn_x/events.jsonl"), "{}\n").unwrap();
533 std::fs::write(tmp.join(".treeship/artifacts/foo.json"), "{}\n").unwrap();
534 std::fs::write(tmp.join(".treeship/session.closing"), "").unwrap();
535 std::fs::write(tmp.join(".treeship/agents/coder.agent"), "name: coder\n").unwrap();
536 std::fs::write(tmp.join(".treeship/declaration.json"), "{}\n").unwrap();
537 std::fs::write(tmp.join("src.rs"), "fn main() {}\n").unwrap();
538
539 let changes = reconcile_changes(&tmp, None);
540 let paths: Vec<&str> = changes.iter().map(|c| c.file_path.as_str()).collect();
541
542 assert!(paths.contains(&"src.rs"), "user file missing: {paths:?}");
544 assert!(paths.contains(&".treeship/agents/coder.agent"), "agent card missing: {paths:?}");
545 assert!(paths.contains(&".treeship/declaration.json"), "declaration missing: {paths:?}");
546
547 assert!(!paths.contains(&".treeship/sessions/ssn_x/events.jsonl"), "leaked: {paths:?}");
549 assert!(!paths.contains(&".treeship/artifacts/foo.json"), "leaked: {paths:?}");
550 assert!(!paths.contains(&".treeship/session.closing"), "leaked: {paths:?}");
551
552 let _ = std::fs::remove_dir_all(&tmp);
553 }
554
555 #[test]
556 fn reconcile_truncates_untracked_without_promoting_per_file_events() {
557 let tmp = std::env::temp_dir().join(format!("treeship-reconcile-cap-{}", rand::random::<u32>()));
558 std::fs::create_dir_all(&tmp).unwrap();
559
560 let run = |args: &[&str]| {
561 std::process::Command::new("git")
562 .arg("-C").arg(&tmp)
563 .args(args)
564 .stdout(std::process::Stdio::null())
565 .stderr(std::process::Stdio::null())
566 .status()
567 .ok();
568 };
569
570 run(&["init", "-q"]);
571 run(&["config", "user.email", "test@example.com"]);
572 run(&["config", "user.name", "Test"]);
573 std::fs::write(tmp.join("README.md"), "hi\n").unwrap();
574 run(&["add", "."]);
575 run(&["commit", "-q", "-m", "init"]);
576
577 std::fs::write(tmp.join("a.txt"), "a\n").unwrap();
578 std::fs::write(tmp.join("b.txt"), "b\n").unwrap();
579 std::fs::write(tmp.join("c.txt"), "c\n").unwrap();
580
581 let result = reconcile_changes_with_options(
582 &tmp,
583 None,
584 &ReconcileOptions { untracked_max: 2 },
585 );
586
587 assert!(result.summary.untracked_truncated);
588 assert_eq!(result.summary.untracked_cap, 2);
589 assert!(result.summary.untracked_seen >= 3);
590 assert!(
591 result.changes.iter().all(|c| c.operation != "untracked"),
592 "truncated untracked files must not be emitted one-per-file: {:?}",
593 result.changes,
594 );
595
596 let _ = std::fs::remove_dir_all(&tmp);
597 }
598}