use std::path::Path;
use std::process::{Command, Stdio};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitChange {
pub file_path: String,
pub operation: String,
pub additions: Option<u32>,
pub deletions: Option<u32>,
}
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 is_git_repo(repo_dir: &Path) -> bool {
git_capture(repo_dir, &["rev-parse", "--is-inside-work-tree"])
.as_deref()
== Some("true")
}
fn translate_status(code: &str) -> &'static str {
match code.chars().next().unwrap_or(' ') {
'A' => "created",
'D' => "deleted",
'R' => "renamed",
'C' => "created", 'T' => "modified",
'?' => "untracked",
_ => "modified", }
}
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' => {
parts.next().map(|p| p.to_string()).unwrap_or_else(|| first_path.to_string())
}
_ => first_path.to_string(),
};
Some((op, path))
}
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))
}
fn is_treeship_runtime_artifact(path: &str) -> bool {
let p = path.strip_prefix("./").unwrap_or(path);
if !p.starts_with(".treeship/") && p != ".treeship" {
return false;
}
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/")
}
pub fn reconcile_changes(repo_dir: &Path, since_sha: Option<&str>) -> Vec<GitChange> {
if !is_git_repo(repo_dir) {
return Vec::new();
}
use std::collections::BTreeMap;
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,
});
};
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);
}
}
}
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);
}
}
}
}
if let Some(out) = git_capture(repo_dir, &["ls-files", "--others", "--exclude-standard"]) {
for path in out.lines().filter(|l| !l.is_empty()) {
record(path.to_string(), "untracked");
}
}
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;
}
}
}
}
}
by_path.into_values().collect()
}
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));
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);
}
#[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() {
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() {
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() {
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()); assert!(parse_name_status_line("M").is_none());
}
#[test]
fn runtime_artifact_filter_excludes_generated_state() {
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"));
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() {
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"));
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() {
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"]);
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();
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:?}");
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);
}
}