use std::path::Path;
use super::contract::OutcomeContract;
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,
}
fn commit_worktree(
worktree: &Path,
intent: &str,
contract: &OutcomeContract,
) -> 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 = format!(
"Authored by CAR Coder.\n\nIntent:\n{}\n\nOutcome contract (all checks passed):\n{}",
intent.trim(),
contract.render()
);
git(
worktree,
&[
"-c",
"user.name=car-coder",
"-c",
"user.email=coder@parslee.ai",
"commit",
"-m",
&subject,
"-m",
&body,
],
)?;
Ok(CommitOutcome::Made(
git(worktree, &["rev-parse", "HEAD"])?.trim().to_string(),
))
}
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)
}
pub fn publish_branch(
repo: &Path,
worktree: &Path,
short_id: &str,
intent: &str,
contract: &OutcomeContract,
) -> Result<String, String> {
let commit = require_commit(commit_worktree(worktree, intent, contract)?)?;
let branch = format!("car/coder/{short_id}");
git(repo, &["branch", &branch, &commit])?;
Ok(branch)
}
pub fn publish_branch_headless(
repo: &Path,
worktree: &Path,
short_id: &str,
intent: &str,
contract: &OutcomeContract,
base_branch: &str,
) -> Result<String, String> {
let commit = match commit_worktree(worktree, intent, contract)? {
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,
) -> Result<String, String> {
let commit = require_commit(commit_worktree(worktree, intent, contract)?)?;
git(repo, &["merge", "--ff-only", &commit]).map_err(|e| {
format!("could not fast-forward the project's main branch (it moved since the session started): {e}")
})?;
Ok(commit)
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StagedDiff {
pub stat: String,
pub patch: String,
pub truncated: bool,
pub full_bytes: usize,
pub changed_paths: Vec<String>,
}
pub fn stage_and_diff(worktree: &Path, patch_cap_bytes: usize) -> Result<StagedDiff, String> {
git(worktree, &["add", "-A"])?;
let stat = git(worktree, &["diff", "--cached", "--stat"])?;
let patch = git(worktree, &["diff", "--cached"])?;
let names = git(
worktree,
&[
"-c",
"core.quotepath=false",
"diff",
"--cached",
"--name-status",
"-z",
],
)?;
let changed_paths = parse_name_status_z(&names);
let full_bytes = patch.len();
Ok(StagedDiff {
patch: super::shell_tool::tail(&patch, patch_cap_bytes),
truncated: full_bytes > patch_cap_bytes,
full_bytes,
stat,
changed_paths,
})
}
fn parse_name_status_z(raw: &str) -> Vec<String> {
let mut out = Vec::new();
let mut fields = raw.split('\0').filter(|f| !f.is_empty());
while let Some(status) = fields.next() {
let bytes = status.as_bytes();
let well_formed = status.len() <= 4
&& matches!(
bytes[0],
b'A' | b'C' | b'D' | b'M' | b'R' | b'T' | b'U' | b'X' | b'B'
)
&& status[1..].bytes().all(|b| b.is_ascii_digit());
if !well_formed {
tracing::warn!(
status = %status,
"unexpected field in `git diff --name-status -z`; changed-path list truncated \
rather than risk a desynchronized parse"
);
break;
}
let two_paths = bytes[0] == b'R' || bytes[0] == b'C';
let Some(first) = fields.next() else {
tracing::warn!(status = %status, "name-status stream ended mid-entry");
break;
};
out.push(first.to_string());
if two_paths {
match fields.next() {
Some(second) => out.push(second.to_string()),
None => {
tracing::warn!(status = %status, "rename/copy entry missing its destination");
break;
}
}
}
}
out.sort();
out.dedup();
out
}
pub(crate) fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
let 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);
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(String::from_utf8_lossy(&out.stdout).into_owned())
} 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,
}
#[derive(Debug, Clone, PartialEq)]
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,
}
#[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 GitHubApi: Send + Sync {
fn auth_status(&self) -> Result<(), GhError>;
fn list_prs_for_head(&self, dir: &Path, head_branch: &str) -> Result<Vec<PrRecord>, GhError>;
fn create_pr(
&self,
dir: &Path,
head_branch: &str,
base_branch: &str,
title: &str,
body: &str,
draft: bool,
) -> Result<PrRecord, GhError>;
fn set_pr_body(&self, dir: &Path, number: u64, body: &str) -> Result<(), GhError>;
}
pub struct GhCli;
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
}
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 GhError {
pub message: String,
pub stderr: String,
}
impl std::fmt::Display for GhError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl GhError {
fn local(message: impl Into<String>) -> Self {
let message = message.into();
Self {
stderr: message.clone(),
message,
}
}
}
pub(super) fn gh(dir: &Path, args: &[String]) -> Result<String, GhError> {
let mut cmd = std::process::Command::new("gh");
cmd.current_dir(dir).args(args);
no_interactive_prompts(&mut cmd);
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 => GhError::local(
"`gh` not found on PATH — install the GitHub CLI (https://cli.github.com) \
and authenticate it",
),
RunFailure::Spawn(io) => GhError::local(format!(
"failed to run `gh {}`: {io}",
gh_subcommand_shape(args)
)),
RunFailure::TimedOut(secs) => GhError::local(format!(
"`gh {}` 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(GhError {
message: format!("gh {} failed: {stderr}", gh_subcommand_shape(args)),
stderr,
})
}
}
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");
}
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 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 GitHubApi for GhCli {
fn auth_status(&self) -> Result<(), GhError> {
gh(Path::new("."), &gh_auth_status_args())
.map(|_| ())
.map_err(|e| GhError {
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>, GhError> {
let mut args = gh_repo_args(dir);
args.extend(gh_pr_list_args(head_branch));
parse_pr_list(&gh(dir, &args)?).map_err(GhError::local)
}
fn create_pr(
&self,
dir: &Path,
head_branch: &str,
base_branch: &str,
title: &str,
body: &str,
draft: bool,
) -> Result<PrRecord, GhError> {
let mut args = gh_repo_args(dir);
args.extend(gh_pr_create_args(
head_branch,
base_branch,
title,
body,
draft,
));
let url = gh(dir, &args)?;
Ok(PrRecord {
number: pr_number_from_url(&url).map_err(GhError::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<(), GhError> {
let mut args = gh_repo_args(dir);
args.extend([
"pr".to_string(),
"edit".to_string(),
number.to_string(),
"--body".to_string(),
body.to_string(),
]);
gh(dir, &args).map(|_| ())
}
}
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> {
deliver_pr_with(d, &GhCli)
}
pub fn deliver_pr_with(
d: PrDelivery<'_>,
gh_api: &dyn GitHubApi,
) -> 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
),
});
}
gh_api
.auth_status()
.map_err(|e| DeliveryFailure::Preflight {
reason: e.to_string(),
})?;
let listed = gh_api
.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) {
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 {
gh_api
.set_pr_body(d.repo, pr.number, d.body)
.map_err(pr_failure)?;
((*pr).clone(), PrAction::Updated)
} else {
let created = gh_api
.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)
};
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,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::coder::contract::ContractCheck;
fn contract() -> OutcomeContract {
OutcomeContract {
description: "x exists".into(),
checks: vec![ContractCheck {
name: "exists".into(),
command: "test -f x.txt".into(),
expect_exit_zero: true,
output_contains: None,
timeout_secs: 10,
}],
}
}
fn init_repo(dir: &Path) {
for args in [
vec!["init", "-q", "-b", "main"],
vec![
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"--allow-empty",
"-m",
"init",
],
] {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(&args)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn publishes_branch_without_touching_user_checkout() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_repo(repo);
let wt_base = tempfile::tempdir().unwrap();
let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
let ws = car_multi::AgentWorkspace::provision(&config, "coder-merge-test").unwrap();
std::fs::write(ws.path().join("x.txt"), "made by coder").unwrap();
let branch = publish_branch(
repo,
ws.path(),
"abc12345",
"create x.txt with content",
&contract(),
)
.unwrap();
assert_eq!(branch, "car/coder/abc12345");
let show = git(repo, &["show", &format!("{branch}:x.txt")]).unwrap();
assert_eq!(show, "made by coder");
let author = git(repo, &["log", "-1", "--format=%an", &branch]).unwrap();
assert_eq!(author.trim(), "car-coder");
let status = git(repo, &["status", "--porcelain"]).unwrap();
assert!(status.is_empty(), "user checkout dirtied: {status}");
assert!(!repo.join("x.txt").exists());
}
#[test]
fn clean_worktree_refuses_to_publish() {
let repo_dir = tempfile::tempdir().unwrap();
init_repo(repo_dir.path());
let wt_base = tempfile::tempdir().unwrap();
let config = car_multi::WorkspaceConfig::git_worktree_at(repo_dir.path(), wt_base.path());
let ws = car_multi::AgentWorkspace::provision(&config, "coder-clean-test").unwrap();
let err =
publish_branch(repo_dir.path(), ws.path(), "def", "noop", &contract()).unwrap_err();
assert!(err.contains("no changes"), "{err}");
}
#[test]
fn long_intent_is_truncated_in_subject() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_repo(repo);
let wt_base = tempfile::tempdir().unwrap();
let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
let ws = car_multi::AgentWorkspace::provision(&config, "coder-long-test").unwrap();
std::fs::write(ws.path().join("y.txt"), "y").unwrap();
let long_intent = "a very ".repeat(40) + "long intent";
let branch = publish_branch(repo, ws.path(), "fff", &long_intent, &contract()).unwrap();
let subject = git(repo, &["log", "-1", "--format=%s", &branch]).unwrap();
assert!(subject.trim().len() <= 72);
assert!(subject.contains("..."));
}
#[test]
fn commit_to_main_fast_forwards_the_checkout() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_repo(repo);
let wt_base = tempfile::tempdir().unwrap();
let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
let ws = car_multi::AgentWorkspace::provision(&config, "coder-main-test").unwrap();
std::fs::write(ws.path().join("z.txt"), "managed").unwrap();
let commit = commit_to_main(repo, ws.path(), "add z", &contract()).unwrap();
let head = git(repo, &["rev-parse", "HEAD"]).unwrap();
assert_eq!(head.trim(), commit);
assert_eq!(
std::fs::read_to_string(repo.join("z.txt")).unwrap(),
"managed"
);
assert!(git(repo, &["branch", "--list", "car/coder/*"])
.unwrap()
.is_empty());
}
#[test]
fn commit_to_main_errors_when_main_moved() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_repo(repo);
let wt_base = tempfile::tempdir().unwrap();
let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
let ws = car_multi::AgentWorkspace::provision(&config, "coder-moved-test").unwrap();
std::fs::write(ws.path().join("a.txt"), "from session").unwrap();
std::fs::write(repo.join("b.txt"), "concurrent").unwrap();
for args in [
vec!["-c", "user.name=t", "-c", "user.email=t@t", "add", "-A"],
vec![
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"-m",
"concurrent",
],
] {
assert!(std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(&args)
.output()
.unwrap()
.status
.success());
}
let err = commit_to_main(repo, ws.path(), "add a", &contract()).unwrap_err();
assert!(err.contains("fast-forward"), "{err}");
}
#[test]
fn a_rename_reports_both_endpoints_not_just_the_destination() {
let paths = parse_name_status_z("R100\0secrets/key.txt\0public_key.txt\0");
assert!(
paths.contains(&"secrets/key.txt".to_string()),
"the source directory must not vanish: {paths:?}"
);
assert!(paths.contains(&"public_key.txt".to_string()), "{paths:?}");
assert_eq!(paths.len(), 2);
}
#[test]
fn a_copy_also_reports_both_endpoints() {
let paths = parse_name_status_z("C75\0src/a.rs\0src/b.rs\0");
assert_eq!(paths, vec!["src/a.rs".to_string(), "src/b.rs".to_string()]);
}
#[test]
fn mixed_entries_stay_in_sync_after_a_rename() {
let paths = parse_name_status_z("M\0src/a.rs\0R100\0old/x.rs\0new/x.rs\0A\0src/z.rs\0");
assert_eq!(
paths,
vec![
"new/x.rs".to_string(),
"old/x.rs".to_string(),
"src/a.rs".to_string(),
"src/z.rs".to_string(),
]
);
}
#[test]
fn a_newline_in_a_filename_does_not_forge_an_entry() {
let paths = parse_name_status_z("A\0we\nird.txt\0");
assert_eq!(paths, vec!["we\nird.txt".to_string()]);
}
#[test]
fn an_empty_diff_yields_no_paths() {
assert!(parse_name_status_z("").is_empty());
}
#[test]
fn type_change_and_unmerged_are_single_path_entries() {
assert_eq!(
parse_name_status_z("T\0src/link.txt\0M\0src/after.rs\0"),
vec!["src/after.rs".to_string(), "src/link.txt".to_string()]
);
assert_eq!(
parse_name_status_z("U\0conflict.txt\0"),
vec!["conflict.txt".to_string()]
);
}
#[test]
fn an_unrecognized_status_bails_instead_of_desynchronizing() {
assert!(parse_name_status_z("Z9\0a.txt\0b.txt\0").is_empty());
assert_eq!(
parse_name_status_z("M\0good.rs\0Z9\0a.txt\0"),
vec!["good.rs".to_string()]
);
}
#[test]
fn a_rename_missing_its_destination_bails() {
assert_eq!(
parse_name_status_z("R100\0only-one.txt\0"),
vec!["only-one.txt".to_string()]
);
}
#[test]
fn stage_and_diff_sees_a_renamed_out_of_directory_source() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
for args in [
vec!["init", "-q", "."],
vec!["config", "user.email", "t@t"],
vec!["config", "user.name", "t"],
] {
git(repo, &args).unwrap();
}
std::fs::create_dir(repo.join("secrets")).unwrap();
std::fs::write(repo.join("secrets/key.txt"), "k").unwrap();
git(repo, &["add", "-A"]).unwrap();
git(repo, &["commit", "-qm", "init"]).unwrap();
std::fs::rename(repo.join("secrets/key.txt"), repo.join("public_key.txt")).unwrap();
let diff = stage_and_diff(repo, 64 * 1024).unwrap();
assert!(
diff.changed_paths.iter().any(|p| p.starts_with("secrets/")),
"the source directory must appear: {:?}",
diff.changed_paths
);
assert_eq!(
diff.changed_paths,
vec!["public_key.txt".to_string(), "secrets/key.txt".to_string()],
"both endpoints, and nothing else"
);
}
use std::path::PathBuf;
use std::sync::Mutex;
const MERGE_RS_SOURCE: &str = include_str!("merge.rs");
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>>,
}
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),
}
}
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 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(())
}
}
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,
}
}
const TARGET: &str = "goalpool/g_abc123";
#[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!(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");
}
#[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 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:?}"
);
}
#[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",
},
&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").unwrap();
assert_eq!(first, "car/coder/r1");
let second =
publish_branch_headless(&f.repo, &wt, "r2", "make x exist", &c, "main").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").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 the_gh_command_shape_elides_the_title_and_the_body() {
let args = gh_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}");
assert!(shape.contains("--head head"), "{shape}");
}
}