treeship-core 0.11.1

Portable trust receipts for agent workflows - core library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
//! Git-based reconciliation for session close.
//!
//! Backstop layer of the trust-fabric file-capture stack:
//!
//!   1. (highest trust) specialized event types (`agent.wrote_file`)
//!   2. (medium)        promoted from generic `agent.called_tool`
//!   3. (this module)   shell out to `git` at session close and pick
//!                      up anything an agent edited outside any
//!                      captured tool channel
//!
//! Why this matters: the trust-fabric bar is "if a file changed, it
//! must appear in the receipt." Hooks and MCP cover most paths but
//! not all -- an agent that ran `sed -i` inside a Bash command, a
//! build tool that modified files, or any other untracked side
//! effect would otherwise vanish silently. Running `git diff` and
//! `git ls-files --others` at close catches the rest.
//!
//! Fail-open by design: if the working dir isn't a git repo, if the
//! git binary is missing, or if any git command errors, returns an
//! empty list. The receipt is still produced; reconciliation is a
//! best-effort enhancement, never a gate.

use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

pub const DEFAULT_UNTRACKED_MAX: usize = 5_000;

/// One file change observed via git that wasn't already captured by a
/// tool channel. Mapped 1:1 into a synthetic `AgentWroteFile` event
/// at session close so it flows through the normal aggregator and
/// receipt composition path.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitChange {
    pub file_path: String,
    /// "created", "modified", "deleted", "renamed", or "untracked".
    pub operation: String,
    pub additions: Option<u32>,
    pub deletions: Option<u32>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReconcileOptions {
    pub untracked_max: usize,
}

