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)
}
pub fn stage_and_diff(worktree: &Path) -> Result<(String, String), String> {
git(worktree, &["add", "-A"])?;
let stat = git(worktree, &["diff", "--cached", "--stat"])?;
let patch = git(worktree, &["diff", "--cached"])?;
Ok((stat, super::shell_tool::tail(&patch, 32 * 1024)))
}
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}");
}
}