Skip to main content

treeship_core/session/
git.rs

1//! Git-based reconciliation for session close.
2//!
3//! Backstop layer of the trust-fabric file-capture stack:
4//!
5//!   1. (highest trust) specialized event types (`agent.wrote_file`)
6//!   2. (medium)        promoted from generic `agent.called_tool`
7//!   3. (this module)   shell out to `git` at session close and pick
8//!                      up anything an agent edited outside any
9//!                      captured tool channel
10//!
11//! Why this matters: the trust-fabric bar is "if a file changed, it
12//! must appear in the receipt." Hooks and MCP cover most paths but
13//! not all -- an agent that ran `sed -i` inside a Bash command, a
14//! build tool that modified files, or any other untracked side
15//! effect would otherwise vanish silently. Running `git diff` and
16//! `git ls-files --others` at close catches the rest.
17//!
18//! Fail-open by design: if the working dir isn't a git repo, if the
19//! git binary is missing, or if any git command errors, returns an
20//! empty list. The receipt is still produced; reconciliation is a
21//! best-effort enhancement, never a gate.
22
23use 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/// One file change observed via git that wasn't already captured by a
30/// tool channel. Mapped 1:1 into a synthetic `AgentWroteFile` event
31/// at session close so it flows through the normal aggregator and
32/// receipt composition path.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct GitChange {
35    pub file_path: String,
36    /// "created", "modified", "deleted", "renamed", or "untracked".
37    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    /// Whether a git work tree was actually present when reconcile ran
61    /// (AUD-07). `false` means `is_git_repo` returned false — not in a repo,
62    /// git binary missing/PATH-poisoned, or `.git` removed/corrupted — so the
63    /// git-diff backstop produced NO changes for a reason other than "clean
64    /// tree". The close path cross-checks this against the session's
65    /// start-of-session HEAD: if git worked at start but not at close, the
66    /// backstop was disabled mid-session and the receipt is stamped degraded.
67    /// Defaults to false so a `ReconcileSummary::default()` (reconcile never
68    /// ran) is treated as "git not confirmed present", not a clean tree.
69    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
78/// Run `git` in `repo_dir` with the given args, returning stdout
79/// trimmed of trailing newline. Returns None on any failure (not
80/// a git repo, git missing, non-zero exit, etc.) -- this module is
81/// fail-open by contract.
82fn 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
136/// Fast probe: is this directory inside a git repo?
137fn 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
145/// Translate a git diff/status name-status code to our operation
146/// vocabulary. Codes from `git diff --name-status`: A=added,
147/// M=modified, D=deleted, R=renamed, C=copied, T=type-change,
148/// U=unmerged, plus special "??" for ls-files untracked.
149fn 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", // copy = new file at the destination
155        'T' => "modified",
156        '?' => "untracked",
157        _ => "modified", // M, U, anything else
158    }
159}
160
161/// Parse a single line of `git diff --name-status`, returning the
162/// canonical operation and the FINAL path of the change.
163///
164/// Codex round-2 finding 5: the original `parts.next() / parts.next()`
165/// shorthand grabbed the FIRST path, which on rename / copy lines is
166/// the OLD path. So `git mv src/old.rs src/new.rs` produced
167/// `R100\told.rs\tnew.rs`, and reconcile recorded `src/old.rs` (which
168/// no longer exists) instead of `src/new.rs` (the destination the
169/// agent actually created). Cross-verify against a cert that allowed
170/// "src/new.rs" then incorrectly flagged the change as touching an
171/// unauthorized file.
172///
173/// Format from `git diff --name-status`:
174///   M\tpath               -- modified
175///   A\tpath               -- added
176///   D\tpath               -- deleted
177///   T\tpath               -- type-changed
178///   R<score>\told\tnew    -- renamed (with similarity score)
179///   C<score>\told\tnew    -- copied
180///   ?\?\tpath             -- (only from ls-files, never name-status)
181///
182/// For R* and C* we return the destination (new) path. For everything
183/// else, the single path. Returns None when the line is empty or
184/// missing fields.
185fn 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            // Rename / copy: the FINAL path is the third field.
196            // If the destination is missing for any reason, fall
197            // back to the source so we still record SOMETHING.
198            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
208/// Parse a single line of `git diff --numstat` (additions, deletions,
209/// path). Numeric fields are "-" for binary files; we represent
210/// those as None.
211fn 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
221/// Decides whether a path discovered by git reconciliation should be
222/// included in the receipt.
223///
224/// Treeship's own runtime artifacts -- session.closing markers,
225/// sessions/<id>/ event logs, artifact storage, scratch tmp -- live
226/// inside `.treeship/` and get touched by the very session that's
227/// closing. Without this filter they show up in `files_written` as
228/// "the agent modified .treeship/sessions/ssn_X/events.jsonl",
229/// noisy and misleading: it was Treeship's own bookkeeping, not the
230/// agent's work.
231///
232/// User-authored Treeship files (config.yaml, declaration.json, agent
233/// cards, policy) DO get surfaced -- those are the operator's own
234/// changes that an audit reader cares about.
235fn is_treeship_runtime_artifact(path: &str) -> bool {
236    // Strip leading "./" if present so both forms compare cleanly.
237    let p = path.strip_prefix("./").unwrap_or(path);
238    if !p.starts_with(".treeship/") && p != ".treeship" {
239        return false;
240    }
241    // Within .treeship/, exclude generated runtime state.
242    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
250/// Collect every file change in `repo_dir` worth surfacing in a
251/// session receipt: working-tree modifications (staged or not),
252/// committed-since-`since_sha` changes (when provided), and untracked
253/// files.
254///
255/// Deduplicated across the three sources so a single file shows up
256/// once. When `git diff --numstat` reports additions/deletions for a
257/// path, those are attached; binary files leave them as None.
258///
259/// Returns an empty Vec if `repo_dir` isn't a git repo or git isn't
260/// available -- callers (i.e. session close) should treat absence as
261/// "nothing to reconcile" and continue.
262pub 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        // git_repo_present stays false: the caller can distinguish "clean tree
281        // in a real repo" from "no repo / git unavailable" (AUD-07).
282        return result;
283    }
284    result.summary.git_repo_present = true;
285
286    use std::collections::BTreeMap;
287    // path -> change. BTreeMap so output is deterministic across runs,
288    // which matters for canonical receipt JSON / merkle stability.
289    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    // 1. Uncommitted changes vs HEAD (staged + unstaged combined via
304    //    name-status). The common agent case: edited a file, didn't
305    //    commit.
306    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    // 2. Committed-during-session changes if the caller captured a
315    //    starting SHA at session start.
316    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    // 3. Untracked files (new files the agent added but didn't `git
328    //    add`). These never show in `git diff` so they need their own
329    //    pass.
330    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    // 4. Numstat for the same diff range -- attach additions/deletions
350    //    where available. Numstat reports differently than name-status
351    //    (no rename indicator), so we just match by path.
352    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
380/// Capture the current HEAD commit SHA so it can be stored in the
381/// session manifest at session start. The session close pass uses it
382/// as the diff base for committed-during-session changes. Returns
383/// None when not in a git repo or no commits exist yet.
384pub 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        // Binary files: numstat uses "-\t-\tpath".
414        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    // AUD-07: git_repo_present distinguishes "clean tree in a real repo" from
431    // "no repo / git unavailable" so the close path can detect a backstop that
432    // was disabled mid-session.
433    #[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    // ── Codex round-2 finding 5: rename / copy parsing returned the
469    //    SOURCE path instead of the destination, so `git mv old new`
470    //    surfaced "old" (which no longer exists) instead of "new"
471    //    (which the agent created).
472
473    #[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        // The bug Codex caught: this line produced path="src/old.rs"
497        // even though the agent's `git mv` ended with src/new.rs as
498        // the actual file on disk.
499        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        // Copies map to "created" semantically -- a new file appeared
510        // at the destination -- and we record the destination path.
511        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        // Defensive: if git output were ever truncated to "R100\told"
523        // with no destination, we should still record SOMETHING
524        // rather than silently swallowing the line. Falls back to
525        // the source path -- not perfect, but visible.
526        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()); // empty code field
535                                                           // Code with no path -- nothing to record.
536        assert!(parse_name_status_line("M").is_none());
537    }
538
539    #[test]
540    fn runtime_artifact_filter_excludes_generated_state() {
541        // Generated runtime state -- never the agent's work.
542        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        // "./"-prefixed forms (some git output emits these).
557        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        // User-authored Treeship config / policy / cards: these ARE the
566        // operator's own changes and must show up in the receipt.
567        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        // Anything outside .treeship/ is never filtered.
579        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        // Build a real one-commit git repo, then make changes to a mix
588        // of runtime-artifact paths and user-authored paths. The
589        // returned reconciliation must include the user files and
590        // exclude the runtime artifacts.
591        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        // Now create a mix: runtime artifacts and a real user file.
614        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        // User-authored content: present.
628        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        // Runtime artifacts: excluded.
639        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}