impl Default for ReconcileOptions {
    fn default() -> Self {
        Self { untracked_max: DEFAULT_UNTRACKED_MAX }
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReconcileSummary {
    pub untracked_seen: usize,
    pub untracked_cap: usize,
    pub untracked_truncated: bool,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReconcileResult {
    pub changes: Vec<GitChange>,
    pub summary: ReconcileSummary,
}

/// Run `git` in `repo_dir` with the given args, returning stdout
/// trimmed of trailing newline. Returns None on any failure (not
/// a git repo, git missing, non-zero exit, etc.) -- this module is
/// fail-open by contract.
fn git_capture(repo_dir: &Path, args: &[&str]) -> Option<String> {
    let output = Command::new("git")
        .arg("-C").arg(repo_dir)
        .args(args)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let mut s = String::from_utf8(output.stdout).ok()?;
    while s.ends_with('\n') {
        s.pop();
    }
    Some(s)
}

fn git_capture_lines_limited(repo_dir: &Path, args: &[&str], limit: usize) -> Option<(Vec<String>, bool)> {
    let mut child = Command::new("git")
        .arg("-C").arg(repo_dir)
        .args(args)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
        .ok()?;
    let stdout = child.stdout.take()?;
    let reader = BufReader::new(stdout);
    let mut lines = Vec::new();
    let mut truncated = false;
    for line in reader.lines() {
        let line = line.ok()?;
        if lines.len() >= limit {
            truncated = true;
            let _ = child.kill();
            break;
        }
        lines.push(line);
    }
    let status = child.wait().ok()?;
    if !truncated && !status.success() {
        return None;
    }
    Some((lines, truncated))
}

/// Fast probe: is this directory inside a git repo?
fn is_git_repo(repo_dir: &Path) -> bool {
    git_capture(repo_dir, &["rev-parse", "--is-inside-work-tree"])
        .as_deref()
        == Some("true")
}

pub fn git_toplevel(repo_dir: &Path) -> Option<PathBuf> {
    git_capture(repo_dir, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
}

/// Translate a git diff/status name-status code to our operation
/// vocabulary. Codes from `git diff --name-status`: A=added,
/// M=modified, D=deleted, R=renamed, C=copied, T=type-change,
/// U=unmerged, plus special "??" for ls-files untracked.
fn translate_status(code: &str) -> &'static str {
    match code.chars().next().unwrap_or(' ') {
        'A' => "created",
        'D' => "deleted",
        'R' => "renamed",
        'C' => "created", // copy = new file at the destination
        'T' => "modified",
        '?' => "untracked",
        _   => "modified", // M, U, anything else
    }
}

/// Parse a single line of `git diff --name-status`, returning the
/// canonical operation and the FINAL path of the change.
///
/// Codex round-2 finding 5: the original `parts.next() / parts.next()`
/// shorthand grabbed the FIRST path, which on rename / copy lines is
/// the OLD path. So `git mv src/old.rs src/new.rs` produced
/// `R100\told.rs\tnew.rs`, and reconcile recorded `src/old.rs` (which
/// no longer exists) instead of `src/new.rs` (the destination the
/// agent actually created). Cross-verify against a cert that allowed
/// "src/new.rs" then incorrectly flagged the change as touching an
/// unauthorized file.
///
/// Format from `git diff --name-status`:
///   M\tpath               -- modified
///   A\tpath               -- added
///   D\tpath               -- deleted
///   T\tpath               -- type-changed
///   R<score>\told\tnew    -- renamed (with similarity score)
///   C<score>\told\tnew    -- copied
///   ?\?\tpath             -- (only from ls-files, never name-status)
///
/// For R* and C* we return the destination (new) path. For everything
/// else, the single path. Returns None when the line is empty or
/// missing fields.
fn parse_name_status_line(line: &str) -> Option<(&'static str, String)> {
    let mut parts = line.split('\t');
    let code = parts.next()?;
    if code.is_empty() {
        return None;
    }
    let first_path = parts.next()?;
    let op = translate_status(code);
    let path = match code.chars().next().unwrap_or(' ') {
        'R' | 'C' => {
            // Rename / copy: the FINAL path is the third field.
            // If the destination is missing for any reason, fall
            // back to the source so we still record SOMETHING.
            parts.next().map(|p| p.to_string()).unwrap_or_else(|| first_path.to_string())
        }
        _ => first_path.to_string(),
    };
    Some((op, path))
}

/// Parse a single line of `git diff --numstat` (additions, deletions,
/// path). Numeric fields are "-" for binary files; we represent
/// those as None.
fn parse_numstat_line(line: &str) -> Option<(String, Option<u32>, Option<u32>)> {
    let mut parts = line.splitn(3, '\t');
    let adds_s = parts.next()?;
    let dels_s = parts.next()?;
    let path = parts.next()?.to_string();
    let adds = adds_s.parse::<u32>().ok();
    let dels = dels_s.parse::<u32>().ok();
    Some((path, adds, dels))
}

/// Decides whether a path discovered by git reconciliation should be
/// included in the receipt.
///
/// Treeship's own runtime artifacts -- session.closing markers,
/// sessions/<id>/ event logs, artifact storage, scratch tmp -- live
/// inside `.treeship/` and get touched by the very session that's
/// closing. Without this filter they show up in `files_written` as
/// "the agent modified .treeship/sessions/ssn_X/events.jsonl",
/// noisy and misleading: it was Treeship's own bookkeeping, not the
/// agent's work.
///
/// User-authored Treeship files (config.yaml, declaration.json, agent
/// cards, policy) DO get surfaced -- those are the operator's own
/// changes that an audit reader cares about.
fn is_treeship_runtime_artifact(path: &str) -> bool {
    // Strip leading "./" if present so both forms compare cleanly.
    let p = path.strip_prefix("./").unwrap_or(path);
    if !p.starts_with(".treeship/") && p != ".treeship" {
        return false;
    }
    // Within .treeship/, exclude generated runtime state.
    p == ".treeship/session.closing"
        || p == ".treeship/session.json"
        || p.starts_with(".treeship/sessions/")
        || p.starts_with(".treeship/artifacts/")
        || p.starts_with(".treeship/tmp/")
        || p.starts_with(".treeship/proof_queue/")
}

/// Collect every file change in `repo_dir` worth surfacing in a
/// session receipt: working-tree modifications (staged or not),
/// committed-since-`since_sha` changes (when provided), and untracked
/// files.
///
/// Deduplicated across the three sources so a single file shows up
/// once. When `git diff --numstat` reports additions/deletions for a
/// path, those are attached; binary files leave them as None.
///
/// Returns an empty Vec if `repo_dir` isn't a git repo or git isn't
/// available -- callers (i.e. session close) should treat absence as
/// "nothing to reconcile" and continue.
pub fn reconcile_changes(repo_dir: &Path, since_sha: Option<&str>) -> Vec<GitChange> {
    reconcile_changes_with_options(repo_dir, since_sha, &ReconcileOptions::default()).changes
}

pub fn reconcile_changes_with_options(
    repo_dir: &Path,
    since_sha: Option<&str>,
    options: &ReconcileOptions,
) -> ReconcileResult {
    let mut result = ReconcileResult {
        summary: ReconcileSummary {
            untracked_cap: options.untracked_max,
            ..ReconcileSummary::default()
        },
        ..ReconcileResult::default()
    };

    if !is_git_repo(repo_dir) {
        return result;
    }

    use std::collections::BTreeMap;
    // path -> change. BTreeMap so output is deterministic across runs,
    // which matters for canonical receipt JSON / merkle stability.
    let mut by_path: BTreeMap<String, GitChange> = BTreeMap::new();

    let mut record = |path: String, op: &str| {
        if is_treeship_runtime_artifact(&path) {
            return;
        }
        by_path.entry(path.clone()).or_insert(GitChange {
            file_path: path,
            operation: op.to_string(),
            additions: None,
            deletions: None,
        });
    };

    // 1. Uncommitted changes vs HEAD (staged + unstaged combined via
    //    name-status). The common agent case: edited a file, didn't
    //    commit.
    if let Some(out) = git_capture(repo_dir, &["diff", "HEAD", "--name-status"]) {
        for line in out.lines() {
            if let Some((op, path)) = parse_name_status_line(line) {
                record(path, op);
            }
        }
    }

    // 2. Committed-during-session changes if the caller captured a
    //    starting SHA at session start.
    if let Some(sha) = since_sha {
        let range = format!("{sha}..HEAD");
        if let Some(out) = git_capture(repo_dir, &["diff", &range, "--name-status"]) {
            for line in out.lines() {
                if let Some((op, path)) = parse_name_status_line(line) {
                    record(path, op);
                }
            }
        }
    }

    // 3. Untracked files (new files the agent added but didn't `git
    //    add`). These never show in `git diff` so they need their own
    //    pass.
    if let Some((lines, truncated)) = git_capture_lines_limited(
        repo_dir,
        &["ls-files", "--others", "--exclude-standard"],
        options.untracked_max.saturating_add(1),
    ) {
        result.summary.untracked_seen = lines.len();
        result.summary.untracked_truncated = truncated || lines.len() > options.untracked_max;
        if result.summary.untracked_truncated {
            result.summary.untracked_seen = result.summary.untracked_seen.max(options.untracked_max.saturating_add(1));
        } else {
            for path in lines.iter().filter(|l| !l.is_empty()) {
                record(path.to_string(), "untracked");
            }
        }
    }

    // 4. Numstat for the same diff range -- attach additions/deletions
    //    where available. Numstat reports differently than name-status
    //    (no rename indicator), so we just match by path.
    if let Some(out) = git_capture(repo_dir, &["diff", "HEAD", "--numstat"]) {
        for line in out.lines() {
            if let Some((path, adds, dels)) = parse_numstat_line(line) {
                if let Some(entry) = by_path.get_mut(&path) {
                    entry.additions = adds;
                    entry.deletions = dels;
                }
            }
        }
    }
    if let Some(sha) = since_sha {
        let range = format!("{sha}..HEAD");
        if let Some(out) = git_capture(repo_dir, &["diff", &range, "--numstat"]) {
            for line in out.lines() {
                if let Some((path, adds, dels)) = parse_numstat_line(line) {
                    if let Some(entry) = by_path.get_mut(&path) {
                        entry.additions = adds;
                        entry.deletions = dels;
                    }
                }
            }
        }
    }

    result.changes = by_path.into_values().collect();
    result
}

/// Capture the current HEAD commit SHA so it can be stored in the
/// session manifest at session start. The session close pass uses it
/// as the diff base for committed-during-session changes. Returns
/// None when not in a git repo or no commits exist yet.
pub fn current_head_sha(repo_dir: &Path) -> Option<String> {
    if !is_git_repo(repo_dir) {
        return None;
    }
    git_capture(repo_dir, &["rev-parse", "HEAD"])
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn translate_status_maps_known_codes() {
        assert_eq!(translate_status("A"), "created");
        assert_eq!(translate_status("M"), "modified");
        assert_eq!(translate_status("D"), "deleted");
        assert_eq!(translate_status("R100"), "renamed");
        assert_eq!(translate_status("??"), "untracked");
        assert_eq!(translate_status(""), "modified");
        assert_eq!(translate_status("X"), "modified");
    }

    #[test]
    fn parse_numstat_handles_text_and_binary() {
        let (p, a, d) = parse_numstat_line("12\t3\tsrc/a.rs").unwrap();
        assert_eq!(p, "src/a.rs");
        assert_eq!(a, Some(12));
        assert_eq!(d, Some(3));

        // Binary files: numstat uses "-\t-\tpath".
        let (p, a, d) = parse_numstat_line("-\t-\tassets/logo.png").unwrap();
        assert_eq!(p, "assets/logo.png");
        assert_eq!(a, None);
        assert_eq!(d, None);
    }

    #[test]
    fn reconcile_in_non_git_dir_returns_empty() {
        let tmp = std::env::temp_dir().join(format!("treeship-not-a-repo-{}", rand::random::<u32>()));
        std::fs::create_dir_all(&tmp).unwrap();
        let result = reconcile_changes(&tmp, None);
        assert!(result.is_empty());
        let _ = std::fs::remove_dir_all(&tmp);
    }

    // ── Codex round-2 finding 5: rename / copy parsing returned the
    //    SOURCE path instead of the destination, so `git mv old new`
    //    surfaced "old" (which no longer exists) instead of "new"
    //    (which the agent created).

    #[test]
    fn parse_name_status_modify_uses_single_path() {
        let (op, path) = parse_name_status_line("M\tsrc/lib.rs").unwrap();
        assert_eq!(op, "modified");
        assert_eq!(path, "src/lib.rs");
    }

    #[test]
    fn parse_name_status_added_uses_single_path() {
        let (op, path) = parse_name_status_line("A\tsrc/new.rs").unwrap();
        assert_eq!(op, "created");
        assert_eq!(path, "src/new.rs");
    }

    #[test]
    fn parse_name_status_deleted_uses_single_path() {
        let (op, path) = parse_name_status_line("D\tsrc/gone.rs").unwrap();
        assert_eq!(op, "deleted");
        assert_eq!(path, "src/gone.rs");
    }

    #[test]
    fn parse_name_status_rename_uses_destination() {
        // The bug Codex caught: this line produced path="src/old.rs"
        // even though the agent's `git mv` ended with src/new.rs as
        // the actual file on disk.
        let (op, path) = parse_name_status_line("R100\tsrc/old.rs\tsrc/new.rs").unwrap();
        assert_eq!(op, "renamed");
        assert_eq!(path, "src/new.rs", "rename must record the destination, not the source");
    }

    #[test]
    fn parse_name_status_copy_uses_destination() {
        // Copies map to "created" semantically -- a new file appeared
        // at the destination -- and we record the destination path.
        let (op, path) = parse_name_status_line("C75\tsrc/template.rs\tsrc/new-from-template.rs").unwrap();
        assert_eq!(op, "created");
        assert_eq!(path, "src/new-from-template.rs", "copy must record the destination");
    }

    #[test]
    fn parse_name_status_rename_falls_back_to_source_if_dest_missing() {
        // Defensive: if git output were ever truncated to "R100\told"
        // with no destination, we should still record SOMETHING
        // rather than silently swallowing the line. Falls back to
        // the source path -- not perfect, but visible.
        let (op, path) = parse_name_status_line("R100\tsrc/only-old.rs").unwrap();
        assert_eq!(op, "renamed");
        assert_eq!(path, "src/only-old.rs");
    }

    #[test]
    fn parse_name_status_handles_empty_or_garbage_lines() {
        assert!(parse_name_status_line("").is_none());
        assert!(parse_name_status_line("\t\t").is_none()); // empty code field
        // Code with no path -- nothing to record.
        assert!(parse_name_status_line("M").is_none());
    }

    #[test]
    fn runtime_artifact_filter_excludes_generated_state() {
        // Generated runtime state -- never the agent's work.
        assert!(is_treeship_runtime_artifact(".treeship/session.closing"));
        assert!(is_treeship_runtime_artifact(".treeship/session.json"));
        assert!(is_treeship_runtime_artifact(".treeship/sessions/ssn_abc/events.jsonl"));
        assert!(is_treeship_runtime_artifact(".treeship/sessions/ssn_abc/manifest.json"));
        assert!(is_treeship_runtime_artifact(".treeship/artifacts/foo.json"));
        assert!(is_treeship_runtime_artifact(".treeship/tmp/scratch"));
        assert!(is_treeship_runtime_artifact(".treeship/proof_queue/pending.json"));

        // "./"-prefixed forms (some git output emits these).
        assert!(is_treeship_runtime_artifact("./.treeship/session.closing"));
        assert!(is_treeship_runtime_artifact("./.treeship/sessions/ssn_x/events.jsonl"));
    }

    #[test]
    fn runtime_artifact_filter_preserves_user_authored_files() {
        // User-authored Treeship config / policy / cards: these ARE the
        // operator's own changes and must show up in the receipt.
        assert!(!is_treeship_runtime_artifact(".treeship/config.yaml"));
        assert!(!is_treeship_runtime_artifact(".treeship/config.json"));
        assert!(!is_treeship_runtime_artifact(".treeship/declaration.json"));
        assert!(!is_treeship_runtime_artifact(".treeship/policy.yaml"));
        assert!(!is_treeship_runtime_artifact(".treeship/agents/coder.agent"));
        assert!(!is_treeship_runtime_artifact(".treeship/agents/reviewer.json"));

        // Anything outside .treeship/ is never filtered.
        assert!(!is_treeship_runtime_artifact("src/main.rs"));
        assert!(!is_treeship_runtime_artifact("README.md"));
        assert!(!is_treeship_runtime_artifact("treeship-notes.md"));
        assert!(!is_treeship_runtime_artifact(".treeshiprc"));
    }

    #[test]
    fn reconcile_filters_runtime_artifacts_end_to_end() {
        // Build a real one-commit git repo, then make changes to a mix
        // of runtime-artifact paths and user-authored paths. The
        // returned reconciliation must include the user files and
        // exclude the runtime artifacts.
        let tmp = std::env::temp_dir().join(format!("treeship-reconcile-{}", rand::random::<u32>()));
        std::fs::create_dir_all(&tmp).unwrap();

        let run = |args: &[&str]| {
            std::process::Command::new("git")
                .arg("-C").arg(&tmp)
                .args(args)
                .stdout(std::process::Stdio::null())
                .stderr(std::process::Stdio::null())
                .status()
                .ok();
        };

        run(&["init", "-q"]);
        run(&["config", "user.email", "test@example.com"]);
        run(&["config", "user.name", "Test"]);
        std::fs::write(tmp.join("README.md"), "hi\n").unwrap();
        run(&["add", "."]);
        run(&["commit", "-q", "-m", "init"]);

        // Now create a mix: runtime artifacts and a real user file.
        std::fs::create_dir_all(tmp.join(".treeship/sessions/ssn_x")).unwrap();
        std::fs::create_dir_all(tmp.join(".treeship/artifacts")).unwrap();
        std::fs::create_dir_all(tmp.join(".treeship/agents")).unwrap();
        std::fs::write(tmp.join(".treeship/sessions/ssn_x/events.jsonl"), "{}\n").unwrap();
        std::fs::write(tmp.join(".treeship/artifacts/foo.json"), "{}\n").unwrap();
        std::fs::write(tmp.join(".treeship/session.closing"), "").unwrap();
        std::fs::write(tmp.join(".treeship/agents/coder.agent"), "name: coder\n").unwrap();
        std::fs::write(tmp.join(".treeship/declaration.json"), "{}\n").unwrap();
        std::fs::write(tmp.join("src.rs"), "fn main() {}\n").unwrap();

        let changes = reconcile_changes(&tmp, None);
        let paths: Vec<&str> = changes.iter().map(|c| c.file_path.as_str()).collect();

        // User-authored content: present.
        assert!(paths.contains(&"src.rs"), "user file missing: {paths:?}");
        assert!(paths.contains(&".treeship/agents/coder.agent"), "agent card missing: {paths:?}");
        assert!(paths.contains(&".treeship/declaration.json"), "declaration missing: {paths:?}");

        // Runtime artifacts: excluded.
        assert!(!paths.contains(&".treeship/sessions/ssn_x/events.jsonl"), "leaked: {paths:?}");
        assert!(!paths.contains(&".treeship/artifacts/foo.json"), "leaked: {paths:?}");
        assert!(!paths.contains(&".treeship/session.closing"), "leaked: {paths:?}");

        let _ = std::fs::remove_dir_all(&tmp);
    }

    #[test]
    fn reconcile_truncates_untracked_without_promoting_per_file_events() {
        let tmp = std::env::temp_dir().join(format!("treeship-reconcile-cap-{}", rand::random::<u32>()));
        std::fs::create_dir_all(&tmp).unwrap();

        let run = |args: &[&str]| {
            std::process::Command::new("git")
                .arg("-C").arg(&tmp)
                .args(args)
                .stdout(std::process::Stdio::null())
                .stderr(std::process::Stdio::null())
                .status()
                .ok();
        };

        run(&["init", "-q"]);
        run(&["config", "user.email", "test@example.com"]);
        run(&["config", "user.name", "Test"]);
        std::fs::write(tmp.join("README.md"), "hi\n").unwrap();
        run(&["add", "."]);
        run(&["commit", "-q", "-m", "init"]);

        std::fs::write(tmp.join("a.txt"), "a\n").unwrap();
        std::fs::write(tmp.join("b.txt"), "b\n").unwrap();
        std::fs::write(tmp.join("c.txt"), "c\n").unwrap();

        let result = reconcile_changes_with_options(
            &tmp,
            None,
            &ReconcileOptions { untracked_max: 2 },
        );

        assert!(result.summary.untracked_truncated);
        assert_eq!(result.summary.untracked_cap, 2);
        assert!(result.summary.untracked_seen >= 3);
        assert!(
            result.changes.iter().all(|c| c.operation != "untracked"),
            "truncated untracked files must not be emitted one-per-file: {:?}",
            result.changes,
        );

        let _ = std::fs::remove_dir_all(&tmp);
    }
}