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 { 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    /// Whether a git work tree was actually present when reconcile ran
59    /// (AUD-07). `false` means `is_git_repo` returned false — not in a repo,
60    /// git binary missing/PATH-poisoned, or `.git` removed/corrupted — so the
61    /// git-diff backstop produced NO changes for a reason other than "clean
62    /// tree". The close path cross-checks this against the session's
63    /// start-of-session HEAD: if git worked at start but not at close, the
64    /// backstop was disabled mid-session and the receipt is stamped degraded.
65    /// Defaults to false so a `ReconcileSummary::default()` (reconcile never
66    /// ran) is treated as "git not confirmed present", not a clean tree.
67    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
76/// Run `git` in `repo_dir` with the given args, returning stdout
77/// trimmed of trailing newline. Returns None on any failure (not
78/// a git repo, git missing, non-zero exit, etc.) -- this module is
79/// fail-open by contract.
80fn 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
128/// Fast probe: is this directory inside a git repo?
129fn 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
139/// Translate a git diff/status name-status code to our operation
140/// vocabulary. Codes from `git diff --name-status`: A=added,
141/// M=modified, D=deleted, R=renamed, C=copied, T=type-change,
142/// U=unmerged, plus special "??" for ls-files untracked.
143fn 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", // copy = new file at the destination
149        'T' => "modified",
150        '?' => "untracked",
151        _   => "modified", // M, U, anything else
152    }
153}
154
155/// Parse a single line of `git diff --name-status`, returning the
156/// canonical operation and the FINAL path of the change.
157///
158/// Codex round-2 finding 5: the original `parts.next() / parts.next()`
159/// shorthand grabbed the FIRST path, which on rename / copy lines is
160/// the OLD path. So `git mv src/old.rs src/new.rs` produced
161/// `R100\told.rs\tnew.rs`, and reconcile recorded `src/old.rs` (which
162/// no longer exists) instead of `src/new.rs` (the destination the
163/// agent actually created). Cross-verify against a cert that allowed
164/// "src/new.rs" then incorrectly flagged the change as touching an
165/// unauthorized file.
166///
167/// Format from `git diff --name-status`:
168///   M\tpath               -- modified
169///   A\tpath               -- added
170///   D\tpath               -- deleted
171///   T\tpath               -- type-changed
172///   R<score>\told\tnew    -- renamed (with similarity score)
173///   C<score>\told\tnew    -- copied
174///   ?\?\tpath             -- (only from ls-files, never name-status)
175///
176/// For R* and C* we return the destination (new) path. For everything
177/// else, the single path. Returns None when the line is empty or
178/// missing fields.
179fn 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            // Rename / copy: the FINAL path is the third field.
190            // If the destination is missing for any reason, fall
191            // back to the source so we still record SOMETHING.
192            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
199/// Parse a single line of `git diff --numstat` (additions, deletions,
200/// path). Numeric fields are "-" for binary files; we represent
201/// those as None.
202fn 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
212/// Decides whether a path discovered by git reconciliation should be
213/// included in the receipt.
214///
215/// Treeship's own runtime artifacts -- session.closing markers,
216/// sessions/<id>/ event logs, artifact storage, scratch tmp -- live
217/// inside `.treeship/` and get touched by the very session that's
218/// closing. Without this filter they show up in `files_written` as
219/// "the agent modified .treeship/sessions/ssn_X/events.jsonl",
220/// noisy and misleading: it was Treeship's own bookkeeping, not the
221/// agent's work.
222///
223/// User-authored Treeship files (config.yaml, declaration.json, agent
224/// cards, policy) DO get surfaced -- those are the operator's own
225/// changes that an audit reader cares about.
226fn is_treeship_runtime_artifact(path: &str) -> bool {
227    // Strip leading "./" if present so both forms compare cleanly.
228    let p = path.strip_prefix("./").unwrap_or(path);
229    if !p.starts_with(".treeship/") && p != ".treeship" {
230        return false;
231    }
232    // Within .treeship/, exclude generated runtime state.
233    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
241/// Collect every file change in `repo_dir` worth surfacing in a
242/// session receipt: working-tree modifications (staged or not),
243/// committed-since-`since_sha` changes (when provided), and untracked
244/// files.
245///
246/// Deduplicated across the three sources so a single file shows up
247/// once. When `git diff --numstat` reports additions/deletions for a
248/// path, those are attached; binary files leave them as None.
249///
250/// Returns an empty Vec if `repo_dir` isn't a git repo or git isn't
251/// available -- callers (i.e. session close) should treat absence as
252/// "nothing to reconcile" and continue.
253pub 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        // git_repo_present stays false: the caller can distinguish "clean tree
272        // in a real repo" from "no repo / git unavailable" (AUD-07).
273        return result;
274    }
275    result.summary.git_repo_present = true;
276
277    use std::collections::BTreeMap;
278    // path -> change. BTreeMap so output is deterministic across runs,
279    // which matters for canonical receipt JSON / merkle stability.
280    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    // 1. Uncommitted changes vs HEAD (staged + unstaged combined via
295    //    name-status). The common agent case: edited a file, didn't
296    //    commit.
297    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    // 2. Committed-during-session changes if the caller captured a
306    //    starting SHA at session start.
307    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    // 3. Untracked files (new files the agent added but didn't `git
319    //    add`). These never show in `git diff` so they need their own
320    //    pass.
321    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    // 4. Numstat for the same diff range -- attach additions/deletions
338    //    where available. Numstat reports differently than name-status
339    //    (no rename indicator), so we just match by path.
340    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
368/// Capture the current HEAD commit SHA so it can be stored in the
369/// session manifest at session start. The session close pass uses it
370/// as the diff base for committed-during-session changes. Returns
371/// None when not in a git repo or no commits exist yet.
372pub 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        // Binary files: numstat uses "-\t-\tpath".
402        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    // AUD-07: git_repo_present distinguishes "clean tree in a real repo" from
418    // "no repo / git unavailable" so the close path can detect a backstop that
419    // was disabled mid-session.
420    #[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    // ── Codex round-2 finding 5: rename / copy parsing returned the
445    //    SOURCE path instead of the destination, so `git mv old new`
446    //    surfaced "old" (which no longer exists) instead of "new"
447    //    (which the agent created).
448
449    #[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        // The bug Codex caught: this line produced path="src/old.rs"
473        // even though the agent's `git mv` ended with src/new.rs as
474        // the actual file on disk.
475        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        // Copies map to "created" semantically -- a new file appeared
483        // at the destination -- and we record the destination path.
484        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        // Defensive: if git output were ever truncated to "R100\told"
492        // with no destination, we should still record SOMETHING
493        // rather than silently swallowing the line. Falls back to
494        // the source path -- not perfect, but visible.
495        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()); // empty code field
504        // Code with no path -- nothing to record.
505        assert!(parse_name_status_line("M").is_none());
506    }
507
508    #[test]
509    fn runtime_artifact_filter_excludes_generated_state() {
510        // Generated runtime state -- never the agent's work.
511        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        // "./"-prefixed forms (some git output emits these).
520        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        // User-authored Treeship config / policy / cards: these ARE the
527        // operator's own changes and must show up in the receipt.
528        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        // Anything outside .treeship/ is never filtered.
536        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        // Build a real one-commit git repo, then make changes to a mix
545        // of runtime-artifact paths and user-authored paths. The
546        // returned reconciliation must include the user files and
547        // exclude the runtime artifacts.
548        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        // Now create a mix: runtime artifacts and a real user file.
569        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        // User-authored content: present.
583        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        // Runtime artifacts: excluded.
588        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}