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::path::Path;
24use std::process::{Command, Stdio};
25
26/// One file change observed via git that wasn't already captured by a
27/// tool channel. Mapped 1:1 into a synthetic `AgentWroteFile` event
28/// at session close so it flows through the normal aggregator and
29/// receipt composition path.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct GitChange {
32    pub file_path: String,
33    /// "created", "modified", "deleted", "renamed", or "untracked".
34    pub operation: String,
35    pub additions: Option<u32>,
36    pub deletions: Option<u32>,
37}
38
39/// Run `git` in `repo_dir` with the given args, returning stdout
40/// trimmed of trailing newline. Returns None on any failure (not
41/// a git repo, git missing, non-zero exit, etc.) -- this module is
42/// fail-open by contract.
43fn git_capture(repo_dir: &Path, args: &[&str]) -> Option<String> {
44    let output = Command::new("git")
45        .arg("-C").arg(repo_dir)
46        .args(args)
47        .stdin(Stdio::null())
48        .stdout(Stdio::piped())
49        .stderr(Stdio::null())
50        .output()
51        .ok()?;
52    if !output.status.success() {
53        return None;
54    }
55    let mut s = String::from_utf8(output.stdout).ok()?;
56    while s.ends_with('\n') {
57        s.pop();
58    }
59    Some(s)
60}
61
62/// Fast probe: is this directory inside a git repo?
63fn is_git_repo(repo_dir: &Path) -> bool {
64    git_capture(repo_dir, &["rev-parse", "--is-inside-work-tree"])
65        .as_deref()
66        == Some("true")
67}
68
69/// Translate a git diff/status name-status code to our operation
70/// vocabulary. Codes from `git diff --name-status`: A=added,
71/// M=modified, D=deleted, R=renamed, C=copied, T=type-change,
72/// U=unmerged, plus special "??" for ls-files untracked.
73fn translate_status(code: &str) -> &'static str {
74    match code.chars().next().unwrap_or(' ') {
75        'A' => "created",
76        'D' => "deleted",
77        'R' => "renamed",
78        'C' => "created", // copy = new file at the destination
79        'T' => "modified",
80        '?' => "untracked",
81        _   => "modified", // M, U, anything else
82    }
83}
84
85/// Parse a single line of `git diff --name-status`, returning the
86/// canonical operation and the FINAL path of the change.
87///
88/// Codex round-2 finding 5: the original `parts.next() / parts.next()`
89/// shorthand grabbed the FIRST path, which on rename / copy lines is
90/// the OLD path. So `git mv src/old.rs src/new.rs` produced
91/// `R100\told.rs\tnew.rs`, and reconcile recorded `src/old.rs` (which
92/// no longer exists) instead of `src/new.rs` (the destination the
93/// agent actually created). Cross-verify against a cert that allowed
94/// "src/new.rs" then incorrectly flagged the change as touching an
95/// unauthorized file.
96///
97/// Format from `git diff --name-status`:
98///   M\tpath               -- modified
99///   A\tpath               -- added
100///   D\tpath               -- deleted
101///   T\tpath               -- type-changed
102///   R<score>\told\tnew    -- renamed (with similarity score)
103///   C<score>\told\tnew    -- copied
104///   ?\?\tpath             -- (only from ls-files, never name-status)
105///
106/// For R* and C* we return the destination (new) path. For everything
107/// else, the single path. Returns None when the line is empty or
108/// missing fields.
109fn parse_name_status_line(line: &str) -> Option<(&'static str, String)> {
110    let mut parts = line.split('\t');
111    let code = parts.next()?;
112    if code.is_empty() {
113        return None;
114    }
115    let first_path = parts.next()?;
116    let op = translate_status(code);
117    let path = match code.chars().next().unwrap_or(' ') {
118        'R' | 'C' => {
119            // Rename / copy: the FINAL path is the third field.
120            // If the destination is missing for any reason, fall
121            // back to the source so we still record SOMETHING.
122            parts.next().map(|p| p.to_string()).unwrap_or_else(|| first_path.to_string())
123        }
124        _ => first_path.to_string(),
125    };
126    Some((op, path))
127}
128
129/// Parse a single line of `git diff --numstat` (additions, deletions,
130/// path). Numeric fields are "-" for binary files; we represent
131/// those as None.
132fn parse_numstat_line(line: &str) -> Option<(String, Option<u32>, Option<u32>)> {
133    let mut parts = line.splitn(3, '\t');
134    let adds_s = parts.next()?;
135    let dels_s = parts.next()?;
136    let path = parts.next()?.to_string();
137    let adds = adds_s.parse::<u32>().ok();
138    let dels = dels_s.parse::<u32>().ok();
139    Some((path, adds, dels))
140}
141
142/// Decides whether a path discovered by git reconciliation should be
143/// included in the receipt.
144///
145/// Treeship's own runtime artifacts -- session.closing markers,
146/// sessions/<id>/ event logs, artifact storage, scratch tmp -- live
147/// inside `.treeship/` and get touched by the very session that's
148/// closing. Without this filter they show up in `files_written` as
149/// "the agent modified .treeship/sessions/ssn_X/events.jsonl",
150/// noisy and misleading: it was Treeship's own bookkeeping, not the
151/// agent's work.
152///
153/// User-authored Treeship files (config.yaml, declaration.json, agent
154/// cards, policy) DO get surfaced -- those are the operator's own
155/// changes that an audit reader cares about.
156fn is_treeship_runtime_artifact(path: &str) -> bool {
157    // Strip leading "./" if present so both forms compare cleanly.
158    let p = path.strip_prefix("./").unwrap_or(path);
159    if !p.starts_with(".treeship/") && p != ".treeship" {
160        return false;
161    }
162    // Within .treeship/, exclude generated runtime state.
163    p == ".treeship/session.closing"
164        || p == ".treeship/session.json"
165        || p.starts_with(".treeship/sessions/")
166        || p.starts_with(".treeship/artifacts/")
167        || p.starts_with(".treeship/tmp/")
168        || p.starts_with(".treeship/proof_queue/")
169}
170
171/// Collect every file change in `repo_dir` worth surfacing in a
172/// session receipt: working-tree modifications (staged or not),
173/// committed-since-`since_sha` changes (when provided), and untracked
174/// files.
175///
176/// Deduplicated across the three sources so a single file shows up
177/// once. When `git diff --numstat` reports additions/deletions for a
178/// path, those are attached; binary files leave them as None.
179///
180/// Returns an empty Vec if `repo_dir` isn't a git repo or git isn't
181/// available -- callers (i.e. session close) should treat absence as
182/// "nothing to reconcile" and continue.
183pub fn reconcile_changes(repo_dir: &Path, since_sha: Option<&str>) -> Vec<GitChange> {
184    if !is_git_repo(repo_dir) {
185        return Vec::new();
186    }
187
188    use std::collections::BTreeMap;
189    // path -> change. BTreeMap so output is deterministic across runs,
190    // which matters for canonical receipt JSON / merkle stability.
191    let mut by_path: BTreeMap<String, GitChange> = BTreeMap::new();
192
193    let mut record = |path: String, op: &str| {
194        if is_treeship_runtime_artifact(&path) {
195            return;
196        }
197        by_path.entry(path.clone()).or_insert(GitChange {
198            file_path: path,
199            operation: op.to_string(),
200            additions: None,
201            deletions: None,
202        });
203    };
204
205    // 1. Uncommitted changes vs HEAD (staged + unstaged combined via
206    //    name-status). The common agent case: edited a file, didn't
207    //    commit.
208    if let Some(out) = git_capture(repo_dir, &["diff", "HEAD", "--name-status"]) {
209        for line in out.lines() {
210            if let Some((op, path)) = parse_name_status_line(line) {
211                record(path, op);
212            }
213        }
214    }
215
216    // 2. Committed-during-session changes if the caller captured a
217    //    starting SHA at session start.
218    if let Some(sha) = since_sha {
219        let range = format!("{sha}..HEAD");
220        if let Some(out) = git_capture(repo_dir, &["diff", &range, "--name-status"]) {
221            for line in out.lines() {
222                if let Some((op, path)) = parse_name_status_line(line) {
223                    record(path, op);
224                }
225            }
226        }
227    }
228
229    // 3. Untracked files (new files the agent added but didn't `git
230    //    add`). These never show in `git diff` so they need their own
231    //    pass.
232    if let Some(out) = git_capture(repo_dir, &["ls-files", "--others", "--exclude-standard"]) {
233        for path in out.lines().filter(|l| !l.is_empty()) {
234            record(path.to_string(), "untracked");
235        }
236    }
237
238    // 4. Numstat for the same diff range -- attach additions/deletions
239    //    where available. Numstat reports differently than name-status
240    //    (no rename indicator), so we just match by path.
241    if let Some(out) = git_capture(repo_dir, &["diff", "HEAD", "--numstat"]) {
242        for line in out.lines() {
243            if let Some((path, adds, dels)) = parse_numstat_line(line) {
244                if let Some(entry) = by_path.get_mut(&path) {
245                    entry.additions = adds;
246                    entry.deletions = dels;
247                }
248            }
249        }
250    }
251    if let Some(sha) = since_sha {
252        let range = format!("{sha}..HEAD");
253        if let Some(out) = git_capture(repo_dir, &["diff", &range, "--numstat"]) {
254            for line in out.lines() {
255                if let Some((path, adds, dels)) = parse_numstat_line(line) {
256                    if let Some(entry) = by_path.get_mut(&path) {
257                        entry.additions = adds;
258                        entry.deletions = dels;
259                    }
260                }
261            }
262        }
263    }
264
265    by_path.into_values().collect()
266}
267
268/// Capture the current HEAD commit SHA so it can be stored in the
269/// session manifest at session start. The session close pass uses it
270/// as the diff base for committed-during-session changes. Returns
271/// None when not in a git repo or no commits exist yet.
272pub fn current_head_sha(repo_dir: &Path) -> Option<String> {
273    if !is_git_repo(repo_dir) {
274        return None;
275    }
276    git_capture(repo_dir, &["rev-parse", "HEAD"])
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn translate_status_maps_known_codes() {
285        assert_eq!(translate_status("A"), "created");
286        assert_eq!(translate_status("M"), "modified");
287        assert_eq!(translate_status("D"), "deleted");
288        assert_eq!(translate_status("R100"), "renamed");
289        assert_eq!(translate_status("??"), "untracked");
290        assert_eq!(translate_status(""), "modified");
291        assert_eq!(translate_status("X"), "modified");
292    }
293
294    #[test]
295    fn parse_numstat_handles_text_and_binary() {
296        let (p, a, d) = parse_numstat_line("12\t3\tsrc/a.rs").unwrap();
297        assert_eq!(p, "src/a.rs");
298        assert_eq!(a, Some(12));
299        assert_eq!(d, Some(3));
300
301        // Binary files: numstat uses "-\t-\tpath".
302        let (p, a, d) = parse_numstat_line("-\t-\tassets/logo.png").unwrap();
303        assert_eq!(p, "assets/logo.png");
304        assert_eq!(a, None);
305        assert_eq!(d, None);
306    }
307
308    #[test]
309    fn reconcile_in_non_git_dir_returns_empty() {
310        let tmp = std::env::temp_dir().join(format!("treeship-not-a-repo-{}", rand::random::<u32>()));
311        std::fs::create_dir_all(&tmp).unwrap();
312        let result = reconcile_changes(&tmp, None);
313        assert!(result.is_empty());
314        let _ = std::fs::remove_dir_all(&tmp);
315    }
316
317    // ── Codex round-2 finding 5: rename / copy parsing returned the
318    //    SOURCE path instead of the destination, so `git mv old new`
319    //    surfaced "old" (which no longer exists) instead of "new"
320    //    (which the agent created).
321
322    #[test]
323    fn parse_name_status_modify_uses_single_path() {
324        let (op, path) = parse_name_status_line("M\tsrc/lib.rs").unwrap();
325        assert_eq!(op, "modified");
326        assert_eq!(path, "src/lib.rs");
327    }
328
329    #[test]
330    fn parse_name_status_added_uses_single_path() {
331        let (op, path) = parse_name_status_line("A\tsrc/new.rs").unwrap();
332        assert_eq!(op, "created");
333        assert_eq!(path, "src/new.rs");
334    }
335
336    #[test]
337    fn parse_name_status_deleted_uses_single_path() {
338        let (op, path) = parse_name_status_line("D\tsrc/gone.rs").unwrap();
339        assert_eq!(op, "deleted");
340        assert_eq!(path, "src/gone.rs");
341    }
342
343    #[test]
344    fn parse_name_status_rename_uses_destination() {
345        // The bug Codex caught: this line produced path="src/old.rs"
346        // even though the agent's `git mv` ended with src/new.rs as
347        // the actual file on disk.
348        let (op, path) = parse_name_status_line("R100\tsrc/old.rs\tsrc/new.rs").unwrap();
349        assert_eq!(op, "renamed");
350        assert_eq!(path, "src/new.rs", "rename must record the destination, not the source");
351    }
352
353    #[test]
354    fn parse_name_status_copy_uses_destination() {
355        // Copies map to "created" semantically -- a new file appeared
356        // at the destination -- and we record the destination path.
357        let (op, path) = parse_name_status_line("C75\tsrc/template.rs\tsrc/new-from-template.rs").unwrap();
358        assert_eq!(op, "created");
359        assert_eq!(path, "src/new-from-template.rs", "copy must record the destination");
360    }
361
362    #[test]
363    fn parse_name_status_rename_falls_back_to_source_if_dest_missing() {
364        // Defensive: if git output were ever truncated to "R100\told"
365        // with no destination, we should still record SOMETHING
366        // rather than silently swallowing the line. Falls back to
367        // the source path -- not perfect, but visible.
368        let (op, path) = parse_name_status_line("R100\tsrc/only-old.rs").unwrap();
369        assert_eq!(op, "renamed");
370        assert_eq!(path, "src/only-old.rs");
371    }
372
373    #[test]
374    fn parse_name_status_handles_empty_or_garbage_lines() {
375        assert!(parse_name_status_line("").is_none());
376        assert!(parse_name_status_line("\t\t").is_none()); // empty code field
377        // Code with no path -- nothing to record.
378        assert!(parse_name_status_line("M").is_none());
379    }
380
381    #[test]
382    fn runtime_artifact_filter_excludes_generated_state() {
383        // Generated runtime state -- never the agent's work.
384        assert!(is_treeship_runtime_artifact(".treeship/session.closing"));
385        assert!(is_treeship_runtime_artifact(".treeship/session.json"));
386        assert!(is_treeship_runtime_artifact(".treeship/sessions/ssn_abc/events.jsonl"));
387        assert!(is_treeship_runtime_artifact(".treeship/sessions/ssn_abc/manifest.json"));
388        assert!(is_treeship_runtime_artifact(".treeship/artifacts/foo.json"));
389        assert!(is_treeship_runtime_artifact(".treeship/tmp/scratch"));
390        assert!(is_treeship_runtime_artifact(".treeship/proof_queue/pending.json"));
391
392        // "./"-prefixed forms (some git output emits these).
393        assert!(is_treeship_runtime_artifact("./.treeship/session.closing"));
394        assert!(is_treeship_runtime_artifact("./.treeship/sessions/ssn_x/events.jsonl"));
395    }
396
397    #[test]
398    fn runtime_artifact_filter_preserves_user_authored_files() {
399        // User-authored Treeship config / policy / cards: these ARE the
400        // operator's own changes and must show up in the receipt.
401        assert!(!is_treeship_runtime_artifact(".treeship/config.yaml"));
402        assert!(!is_treeship_runtime_artifact(".treeship/config.json"));
403        assert!(!is_treeship_runtime_artifact(".treeship/declaration.json"));
404        assert!(!is_treeship_runtime_artifact(".treeship/policy.yaml"));
405        assert!(!is_treeship_runtime_artifact(".treeship/agents/coder.agent"));
406        assert!(!is_treeship_runtime_artifact(".treeship/agents/reviewer.json"));
407
408        // Anything outside .treeship/ is never filtered.
409        assert!(!is_treeship_runtime_artifact("src/main.rs"));
410        assert!(!is_treeship_runtime_artifact("README.md"));
411        assert!(!is_treeship_runtime_artifact("treeship-notes.md"));
412        assert!(!is_treeship_runtime_artifact(".treeshiprc"));
413    }
414
415    #[test]
416    fn reconcile_filters_runtime_artifacts_end_to_end() {
417        // Build a real one-commit git repo, then make changes to a mix
418        // of runtime-artifact paths and user-authored paths. The
419        // returned reconciliation must include the user files and
420        // exclude the runtime artifacts.
421        let tmp = std::env::temp_dir().join(format!("treeship-reconcile-{}", rand::random::<u32>()));
422        std::fs::create_dir_all(&tmp).unwrap();
423
424        let run = |args: &[&str]| {
425            std::process::Command::new("git")
426                .arg("-C").arg(&tmp)
427                .args(args)
428                .stdout(std::process::Stdio::null())
429                .stderr(std::process::Stdio::null())
430                .status()
431                .ok();
432        };
433
434        run(&["init", "-q"]);
435        run(&["config", "user.email", "test@example.com"]);
436        run(&["config", "user.name", "Test"]);
437        std::fs::write(tmp.join("README.md"), "hi\n").unwrap();
438        run(&["add", "."]);
439        run(&["commit", "-q", "-m", "init"]);
440
441        // Now create a mix: runtime artifacts and a real user file.
442        std::fs::create_dir_all(tmp.join(".treeship/sessions/ssn_x")).unwrap();
443        std::fs::create_dir_all(tmp.join(".treeship/artifacts")).unwrap();
444        std::fs::create_dir_all(tmp.join(".treeship/agents")).unwrap();
445        std::fs::write(tmp.join(".treeship/sessions/ssn_x/events.jsonl"), "{}\n").unwrap();
446        std::fs::write(tmp.join(".treeship/artifacts/foo.json"), "{}\n").unwrap();
447        std::fs::write(tmp.join(".treeship/session.closing"), "").unwrap();
448        std::fs::write(tmp.join(".treeship/agents/coder.agent"), "name: coder\n").unwrap();
449        std::fs::write(tmp.join(".treeship/declaration.json"), "{}\n").unwrap();
450        std::fs::write(tmp.join("src.rs"), "fn main() {}\n").unwrap();
451
452        let changes = reconcile_changes(&tmp, None);
453        let paths: Vec<&str> = changes.iter().map(|c| c.file_path.as_str()).collect();
454
455        // User-authored content: present.
456        assert!(paths.contains(&"src.rs"), "user file missing: {paths:?}");
457        assert!(paths.contains(&".treeship/agents/coder.agent"), "agent card missing: {paths:?}");
458        assert!(paths.contains(&".treeship/declaration.json"), "declaration missing: {paths:?}");
459
460        // Runtime artifacts: excluded.
461        assert!(!paths.contains(&".treeship/sessions/ssn_x/events.jsonl"), "leaked: {paths:?}");
462        assert!(!paths.contains(&".treeship/artifacts/foo.json"), "leaked: {paths:?}");
463        assert!(!paths.contains(&".treeship/session.closing"), "leaked: {paths:?}");
464
465        let _ = std::fs::remove_dir_all(&tmp);
466    }
467}