use std::path::{Path, PathBuf};
use std::sync::Arc;
use super::contract::OutcomeContract;
use super::session::IntegratedSubtask;
fn subject_from_intent(intent: &str) -> String {
let trimmed = intent.replace("\r\n", "\n");
let trimmed = trimmed.trim();
let head = trimmed.split("\n\n").next().unwrap_or(trimmed).trim();
let s = head.replace(['\n', '\r'], " ");
if s.len() > 72 {
let mut end = 69;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &s[..end])
} else {
s
}
}
enum CommitOutcome {
Made(String),
NothingToCommit,
}
pub fn placement_provenance(
placements: &[car_multi::Placement],
integrated: &[IntegratedSubtask],
repaired_locally: bool,
) -> Option<String> {
if integrated.is_empty() {
return None;
}
let worker_of = |subtask_id: &str| -> Option<&car_multi::Placement> {
placements.iter().find(|p| p.subtask_id == subtask_id)
};
let mut lines: Vec<String> = Vec::new();
let mut any_remote = false;
for landed in integrated {
let Some(p) = worker_of(&landed.subtask_id) else {
continue;
};
let Some(worker) = p.worker_id.as_deref() else {
continue;
};
any_remote |= p.remote;
let mut line = format!(
"CAR-Placement: subtask={} worker={} remote={}",
trailer_value(&landed.subtask_id),
trailer_value(worker),
p.remote
);
if !landed.files.is_empty() {
line.push_str(&format!(
" files={}",
landed
.files
.iter()
.map(|f| trailer_value(f))
.collect::<Vec<_>>()
.join(",")
));
}
lines.push(line);
}
if lines.is_empty() {
return None;
}
if !any_remote && !repaired_locally {
return None;
}
if repaired_locally {
lines.push(
"CAR-Placement: repaired-locally=true (the union failed the contract and was repaired here, so not every delivered hunk is listed above)"
.to_string(),
);
}
Some(lines.join("\n"))
}
fn trailer_value(raw: &str) -> String {
const MAX: usize = 120;
let cleaned: String = raw
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let cleaned = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
if cleaned.chars().count() > MAX {
cleaned.chars().take(MAX).collect::<String>() + "…"
} else {
cleaned
}
}
fn commit_worktree(
worktree: &Path,
intent: &str,
contract: &OutcomeContract,
provenance: Option<&str>,
) -> Result<CommitOutcome, String> {
let status = git(
worktree,
&["status", "--porcelain", "--untracked-files=normal"],
)?;
if status.trim().is_empty() {
return Ok(CommitOutcome::NothingToCommit);
}
git(worktree, &["add", "-A"])?;
let subject = subject_from_intent(intent);
let body = checkpoint_body(intent, contract, provenance);
git(
worktree,
&[
"-c",
"user.name=car-coder",
"-c",
"user.email=coder@parslee.ai",
"commit",
"-m",
&subject,
"-m",
&body,
],
)?;
Ok(CommitOutcome::Made(
git(worktree, &["rev-parse", "HEAD"])?.trim().to_string(),
))
}
fn checkpoint_body(intent: &str, contract: &OutcomeContract, provenance: Option<&str>) -> String {
let mut body = format!(
"Authored by CAR Coder.\n\nIntent:\n{}\n\nOutcome contract (all checks passed):\n{}",
intent.trim(),
contract.render()
);
if let Some(provenance) = provenance {
body.push_str("\n\n");
body.push_str(provenance);
}
body
}
fn require_commit(outcome: CommitOutcome) -> Result<String, String> {
match outcome {
CommitOutcome::Made(sha) => Ok(sha),
CommitOutcome::NothingToCommit => {
Err("no changes to deliver — the worktree is clean".to_string())
}
}
}
fn head_beyond_base(worktree: &Path, base_branch: &str) -> Result<String, String> {
let head = git(worktree, &["rev-parse", "HEAD"])?.trim().to_string();
let base_ref = ["origin/", ""]
.iter()
.map(|p| format!("{p}{base_branch}"))
.find(|r| git(worktree, &["rev-parse", "--verify", "--quiet", r]).is_ok())
.ok_or_else(|| {
format!(
"the worktree is clean and neither origin/{base_branch} nor {base_branch} \
resolves, so whether there is anything to deliver cannot be determined"
)
})?;
if git(worktree, &["merge-base", "--is-ancestor", &head, &base_ref]).is_ok() {
return Err(format!(
"nothing to deliver: the worktree is clean and its HEAD ({head}) is already \
contained in {base_ref}, so there is no work to deliver"
));
}
Ok(head)
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CheckoutIdentity {
pub head: String,
pub reference: String,
}
impl CheckoutIdentity {
pub fn read(repo: &Path) -> Result<Self, String> {
Ok(Self {
head: git(repo, &["rev-parse", "HEAD"])?.trim().into(),
reference: git(repo, &["rev-parse", "--symbolic-full-name", "HEAD"])?
.trim()
.into(),
})
}
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ReviewIdentity {
pub head: String,
pub tree: String,
}
impl ReviewIdentity {
pub fn read(worktree: &Path) -> Result<Self, String> {
if !git(worktree, &["diff", "--name-only", "-z", "--"])?.is_empty()
|| !git(
worktree,
&["ls-files", "--others", "--exclude-standard", "-z"],
)?
.is_empty()
{
return Err("Task files changed after the review diff was staged. Review and verify the updated work before delivery.".into());
}
Ok(Self {
head: git(worktree, &["rev-parse", "HEAD"])?.trim().into(),
tree: git(worktree, &["write-tree"])?.trim().into(),
})
}
pub fn validate(&self, worktree: &Path) -> Result<(), String> {
if &Self::read(worktree)? != self {
return Err("Task revision or staged files changed after review. Review and verify the updated work before delivery.".into());
}
Ok(())
}
}
pub(super) fn snapshot_checkout(repo: &Path, session_id: &str) -> Result<Option<String>, String> {
if session_id.is_empty()
|| !session_id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-')
{
return Err("invalid checkout snapshot session id".into());
}
if !git(repo, &["ls-files", "--unmerged"])?.is_empty() {
return Err("Resolve the checkout's merge conflicts before starting a coding task.".into());
}
let identity = CheckoutIdentity::read(repo)?;
let directory = tempfile::tempdir().map_err(|e| e.to_string())?;
let index = directory.path().join("index");
let isolated = |args: &[&str]| -> Result<String, String> {
git_bytes_with_index(repo, args, Some(&index))
.map(|bytes| String::from_utf8_lossy(&bytes).trim().to_string())
};
isolated(&["read-tree", &identity.head])?;
isolated(&["add", "-A", "--", "."])?;
let tree = isolated(&["write-tree"])?;
if CheckoutIdentity::read(repo)? != identity {
return Err("Checkout revision changed while capturing task inputs; start again.".into());
}
if tree == git(repo, &["rev-parse", "HEAD^{tree}"])?.trim() {
return Ok(None);
}
let commit = git(
repo,
&[
"-c",
"user.name=car-coder",
"-c",
"user.email=coder@parslee.ai",
"commit-tree",
&tree,
"-p",
&identity.head,
"-m",
"CAR Coder: preserve checkout inputs before task",
],
)?
.trim()
.to_string();
git(
repo,
&[
"update-ref",
&format!("refs/car/coder-inputs/{session_id}"),
&commit,
],
)?;
Ok(Some(commit))
}
pub(super) fn apply_to_checkout(
repo: &Path,
worktree: &Path,
session_id: &str,
expected: &CheckoutIdentity,
intent: &str,
contract: &OutcomeContract,
provenance: Option<&str>,
) -> Result<(String, bool), String> {
if session_id.is_empty()
|| !session_id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-')
{
return Err("invalid checkout delivery session id".into());
}
if &CheckoutIdentity::read(repo)? != expected {
return Err("checkout revision or branch changed since this task started; work remains in its worktree. Review the new checkout before applying".into());
}
git(worktree, &["add", "-AN"])?;
let patch = git_bytes(
worktree,
&[
"-c",
"core.quotePath=true",
"-c",
"core.autocrlf=false",
"diff",
"HEAD",
"--binary",
"--no-ext-diff",
"--no-textconv",
],
)?;
if patch.is_empty() {
return Err("no changes to apply".into());
}
let patch_file = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?;
std::fs::write(patch_file.path(), &patch).map_err(|e| e.to_string())?;
let patch_path = patch_file
.path()
.to_str()
.ok_or("non-UTF8 temporary patch path")?;
let already_applied = if git(
repo,
&["apply", "--check", "--whitespace=nowarn", patch_path],
)
.is_ok()
{
false
} else if git(
repo,
&[
"apply",
"--reverse",
"--check",
"--whitespace=nowarn",
patch_path,
],
)
.is_ok()
{
true
} else {
return Err("reviewed changes conflict with the current checkout; no changes applied. Existing edits and the result worktree are preserved".into());
};
git(worktree, &["add", "-A"])?;
let tree = git(worktree, &["write-tree"])?;
let parent = git(worktree, &["rev-parse", "HEAD"])?;
let commit = git(
worktree,
&[
"-c",
"user.name=car-coder",
"-c",
"user.email=coder@parslee.ai",
"commit-tree",
tree.trim(),
"-p",
parent.trim(),
"-m",
&subject_from_intent(intent),
"-m",
&checkpoint_body(intent, contract, provenance),
],
)?
.trim()
.to_string();
let checkpoint = format!("refs/car/coder/{session_id}");
git(repo, &["update-ref", &checkpoint, &commit])?;
if &CheckoutIdentity::read(repo)? != expected {
return Err(
"checkout changed during delivery preparation; changes remain in the worktree".into(),
);
}
if !already_applied {
git(repo, &["apply", "--whitespace=nowarn", patch_path]).map_err(|e| {
format!("checkout apply failed; retain the result worktree for recovery: {e}")
})?;
}
Ok((commit, already_applied))
}
pub fn publish_branch(
repo: &Path,
worktree: &Path,
short_id: &str,
intent: &str,
contract: &OutcomeContract,
provenance: Option<&str>,
) -> Result<String, String> {
publish_branch_with_commit(repo, worktree, short_id, intent, contract, provenance)
.map(|(branch, _)| branch)
}
pub(super) fn publish_branch_with_commit(
repo: &Path,
worktree: &Path,
short_id: &str,
intent: &str,
contract: &OutcomeContract,
provenance: Option<&str>,
) -> Result<(String, String), String> {
let commit = require_commit(commit_worktree(worktree, intent, contract, provenance)?)?;
let branch = format!("car/coder/{short_id}");
git(repo, &["branch", &branch, &commit])?;
Ok((branch, commit))
}
fn changed_paths(repo: &Path, from: &str, to: &str) -> Result<Vec<String>, String> {
let raw = git(
repo,
&[
"-c",
"core.quotePath=false",
"diff",
"--name-only",
"--no-renames",
"-z",
from,
to,
],
)?;
Ok(raw
.split('\0')
.filter(|path| !path.is_empty())
.map(str::to_string)
.collect())
}
pub(super) fn publish_branch_off_snapshot(
repo: &Path,
worktree: &Path,
short_id: &str,
intent: &str,
contract: &OutcomeContract,
provenance: Option<&str>,
snapshot: &str,
) -> Result<(String, String), String> {
let checkout_head = git(repo, &["rev-parse", &format!("{snapshot}^")])
.map_err(|e| format!("the task's private input snapshot has no parent commit: {e}"))?
.trim()
.to_string();
let user_paths = changed_paths(repo, &checkout_head, snapshot)?;
let mut agent_paths = changed_paths(worktree, snapshot, "HEAD")?;
agent_paths.extend(worktree_changed_paths(worktree)?);
let mut overlap: Vec<&String> = agent_paths
.iter()
.filter(|path| user_paths.contains(path))
.collect();
overlap.sort();
overlap.dedup();
if !overlap.is_empty() {
let named: Vec<&str> = overlap.iter().take(5).map(|p| p.as_str()).collect();
return Err(format!(
"your checkout has uncommitted changes to {} and this task changed the same \
file(s), so a branch cannot be published without carrying your work into it. \
Apply the result to your checkout instead, or commit/stash those changes and \
run the task again — the result is preserved in its worktree either way",
named.join(", ")
));
}
let commit = require_commit(commit_worktree(worktree, intent, contract, provenance)?)?;
let patch = git_bytes(
repo,
&[
"-c",
"core.quotePath=true",
"-c",
"core.autocrlf=false",
"diff",
"--binary",
"--no-renames",
"--no-ext-diff",
"--no-textconv",
snapshot,
&commit,
],
)?;
if patch.is_empty() {
return Err(
"no changes to deliver — the result is identical to the files this task started from"
.into(),
);
}
let patch_file = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?;
std::fs::write(patch_file.path(), &patch).map_err(|e| e.to_string())?;
let patch_path = patch_file
.path()
.to_str()
.ok_or("non-UTF8 temporary patch path")?;
let directory = tempfile::tempdir().map_err(|e| e.to_string())?;
let index = directory.path().join("index");
let isolated = |args: &[&str]| -> Result<String, String> {
git_bytes_with_index(repo, args, Some(&index))
.map(|bytes| String::from_utf8_lossy(&bytes).trim().to_string())
};
isolated(&["read-tree", &checkout_head])?;
isolated(&["apply", "--cached", "--whitespace=nowarn", patch_path]).map_err(|e| {
format!("the reviewed changes do not apply to your checkout's revision: {e}")
})?;
let tree = isolated(&["write-tree"])?;
let message = git(repo, &["log", "-1", "--format=%B", &commit])?;
let delivered = git(
repo,
&[
"-c",
"user.name=car-coder",
"-c",
"user.email=coder@parslee.ai",
"commit-tree",
&tree,
"-p",
&checkout_head,
"-m",
message.trim(),
],
)?
.trim()
.to_string();
let branch = format!("car/coder/{short_id}");
git(repo, &["branch", &branch, &delivered])?;
Ok((branch, delivered))
}
fn worktree_changed_paths(worktree: &Path) -> Result<Vec<String>, String> {
let raw = git(
worktree,
&[
"status",
"--porcelain",
"-z",
"--no-renames",
"--untracked-files=all",
],
)?;
Ok(raw
.split('\0')
.filter(|entry| entry.len() > 3)
.map(|entry| entry[3..].to_string())
.collect())
}
pub fn publish_branch_headless(
repo: &Path,
worktree: &Path,
short_id: &str,
intent: &str,
contract: &OutcomeContract,
base_branch: &str,
provenance: Option<&str>,
) -> Result<String, String> {
let commit = match commit_worktree(worktree, intent, contract, provenance)? {
CommitOutcome::Made(sha) => sha,
CommitOutcome::NothingToCommit => head_beyond_base(worktree, base_branch)?,
};
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,
provenance: Option<&str>,
) -> Result<String, String> {
let commit = require_commit(commit_worktree(worktree, intent, contract, provenance)?)?;
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"])?;
read_staged_diff(worktree, patch_cap_bytes)
}
pub(super) fn read_staged_diff(
worktree: &Path,
patch_cap_bytes: usize,
) -> Result<StagedDiff, String> {
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> {
git_bytes(dir, args).map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
}
fn git_bytes(dir: &Path, args: &[&str]) -> Result<Vec<u8>, String> {
git_bytes_with_index(dir, args, None)
}
fn git_bytes_with_index(
dir: &Path,
args: &[&str],
index: Option<&Path>,
) -> Result<Vec<u8>, String> {
let mut cmd = std::process::Command::new("git");
cmd.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_OBJECT_DIRECTORY")
.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES")
.env_remove("GIT_COMMON_DIR")
.arg("-C")
.arg(dir)
.args(args);
if let Some(index) = index {
cmd.env("GIT_INDEX_FILE", index);
}
no_interactive_prompts(&mut cmd);
let out = run_capped(cmd).map_err(|e| match e {
RunFailure::Spawn(io) => format!("git {args:?}: {io}"),
RunFailure::TimedOut(secs) => format!(
"git {args:?} timed out after {secs}s and was killed; treat it as a transport failure"
),
})?;
if out.status.success() {
Ok(out.stdout)
} else {
Err(format!(
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
))
}
}
fn no_interactive_prompts(cmd: &mut std::process::Command) {
cmd.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_ASKPASS", "")
.env("SSH_ASKPASS", "")
.env("SSH_ASKPASS_REQUIRE", "never");
}
const SUBPROCESS_TIMEOUT_SECS: u64 = 900;
enum RunFailure {
Spawn(std::io::Error),
TimedOut(u64),
}
fn run_capped(cmd: std::process::Command) -> Result<std::process::Output, RunFailure> {
run_capped_for(cmd, std::time::Duration::from_secs(SUBPROCESS_TIMEOUT_SECS))
}
fn run_capped_for(
mut cmd: std::process::Command,
timeout: std::time::Duration,
) -> Result<std::process::Output, RunFailure> {
use std::io::Read as _;
use std::process::Stdio;
let mut child = cmd
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(RunFailure::Spawn)?;
let mut child_out = child.stdout.take().expect("stdout piped");
let mut child_err = child.stderr.take().expect("stderr piped");
let out_reader = std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = child_out.read_to_end(&mut buf);
buf
});
let err_reader = std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = child_err.read_to_end(&mut buf);
buf
});
let deadline = std::time::Instant::now() + timeout;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {}
Err(e) => return Err(RunFailure::Spawn(e)),
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err(RunFailure::TimedOut(timeout.as_secs()));
}
std::thread::sleep(std::time::Duration::from_millis(25));
};
Ok(std::process::Output {
status,
stdout: out_reader.join().unwrap_or_default(),
stderr: err_reader.join().unwrap_or_default(),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PrAction {
Opened,
Updated,
}
impl PrAction {
pub fn as_str(&self) -> &'static str {
match self {
PrAction::Opened => "opened",
PrAction::Updated => "updated",
}
}
}
impl std::fmt::Display for PrAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrState {
Open,
ClosedUnmerged,
Merged,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PrRecord {
pub number: u64,
pub state: PrState,
pub url: String,
pub is_draft: bool,
pub base: String,
}
pub struct PrDelivery<'a> {
pub repo: &'a Path,
pub worktree: &'a Path,
pub target_branch: &'a str,
pub base_branch: &'a str,
pub draft: bool,
pub intent: &'a str,
pub contract: &'a OutcomeContract,
pub body: &'a str,
pub provenance: Option<&'a str>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CiState {
Green,
Pending,
Red,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CiCheck {
pub name: String,
pub state: CiState,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CiSummary {
pub head_sha: String,
pub state: CiState,
pub checks: Vec<CiCheck>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observation_error: Option<String>,
}
impl CiSummary {
fn from_checks(head_sha: &str, checks: Vec<(String, CiState)>) -> Self {
let mut by_name = std::collections::BTreeMap::<String, CiState>::new();
for (name, state) in checks {
by_name
.entry(name)
.and_modify(|current| {
if ci_severity(state) > ci_severity(*current) {
*current = state;
}
})
.or_insert(state);
}
let checks: Vec<CiCheck> = by_name
.into_iter()
.map(|(name, state)| CiCheck { name, state })
.collect();
let state = if checks.iter().any(|check| check.state == CiState::Red) {
CiState::Red
} else if checks.is_empty() || checks.iter().any(|check| check.state == CiState::Pending) {
CiState::Pending
} else {
CiState::Green
};
Self {
head_sha: head_sha.to_string(),
state,
checks,
observation_error: None,
}
}
fn names_with_state(&self, state: CiState) -> Vec<String> {
self.checks
.iter()
.filter(|check| check.state == state)
.map(|check| check.name.clone())
.collect()
}
}
fn ci_severity(state: CiState) -> u8 {
match state {
CiState::Green => 0,
CiState::Pending => 1,
CiState::Red => 2,
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PrDeliveryOutcome {
pub branch: String,
pub commit: String,
pub pushed: bool,
pub pr_number: u64,
pub pr_url: String,
pub pr_action: PrAction,
pub draft: bool,
pub ci: CiSummary,
}
impl PrDeliveryOutcome {
pub fn delivery_report(&self) -> String {
if let Some(error) = &self.ci.observation_error {
return format!("delivered; CI unavailable at {}: {error}", self.commit);
}
match self.ci.state {
CiState::Green if self.draft => format!(
"delivered with green checks at {}; pull request remains draft",
self.ci.head_sha
),
CiState::Green => format!(
"delivered with green checks and ready for review at {}",
self.ci.head_sha
),
CiState::Pending => {
let names = named_checks_or(
&self.ci.names_with_state(CiState::Pending),
"no checks reported yet",
);
format!(
"delivered with pending checks at {}: {names}",
self.ci.head_sha
)
}
CiState::Red => {
let names =
named_checks_or(&self.ci.names_with_state(CiState::Red), "unknown check");
format!("delivered red on {names} at {}", self.ci.head_sha)
}
}
}
}
fn named_checks_or(names: &[String], fallback: &str) -> String {
if names.is_empty() {
fallback.to_string()
} else {
names.join(", ")
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum DeliveryFailure {
Preflight { reason: String },
Commit { reason: String },
Push { reason: String, retriable: bool },
Pr { reason: String, retriable: bool },
}
impl DeliveryFailure {
pub fn stage(&self) -> &'static str {
match self {
DeliveryFailure::Preflight { .. } => "preflight",
DeliveryFailure::Commit { .. } => "commit",
DeliveryFailure::Push { .. } => "push",
DeliveryFailure::Pr { .. } => "pr",
}
}
pub fn retriable(&self) -> bool {
match self {
DeliveryFailure::Preflight { .. } | DeliveryFailure::Commit { .. } => false,
DeliveryFailure::Push { retriable, .. } | DeliveryFailure::Pr { retriable, .. } => {
*retriable
}
}
}
pub fn reason(&self) -> &str {
match self {
DeliveryFailure::Preflight { reason }
| DeliveryFailure::Commit { reason }
| DeliveryFailure::Push { reason, .. }
| DeliveryFailure::Pr { reason, .. } => reason,
}
}
}
impl std::fmt::Display for DeliveryFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} failed: {}", self.stage(), self.reason())
}
}
impl std::error::Error for DeliveryFailure {}
pub trait ForgeClient: Send + Sync {
fn auth_status(&self) -> Result<(), ForgeError>;
fn list_prs_for_head(&self, dir: &Path, head_branch: &str)
-> Result<Vec<PrRecord>, ForgeError>;
fn create_pr(
&self,
dir: &Path,
head_branch: &str,
base_branch: &str,
title: &str,
body: &str,
draft: bool,
) -> Result<PrRecord, ForgeError>;
fn set_pr_body(&self, dir: &Path, number: u64, body: &str) -> Result<(), ForgeError>;
fn reopen_pr(&self, _dir: &Path, _number: u64) -> Result<(), ForgeError> {
Err(ForgeError::local(
"this forge client does not implement pull-request reopening",
))
}
fn ci_for_sha(&self, dir: &Path, number: u64, head_sha: &str) -> Result<CiSummary, ForgeError>;
}
pub use ForgeClient as GitHubApi;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ForgeKind {
GitHub,
AzureDevOps,
}
impl ForgeKind {
fn as_str(self) -> &'static str {
match self {
Self::GitHub => "github",
Self::AzureDevOps => "azure-devops",
}
}
}
pub const FORGE_OVERRIDE_ENV: &str = "CAR_CODER_FORGE";
trait ForgeCommandRunner: Send + Sync {
fn run(&self, dir: &Path, program: &str, args: &[String]) -> Result<String, ForgeError>;
}
struct ProcessForgeCommandRunner;
impl ForgeCommandRunner for ProcessForgeCommandRunner {
fn run(&self, dir: &Path, program: &str, args: &[String]) -> Result<String, ForgeError> {
run_forge_cli(dir, program, args)
}
}
pub struct GhCli {
runner: Option<Arc<dyn ForgeCommandRunner>>,
}
#[allow(non_upper_case_globals)]
pub const GhCli: GhCli = GhCli { runner: None };
impl Default for GhCli {
fn default() -> Self {
GhCli
}
}
impl GhCli {
fn runner(&self) -> &dyn ForgeCommandRunner {
self.runner.as_deref().unwrap_or(&ProcessForgeCommandRunner)
}
}
pub struct AzureDevOpsCli {
runner: Arc<dyn ForgeCommandRunner>,
auth_dir: Option<PathBuf>,
}
impl Default for AzureDevOpsCli {
fn default() -> Self {
Self {
runner: Arc::new(ProcessForgeCommandRunner),
auth_dir: None,
}
}
}
impl AzureDevOpsCli {
fn for_repo(repo: &Path) -> Self {
Self {
runner: Arc::new(ProcessForgeCommandRunner),
auth_dir: Some(repo.to_path_buf()),
}
}
fn auth_dir(&self) -> &Path {
self.auth_dir.as_deref().unwrap_or(Path::new("."))
}
}
#[cfg(test)]
impl GhCli {
fn with_runner(runner: Arc<dyn ForgeCommandRunner>) -> Self {
Self {
runner: Some(runner),
}
}
}
#[cfg(test)]
impl AzureDevOpsCli {
fn with_runner(runner: Arc<dyn ForgeCommandRunner>, repo: &Path) -> Self {
Self {
runner,
auth_dir: Some(repo.to_path_buf()),
}
}
}
fn gh_auth_status_args() -> Vec<String> {
vec!["auth".into(), "status".into()]
}
fn gh_repo_args(dir: &Path) -> Vec<String> {
match git(dir, &["remote", "get-url", "origin"])
.ok()
.and_then(|url| parse_github_repo_spec(url.trim()))
{
Some(spec) => vec!["--repo".into(), spec],
None => Vec::new(),
}
}
fn parse_github_repo_spec(url: &str) -> Option<String> {
let after_scheme = url.split_once("://").map(|(_, rest)| rest);
let (host_part, path) = match after_scheme {
Some(rest) => rest.split_once('/')?,
None if url.starts_with('/') || url.starts_with('.') => return None,
None => url.split_once(':')?,
};
let host = host_part
.rsplit('@')
.next()?
.split(':')
.next()?
.to_ascii_lowercase();
if host.is_empty() {
return None;
}
let path = path
.trim_matches('/')
.strip_suffix(".git")
.unwrap_or(path.trim_matches('/'));
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let [owner, name] = segments[..] else {
return None;
};
if host == "github.com" {
Some(format!("{owner}/{name}"))
} else {
Some(format!("{host}/{owner}/{name}"))
}
}
fn gh_pr_list_args(head_branch: &str) -> Vec<String> {
vec![
"pr".into(),
"list".into(),
"--head".into(),
head_branch.to_string(),
"--state".into(),
"all".into(),
"--json".into(),
"number,state,url,isDraft,isCrossRepository,baseRefName".into(),
"--limit".into(),
"100".into(),
]
}
fn gh_pr_create_args(
head_branch: &str,
base_branch: &str,
title: &str,
body: &str,
draft: bool,
) -> Vec<String> {
let mut args = vec![
"pr".into(),
"create".into(),
"--head".into(),
head_branch.to_string(),
"--base".into(),
base_branch.to_string(),
"--title".into(),
title.to_string(),
"--body".into(),
body.to_string(),
];
if draft {
args.push("--draft".into());
}
args
}
fn gh_pr_reopen_args(number: u64) -> Vec<String> {
vec!["pr".into(), "reopen".into(), number.to_string()]
}
fn gh_pr_checks_args(number: u64) -> Vec<String> {
vec![
"pr".into(),
"view".into(),
number.to_string(),
"--json".into(),
"headRefOid,statusCheckRollup".into(),
]
}
fn az_common_tail(output: &str) -> Vec<String> {
vec![
"--detect".into(),
"true".into(),
"--output".into(),
output.into(),
"--only-show-errors".into(),
]
}
fn az_auth_status_args() -> Vec<String> {
let mut args = vec!["repos".into(), "list".into()];
args.extend(az_common_tail("json"));
args
}
fn az_pr_list_args(head_branch: &str) -> Vec<String> {
let mut args = vec![
"repos".into(),
"pr".into(),
"list".into(),
"--source-branch".into(),
head_branch.into(),
"--status".into(),
"all".into(),
"--top".into(),
"100".into(),
"--include-links".into(),
"true".into(),
];
args.extend(az_common_tail("json"));
args
}
fn az_pr_create_args(
head_branch: &str,
base_branch: &str,
title: &str,
body: &str,
draft: bool,
) -> Vec<String> {
let mut args = vec![
"repos".into(),
"pr".into(),
"create".into(),
"--source-branch".into(),
head_branch.into(),
"--target-branch".into(),
base_branch.into(),
"--title".into(),
title.into(),
"--description".into(),
body.into(),
"--draft".into(),
draft.to_string(),
];
args.extend(az_common_tail("json"));
args
}
fn az_pr_update_args(number: u64, body: &str) -> Vec<String> {
let mut args = vec![
"repos".into(),
"pr".into(),
"update".into(),
"--id".into(),
number.to_string(),
"--description".into(),
body.into(),
];
args.extend(az_common_tail("none"));
args
}
fn az_pr_reopen_args(number: u64) -> Vec<String> {
let mut args = vec![
"repos".into(),
"pr".into(),
"update".into(),
"--id".into(),
number.to_string(),
"--status".into(),
"active".into(),
];
args.extend(az_common_tail("none"));
args
}
fn az_pr_show_args(number: u64) -> Vec<String> {
let mut args = vec![
"repos".into(),
"pr".into(),
"show".into(),
"--id".into(),
number.to_string(),
];
args.extend(az_common_tail("json"));
args
}
fn az_pr_policy_list_args(number: u64) -> Vec<String> {
let mut args = vec![
"repos".into(),
"pr".into(),
"policy".into(),
"list".into(),
"--id".into(),
number.to_string(),
"--top".into(),
"100".into(),
];
args.extend(az_common_tail("json"));
args
}
const FORCE_MARKER: char = '+';
fn push_args(commit: &str, target_branch: &str) -> Vec<String> {
vec![
"push".into(),
"origin".into(),
format!("{commit}:refs/heads/{target_branch}"),
]
}
#[derive(Debug, Clone)]
pub struct ForgeError {
pub message: String,
pub stderr: String,
}
pub type GhError = ForgeError;
impl std::fmt::Display for ForgeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl ForgeError {
fn local(message: impl Into<String>) -> Self {
let message = message.into();
Self {
stderr: message.clone(),
message,
}
}
}
fn run_forge_cli(dir: &Path, program: &str, args: &[String]) -> Result<String, ForgeError> {
let mut cmd = std::process::Command::new(program);
cmd.current_dir(dir).args(args);
no_interactive_prompts(&mut cmd);
if program == "gh" {
cmd.env("GH_PROMPT_DISABLED", "1");
}
let out = run_capped(cmd).map_err(|e| match e {
RunFailure::Spawn(io) if io.kind() == std::io::ErrorKind::NotFound => {
if program == "gh" {
ForgeError::local(
"`gh` not found on PATH — install the GitHub CLI (https://cli.github.com) \
and authenticate it",
)
} else {
ForgeError::local(
"`az` not found on PATH — install Azure CLI and the azure-devops extension, \
then authenticate it",
)
}
}
RunFailure::Spawn(io) => ForgeError::local(format!(
"failed to run `{program} {}`: {io}",
gh_subcommand_shape(args)
)),
RunFailure::TimedOut(secs) => ForgeError::local(format!(
"`{program} {}` timed out after {secs}s and was killed",
gh_subcommand_shape(args)
)),
})?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
Err(ForgeError {
message: format!("{program} {} failed: {stderr}", gh_subcommand_shape(args)),
stderr,
})
}
}
pub(super) fn gh(dir: &Path, args: &[String]) -> Result<String, GhError> {
run_forge_cli(dir, "gh", args)
}
fn gh_subcommand_shape(args: &[String]) -> String {
let mut out: Vec<String> = Vec::with_capacity(args.len());
let mut elide_next = false;
for arg in args {
if std::mem::take(&mut elide_next) {
out.push("<…>".to_string());
continue;
}
if arg.starts_with("--") {
elide_next = matches!(arg.as_str(), "--title" | "--body" | "--description");
}
out.push(arg.clone());
}
out.join(" ")
}
fn parse_pr_list(raw: &str) -> Result<Vec<PrRecord>, String> {
let value: serde_json::Value = serde_json::from_str(raw.trim())
.map_err(|e| format!("could not parse `gh pr list` JSON: {e}"))?;
let items = value
.as_array()
.ok_or_else(|| "`gh pr list` did not return a JSON array".to_string())?;
let mut out = Vec::with_capacity(items.len());
for item in items {
if item
.get("isCrossRepository")
.and_then(|c| c.as_bool())
.unwrap_or(false)
{
continue;
}
let number = item
.get("number")
.and_then(|n| n.as_u64())
.ok_or_else(|| "pull request entry has no numeric `number`".to_string())?;
let raw_state = item
.get("state")
.and_then(|s| s.as_str())
.ok_or_else(|| "pull request entry has no `state`".to_string())?;
let state = match raw_state.to_ascii_uppercase().as_str() {
"OPEN" => PrState::Open,
"CLOSED" => PrState::ClosedUnmerged,
"MERGED" => PrState::Merged,
other => return Err(format!("unrecognized pull request state `{other}`")),
};
out.push(PrRecord {
number,
state,
url: item
.get("url")
.and_then(|u| u.as_str())
.unwrap_or_default()
.to_string(),
is_draft: item
.get("isDraft")
.and_then(|d| d.as_bool())
.unwrap_or(false),
base: item
.get("baseRefName")
.and_then(|b| b.as_str())
.ok_or_else(|| "pull request entry has no `baseRefName`".to_string())?
.to_string(),
});
}
Ok(out)
}
fn parse_github_ci_summary(raw: &str, expected_head_sha: &str) -> Result<CiSummary, String> {
let value: serde_json::Value = serde_json::from_str(raw.trim())
.map_err(|e| format!("could not parse `gh pr view` CI JSON: {e}"))?;
let actual_head = value
.get("headRefOid")
.and_then(|v| v.as_str())
.ok_or_else(|| "`gh pr view` CI response has no `headRefOid`".to_string())?;
if actual_head != expected_head_sha {
return Err(format!(
"pull request head moved while delivery was reading CI: expected {expected_head_sha}, found {actual_head}"
));
}
let rollup = match value.get("statusCheckRollup") {
None | Some(serde_json::Value::Null) => &[][..],
Some(serde_json::Value::Array(items)) => items.as_slice(),
Some(_) => {
return Err("`gh pr view` CI response has a non-array `statusCheckRollup`".to_string())
}
};
let mut checks = Vec::with_capacity(rollup.len());
for item in rollup {
let kind = item
.get("__typename")
.and_then(|v| v.as_str())
.ok_or_else(|| "CI rollup entry has no `__typename`".to_string())?;
let (name, state) = match kind {
"CheckRun" => {
let name = required_string(item, "name", "CI rollup entry")?;
let status = item
.get("status")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("check run `{name}` has no `status`"))?;
let state = if status.eq_ignore_ascii_case("COMPLETED") {
let conclusion =
item.get("conclusion")
.and_then(|v| v.as_str())
.ok_or_else(|| {
format!("completed check run `{name}` has no `conclusion`")
})?;
match conclusion.to_ascii_uppercase().as_str() {
"SUCCESS" | "NEUTRAL" | "SKIPPED" => CiState::Green,
"ACTION_REQUIRED" | "CANCELLED" | "FAILURE" | "STALE"
| "STARTUP_FAILURE" | "TIMED_OUT" => CiState::Red,
other => {
return Err(format!(
"check run `{name}` has unrecognized conclusion `{other}`"
))
}
}
} else {
CiState::Pending
};
(name, state)
}
"StatusContext" => {
let name = required_string(item, "context", "CI rollup entry")?;
let raw_state = item
.get("state")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("status context `{name}` has no `state`"))?;
let state = match raw_state.to_ascii_uppercase().as_str() {
"SUCCESS" => CiState::Green,
"EXPECTED" | "PENDING" => CiState::Pending,
"ERROR" | "FAILURE" => CiState::Red,
other => {
return Err(format!(
"status context `{name}` has unrecognized state `{other}`"
))
}
};
(name, state)
}
other => return Err(format!("unrecognized CI rollup entry type `{other}`")),
};
checks.push((name, state));
}
Ok(CiSummary::from_checks(expected_head_sha, checks))
}
fn required_string(
value: &serde_json::Value,
field: &str,
subject: &str,
) -> Result<String, String> {
value
.get(field)
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.map(str::to_string)
.ok_or_else(|| format!("{subject} has no non-empty `{field}`"))
}
fn strip_heads_prefix(name: &str) -> String {
name.strip_prefix("refs/heads/").unwrap_or(name).to_string()
}
fn azure_pr_url(value: &serde_json::Value, number: u64) -> String {
value
.pointer("/_links/web/href")
.and_then(|v| v.as_str())
.or_else(|| value.get("remoteUrl").and_then(|v| v.as_str()))
.map(str::to_string)
.or_else(|| {
value
.pointer("/repository/webUrl")
.and_then(|v| v.as_str())
.map(|base| format!("{}/pullrequest/{number}", base.trim_end_matches('/')))
})
.or_else(|| {
value
.get("url")
.and_then(|v| v.as_str())
.map(str::to_string)
})
.unwrap_or_default()
}
fn parse_azure_pr(value: &serde_json::Value) -> Result<PrRecord, String> {
let number = value
.get("pullRequestId")
.and_then(|v| v.as_u64())
.ok_or_else(|| "Azure DevOps pull request has no numeric `pullRequestId`".to_string())?;
let raw_state = value
.get("status")
.and_then(|v| v.as_str())
.ok_or_else(|| "Azure DevOps pull request has no `status`".to_string())?;
let state = match raw_state.to_ascii_lowercase().as_str() {
"active" => PrState::Open,
"abandoned" => PrState::ClosedUnmerged,
"completed" => PrState::Merged,
other => {
return Err(format!(
"unrecognized Azure DevOps pull request status `{other}`"
))
}
};
let target = required_string(value, "targetRefName", "Azure DevOps pull request")?;
Ok(PrRecord {
number,
state,
url: azure_pr_url(value, number),
is_draft: value
.get("isDraft")
.and_then(|v| v.as_bool())
.unwrap_or(false),
base: strip_heads_prefix(&target),
})
}
fn parse_azure_pr_list(raw: &str) -> Result<Vec<PrRecord>, String> {
let value: serde_json::Value = serde_json::from_str(raw.trim())
.map_err(|e| format!("could not parse `az repos pr list` JSON: {e}"))?;
let items = value
.as_array()
.ok_or_else(|| "`az repos pr list` did not return a JSON array".to_string())?;
items.iter().map(parse_azure_pr).collect()
}
fn azure_pr_head(pr_raw: &str) -> Result<String, String> {
let pr: serde_json::Value = serde_json::from_str(pr_raw.trim())
.map_err(|e| format!("could not parse `az repos pr show` JSON: {e}"))?;
pr.pointer("/lastMergeSourceCommit/commitId")
.and_then(|v| v.as_str())
.map(str::to_string)
.ok_or_else(|| {
"`az repos pr show` response has no `lastMergeSourceCommit.commitId`".to_string()
})
}
fn parse_azure_ci_summary(
pr_before_raw: &str,
policies_raw: &str,
pr_after_raw: &str,
expected_head_sha: &str,
) -> Result<CiSummary, String> {
let before_head = azure_pr_head(pr_before_raw)?;
let after_head = azure_pr_head(pr_after_raw)?;
if before_head != expected_head_sha || after_head != expected_head_sha {
let actual = if before_head != expected_head_sha {
before_head
} else {
after_head
};
return Err(format!(
"pull request head moved while delivery was reading CI: expected {expected_head_sha}, found {actual}"
));
}
let policies: serde_json::Value = serde_json::from_str(policies_raw.trim())
.map_err(|e| format!("could not parse `az repos pr policy list` JSON: {e}"))?;
let items = policies
.as_array()
.ok_or_else(|| "`az repos pr policy list` did not return a JSON array".to_string())?;
let mut checks = Vec::with_capacity(items.len());
for item in items {
let name = item
.pointer("/configuration/type/displayName")
.and_then(|v| v.as_str())
.or_else(|| item.pointer("/type/displayName").and_then(|v| v.as_str()))
.or_else(|| item.pointer("/context/name").and_then(|v| v.as_str()))
.filter(|s| !s.trim().is_empty())
.map(str::to_string)
.or_else(|| {
item.get("evaluationId")
.and_then(|v| v.as_str())
.map(|id| format!("policy {id}"))
})
.ok_or_else(|| "Azure DevOps policy has no name or evaluation id".to_string())?;
let raw_state = item
.get("status")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("Azure DevOps policy `{name}` has no `status`"))?;
let state = match raw_state.to_ascii_lowercase().as_str() {
"approved" | "notapplicable" => CiState::Green,
"queued" | "running" => CiState::Pending,
"rejected" | "broken" => CiState::Red,
other => {
return Err(format!(
"Azure DevOps policy `{name}` has unrecognized status `{other}`"
))
}
};
checks.push((name, state));
}
Ok(CiSummary::from_checks(expected_head_sha, checks))
}
fn pr_number_from_url(url: &str) -> Result<u64, String> {
url.trim()
.rsplit('/')
.find(|seg| !seg.is_empty())
.and_then(|seg| seg.parse::<u64>().ok())
.ok_or_else(|| format!("could not read a pull request number out of `{url}`"))
}
impl ForgeClient for GhCli {
fn auth_status(&self) -> Result<(), ForgeError> {
self.runner()
.run(Path::new("."), "gh", &gh_auth_status_args())
.map(|_| ())
.map_err(|e| ForgeError {
message: format!(
"no usable GitHub credential: `gh auth status` failed. Authenticate with \
`gh auth login`, or set GH_TOKEN (or GITHUB_TOKEN) in this process's \
environment. Underlying error: {e}"
),
stderr: e.stderr,
})
}
fn list_prs_for_head(
&self,
dir: &Path,
head_branch: &str,
) -> Result<Vec<PrRecord>, ForgeError> {
let mut args = gh_repo_args(dir);
args.extend(gh_pr_list_args(head_branch));
parse_pr_list(&self.runner().run(dir, "gh", &args)?).map_err(ForgeError::local)
}
fn create_pr(
&self,
dir: &Path,
head_branch: &str,
base_branch: &str,
title: &str,
body: &str,
draft: bool,
) -> Result<PrRecord, ForgeError> {
let mut args = gh_repo_args(dir);
args.extend(gh_pr_create_args(
head_branch,
base_branch,
title,
body,
draft,
));
let url = self.runner().run(dir, "gh", &args)?;
Ok(PrRecord {
number: pr_number_from_url(&url).map_err(ForgeError::local)?,
state: PrState::Open,
url: url.trim().to_string(),
is_draft: draft,
base: base_branch.to_string(),
})
}
fn set_pr_body(&self, dir: &Path, number: u64, body: &str) -> Result<(), ForgeError> {
let mut args = gh_repo_args(dir);
args.extend([
"pr".to_string(),
"edit".to_string(),
number.to_string(),
"--body".to_string(),
body.to_string(),
]);
self.runner().run(dir, "gh", &args).map(|_| ())
}
fn reopen_pr(&self, dir: &Path, number: u64) -> Result<(), ForgeError> {
let mut args = gh_repo_args(dir);
args.extend(gh_pr_reopen_args(number));
self.runner().run(dir, "gh", &args).map(|_| ())
}
fn ci_for_sha(&self, dir: &Path, number: u64, head_sha: &str) -> Result<CiSummary, ForgeError> {
let mut args = gh_repo_args(dir);
args.extend(gh_pr_checks_args(number));
let raw = self.runner().run(dir, "gh", &args)?;
parse_github_ci_summary(&raw, head_sha).map_err(ForgeError::local)
}
}
impl ForgeClient for AzureDevOpsCli {
fn auth_status(&self) -> Result<(), ForgeError> {
self.runner
.run(self.auth_dir(), "az", &az_auth_status_args())
.map(|_| ())
.map_err(|e| ForgeError {
message: format!(
"no usable Azure DevOps credential: `az repos list` failed. Install the \
azure-devops extension and authenticate with `az login` or \
AZURE_DEVOPS_EXT_PAT. Underlying error: {e}"
),
stderr: e.stderr,
})
}
fn list_prs_for_head(
&self,
dir: &Path,
head_branch: &str,
) -> Result<Vec<PrRecord>, ForgeError> {
let raw = self.runner.run(dir, "az", &az_pr_list_args(head_branch))?;
parse_azure_pr_list(&raw).map_err(ForgeError::local)
}
fn create_pr(
&self,
dir: &Path,
head_branch: &str,
base_branch: &str,
title: &str,
body: &str,
draft: bool,
) -> Result<PrRecord, ForgeError> {
let raw = self.runner.run(
dir,
"az",
&az_pr_create_args(head_branch, base_branch, title, body, draft),
)?;
let value: serde_json::Value = serde_json::from_str(raw.trim()).map_err(|e| {
ForgeError::local(format!("could not parse `az repos pr create` JSON: {e}"))
})?;
parse_azure_pr(&value).map_err(ForgeError::local)
}
fn set_pr_body(&self, dir: &Path, number: u64, body: &str) -> Result<(), ForgeError> {
self.runner
.run(dir, "az", &az_pr_update_args(number, body))
.map(|_| ())
}
fn reopen_pr(&self, dir: &Path, number: u64) -> Result<(), ForgeError> {
self.runner
.run(dir, "az", &az_pr_reopen_args(number))
.map(|_| ())
}
fn ci_for_sha(&self, dir: &Path, number: u64, head_sha: &str) -> Result<CiSummary, ForgeError> {
let before = self.runner.run(dir, "az", &az_pr_show_args(number))?;
let policies = self
.runner
.run(dir, "az", &az_pr_policy_list_args(number))?;
let after = self.runner.run(dir, "az", &az_pr_show_args(number))?;
parse_azure_ci_summary(&before, &policies, &after, head_sha).map_err(ForgeError::local)
}
}
fn remote_host(remote_url: &str) -> Option<String> {
let raw = remote_url.trim();
let authority = if let Some((_, rest)) = raw.split_once("://") {
rest.split('/').next()?
} else {
if raw.starts_with('/') || raw.starts_with('.') {
return None;
}
let (left, _) = raw.split_once(':')?;
if left.len() == 1 {
return None;
}
left
};
authority
.rsplit('@')
.next()?
.split(':')
.next()
.filter(|host| !host.is_empty())
.map(str::to_ascii_lowercase)
}
fn forge_kind_from_remote(
remote_url: &str,
override_value: Option<&str>,
) -> Result<ForgeKind, String> {
if let Some(value) = override_value {
return match value.trim().to_ascii_lowercase().as_str() {
"github" | "gh" => Ok(ForgeKind::GitHub),
"azure-devops" | "azure_devops" | "azdo" => Ok(ForgeKind::AzureDevOps),
other => Err(format!(
"unsupported {FORGE_OVERRIDE_ENV} value `{other}`; use `github` or `azure-devops`"
)),
};
}
let host = remote_host(remote_url).ok_or_else(|| {
format!(
"cannot identify a pull-request forge from origin `{remote_url}`; set \
{FORGE_OVERRIDE_ENV}=github or {FORGE_OVERRIDE_ENV}=azure-devops"
)
})?;
if matches!(host.as_str(), "github.com" | "ssh.github.com") {
Ok(ForgeKind::GitHub)
} else if matches!(host.as_str(), "dev.azure.com" | "ssh.dev.azure.com")
|| host.ends_with(".visualstudio.com")
{
Ok(ForgeKind::AzureDevOps)
} else {
Err(format!(
"cannot identify a pull-request forge for origin host `{host}`; set \
{FORGE_OVERRIDE_ENV}=github or {FORGE_OVERRIDE_ENV}=azure-devops"
))
}
}
fn selected_forge(repo: &Path) -> Result<Box<dyn ForgeClient>, String> {
let remote_url = git(repo, &["remote", "get-url", "origin"])
.map_err(|e| format!("cannot read origin remote for forge selection: {e}"))?;
let override_value = match std::env::var(FORGE_OVERRIDE_ENV) {
Ok(value) => Some(value),
Err(std::env::VarError::NotPresent) => None,
Err(std::env::VarError::NotUnicode(_)) => {
return Err(format!(
"{FORGE_OVERRIDE_ENV} is not valid UTF-8; use `github` or `azure-devops`"
))
}
};
let kind = forge_kind_from_remote(&remote_url, override_value.as_deref())?;
tracing::debug!(forge = kind.as_str(), remote = %remote_url.trim(), "selected PR forge");
Ok(match kind {
ForgeKind::GitHub => Box::new(GhCli::default()),
ForgeKind::AzureDevOps => Box::new(AzureDevOpsCli::for_repo(repo)),
})
}
pub fn validate_branch_name(label: &str, name: &str) -> Result<(), String> {
if name.is_empty() {
return Err(format!("{label} is empty"));
}
if name.starts_with('-') {
return Err(format!(
"{label} `{name}` starts with '-', which git and gh would read as a flag"
));
}
if name.starts_with(FORCE_MARKER) {
return Err(format!(
"{label} `{name}` starts with `{FORCE_MARKER}`, git's force marker in a refspec"
));
}
if let Some(bad) = name
.chars()
.find(|c| c.is_whitespace() || c.is_control() || "~^:?*[]\\".contains(*c))
{
return Err(format!(
"{label} `{name}` contains `{bad}`, which is not legal in a git ref name"
));
}
if name.contains("..")
|| name.ends_with('/')
|| name.starts_with('/')
|| name.ends_with(".lock")
|| name.split('/').any(|c| c.ends_with(".lock"))
|| name.ends_with('.')
|| name.contains("//")
|| name.contains("@{")
|| name.split('/').any(|c| c.is_empty() || c.starts_with('.'))
{
return Err(format!("{label} `{name}` is not a legal git ref name"));
}
Ok(())
}
fn classify_push_error(err: &str) -> (String, bool) {
let low = err.to_ascii_lowercase();
let refused = low.contains("permission denied")
|| (low.contains("permission to") && low.contains("denied"))
|| low.contains("returned error: 403")
|| low.contains("status code 403")
|| low.contains("http 403")
|| low.contains("error 403")
|| low.contains("authentication failed")
|| low.contains("could not read username")
|| low.contains("could not read password")
|| low.contains("terminal prompts disabled")
|| low.contains("host key verification failed")
|| low.contains("returned error: 401")
|| low.contains("status code 401")
|| low.contains("http 401")
|| low.contains("error 401")
|| low.contains("repository not found")
|| low.contains("does not appear to be a git repository")
|| low.contains("pre-receive hook declined")
|| low.contains("protected branch")
|| low.contains("refusing to allow")
|| low.contains("workflow' scope")
|| low.contains("shallow update not allowed")
|| low.contains("file size limit")
|| low.contains("exists; cannot create")
|| low.contains("push declined")
|| mentions_github_policy_code(&low);
if refused {
return (
format!("push refused for credential/permission reasons: {err}"),
false,
);
}
let moved = low.contains("non-fast-forward")
|| low.contains("fetch first")
|| low.contains("[rejected]")
|| low.contains("remote rejected")
|| low.contains("cannot lock ref")
|| low.contains("failed to update ref");
if moved {
return (
format!(
"non-fast-forward: the target branch moved since this worktree was cut \
(lost a push race) — {err}"
),
true,
);
}
(err.to_string(), true)
}
fn mentions_github_policy_code(low: &str) -> bool {
let bytes = low.as_bytes();
bytes.windows(6).enumerate().any(|(i, w)| {
w[0] == b'g'
&& w[1] == b'h'
&& w[2..5].iter().all(u8::is_ascii_digit)
&& w[5] == b':'
&& (i == 0 || !bytes[i - 1].is_ascii_alphanumeric())
})
}
fn classify_pr_error(err: &str) -> (String, bool) {
let low = err.to_ascii_lowercase();
let permanent = low.contains("no commits between")
|| low.contains("draft pull requests are not supported")
|| low.contains("must be a collaborator")
|| low.contains("no such remote")
|| low.contains("could not resolve to a repository");
(err.to_string(), !permanent)
}
fn pr_failure(e: GhError) -> DeliveryFailure {
let (_, retriable) = classify_pr_error(&e.stderr);
DeliveryFailure::Pr {
reason: e.message,
retriable,
}
}
pub fn ambiguous_head_refusal(
prs: &[PrRecord],
target_branch: &str,
base_branch: &str,
) -> Option<String> {
let foreign_open: Vec<&PrRecord> = prs
.iter()
.filter(|p| p.state == PrState::Open && p.base != base_branch)
.collect();
if foreign_open.is_empty() {
return None;
}
let described = foreign_open
.iter()
.map(|p| format!("#{} into `{}`", p.number, p.base))
.collect::<Vec<_>>()
.join(", ");
let numbers = foreign_open
.iter()
.map(|p| format!("#{}", p.number))
.collect::<Vec<_>>()
.join(", ");
let plural = if foreign_open.len() == 1 { "" } else { "s" };
Some(format!(
"branch `{target_branch}` already has open pull request{plural} {described} — not into \
`{base_branch}`, this run's base. Pushing this round's commits to `{target_branch}` \
would add them to {numbers} as well, because a pull request tracks its head branch. \
Close {numbers}, or deliver to a different --target-branch"
))
}
pub fn closed_pr_refusal(
prs: &[PrRecord],
target_branch: &str,
base_branch: &str,
) -> Option<String> {
if prs
.iter()
.any(|p| p.state == PrState::Open && p.base == base_branch)
{
return None;
}
let closed: Vec<&PrRecord> = prs
.iter()
.filter(|p| p.state == PrState::ClosedUnmerged && p.base == base_branch)
.collect();
if closed.is_empty() {
return None;
}
let numbers = closed
.iter()
.map(|p| format!("#{}", p.number))
.collect::<Vec<_>>()
.join(", ");
let plural = if closed.len() == 1 { "" } else { "s" };
let was = if closed.len() == 1 { "was" } else { "were" };
Some(format!(
"pull request{plural} {numbers} from `{target_branch}` into `{base_branch}` {was} \
closed — the runtime does not reopen a pull request it did not close. Reopen {numbers} \
yourself to continue on this branch, or deliver to a different --target-branch"
))
}
pub fn delivery_head_refusal(
prs: &[PrRecord],
target_branch: &str,
base_branch: &str,
) -> Option<String> {
ambiguous_head_refusal(prs, target_branch, base_branch)
.or_else(|| closed_pr_refusal(prs, target_branch, base_branch))
}
pub fn deliver_pr(d: PrDelivery<'_>) -> Result<PrDeliveryOutcome, DeliveryFailure> {
let forge = selected_forge(d.repo).map_err(|reason| DeliveryFailure::Preflight { reason })?;
deliver_pr_with(d, forge.as_ref())
}
pub fn deliver_pr_with(
d: PrDelivery<'_>,
forge: &dyn ForgeClient,
) -> Result<PrDeliveryOutcome, DeliveryFailure> {
validate_branch_name("target branch", d.target_branch)
.map_err(|reason| DeliveryFailure::Preflight { reason })?;
validate_branch_name("base branch", d.base_branch)
.map_err(|reason| DeliveryFailure::Preflight { reason })?;
if d.target_branch == d.base_branch {
return Err(DeliveryFailure::Preflight {
reason: format!(
"target branch and base branch are both `{}`; delivering would push \
unreviewed work directly onto the base instead of opening a pull request",
d.target_branch
),
});
}
forge
.auth_status()
.map_err(|e| DeliveryFailure::Preflight {
reason: e.to_string(),
})?;
let listed = forge
.list_prs_for_head(d.repo, d.target_branch)
.map_err(pr_failure)?;
if let Some(reason) = delivery_head_refusal(&listed, d.target_branch, d.base_branch) {
return Err(DeliveryFailure::Preflight { reason });
}
let commit = match commit_worktree(d.worktree, d.intent, d.contract, d.provenance) {
Ok(CommitOutcome::Made(c)) => c,
Ok(CommitOutcome::NothingToCommit) => head_beyond_base(d.worktree, d.base_branch)
.map_err(|reason| DeliveryFailure::Commit { reason })?,
Err(reason) => return Err(DeliveryFailure::Commit { reason }),
};
let args = push_args(&commit, d.target_branch);
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
if let Err(e) = git(d.worktree, &arg_refs) {
let (reason, retriable) = classify_push_error(&e);
return Err(DeliveryFailure::Push { reason, retriable });
}
let existing: Vec<&PrRecord> = listed.iter().filter(|p| p.base == d.base_branch).collect();
let open = existing
.iter()
.filter(|p| p.state == PrState::Open)
.max_by_key(|p| p.number);
let (record, action) = if let Some(pr) = open {
forge
.set_pr_body(d.repo, pr.number, d.body)
.map_err(pr_failure)?;
((*pr).clone(), PrAction::Updated)
} else {
let created = forge
.create_pr(
d.repo,
d.target_branch,
d.base_branch,
&subject_from_intent(d.intent),
d.body,
d.draft,
)
.map_err(pr_failure)?;
(created, PrAction::Opened)
};
let ci = forge
.ci_for_sha(d.repo, record.number, &commit)
.unwrap_or_else(|error| {
let mut ci = CiSummary::from_checks(&commit, Vec::new());
ci.observation_error = Some(error.message);
ci
});
Ok(PrDeliveryOutcome {
branch: d.target_branch.to_string(),
commit,
pushed: true,
pr_number: record.number,
pr_url: record.url,
pr_action: action,
draft: record.is_draft,
ci,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::coder::contract::ContractCheck;
fn contract() -> OutcomeContract {
OutcomeContract {
allow_credentials: false,
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,
baseline: false,
differential: None,
}],
}
}
fn placement(subtask: &str, worker: Option<&str>, remote: bool) -> car_multi::Placement {
car_multi::Placement {
subtask_id: subtask.to_string(),
worker_id: worker.map(str::to_string),
remote,
attempts: Vec::new(),
}
}
fn landed(subtask: &str, files: &[&str]) -> IntegratedSubtask {
IntegratedSubtask {
subtask_id: subtask.to_string(),
files: files.iter().map(|f| f.to_string()).collect(),
}
}
#[test]
fn a_local_run_renders_no_trailers() {
assert_eq!(placement_provenance(&[], &[], false), None);
assert_eq!(
placement_provenance(
&[placement("s1", Some("this-host"), false)],
&[landed("s1", &["a.rs"])],
false
),
None
);
}
#[test]
fn workers_that_ran_but_whose_patches_never_landed_are_not_credited() {
let ran_everywhere = [
placement("s1", Some("studio"), true),
placement("s2", Some("laptop"), true),
];
assert_eq!(
placement_provenance(&ran_everywhere, &[], false),
None,
"nothing was integrated, so nothing may be claimed"
);
let rendered =
placement_provenance(&ran_everywhere, &[landed("s1", &["a.rs"])], false).unwrap();
assert!(rendered.contains("worker=studio"), "{rendered}");
assert!(
!rendered.contains("laptop"),
"a rejected patch's worker must not appear: {rendered}"
);
}
#[test]
fn a_locally_repaired_union_says_so_rather_than_crediting_the_fleet_alone() {
let rendered = placement_provenance(
&[placement("s1", Some("studio"), true)],
&[landed("s1", &["a.rs"])],
true,
)
.unwrap();
assert!(rendered.contains("repaired-locally=true"), "{rendered}");
}
#[test]
fn trailers_name_the_machine_and_the_files_it_wrote() {
let rendered = placement_provenance(
&[
placement("s1", Some("studio"), true),
placement("s2", Some("this-host"), false),
],
&[landed("s1", &["src/a.rs", "src/b.rs"]), landed("s2", &[])],
false,
)
.expect("a distributed run renders");
assert!(
rendered.contains("CAR-Placement: subtask=s1 worker=studio remote=true"),
"{rendered}"
);
assert!(rendered.contains("files=src/a.rs,src/b.rs"), "{rendered}");
assert!(
rendered.contains("CAR-Placement: subtask=s2 worker=this-host remote=false"),
"{rendered}"
);
assert!(rendered.lines().all(|l| l.starts_with("CAR-Placement:")));
}
#[test]
fn nothing_from_a_peer_can_forge_a_trailer_or_break_the_commit() {
let hostile = "studio\n\nSigned-off-by: Someone <x@y.z>";
let rendered = placement_provenance(
&[placement("s1", Some(hostile), true)],
&[landed("s1", &["a.rs"])],
false,
)
.unwrap();
assert!(!rendered.contains('\n') || rendered.lines().count() == 1);
assert!(
!rendered.contains("Signed-off-by:\n") && rendered.lines().count() == 1,
"a peer must not be able to add a paragraph: {rendered}"
);
let rendered = placement_provenance(
&[placement("s\u{0}1", Some("a\u{0}b"), true)],
&[landed("s\u{0}1", &["x.rs"])],
false,
)
.unwrap();
assert!(!rendered.contains('\u{0}'), "{rendered}");
let long = "w".repeat(100_000);
let rendered = placement_provenance(
&[placement("s1", Some(&long), true)],
&[landed("s1", &["x.rs"])],
false,
)
.unwrap();
assert!(rendered.len() < 400, "len {}", rendered.len());
}
#[test]
fn peer_failure_prose_never_reaches_the_commit() {
let mut p = placement("s1", Some("studio"), true);
p.attempts = vec![car_multi::FailedAttempt {
worker_id: "laptop".into(),
error: "SECRET-STDERR-abcdef".into(),
}];
let rendered = placement_provenance(&[p], &[landed("s1", &["a.rs"])], false).unwrap();
assert!(!rendered.contains("SECRET-STDERR"), "{rendered}");
}
#[test]
fn a_distributed_deliverys_commit_body_says_where_each_subtask_ran() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_repo(repo);
let ws = tempfile::tempdir().unwrap();
git(
repo,
&["worktree", "add", "--detach", &ws.path().to_string_lossy()],
)
.unwrap();
std::fs::write(ws.path().join("x.txt"), "made across the fleet").unwrap();
let provenance = placement_provenance(
&[placement("s1", Some("studio"), true)],
&[landed("s1", &["x.txt"])],
false,
)
.unwrap();
let branch = publish_branch(
repo,
ws.path(),
"fleet0001",
"spread this out",
&contract(),
Some(&provenance),
)
.unwrap();
let message = git(repo, &["log", "-1", "--format=%B", &branch]).unwrap();
assert!(message.contains("Authored by CAR Coder."), "{message}");
assert!(
message.contains("CAR-Placement: subtask=s1 worker=studio remote=true"),
"{message}"
);
assert!(message.contains("Outcome contract"), "{message}");
let trailers = git(
repo,
&[
"log",
"-1",
"--format=%(trailers:key=CAR-Placement,valueonly)",
&branch,
],
)
.unwrap();
assert!(trailers.contains("subtask=s1 worker=studio"), "{trailers}");
}
#[test]
fn a_local_deliverys_commit_body_is_unchanged() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_repo(repo);
let ws = tempfile::tempdir().unwrap();
git(
repo,
&["worktree", "add", "--detach", &ws.path().to_string_lossy()],
)
.unwrap();
std::fs::write(ws.path().join("x.txt"), "made here").unwrap();
let branch =
publish_branch(repo, ws.path(), "local001", "do it", &contract(), None).unwrap();
let message = git(repo, &["log", "-1", "--format=%B", &branch]).unwrap();
assert!(message.contains("Authored by CAR Coder."), "{message}");
assert!(
!message.contains("CAR-Placement"),
"a local run claims nothing about a fleet: {message}"
);
}
fn init_repo(dir: &Path) {
for args in [
vec!["init", "-q", "-b", "main"],
vec!["config", "core.autocrlf", "false"],
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 review_identity_detects_changes_without_rewriting_the_index() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
std::fs::write(repo.path().join("result.txt"), "verified\n").unwrap();
git(repo.path(), &["add", "result.txt"]).unwrap();
let identity = ReviewIdentity::read(repo.path()).unwrap();
identity.validate(repo.path()).unwrap();
std::fs::write(repo.path().join("result.txt"), "changed\n").unwrap();
assert!(identity
.validate(repo.path())
.unwrap_err()
.contains("changed"));
assert_eq!(
git(repo.path(), &["write-tree"]).unwrap().trim(),
identity.tree
);
git(repo.path(), &["add", "result.txt"]).unwrap();
assert!(identity
.validate(repo.path())
.unwrap_err()
.contains("staged"));
std::fs::write(repo.path().join("result.txt"), "verified\n").unwrap();
git(repo.path(), &["add", "result.txt"]).unwrap();
std::fs::write(repo.path().join("unreviewed.txt"), "extra\n").unwrap();
assert!(identity
.validate(repo.path())
.unwrap_err()
.contains("changed"));
std::fs::remove_file(repo.path().join("unreviewed.txt")).unwrap();
identity.validate(repo.path()).unwrap();
git(
repo.path(),
&[
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-qm",
"moved HEAD",
],
)
.unwrap();
assert!(identity
.validate(repo.path())
.unwrap_err()
.contains("revision"));
}
#[test]
fn checkout_snapshot_keeps_user_inputs_out_of_the_agent_diff() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let identity = CheckoutIdentity::read(repo.path()).unwrap();
assert!(snapshot_checkout(repo.path(), "clean").unwrap().is_none());
std::fs::write(repo.path().join("personal.txt"), "staged").unwrap();
git(repo.path(), &["add", "personal.txt"]).unwrap();
std::fs::write(repo.path().join("personal.txt"), "current input").unwrap();
std::fs::write(repo.path().join("AGENTS.md"), "preserve public names").unwrap();
std::fs::write(repo.path().join(".gitignore"), "cache.txt\n").unwrap();
std::fs::write(repo.path().join("cache.txt"), "ignored").unwrap();
let index = git(repo.path(), &["diff", "--cached", "--binary"]).unwrap();
let base = snapshot_checkout(repo.path(), "snapshot-test")
.unwrap()
.unwrap();
let dir = tempfile::tempdir().unwrap();
let ws = car_multi::AgentWorkspace::provision(
&car_multi::WorkspaceConfig::git_worktree_at(repo.path(), dir.path()).with_rev(base),
"task",
)
.unwrap();
assert_eq!(
std::fs::read_to_string(ws.path().join("personal.txt")).unwrap(),
"current input"
);
assert!(ws.path().join("AGENTS.md").exists());
assert!(!ws.path().join("cache.txt").exists());
assert!(git(ws.path(), &["status", "--porcelain"])
.unwrap()
.is_empty());
std::fs::write(
ws.path().join("personal.txt"),
"current input plus agent edit",
)
.unwrap();
let diff = git(ws.path(), &["diff", "HEAD"]).unwrap();
assert!(diff.contains("-current input"));
assert!(!diff.contains("AGENTS.md"));
apply_to_checkout(
repo.path(),
ws.path(),
"snapshot-test",
&identity,
"extend input",
&contract(),
None,
)
.unwrap();
assert_eq!(CheckoutIdentity::read(repo.path()).unwrap(), identity);
assert_eq!(
git(repo.path(), &["diff", "--cached", "--binary"]).unwrap(),
index
);
assert_eq!(
std::fs::read_to_string(repo.path().join("personal.txt")).unwrap(),
"current input plus agent edit"
);
assert!(git(repo.path(), &["branch", "--list", "car/coder/*"])
.unwrap()
.is_empty());
}
#[test]
fn checkout_delivery_preserves_dirty_files_index_and_branch_and_can_retry() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let identity = CheckoutIdentity::read(repo.path()).unwrap();
let base = tempfile::tempdir().unwrap();
let ws = car_multi::AgentWorkspace::provision(
&car_multi::WorkspaceConfig::git_worktree_at(repo.path(), base.path()),
"checkout-test",
)
.unwrap();
std::fs::write(repo.path().join("personal.txt"), "staged user edit").unwrap();
git(repo.path(), &["add", "personal.txt"]).unwrap();
std::fs::write(repo.path().join("personal.txt"), "unstaged user edit").unwrap();
std::fs::write(repo.path().join("untracked.txt"), "user work").unwrap();
let index = git(repo.path(), &["diff", "--cached", "--binary"]).unwrap();
let branches = git(repo.path(), &["for-each-ref", "refs/heads"]).unwrap();
std::fs::write(ws.path().join("result.txt"), "reviewed change").unwrap();
std::fs::write(ws.path().join("legacy.txt"), b"legacy \xff\n").unwrap();
let (commit, already) = apply_to_checkout(
repo.path(),
ws.path(),
"coder-checkout-test",
&identity,
"add result",
&contract(),
None,
)
.unwrap();
assert!(!already);
assert_eq!(
std::fs::read(repo.path().join("legacy.txt")).unwrap(),
b"legacy \xff\n"
);
assert_eq!(CheckoutIdentity::read(repo.path()).unwrap(), identity);
assert_eq!(
git(repo.path(), &["diff", "--cached", "--binary"]).unwrap(),
index
);
assert_eq!(
git(repo.path(), &["for-each-ref", "refs/heads"]).unwrap(),
branches
);
assert_eq!(
std::fs::read_to_string(repo.path().join("personal.txt")).unwrap(),
"unstaged user edit"
);
assert_eq!(
std::fs::read_to_string(repo.path().join("untracked.txt")).unwrap(),
"user work"
);
assert_eq!(
std::fs::read_to_string(repo.path().join("result.txt")).unwrap(),
"reviewed change"
);
assert_eq!(
git(repo.path(), &["show", &format!("{commit}:result.txt")]).unwrap(),
"reviewed change"
);
assert!(
apply_to_checkout(
repo.path(),
ws.path(),
"coder-checkout-test",
&identity,
"add result",
&contract(),
None,
)
.unwrap()
.1
);
}
#[test]
fn checkout_delivery_conflict_does_not_apply_a_partial_patch() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let identity = CheckoutIdentity::read(repo.path()).unwrap();
let base = tempfile::tempdir().unwrap();
let ws = car_multi::AgentWorkspace::provision(
&car_multi::WorkspaceConfig::git_worktree_at(repo.path(), base.path()),
"conflict-test",
)
.unwrap();
std::fs::write(ws.path().join("a-new.txt"), "must not partially apply").unwrap();
std::fs::write(ws.path().join("z-conflict.txt"), "agent version").unwrap();
std::fs::write(repo.path().join("z-conflict.txt"), "user version").unwrap();
assert!(apply_to_checkout(
repo.path(),
ws.path(),
"coder-conflict",
&identity,
"add files",
&contract(),
None,
)
.is_err());
assert!(!repo.path().join("a-new.txt").exists());
assert_eq!(
std::fs::read_to_string(repo.path().join("z-conflict.txt")).unwrap(),
"user version"
);
assert!(ws.path().join("a-new.txt").exists());
let changed = CheckoutIdentity {
head: "different".into(),
reference: identity.reference,
};
assert!(apply_to_checkout(
repo.path(),
ws.path(),
"coder-conflict",
&changed,
"add files",
&contract(),
None,
)
.unwrap_err()
.contains("checkout revision or branch changed"));
}
#[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(),
None,
)
.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(), None)
.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(), None).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(), None).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(), None).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"
);
}
use std::path::PathBuf;
use std::sync::Mutex;
const MERGE_RS_SOURCE: &str = include_str!("merge.rs");
#[derive(Debug, Clone, PartialEq, Eq)]
struct ForgeCall {
program: String,
args: Vec<String>,
}
struct FakeForgeCommands {
responses: Mutex<std::collections::VecDeque<Result<String, ForgeError>>>,
calls: Mutex<Vec<ForgeCall>>,
}
impl FakeForgeCommands {
fn answers(responses: &[&str]) -> Arc<Self> {
Arc::new(Self {
responses: Mutex::new(responses.iter().map(|s| Ok((*s).to_string())).collect()),
calls: Mutex::new(Vec::new()),
})
}
fn calls(&self) -> Vec<ForgeCall> {
self.calls.lock().unwrap().clone()
}
}
impl ForgeCommandRunner for FakeForgeCommands {
fn run(&self, _dir: &Path, program: &str, args: &[String]) -> Result<String, ForgeError> {
self.calls.lock().unwrap().push(ForgeCall {
program: program.to_string(),
args: args.to_vec(),
});
self.responses
.lock()
.unwrap()
.pop_front()
.expect("a scripted forge response")
}
}
fn repo_with_remote(remote: &str) -> tempfile::TempDir {
let repo = tempfile::tempdir().unwrap();
git(repo.path(), &["init", "-q"]).unwrap();
git(repo.path(), &["remote", "add", "origin", remote]).unwrap();
repo
}
#[test]
fn remote_url_selects_github_or_azure_and_unknown_names_the_override() {
for remote in [
"https://github.com/acme/widgets.git",
"git@github.com:acme/widgets.git",
] {
assert_eq!(
forge_kind_from_remote(remote, None).unwrap(),
ForgeKind::GitHub
);
}
for remote in [
"https://dev.azure.com/acme/platform/_git/widgets",
"git@ssh.dev.azure.com:v3/acme/platform/widgets",
"https://acme.visualstudio.com/platform/_git/widgets",
] {
assert_eq!(
forge_kind_from_remote(remote, None).unwrap(),
ForgeKind::AzureDevOps
);
}
let error =
forge_kind_from_remote("ssh://git@git.example.test/acme/widgets", None).unwrap_err();
assert!(error.contains(FORGE_OVERRIDE_ENV), "{error}");
assert_eq!(
forge_kind_from_remote(
"ssh://git@git.example.test/acme/widgets",
Some("azure-devops")
)
.unwrap(),
ForgeKind::AzureDevOps
);
}
#[test]
fn github_client_uses_the_existing_cli_contract_through_a_fake_runner() {
let repo = repo_with_remote("https://github.com/acme/widgets.git");
let runner = FakeForgeCommands::answers(&[
"",
r#"[{"number":7,"state":"OPEN","url":"https://github.com/acme/widgets/pull/7","isDraft":false,"isCrossRepository":false,"baseRefName":"main"}]"#,
"https://github.com/acme/widgets/pull/8",
"",
"",
r#"{"headRefOid":"abc123","statusCheckRollup":[{"__typename":"CheckRun","name":"test","status":"COMPLETED","conclusion":"SUCCESS"},{"__typename":"StatusContext","context":"legacy","state":"PENDING"}]}"#,
]);
let github = GhCli::with_runner(runner.clone());
github.auth_status().unwrap();
let listed = github.list_prs_for_head(repo.path(), "car/work").unwrap();
assert_eq!(listed[0].number, 7);
let created = github
.create_pr(repo.path(), "car/work", "main", "title", "body", true)
.unwrap();
assert_eq!(created.number, 8);
github.set_pr_body(repo.path(), 7, "new body").unwrap();
github.reopen_pr(repo.path(), 7).unwrap();
let ci = github.ci_for_sha(repo.path(), 7, "abc123").unwrap();
assert_eq!(ci.state, CiState::Pending);
assert_eq!(ci.checks.len(), 2);
let calls = runner.calls();
assert_eq!(calls.len(), 6);
assert!(calls.iter().all(|call| call.program == "gh"));
assert_eq!(calls[0].args, gh_auth_status_args());
let mut expected_list = gh_repo_args(repo.path());
expected_list.extend(gh_pr_list_args("car/work"));
assert_eq!(calls[1].args, expected_list);
let mut expected_create = gh_repo_args(repo.path());
expected_create.extend(gh_pr_create_args("car/work", "main", "title", "body", true));
assert_eq!(calls[2].args, expected_create);
let mut expected_edit = gh_repo_args(repo.path());
expected_edit.extend([
"pr".to_string(),
"edit".to_string(),
"7".to_string(),
"--body".to_string(),
"new body".to_string(),
]);
assert_eq!(calls[3].args, expected_edit);
let mut expected_reopen = gh_repo_args(repo.path());
expected_reopen.extend(gh_pr_reopen_args(7));
assert_eq!(calls[4].args, expected_reopen);
let mut expected_checks = gh_repo_args(repo.path());
expected_checks.extend(gh_pr_checks_args(7));
assert_eq!(calls[5].args, expected_checks);
}
#[test]
fn azure_client_creates_lists_updates_and_reads_checks_through_a_fake_runner() {
let repo = repo_with_remote("https://dev.azure.com/acme/platform/_git/widgets");
let listed = r#"[{"pullRequestId":41,"status":"active","isDraft":false,"targetRefName":"refs/heads/main","_links":{"web":{"href":"https://dev.azure.com/acme/platform/_git/widgets/pullrequest/41"}}}]"#;
let created = r#"{"pullRequestId":42,"status":"active","isDraft":true,"targetRefName":"refs/heads/main","repository":{"webUrl":"https://dev.azure.com/acme/platform/_git/widgets"}}"#;
let shown = r#"{"lastMergeSourceCommit":{"commitId":"def456"}}"#;
let policies = r#"[
{"status":"approved","configuration":{"type":{"displayName":"Build"}}},
{"status":"running","configuration":{"type":{"displayName":"Security"}}},
{"status":"rejected","configuration":{"type":{"displayName":"Windows"}}}
]"#;
let runner =
FakeForgeCommands::answers(&["[]", listed, created, "", "", shown, policies, shown]);
let azure = AzureDevOpsCli::with_runner(runner.clone(), repo.path());
azure.auth_status().unwrap();
let prs = azure.list_prs_for_head(repo.path(), "car/work").unwrap();
assert_eq!(prs[0].number, 41);
assert_eq!(prs[0].base, "main");
let pr = azure
.create_pr(repo.path(), "car/work", "main", "title", "body", true)
.unwrap();
assert_eq!(pr.number, 42);
assert!(pr.is_draft);
assert_eq!(
pr.url,
"https://dev.azure.com/acme/platform/_git/widgets/pullrequest/42"
);
azure.set_pr_body(repo.path(), 41, "new body").unwrap();
azure.reopen_pr(repo.path(), 41).unwrap();
let ci = azure.ci_for_sha(repo.path(), 41, "def456").unwrap();
assert_eq!(ci.state, CiState::Red);
assert_eq!(
ci.checks,
vec![
CiCheck {
name: "Build".into(),
state: CiState::Green,
},
CiCheck {
name: "Security".into(),
state: CiState::Pending,
},
CiCheck {
name: "Windows".into(),
state: CiState::Red,
},
]
);
let calls = runner.calls();
assert_eq!(calls.len(), 8);
assert!(calls.iter().all(|call| call.program == "az"));
assert_eq!(calls[0].args, az_auth_status_args());
assert_eq!(calls[1].args, az_pr_list_args("car/work"));
assert_eq!(
calls[2].args,
az_pr_create_args("car/work", "main", "title", "body", true)
);
assert_eq!(calls[3].args, az_pr_update_args(41, "new body"));
assert_eq!(calls[4].args, az_pr_reopen_args(41));
assert_eq!(calls[5].args, az_pr_show_args(41));
assert_eq!(calls[6].args, az_pr_policy_list_args(41));
assert_eq!(calls[7].args, az_pr_show_args(41));
}
#[test]
fn azure_check_read_refuses_a_moved_head() {
let error = parse_azure_ci_summary(
r#"{"lastMergeSourceCommit":{"commitId":"delivered"}}"#,
"[]",
r#"{"lastMergeSourceCommit":{"commitId":"newer"}}"#,
"delivered",
)
.unwrap_err();
assert!(error.contains("expected delivered, found newer"), "{error}");
}
struct FakeGh {
auth: Result<(), GhError>,
prs: Mutex<Vec<PrRecord>>,
calls: Mutex<Vec<String>>,
next_number: Mutex<u64>,
fail_list: Mutex<Option<String>>,
fail_create: Mutex<Option<String>>,
fail_set_body: Mutex<Option<String>>,
fail_ci: Mutex<Option<String>>,
checks: Mutex<Vec<(String, CiState)>>,
}
fn force_char_offenders(src: &str) -> Vec<usize> {
let production = src.split_once("mod tests {").map(|(h, _)| h).unwrap_or(src);
let needle: String = ['\'', '+', '\''].iter().collect();
production
.lines()
.enumerate()
.filter(|(_, line)| line.contains(needle.as_str()))
.filter(|(_, line)| !line.contains("FORCE_MARKER: char"))
.map(|(i, _)| i + 1)
.collect()
}
fn gh_err(message: &str, stderr: &str) -> GhError {
GhError {
message: message.to_string(),
stderr: stderr.to_string(),
}
}
impl FakeGh {
fn ok() -> Self {
Self {
auth: Ok(()),
prs: Mutex::new(Vec::new()),
calls: Mutex::new(Vec::new()),
next_number: Mutex::new(101),
fail_list: Mutex::new(None),
fail_create: Mutex::new(None),
fail_set_body: Mutex::new(None),
fail_ci: Mutex::new(None),
checks: Mutex::new(vec![
("lint".to_string(), CiState::Green),
("test".to_string(), CiState::Green),
]),
}
}
fn no_credential() -> Self {
Self {
auth: Err(gh_err(
"no usable GitHub credential: `gh auth status` failed. Authenticate with \
`gh auth login`, or set GH_TOKEN (or GITHUB_TOKEN)",
"gh: To get started with GitHub CLI, please run: gh auth login",
)),
..Self::ok()
}
}
fn with_prs(prs: Vec<PrRecord>) -> Self {
Self {
prs: Mutex::new(prs),
..Self::ok()
}
}
fn with_checks(checks: Vec<(&str, CiState)>) -> Self {
let me = Self::ok();
*me.checks.lock().unwrap() = checks
.into_iter()
.map(|(name, state)| (name.to_string(), state))
.collect();
me
}
fn failing_set_body(stderr: &str) -> Self {
let me = Self::ok();
*me.fail_set_body.lock().unwrap() = Some(stderr.to_string());
me
}
fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
}
fn fake_gh_failure(command: &str, stderr: &str, body: &str) -> GhError {
gh_err(
&format!("gh {command} --body {body} failed: {stderr}"),
stderr,
)
}
impl GitHubApi for FakeGh {
fn auth_status(&self) -> Result<(), GhError> {
self.calls.lock().unwrap().push("auth_status".into());
self.auth.clone().map_err(|e| e.clone())
}
fn list_prs_for_head(&self, _dir: &Path, head: &str) -> Result<Vec<PrRecord>, GhError> {
self.calls.lock().unwrap().push(format!("list {head}"));
if let Some(stderr) = self.fail_list.lock().unwrap().clone() {
return Err(gh_err(&format!("gh pr list failed: {stderr}"), &stderr));
}
Ok(self.prs.lock().unwrap().clone())
}
fn create_pr(
&self,
_dir: &Path,
head: &str,
base: &str,
title: &str,
body: &str,
draft: bool,
) -> Result<PrRecord, GhError> {
self.calls.lock().unwrap().push(format!(
"create head={head} base={base} draft={draft} title={title} body={body}"
));
if let Some(stderr) = self.fail_create.lock().unwrap().clone() {
return Err(fake_gh_failure("pr create", &stderr, body));
}
let mut n = self.next_number.lock().unwrap();
let record = PrRecord {
number: *n,
state: PrState::Open,
url: format!("https://github.com/acme/repo/pull/{n}"),
is_draft: draft,
base: base.to_string(),
};
*n += 1;
self.prs.lock().unwrap().push(record.clone());
Ok(record)
}
fn set_pr_body(&self, _dir: &Path, number: u64, body: &str) -> Result<(), GhError> {
self.calls
.lock()
.unwrap()
.push(format!("set_body {number} {body}"));
if let Some(stderr) = self.fail_set_body.lock().unwrap().clone() {
return Err(fake_gh_failure("pr edit", &stderr, body));
}
Ok(())
}
fn reopen_pr(&self, _dir: &Path, number: u64) -> Result<(), GhError> {
self.calls.lock().unwrap().push(format!("reopen {number}"));
Ok(())
}
fn ci_for_sha(
&self,
_dir: &Path,
number: u64,
head_sha: &str,
) -> Result<CiSummary, GhError> {
self.calls
.lock()
.unwrap()
.push(format!("ci {number} {head_sha}"));
if let Some(stderr) = self.fail_ci.lock().unwrap().clone() {
return Err(gh_err(&format!("gh pr view failed: {stderr}"), &stderr));
}
Ok(CiSummary::from_checks(
head_sha,
self.checks.lock().unwrap().clone(),
))
}
}
struct Fixture {
origin: PathBuf,
repo: PathBuf,
wt_base: PathBuf,
_dirs: Vec<tempfile::TempDir>,
}
fn fixture() -> Fixture {
let origin_dir = tempfile::tempdir().unwrap();
let repo_dir = tempfile::tempdir().unwrap();
let wt_dir = tempfile::tempdir().unwrap();
let origin = origin_dir.path().to_path_buf();
let repo = repo_dir.path().to_path_buf();
git(&origin, &["init", "-q", "--bare", "-b", "main"]).unwrap();
git(&repo, &["init", "-q", "-b", "main"]).unwrap();
git(&repo, &["config", "user.name", "t"]).unwrap();
git(&repo, &["config", "user.email", "t@t"]).unwrap();
std::fs::write(repo.join("README.md"), "seed").unwrap();
git(&repo, &["add", "-A"]).unwrap();
git(&repo, &["commit", "-qm", "seed"]).unwrap();
git(
&repo,
&["remote", "add", "origin", origin.to_str().unwrap()],
)
.unwrap();
git(&repo, &["push", "-q", "origin", "main"]).unwrap();
Fixture {
origin,
repo,
wt_base: wt_dir.path().to_path_buf(),
_dirs: vec![origin_dir, repo_dir, wt_dir],
}
}
impl Fixture {
fn cut(&self, name: &str, from_ref: &str) -> PathBuf {
let path = self.wt_base.join(name);
git(
&self.repo,
&[
"worktree",
"add",
"--detach",
"-q",
path.to_str().unwrap(),
from_ref,
],
)
.unwrap();
path
}
fn origin_head(&self, branch: &str) -> Option<String> {
git(
&self.origin,
&["rev-parse", "--verify", &format!("refs/heads/{branch}")],
)
.ok()
.map(|s| s.trim().to_string())
}
}
fn delivery<'a>(
f: &'a Fixture,
worktree: &'a Path,
contract: &'a OutcomeContract,
target: &'a str,
draft: bool,
body: &'a str,
) -> PrDelivery<'a> {
PrDelivery {
repo: &f.repo,
worktree,
target_branch: target,
base_branch: "main",
draft,
intent: "make x exist",
contract,
body,
provenance: None,
}
}
const TARGET: &str = "goalpool/g_abc123";
#[test]
fn github_rollup_combines_check_runs_and_status_contexts_for_the_exact_head() {
let raw = r#"{
"headRefOid":"abc123",
"statusCheckRollup":[
{"__typename":"CheckRun","name":"lint","status":"COMPLETED","conclusion":"SUCCESS"},
{"__typename":"CheckRun","name":"tests","status":"IN_PROGRESS","conclusion":""},
{"__typename":"StatusContext","context":"deploy","state":"FAILURE"},
{"__typename":"StatusContext","context":"lint","state":"PENDING"}
]
}"#;
let summary = parse_github_ci_summary(raw, "abc123").unwrap();
assert_eq!(summary.head_sha, "abc123");
assert_eq!(summary.state, CiState::Red);
assert_eq!(
summary.checks,
vec![
CiCheck {
name: "deploy".into(),
state: CiState::Red,
},
CiCheck {
name: "lint".into(),
state: CiState::Pending,
},
CiCheck {
name: "tests".into(),
state: CiState::Pending,
},
]
);
}
#[test]
fn github_rollup_with_no_checks_is_pending_not_green() {
let summary = parse_github_ci_summary(
r#"{"headRefOid":"abc123","statusCheckRollup":null}"#,
"abc123",
)
.unwrap();
assert_eq!(summary.state, CiState::Pending);
assert!(summary.checks.is_empty());
}
#[test]
fn github_rollup_refuses_ci_from_a_different_head() {
let err = parse_github_ci_summary(
r#"{"headRefOid":"newer","statusCheckRollup":[]}"#,
"delivered",
)
.unwrap_err();
assert!(err.contains("expected delivered, found newer"), "{err}");
}
#[test]
fn ci_lookup_failure_preserves_successful_publication() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "made by coder").unwrap();
let gh = FakeGh::ok();
*gh.fail_ci.lock().unwrap() = Some("HTTP 503".into());
let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "body"), &gh).unwrap();
assert!(out.pushed);
assert_eq!(out.pr_number, 101);
assert_eq!(out.pr_action, PrAction::Opened);
assert!(!out.pr_url.is_empty());
assert_eq!(out.ci.head_sha, out.commit);
assert_eq!(out.ci.state, CiState::Pending);
assert!(out.ci.checks.is_empty());
assert!(out.delivery_report().contains("CI unavailable"));
assert!(out.delivery_report().contains("HTTP 503"));
assert_eq!(gh.prs.lock().unwrap().len(), 1);
}
#[test]
fn a_green_delivery_pushes_the_commit_and_opens_one_pr() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "made by coder").unwrap();
let gh = FakeGh::ok();
let out =
deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "round 1 body"), &gh).unwrap();
assert!(out.pushed);
assert_eq!(out.branch, TARGET);
assert_eq!(out.pr_action, PrAction::Opened);
assert_eq!(out.pr_number, 101);
assert!(out.draft, "a draft was requested at create time");
assert_eq!(out.ci.state, CiState::Green);
assert_eq!(out.ci.head_sha, out.commit);
assert_eq!(
out.ci.checks,
[
CiCheck {
name: "lint".into(),
state: CiState::Green,
},
CiCheck {
name: "test".into(),
state: CiState::Green,
},
]
);
assert_eq!(
out.delivery_report(),
format!(
"delivered with green checks at {}; pull request remains draft",
out.commit
)
);
assert_eq!(f.origin_head(TARGET).as_deref(), Some(out.commit.as_str()));
assert_eq!(
git(&f.origin, &["show", &format!("refs/heads/{TARGET}:x.txt")]).unwrap(),
"made by coder"
);
assert_eq!(
git(
&f.origin,
&[
"log",
"-1",
"--format=%an <%ae>",
&format!("refs/heads/{TARGET}")
]
)
.unwrap()
.trim(),
"car-coder <coder@parslee.ai>"
);
assert_eq!(gh.calls()[0], "auth_status");
assert_eq!(gh.calls().last(), Some(&format!("ci 101 {}", out.commit)));
}
#[test]
fn a_red_delivery_names_failed_checks_at_the_delivered_head() {
let f = fixture();
let c = contract();
let wt = f.cut("red", "main");
std::fs::write(wt.join("x.txt"), "made by coder").unwrap();
let gh = FakeGh::with_checks(vec![
("lint", CiState::Green),
("windows", CiState::Red),
("test", CiState::Pending),
]);
let out =
deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "red delivery"), &gh).unwrap();
assert_eq!(out.ci.state, CiState::Red);
assert!(out.ci.checks.contains(&CiCheck {
name: "windows".into(),
state: CiState::Red
}));
assert!(out.ci.checks.contains(&CiCheck {
name: "test".into(),
state: CiState::Pending
}));
assert_eq!(
out.delivery_report(),
format!("delivered red on windows at {}", out.commit)
);
}
#[test]
fn a_second_delivery_appends_to_the_same_branch_and_the_same_pr() {
let f = fixture();
let c = contract();
let wt1 = f.cut("s1", "main");
std::fs::write(wt1.join("x.txt"), "round one").unwrap();
let gh1 = FakeGh::ok();
let first =
deliver_pr_with(delivery(&f, &wt1, &c, TARGET, true, "round 1 body"), &gh1).unwrap();
git(&f.repo, &["fetch", "-q", "origin"]).unwrap();
let wt2 = f.cut("s2", &format!("origin/{TARGET}"));
std::fs::write(wt2.join("y.txt"), "round two").unwrap();
let gh2 = FakeGh::with_prs(vec![PrRecord {
number: 101,
state: PrState::Open,
url: "https://github.com/acme/repo/pull/101".into(),
is_draft: true,
base: "main".into(),
}]);
let second =
deliver_pr_with(delivery(&f, &wt2, &c, TARGET, true, "round 2 body"), &gh2).unwrap();
assert_eq!(second.pr_action, PrAction::Updated);
assert_eq!(second.pr_number, 101);
assert_ne!(second.commit, first.commit);
assert!(
!gh2.calls().iter().any(|c| c.starts_with("create")),
"a second PR must never be created for the same branch: {:?}",
gh2.calls()
);
assert!(gh2.calls().iter().any(|c| c == "set_body 101 round 2 body"));
assert_eq!(
git(
&f.origin,
&["rev-list", "--count", &format!("refs/heads/{TARGET}")]
)
.unwrap()
.trim(),
"3"
);
assert_eq!(
git(
&f.origin,
&[
"rev-list",
"--count",
"--merges",
&format!("refs/heads/{TARGET}")
]
)
.unwrap()
.trim(),
"0"
);
assert!(git(
&f.origin,
&["merge-base", "--is-ancestor", &first.commit, &second.commit]
)
.is_ok());
let mut branches: Vec<String> = git(
&f.origin,
&["for-each-ref", "--format=%(refname:short)", "refs/heads/"],
)
.unwrap()
.lines()
.map(|l| l.to_string())
.collect();
branches.sort();
assert_eq!(branches, vec![TARGET.to_string(), "main".to_string()]);
}
#[test]
fn a_non_fast_forward_is_retriable_and_leaves_the_remote_alone() {
let f = fixture();
let c = contract();
let wt1 = f.cut("s1", "main");
std::fs::write(wt1.join("x.txt"), "round one").unwrap();
let first =
deliver_pr_with(delivery(&f, &wt1, &c, TARGET, true, "b1"), &FakeGh::ok()).unwrap();
let wt2 = f.cut("stale", "main");
std::fs::write(wt2.join("z.txt"), "stale round").unwrap();
let gh = FakeGh::with_prs(vec![PrRecord {
number: 101,
state: PrState::Open,
url: "https://github.com/acme/repo/pull/101".into(),
is_draft: true,
base: "main".into(),
}]);
let err = deliver_pr_with(delivery(&f, &wt2, &c, TARGET, true, "b2"), &gh).unwrap_err();
assert_eq!(err.stage(), "push");
assert!(err.retriable(), "{err}");
assert!(
matches!(
err,
DeliveryFailure::Push {
retriable: true,
..
}
),
"{err:?}"
);
assert!(
err.reason().contains("non-fast-forward"),
"the reason must name the condition: {}",
err.reason()
);
assert_eq!(
f.origin_head(TARGET).as_deref(),
Some(first.commit.as_str())
);
assert!(
!gh.calls().iter().any(|c| c.starts_with("create")),
"{:?}",
gh.calls()
);
}
#[test]
fn a_missing_credential_fails_preflight_and_touches_nothing() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "never delivered").unwrap();
let gh = FakeGh::no_credential();
let err = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "body"), &gh).unwrap_err();
assert_eq!(err.stage(), "preflight");
assert!(!err.retriable(), "a missing credential is not retriable");
assert!(
err.reason().contains("GH_TOKEN") || err.reason().contains("gh auth"),
"the failure must name the missing credential: {}",
err.reason()
);
assert!(
f.origin_head(TARGET).is_none(),
"the remote gained a branch"
);
assert!(
!git(&wt, &["status", "--porcelain"])
.unwrap()
.trim()
.is_empty(),
"the worktree was committed despite the preflight failure"
);
assert_eq!(gh.calls(), vec!["auth_status".to_string()]);
}
#[test]
fn a_closed_unmerged_pull_request_parks_the_round() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "again").unwrap();
let gh = FakeGh::with_prs(vec![PrRecord {
number: 55,
state: PrState::ClosedUnmerged,
url: "https://github.com/acme/repo/pull/55".into(),
is_draft: false,
base: "main".into(),
}]);
let err = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "revived"), &gh).unwrap_err();
assert!(
matches!(err, DeliveryFailure::Preflight { .. }),
"a closed pull request is refused before the commit: {err:?}"
);
assert!(
!err.retriable(),
"retrying changes nothing — a human reopens #55 or picks another target branch"
);
assert!(
err.reason().contains("#55") && err.reason().contains("Reopen"),
"{}",
err.reason()
);
assert_eq!(
gh.calls(),
vec!["auth_status".to_string(), format!("list {TARGET}")]
);
assert_eq!(f.origin_head(TARGET), None, "nothing may be pushed");
assert!(
!git(&wt, &["status", "--porcelain"])
.unwrap()
.trim()
.is_empty(),
"the worktree was committed despite the preflight failure"
);
}
#[test]
fn a_reviewers_edited_body_survives_the_next_round() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "round two").unwrap();
let gh = FakeGh::with_prs(vec![PrRecord {
number: 40,
state: PrState::ClosedUnmerged,
url: "https://github.com/acme/repo/pull/40".into(),
is_draft: false,
base: "main".into(),
}]);
let err = deliver_pr_with(
delivery(&f, &wt, &c, TARGET, false, "round two's generated body"),
&gh,
)
.unwrap_err();
assert_eq!(err.stage(), "preflight");
assert!(
!gh.calls().iter().any(|c| c.starts_with("set_body")),
"the reviewer's description was rewritten: {:?}",
gh.calls()
);
assert!(
!gh.calls()
.iter()
.any(|c| c.contains("round two's generated body")),
"this round's body reached GitHub: {:?}",
gh.calls()
);
}
#[test]
fn a_superseding_open_pull_request_beats_the_closed_one() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "carried forward").unwrap();
let gh = FakeGh::with_prs(vec![
PrRecord {
number: 40,
state: PrState::ClosedUnmerged,
url: "https://github.com/acme/repo/pull/40".into(),
is_draft: false,
base: "main".into(),
},
PrRecord {
number: 55,
state: PrState::Open,
url: "https://github.com/acme/repo/pull/55".into(),
is_draft: false,
base: "main".into(),
},
]);
let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap();
assert_eq!(out.pr_action, PrAction::Updated);
assert_eq!(out.pr_number, 55, "the live pull request receives the push");
assert!(out.pushed);
assert!(
gh.calls().iter().any(|c| c.starts_with("set_body 55")),
"{:?}",
gh.calls()
);
assert!(
!gh.calls().iter().any(|c| c.starts_with("create")),
"{:?}",
gh.calls()
);
assert!(
!gh.calls().iter().any(|c| c.contains(" 40 ")),
"{:?}",
gh.calls()
);
}
#[test]
fn the_close_veto_is_suppressed_only_by_an_open_pr_into_the_same_base() {
let closed = PrRecord {
number: 40,
state: PrState::ClosedUnmerged,
url: "u40".into(),
is_draft: false,
base: "main".into(),
};
let open_same = PrRecord {
number: 55,
state: PrState::Open,
url: "u55".into(),
is_draft: false,
base: "main".into(),
};
let open_other = PrRecord {
base: "release/2.1".into(),
..open_same.clone()
};
assert!(
closed_pr_refusal(std::slice::from_ref(&closed), TARGET, "main").is_some(),
"a lone closed pull request still parks the round"
);
assert!(
closed_pr_refusal(&[closed.clone(), open_same], TARGET, "main").is_none(),
"the open pull request into `main` supersedes the close"
);
assert!(
closed_pr_refusal(&[closed, open_other], TARGET, "main").is_some(),
"an open pull request into ANOTHER base says nothing about this base"
);
}
#[test]
fn a_merged_pr_does_not_block_a_new_one() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "next chapter").unwrap();
let gh = FakeGh::with_prs(vec![PrRecord {
number: 9,
state: PrState::Merged,
url: "https://github.com/acme/repo/pull/9".into(),
is_draft: false,
base: "main".into(),
}]);
let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "fresh"), &gh).unwrap();
assert_eq!(out.pr_action, PrAction::Opened);
assert!(gh.calls().iter().any(|c| c.starts_with("create")));
}
#[test]
fn an_updated_pr_never_has_its_draft_state_flipped() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "more work").unwrap();
let gh = FakeGh::with_prs(vec![PrRecord {
number: 77,
state: PrState::Open,
url: "https://github.com/acme/repo/pull/77".into(),
is_draft: false,
base: "main".into(),
}]);
let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "body"), &gh).unwrap();
assert_eq!(out.pr_action, PrAction::Updated);
assert!(
!out.draft,
"delivery must report the PR's real state, not re-draft a ready PR"
);
}
#[test]
fn a_clean_worktree_redelivers_head_after_an_earlier_push_failure() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "work").unwrap();
git(
&f.repo,
&["remote", "set-url", "origin", "/nonexistent/nope.git"],
)
.unwrap();
let err =
deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "b"), &FakeGh::ok()).unwrap_err();
assert_eq!(err.stage(), "push");
assert!(
!err.retriable(),
"a missing remote cannot be fixed by trying again: {err}"
);
let committed = git(&wt, &["rev-parse", "HEAD"]).unwrap().trim().to_string();
git(
&f.repo,
&["remote", "set-url", "origin", f.origin.to_str().unwrap()],
)
.unwrap();
let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "b"), &FakeGh::ok()).unwrap();
assert_eq!(out.commit, committed);
assert_eq!(f.origin_head(TARGET).as_deref(), Some(committed.as_str()));
}
#[test]
fn a_branch_name_that_looks_like_a_flag_is_refused_at_preflight() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "x").unwrap();
for bad in [
"--upload-pack=touch /tmp/pwn",
"goalpool/../../etc",
"has space",
] {
let err =
deliver_pr_with(delivery(&f, &wt, &c, bad, true, "b"), &FakeGh::ok()).unwrap_err();
assert_eq!(err.stage(), "preflight", "for `{bad}`: {err}");
}
let plus = format!("{}goalpool/x", '+');
let err =
deliver_pr_with(delivery(&f, &wt, &c, &plus, true, "b"), &FakeGh::ok()).unwrap_err();
assert_eq!(err.stage(), "preflight", "{err}");
}
#[test]
fn no_force_push_token_appears_anywhere_in_this_file() {
let dashes = "-".repeat(2);
let plain = format!("{dashes}{}", "force");
let lease = format!("{plain}-with-lease");
let plus_refspec: String = ['"', '+'].iter().collect();
let short_flag: String = ['"', '-', 'f', '"'].iter().collect();
for needle in [
plain.as_str(),
lease.as_str(),
plus_refspec.as_str(),
short_flag.as_str(),
] {
assert!(
!MERGE_RS_SOURCE.contains(needle),
"`{needle}` must appear nowhere on the delivery path"
);
}
let production = MERGE_RS_SOURCE
.split_once("mod tests {")
.map(|(head, _)| head)
.unwrap_or(MERGE_RS_SOURCE);
for tok in [
['"', 'r', 'e', 'b', 'a', 's', 'e', '"']
.iter()
.collect::<String>(),
['"', '-', '-', 'r', 'e', 'b', 'a', 's', 'e', '"']
.iter()
.collect::<String>(),
] {
assert!(
!production.contains(tok.as_str()),
"delivery must never rebase: `{tok}`"
);
}
assert_eq!(
force_char_offenders(MERGE_RS_SOURCE),
Vec::<usize>::new(),
"a char-literal plus on the delivery path is a force refspec"
);
}
#[test]
fn the_force_char_scanner_catches_the_spellings_that_slipped_past_it() {
let wrapped = "fn f() {\n let plus = '+';\n let refspec = format!(\n \
\"{plus}{commit}:refs/heads/{branch}\"\n );\n}\n";
assert!(
!force_char_offenders(wrapped).is_empty(),
"a plus bound to a name and interpolated is still a force refspec"
);
let inline = "fn f() { let r = format!(\"{}{commit}:refs/heads/{b}\", '+'); }\n";
assert!(!force_char_offenders(inline).is_empty());
let only_in_tests = "fn f() {}\nmod tests {\n let plus = '+';\n}\n";
assert!(force_char_offenders(only_in_tests).is_empty());
}
#[test]
fn delivery_never_marks_a_pull_request_ready_for_review() {
let ready_arg = String::from('"') + "read" + "y" + "\"";
assert!(
!MERGE_RS_SOURCE.contains(&ready_arg),
"the runtime must not flip a pull request out of draft"
);
}
#[test]
fn no_shell_invocation_appears_on_the_delivery_path() {
for needle in ["Command::new(\"sh\")", "Command::new(\"bash\")"] {
assert!(
!MERGE_RS_SOURCE.contains(needle),
"`{needle}` would reintroduce shell interpolation"
);
}
}
#[test]
fn delivery_has_no_pull_request_merge_invocation() {
let production = MERGE_RS_SOURCE
.split_once("mod tests {")
.map(|(head, _)| head)
.unwrap_or(MERGE_RS_SOURCE);
let lines: Vec<&str> = production.lines().collect();
for (index, line) in lines.iter().enumerate() {
if line.contains("\"pr\"") {
let end = (index + 4).min(lines.len());
let window = lines[index..end].join("\n");
assert!(
!window.contains("\"merge\""),
"pull-request merge invocation at production line {}",
index + 1
);
}
}
}
#[test]
fn pr_check_read_requests_the_rollup_and_head_sha() {
assert_eq!(
gh_pr_checks_args(41),
["pr", "view", "41", "--json", "headRefOid,statusCheckRollup"]
);
}
#[test]
fn draft_adds_the_draft_flag_and_nothing_else_does() {
let with = gh_pr_create_args("h", "main", "t", "b", true);
assert!(with.contains(&"--draft".to_string()), "{with:?}");
let without = gh_pr_create_args("h", "main", "t", "b", false);
assert!(!without.contains(&"--draft".to_string()), "{without:?}");
assert!(with.windows(2).any(|w| w[0] == "--body" && w[1] == "b"));
assert!(with.windows(2).any(|w| w[0] == "--title" && w[1] == "t"));
}
#[test]
fn the_push_refspec_is_append_only_and_fully_qualified() {
let args = push_args("abc123", "goalpool/g_1");
assert_eq!(
args,
vec![
"push".to_string(),
"origin".to_string(),
"abc123:refs/heads/goalpool/g_1".to_string(),
]
);
let plus = '+';
assert!(
!args.iter().any(|a| a.starts_with(plus)),
"a leading plus is git's force marker: {args:?}"
);
let mut child = std::process::Command::new("git")
.args(["check-ref-format", "refs/heads/goalpool/g_1"])
.spawn()
.expect("spawn git check-ref-format fixture");
let status = child.wait().expect("reap git fixture child");
assert!(status.success(), "git rejected the destination ref");
}
#[test]
fn pr_list_asks_for_every_state_of_one_head_branch() {
let args = gh_pr_list_args("goalpool/g_1");
assert!(args
.windows(2)
.any(|w| w[0] == "--head" && w[1] == "goalpool/g_1"));
assert!(args.windows(2).any(|w| w[0] == "--state" && w[1] == "all"));
let limit: u32 = args
.windows(2)
.find(|w| w[0] == "--limit")
.map(|w| w[1].parse().expect("--limit is a number"))
.expect("an explicit --limit, or gh silently pages at 30");
assert!(
limit >= 100,
"the head listing must not be truncated below 100: {args:?}"
);
}
#[test]
fn pr_list_json_maps_merged_apart_from_closed() {
let prs = parse_pr_list(
r#"[{"number":1,"state":"OPEN","url":"u1","isDraft":true,"baseRefName":"main"},
{"number":2,"state":"CLOSED","url":"u2","isDraft":false,"baseRefName":"main"},
{"number":3,"state":"MERGED","url":"u3","isDraft":false,"baseRefName":"main"}]"#,
)
.unwrap();
assert_eq!(prs[0].state, PrState::Open);
assert!(prs[0].is_draft);
assert_eq!(prs[1].state, PrState::ClosedUnmerged);
assert_eq!(prs[2].state, PrState::Merged);
}
#[test]
fn an_unknown_pr_state_is_an_error_not_a_guess() {
assert!(
parse_pr_list(r#"[{"number":1,"state":"WAT","url":"u","baseRefName":"main"}]"#)
.is_err()
);
assert!(
parse_pr_list(r#"[{"number":1,"state":"OPEN","url":"u","isDraft":false}]"#).is_err(),
"a pull request with no baseRefName cannot be reconciled against a base"
);
assert!(parse_pr_list("not json").is_err());
assert!(parse_pr_list("[]").unwrap().is_empty());
}
#[test]
fn a_pr_number_is_read_off_the_created_url() {
assert_eq!(
pr_number_from_url("https://github.com/acme/repo/pull/4821\n").unwrap(),
4821
);
assert!(pr_number_from_url("https://github.com/acme/repo").is_err());
}
#[test]
fn push_errors_split_into_retriable_and_not() {
let (reason, retriable) = classify_push_error("! [rejected] abc -> b (non-fast-forward)");
assert!(retriable);
assert!(reason.contains("non-fast-forward"));
let (_, retriable) = classify_push_error("remote: Permission denied to car-coder.");
assert!(!retriable, "a permission refusal must not be retried");
let (_, retriable) = classify_push_error(
"ssh: Could not resolve hostname github.com: nodename nor servname provided, \
or not known\nfatal: Could not read from remote repository.\n\nPlease make \
sure you have the correct access rights\nand the repository exists.",
);
assert!(retriable, "transport failures are worth another round");
}
#[test]
fn a_sha_containing_403_is_not_mistaken_for_a_permission_refusal() {
let (reason, retriable) = classify_push_error(
"! [remote rejected] goalpool/g_1 -> goalpool/g_1 (cannot lock ref \
'refs/heads/goalpool/g_1': is at a4973f07ba3815b8d45b86a7e9633d9fbc5e4403 \
but expected b1c2d3e4f5061728394a5b6c7d8e9f0011223344)",
);
assert!(
retriable,
"a lost push race is retriable; the digits 403 inside a SHA are not an HTTP status"
);
assert!(
reason.contains("race") || reason.contains("moved"),
"the reason must name what actually happened: {reason}"
);
}
#[test]
fn gits_lost_race_wording_is_recognised_rather_than_defaulted() {
for message in [
"! [remote rejected] main -> main (failed to update ref)",
"error: cannot lock ref 'refs/heads/goalpool/g_1': is at aaa but expected bbb",
"! [rejected] abc -> b (fetch first)",
] {
let (reason, retriable) = classify_push_error(message);
assert!(retriable, "{message}");
assert!(
reason.contains("moved") || reason.contains("race"),
"the reason must tell an operator the branch moved, not echo git: {reason}"
);
}
}
#[test]
fn a_403_in_a_branch_or_repo_name_is_not_a_permission_refusal() {
for message in [
"! [rejected] abc1234 -> feature-403 (non-fast-forward)",
"! [rejected] abc -> goalpool/g_403 (fetch first)",
"! [remote rejected] x -> release_403 (failed to update ref)",
] {
let (reason, retriable) = classify_push_error(message);
assert!(retriable, "must stay a retriable race: {message}");
assert!(
reason.contains("moved") || reason.contains("race"),
"{reason}"
);
}
let (_, retriable) =
classify_push_error("fatal: unable to access 'https://github.com/org/repo-403.git/'");
assert!(retriable, "a repo name is not a status code");
}
#[test]
fn permanent_server_refusals_inside_remote_rejected_are_not_retried() {
for message in [
"! [remote rejected] b -> b (refusing to allow an OAuth App to create or update \
workflow '.github/workflows/x.yml' without 'workflow' scope)",
"! [remote rejected] b -> b (shallow update not allowed)",
"remote: error: GH001: Large files detected. File exceeds GitHub's file size limit \
of 100.00 MB",
"remote: error: cannot lock ref 'refs/heads/goalpool/g_1': \
'refs/heads/goalpool' exists; cannot create 'refs/heads/goalpool/g_1'\n \
! [remote rejected] HEAD -> goalpool/g_1 (failed to update ref)",
] {
let (_, retriable) = classify_push_error(message);
assert!(!retriable, "permanent, must not be retried: {message}");
}
}
#[test]
fn a_repository_rule_violation_is_permanent_not_a_lost_race() {
let (reason, retriable) = classify_push_error(
"remote: error: GH013: Repository rule violations found for \
refs/heads/goalpool/g_1.\nremote:\nremote: - GITHUB PUSH PROTECTION\nremote: \
—— GitHub Personal Access Token ————————————————\nremote:\n \
! [remote rejected] goalpool/g_1 -> goalpool/g_1 (push declined due to \
repository rule violations)\nerror: failed to push some refs to \
'https://github.com/o/r.git'",
);
assert!(
!retriable,
"a ruleset block cannot be got past by pushing the same commit again"
);
assert!(
!reason.contains("race") && !reason.contains("moved"),
"and it must not be described as a lost push race: {reason}"
);
let (_, retriable) = classify_push_error(
"! [remote rejected] b -> b (push declined due to repository rule violations)",
);
assert!(!retriable);
for code in ["GH009", "GH011", "GH013"] {
let (_, retriable) =
classify_push_error(&format!("remote: error: {code}: blocked by policy"));
assert!(!retriable, "{code} must be read as a policy refusal");
}
}
#[test]
fn a_github_code_in_a_branch_name_is_not_a_policy_refusal() {
for message in [
"! [rejected] abc -> fix-gh013-secret-scanning (non-fast-forward)",
"! [remote rejected] x -> gh001 (failed to update ref)",
"fatal: unable to access 'https://github.com/org/gh013.git/'",
] {
let (_, retriable) = classify_push_error(message);
assert!(retriable, "a name is not a status code: {message}");
}
}
#[test]
fn permanent_pr_reconciliation_failures_are_not_retriable() {
for message in [
"GraphQL: No commits between main and goalpool/g_1",
"GraphQL: Draft pull requests are not supported in this repository",
] {
let (_, retriable) = classify_pr_error(message);
assert!(!retriable, "permanent, must not be retried: {message}");
}
let (_, retriable) = classify_pr_error(
"a pull request for branch \"goalpool/g_1\" into branch \"main\" already exists: #7",
);
assert!(
retriable,
"the error proves a usable pull request exists; the next round adopts it"
);
let (_, retriable) = classify_pr_error("502 Bad Gateway");
assert!(retriable);
}
#[test]
fn a_paragraph_break_in_the_intent_ends_the_commit_subject() {
let f = fixture();
let c = contract();
let wt = f.cut("subject-check", "main");
std::fs::write(wt.join("greet.js"), "console.log('hi');\n").unwrap();
let summary = "Goal: implement greet() in the scratch repo";
let body_text = "Read the goal brief /tmp/car-e2e-2/gp-home/logs/brief.md and \
follow it exactly. This pointer text must not reach the subject.";
let intent = format!("{summary}\n\n{body_text}");
let out = deliver_pr_with(
PrDelivery {
repo: &f.repo,
worktree: &wt,
target_branch: "goalpool/g_subject",
base_branch: "main",
draft: true,
intent: &intent,
contract: &c,
body: "b",
provenance: None,
},
&FakeGh::ok(),
)
.expect("delivery succeeds");
let subject = git(&f.repo, &["log", "-1", "--format=%s", &out.commit]).unwrap();
assert_eq!(
subject.trim(),
summary,
"the subject must be exactly the first paragraph"
);
assert!(
!subject.contains("goal brief"),
"the pointer text must not ride along: {subject}"
);
let body = git(&f.repo, &["log", "-1", "--format=%b", &out.commit]).unwrap();
assert!(
body.contains("goal brief"),
"the remainder belongs in the body: {body}"
);
}
#[test]
fn a_single_paragraph_intent_still_truncates_as_before() {
let short = "Add a --verbose flag to the CLI";
assert_eq!(subject_from_intent(short), short);
let long = "Add a --verbose flag to the export subcommand and thread it through \
every downstream call site so the whole pipeline reports progress";
let subject = subject_from_intent(long);
assert!(subject.ends_with("..."), "{subject}");
assert!(subject.len() <= 72, "len {}: {subject}", subject.len());
assert!(!subject.contains('\n'));
assert_eq!(
subject_from_intent("wrapped over\ntwo lines\n\nbody here"),
"wrapped over two lines"
);
}
#[test]
fn no_runtime_bookkeeping_reaches_the_delivered_tree() {
let f = fixture();
let c = contract();
let wt = f.cut("marker-check", "main");
let gitdir = std::fs::read_to_string(wt.join(".git"))
.ok()
.and_then(|m| {
m.trim()
.strip_prefix("gitdir:")
.map(|g| g.trim().to_string())
})
.map(PathBuf::from)
.expect("a worktree's .git is a file holding a gitdir pointer");
std::fs::write(gitdir.join("car-code-task"), "claimed\n").unwrap();
std::fs::write(wt.join("greet.js"), "console.log('hi');\n").unwrap();
let out = deliver_pr_with(
delivery(&f, &wt, &c, "goalpool/g_marker", true, "b"),
&FakeGh::ok(),
)
.expect("delivery succeeds");
let tree = git(&f.origin, &["ls-tree", "-r", "--name-only", &out.commit]).unwrap();
assert!(
tree.contains("greet.js"),
"the actual work must be there: {tree}"
);
for bookkeeping in ["car-code-task", ".car-code-task"] {
assert!(
!tree.contains(bookkeeping),
"`{bookkeeping}` is runtime bookkeeping and must not reach a reviewed diff: {tree}"
);
}
}
#[test]
fn a_clean_worktree_at_the_base_is_refused_rather_than_pushed_empty() {
let f = fixture();
let c = contract();
let wt = f.cut("empty-round", "main");
let base_tip = git(&wt, &["rev-parse", "HEAD"]).unwrap().trim().to_string();
let err = deliver_pr_with(
delivery(&f, &wt, &c, "goalpool/g_empty", true, "b"),
&FakeGh::ok(),
)
.expect_err("an empty round must not open a pull request");
assert_eq!(err.stage(), "commit", "{err}");
assert!(
err.reason().contains("nothing to deliver"),
"{}",
err.reason()
);
assert!(err.reason().contains(&base_tip), "{}", err.reason());
assert!(!err.retriable(), "an empty round will be empty again");
assert!(
git(
&f.origin,
&[
"rev-parse",
"--verify",
"--quiet",
"refs/heads/goalpool/g_empty"
]
)
.is_err(),
"no stray branch may be created for an empty round"
);
}
#[test]
fn a_policy_refusal_wrapped_in_rejection_wording_is_not_a_race() {
let (_, retriable) =
classify_push_error("! [remote rejected] main -> main (pre-receive hook declined)");
assert!(!retriable, "branch protection is not worth retrying");
}
#[test]
fn a_genuine_403_is_still_a_non_retriable_refusal() {
for message in [
"fatal: unable to access 'https://github.com/o/r/': The requested URL returned error: 403",
"remote: Permission to o/r.git denied to car-coder.",
"fatal: Authentication failed for 'https://github.com/o/r/'",
"fatal: could not read Username for 'https://github.com'",
] {
let (_, retriable) = classify_push_error(message);
assert!(!retriable, "must not be retried: {message}");
}
}
#[test]
fn delivering_onto_the_base_branch_is_refused_before_anything_is_pushed() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "unreviewed").unwrap();
let gh = FakeGh::ok();
let err = deliver_pr_with(delivery(&f, &wt, &c, "main", true, "b"), &gh).unwrap_err();
assert_eq!(err.stage(), "preflight", "{err}");
assert!(!err.retriable());
assert!(err.reason().contains("base"), "{err}");
assert!(
git(&wt, &["log", "-1", "--format=%an"])
.unwrap()
.trim()
.ne("car-coder"),
"the worktree must not have been committed"
);
assert!(gh.calls().is_empty(), "{:?}", gh.calls());
}
#[test]
fn a_commit_failure_is_not_mistaken_for_a_clean_worktree() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "round one").unwrap();
let first =
deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "b"), &FakeGh::ok()).unwrap();
std::fs::write(
wt.join("y.txt"),
"round two — the work that must not be lost",
)
.unwrap();
git(&f.repo, &["config", "commit.gpgsign", "true"]).unwrap();
git(&f.repo, &["config", "gpg.program", "/nonexistent/gpg"]).unwrap();
let intent = "fix delivery so it reports 'no changes to deliver' correctly";
let d = PrDelivery {
intent,
..delivery(&f, &wt, &c, TARGET, true, "b")
};
let err = deliver_pr_with(d, &FakeGh::ok()).unwrap_err();
assert_eq!(err.stage(), "commit", "{err}");
assert_eq!(
f.origin_head(TARGET).as_deref(),
Some(first.commit.as_str()),
"the stale commit must not be re-delivered as if it were round 2"
);
}
#[test]
fn a_cross_repository_pull_request_is_not_adopted() {
let raw = r#"[
{"number":200,"state":"OPEN","url":"https://github.com/acme/repo/pull/200",
"isDraft":false,"isCrossRepository":true,"baseRefName":"main"},
{"number":7,"state":"OPEN","url":"https://github.com/acme/repo/pull/7",
"isDraft":false,"isCrossRepository":false,"baseRefName":"main"}
]"#;
let prs = parse_pr_list(raw).unwrap();
assert_eq!(
prs.iter().map(|p| p.number).collect::<Vec<_>>(),
vec![7],
"only the same-repository pull request may be reconciled"
);
assert!(gh_pr_list_args("b")
.iter()
.any(|a| a == "number,state,url,isDraft,isCrossRepository,baseRefName"));
}
#[test]
fn the_github_repository_is_taken_from_the_same_remote_the_push_uses() {
for (url, expect) in [
("https://github.com/acme/repo.git", Some("acme/repo")),
("https://github.com/acme/repo", Some("acme/repo")),
("git@github.com:acme/repo.git", Some("acme/repo")),
("ssh://git@github.com/acme/repo.git", Some("acme/repo")),
(
"https://x-token@github.com/acme/repo.git",
Some("acme/repo"),
),
(
"git@github.example.com:acme/repo.git",
Some("github.example.com/acme/repo"),
),
("/srv/mirrors/repo.git", None),
("../sibling", None),
] {
assert_eq!(
parse_github_repo_spec(url).as_deref(),
expect,
"for `{url}`"
);
}
let f = fixture();
assert!(gh_repo_args(&f.repo).is_empty());
git(
&f.repo,
&[
"remote",
"set-url",
"origin",
"git@github.com:acme/repo.git",
],
)
.unwrap();
assert_eq!(
gh_repo_args(&f.repo),
vec!["--repo".to_string(), "acme/repo".to_string()]
);
}
#[test]
fn a_404_or_401_push_failure_is_permanent_not_a_race() {
for message in [
"remote: Repository not found.\nfatal: repository \
'https://github.com/o/private.git/' not found",
"ERROR: Repository not found.\nfatal: Could not read from remote repository.",
"fatal: unable to access 'https://github.com/o/r/': The requested URL returned \
error: 401",
"fatal: 'origin' does not appear to be a git repository",
] {
let (_, retriable) = classify_push_error(message);
assert!(!retriable, "must not be retried forever: {message}");
}
let (_, retriable) =
classify_push_error("! [rejected] goalpool/g_404 -> goalpool/g_404 (non-fast-forward)");
assert!(retriable, "a moved branch is still worth another round");
}
#[test]
fn git_does_not_inherit_the_repository_from_the_environment() {
let production = MERGE_RS_SOURCE
.split_once("mod tests {")
.map(|(head, _)| head)
.unwrap_or(MERGE_RS_SOURCE);
for var in ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"] {
assert!(
production.contains(&format!(".env_remove(\"{var}\")")),
"`git()` must clear {var}, which otherwise overrides `-C`"
);
}
}
#[test]
fn a_worktree_holding_only_untracked_work_is_not_read_as_clean() {
let f = fixture();
let c = contract();
git(&f.repo, &["config", "status.showUntrackedFiles", "no"]).unwrap();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("brand-new.txt"), "a day of work").unwrap();
let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "b"), &FakeGh::ok()).unwrap();
assert_eq!(
git(
&f.origin,
&["show", &format!("refs/heads/{TARGET}:brand-new.txt")]
)
.unwrap(),
"a day of work",
"the untracked work must be in the delivered commit"
);
assert_eq!(out.pr_action, PrAction::Opened);
}
#[test]
fn a_permanent_phrase_in_the_body_cannot_make_a_transient_failure_permanent() {
let f = fixture();
let c = contract();
let wt1 = f.cut("s1", "main");
std::fs::write(wt1.join("x.txt"), "one").unwrap();
deliver_pr_with(delivery(&f, &wt1, &c, TARGET, false, "one"), &FakeGh::ok()).unwrap();
git(&f.repo, &["fetch", "-q", "origin"]).unwrap();
let wt2 = f.cut("s2", &format!("origin/{TARGET}"));
std::fs::write(wt2.join("y.txt"), "two").unwrap();
let body = "This round fixes the 'no commits between' error on empty deliveries.";
let gh = FakeGh::failing_set_body("HTTP 502: Bad Gateway (https://api.github.com/…)");
*gh.prs.lock().unwrap() = vec![PrRecord {
number: 101,
state: PrState::Open,
url: "https://github.com/acme/repo/pull/101".into(),
is_draft: false,
base: "main".into(),
}];
let err = deliver_pr_with(delivery(&f, &wt2, &c, TARGET, false, body), &gh).unwrap_err();
assert!(
err.retriable(),
"a 502 is retriable however the body is worded: {err:?}"
);
let gh2 = FakeGh::failing_set_body("GraphQL: No commits between main and goalpool/g_1");
*gh2.prs.lock().unwrap() = vec![PrRecord {
number: 101,
state: PrState::Open,
url: "https://github.com/acme/repo/pull/101".into(),
is_draft: false,
base: "main".into(),
}];
let wt3 = f.cut("s3", &format!("origin/{TARGET}"));
std::fs::write(wt3.join("z.txt"), "three").unwrap();
let err2 =
deliver_pr_with(delivery(&f, &wt3, &c, TARGET, false, "plain"), &gh2).unwrap_err();
assert!(!err2.retriable(), "{err2:?}");
}
#[test]
fn an_open_pull_request_into_another_base_is_refused_rather_than_reconciled() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "work").unwrap();
let gh = FakeGh::with_prs(vec![
PrRecord {
number: 10,
state: PrState::Open,
url: "https://github.com/acme/repo/pull/10".into(),
is_draft: false,
base: "main".into(),
},
PrRecord {
number: 12,
state: PrState::Open,
url: "https://github.com/acme/repo/pull/12".into(),
is_draft: false,
base: "release/2.1".into(),
},
]);
let err =
deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap_err();
assert!(
matches!(err, DeliveryFailure::Preflight { .. }),
"an ambiguous head is refused before the commit: {err:?}"
);
assert!(
!err.retriable(),
"retrying changes nothing — a human closes #12 or picks another target branch"
);
assert!(
err.reason().contains("#12") && err.reason().contains("release/2.1"),
"{}",
err.reason()
);
assert_eq!(f.origin_head(TARGET), None, "nothing may be pushed");
assert!(
!gh.calls()
.iter()
.any(|c| c.starts_with("set_body") || c.starts_with("create")),
"{:?}",
gh.calls()
);
}
#[test]
fn a_human_pull_request_into_another_base_parks_delivery_before_the_push() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "unreviewed model output").unwrap();
let gh = FakeGh::with_prs(vec![PrRecord {
number: 200,
state: PrState::Open,
url: "https://github.com/acme/repo/pull/200".into(),
is_draft: false,
base: "release/2.1".into(),
}]);
let err =
deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap_err();
assert_eq!(err.stage(), "preflight");
assert!(!err.retriable(), "{err:?}");
assert!(err.reason().contains("#200"), "{}", err.reason());
assert_eq!(
f.origin_head(TARGET),
None,
"the model's commits must never reach a branch #200 tracks"
);
assert!(
!gh.calls().iter().any(|c| c.starts_with("create")),
"no second pull request is opened to paper over the refusal: {:?}",
gh.calls()
);
}
#[test]
fn a_changed_base_is_refused_while_the_old_pull_request_is_open() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "work").unwrap();
let gh = FakeGh::with_prs(vec![PrRecord {
number: 10,
state: PrState::Open,
url: "https://github.com/acme/repo/pull/10".into(),
is_draft: false,
base: "main".into(),
}]);
let mut d = delivery(&f, &wt, &c, TARGET, false, "body");
d.base_branch = "release/2.1";
let err = deliver_pr_with(d, &gh).unwrap_err();
assert_eq!(err.stage(), "preflight");
assert!(err.reason().contains("#10"), "{}", err.reason());
assert_eq!(f.origin_head(TARGET), None);
}
#[test]
fn a_changed_base_opens_its_own_pull_request_once_the_old_one_is_closed() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "work").unwrap();
let gh = FakeGh::with_prs(vec![PrRecord {
number: 10,
state: PrState::ClosedUnmerged,
url: "https://github.com/acme/repo/pull/10".into(),
is_draft: false,
base: "main".into(),
}]);
let mut d = delivery(&f, &wt, &c, TARGET, false, "body");
d.base_branch = "release/2.1";
let out = deliver_pr_with(d, &gh).unwrap();
assert_eq!(out.pr_action, PrAction::Opened);
assert_ne!(out.pr_number, 10);
assert!(
gh.calls()
.iter()
.any(|c| c.contains("create head=") && c.contains("base=release/2.1")),
"{:?}",
gh.calls()
);
assert!(
!gh.calls().iter().any(|c| c.starts_with("set_body")),
"{:?}",
gh.calls()
);
}
#[test]
fn a_merged_pull_request_into_another_base_does_not_park_delivery() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "work").unwrap();
let gh = FakeGh::with_prs(vec![
PrRecord {
number: 10,
state: PrState::Open,
url: "https://github.com/acme/repo/pull/10".into(),
is_draft: false,
base: "main".into(),
},
PrRecord {
number: 12,
state: PrState::Merged,
url: "https://github.com/acme/repo/pull/12".into(),
is_draft: false,
base: "release/2.1".into(),
},
]);
let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap();
assert_eq!(out.pr_action, PrAction::Updated);
assert_eq!(out.pr_number, 10);
}
#[test]
fn a_cross_repository_pull_request_does_not_park_delivery() {
let parsed = parse_pr_list(
r#"[
{"number": 77, "state": "OPEN", "url": "u77", "isDraft": false,
"baseRefName": "release/2.1", "isCrossRepository": true},
{"number": 10, "state": "OPEN", "url": "u10", "isDraft": false,
"baseRefName": "main", "isCrossRepository": false}
]"#,
)
.unwrap();
assert_eq!(parsed.len(), 1, "the fork entry is dropped: {parsed:?}");
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "work").unwrap();
let gh = FakeGh::with_prs(parsed);
let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap();
assert_eq!(out.pr_action, PrAction::Updated);
assert_eq!(out.pr_number, 10);
}
#[test]
fn credential_prompts_that_can_never_be_answered_are_permanent() {
for message in [
"fatal: could not read Username for 'https://github.com': No such device or address",
"fatal: could not read Password for 'https://someuser@github.com': \
No such device or address",
"fatal: could not read Username for 'https://github.com': terminal prompts disabled",
"Host key verification failed.\nfatal: Could not read from remote repository.",
] {
let (_, retriable) = classify_push_error(message);
assert!(
!retriable,
"a credential that will never appear must not be retried: {message}"
);
}
let (_, retriable) =
classify_push_error("! [rejected] goalpool/g_pw -> goalpool/g_pw (non-fast-forward)");
assert!(retriable);
}
#[test]
fn git_children_cannot_open_a_terminal_prompt() {
let dir = tempfile::tempdir().unwrap();
git(dir.path(), &["init", "-q", "-b", "main"]).unwrap();
let seen = git(
dir.path(),
&[
"-c",
"alias.envprobe=!printf %s \"${GIT_TERMINAL_PROMPT-unset}\"",
"envprobe",
],
)
.unwrap();
assert_eq!(
seen.trim(),
"0",
"GIT_TERMINAL_PROMPT must be 0 for every git this module runs"
);
}
#[test]
fn a_subprocess_that_never_finishes_is_killed_and_reported() {
let mut cmd = std::process::Command::new("sleep");
cmd.arg("60");
let started = std::time::Instant::now();
let err = run_capped_for(cmd, std::time::Duration::from_millis(300)).err();
assert!(
matches!(err, Some(RunFailure::TimedOut(_))),
"the child must be killed, not waited on"
);
assert!(
started.elapsed() < std::time::Duration::from_secs(20),
"the kill must not wait for the child's own exit"
);
}
#[test]
fn branch_mode_re_delivers_a_clean_worktree_whose_head_is_ahead_of_the_base() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
std::fs::write(wt.join("x.txt"), "work").unwrap();
let first =
publish_branch_headless(&f.repo, &wt, "r1", "make x exist", &c, "main", None).unwrap();
assert_eq!(first, "car/coder/r1");
let second =
publish_branch_headless(&f.repo, &wt, "r2", "make x exist", &c, "main", None).unwrap();
assert_eq!(second, "car/coder/r2");
assert_eq!(
git(&f.repo, &["rev-parse", "car/coder/r1"]).unwrap().trim(),
git(&f.repo, &["rev-parse", "car/coder/r2"]).unwrap().trim(),
"the same commit is re-delivered, not redone"
);
}
#[test]
fn branch_mode_still_refuses_a_clean_worktree_that_holds_no_work() {
let f = fixture();
let c = contract();
let wt = f.cut("s1", "main");
let err =
publish_branch_headless(&f.repo, &wt, "r1", "noop", &c, "main", None).unwrap_err();
assert!(err.contains("nothing to deliver"), "{err}");
}
#[test]
fn a_crlf_intent_still_stops_at_its_blank_line() {
let intent =
"Add the retry shim\r\n\r\nPointers:\r\n- see src/net.rs\r\n- and the docs\r\n";
let subject = subject_from_intent(intent);
assert_eq!(subject, "Add the retry shim");
assert!(!subject.contains('\r'), "{subject:?}");
assert!(!subject_from_intent("a\rb\r\nc").contains('\r'));
}
#[test]
fn forge_command_shapes_elide_the_title_and_body() {
for args in [
gh_pr_create_args("head", "main", "a title", "a very long body", false),
az_pr_create_args("head", "main", "a title", "a very long body", false),
] {
let shape = gh_subcommand_shape(&args);
assert!(!shape.contains("a title"), "{shape}");
assert!(!shape.contains("a very long body"), "{shape}");
assert!(shape.contains("--title"), "{shape}");
}
}
}