use std::path::Path;
use super::contract::OutcomeContract;
fn commit_worktree(
worktree: &Path,
intent: &str,
contract: &OutcomeContract,
) -> Result<String, String> {
let status = git(worktree, &["status", "--porcelain"])?;
if status.trim().is_empty() {
return Err("no changes to deliver — the worktree is clean".to_string());
}
git(worktree, &["add", "-A"])?;
let subject: String = {
let s = intent.trim().replace('\n', " ");
if s.len() > 72 {
let mut end = 69;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &s[..end])
} else {
s
}
};
let body = format!(
"Authored by CAR Coder.\n\nIntent:\n{}\n\nOutcome contract (all checks passed):\n{}",
intent.trim(),
contract.render()
);
git(
worktree,
&[
"-c",
"user.name=car-coder",
"-c",
"user.email=coder@parslee.ai",
"commit",
"-m",
&subject,
"-m",
&body,
],
)?;
Ok(git(worktree, &["rev-parse", "HEAD"])?.trim().to_string())
}
pub fn publish_branch(
repo: &Path,
worktree: &Path,
short_id: &str,
intent: &str,
contract: &OutcomeContract,
) -> Result<String, String> {
let commit = commit_worktree(worktree, intent, contract)?;
let branch = format!("car/coder/{short_id}");
git(repo, &["branch", &branch, &commit])?;
Ok(branch)
}
pub fn commit_to_main(
repo: &Path,
worktree: &Path,
intent: &str,
contract: &OutcomeContract,
) -> Result<String, String> {
let commit = commit_worktree(worktree, intent, contract)?;
git(repo, &["merge", "--ff-only", &commit]).map_err(|e| {
format!("could not fast-forward the project's main branch (it moved since the session started): {e}")
})?;
Ok(commit)
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StagedDiff {
pub stat: String,
pub patch: String,
pub truncated: bool,
pub full_bytes: usize,
pub changed_paths: Vec<String>,
}
pub fn stage_and_diff(worktree: &Path, patch_cap_bytes: usize) -> Result<StagedDiff, String> {
git(worktree, &["add", "-A"])?;
let stat = git(worktree, &["diff", "--cached", "--stat"])?;
let patch = git(worktree, &["diff", "--cached"])?;
let names = git(
worktree,
&[
"-c",
"core.quotepath=false",
"diff",
"--cached",
"--name-status",
"-z",
],
)?;
let changed_paths = parse_name_status_z(&names);
let full_bytes = patch.len();
Ok(StagedDiff {
patch: super::shell_tool::tail(&patch, patch_cap_bytes),
truncated: full_bytes > patch_cap_bytes,
full_bytes,
stat,
changed_paths,
})
}
fn parse_name_status_z(raw: &str) -> Vec<String> {
let mut out = Vec::new();
let mut fields = raw.split('\0').filter(|f| !f.is_empty());
while let Some(status) = fields.next() {
let bytes = status.as_bytes();
let well_formed = status.len() <= 4
&& matches!(
bytes[0],
b'A' | b'C' | b'D' | b'M' | b'R' | b'T' | b'U' | b'X' | b'B'
)
&& status[1..].bytes().all(|b| b.is_ascii_digit());
if !well_formed {
tracing::warn!(
status = %status,
"unexpected field in `git diff --name-status -z`; changed-path list truncated \
rather than risk a desynchronized parse"
);
break;
}
let two_paths = bytes[0] == b'R' || bytes[0] == b'C';
let Some(first) = fields.next() else {
tracing::warn!(status = %status, "name-status stream ended mid-entry");
break;
};
out.push(first.to_string());
if two_paths {
match fields.next() {
Some(second) => out.push(second.to_string()),
None => {
tracing::warn!(status = %status, "rename/copy entry missing its destination");
break;
}
}
}
}
out.sort();
out.dedup();
out
}
pub(crate) fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.map_err(|e| format!("git {args:?}: {e}"))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
} else {
Err(format!(
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::coder::contract::ContractCheck;
fn contract() -> OutcomeContract {
OutcomeContract {
description: "x exists".into(),
checks: vec![ContractCheck {
name: "exists".into(),
command: "test -f x.txt".into(),
expect_exit_zero: true,
output_contains: None,
timeout_secs: 10,
}],
}
}
fn init_repo(dir: &Path) {
for args in [
vec!["init", "-q", "-b", "main"],
vec![
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"--allow-empty",
"-m",
"init",
],
] {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(&args)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn publishes_branch_without_touching_user_checkout() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_repo(repo);
let wt_base = tempfile::tempdir().unwrap();
let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
let ws = car_multi::AgentWorkspace::provision(&config, "coder-merge-test").unwrap();
std::fs::write(ws.path().join("x.txt"), "made by coder").unwrap();
let branch = publish_branch(
repo,
ws.path(),
"abc12345",
"create x.txt with content",
&contract(),
)
.unwrap();
assert_eq!(branch, "car/coder/abc12345");
let show = git(repo, &["show", &format!("{branch}:x.txt")]).unwrap();
assert_eq!(show, "made by coder");
let author = git(repo, &["log", "-1", "--format=%an", &branch]).unwrap();
assert_eq!(author.trim(), "car-coder");
let status = git(repo, &["status", "--porcelain"]).unwrap();
assert!(status.is_empty(), "user checkout dirtied: {status}");
assert!(!repo.join("x.txt").exists());
}
#[test]
fn clean_worktree_refuses_to_publish() {
let repo_dir = tempfile::tempdir().unwrap();
init_repo(repo_dir.path());
let wt_base = tempfile::tempdir().unwrap();
let config = car_multi::WorkspaceConfig::git_worktree_at(repo_dir.path(), wt_base.path());
let ws = car_multi::AgentWorkspace::provision(&config, "coder-clean-test").unwrap();
let err =
publish_branch(repo_dir.path(), ws.path(), "def", "noop", &contract()).unwrap_err();
assert!(err.contains("no changes"), "{err}");
}
#[test]
fn long_intent_is_truncated_in_subject() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_repo(repo);
let wt_base = tempfile::tempdir().unwrap();
let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
let ws = car_multi::AgentWorkspace::provision(&config, "coder-long-test").unwrap();
std::fs::write(ws.path().join("y.txt"), "y").unwrap();
let long_intent = "a very ".repeat(40) + "long intent";
let branch = publish_branch(repo, ws.path(), "fff", &long_intent, &contract()).unwrap();
let subject = git(repo, &["log", "-1", "--format=%s", &branch]).unwrap();
assert!(subject.trim().len() <= 72);
assert!(subject.contains("..."));
}
#[test]
fn commit_to_main_fast_forwards_the_checkout() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_repo(repo);
let wt_base = tempfile::tempdir().unwrap();
let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
let ws = car_multi::AgentWorkspace::provision(&config, "coder-main-test").unwrap();
std::fs::write(ws.path().join("z.txt"), "managed").unwrap();
let commit = commit_to_main(repo, ws.path(), "add z", &contract()).unwrap();
let head = git(repo, &["rev-parse", "HEAD"]).unwrap();
assert_eq!(head.trim(), commit);
assert_eq!(
std::fs::read_to_string(repo.join("z.txt")).unwrap(),
"managed"
);
assert!(git(repo, &["branch", "--list", "car/coder/*"])
.unwrap()
.is_empty());
}
#[test]
fn commit_to_main_errors_when_main_moved() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_repo(repo);
let wt_base = tempfile::tempdir().unwrap();
let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
let ws = car_multi::AgentWorkspace::provision(&config, "coder-moved-test").unwrap();
std::fs::write(ws.path().join("a.txt"), "from session").unwrap();
std::fs::write(repo.join("b.txt"), "concurrent").unwrap();
for args in [
vec!["-c", "user.name=t", "-c", "user.email=t@t", "add", "-A"],
vec![
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"-m",
"concurrent",
],
] {
assert!(std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(&args)
.output()
.unwrap()
.status
.success());
}
let err = commit_to_main(repo, ws.path(), "add a", &contract()).unwrap_err();
assert!(err.contains("fast-forward"), "{err}");
}
#[test]
fn a_rename_reports_both_endpoints_not_just_the_destination() {
let paths = parse_name_status_z("R100\0secrets/key.txt\0public_key.txt\0");
assert!(
paths.contains(&"secrets/key.txt".to_string()),
"the source directory must not vanish: {paths:?}"
);
assert!(paths.contains(&"public_key.txt".to_string()), "{paths:?}");
assert_eq!(paths.len(), 2);
}
#[test]
fn a_copy_also_reports_both_endpoints() {
let paths = parse_name_status_z("C75\0src/a.rs\0src/b.rs\0");
assert_eq!(paths, vec!["src/a.rs".to_string(), "src/b.rs".to_string()]);
}
#[test]
fn mixed_entries_stay_in_sync_after_a_rename() {
let paths = parse_name_status_z("M\0src/a.rs\0R100\0old/x.rs\0new/x.rs\0A\0src/z.rs\0");
assert_eq!(
paths,
vec![
"new/x.rs".to_string(),
"old/x.rs".to_string(),
"src/a.rs".to_string(),
"src/z.rs".to_string(),
]
);
}
#[test]
fn a_newline_in_a_filename_does_not_forge_an_entry() {
let paths = parse_name_status_z("A\0we\nird.txt\0");
assert_eq!(paths, vec!["we\nird.txt".to_string()]);
}
#[test]
fn an_empty_diff_yields_no_paths() {
assert!(parse_name_status_z("").is_empty());
}
#[test]
fn type_change_and_unmerged_are_single_path_entries() {
assert_eq!(
parse_name_status_z("T\0src/link.txt\0M\0src/after.rs\0"),
vec!["src/after.rs".to_string(), "src/link.txt".to_string()]
);
assert_eq!(
parse_name_status_z("U\0conflict.txt\0"),
vec!["conflict.txt".to_string()]
);
}
#[test]
fn an_unrecognized_status_bails_instead_of_desynchronizing() {
assert!(parse_name_status_z("Z9\0a.txt\0b.txt\0").is_empty());
assert_eq!(
parse_name_status_z("M\0good.rs\0Z9\0a.txt\0"),
vec!["good.rs".to_string()]
);
}
#[test]
fn a_rename_missing_its_destination_bails() {
assert_eq!(
parse_name_status_z("R100\0only-one.txt\0"),
vec!["only-one.txt".to_string()]
);
}
#[test]
fn stage_and_diff_sees_a_renamed_out_of_directory_source() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
for args in [
vec!["init", "-q", "."],
vec!["config", "user.email", "t@t"],
vec!["config", "user.name", "t"],
] {
git(repo, &args).unwrap();
}
std::fs::create_dir(repo.join("secrets")).unwrap();
std::fs::write(repo.join("secrets/key.txt"), "k").unwrap();
git(repo, &["add", "-A"]).unwrap();
git(repo, &["commit", "-qm", "init"]).unwrap();
std::fs::rename(repo.join("secrets/key.txt"), repo.join("public_key.txt")).unwrap();
let diff = stage_and_diff(repo, 64 * 1024).unwrap();
assert!(
diff.changed_paths.iter().any(|p| p.starts_with("secrets/")),
"the source directory must appear: {:?}",
diff.changed_paths
);
assert_eq!(
diff.changed_paths,
vec!["public_key.txt".to_string(), "secrets/key.txt".to_string()],
"both endpoints, and nothing else"
);
}
}