use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
pub const DEFAULT_UNTRACKED_MAX: usize = 5_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitChange {
pub file_path: String,
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,
}
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))
}
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)
}
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> {
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;
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((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");
}
}
}
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
}
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);
}
#[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);
}
}