1use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21use super::contract::OutcomeContract;
22use super::session::IntegratedSubtask;
23
24fn subject_from_intent(intent: &str) -> String {
28 let trimmed = intent.replace("\r\n", "\n");
38 let trimmed = trimmed.trim();
39 let head = trimmed.split("\n\n").next().unwrap_or(trimmed).trim();
47 let s = head.replace(['\n', '\r'], " ");
51 if s.len() > 72 {
52 let mut end = 69;
53 while !s.is_char_boundary(end) {
54 end -= 1;
55 }
56 format!("{}...", &s[..end])
57 } else {
58 s
59 }
60}
61
62enum CommitOutcome {
79 Made(String),
81 NothingToCommit,
84}
85
86pub fn placement_provenance(
118 placements: &[car_multi::Placement],
119 integrated: &[IntegratedSubtask],
120 repaired_locally: bool,
121) -> Option<String> {
122 if integrated.is_empty() {
123 return None;
124 }
125 let worker_of = |subtask_id: &str| -> Option<&car_multi::Placement> {
126 placements.iter().find(|p| p.subtask_id == subtask_id)
127 };
128 let mut lines: Vec<String> = Vec::new();
129 let mut any_remote = false;
130 for landed in integrated {
131 let Some(p) = worker_of(&landed.subtask_id) else {
132 continue;
133 };
134 let Some(worker) = p.worker_id.as_deref() else {
137 continue;
138 };
139 any_remote |= p.remote;
140 let mut line = format!(
141 "CAR-Placement: subtask={} worker={} remote={}",
142 trailer_value(&landed.subtask_id),
143 trailer_value(worker),
144 p.remote
145 );
146 if !landed.files.is_empty() {
147 line.push_str(&format!(
148 " files={}",
149 landed
150 .files
151 .iter()
152 .map(|f| trailer_value(f))
153 .collect::<Vec<_>>()
154 .join(",")
155 ));
156 }
157 lines.push(line);
158 }
159 if lines.is_empty() {
160 return None;
161 }
162 if !any_remote && !repaired_locally {
166 return None;
167 }
168 if repaired_locally {
169 lines.push(
170 "CAR-Placement: repaired-locally=true (the union failed the contract and was repaired here, so not every delivered hunk is listed above)"
171 .to_string(),
172 );
173 }
174 Some(lines.join("\n"))
175}
176
177fn trailer_value(raw: &str) -> String {
184 const MAX: usize = 120;
185 let cleaned: String = raw
186 .chars()
187 .map(|c| if c.is_control() { ' ' } else { c })
188 .collect();
189 let cleaned = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
190 if cleaned.chars().count() > MAX {
191 cleaned.chars().take(MAX).collect::<String>() + "…"
192 } else {
193 cleaned
194 }
195}
196
197fn commit_worktree(
201 worktree: &Path,
202 intent: &str,
203 contract: &OutcomeContract,
204 provenance: Option<&str>,
207) -> Result<CommitOutcome, String> {
208 let status = git(
215 worktree,
216 &["status", "--porcelain", "--untracked-files=normal"],
217 )?;
218 if status.trim().is_empty() {
219 return Ok(CommitOutcome::NothingToCommit);
220 }
221 git(worktree, &["add", "-A"])?;
222
223 let subject = subject_from_intent(intent);
224 let mut body = format!(
225 "Authored by CAR Coder.\n\nIntent:\n{}\n\nOutcome contract (all checks passed):\n{}",
226 intent.trim(),
227 contract.render()
228 );
229 if let Some(provenance) = provenance {
230 body.push_str("\n\n");
231 body.push_str(provenance);
232 }
233 git(
234 worktree,
235 &[
236 "-c",
237 "user.name=car-coder",
238 "-c",
239 "user.email=coder@parslee.ai",
240 "commit",
241 "-m",
242 &subject,
243 "-m",
244 &body,
245 ],
246 )?;
247 Ok(CommitOutcome::Made(
248 git(worktree, &["rev-parse", "HEAD"])?.trim().to_string(),
249 ))
250}
251
252fn require_commit(outcome: CommitOutcome) -> Result<String, String> {
255 match outcome {
256 CommitOutcome::Made(sha) => Ok(sha),
257 CommitOutcome::NothingToCommit => {
258 Err("no changes to deliver — the worktree is clean".to_string())
259 }
260 }
261}
262
263fn head_beyond_base(worktree: &Path, base_branch: &str) -> Result<String, String> {
287 let head = git(worktree, &["rev-parse", "HEAD"])?.trim().to_string();
288 let base_ref = ["origin/", ""]
289 .iter()
290 .map(|p| format!("{p}{base_branch}"))
291 .find(|r| git(worktree, &["rev-parse", "--verify", "--quiet", r]).is_ok())
292 .ok_or_else(|| {
293 format!(
294 "the worktree is clean and neither origin/{base_branch} nor {base_branch} \
295 resolves, so whether there is anything to deliver cannot be determined"
296 )
297 })?;
298 if git(worktree, &["merge-base", "--is-ancestor", &head, &base_ref]).is_ok() {
299 return Err(format!(
300 "nothing to deliver: the worktree is clean and its HEAD ({head}) is already \
301 contained in {base_ref}, so there is no work to deliver"
302 ));
303 }
304 Ok(head)
305}
306
307pub fn publish_branch(
309 repo: &Path,
310 worktree: &Path,
311 short_id: &str,
312 intent: &str,
313 contract: &OutcomeContract,
314 provenance: Option<&str>,
315) -> Result<String, String> {
316 let commit = require_commit(commit_worktree(worktree, intent, contract, provenance)?)?;
317 let branch = format!("car/coder/{short_id}");
318 git(repo, &["branch", &branch, &commit])?;
321 Ok(branch)
322}
323
324pub fn publish_branch_headless(
341 repo: &Path,
342 worktree: &Path,
343 short_id: &str,
344 intent: &str,
345 contract: &OutcomeContract,
346 base_branch: &str,
347 provenance: Option<&str>,
348) -> Result<String, String> {
349 let commit = match commit_worktree(worktree, intent, contract, provenance)? {
350 CommitOutcome::Made(sha) => sha,
351 CommitOutcome::NothingToCommit => head_beyond_base(worktree, base_branch)?,
352 };
353 let branch = format!("car/coder/{short_id}");
354 git(repo, &["branch", &branch, &commit])?;
355 Ok(branch)
356}
357
358pub fn commit_to_main(
365 repo: &Path,
366 worktree: &Path,
367 intent: &str,
368 contract: &OutcomeContract,
369 provenance: Option<&str>,
370) -> Result<String, String> {
371 let commit = require_commit(commit_worktree(worktree, intent, contract, provenance)?)?;
372 git(repo, &["merge", "--ff-only", &commit]).map_err(|e| {
373 format!("could not fast-forward the project's main branch (it moved since the session started): {e}")
374 })?;
375 Ok(commit)
376}
377
378#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
380pub struct StagedDiff {
381 pub stat: String,
384 pub patch: String,
386 pub truncated: bool,
390 pub full_bytes: usize,
393 pub changed_paths: Vec<String>,
399}
400
401pub fn stage_and_diff(worktree: &Path, patch_cap_bytes: usize) -> Result<StagedDiff, String> {
410 git(worktree, &["add", "-A"])?;
411 let stat = git(worktree, &["diff", "--cached", "--stat"])?;
412 let patch = git(worktree, &["diff", "--cached"])?;
413 let names = git(
414 worktree,
415 &[
416 "-c",
422 "core.quotepath=false",
423 "diff",
424 "--cached",
425 "--name-status",
434 "-z",
435 ],
436 )?;
437 let changed_paths = parse_name_status_z(&names);
438 let full_bytes = patch.len();
439 Ok(StagedDiff {
440 patch: super::shell_tool::tail(&patch, patch_cap_bytes),
441 truncated: full_bytes > patch_cap_bytes,
442 full_bytes,
443 stat,
444 changed_paths,
445 })
446}
447
448fn parse_name_status_z(raw: &str) -> Vec<String> {
459 let mut out = Vec::new();
460 let mut fields = raw.split('\0').filter(|f| !f.is_empty());
461 while let Some(status) = fields.next() {
462 let bytes = status.as_bytes();
471 let well_formed = status.len() <= 4
472 && matches!(
473 bytes[0],
474 b'A' | b'C' | b'D' | b'M' | b'R' | b'T' | b'U' | b'X' | b'B'
475 )
476 && status[1..].bytes().all(|b| b.is_ascii_digit());
477 if !well_formed {
478 tracing::warn!(
479 status = %status,
480 "unexpected field in `git diff --name-status -z`; changed-path list truncated \
481 rather than risk a desynchronized parse"
482 );
483 break;
484 }
485 let two_paths = bytes[0] == b'R' || bytes[0] == b'C';
487 let Some(first) = fields.next() else {
488 tracing::warn!(status = %status, "name-status stream ended mid-entry");
489 break;
490 };
491 out.push(first.to_string());
492 if two_paths {
493 match fields.next() {
494 Some(second) => out.push(second.to_string()),
495 None => {
498 tracing::warn!(status = %status, "rename/copy entry missing its destination");
499 break;
500 }
501 }
502 }
503 }
504 out.sort();
505 out.dedup();
506 out
507}
508
509pub(crate) fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
521 let mut cmd = std::process::Command::new("git");
522 cmd.env_remove("GIT_DIR")
523 .env_remove("GIT_WORK_TREE")
524 .env_remove("GIT_INDEX_FILE")
525 .env_remove("GIT_OBJECT_DIRECTORY")
526 .env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES")
527 .env_remove("GIT_COMMON_DIR")
528 .arg("-C")
529 .arg(dir)
530 .args(args);
531 no_interactive_prompts(&mut cmd);
532 let out = run_capped(cmd).map_err(|e| match e {
533 RunFailure::Spawn(io) => format!("git {args:?}: {io}"),
534 RunFailure::TimedOut(secs) => format!(
535 "git {args:?} timed out after {secs}s and was killed; treat it as a transport failure"
536 ),
537 })?;
538 if out.status.success() {
539 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
540 } else {
541 Err(format!(
542 "git {args:?} failed: {}",
543 String::from_utf8_lossy(&out.stderr).trim()
544 ))
545 }
546}
547
548fn no_interactive_prompts(cmd: &mut std::process::Command) {
565 cmd.env("GIT_TERMINAL_PROMPT", "0")
566 .env("GIT_ASKPASS", "")
567 .env("SSH_ASKPASS", "")
568 .env("SSH_ASKPASS_REQUIRE", "never");
569}
570
571const SUBPROCESS_TIMEOUT_SECS: u64 = 900;
577
578enum RunFailure {
580 Spawn(std::io::Error),
582 TimedOut(u64),
584}
585
586fn run_capped(cmd: std::process::Command) -> Result<std::process::Output, RunFailure> {
601 run_capped_for(cmd, std::time::Duration::from_secs(SUBPROCESS_TIMEOUT_SECS))
602}
603
604fn run_capped_for(
608 mut cmd: std::process::Command,
609 timeout: std::time::Duration,
610) -> Result<std::process::Output, RunFailure> {
611 use std::io::Read as _;
612 use std::process::Stdio;
613
614 let mut child = cmd
615 .stdin(Stdio::null())
616 .stdout(Stdio::piped())
617 .stderr(Stdio::piped())
618 .spawn()
619 .map_err(RunFailure::Spawn)?;
620
621 let mut child_out = child.stdout.take().expect("stdout piped");
622 let mut child_err = child.stderr.take().expect("stderr piped");
623 let out_reader = std::thread::spawn(move || {
624 let mut buf = Vec::new();
625 let _ = child_out.read_to_end(&mut buf);
626 buf
627 });
628 let err_reader = std::thread::spawn(move || {
629 let mut buf = Vec::new();
630 let _ = child_err.read_to_end(&mut buf);
631 buf
632 });
633
634 let deadline = std::time::Instant::now() + timeout;
635 let status = loop {
636 match child.try_wait() {
637 Ok(Some(status)) => break status,
638 Ok(None) => {}
639 Err(e) => return Err(RunFailure::Spawn(e)),
640 }
641 if std::time::Instant::now() >= deadline {
642 let _ = child.kill();
643 let _ = child.wait();
644 return Err(RunFailure::TimedOut(timeout.as_secs()));
645 }
646 std::thread::sleep(std::time::Duration::from_millis(25));
647 };
648
649 Ok(std::process::Output {
650 status,
651 stdout: out_reader.join().unwrap_or_default(),
652 stderr: err_reader.join().unwrap_or_default(),
653 })
654}
655
656#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
674#[serde(rename_all = "lowercase")]
675pub enum PrAction {
676 Opened,
678 Updated,
680}
681
682impl PrAction {
683 pub fn as_str(&self) -> &'static str {
685 match self {
686 PrAction::Opened => "opened",
687 PrAction::Updated => "updated",
688 }
689 }
690}
691
692impl std::fmt::Display for PrAction {
693 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
694 f.write_str(self.as_str())
695 }
696}
697
698#[derive(Debug, Clone, Copy, PartialEq, Eq)]
704pub enum PrState {
705 Open,
707 ClosedUnmerged,
710 Merged,
712}
713
714#[derive(Debug, Clone, PartialEq)]
716pub struct PrRecord {
717 pub number: u64,
718 pub state: PrState,
719 pub url: String,
720 pub is_draft: bool,
721 pub base: String,
733}
734
735pub struct PrDelivery<'a> {
738 pub repo: &'a Path,
741 pub worktree: &'a Path,
744 pub target_branch: &'a str,
747 pub base_branch: &'a str,
749 pub draft: bool,
753 pub intent: &'a str,
755 pub contract: &'a OutcomeContract,
758 pub body: &'a str,
762 pub provenance: Option<&'a str>,
771}
772
773#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
775#[serde(rename_all = "lowercase")]
776pub enum CiState {
777 Green,
778 Pending,
779 Red,
780}
781
782#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
784pub struct CiCheck {
785 pub name: String,
786 pub state: CiState,
787}
788
789#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
791pub struct CiSummary {
792 pub head_sha: String,
795 pub state: CiState,
796 pub checks: Vec<CiCheck>,
798 #[serde(default, skip_serializing_if = "Option::is_none")]
800 pub observation_error: Option<String>,
801}
802
803impl CiSummary {
804 fn from_checks(head_sha: &str, checks: Vec<(String, CiState)>) -> Self {
805 let mut by_name = std::collections::BTreeMap::<String, CiState>::new();
809 for (name, state) in checks {
810 by_name
811 .entry(name)
812 .and_modify(|current| {
813 if ci_severity(state) > ci_severity(*current) {
814 *current = state;
815 }
816 })
817 .or_insert(state);
818 }
819
820 let checks: Vec<CiCheck> = by_name
821 .into_iter()
822 .map(|(name, state)| CiCheck { name, state })
823 .collect();
824 let state = if checks.iter().any(|check| check.state == CiState::Red) {
825 CiState::Red
826 } else if checks.is_empty() || checks.iter().any(|check| check.state == CiState::Pending) {
827 CiState::Pending
830 } else {
831 CiState::Green
832 };
833 Self {
834 head_sha: head_sha.to_string(),
835 state,
836 checks,
837 observation_error: None,
838 }
839 }
840
841 fn names_with_state(&self, state: CiState) -> Vec<String> {
842 self.checks
843 .iter()
844 .filter(|check| check.state == state)
845 .map(|check| check.name.clone())
846 .collect()
847 }
848}
849
850fn ci_severity(state: CiState) -> u8 {
851 match state {
852 CiState::Green => 0,
853 CiState::Pending => 1,
854 CiState::Red => 2,
855 }
856}
857
858#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
860pub struct PrDeliveryOutcome {
861 pub branch: String,
863 pub commit: String,
865 pub pushed: bool,
868 pub pr_number: u64,
869 pub pr_url: String,
870 pub pr_action: PrAction,
871 pub draft: bool,
876 pub ci: CiSummary,
878}
879
880impl PrDeliveryOutcome {
881 pub fn delivery_report(&self) -> String {
884 if let Some(error) = &self.ci.observation_error {
885 return format!("delivered; CI unavailable at {}: {error}", self.commit);
886 }
887 match self.ci.state {
888 CiState::Green if self.draft => format!(
889 "delivered with green checks at {}; pull request remains draft",
890 self.ci.head_sha
891 ),
892 CiState::Green => format!(
893 "delivered with green checks and ready for review at {}",
894 self.ci.head_sha
895 ),
896 CiState::Pending => {
897 let names = named_checks_or(
898 &self.ci.names_with_state(CiState::Pending),
899 "no checks reported yet",
900 );
901 format!(
902 "delivered with pending checks at {}: {names}",
903 self.ci.head_sha
904 )
905 }
906 CiState::Red => {
907 let names =
908 named_checks_or(&self.ci.names_with_state(CiState::Red), "unknown check");
909 format!("delivered red on {names} at {}", self.ci.head_sha)
910 }
911 }
912 }
913}
914
915fn named_checks_or(names: &[String], fallback: &str) -> String {
916 if names.is_empty() {
917 fallback.to_string()
918 } else {
919 names.join(", ")
920 }
921}
922
923#[derive(Debug, Clone, PartialEq)]
930pub enum DeliveryFailure {
931 Preflight { reason: String },
946 Commit { reason: String },
949 Push { reason: String, retriable: bool },
953 Pr { reason: String, retriable: bool },
961}
962
963impl DeliveryFailure {
964 pub fn stage(&self) -> &'static str {
966 match self {
967 DeliveryFailure::Preflight { .. } => "preflight",
968 DeliveryFailure::Commit { .. } => "commit",
969 DeliveryFailure::Push { .. } => "push",
970 DeliveryFailure::Pr { .. } => "pr",
971 }
972 }
973
974 pub fn retriable(&self) -> bool {
977 match self {
978 DeliveryFailure::Preflight { .. } | DeliveryFailure::Commit { .. } => false,
979 DeliveryFailure::Push { retriable, .. } | DeliveryFailure::Pr { retriable, .. } => {
980 *retriable
981 }
982 }
983 }
984
985 pub fn reason(&self) -> &str {
987 match self {
988 DeliveryFailure::Preflight { reason }
989 | DeliveryFailure::Commit { reason }
990 | DeliveryFailure::Push { reason, .. }
991 | DeliveryFailure::Pr { reason, .. } => reason,
992 }
993 }
994}
995
996impl std::fmt::Display for DeliveryFailure {
997 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
998 write!(f, "{} failed: {}", self.stage(), self.reason())
999 }
1000}
1001
1002impl std::error::Error for DeliveryFailure {}
1003
1004pub trait ForgeClient: Send + Sync {
1011 fn auth_status(&self) -> Result<(), ForgeError>;
1013 fn list_prs_for_head(&self, dir: &Path, head_branch: &str)
1015 -> Result<Vec<PrRecord>, ForgeError>;
1016 fn create_pr(
1017 &self,
1018 dir: &Path,
1019 head_branch: &str,
1020 base_branch: &str,
1021 title: &str,
1022 body: &str,
1023 draft: bool,
1024 ) -> Result<PrRecord, ForgeError>;
1025 fn set_pr_body(&self, dir: &Path, number: u64, body: &str) -> Result<(), ForgeError>;
1027 fn reopen_pr(&self, _dir: &Path, _number: u64) -> Result<(), ForgeError> {
1031 Err(ForgeError::local(
1032 "this forge client does not implement pull-request reopening",
1033 ))
1034 }
1035 fn ci_for_sha(&self, dir: &Path, number: u64, head_sha: &str) -> Result<CiSummary, ForgeError>;
1037}
1038
1039pub use ForgeClient as GitHubApi;
1042
1043#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1045pub enum ForgeKind {
1046 GitHub,
1047 AzureDevOps,
1048}
1049
1050impl ForgeKind {
1051 fn as_str(self) -> &'static str {
1052 match self {
1053 Self::GitHub => "github",
1054 Self::AzureDevOps => "azure-devops",
1055 }
1056 }
1057}
1058
1059pub const FORGE_OVERRIDE_ENV: &str = "CAR_CODER_FORGE";
1061
1062trait ForgeCommandRunner: Send + Sync {
1063 fn run(&self, dir: &Path, program: &str, args: &[String]) -> Result<String, ForgeError>;
1064}
1065
1066struct ProcessForgeCommandRunner;
1067
1068impl ForgeCommandRunner for ProcessForgeCommandRunner {
1069 fn run(&self, dir: &Path, program: &str, args: &[String]) -> Result<String, ForgeError> {
1070 run_forge_cli(dir, program, args)
1071 }
1072}
1073
1074pub struct GhCli {
1076 runner: Option<Arc<dyn ForgeCommandRunner>>,
1077}
1078
1079#[allow(non_upper_case_globals)]
1083pub const GhCli: GhCli = GhCli { runner: None };
1084
1085impl Default for GhCli {
1086 fn default() -> Self {
1087 GhCli
1088 }
1089}
1090
1091impl GhCli {
1092 fn runner(&self) -> &dyn ForgeCommandRunner {
1093 self.runner.as_deref().unwrap_or(&ProcessForgeCommandRunner)
1094 }
1095}
1096
1097pub struct AzureDevOpsCli {
1099 runner: Arc<dyn ForgeCommandRunner>,
1100 auth_dir: Option<PathBuf>,
1101}
1102
1103impl Default for AzureDevOpsCli {
1104 fn default() -> Self {
1105 Self {
1106 runner: Arc::new(ProcessForgeCommandRunner),
1107 auth_dir: None,
1108 }
1109 }
1110}
1111
1112impl AzureDevOpsCli {
1113 fn for_repo(repo: &Path) -> Self {
1114 Self {
1115 runner: Arc::new(ProcessForgeCommandRunner),
1116 auth_dir: Some(repo.to_path_buf()),
1117 }
1118 }
1119
1120 fn auth_dir(&self) -> &Path {
1121 self.auth_dir.as_deref().unwrap_or(Path::new("."))
1122 }
1123}
1124
1125#[cfg(test)]
1126impl GhCli {
1127 fn with_runner(runner: Arc<dyn ForgeCommandRunner>) -> Self {
1128 Self {
1129 runner: Some(runner),
1130 }
1131 }
1132}
1133
1134#[cfg(test)]
1135impl AzureDevOpsCli {
1136 fn with_runner(runner: Arc<dyn ForgeCommandRunner>, repo: &Path) -> Self {
1137 Self {
1138 runner,
1139 auth_dir: Some(repo.to_path_buf()),
1140 }
1141 }
1142}
1143
1144fn gh_auth_status_args() -> Vec<String> {
1147 vec!["auth".into(), "status".into()]
1148}
1149
1150fn gh_repo_args(dir: &Path) -> Vec<String> {
1167 match git(dir, &["remote", "get-url", "origin"])
1168 .ok()
1169 .and_then(|url| parse_github_repo_spec(url.trim()))
1170 {
1171 Some(spec) => vec!["--repo".into(), spec],
1172 None => Vec::new(),
1173 }
1174}
1175
1176fn parse_github_repo_spec(url: &str) -> Option<String> {
1180 let after_scheme = url.split_once("://").map(|(_, rest)| rest);
1183 let (host_part, path) = match after_scheme {
1184 Some(rest) => rest.split_once('/')?,
1185 None if url.starts_with('/') || url.starts_with('.') => return None,
1187 None => url.split_once(':')?,
1188 };
1189 let host = host_part
1190 .rsplit('@')
1191 .next()?
1192 .split(':')
1193 .next()?
1194 .to_ascii_lowercase();
1195 if host.is_empty() {
1196 return None;
1197 }
1198 let path = path
1199 .trim_matches('/')
1200 .strip_suffix(".git")
1201 .unwrap_or(path.trim_matches('/'));
1202 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
1203 let [owner, name] = segments[..] else {
1206 return None;
1207 };
1208 if host == "github.com" {
1209 Some(format!("{owner}/{name}"))
1210 } else {
1211 Some(format!("{host}/{owner}/{name}"))
1212 }
1213}
1214
1215fn gh_pr_list_args(head_branch: &str) -> Vec<String> {
1217 vec![
1218 "pr".into(),
1219 "list".into(),
1220 "--head".into(),
1221 head_branch.to_string(),
1222 "--state".into(),
1223 "all".into(),
1224 "--json".into(),
1225 "number,state,url,isDraft,isCrossRepository,baseRefName".into(),
1238 "--limit".into(),
1248 "100".into(),
1249 ]
1250}
1251
1252fn gh_pr_create_args(
1254 head_branch: &str,
1255 base_branch: &str,
1256 title: &str,
1257 body: &str,
1258 draft: bool,
1259) -> Vec<String> {
1260 let mut args = vec![
1261 "pr".into(),
1262 "create".into(),
1263 "--head".into(),
1264 head_branch.to_string(),
1265 "--base".into(),
1266 base_branch.to_string(),
1267 "--title".into(),
1268 title.to_string(),
1269 "--body".into(),
1270 body.to_string(),
1271 ];
1272 if draft {
1273 args.push("--draft".into());
1274 }
1275 args
1276}
1277
1278fn gh_pr_reopen_args(number: u64) -> Vec<String> {
1280 vec!["pr".into(), "reopen".into(), number.to_string()]
1281}
1282
1283fn gh_pr_checks_args(number: u64) -> Vec<String> {
1285 vec![
1286 "pr".into(),
1287 "view".into(),
1288 number.to_string(),
1289 "--json".into(),
1290 "headRefOid,statusCheckRollup".into(),
1291 ]
1292}
1293
1294fn az_common_tail(output: &str) -> Vec<String> {
1295 vec![
1296 "--detect".into(),
1297 "true".into(),
1298 "--output".into(),
1299 output.into(),
1300 "--only-show-errors".into(),
1301 ]
1302}
1303
1304fn az_auth_status_args() -> Vec<String> {
1305 let mut args = vec!["repos".into(), "list".into()];
1306 args.extend(az_common_tail("json"));
1307 args
1308}
1309
1310fn az_pr_list_args(head_branch: &str) -> Vec<String> {
1311 let mut args = vec![
1312 "repos".into(),
1313 "pr".into(),
1314 "list".into(),
1315 "--source-branch".into(),
1316 head_branch.into(),
1317 "--status".into(),
1318 "all".into(),
1319 "--top".into(),
1320 "100".into(),
1321 "--include-links".into(),
1322 "true".into(),
1323 ];
1324 args.extend(az_common_tail("json"));
1325 args
1326}
1327
1328fn az_pr_create_args(
1329 head_branch: &str,
1330 base_branch: &str,
1331 title: &str,
1332 body: &str,
1333 draft: bool,
1334) -> Vec<String> {
1335 let mut args = vec![
1336 "repos".into(),
1337 "pr".into(),
1338 "create".into(),
1339 "--source-branch".into(),
1340 head_branch.into(),
1341 "--target-branch".into(),
1342 base_branch.into(),
1343 "--title".into(),
1344 title.into(),
1345 "--description".into(),
1346 body.into(),
1347 "--draft".into(),
1348 draft.to_string(),
1349 ];
1350 args.extend(az_common_tail("json"));
1351 args
1352}
1353
1354fn az_pr_update_args(number: u64, body: &str) -> Vec<String> {
1355 let mut args = vec![
1356 "repos".into(),
1357 "pr".into(),
1358 "update".into(),
1359 "--id".into(),
1360 number.to_string(),
1361 "--description".into(),
1362 body.into(),
1363 ];
1364 args.extend(az_common_tail("none"));
1365 args
1366}
1367
1368fn az_pr_reopen_args(number: u64) -> Vec<String> {
1369 let mut args = vec![
1370 "repos".into(),
1371 "pr".into(),
1372 "update".into(),
1373 "--id".into(),
1374 number.to_string(),
1375 "--status".into(),
1376 "active".into(),
1377 ];
1378 args.extend(az_common_tail("none"));
1379 args
1380}
1381
1382fn az_pr_show_args(number: u64) -> Vec<String> {
1383 let mut args = vec![
1384 "repos".into(),
1385 "pr".into(),
1386 "show".into(),
1387 "--id".into(),
1388 number.to_string(),
1389 ];
1390 args.extend(az_common_tail("json"));
1391 args
1392}
1393
1394fn az_pr_policy_list_args(number: u64) -> Vec<String> {
1395 let mut args = vec![
1396 "repos".into(),
1397 "pr".into(),
1398 "policy".into(),
1399 "list".into(),
1400 "--id".into(),
1401 number.to_string(),
1402 "--top".into(),
1403 "100".into(),
1404 ];
1405 args.extend(az_common_tail("json"));
1406 args
1407}
1408
1409const FORCE_MARKER: char = '+';
1422
1423fn push_args(commit: &str, target_branch: &str) -> Vec<String> {
1431 vec![
1432 "push".into(),
1433 "origin".into(),
1434 format!("{commit}:refs/heads/{target_branch}"),
1435 ]
1436}
1437
1438#[derive(Debug, Clone)]
1443pub struct ForgeError {
1444 pub message: String,
1446 pub stderr: String,
1448}
1449
1450pub type GhError = ForgeError;
1452
1453impl std::fmt::Display for ForgeError {
1454 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1455 f.write_str(&self.message)
1456 }
1457}
1458
1459impl ForgeError {
1460 fn local(message: impl Into<String>) -> Self {
1461 let message = message.into();
1462 Self {
1463 stderr: message.clone(),
1464 message,
1465 }
1466 }
1467}
1468
1469fn run_forge_cli(dir: &Path, program: &str, args: &[String]) -> Result<String, ForgeError> {
1471 let mut cmd = std::process::Command::new(program);
1472 cmd.current_dir(dir).args(args);
1473 no_interactive_prompts(&mut cmd);
1474 if program == "gh" {
1475 cmd.env("GH_PROMPT_DISABLED", "1");
1477 }
1478 let out = run_capped(cmd).map_err(|e| match e {
1479 RunFailure::Spawn(io) if io.kind() == std::io::ErrorKind::NotFound => {
1480 if program == "gh" {
1481 ForgeError::local(
1482 "`gh` not found on PATH — install the GitHub CLI (https://cli.github.com) \
1483 and authenticate it",
1484 )
1485 } else {
1486 ForgeError::local(
1487 "`az` not found on PATH — install Azure CLI and the azure-devops extension, \
1488 then authenticate it",
1489 )
1490 }
1491 }
1492 RunFailure::Spawn(io) => ForgeError::local(format!(
1493 "failed to run `{program} {}`: {io}",
1494 gh_subcommand_shape(args)
1495 )),
1496 RunFailure::TimedOut(secs) => ForgeError::local(format!(
1497 "`{program} {}` timed out after {secs}s and was killed",
1498 gh_subcommand_shape(args)
1499 )),
1500 })?;
1501 if out.status.success() {
1502 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
1503 } else {
1504 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1505 Err(ForgeError {
1506 message: format!("{program} {} failed: {stderr}", gh_subcommand_shape(args)),
1507 stderr,
1508 })
1509 }
1510}
1511
1512pub(super) fn gh(dir: &Path, args: &[String]) -> Result<String, GhError> {
1514 run_forge_cli(dir, "gh", args)
1515}
1516
1517fn gh_subcommand_shape(args: &[String]) -> String {
1526 let mut out: Vec<String> = Vec::with_capacity(args.len());
1527 let mut elide_next = false;
1528 for arg in args {
1529 if std::mem::take(&mut elide_next) {
1530 out.push("<…>".to_string());
1531 continue;
1532 }
1533 if arg.starts_with("--") {
1534 elide_next = matches!(arg.as_str(), "--title" | "--body" | "--description");
1535 }
1536 out.push(arg.clone());
1537 }
1538 out.join(" ")
1539}
1540
1541fn parse_pr_list(raw: &str) -> Result<Vec<PrRecord>, String> {
1551 let value: serde_json::Value = serde_json::from_str(raw.trim())
1552 .map_err(|e| format!("could not parse `gh pr list` JSON: {e}"))?;
1553 let items = value
1554 .as_array()
1555 .ok_or_else(|| "`gh pr list` did not return a JSON array".to_string())?;
1556 let mut out = Vec::with_capacity(items.len());
1557 for item in items {
1558 if item
1562 .get("isCrossRepository")
1563 .and_then(|c| c.as_bool())
1564 .unwrap_or(false)
1565 {
1566 continue;
1567 }
1568 let number = item
1569 .get("number")
1570 .and_then(|n| n.as_u64())
1571 .ok_or_else(|| "pull request entry has no numeric `number`".to_string())?;
1572 let raw_state = item
1573 .get("state")
1574 .and_then(|s| s.as_str())
1575 .ok_or_else(|| "pull request entry has no `state`".to_string())?;
1576 let state = match raw_state.to_ascii_uppercase().as_str() {
1580 "OPEN" => PrState::Open,
1581 "CLOSED" => PrState::ClosedUnmerged,
1582 "MERGED" => PrState::Merged,
1583 other => return Err(format!("unrecognized pull request state `{other}`")),
1584 };
1585 out.push(PrRecord {
1586 number,
1587 state,
1588 url: item
1589 .get("url")
1590 .and_then(|u| u.as_str())
1591 .unwrap_or_default()
1592 .to_string(),
1593 is_draft: item
1594 .get("isDraft")
1595 .and_then(|d| d.as_bool())
1596 .unwrap_or(false),
1597 base: item
1603 .get("baseRefName")
1604 .and_then(|b| b.as_str())
1605 .ok_or_else(|| "pull request entry has no `baseRefName`".to_string())?
1606 .to_string(),
1607 });
1608 }
1609 Ok(out)
1610}
1611
1612fn parse_github_ci_summary(raw: &str, expected_head_sha: &str) -> Result<CiSummary, String> {
1614 let value: serde_json::Value = serde_json::from_str(raw.trim())
1615 .map_err(|e| format!("could not parse `gh pr view` CI JSON: {e}"))?;
1616 let actual_head = value
1617 .get("headRefOid")
1618 .and_then(|v| v.as_str())
1619 .ok_or_else(|| "`gh pr view` CI response has no `headRefOid`".to_string())?;
1620 if actual_head != expected_head_sha {
1621 return Err(format!(
1622 "pull request head moved while delivery was reading CI: expected {expected_head_sha}, found {actual_head}"
1623 ));
1624 }
1625
1626 let rollup = match value.get("statusCheckRollup") {
1627 None | Some(serde_json::Value::Null) => &[][..],
1628 Some(serde_json::Value::Array(items)) => items.as_slice(),
1629 Some(_) => {
1630 return Err("`gh pr view` CI response has a non-array `statusCheckRollup`".to_string())
1631 }
1632 };
1633 let mut checks = Vec::with_capacity(rollup.len());
1634 for item in rollup {
1635 let kind = item
1636 .get("__typename")
1637 .and_then(|v| v.as_str())
1638 .ok_or_else(|| "CI rollup entry has no `__typename`".to_string())?;
1639 let (name, state) = match kind {
1640 "CheckRun" => {
1641 let name = required_string(item, "name", "CI rollup entry")?;
1642 let status = item
1643 .get("status")
1644 .and_then(|v| v.as_str())
1645 .ok_or_else(|| format!("check run `{name}` has no `status`"))?;
1646 let state = if status.eq_ignore_ascii_case("COMPLETED") {
1647 let conclusion =
1648 item.get("conclusion")
1649 .and_then(|v| v.as_str())
1650 .ok_or_else(|| {
1651 format!("completed check run `{name}` has no `conclusion`")
1652 })?;
1653 match conclusion.to_ascii_uppercase().as_str() {
1654 "SUCCESS" | "NEUTRAL" | "SKIPPED" => CiState::Green,
1655 "ACTION_REQUIRED" | "CANCELLED" | "FAILURE" | "STALE"
1656 | "STARTUP_FAILURE" | "TIMED_OUT" => CiState::Red,
1657 other => {
1658 return Err(format!(
1659 "check run `{name}` has unrecognized conclusion `{other}`"
1660 ))
1661 }
1662 }
1663 } else {
1664 CiState::Pending
1667 };
1668 (name, state)
1669 }
1670 "StatusContext" => {
1671 let name = required_string(item, "context", "CI rollup entry")?;
1672 let raw_state = item
1673 .get("state")
1674 .and_then(|v| v.as_str())
1675 .ok_or_else(|| format!("status context `{name}` has no `state`"))?;
1676 let state = match raw_state.to_ascii_uppercase().as_str() {
1677 "SUCCESS" => CiState::Green,
1678 "EXPECTED" | "PENDING" => CiState::Pending,
1679 "ERROR" | "FAILURE" => CiState::Red,
1680 other => {
1681 return Err(format!(
1682 "status context `{name}` has unrecognized state `{other}`"
1683 ))
1684 }
1685 };
1686 (name, state)
1687 }
1688 other => return Err(format!("unrecognized CI rollup entry type `{other}`")),
1689 };
1690 checks.push((name, state));
1691 }
1692 Ok(CiSummary::from_checks(expected_head_sha, checks))
1693}
1694
1695fn required_string(
1696 value: &serde_json::Value,
1697 field: &str,
1698 subject: &str,
1699) -> Result<String, String> {
1700 value
1701 .get(field)
1702 .and_then(|v| v.as_str())
1703 .filter(|s| !s.trim().is_empty())
1704 .map(str::to_string)
1705 .ok_or_else(|| format!("{subject} has no non-empty `{field}`"))
1706}
1707
1708fn strip_heads_prefix(name: &str) -> String {
1709 name.strip_prefix("refs/heads/").unwrap_or(name).to_string()
1710}
1711
1712fn azure_pr_url(value: &serde_json::Value, number: u64) -> String {
1713 value
1714 .pointer("/_links/web/href")
1715 .and_then(|v| v.as_str())
1716 .or_else(|| value.get("remoteUrl").and_then(|v| v.as_str()))
1717 .map(str::to_string)
1718 .or_else(|| {
1719 value
1720 .pointer("/repository/webUrl")
1721 .and_then(|v| v.as_str())
1722 .map(|base| format!("{}/pullrequest/{number}", base.trim_end_matches('/')))
1723 })
1724 .or_else(|| {
1725 value
1726 .get("url")
1727 .and_then(|v| v.as_str())
1728 .map(str::to_string)
1729 })
1730 .unwrap_or_default()
1731}
1732
1733fn parse_azure_pr(value: &serde_json::Value) -> Result<PrRecord, String> {
1734 let number = value
1735 .get("pullRequestId")
1736 .and_then(|v| v.as_u64())
1737 .ok_or_else(|| "Azure DevOps pull request has no numeric `pullRequestId`".to_string())?;
1738 let raw_state = value
1739 .get("status")
1740 .and_then(|v| v.as_str())
1741 .ok_or_else(|| "Azure DevOps pull request has no `status`".to_string())?;
1742 let state = match raw_state.to_ascii_lowercase().as_str() {
1743 "active" => PrState::Open,
1744 "abandoned" => PrState::ClosedUnmerged,
1745 "completed" => PrState::Merged,
1746 other => {
1747 return Err(format!(
1748 "unrecognized Azure DevOps pull request status `{other}`"
1749 ))
1750 }
1751 };
1752 let target = required_string(value, "targetRefName", "Azure DevOps pull request")?;
1753 Ok(PrRecord {
1754 number,
1755 state,
1756 url: azure_pr_url(value, number),
1757 is_draft: value
1758 .get("isDraft")
1759 .and_then(|v| v.as_bool())
1760 .unwrap_or(false),
1761 base: strip_heads_prefix(&target),
1762 })
1763}
1764
1765fn parse_azure_pr_list(raw: &str) -> Result<Vec<PrRecord>, String> {
1766 let value: serde_json::Value = serde_json::from_str(raw.trim())
1767 .map_err(|e| format!("could not parse `az repos pr list` JSON: {e}"))?;
1768 let items = value
1769 .as_array()
1770 .ok_or_else(|| "`az repos pr list` did not return a JSON array".to_string())?;
1771 items.iter().map(parse_azure_pr).collect()
1772}
1773
1774fn azure_pr_head(pr_raw: &str) -> Result<String, String> {
1775 let pr: serde_json::Value = serde_json::from_str(pr_raw.trim())
1776 .map_err(|e| format!("could not parse `az repos pr show` JSON: {e}"))?;
1777 pr.pointer("/lastMergeSourceCommit/commitId")
1778 .and_then(|v| v.as_str())
1779 .map(str::to_string)
1780 .ok_or_else(|| {
1781 "`az repos pr show` response has no `lastMergeSourceCommit.commitId`".to_string()
1782 })
1783}
1784
1785fn parse_azure_ci_summary(
1786 pr_before_raw: &str,
1787 policies_raw: &str,
1788 pr_after_raw: &str,
1789 expected_head_sha: &str,
1790) -> Result<CiSummary, String> {
1791 let before_head = azure_pr_head(pr_before_raw)?;
1795 let after_head = azure_pr_head(pr_after_raw)?;
1796 if before_head != expected_head_sha || after_head != expected_head_sha {
1797 let actual = if before_head != expected_head_sha {
1798 before_head
1799 } else {
1800 after_head
1801 };
1802 return Err(format!(
1803 "pull request head moved while delivery was reading CI: expected {expected_head_sha}, found {actual}"
1804 ));
1805 }
1806
1807 let policies: serde_json::Value = serde_json::from_str(policies_raw.trim())
1808 .map_err(|e| format!("could not parse `az repos pr policy list` JSON: {e}"))?;
1809 let items = policies
1810 .as_array()
1811 .ok_or_else(|| "`az repos pr policy list` did not return a JSON array".to_string())?;
1812 let mut checks = Vec::with_capacity(items.len());
1813 for item in items {
1814 let name = item
1815 .pointer("/configuration/type/displayName")
1816 .and_then(|v| v.as_str())
1817 .or_else(|| item.pointer("/type/displayName").and_then(|v| v.as_str()))
1818 .or_else(|| item.pointer("/context/name").and_then(|v| v.as_str()))
1819 .filter(|s| !s.trim().is_empty())
1820 .map(str::to_string)
1821 .or_else(|| {
1822 item.get("evaluationId")
1823 .and_then(|v| v.as_str())
1824 .map(|id| format!("policy {id}"))
1825 })
1826 .ok_or_else(|| "Azure DevOps policy has no name or evaluation id".to_string())?;
1827 let raw_state = item
1828 .get("status")
1829 .and_then(|v| v.as_str())
1830 .ok_or_else(|| format!("Azure DevOps policy `{name}` has no `status`"))?;
1831 let state = match raw_state.to_ascii_lowercase().as_str() {
1832 "approved" | "notapplicable" => CiState::Green,
1833 "queued" | "running" => CiState::Pending,
1834 "rejected" | "broken" => CiState::Red,
1835 other => {
1836 return Err(format!(
1837 "Azure DevOps policy `{name}` has unrecognized status `{other}`"
1838 ))
1839 }
1840 };
1841 checks.push((name, state));
1842 }
1843 Ok(CiSummary::from_checks(expected_head_sha, checks))
1844}
1845
1846fn pr_number_from_url(url: &str) -> Result<u64, String> {
1849 url.trim()
1850 .rsplit('/')
1851 .find(|seg| !seg.is_empty())
1852 .and_then(|seg| seg.parse::<u64>().ok())
1853 .ok_or_else(|| format!("could not read a pull request number out of `{url}`"))
1854}
1855
1856impl ForgeClient for GhCli {
1857 fn auth_status(&self) -> Result<(), ForgeError> {
1858 self.runner()
1861 .run(Path::new("."), "gh", &gh_auth_status_args())
1862 .map(|_| ())
1863 .map_err(|e| ForgeError {
1864 message: format!(
1865 "no usable GitHub credential: `gh auth status` failed. Authenticate with \
1866 `gh auth login`, or set GH_TOKEN (or GITHUB_TOKEN) in this process's \
1867 environment. Underlying error: {e}"
1868 ),
1869 stderr: e.stderr,
1870 })
1871 }
1872
1873 fn list_prs_for_head(
1874 &self,
1875 dir: &Path,
1876 head_branch: &str,
1877 ) -> Result<Vec<PrRecord>, ForgeError> {
1878 let mut args = gh_repo_args(dir);
1879 args.extend(gh_pr_list_args(head_branch));
1880 parse_pr_list(&self.runner().run(dir, "gh", &args)?).map_err(ForgeError::local)
1881 }
1882
1883 fn create_pr(
1884 &self,
1885 dir: &Path,
1886 head_branch: &str,
1887 base_branch: &str,
1888 title: &str,
1889 body: &str,
1890 draft: bool,
1891 ) -> Result<PrRecord, ForgeError> {
1892 let mut args = gh_repo_args(dir);
1893 args.extend(gh_pr_create_args(
1894 head_branch,
1895 base_branch,
1896 title,
1897 body,
1898 draft,
1899 ));
1900 let url = self.runner().run(dir, "gh", &args)?;
1901 Ok(PrRecord {
1902 number: pr_number_from_url(&url).map_err(ForgeError::local)?,
1903 state: PrState::Open,
1904 url: url.trim().to_string(),
1905 is_draft: draft,
1906 base: base_branch.to_string(),
1907 })
1908 }
1909
1910 fn set_pr_body(&self, dir: &Path, number: u64, body: &str) -> Result<(), ForgeError> {
1911 let mut args = gh_repo_args(dir);
1912 args.extend([
1913 "pr".to_string(),
1914 "edit".to_string(),
1915 number.to_string(),
1916 "--body".to_string(),
1917 body.to_string(),
1918 ]);
1919 self.runner().run(dir, "gh", &args).map(|_| ())
1920 }
1921
1922 fn reopen_pr(&self, dir: &Path, number: u64) -> Result<(), ForgeError> {
1923 let mut args = gh_repo_args(dir);
1924 args.extend(gh_pr_reopen_args(number));
1925 self.runner().run(dir, "gh", &args).map(|_| ())
1926 }
1927
1928 fn ci_for_sha(&self, dir: &Path, number: u64, head_sha: &str) -> Result<CiSummary, ForgeError> {
1929 let mut args = gh_repo_args(dir);
1930 args.extend(gh_pr_checks_args(number));
1931 let raw = self.runner().run(dir, "gh", &args)?;
1932 parse_github_ci_summary(&raw, head_sha).map_err(ForgeError::local)
1933 }
1934}
1935
1936impl ForgeClient for AzureDevOpsCli {
1937 fn auth_status(&self) -> Result<(), ForgeError> {
1938 self.runner
1939 .run(self.auth_dir(), "az", &az_auth_status_args())
1940 .map(|_| ())
1941 .map_err(|e| ForgeError {
1942 message: format!(
1943 "no usable Azure DevOps credential: `az repos list` failed. Install the \
1944 azure-devops extension and authenticate with `az login` or \
1945 AZURE_DEVOPS_EXT_PAT. Underlying error: {e}"
1946 ),
1947 stderr: e.stderr,
1948 })
1949 }
1950
1951 fn list_prs_for_head(
1952 &self,
1953 dir: &Path,
1954 head_branch: &str,
1955 ) -> Result<Vec<PrRecord>, ForgeError> {
1956 let raw = self.runner.run(dir, "az", &az_pr_list_args(head_branch))?;
1957 parse_azure_pr_list(&raw).map_err(ForgeError::local)
1958 }
1959
1960 fn create_pr(
1961 &self,
1962 dir: &Path,
1963 head_branch: &str,
1964 base_branch: &str,
1965 title: &str,
1966 body: &str,
1967 draft: bool,
1968 ) -> Result<PrRecord, ForgeError> {
1969 let raw = self.runner.run(
1970 dir,
1971 "az",
1972 &az_pr_create_args(head_branch, base_branch, title, body, draft),
1973 )?;
1974 let value: serde_json::Value = serde_json::from_str(raw.trim()).map_err(|e| {
1975 ForgeError::local(format!("could not parse `az repos pr create` JSON: {e}"))
1976 })?;
1977 parse_azure_pr(&value).map_err(ForgeError::local)
1978 }
1979
1980 fn set_pr_body(&self, dir: &Path, number: u64, body: &str) -> Result<(), ForgeError> {
1981 self.runner
1982 .run(dir, "az", &az_pr_update_args(number, body))
1983 .map(|_| ())
1984 }
1985
1986 fn reopen_pr(&self, dir: &Path, number: u64) -> Result<(), ForgeError> {
1987 self.runner
1988 .run(dir, "az", &az_pr_reopen_args(number))
1989 .map(|_| ())
1990 }
1991
1992 fn ci_for_sha(&self, dir: &Path, number: u64, head_sha: &str) -> Result<CiSummary, ForgeError> {
1993 let before = self.runner.run(dir, "az", &az_pr_show_args(number))?;
1994 let policies = self
1995 .runner
1996 .run(dir, "az", &az_pr_policy_list_args(number))?;
1997 let after = self.runner.run(dir, "az", &az_pr_show_args(number))?;
1998 parse_azure_ci_summary(&before, &policies, &after, head_sha).map_err(ForgeError::local)
1999 }
2000}
2001
2002fn remote_host(remote_url: &str) -> Option<String> {
2003 let raw = remote_url.trim();
2004 let authority = if let Some((_, rest)) = raw.split_once("://") {
2005 rest.split('/').next()?
2006 } else {
2007 if raw.starts_with('/') || raw.starts_with('.') {
2008 return None;
2009 }
2010 let (left, _) = raw.split_once(':')?;
2011 if left.len() == 1 {
2013 return None;
2014 }
2015 left
2016 };
2017 authority
2018 .rsplit('@')
2019 .next()?
2020 .split(':')
2021 .next()
2022 .filter(|host| !host.is_empty())
2023 .map(str::to_ascii_lowercase)
2024}
2025
2026fn forge_kind_from_remote(
2027 remote_url: &str,
2028 override_value: Option<&str>,
2029) -> Result<ForgeKind, String> {
2030 if let Some(value) = override_value {
2031 return match value.trim().to_ascii_lowercase().as_str() {
2032 "github" | "gh" => Ok(ForgeKind::GitHub),
2033 "azure-devops" | "azure_devops" | "azdo" => Ok(ForgeKind::AzureDevOps),
2034 other => Err(format!(
2035 "unsupported {FORGE_OVERRIDE_ENV} value `{other}`; use `github` or `azure-devops`"
2036 )),
2037 };
2038 }
2039
2040 let host = remote_host(remote_url).ok_or_else(|| {
2041 format!(
2042 "cannot identify a pull-request forge from origin `{remote_url}`; set \
2043 {FORGE_OVERRIDE_ENV}=github or {FORGE_OVERRIDE_ENV}=azure-devops"
2044 )
2045 })?;
2046 if matches!(host.as_str(), "github.com" | "ssh.github.com") {
2047 Ok(ForgeKind::GitHub)
2048 } else if matches!(host.as_str(), "dev.azure.com" | "ssh.dev.azure.com")
2049 || host.ends_with(".visualstudio.com")
2050 {
2051 Ok(ForgeKind::AzureDevOps)
2052 } else {
2053 Err(format!(
2054 "cannot identify a pull-request forge for origin host `{host}`; set \
2055 {FORGE_OVERRIDE_ENV}=github or {FORGE_OVERRIDE_ENV}=azure-devops"
2056 ))
2057 }
2058}
2059
2060fn selected_forge(repo: &Path) -> Result<Box<dyn ForgeClient>, String> {
2061 let remote_url = git(repo, &["remote", "get-url", "origin"])
2062 .map_err(|e| format!("cannot read origin remote for forge selection: {e}"))?;
2063 let override_value = match std::env::var(FORGE_OVERRIDE_ENV) {
2064 Ok(value) => Some(value),
2065 Err(std::env::VarError::NotPresent) => None,
2066 Err(std::env::VarError::NotUnicode(_)) => {
2067 return Err(format!(
2068 "{FORGE_OVERRIDE_ENV} is not valid UTF-8; use `github` or `azure-devops`"
2069 ))
2070 }
2071 };
2072 let kind = forge_kind_from_remote(&remote_url, override_value.as_deref())?;
2073 tracing::debug!(forge = kind.as_str(), remote = %remote_url.trim(), "selected PR forge");
2074 Ok(match kind {
2075 ForgeKind::GitHub => Box::new(GhCli::default()),
2076 ForgeKind::AzureDevOps => Box::new(AzureDevOpsCli::for_repo(repo)),
2077 })
2078}
2079
2080pub fn validate_branch_name(label: &str, name: &str) -> Result<(), String> {
2097 if name.is_empty() {
2098 return Err(format!("{label} is empty"));
2099 }
2100 if name.starts_with('-') {
2101 return Err(format!(
2102 "{label} `{name}` starts with '-', which git and gh would read as a flag"
2103 ));
2104 }
2105 if name.starts_with(FORCE_MARKER) {
2106 return Err(format!(
2107 "{label} `{name}` starts with `{FORCE_MARKER}`, git's force marker in a refspec"
2108 ));
2109 }
2110 if let Some(bad) = name
2111 .chars()
2112 .find(|c| c.is_whitespace() || c.is_control() || "~^:?*[]\\".contains(*c))
2113 {
2114 return Err(format!(
2115 "{label} `{name}` contains `{bad}`, which is not legal in a git ref name"
2116 ));
2117 }
2118 if name.contains("..")
2124 || name.ends_with('/')
2125 || name.starts_with('/')
2126 || name.ends_with(".lock")
2127 || name.split('/').any(|c| c.ends_with(".lock"))
2131 || name.ends_with('.')
2132 || name.contains("//")
2133 || name.contains("@{")
2134 || name.split('/').any(|c| c.is_empty() || c.starts_with('.'))
2135 {
2136 return Err(format!("{label} `{name}` is not a legal git ref name"));
2137 }
2138 Ok(())
2139}
2140
2141fn classify_push_error(err: &str) -> (String, bool) {
2169 let low = err.to_ascii_lowercase();
2170
2171 let refused = low.contains("permission denied")
2172 || (low.contains("permission to") && low.contains("denied"))
2173 || low.contains("returned error: 403")
2181 || low.contains("status code 403")
2182 || low.contains("http 403")
2183 || low.contains("error 403")
2184 || low.contains("authentication failed")
2185 || low.contains("could not read username")
2199 || low.contains("could not read password")
2200 || low.contains("terminal prompts disabled")
2205 || low.contains("host key verification failed")
2209 || low.contains("returned error: 401")
2213 || low.contains("status code 401")
2214 || low.contains("http 401")
2215 || low.contains("error 401")
2216 || low.contains("repository not found")
2226 || low.contains("does not appear to be a git repository")
2231 || low.contains("pre-receive hook declined")
2234 || low.contains("protected branch")
2235 || low.contains("refusing to allow")
2240 || low.contains("workflow' scope")
2241 || low.contains("shallow update not allowed")
2242 || low.contains("file size limit")
2243 || low.contains("exists; cannot create")
2251 || low.contains("push declined")
2255 || mentions_github_policy_code(&low);
2256 if refused {
2257 return (
2258 format!("push refused for credential/permission reasons: {err}"),
2259 false,
2260 );
2261 }
2262
2263 let moved = low.contains("non-fast-forward")
2264 || low.contains("fetch first")
2265 || low.contains("[rejected]")
2266 || low.contains("remote rejected")
2267 || low.contains("cannot lock ref")
2268 || low.contains("failed to update ref");
2269 if moved {
2270 return (
2271 format!(
2272 "non-fast-forward: the target branch moved since this worktree was cut \
2273 (lost a push race) — {err}"
2274 ),
2275 true,
2276 );
2277 }
2278
2279 (err.to_string(), true)
2282}
2283
2284fn mentions_github_policy_code(low: &str) -> bool {
2303 let bytes = low.as_bytes();
2304 bytes.windows(6).enumerate().any(|(i, w)| {
2305 w[0] == b'g'
2306 && w[1] == b'h'
2307 && w[2..5].iter().all(u8::is_ascii_digit)
2308 && w[5] == b':'
2309 && (i == 0 || !bytes[i - 1].is_ascii_alphanumeric())
2310 })
2311}
2312
2313fn classify_pr_error(err: &str) -> (String, bool) {
2328 let low = err.to_ascii_lowercase();
2329 let permanent = low.contains("no commits between")
2340 || low.contains("draft pull requests are not supported")
2341 || low.contains("must be a collaborator")
2342 || low.contains("no such remote")
2343 || low.contains("could not resolve to a repository");
2344 (err.to_string(), !permanent)
2345}
2346
2347fn pr_failure(e: GhError) -> DeliveryFailure {
2354 let (_, retriable) = classify_pr_error(&e.stderr);
2355 DeliveryFailure::Pr {
2356 reason: e.message,
2357 retriable,
2358 }
2359}
2360
2361pub fn ambiguous_head_refusal(
2379 prs: &[PrRecord],
2380 target_branch: &str,
2381 base_branch: &str,
2382) -> Option<String> {
2383 let foreign_open: Vec<&PrRecord> = prs
2384 .iter()
2385 .filter(|p| p.state == PrState::Open && p.base != base_branch)
2386 .collect();
2387 if foreign_open.is_empty() {
2388 return None;
2389 }
2390 let described = foreign_open
2391 .iter()
2392 .map(|p| format!("#{} into `{}`", p.number, p.base))
2393 .collect::<Vec<_>>()
2394 .join(", ");
2395 let numbers = foreign_open
2396 .iter()
2397 .map(|p| format!("#{}", p.number))
2398 .collect::<Vec<_>>()
2399 .join(", ");
2400 let plural = if foreign_open.len() == 1 { "" } else { "s" };
2401 Some(format!(
2402 "branch `{target_branch}` already has open pull request{plural} {described} — not into \
2403 `{base_branch}`, this run's base. Pushing this round's commits to `{target_branch}` \
2404 would add them to {numbers} as well, because a pull request tracks its head branch. \
2405 Close {numbers}, or deliver to a different --target-branch"
2406 ))
2407}
2408
2409pub fn closed_pr_refusal(
2444 prs: &[PrRecord],
2445 target_branch: &str,
2446 base_branch: &str,
2447) -> Option<String> {
2448 if prs
2451 .iter()
2452 .any(|p| p.state == PrState::Open && p.base == base_branch)
2453 {
2454 return None;
2455 }
2456 let closed: Vec<&PrRecord> = prs
2457 .iter()
2458 .filter(|p| p.state == PrState::ClosedUnmerged && p.base == base_branch)
2459 .collect();
2460 if closed.is_empty() {
2461 return None;
2462 }
2463 let numbers = closed
2464 .iter()
2465 .map(|p| format!("#{}", p.number))
2466 .collect::<Vec<_>>()
2467 .join(", ");
2468 let plural = if closed.len() == 1 { "" } else { "s" };
2469 let was = if closed.len() == 1 { "was" } else { "were" };
2470 Some(format!(
2471 "pull request{plural} {numbers} from `{target_branch}` into `{base_branch}` {was} \
2472 closed — the runtime does not reopen a pull request it did not close. Reopen {numbers} \
2473 yourself to continue on this branch, or deliver to a different --target-branch"
2474 ))
2475}
2476
2477pub fn delivery_head_refusal(
2492 prs: &[PrRecord],
2493 target_branch: &str,
2494 base_branch: &str,
2495) -> Option<String> {
2496 ambiguous_head_refusal(prs, target_branch, base_branch)
2497 .or_else(|| closed_pr_refusal(prs, target_branch, base_branch))
2498}
2499
2500pub fn deliver_pr(d: PrDelivery<'_>) -> Result<PrDeliveryOutcome, DeliveryFailure> {
2585 let forge = selected_forge(d.repo).map_err(|reason| DeliveryFailure::Preflight { reason })?;
2586 deliver_pr_with(d, forge.as_ref())
2587}
2588
2589pub fn deliver_pr_with(
2592 d: PrDelivery<'_>,
2593 forge: &dyn ForgeClient,
2594) -> Result<PrDeliveryOutcome, DeliveryFailure> {
2595 validate_branch_name("target branch", d.target_branch)
2597 .map_err(|reason| DeliveryFailure::Preflight { reason })?;
2598 validate_branch_name("base branch", d.base_branch)
2599 .map_err(|reason| DeliveryFailure::Preflight { reason })?;
2600 if d.target_branch == d.base_branch {
2610 return Err(DeliveryFailure::Preflight {
2611 reason: format!(
2612 "target branch and base branch are both `{}`; delivering would push \
2613 unreviewed work directly onto the base instead of opening a pull request",
2614 d.target_branch
2615 ),
2616 });
2617 }
2618 forge
2619 .auth_status()
2620 .map_err(|e| DeliveryFailure::Preflight {
2621 reason: e.to_string(),
2622 })?;
2623
2624 let listed = forge
2650 .list_prs_for_head(d.repo, d.target_branch)
2651 .map_err(pr_failure)?;
2652 if let Some(reason) = delivery_head_refusal(&listed, d.target_branch, d.base_branch) {
2653 return Err(DeliveryFailure::Preflight { reason });
2654 }
2655
2656 let commit = match commit_worktree(d.worktree, d.intent, d.contract, d.provenance) {
2658 Ok(CommitOutcome::Made(c)) => c,
2659 Ok(CommitOutcome::NothingToCommit) => head_beyond_base(d.worktree, d.base_branch)
2660 .map_err(|reason| DeliveryFailure::Commit { reason })?,
2661 Err(reason) => return Err(DeliveryFailure::Commit { reason }),
2662 };
2663
2664 let args = push_args(&commit, d.target_branch);
2666 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
2667 if let Err(e) = git(d.worktree, &arg_refs) {
2668 let (reason, retriable) = classify_push_error(&e);
2669 return Err(DeliveryFailure::Push { reason, retriable });
2670 }
2671
2672 let existing: Vec<&PrRecord> = listed.iter().filter(|p| p.base == d.base_branch).collect();
2690
2691 let open = existing
2698 .iter()
2699 .filter(|p| p.state == PrState::Open)
2700 .max_by_key(|p| p.number);
2701
2702 let (record, action) = if let Some(pr) = open {
2703 forge
2706 .set_pr_body(d.repo, pr.number, d.body)
2707 .map_err(pr_failure)?;
2708 ((*pr).clone(), PrAction::Updated)
2709 } else {
2710 let created = forge
2713 .create_pr(
2714 d.repo,
2715 d.target_branch,
2716 d.base_branch,
2717 &subject_from_intent(d.intent),
2718 d.body,
2719 d.draft,
2720 )
2721 .map_err(pr_failure)?;
2722 (created, PrAction::Opened)
2723 };
2724
2725 let ci = forge
2732 .ci_for_sha(d.repo, record.number, &commit)
2733 .unwrap_or_else(|error| {
2734 let mut ci = CiSummary::from_checks(&commit, Vec::new());
2735 ci.observation_error = Some(error.message);
2736 ci
2737 });
2738
2739 Ok(PrDeliveryOutcome {
2740 branch: d.target_branch.to_string(),
2741 commit,
2742 pushed: true,
2743 pr_number: record.number,
2744 pr_url: record.url,
2745 pr_action: action,
2746 draft: record.is_draft,
2747 ci,
2748 })
2749}
2750
2751#[cfg(test)]
2752mod tests {
2753 use super::*;
2754 use crate::coder::contract::ContractCheck;
2755
2756 fn contract() -> OutcomeContract {
2757 OutcomeContract {
2758 description: "x exists".into(),
2759 checks: vec![ContractCheck {
2760 name: "exists".into(),
2761 command: "test -f x.txt".into(),
2762 expect_exit_zero: true,
2763 output_contains: None,
2764 timeout_secs: 10,
2765 baseline: false,
2766 differential: None,
2767 }],
2768 }
2769 }
2770
2771 fn placement(subtask: &str, worker: Option<&str>, remote: bool) -> car_multi::Placement {
2772 car_multi::Placement {
2773 subtask_id: subtask.to_string(),
2774 worker_id: worker.map(str::to_string),
2775 remote,
2776 attempts: Vec::new(),
2777 }
2778 }
2779
2780 fn landed(subtask: &str, files: &[&str]) -> IntegratedSubtask {
2781 IntegratedSubtask {
2782 subtask_id: subtask.to_string(),
2783 files: files.iter().map(|f| f.to_string()).collect(),
2784 }
2785 }
2786
2787 #[test]
2788 fn a_local_run_renders_no_trailers() {
2789 assert_eq!(placement_provenance(&[], &[], false), None);
2790 assert_eq!(
2794 placement_provenance(
2795 &[placement("s1", Some("this-host"), false)],
2796 &[landed("s1", &["a.rs"])],
2797 false
2798 ),
2799 None
2800 );
2801 }
2802
2803 #[test]
2809 fn workers_that_ran_but_whose_patches_never_landed_are_not_credited() {
2810 let ran_everywhere = [
2811 placement("s1", Some("studio"), true),
2812 placement("s2", Some("laptop"), true),
2813 ];
2814 assert_eq!(
2815 placement_provenance(&ran_everywhere, &[], false),
2816 None,
2817 "nothing was integrated, so nothing may be claimed"
2818 );
2819
2820 let rendered =
2822 placement_provenance(&ran_everywhere, &[landed("s1", &["a.rs"])], false).unwrap();
2823 assert!(rendered.contains("worker=studio"), "{rendered}");
2824 assert!(
2825 !rendered.contains("laptop"),
2826 "a rejected patch's worker must not appear: {rendered}"
2827 );
2828 }
2829
2830 #[test]
2831 fn a_locally_repaired_union_says_so_rather_than_crediting_the_fleet_alone() {
2832 let rendered = placement_provenance(
2833 &[placement("s1", Some("studio"), true)],
2834 &[landed("s1", &["a.rs"])],
2835 true,
2836 )
2837 .unwrap();
2838 assert!(rendered.contains("repaired-locally=true"), "{rendered}");
2839 }
2840
2841 #[test]
2842 fn trailers_name_the_machine_and_the_files_it_wrote() {
2843 let rendered = placement_provenance(
2844 &[
2845 placement("s1", Some("studio"), true),
2846 placement("s2", Some("this-host"), false),
2847 ],
2848 &[landed("s1", &["src/a.rs", "src/b.rs"]), landed("s2", &[])],
2849 false,
2850 )
2851 .expect("a distributed run renders");
2852
2853 assert!(
2856 rendered.contains("CAR-Placement: subtask=s1 worker=studio remote=true"),
2857 "{rendered}"
2858 );
2859 assert!(rendered.contains("files=src/a.rs,src/b.rs"), "{rendered}");
2862 assert!(
2863 rendered.contains("CAR-Placement: subtask=s2 worker=this-host remote=false"),
2864 "{rendered}"
2865 );
2866 assert!(rendered.lines().all(|l| l.starts_with("CAR-Placement:")));
2867 }
2868
2869 #[test]
2874 fn nothing_from_a_peer_can_forge_a_trailer_or_break_the_commit() {
2875 let hostile = "studio\n\nSigned-off-by: Someone <x@y.z>";
2876 let rendered = placement_provenance(
2877 &[placement("s1", Some(hostile), true)],
2878 &[landed("s1", &["a.rs"])],
2879 false,
2880 )
2881 .unwrap();
2882 assert!(!rendered.contains('\n') || rendered.lines().count() == 1);
2883 assert!(
2884 !rendered.contains("Signed-off-by:\n") && rendered.lines().count() == 1,
2885 "a peer must not be able to add a paragraph: {rendered}"
2886 );
2887
2888 let rendered = placement_provenance(
2890 &[placement("s\u{0}1", Some("a\u{0}b"), true)],
2891 &[landed("s\u{0}1", &["x.rs"])],
2892 false,
2893 )
2894 .unwrap();
2895 assert!(!rendered.contains('\u{0}'), "{rendered}");
2896
2897 let long = "w".repeat(100_000);
2899 let rendered = placement_provenance(
2900 &[placement("s1", Some(&long), true)],
2901 &[landed("s1", &["x.rs"])],
2902 false,
2903 )
2904 .unwrap();
2905 assert!(rendered.len() < 400, "len {}", rendered.len());
2906 }
2907
2908 #[test]
2912 fn peer_failure_prose_never_reaches_the_commit() {
2913 let mut p = placement("s1", Some("studio"), true);
2914 p.attempts = vec![car_multi::FailedAttempt {
2915 worker_id: "laptop".into(),
2916 error: "SECRET-STDERR-abcdef".into(),
2917 }];
2918 let rendered = placement_provenance(&[p], &[landed("s1", &["a.rs"])], false).unwrap();
2919 assert!(!rendered.contains("SECRET-STDERR"), "{rendered}");
2920 }
2921
2922 #[test]
2923 fn a_distributed_deliverys_commit_body_says_where_each_subtask_ran() {
2924 let repo_dir = tempfile::tempdir().unwrap();
2925 let repo = repo_dir.path();
2926 init_repo(repo);
2927 let ws = tempfile::tempdir().unwrap();
2928 git(
2929 repo,
2930 &["worktree", "add", "--detach", &ws.path().to_string_lossy()],
2931 )
2932 .unwrap();
2933 std::fs::write(ws.path().join("x.txt"), "made across the fleet").unwrap();
2934
2935 let provenance = placement_provenance(
2936 &[placement("s1", Some("studio"), true)],
2937 &[landed("s1", &["x.txt"])],
2938 false,
2939 )
2940 .unwrap();
2941 let branch = publish_branch(
2942 repo,
2943 ws.path(),
2944 "fleet0001",
2945 "spread this out",
2946 &contract(),
2947 Some(&provenance),
2948 )
2949 .unwrap();
2950
2951 let message = git(repo, &["log", "-1", "--format=%B", &branch]).unwrap();
2955 assert!(message.contains("Authored by CAR Coder."), "{message}");
2956 assert!(
2957 message.contains("CAR-Placement: subtask=s1 worker=studio remote=true"),
2958 "{message}"
2959 );
2960 assert!(message.contains("Outcome contract"), "{message}");
2962
2963 let trailers = git(
2966 repo,
2967 &[
2968 "log",
2969 "-1",
2970 "--format=%(trailers:key=CAR-Placement,valueonly)",
2971 &branch,
2972 ],
2973 )
2974 .unwrap();
2975 assert!(trailers.contains("subtask=s1 worker=studio"), "{trailers}");
2976 }
2977
2978 #[test]
2982 fn a_local_deliverys_commit_body_is_unchanged() {
2983 let repo_dir = tempfile::tempdir().unwrap();
2984 let repo = repo_dir.path();
2985 init_repo(repo);
2986 let ws = tempfile::tempdir().unwrap();
2987 git(
2988 repo,
2989 &["worktree", "add", "--detach", &ws.path().to_string_lossy()],
2990 )
2991 .unwrap();
2992 std::fs::write(ws.path().join("x.txt"), "made here").unwrap();
2993
2994 let branch =
2995 publish_branch(repo, ws.path(), "local001", "do it", &contract(), None).unwrap();
2996 let message = git(repo, &["log", "-1", "--format=%B", &branch]).unwrap();
2997 assert!(message.contains("Authored by CAR Coder."), "{message}");
2998 assert!(
2999 !message.contains("CAR-Placement"),
3000 "a local run claims nothing about a fleet: {message}"
3001 );
3002 }
3003
3004 fn init_repo(dir: &Path) {
3005 for args in [
3006 vec!["init", "-q", "-b", "main"],
3007 vec![
3008 "-c",
3009 "user.name=t",
3010 "-c",
3011 "user.email=t@t",
3012 "commit",
3013 "-q",
3014 "--allow-empty",
3015 "-m",
3016 "init",
3017 ],
3018 ] {
3019 let out = std::process::Command::new("git")
3020 .arg("-C")
3021 .arg(dir)
3022 .args(&args)
3023 .output()
3024 .unwrap();
3025 assert!(
3026 out.status.success(),
3027 "{}",
3028 String::from_utf8_lossy(&out.stderr)
3029 );
3030 }
3031 }
3032
3033 #[test]
3034 fn publishes_branch_without_touching_user_checkout() {
3035 let repo_dir = tempfile::tempdir().unwrap();
3036 let repo = repo_dir.path();
3037 init_repo(repo);
3038
3039 let wt_base = tempfile::tempdir().unwrap();
3041 let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
3042 let ws = car_multi::AgentWorkspace::provision(&config, "coder-merge-test").unwrap();
3043
3044 std::fs::write(ws.path().join("x.txt"), "made by coder").unwrap();
3045 let branch = publish_branch(
3046 repo,
3047 ws.path(),
3048 "abc12345",
3049 "create x.txt with content",
3050 &contract(),
3051 None,
3052 )
3053 .unwrap();
3054 assert_eq!(branch, "car/coder/abc12345");
3055
3056 let show = git(repo, &["show", &format!("{branch}:x.txt")]).unwrap();
3058 assert_eq!(show, "made by coder");
3059 let author = git(repo, &["log", "-1", "--format=%an", &branch]).unwrap();
3061 assert_eq!(author.trim(), "car-coder");
3062 let status = git(repo, &["status", "--porcelain"]).unwrap();
3064 assert!(status.is_empty(), "user checkout dirtied: {status}");
3065 assert!(!repo.join("x.txt").exists());
3066 }
3067
3068 #[test]
3069 fn clean_worktree_refuses_to_publish() {
3070 let repo_dir = tempfile::tempdir().unwrap();
3071 init_repo(repo_dir.path());
3072 let wt_base = tempfile::tempdir().unwrap();
3073 let config = car_multi::WorkspaceConfig::git_worktree_at(repo_dir.path(), wt_base.path());
3074 let ws = car_multi::AgentWorkspace::provision(&config, "coder-clean-test").unwrap();
3075
3076 let err = publish_branch(repo_dir.path(), ws.path(), "def", "noop", &contract(), None)
3077 .unwrap_err();
3078 assert!(err.contains("no changes"), "{err}");
3079 }
3080
3081 #[test]
3082 fn long_intent_is_truncated_in_subject() {
3083 let repo_dir = tempfile::tempdir().unwrap();
3084 let repo = repo_dir.path();
3085 init_repo(repo);
3086 let wt_base = tempfile::tempdir().unwrap();
3087 let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
3088 let ws = car_multi::AgentWorkspace::provision(&config, "coder-long-test").unwrap();
3089 std::fs::write(ws.path().join("y.txt"), "y").unwrap();
3090
3091 let long_intent = "a very ".repeat(40) + "long intent";
3092 let branch =
3093 publish_branch(repo, ws.path(), "fff", &long_intent, &contract(), None).unwrap();
3094 let subject = git(repo, &["log", "-1", "--format=%s", &branch]).unwrap();
3095 assert!(subject.trim().len() <= 72);
3096 assert!(subject.contains("..."));
3097 }
3098
3099 #[test]
3100 fn commit_to_main_fast_forwards_the_checkout() {
3101 let repo_dir = tempfile::tempdir().unwrap();
3102 let repo = repo_dir.path();
3103 init_repo(repo);
3104 let wt_base = tempfile::tempdir().unwrap();
3105 let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
3106 let ws = car_multi::AgentWorkspace::provision(&config, "coder-main-test").unwrap();
3107 std::fs::write(ws.path().join("z.txt"), "managed").unwrap();
3108
3109 let commit = commit_to_main(repo, ws.path(), "add z", &contract(), None).unwrap();
3110 let head = git(repo, &["rev-parse", "HEAD"]).unwrap();
3112 assert_eq!(head.trim(), commit);
3113 assert_eq!(
3114 std::fs::read_to_string(repo.join("z.txt")).unwrap(),
3115 "managed"
3116 );
3117 assert!(git(repo, &["branch", "--list", "car/coder/*"])
3119 .unwrap()
3120 .is_empty());
3121 }
3122
3123 #[test]
3124 fn commit_to_main_errors_when_main_moved() {
3125 let repo_dir = tempfile::tempdir().unwrap();
3126 let repo = repo_dir.path();
3127 init_repo(repo);
3128 let wt_base = tempfile::tempdir().unwrap();
3129 let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
3130 let ws = car_multi::AgentWorkspace::provision(&config, "coder-moved-test").unwrap();
3131 std::fs::write(ws.path().join("a.txt"), "from session").unwrap();
3132
3133 std::fs::write(repo.join("b.txt"), "concurrent").unwrap();
3136 for args in [
3137 vec!["-c", "user.name=t", "-c", "user.email=t@t", "add", "-A"],
3138 vec![
3139 "-c",
3140 "user.name=t",
3141 "-c",
3142 "user.email=t@t",
3143 "commit",
3144 "-q",
3145 "-m",
3146 "concurrent",
3147 ],
3148 ] {
3149 assert!(std::process::Command::new("git")
3150 .arg("-C")
3151 .arg(repo)
3152 .args(&args)
3153 .output()
3154 .unwrap()
3155 .status
3156 .success());
3157 }
3158
3159 let err = commit_to_main(repo, ws.path(), "add a", &contract(), None).unwrap_err();
3160 assert!(err.contains("fast-forward"), "{err}");
3161 }
3162
3163 #[test]
3171 fn a_rename_reports_both_endpoints_not_just_the_destination() {
3172 let paths = parse_name_status_z("R100\0secrets/key.txt\0public_key.txt\0");
3173 assert!(
3174 paths.contains(&"secrets/key.txt".to_string()),
3175 "the source directory must not vanish: {paths:?}"
3176 );
3177 assert!(paths.contains(&"public_key.txt".to_string()), "{paths:?}");
3178 assert_eq!(paths.len(), 2);
3179 }
3180
3181 #[test]
3183 fn a_copy_also_reports_both_endpoints() {
3184 let paths = parse_name_status_z("C75\0src/a.rs\0src/b.rs\0");
3185 assert_eq!(paths, vec!["src/a.rs".to_string(), "src/b.rs".to_string()]);
3186 }
3187
3188 #[test]
3191 fn mixed_entries_stay_in_sync_after_a_rename() {
3192 let paths = parse_name_status_z("M\0src/a.rs\0R100\0old/x.rs\0new/x.rs\0A\0src/z.rs\0");
3193 assert_eq!(
3194 paths,
3195 vec![
3196 "new/x.rs".to_string(),
3197 "old/x.rs".to_string(),
3198 "src/a.rs".to_string(),
3199 "src/z.rs".to_string(),
3200 ]
3201 );
3202 }
3203
3204 #[test]
3207 fn a_newline_in_a_filename_does_not_forge_an_entry() {
3208 let paths = parse_name_status_z("A\0we\nird.txt\0");
3209 assert_eq!(paths, vec!["we\nird.txt".to_string()]);
3210 }
3211
3212 #[test]
3213 fn an_empty_diff_yields_no_paths() {
3214 assert!(parse_name_status_z("").is_empty());
3215 }
3216
3217 #[test]
3221 fn type_change_and_unmerged_are_single_path_entries() {
3222 assert_eq!(
3223 parse_name_status_z("T\0src/link.txt\0M\0src/after.rs\0"),
3224 vec!["src/after.rs".to_string(), "src/link.txt".to_string()]
3225 );
3226 assert_eq!(
3227 parse_name_status_z("U\0conflict.txt\0"),
3228 vec!["conflict.txt".to_string()]
3229 );
3230 }
3231
3232 #[test]
3236 fn an_unrecognized_status_bails_instead_of_desynchronizing() {
3237 assert!(parse_name_status_z("Z9\0a.txt\0b.txt\0").is_empty());
3239 assert_eq!(
3241 parse_name_status_z("M\0good.rs\0Z9\0a.txt\0"),
3242 vec!["good.rs".to_string()]
3243 );
3244 }
3245
3246 #[test]
3249 fn a_rename_missing_its_destination_bails() {
3250 assert_eq!(
3251 parse_name_status_z("R100\0only-one.txt\0"),
3252 vec!["only-one.txt".to_string()]
3253 );
3254 }
3255
3256 #[test]
3258 fn stage_and_diff_sees_a_renamed_out_of_directory_source() {
3259 let dir = tempfile::tempdir().unwrap();
3260 let repo = dir.path();
3261 for args in [
3262 vec!["init", "-q", "."],
3263 vec!["config", "user.email", "t@t"],
3264 vec!["config", "user.name", "t"],
3265 ] {
3266 git(repo, &args).unwrap();
3267 }
3268 std::fs::create_dir(repo.join("secrets")).unwrap();
3269 std::fs::write(repo.join("secrets/key.txt"), "k").unwrap();
3270 git(repo, &["add", "-A"]).unwrap();
3271 git(repo, &["commit", "-qm", "init"]).unwrap();
3272 std::fs::rename(repo.join("secrets/key.txt"), repo.join("public_key.txt")).unwrap();
3273
3274 let diff = stage_and_diff(repo, 64 * 1024).unwrap();
3275 assert!(
3276 diff.changed_paths.iter().any(|p| p.starts_with("secrets/")),
3277 "the source directory must appear: {:?}",
3278 diff.changed_paths
3279 );
3280 assert_eq!(
3283 diff.changed_paths,
3284 vec!["public_key.txt".to_string(), "secrets/key.txt".to_string()],
3285 "both endpoints, and nothing else"
3286 );
3287 }
3288
3289 use std::path::PathBuf;
3292 use std::sync::Mutex;
3293
3294 const MERGE_RS_SOURCE: &str = include_str!("merge.rs");
3298
3299 #[derive(Debug, Clone, PartialEq, Eq)]
3300 struct ForgeCall {
3301 program: String,
3302 args: Vec<String>,
3303 }
3304
3305 struct FakeForgeCommands {
3306 responses: Mutex<std::collections::VecDeque<Result<String, ForgeError>>>,
3307 calls: Mutex<Vec<ForgeCall>>,
3308 }
3309
3310 impl FakeForgeCommands {
3311 fn answers(responses: &[&str]) -> Arc<Self> {
3312 Arc::new(Self {
3313 responses: Mutex::new(responses.iter().map(|s| Ok((*s).to_string())).collect()),
3314 calls: Mutex::new(Vec::new()),
3315 })
3316 }
3317
3318 fn calls(&self) -> Vec<ForgeCall> {
3319 self.calls.lock().unwrap().clone()
3320 }
3321 }
3322
3323 impl ForgeCommandRunner for FakeForgeCommands {
3324 fn run(&self, _dir: &Path, program: &str, args: &[String]) -> Result<String, ForgeError> {
3325 self.calls.lock().unwrap().push(ForgeCall {
3326 program: program.to_string(),
3327 args: args.to_vec(),
3328 });
3329 self.responses
3330 .lock()
3331 .unwrap()
3332 .pop_front()
3333 .expect("a scripted forge response")
3334 }
3335 }
3336
3337 fn repo_with_remote(remote: &str) -> tempfile::TempDir {
3338 let repo = tempfile::tempdir().unwrap();
3339 git(repo.path(), &["init", "-q"]).unwrap();
3340 git(repo.path(), &["remote", "add", "origin", remote]).unwrap();
3341 repo
3342 }
3343
3344 #[test]
3345 fn remote_url_selects_github_or_azure_and_unknown_names_the_override() {
3346 for remote in [
3347 "https://github.com/acme/widgets.git",
3348 "git@github.com:acme/widgets.git",
3349 ] {
3350 assert_eq!(
3351 forge_kind_from_remote(remote, None).unwrap(),
3352 ForgeKind::GitHub
3353 );
3354 }
3355 for remote in [
3356 "https://dev.azure.com/acme/platform/_git/widgets",
3357 "git@ssh.dev.azure.com:v3/acme/platform/widgets",
3358 "https://acme.visualstudio.com/platform/_git/widgets",
3359 ] {
3360 assert_eq!(
3361 forge_kind_from_remote(remote, None).unwrap(),
3362 ForgeKind::AzureDevOps
3363 );
3364 }
3365 let error =
3366 forge_kind_from_remote("ssh://git@git.example.test/acme/widgets", None).unwrap_err();
3367 assert!(error.contains(FORGE_OVERRIDE_ENV), "{error}");
3368 assert_eq!(
3369 forge_kind_from_remote(
3370 "ssh://git@git.example.test/acme/widgets",
3371 Some("azure-devops")
3372 )
3373 .unwrap(),
3374 ForgeKind::AzureDevOps
3375 );
3376 }
3377
3378 #[test]
3379 fn github_client_uses_the_existing_cli_contract_through_a_fake_runner() {
3380 let repo = repo_with_remote("https://github.com/acme/widgets.git");
3381 let runner = FakeForgeCommands::answers(&[
3382 "",
3383 r#"[{"number":7,"state":"OPEN","url":"https://github.com/acme/widgets/pull/7","isDraft":false,"isCrossRepository":false,"baseRefName":"main"}]"#,
3384 "https://github.com/acme/widgets/pull/8",
3385 "",
3386 "",
3387 r#"{"headRefOid":"abc123","statusCheckRollup":[{"__typename":"CheckRun","name":"test","status":"COMPLETED","conclusion":"SUCCESS"},{"__typename":"StatusContext","context":"legacy","state":"PENDING"}]}"#,
3388 ]);
3389 let github = GhCli::with_runner(runner.clone());
3390
3391 github.auth_status().unwrap();
3392 let listed = github.list_prs_for_head(repo.path(), "car/work").unwrap();
3393 assert_eq!(listed[0].number, 7);
3394 let created = github
3395 .create_pr(repo.path(), "car/work", "main", "title", "body", true)
3396 .unwrap();
3397 assert_eq!(created.number, 8);
3398 github.set_pr_body(repo.path(), 7, "new body").unwrap();
3399 github.reopen_pr(repo.path(), 7).unwrap();
3400 let ci = github.ci_for_sha(repo.path(), 7, "abc123").unwrap();
3401 assert_eq!(ci.state, CiState::Pending);
3402 assert_eq!(ci.checks.len(), 2);
3403
3404 let calls = runner.calls();
3405 assert_eq!(calls.len(), 6);
3406 assert!(calls.iter().all(|call| call.program == "gh"));
3407 assert_eq!(calls[0].args, gh_auth_status_args());
3408 let mut expected_list = gh_repo_args(repo.path());
3409 expected_list.extend(gh_pr_list_args("car/work"));
3410 assert_eq!(calls[1].args, expected_list);
3411 let mut expected_create = gh_repo_args(repo.path());
3412 expected_create.extend(gh_pr_create_args("car/work", "main", "title", "body", true));
3413 assert_eq!(calls[2].args, expected_create);
3414 let mut expected_edit = gh_repo_args(repo.path());
3415 expected_edit.extend([
3416 "pr".to_string(),
3417 "edit".to_string(),
3418 "7".to_string(),
3419 "--body".to_string(),
3420 "new body".to_string(),
3421 ]);
3422 assert_eq!(calls[3].args, expected_edit);
3423 let mut expected_reopen = gh_repo_args(repo.path());
3424 expected_reopen.extend(gh_pr_reopen_args(7));
3425 assert_eq!(calls[4].args, expected_reopen);
3426 let mut expected_checks = gh_repo_args(repo.path());
3427 expected_checks.extend(gh_pr_checks_args(7));
3428 assert_eq!(calls[5].args, expected_checks);
3429 }
3430
3431 #[test]
3432 fn azure_client_creates_lists_updates_and_reads_checks_through_a_fake_runner() {
3433 let repo = repo_with_remote("https://dev.azure.com/acme/platform/_git/widgets");
3434 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"}}}]"#;
3435 let created = r#"{"pullRequestId":42,"status":"active","isDraft":true,"targetRefName":"refs/heads/main","repository":{"webUrl":"https://dev.azure.com/acme/platform/_git/widgets"}}"#;
3436 let shown = r#"{"lastMergeSourceCommit":{"commitId":"def456"}}"#;
3437 let policies = r#"[
3438 {"status":"approved","configuration":{"type":{"displayName":"Build"}}},
3439 {"status":"running","configuration":{"type":{"displayName":"Security"}}},
3440 {"status":"rejected","configuration":{"type":{"displayName":"Windows"}}}
3441 ]"#;
3442 let runner =
3443 FakeForgeCommands::answers(&["[]", listed, created, "", "", shown, policies, shown]);
3444 let azure = AzureDevOpsCli::with_runner(runner.clone(), repo.path());
3445
3446 azure.auth_status().unwrap();
3447 let prs = azure.list_prs_for_head(repo.path(), "car/work").unwrap();
3448 assert_eq!(prs[0].number, 41);
3449 assert_eq!(prs[0].base, "main");
3450 let pr = azure
3451 .create_pr(repo.path(), "car/work", "main", "title", "body", true)
3452 .unwrap();
3453 assert_eq!(pr.number, 42);
3454 assert!(pr.is_draft);
3455 assert_eq!(
3456 pr.url,
3457 "https://dev.azure.com/acme/platform/_git/widgets/pullrequest/42"
3458 );
3459 azure.set_pr_body(repo.path(), 41, "new body").unwrap();
3460 azure.reopen_pr(repo.path(), 41).unwrap();
3461 let ci = azure.ci_for_sha(repo.path(), 41, "def456").unwrap();
3462 assert_eq!(ci.state, CiState::Red);
3463 assert_eq!(
3464 ci.checks,
3465 vec![
3466 CiCheck {
3467 name: "Build".into(),
3468 state: CiState::Green,
3469 },
3470 CiCheck {
3471 name: "Security".into(),
3472 state: CiState::Pending,
3473 },
3474 CiCheck {
3475 name: "Windows".into(),
3476 state: CiState::Red,
3477 },
3478 ]
3479 );
3480
3481 let calls = runner.calls();
3482 assert_eq!(calls.len(), 8);
3483 assert!(calls.iter().all(|call| call.program == "az"));
3484 assert_eq!(calls[0].args, az_auth_status_args());
3485 assert_eq!(calls[1].args, az_pr_list_args("car/work"));
3486 assert_eq!(
3487 calls[2].args,
3488 az_pr_create_args("car/work", "main", "title", "body", true)
3489 );
3490 assert_eq!(calls[3].args, az_pr_update_args(41, "new body"));
3491 assert_eq!(calls[4].args, az_pr_reopen_args(41));
3492 assert_eq!(calls[5].args, az_pr_show_args(41));
3493 assert_eq!(calls[6].args, az_pr_policy_list_args(41));
3494 assert_eq!(calls[7].args, az_pr_show_args(41));
3495 }
3496
3497 #[test]
3498 fn azure_check_read_refuses_a_moved_head() {
3499 let error = parse_azure_ci_summary(
3500 r#"{"lastMergeSourceCommit":{"commitId":"delivered"}}"#,
3501 "[]",
3502 r#"{"lastMergeSourceCommit":{"commitId":"newer"}}"#,
3503 "delivered",
3504 )
3505 .unwrap_err();
3506 assert!(error.contains("expected delivered, found newer"), "{error}");
3507 }
3508
3509 struct FakeGh {
3512 auth: Result<(), GhError>,
3513 prs: Mutex<Vec<PrRecord>>,
3514 calls: Mutex<Vec<String>>,
3515 next_number: Mutex<u64>,
3516 fail_list: Mutex<Option<String>>,
3524 fail_create: Mutex<Option<String>>,
3525 fail_set_body: Mutex<Option<String>>,
3526 fail_ci: Mutex<Option<String>>,
3527 checks: Mutex<Vec<(String, CiState)>>,
3528 }
3529
3530 fn force_char_offenders(src: &str) -> Vec<usize> {
3543 let production = src.split_once("mod tests {").map(|(h, _)| h).unwrap_or(src);
3544 let needle: String = ['\'', '+', '\''].iter().collect();
3545 production
3546 .lines()
3547 .enumerate()
3548 .filter(|(_, line)| line.contains(needle.as_str()))
3549 .filter(|(_, line)| !line.contains("FORCE_MARKER: char"))
3550 .map(|(i, _)| i + 1)
3551 .collect()
3552 }
3553
3554 fn gh_err(message: &str, stderr: &str) -> GhError {
3557 GhError {
3558 message: message.to_string(),
3559 stderr: stderr.to_string(),
3560 }
3561 }
3562
3563 impl FakeGh {
3564 fn ok() -> Self {
3565 Self {
3566 auth: Ok(()),
3567 prs: Mutex::new(Vec::new()),
3568 calls: Mutex::new(Vec::new()),
3569 next_number: Mutex::new(101),
3570 fail_list: Mutex::new(None),
3571 fail_create: Mutex::new(None),
3572 fail_set_body: Mutex::new(None),
3573 fail_ci: Mutex::new(None),
3574 checks: Mutex::new(vec![
3575 ("lint".to_string(), CiState::Green),
3576 ("test".to_string(), CiState::Green),
3577 ]),
3578 }
3579 }
3580
3581 fn no_credential() -> Self {
3582 Self {
3583 auth: Err(gh_err(
3584 "no usable GitHub credential: `gh auth status` failed. Authenticate with \
3585 `gh auth login`, or set GH_TOKEN (or GITHUB_TOKEN)",
3586 "gh: To get started with GitHub CLI, please run: gh auth login",
3587 )),
3588 ..Self::ok()
3589 }
3590 }
3591
3592 fn with_prs(prs: Vec<PrRecord>) -> Self {
3593 Self {
3594 prs: Mutex::new(prs),
3595 ..Self::ok()
3596 }
3597 }
3598
3599 fn with_checks(checks: Vec<(&str, CiState)>) -> Self {
3600 let me = Self::ok();
3601 *me.checks.lock().unwrap() = checks
3602 .into_iter()
3603 .map(|(name, state)| (name.to_string(), state))
3604 .collect();
3605 me
3606 }
3607
3608 fn failing_set_body(stderr: &str) -> Self {
3610 let me = Self::ok();
3611 *me.fail_set_body.lock().unwrap() = Some(stderr.to_string());
3612 me
3613 }
3614
3615 fn calls(&self) -> Vec<String> {
3616 self.calls.lock().unwrap().clone()
3617 }
3618 }
3619
3620 fn fake_gh_failure(command: &str, stderr: &str, body: &str) -> GhError {
3624 gh_err(
3625 &format!("gh {command} --body {body} failed: {stderr}"),
3626 stderr,
3627 )
3628 }
3629
3630 impl GitHubApi for FakeGh {
3631 fn auth_status(&self) -> Result<(), GhError> {
3632 self.calls.lock().unwrap().push("auth_status".into());
3633 self.auth.clone().map_err(|e| e.clone())
3634 }
3635
3636 fn list_prs_for_head(&self, _dir: &Path, head: &str) -> Result<Vec<PrRecord>, GhError> {
3637 self.calls.lock().unwrap().push(format!("list {head}"));
3638 if let Some(stderr) = self.fail_list.lock().unwrap().clone() {
3639 return Err(gh_err(&format!("gh pr list failed: {stderr}"), &stderr));
3640 }
3641 Ok(self.prs.lock().unwrap().clone())
3642 }
3643
3644 fn create_pr(
3645 &self,
3646 _dir: &Path,
3647 head: &str,
3648 base: &str,
3649 title: &str,
3650 body: &str,
3651 draft: bool,
3652 ) -> Result<PrRecord, GhError> {
3653 self.calls.lock().unwrap().push(format!(
3654 "create head={head} base={base} draft={draft} title={title} body={body}"
3655 ));
3656 if let Some(stderr) = self.fail_create.lock().unwrap().clone() {
3657 return Err(fake_gh_failure("pr create", &stderr, body));
3658 }
3659 let mut n = self.next_number.lock().unwrap();
3660 let record = PrRecord {
3661 number: *n,
3662 state: PrState::Open,
3663 url: format!("https://github.com/acme/repo/pull/{n}"),
3664 is_draft: draft,
3665 base: base.to_string(),
3666 };
3667 *n += 1;
3668 self.prs.lock().unwrap().push(record.clone());
3669 Ok(record)
3670 }
3671
3672 fn set_pr_body(&self, _dir: &Path, number: u64, body: &str) -> Result<(), GhError> {
3673 self.calls
3674 .lock()
3675 .unwrap()
3676 .push(format!("set_body {number} {body}"));
3677 if let Some(stderr) = self.fail_set_body.lock().unwrap().clone() {
3678 return Err(fake_gh_failure("pr edit", &stderr, body));
3679 }
3680 Ok(())
3681 }
3682
3683 fn reopen_pr(&self, _dir: &Path, number: u64) -> Result<(), GhError> {
3684 self.calls.lock().unwrap().push(format!("reopen {number}"));
3685 Ok(())
3686 }
3687
3688 fn ci_for_sha(
3689 &self,
3690 _dir: &Path,
3691 number: u64,
3692 head_sha: &str,
3693 ) -> Result<CiSummary, GhError> {
3694 self.calls
3695 .lock()
3696 .unwrap()
3697 .push(format!("ci {number} {head_sha}"));
3698 if let Some(stderr) = self.fail_ci.lock().unwrap().clone() {
3699 return Err(gh_err(&format!("gh pr view failed: {stderr}"), &stderr));
3700 }
3701 Ok(CiSummary::from_checks(
3702 head_sha,
3703 self.checks.lock().unwrap().clone(),
3704 ))
3705 }
3706 }
3707
3708 struct Fixture {
3711 origin: PathBuf,
3712 repo: PathBuf,
3713 wt_base: PathBuf,
3714 _dirs: Vec<tempfile::TempDir>,
3715 }
3716
3717 fn fixture() -> Fixture {
3718 let origin_dir = tempfile::tempdir().unwrap();
3719 let repo_dir = tempfile::tempdir().unwrap();
3720 let wt_dir = tempfile::tempdir().unwrap();
3721 let origin = origin_dir.path().to_path_buf();
3722 let repo = repo_dir.path().to_path_buf();
3723
3724 git(&origin, &["init", "-q", "--bare", "-b", "main"]).unwrap();
3725 git(&repo, &["init", "-q", "-b", "main"]).unwrap();
3726 git(&repo, &["config", "user.name", "t"]).unwrap();
3727 git(&repo, &["config", "user.email", "t@t"]).unwrap();
3728 std::fs::write(repo.join("README.md"), "seed").unwrap();
3729 git(&repo, &["add", "-A"]).unwrap();
3730 git(&repo, &["commit", "-qm", "seed"]).unwrap();
3731 git(
3732 &repo,
3733 &["remote", "add", "origin", origin.to_str().unwrap()],
3734 )
3735 .unwrap();
3736 git(&repo, &["push", "-q", "origin", "main"]).unwrap();
3737
3738 Fixture {
3739 origin,
3740 repo,
3741 wt_base: wt_dir.path().to_path_buf(),
3742 _dirs: vec![origin_dir, repo_dir, wt_dir],
3743 }
3744 }
3745
3746 impl Fixture {
3747 fn cut(&self, name: &str, from_ref: &str) -> PathBuf {
3749 let path = self.wt_base.join(name);
3750 git(
3751 &self.repo,
3752 &[
3753 "worktree",
3754 "add",
3755 "--detach",
3756 "-q",
3757 path.to_str().unwrap(),
3758 from_ref,
3759 ],
3760 )
3761 .unwrap();
3762 path
3763 }
3764
3765 fn origin_head(&self, branch: &str) -> Option<String> {
3768 git(
3769 &self.origin,
3770 &["rev-parse", "--verify", &format!("refs/heads/{branch}")],
3771 )
3772 .ok()
3773 .map(|s| s.trim().to_string())
3774 }
3775 }
3776
3777 fn delivery<'a>(
3778 f: &'a Fixture,
3779 worktree: &'a Path,
3780 contract: &'a OutcomeContract,
3781 target: &'a str,
3782 draft: bool,
3783 body: &'a str,
3784 ) -> PrDelivery<'a> {
3785 PrDelivery {
3786 repo: &f.repo,
3787 worktree,
3788 target_branch: target,
3789 base_branch: "main",
3790 draft,
3791 intent: "make x exist",
3792 contract,
3793 body,
3794 provenance: None,
3795 }
3796 }
3797
3798 const TARGET: &str = "goalpool/g_abc123";
3799
3800 #[test]
3801 fn github_rollup_combines_check_runs_and_status_contexts_for_the_exact_head() {
3802 let raw = r#"{
3803 "headRefOid":"abc123",
3804 "statusCheckRollup":[
3805 {"__typename":"CheckRun","name":"lint","status":"COMPLETED","conclusion":"SUCCESS"},
3806 {"__typename":"CheckRun","name":"tests","status":"IN_PROGRESS","conclusion":""},
3807 {"__typename":"StatusContext","context":"deploy","state":"FAILURE"},
3808 {"__typename":"StatusContext","context":"lint","state":"PENDING"}
3809 ]
3810 }"#;
3811
3812 let summary = parse_github_ci_summary(raw, "abc123").unwrap();
3813 assert_eq!(summary.head_sha, "abc123");
3814 assert_eq!(summary.state, CiState::Red);
3815 assert_eq!(
3817 summary.checks,
3818 vec![
3819 CiCheck {
3820 name: "deploy".into(),
3821 state: CiState::Red,
3822 },
3823 CiCheck {
3824 name: "lint".into(),
3825 state: CiState::Pending,
3826 },
3827 CiCheck {
3828 name: "tests".into(),
3829 state: CiState::Pending,
3830 },
3831 ]
3832 );
3833 }
3834
3835 #[test]
3836 fn github_rollup_with_no_checks_is_pending_not_green() {
3837 let summary = parse_github_ci_summary(
3838 r#"{"headRefOid":"abc123","statusCheckRollup":null}"#,
3839 "abc123",
3840 )
3841 .unwrap();
3842 assert_eq!(summary.state, CiState::Pending);
3843 assert!(summary.checks.is_empty());
3844 }
3845
3846 #[test]
3847 fn github_rollup_refuses_ci_from_a_different_head() {
3848 let err = parse_github_ci_summary(
3849 r#"{"headRefOid":"newer","statusCheckRollup":[]}"#,
3850 "delivered",
3851 )
3852 .unwrap_err();
3853 assert!(err.contains("expected delivered, found newer"), "{err}");
3854 }
3855
3856 #[test]
3857 fn ci_lookup_failure_preserves_successful_publication() {
3858 let f = fixture();
3859 let c = contract();
3860 let wt = f.cut("s1", "main");
3861 std::fs::write(wt.join("x.txt"), "made by coder").unwrap();
3862 let gh = FakeGh::ok();
3863 *gh.fail_ci.lock().unwrap() = Some("HTTP 503".into());
3864 let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "body"), &gh).unwrap();
3865 assert!(out.pushed);
3866 assert_eq!(out.pr_number, 101);
3867 assert_eq!(out.pr_action, PrAction::Opened);
3868 assert!(!out.pr_url.is_empty());
3869 assert_eq!(out.ci.head_sha, out.commit);
3870 assert_eq!(out.ci.state, CiState::Pending);
3871 assert!(out.ci.checks.is_empty());
3872 assert!(out.delivery_report().contains("CI unavailable"));
3873 assert!(out.delivery_report().contains("HTTP 503"));
3874 assert_eq!(gh.prs.lock().unwrap().len(), 1);
3875 }
3876
3877 #[test]
3878 fn a_green_delivery_pushes_the_commit_and_opens_one_pr() {
3879 let f = fixture();
3880 let c = contract();
3881 let wt = f.cut("s1", "main");
3882 std::fs::write(wt.join("x.txt"), "made by coder").unwrap();
3883
3884 let gh = FakeGh::ok();
3885 let out =
3886 deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "round 1 body"), &gh).unwrap();
3887
3888 assert!(out.pushed);
3889 assert_eq!(out.branch, TARGET);
3890 assert_eq!(out.pr_action, PrAction::Opened);
3891 assert_eq!(out.pr_number, 101);
3892 assert!(out.draft, "a draft was requested at create time");
3893 assert_eq!(out.ci.state, CiState::Green);
3894 assert_eq!(out.ci.head_sha, out.commit);
3895 assert_eq!(
3896 out.ci.checks,
3897 [
3898 CiCheck {
3899 name: "lint".into(),
3900 state: CiState::Green,
3901 },
3902 CiCheck {
3903 name: "test".into(),
3904 state: CiState::Green,
3905 },
3906 ]
3907 );
3908 assert_eq!(
3909 out.delivery_report(),
3910 format!(
3911 "delivered with green checks at {}; pull request remains draft",
3912 out.commit
3913 )
3914 );
3915
3916 assert_eq!(f.origin_head(TARGET).as_deref(), Some(out.commit.as_str()));
3918 assert_eq!(
3919 git(&f.origin, &["show", &format!("refs/heads/{TARGET}:x.txt")]).unwrap(),
3920 "made by coder"
3921 );
3922 assert_eq!(
3923 git(
3924 &f.origin,
3925 &[
3926 "log",
3927 "-1",
3928 "--format=%an <%ae>",
3929 &format!("refs/heads/{TARGET}")
3930 ]
3931 )
3932 .unwrap()
3933 .trim(),
3934 "car-coder <coder@parslee.ai>"
3935 );
3936 assert_eq!(gh.calls()[0], "auth_status");
3939 assert_eq!(gh.calls().last(), Some(&format!("ci 101 {}", out.commit)));
3940 }
3941
3942 #[test]
3943 fn a_red_delivery_names_failed_checks_at_the_delivered_head() {
3944 let f = fixture();
3945 let c = contract();
3946 let wt = f.cut("red", "main");
3947 std::fs::write(wt.join("x.txt"), "made by coder").unwrap();
3948 let gh = FakeGh::with_checks(vec![
3949 ("lint", CiState::Green),
3950 ("windows", CiState::Red),
3951 ("test", CiState::Pending),
3952 ]);
3953
3954 let out =
3955 deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "red delivery"), &gh).unwrap();
3956
3957 assert_eq!(out.ci.state, CiState::Red);
3958 assert!(out.ci.checks.contains(&CiCheck {
3959 name: "windows".into(),
3960 state: CiState::Red
3961 }));
3962 assert!(out.ci.checks.contains(&CiCheck {
3963 name: "test".into(),
3964 state: CiState::Pending
3965 }));
3966 assert_eq!(
3967 out.delivery_report(),
3968 format!("delivered red on windows at {}", out.commit)
3969 );
3970 }
3971
3972 #[test]
3973 fn a_second_delivery_appends_to_the_same_branch_and_the_same_pr() {
3974 let f = fixture();
3975 let c = contract();
3976
3977 let wt1 = f.cut("s1", "main");
3978 std::fs::write(wt1.join("x.txt"), "round one").unwrap();
3979 let gh1 = FakeGh::ok();
3980 let first =
3981 deliver_pr_with(delivery(&f, &wt1, &c, TARGET, true, "round 1 body"), &gh1).unwrap();
3982
3983 git(&f.repo, &["fetch", "-q", "origin"]).unwrap();
3985 let wt2 = f.cut("s2", &format!("origin/{TARGET}"));
3986 std::fs::write(wt2.join("y.txt"), "round two").unwrap();
3987
3988 let gh2 = FakeGh::with_prs(vec![PrRecord {
3990 number: 101,
3991 state: PrState::Open,
3992 url: "https://github.com/acme/repo/pull/101".into(),
3993 is_draft: true,
3994 base: "main".into(),
3995 }]);
3996 let second =
3997 deliver_pr_with(delivery(&f, &wt2, &c, TARGET, true, "round 2 body"), &gh2).unwrap();
3998
3999 assert_eq!(second.pr_action, PrAction::Updated);
4000 assert_eq!(second.pr_number, 101);
4001 assert_ne!(second.commit, first.commit);
4002 assert!(
4003 !gh2.calls().iter().any(|c| c.starts_with("create")),
4004 "a second PR must never be created for the same branch: {:?}",
4005 gh2.calls()
4006 );
4007 assert!(gh2.calls().iter().any(|c| c == "set_body 101 round 2 body"));
4008
4009 assert_eq!(
4011 git(
4012 &f.origin,
4013 &["rev-list", "--count", &format!("refs/heads/{TARGET}")]
4014 )
4015 .unwrap()
4016 .trim(),
4017 "3"
4018 );
4019 assert_eq!(
4020 git(
4021 &f.origin,
4022 &[
4023 "rev-list",
4024 "--count",
4025 "--merges",
4026 &format!("refs/heads/{TARGET}")
4027 ]
4028 )
4029 .unwrap()
4030 .trim(),
4031 "0"
4032 );
4033 assert!(git(
4035 &f.origin,
4036 &["merge-base", "--is-ancestor", &first.commit, &second.commit]
4037 )
4038 .is_ok());
4039 let mut branches: Vec<String> = git(
4041 &f.origin,
4042 &["for-each-ref", "--format=%(refname:short)", "refs/heads/"],
4043 )
4044 .unwrap()
4045 .lines()
4046 .map(|l| l.to_string())
4047 .collect();
4048 branches.sort();
4049 assert_eq!(branches, vec![TARGET.to_string(), "main".to_string()]);
4050 }
4051
4052 #[test]
4053 fn a_non_fast_forward_is_retriable_and_leaves_the_remote_alone() {
4054 let f = fixture();
4055 let c = contract();
4056
4057 let wt1 = f.cut("s1", "main");
4058 std::fs::write(wt1.join("x.txt"), "round one").unwrap();
4059 let first =
4060 deliver_pr_with(delivery(&f, &wt1, &c, TARGET, true, "b1"), &FakeGh::ok()).unwrap();
4061
4062 let wt2 = f.cut("stale", "main");
4065 std::fs::write(wt2.join("z.txt"), "stale round").unwrap();
4066 let gh = FakeGh::with_prs(vec![PrRecord {
4067 number: 101,
4068 state: PrState::Open,
4069 url: "https://github.com/acme/repo/pull/101".into(),
4070 is_draft: true,
4071 base: "main".into(),
4072 }]);
4073 let err = deliver_pr_with(delivery(&f, &wt2, &c, TARGET, true, "b2"), &gh).unwrap_err();
4074
4075 assert_eq!(err.stage(), "push");
4076 assert!(err.retriable(), "{err}");
4077 assert!(
4078 matches!(
4079 err,
4080 DeliveryFailure::Push {
4081 retriable: true,
4082 ..
4083 }
4084 ),
4085 "{err:?}"
4086 );
4087 assert!(
4088 err.reason().contains("non-fast-forward"),
4089 "the reason must name the condition: {}",
4090 err.reason()
4091 );
4092 assert_eq!(
4094 f.origin_head(TARGET).as_deref(),
4095 Some(first.commit.as_str())
4096 );
4097 assert!(
4099 !gh.calls().iter().any(|c| c.starts_with("create")),
4100 "{:?}",
4101 gh.calls()
4102 );
4103 }
4104
4105 #[test]
4106 fn a_missing_credential_fails_preflight_and_touches_nothing() {
4107 let f = fixture();
4108 let c = contract();
4109 let wt = f.cut("s1", "main");
4110 std::fs::write(wt.join("x.txt"), "never delivered").unwrap();
4111
4112 let gh = FakeGh::no_credential();
4113 let err = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "body"), &gh).unwrap_err();
4114
4115 assert_eq!(err.stage(), "preflight");
4116 assert!(!err.retriable(), "a missing credential is not retriable");
4117 assert!(
4118 err.reason().contains("GH_TOKEN") || err.reason().contains("gh auth"),
4119 "the failure must name the missing credential: {}",
4120 err.reason()
4121 );
4122 assert!(
4124 f.origin_head(TARGET).is_none(),
4125 "the remote gained a branch"
4126 );
4127 assert!(
4128 !git(&wt, &["status", "--porcelain"])
4129 .unwrap()
4130 .trim()
4131 .is_empty(),
4132 "the worktree was committed despite the preflight failure"
4133 );
4134 assert_eq!(gh.calls(), vec!["auth_status".to_string()]);
4135 }
4136
4137 #[test]
4143 fn a_closed_unmerged_pull_request_parks_the_round() {
4144 let f = fixture();
4145 let c = contract();
4146 let wt = f.cut("s1", "main");
4147 std::fs::write(wt.join("x.txt"), "again").unwrap();
4148
4149 let gh = FakeGh::with_prs(vec![PrRecord {
4150 number: 55,
4151 state: PrState::ClosedUnmerged,
4152 url: "https://github.com/acme/repo/pull/55".into(),
4153 is_draft: false,
4154 base: "main".into(),
4155 }]);
4156 let err = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "revived"), &gh).unwrap_err();
4157
4158 assert!(
4159 matches!(err, DeliveryFailure::Preflight { .. }),
4160 "a closed pull request is refused before the commit: {err:?}"
4161 );
4162 assert!(
4163 !err.retriable(),
4164 "retrying changes nothing — a human reopens #55 or picks another target branch"
4165 );
4166 assert!(
4168 err.reason().contains("#55") && err.reason().contains("Reopen"),
4169 "{}",
4170 err.reason()
4171 );
4172
4173 assert_eq!(
4176 gh.calls(),
4177 vec!["auth_status".to_string(), format!("list {TARGET}")]
4178 );
4179 assert_eq!(f.origin_head(TARGET), None, "nothing may be pushed");
4181 assert!(
4182 !git(&wt, &["status", "--porcelain"])
4183 .unwrap()
4184 .trim()
4185 .is_empty(),
4186 "the worktree was committed despite the preflight failure"
4187 );
4188 }
4189
4190 #[test]
4195 fn a_reviewers_edited_body_survives_the_next_round() {
4196 let f = fixture();
4197 let c = contract();
4198 let wt = f.cut("s1", "main");
4199 std::fs::write(wt.join("x.txt"), "round two").unwrap();
4200
4201 let gh = FakeGh::with_prs(vec![PrRecord {
4202 number: 40,
4203 state: PrState::ClosedUnmerged,
4204 url: "https://github.com/acme/repo/pull/40".into(),
4205 is_draft: false,
4206 base: "main".into(),
4207 }]);
4208 let err = deliver_pr_with(
4209 delivery(&f, &wt, &c, TARGET, false, "round two's generated body"),
4210 &gh,
4211 )
4212 .unwrap_err();
4213
4214 assert_eq!(err.stage(), "preflight");
4215 assert!(
4216 !gh.calls().iter().any(|c| c.starts_with("set_body")),
4217 "the reviewer's description was rewritten: {:?}",
4218 gh.calls()
4219 );
4220 assert!(
4227 !gh.calls()
4228 .iter()
4229 .any(|c| c.contains("round two's generated body")),
4230 "this round's body reached GitHub: {:?}",
4231 gh.calls()
4232 );
4233 }
4234
4235 #[test]
4243 fn a_superseding_open_pull_request_beats_the_closed_one() {
4244 let f = fixture();
4245 let c = contract();
4246 let wt = f.cut("s1", "main");
4247 std::fs::write(wt.join("x.txt"), "carried forward").unwrap();
4248
4249 let gh = FakeGh::with_prs(vec![
4250 PrRecord {
4251 number: 40,
4252 state: PrState::ClosedUnmerged,
4253 url: "https://github.com/acme/repo/pull/40".into(),
4254 is_draft: false,
4255 base: "main".into(),
4256 },
4257 PrRecord {
4258 number: 55,
4259 state: PrState::Open,
4260 url: "https://github.com/acme/repo/pull/55".into(),
4261 is_draft: false,
4262 base: "main".into(),
4263 },
4264 ]);
4265
4266 let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap();
4267
4268 assert_eq!(out.pr_action, PrAction::Updated);
4269 assert_eq!(out.pr_number, 55, "the live pull request receives the push");
4270 assert!(out.pushed);
4271 assert!(
4272 gh.calls().iter().any(|c| c.starts_with("set_body 55")),
4273 "{:?}",
4274 gh.calls()
4275 );
4276 assert!(
4279 !gh.calls().iter().any(|c| c.starts_with("create")),
4280 "{:?}",
4281 gh.calls()
4282 );
4283 assert!(
4284 !gh.calls().iter().any(|c| c.contains(" 40 ")),
4285 "{:?}",
4286 gh.calls()
4287 );
4288 }
4289
4290 #[test]
4294 fn the_close_veto_is_suppressed_only_by_an_open_pr_into_the_same_base() {
4295 let closed = PrRecord {
4296 number: 40,
4297 state: PrState::ClosedUnmerged,
4298 url: "u40".into(),
4299 is_draft: false,
4300 base: "main".into(),
4301 };
4302 let open_same = PrRecord {
4303 number: 55,
4304 state: PrState::Open,
4305 url: "u55".into(),
4306 is_draft: false,
4307 base: "main".into(),
4308 };
4309 let open_other = PrRecord {
4310 base: "release/2.1".into(),
4311 ..open_same.clone()
4312 };
4313
4314 assert!(
4315 closed_pr_refusal(std::slice::from_ref(&closed), TARGET, "main").is_some(),
4316 "a lone closed pull request still parks the round"
4317 );
4318 assert!(
4319 closed_pr_refusal(&[closed.clone(), open_same], TARGET, "main").is_none(),
4320 "the open pull request into `main` supersedes the close"
4321 );
4322 assert!(
4323 closed_pr_refusal(&[closed, open_other], TARGET, "main").is_some(),
4324 "an open pull request into ANOTHER base says nothing about this base"
4325 );
4326 }
4327
4328 #[test]
4329 fn a_merged_pr_does_not_block_a_new_one() {
4330 let f = fixture();
4331 let c = contract();
4332 let wt = f.cut("s1", "main");
4333 std::fs::write(wt.join("x.txt"), "next chapter").unwrap();
4334
4335 let gh = FakeGh::with_prs(vec![PrRecord {
4336 number: 9,
4337 state: PrState::Merged,
4338 url: "https://github.com/acme/repo/pull/9".into(),
4339 is_draft: false,
4340 base: "main".into(),
4341 }]);
4342 let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "fresh"), &gh).unwrap();
4343
4344 assert_eq!(out.pr_action, PrAction::Opened);
4345 assert!(gh.calls().iter().any(|c| c.starts_with("create")));
4348 }
4349
4350 #[test]
4351 fn an_updated_pr_never_has_its_draft_state_flipped() {
4352 let f = fixture();
4353 let c = contract();
4354 let wt = f.cut("s1", "main");
4355 std::fs::write(wt.join("x.txt"), "more work").unwrap();
4356
4357 let gh = FakeGh::with_prs(vec![PrRecord {
4359 number: 77,
4360 state: PrState::Open,
4361 url: "https://github.com/acme/repo/pull/77".into(),
4362 is_draft: false,
4363 base: "main".into(),
4364 }]);
4365 let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "body"), &gh).unwrap();
4367
4368 assert_eq!(out.pr_action, PrAction::Updated);
4369 assert!(
4370 !out.draft,
4371 "delivery must report the PR's real state, not re-draft a ready PR"
4372 );
4373 }
4374
4375 #[test]
4376 fn a_clean_worktree_redelivers_head_after_an_earlier_push_failure() {
4377 let f = fixture();
4378 let c = contract();
4379
4380 let wt = f.cut("s1", "main");
4382 std::fs::write(wt.join("x.txt"), "work").unwrap();
4383 git(
4384 &f.repo,
4385 &["remote", "set-url", "origin", "/nonexistent/nope.git"],
4386 )
4387 .unwrap();
4388 let err =
4389 deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "b"), &FakeGh::ok()).unwrap_err();
4390 assert_eq!(err.stage(), "push");
4391 assert!(
4394 !err.retriable(),
4395 "a missing remote cannot be fixed by trying again: {err}"
4396 );
4397 let committed = git(&wt, &["rev-parse", "HEAD"]).unwrap().trim().to_string();
4398
4399 git(
4402 &f.repo,
4403 &["remote", "set-url", "origin", f.origin.to_str().unwrap()],
4404 )
4405 .unwrap();
4406 let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "b"), &FakeGh::ok()).unwrap();
4407 assert_eq!(out.commit, committed);
4408 assert_eq!(f.origin_head(TARGET).as_deref(), Some(committed.as_str()));
4409 }
4410
4411 #[test]
4412 fn a_branch_name_that_looks_like_a_flag_is_refused_at_preflight() {
4413 let f = fixture();
4414 let c = contract();
4415 let wt = f.cut("s1", "main");
4416 std::fs::write(wt.join("x.txt"), "x").unwrap();
4417
4418 for bad in [
4419 "--upload-pack=touch /tmp/pwn",
4420 "goalpool/../../etc",
4421 "has space",
4422 ] {
4423 let err =
4424 deliver_pr_with(delivery(&f, &wt, &c, bad, true, "b"), &FakeGh::ok()).unwrap_err();
4425 assert_eq!(err.stage(), "preflight", "for `{bad}`: {err}");
4426 }
4427 let plus = format!("{}goalpool/x", '+');
4429 let err =
4430 deliver_pr_with(delivery(&f, &wt, &c, &plus, true, "b"), &FakeGh::ok()).unwrap_err();
4431 assert_eq!(err.stage(), "preflight", "{err}");
4432 }
4433
4434 #[test]
4441 fn no_force_push_token_appears_anywhere_in_this_file() {
4442 let dashes = "-".repeat(2);
4443 let plain = format!("{dashes}{}", "force");
4444 let lease = format!("{plain}-with-lease");
4445 let plus_refspec: String = ['"', '+'].iter().collect();
4458 let short_flag: String = ['"', '-', 'f', '"'].iter().collect();
4461 for needle in [
4462 plain.as_str(),
4463 lease.as_str(),
4464 plus_refspec.as_str(),
4465 short_flag.as_str(),
4466 ] {
4467 assert!(
4468 !MERGE_RS_SOURCE.contains(needle),
4469 "`{needle}` must appear nowhere on the delivery path"
4470 );
4471 }
4472
4473 let production = MERGE_RS_SOURCE
4486 .split_once("mod tests {")
4487 .map(|(head, _)| head)
4488 .unwrap_or(MERGE_RS_SOURCE);
4489 for tok in [
4495 ['"', 'r', 'e', 'b', 'a', 's', 'e', '"']
4496 .iter()
4497 .collect::<String>(),
4498 ['"', '-', '-', 'r', 'e', 'b', 'a', 's', 'e', '"']
4499 .iter()
4500 .collect::<String>(),
4501 ] {
4502 assert!(
4503 !production.contains(tok.as_str()),
4504 "delivery must never rebase: `{tok}`"
4505 );
4506 }
4507
4508 assert_eq!(
4509 force_char_offenders(MERGE_RS_SOURCE),
4510 Vec::<usize>::new(),
4511 "a char-literal plus on the delivery path is a force refspec"
4512 );
4513 }
4514
4515 #[test]
4526 fn the_force_char_scanner_catches_the_spellings_that_slipped_past_it() {
4527 let wrapped = "fn f() {\n let plus = '+';\n let refspec = format!(\n \
4530 \"{plus}{commit}:refs/heads/{branch}\"\n );\n}\n";
4531 assert!(
4532 !force_char_offenders(wrapped).is_empty(),
4533 "a plus bound to a name and interpolated is still a force refspec"
4534 );
4535 let inline = "fn f() { let r = format!(\"{}{commit}:refs/heads/{b}\", '+'); }\n";
4537 assert!(!force_char_offenders(inline).is_empty());
4538 let only_in_tests = "fn f() {}\nmod tests {\n let plus = '+';\n}\n";
4541 assert!(force_char_offenders(only_in_tests).is_empty());
4542 }
4543
4544 #[test]
4548 fn delivery_never_marks_a_pull_request_ready_for_review() {
4549 let ready_arg = String::from('"') + "read" + "y" + "\"";
4551 assert!(
4552 !MERGE_RS_SOURCE.contains(&ready_arg),
4553 "the runtime must not flip a pull request out of draft"
4554 );
4555 }
4556
4557 #[test]
4560 fn no_shell_invocation_appears_on_the_delivery_path() {
4561 for needle in ["Command::new(\"sh\")", "Command::new(\"bash\")"] {
4562 assert!(
4563 !MERGE_RS_SOURCE.contains(needle),
4564 "`{needle}` would reintroduce shell interpolation"
4565 );
4566 }
4567 }
4568
4569 #[test]
4570 fn delivery_has_no_pull_request_merge_invocation() {
4571 let production = MERGE_RS_SOURCE
4572 .split_once("mod tests {")
4573 .map(|(head, _)| head)
4574 .unwrap_or(MERGE_RS_SOURCE);
4575 let lines: Vec<&str> = production.lines().collect();
4576 for (index, line) in lines.iter().enumerate() {
4577 if line.contains("\"pr\"") {
4578 let end = (index + 4).min(lines.len());
4579 let window = lines[index..end].join("\n");
4580 assert!(
4581 !window.contains("\"merge\""),
4582 "pull-request merge invocation at production line {}",
4583 index + 1
4584 );
4585 }
4586 }
4587 }
4588
4589 #[test]
4592 fn pr_check_read_requests_the_rollup_and_head_sha() {
4593 assert_eq!(
4594 gh_pr_checks_args(41),
4595 ["pr", "view", "41", "--json", "headRefOid,statusCheckRollup"]
4596 );
4597 }
4598
4599 #[test]
4600 fn draft_adds_the_draft_flag_and_nothing_else_does() {
4601 let with = gh_pr_create_args("h", "main", "t", "b", true);
4602 assert!(with.contains(&"--draft".to_string()), "{with:?}");
4603 let without = gh_pr_create_args("h", "main", "t", "b", false);
4604 assert!(!without.contains(&"--draft".to_string()), "{without:?}");
4605 assert!(with.windows(2).any(|w| w[0] == "--body" && w[1] == "b"));
4607 assert!(with.windows(2).any(|w| w[0] == "--title" && w[1] == "t"));
4608 }
4609
4610 #[test]
4611 fn the_push_refspec_is_append_only_and_fully_qualified() {
4612 let args = push_args("abc123", "goalpool/g_1");
4613 assert_eq!(
4614 args,
4615 vec![
4616 "push".to_string(),
4617 "origin".to_string(),
4618 "abc123:refs/heads/goalpool/g_1".to_string(),
4619 ]
4620 );
4621 let plus = '+';
4622 assert!(
4623 !args.iter().any(|a| a.starts_with(plus)),
4624 "a leading plus is git's force marker: {args:?}"
4625 );
4626
4627 let mut child = std::process::Command::new("git")
4631 .args(["check-ref-format", "refs/heads/goalpool/g_1"])
4632 .spawn()
4633 .expect("spawn git check-ref-format fixture");
4634 let status = child.wait().expect("reap git fixture child");
4635 assert!(status.success(), "git rejected the destination ref");
4636 }
4637
4638 #[test]
4643 fn pr_list_asks_for_every_state_of_one_head_branch() {
4644 let args = gh_pr_list_args("goalpool/g_1");
4645 assert!(args
4646 .windows(2)
4647 .any(|w| w[0] == "--head" && w[1] == "goalpool/g_1"));
4648 assert!(args.windows(2).any(|w| w[0] == "--state" && w[1] == "all"));
4649 let limit: u32 = args
4650 .windows(2)
4651 .find(|w| w[0] == "--limit")
4652 .map(|w| w[1].parse().expect("--limit is a number"))
4653 .expect("an explicit --limit, or gh silently pages at 30");
4654 assert!(
4655 limit >= 100,
4656 "the head listing must not be truncated below 100: {args:?}"
4657 );
4658 }
4659
4660 #[test]
4663 fn pr_list_json_maps_merged_apart_from_closed() {
4664 let prs = parse_pr_list(
4665 r#"[{"number":1,"state":"OPEN","url":"u1","isDraft":true,"baseRefName":"main"},
4666 {"number":2,"state":"CLOSED","url":"u2","isDraft":false,"baseRefName":"main"},
4667 {"number":3,"state":"MERGED","url":"u3","isDraft":false,"baseRefName":"main"}]"#,
4668 )
4669 .unwrap();
4670 assert_eq!(prs[0].state, PrState::Open);
4671 assert!(prs[0].is_draft);
4672 assert_eq!(prs[1].state, PrState::ClosedUnmerged);
4673 assert_eq!(prs[2].state, PrState::Merged);
4674 }
4675
4676 #[test]
4677 fn an_unknown_pr_state_is_an_error_not_a_guess() {
4678 assert!(
4679 parse_pr_list(r#"[{"number":1,"state":"WAT","url":"u","baseRefName":"main"}]"#)
4680 .is_err()
4681 );
4682 assert!(
4686 parse_pr_list(r#"[{"number":1,"state":"OPEN","url":"u","isDraft":false}]"#).is_err(),
4687 "a pull request with no baseRefName cannot be reconciled against a base"
4688 );
4689 assert!(parse_pr_list("not json").is_err());
4690 assert!(parse_pr_list("[]").unwrap().is_empty());
4691 }
4692
4693 #[test]
4694 fn a_pr_number_is_read_off_the_created_url() {
4695 assert_eq!(
4696 pr_number_from_url("https://github.com/acme/repo/pull/4821\n").unwrap(),
4697 4821
4698 );
4699 assert!(pr_number_from_url("https://github.com/acme/repo").is_err());
4700 }
4701
4702 #[test]
4703 fn push_errors_split_into_retriable_and_not() {
4704 let (reason, retriable) = classify_push_error("! [rejected] abc -> b (non-fast-forward)");
4705 assert!(retriable);
4706 assert!(reason.contains("non-fast-forward"));
4707
4708 let (_, retriable) = classify_push_error("remote: Permission denied to car-coder.");
4709 assert!(!retriable, "a permission refusal must not be retried");
4710
4711 let (_, retriable) = classify_push_error(
4718 "ssh: Could not resolve hostname github.com: nodename nor servname provided, \
4719 or not known\nfatal: Could not read from remote repository.\n\nPlease make \
4720 sure you have the correct access rights\nand the repository exists.",
4721 );
4722 assert!(retriable, "transport failures are worth another round");
4723 }
4724
4725 #[test]
4737 fn a_sha_containing_403_is_not_mistaken_for_a_permission_refusal() {
4738 let (reason, retriable) = classify_push_error(
4739 "! [remote rejected] goalpool/g_1 -> goalpool/g_1 (cannot lock ref \
4740 'refs/heads/goalpool/g_1': is at a4973f07ba3815b8d45b86a7e9633d9fbc5e4403 \
4741 but expected b1c2d3e4f5061728394a5b6c7d8e9f0011223344)",
4742 );
4743 assert!(
4744 retriable,
4745 "a lost push race is retriable; the digits 403 inside a SHA are not an HTTP status"
4746 );
4747 assert!(
4748 reason.contains("race") || reason.contains("moved"),
4749 "the reason must name what actually happened: {reason}"
4750 );
4751 }
4752
4753 #[test]
4761 fn gits_lost_race_wording_is_recognised_rather_than_defaulted() {
4762 for message in [
4763 "! [remote rejected] main -> main (failed to update ref)",
4764 "error: cannot lock ref 'refs/heads/goalpool/g_1': is at aaa but expected bbb",
4765 "! [rejected] abc -> b (fetch first)",
4766 ] {
4767 let (reason, retriable) = classify_push_error(message);
4768 assert!(retriable, "{message}");
4769 assert!(
4770 reason.contains("moved") || reason.contains("race"),
4771 "the reason must tell an operator the branch moved, not echo git: {reason}"
4772 );
4773 }
4774 }
4775
4776 #[test]
4785 fn a_403_in_a_branch_or_repo_name_is_not_a_permission_refusal() {
4786 for message in [
4787 "! [rejected] abc1234 -> feature-403 (non-fast-forward)",
4788 "! [rejected] abc -> goalpool/g_403 (fetch first)",
4789 "! [remote rejected] x -> release_403 (failed to update ref)",
4790 ] {
4791 let (reason, retriable) = classify_push_error(message);
4792 assert!(retriable, "must stay a retriable race: {message}");
4793 assert!(
4794 reason.contains("moved") || reason.contains("race"),
4795 "{reason}"
4796 );
4797 }
4798 let (_, retriable) =
4800 classify_push_error("fatal: unable to access 'https://github.com/org/repo-403.git/'");
4801 assert!(retriable, "a repo name is not a status code");
4802 }
4803
4804 #[test]
4811 fn permanent_server_refusals_inside_remote_rejected_are_not_retried() {
4812 for message in [
4813 "! [remote rejected] b -> b (refusing to allow an OAuth App to create or update \
4814 workflow '.github/workflows/x.yml' without 'workflow' scope)",
4815 "! [remote rejected] b -> b (shallow update not allowed)",
4816 "remote: error: GH001: Large files detected. File exceeds GitHub's file size limit \
4817 of 100.00 MB",
4818 "remote: error: cannot lock ref 'refs/heads/goalpool/g_1': \
4823 'refs/heads/goalpool' exists; cannot create 'refs/heads/goalpool/g_1'\n \
4824 ! [remote rejected] HEAD -> goalpool/g_1 (failed to update ref)",
4825 ] {
4826 let (_, retriable) = classify_push_error(message);
4827 assert!(!retriable, "permanent, must not be retried: {message}");
4828 }
4829 }
4830
4831 #[test]
4848 fn a_repository_rule_violation_is_permanent_not_a_lost_race() {
4849 let (reason, retriable) = classify_push_error(
4850 "remote: error: GH013: Repository rule violations found for \
4851 refs/heads/goalpool/g_1.\nremote:\nremote: - GITHUB PUSH PROTECTION\nremote: \
4852 —— GitHub Personal Access Token ————————————————\nremote:\n \
4853 ! [remote rejected] goalpool/g_1 -> goalpool/g_1 (push declined due to \
4854 repository rule violations)\nerror: failed to push some refs to \
4855 'https://github.com/o/r.git'",
4856 );
4857 assert!(
4858 !retriable,
4859 "a ruleset block cannot be got past by pushing the same commit again"
4860 );
4861 assert!(
4862 !reason.contains("race") && !reason.contains("moved"),
4863 "and it must not be described as a lost push race: {reason}"
4864 );
4865
4866 let (_, retriable) = classify_push_error(
4869 "! [remote rejected] b -> b (push declined due to repository rule violations)",
4870 );
4871 assert!(!retriable);
4872
4873 for code in ["GH009", "GH011", "GH013"] {
4875 let (_, retriable) =
4876 classify_push_error(&format!("remote: error: {code}: blocked by policy"));
4877 assert!(!retriable, "{code} must be read as a policy refusal");
4878 }
4879 }
4880
4881 #[test]
4888 fn a_github_code_in_a_branch_name_is_not_a_policy_refusal() {
4889 for message in [
4890 "! [rejected] abc -> fix-gh013-secret-scanning (non-fast-forward)",
4891 "! [remote rejected] x -> gh001 (failed to update ref)",
4892 "fatal: unable to access 'https://github.com/org/gh013.git/'",
4893 ] {
4894 let (_, retriable) = classify_push_error(message);
4895 assert!(retriable, "a name is not a status code: {message}");
4896 }
4897 }
4898
4899 #[test]
4901 fn permanent_pr_reconciliation_failures_are_not_retriable() {
4902 for message in [
4903 "GraphQL: No commits between main and goalpool/g_1",
4904 "GraphQL: Draft pull requests are not supported in this repository",
4905 ] {
4906 let (_, retriable) = classify_pr_error(message);
4907 assert!(!retriable, "permanent, must not be retried: {message}");
4908 }
4909
4910 let (_, retriable) = classify_pr_error(
4917 "a pull request for branch \"goalpool/g_1\" into branch \"main\" already exists: #7",
4918 );
4919 assert!(
4920 retriable,
4921 "the error proves a usable pull request exists; the next round adopts it"
4922 );
4923 let (_, retriable) = classify_pr_error("502 Bad Gateway");
4926 assert!(retriable);
4927 }
4928
4929 #[test]
4942 fn a_paragraph_break_in_the_intent_ends_the_commit_subject() {
4943 let f = fixture();
4944 let c = contract();
4945 let wt = f.cut("subject-check", "main");
4946 std::fs::write(wt.join("greet.js"), "console.log('hi');\n").unwrap();
4947
4948 let summary = "Goal: implement greet() in the scratch repo";
4949 let body_text = "Read the goal brief /tmp/car-e2e-2/gp-home/logs/brief.md and \
4950 follow it exactly. This pointer text must not reach the subject.";
4951 let intent = format!("{summary}\n\n{body_text}");
4952
4953 let out = deliver_pr_with(
4954 PrDelivery {
4955 repo: &f.repo,
4956 worktree: &wt,
4957 target_branch: "goalpool/g_subject",
4958 base_branch: "main",
4959 draft: true,
4960 intent: &intent,
4961 contract: &c,
4962 body: "b",
4963 provenance: None,
4964 },
4965 &FakeGh::ok(),
4966 )
4967 .expect("delivery succeeds");
4968
4969 let subject = git(&f.repo, &["log", "-1", "--format=%s", &out.commit]).unwrap();
4970 assert_eq!(
4971 subject.trim(),
4972 summary,
4973 "the subject must be exactly the first paragraph"
4974 );
4975 assert!(
4976 !subject.contains("goal brief"),
4977 "the pointer text must not ride along: {subject}"
4978 );
4979
4980 let body = git(&f.repo, &["log", "-1", "--format=%b", &out.commit]).unwrap();
4982 assert!(
4983 body.contains("goal brief"),
4984 "the remainder belongs in the body: {body}"
4985 );
4986 }
4987
4988 #[test]
4991 fn a_single_paragraph_intent_still_truncates_as_before() {
4992 let short = "Add a --verbose flag to the CLI";
4993 assert_eq!(subject_from_intent(short), short);
4994
4995 let long = "Add a --verbose flag to the export subcommand and thread it through \
4996 every downstream call site so the whole pipeline reports progress";
4997 let subject = subject_from_intent(long);
4998 assert!(subject.ends_with("..."), "{subject}");
4999 assert!(subject.len() <= 72, "len {}: {subject}", subject.len());
5000 assert!(!subject.contains('\n'));
5001
5002 assert_eq!(
5004 subject_from_intent("wrapped over\ntwo lines\n\nbody here"),
5005 "wrapped over two lines"
5006 );
5007 }
5008
5009 #[test]
5018 fn no_runtime_bookkeeping_reaches_the_delivered_tree() {
5019 let f = fixture();
5020 let c = contract();
5021 let wt = f.cut("marker-check", "main");
5022
5023 let gitdir = std::fs::read_to_string(wt.join(".git"))
5026 .ok()
5027 .and_then(|m| {
5028 m.trim()
5029 .strip_prefix("gitdir:")
5030 .map(|g| g.trim().to_string())
5031 })
5032 .map(PathBuf::from)
5033 .expect("a worktree's .git is a file holding a gitdir pointer");
5034 std::fs::write(gitdir.join("car-code-task"), "claimed\n").unwrap();
5035
5036 std::fs::write(wt.join("greet.js"), "console.log('hi');\n").unwrap();
5037 let out = deliver_pr_with(
5038 delivery(&f, &wt, &c, "goalpool/g_marker", true, "b"),
5039 &FakeGh::ok(),
5040 )
5041 .expect("delivery succeeds");
5042
5043 let tree = git(&f.origin, &["ls-tree", "-r", "--name-only", &out.commit]).unwrap();
5044 assert!(
5045 tree.contains("greet.js"),
5046 "the actual work must be there: {tree}"
5047 );
5048 for bookkeeping in ["car-code-task", ".car-code-task"] {
5049 assert!(
5050 !tree.contains(bookkeeping),
5051 "`{bookkeeping}` is runtime bookkeeping and must not reach a reviewed diff: {tree}"
5052 );
5053 }
5054 }
5055
5056 #[test]
5065 fn a_clean_worktree_at_the_base_is_refused_rather_than_pushed_empty() {
5066 let f = fixture();
5067 let c = contract();
5068 let wt = f.cut("empty-round", "main");
5071 let base_tip = git(&wt, &["rev-parse", "HEAD"]).unwrap().trim().to_string();
5072
5073 let err = deliver_pr_with(
5074 delivery(&f, &wt, &c, "goalpool/g_empty", true, "b"),
5075 &FakeGh::ok(),
5076 )
5077 .expect_err("an empty round must not open a pull request");
5078 assert_eq!(err.stage(), "commit", "{err}");
5079 assert!(
5080 err.reason().contains("nothing to deliver"),
5081 "{}",
5082 err.reason()
5083 );
5084 assert!(err.reason().contains(&base_tip), "{}", err.reason());
5085 assert!(!err.retriable(), "an empty round will be empty again");
5086
5087 assert!(
5089 git(
5090 &f.origin,
5091 &[
5092 "rev-parse",
5093 "--verify",
5094 "--quiet",
5095 "refs/heads/goalpool/g_empty"
5096 ]
5097 )
5098 .is_err(),
5099 "no stray branch may be created for an empty round"
5100 );
5101 }
5102
5103 #[test]
5110 fn a_policy_refusal_wrapped_in_rejection_wording_is_not_a_race() {
5111 let (_, retriable) =
5112 classify_push_error("! [remote rejected] main -> main (pre-receive hook declined)");
5113 assert!(!retriable, "branch protection is not worth retrying");
5114 }
5115
5116 #[test]
5118 fn a_genuine_403_is_still_a_non_retriable_refusal() {
5119 for message in [
5120 "fatal: unable to access 'https://github.com/o/r/': The requested URL returned error: 403",
5121 "remote: Permission to o/r.git denied to car-coder.",
5122 "fatal: Authentication failed for 'https://github.com/o/r/'",
5123 "fatal: could not read Username for 'https://github.com'",
5124 ] {
5125 let (_, retriable) = classify_push_error(message);
5126 assert!(!retriable, "must not be retried: {message}");
5127 }
5128 }
5129
5130 #[test]
5136 fn delivering_onto_the_base_branch_is_refused_before_anything_is_pushed() {
5137 let f = fixture();
5138 let c = contract();
5139 let wt = f.cut("s1", "main");
5140 std::fs::write(wt.join("x.txt"), "unreviewed").unwrap();
5141
5142 let gh = FakeGh::ok();
5143 let err = deliver_pr_with(delivery(&f, &wt, &c, "main", true, "b"), &gh).unwrap_err();
5144
5145 assert_eq!(err.stage(), "preflight", "{err}");
5146 assert!(!err.retriable());
5147 assert!(err.reason().contains("base"), "{err}");
5148 assert!(
5151 git(&wt, &["log", "-1", "--format=%an"])
5152 .unwrap()
5153 .trim()
5154 .ne("car-coder"),
5155 "the worktree must not have been committed"
5156 );
5157 assert!(gh.calls().is_empty(), "{:?}", gh.calls());
5158 }
5159
5160 #[test]
5165 fn a_commit_failure_is_not_mistaken_for_a_clean_worktree() {
5166 let f = fixture();
5167 let c = contract();
5168
5169 let wt = f.cut("s1", "main");
5172 std::fs::write(wt.join("x.txt"), "round one").unwrap();
5173 let first =
5174 deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "b"), &FakeGh::ok()).unwrap();
5175
5176 std::fs::write(
5180 wt.join("y.txt"),
5181 "round two — the work that must not be lost",
5182 )
5183 .unwrap();
5184 git(&f.repo, &["config", "commit.gpgsign", "true"]).unwrap();
5185 git(&f.repo, &["config", "gpg.program", "/nonexistent/gpg"]).unwrap();
5186
5187 let intent = "fix delivery so it reports 'no changes to deliver' correctly";
5188 let d = PrDelivery {
5189 intent,
5190 ..delivery(&f, &wt, &c, TARGET, true, "b")
5191 };
5192 let err = deliver_pr_with(d, &FakeGh::ok()).unwrap_err();
5193
5194 assert_eq!(err.stage(), "commit", "{err}");
5195 assert_eq!(
5198 f.origin_head(TARGET).as_deref(),
5199 Some(first.commit.as_str()),
5200 "the stale commit must not be re-delivered as if it were round 2"
5201 );
5202 }
5203
5204 #[test]
5208 fn a_cross_repository_pull_request_is_not_adopted() {
5209 let raw = r#"[
5210 {"number":200,"state":"OPEN","url":"https://github.com/acme/repo/pull/200",
5211 "isDraft":false,"isCrossRepository":true,"baseRefName":"main"},
5212 {"number":7,"state":"OPEN","url":"https://github.com/acme/repo/pull/7",
5213 "isDraft":false,"isCrossRepository":false,"baseRefName":"main"}
5214 ]"#;
5215 let prs = parse_pr_list(raw).unwrap();
5216 assert_eq!(
5217 prs.iter().map(|p| p.number).collect::<Vec<_>>(),
5218 vec![7],
5219 "only the same-repository pull request may be reconciled"
5220 );
5221 assert!(gh_pr_list_args("b")
5223 .iter()
5224 .any(|a| a == "number,state,url,isDraft,isCrossRepository,baseRefName"));
5225 }
5226
5227 #[test]
5231 fn the_github_repository_is_taken_from_the_same_remote_the_push_uses() {
5232 for (url, expect) in [
5233 ("https://github.com/acme/repo.git", Some("acme/repo")),
5234 ("https://github.com/acme/repo", Some("acme/repo")),
5235 ("git@github.com:acme/repo.git", Some("acme/repo")),
5236 ("ssh://git@github.com/acme/repo.git", Some("acme/repo")),
5237 (
5238 "https://x-token@github.com/acme/repo.git",
5239 Some("acme/repo"),
5240 ),
5241 (
5244 "git@github.example.com:acme/repo.git",
5245 Some("github.example.com/acme/repo"),
5246 ),
5247 ("/srv/mirrors/repo.git", None),
5250 ("../sibling", None),
5251 ] {
5252 assert_eq!(
5253 parse_github_repo_spec(url).as_deref(),
5254 expect,
5255 "for `{url}`"
5256 );
5257 }
5258
5259 let f = fixture();
5262 assert!(gh_repo_args(&f.repo).is_empty());
5263 git(
5264 &f.repo,
5265 &[
5266 "remote",
5267 "set-url",
5268 "origin",
5269 "git@github.com:acme/repo.git",
5270 ],
5271 )
5272 .unwrap();
5273 assert_eq!(
5274 gh_repo_args(&f.repo),
5275 vec!["--repo".to_string(), "acme/repo".to_string()]
5276 );
5277 }
5278
5279 #[test]
5283 fn a_404_or_401_push_failure_is_permanent_not_a_race() {
5284 for message in [
5285 "remote: Repository not found.\nfatal: repository \
5286 'https://github.com/o/private.git/' not found",
5287 "ERROR: Repository not found.\nfatal: Could not read from remote repository.",
5288 "fatal: unable to access 'https://github.com/o/r/': The requested URL returned \
5289 error: 401",
5290 "fatal: 'origin' does not appear to be a git repository",
5291 ] {
5292 let (_, retriable) = classify_push_error(message);
5293 assert!(!retriable, "must not be retried forever: {message}");
5294 }
5295 let (_, retriable) =
5297 classify_push_error("! [rejected] goalpool/g_404 -> goalpool/g_404 (non-fast-forward)");
5298 assert!(retriable, "a moved branch is still worth another round");
5299 }
5300
5301 #[test]
5306 fn git_does_not_inherit_the_repository_from_the_environment() {
5307 let production = MERGE_RS_SOURCE
5308 .split_once("mod tests {")
5309 .map(|(head, _)| head)
5310 .unwrap_or(MERGE_RS_SOURCE);
5311 for var in ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"] {
5312 assert!(
5313 production.contains(&format!(".env_remove(\"{var}\")")),
5314 "`git()` must clear {var}, which otherwise overrides `-C`"
5315 );
5316 }
5317 }
5318
5319 #[test]
5324 fn a_worktree_holding_only_untracked_work_is_not_read_as_clean() {
5325 let f = fixture();
5326 let c = contract();
5327 git(&f.repo, &["config", "status.showUntrackedFiles", "no"]).unwrap();
5328 let wt = f.cut("s1", "main");
5329 std::fs::write(wt.join("brand-new.txt"), "a day of work").unwrap();
5330
5331 let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "b"), &FakeGh::ok()).unwrap();
5332
5333 assert_eq!(
5334 git(
5335 &f.origin,
5336 &["show", &format!("refs/heads/{TARGET}:brand-new.txt")]
5337 )
5338 .unwrap(),
5339 "a day of work",
5340 "the untracked work must be in the delivered commit"
5341 );
5342 assert_eq!(out.pr_action, PrAction::Opened);
5343 }
5344
5345 #[test]
5357 fn a_permanent_phrase_in_the_body_cannot_make_a_transient_failure_permanent() {
5358 let f = fixture();
5359 let c = contract();
5360
5361 let wt1 = f.cut("s1", "main");
5363 std::fs::write(wt1.join("x.txt"), "one").unwrap();
5364 deliver_pr_with(delivery(&f, &wt1, &c, TARGET, false, "one"), &FakeGh::ok()).unwrap();
5365
5366 git(&f.repo, &["fetch", "-q", "origin"]).unwrap();
5368 let wt2 = f.cut("s2", &format!("origin/{TARGET}"));
5369 std::fs::write(wt2.join("y.txt"), "two").unwrap();
5370
5371 let body = "This round fixes the 'no commits between' error on empty deliveries.";
5372 let gh = FakeGh::failing_set_body("HTTP 502: Bad Gateway (https://api.github.com/…)");
5373 *gh.prs.lock().unwrap() = vec![PrRecord {
5374 number: 101,
5375 state: PrState::Open,
5376 url: "https://github.com/acme/repo/pull/101".into(),
5377 is_draft: false,
5378 base: "main".into(),
5379 }];
5380
5381 let err = deliver_pr_with(delivery(&f, &wt2, &c, TARGET, false, body), &gh).unwrap_err();
5382 assert!(
5383 err.retriable(),
5384 "a 502 is retriable however the body is worded: {err:?}"
5385 );
5386
5387 let gh2 = FakeGh::failing_set_body("GraphQL: No commits between main and goalpool/g_1");
5390 *gh2.prs.lock().unwrap() = vec![PrRecord {
5391 number: 101,
5392 state: PrState::Open,
5393 url: "https://github.com/acme/repo/pull/101".into(),
5394 is_draft: false,
5395 base: "main".into(),
5396 }];
5397 let wt3 = f.cut("s3", &format!("origin/{TARGET}"));
5398 std::fs::write(wt3.join("z.txt"), "three").unwrap();
5399 let err2 =
5400 deliver_pr_with(delivery(&f, &wt3, &c, TARGET, false, "plain"), &gh2).unwrap_err();
5401 assert!(!err2.retriable(), "{err2:?}");
5402 }
5403
5404 #[test]
5417 fn an_open_pull_request_into_another_base_is_refused_rather_than_reconciled() {
5418 let f = fixture();
5419 let c = contract();
5420 let wt = f.cut("s1", "main");
5421 std::fs::write(wt.join("x.txt"), "work").unwrap();
5422
5423 let gh = FakeGh::with_prs(vec![
5426 PrRecord {
5427 number: 10,
5428 state: PrState::Open,
5429 url: "https://github.com/acme/repo/pull/10".into(),
5430 is_draft: false,
5431 base: "main".into(),
5432 },
5433 PrRecord {
5434 number: 12,
5435 state: PrState::Open,
5436 url: "https://github.com/acme/repo/pull/12".into(),
5437 is_draft: false,
5438 base: "release/2.1".into(),
5439 },
5440 ]);
5441
5442 let err =
5443 deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap_err();
5444
5445 assert!(
5446 matches!(err, DeliveryFailure::Preflight { .. }),
5447 "an ambiguous head is refused before the commit: {err:?}"
5448 );
5449 assert!(
5450 !err.retriable(),
5451 "retrying changes nothing — a human closes #12 or picks another target branch"
5452 );
5453 assert!(
5455 err.reason().contains("#12") && err.reason().contains("release/2.1"),
5456 "{}",
5457 err.reason()
5458 );
5459
5460 assert_eq!(f.origin_head(TARGET), None, "nothing may be pushed");
5463 assert!(
5464 !gh.calls()
5465 .iter()
5466 .any(|c| c.starts_with("set_body") || c.starts_with("create")),
5467 "{:?}",
5468 gh.calls()
5469 );
5470 }
5471
5472 #[test]
5477 fn a_human_pull_request_into_another_base_parks_delivery_before_the_push() {
5478 let f = fixture();
5479 let c = contract();
5480 let wt = f.cut("s1", "main");
5481 std::fs::write(wt.join("x.txt"), "unreviewed model output").unwrap();
5482
5483 let gh = FakeGh::with_prs(vec![PrRecord {
5484 number: 200,
5485 state: PrState::Open,
5486 url: "https://github.com/acme/repo/pull/200".into(),
5487 is_draft: false,
5488 base: "release/2.1".into(),
5489 }]);
5490
5491 let err =
5492 deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap_err();
5493
5494 assert_eq!(err.stage(), "preflight");
5495 assert!(!err.retriable(), "{err:?}");
5496 assert!(err.reason().contains("#200"), "{}", err.reason());
5497 assert_eq!(
5498 f.origin_head(TARGET),
5499 None,
5500 "the model's commits must never reach a branch #200 tracks"
5501 );
5502 assert!(
5503 !gh.calls().iter().any(|c| c.starts_with("create")),
5504 "no second pull request is opened to paper over the refusal: {:?}",
5505 gh.calls()
5506 );
5507 }
5508
5509 #[test]
5512 fn a_changed_base_is_refused_while_the_old_pull_request_is_open() {
5513 let f = fixture();
5514 let c = contract();
5515 let wt = f.cut("s1", "main");
5516 std::fs::write(wt.join("x.txt"), "work").unwrap();
5517
5518 let gh = FakeGh::with_prs(vec![PrRecord {
5519 number: 10,
5520 state: PrState::Open,
5521 url: "https://github.com/acme/repo/pull/10".into(),
5522 is_draft: false,
5523 base: "main".into(),
5524 }]);
5525 let mut d = delivery(&f, &wt, &c, TARGET, false, "body");
5526 d.base_branch = "release/2.1";
5527
5528 let err = deliver_pr_with(d, &gh).unwrap_err();
5529 assert_eq!(err.stage(), "preflight");
5530 assert!(err.reason().contains("#10"), "{}", err.reason());
5531 assert_eq!(f.origin_head(TARGET), None);
5532 }
5533
5534 #[test]
5537 fn a_changed_base_opens_its_own_pull_request_once_the_old_one_is_closed() {
5538 let f = fixture();
5539 let c = contract();
5540 let wt = f.cut("s1", "main");
5541 std::fs::write(wt.join("x.txt"), "work").unwrap();
5542
5543 let gh = FakeGh::with_prs(vec![PrRecord {
5544 number: 10,
5545 state: PrState::ClosedUnmerged,
5546 url: "https://github.com/acme/repo/pull/10".into(),
5547 is_draft: false,
5548 base: "main".into(),
5549 }]);
5550 let mut d = delivery(&f, &wt, &c, TARGET, false, "body");
5551 d.base_branch = "release/2.1";
5552
5553 let out = deliver_pr_with(d, &gh).unwrap();
5554 assert_eq!(out.pr_action, PrAction::Opened);
5555 assert_ne!(out.pr_number, 10);
5556 assert!(
5557 gh.calls()
5558 .iter()
5559 .any(|c| c.contains("create head=") && c.contains("base=release/2.1")),
5560 "{:?}",
5561 gh.calls()
5562 );
5563 assert!(
5567 !gh.calls().iter().any(|c| c.starts_with("set_body")),
5568 "{:?}",
5569 gh.calls()
5570 );
5571 }
5572
5573 #[test]
5576 fn a_merged_pull_request_into_another_base_does_not_park_delivery() {
5577 let f = fixture();
5578 let c = contract();
5579 let wt = f.cut("s1", "main");
5580 std::fs::write(wt.join("x.txt"), "work").unwrap();
5581
5582 let gh = FakeGh::with_prs(vec![
5583 PrRecord {
5584 number: 10,
5585 state: PrState::Open,
5586 url: "https://github.com/acme/repo/pull/10".into(),
5587 is_draft: false,
5588 base: "main".into(),
5589 },
5590 PrRecord {
5591 number: 12,
5592 state: PrState::Merged,
5593 url: "https://github.com/acme/repo/pull/12".into(),
5594 is_draft: false,
5595 base: "release/2.1".into(),
5596 },
5597 ]);
5598
5599 let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap();
5600 assert_eq!(out.pr_action, PrAction::Updated);
5601 assert_eq!(out.pr_number, 10);
5602 }
5603
5604 #[test]
5609 fn a_cross_repository_pull_request_does_not_park_delivery() {
5610 let parsed = parse_pr_list(
5611 r#"[
5612 {"number": 77, "state": "OPEN", "url": "u77", "isDraft": false,
5613 "baseRefName": "release/2.1", "isCrossRepository": true},
5614 {"number": 10, "state": "OPEN", "url": "u10", "isDraft": false,
5615 "baseRefName": "main", "isCrossRepository": false}
5616 ]"#,
5617 )
5618 .unwrap();
5619 assert_eq!(parsed.len(), 1, "the fork entry is dropped: {parsed:?}");
5620
5621 let f = fixture();
5622 let c = contract();
5623 let wt = f.cut("s1", "main");
5624 std::fs::write(wt.join("x.txt"), "work").unwrap();
5625 let gh = FakeGh::with_prs(parsed);
5626
5627 let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap();
5628 assert_eq!(out.pr_action, PrAction::Updated);
5629 assert_eq!(out.pr_number, 10);
5630 }
5631
5632 #[test]
5636 fn credential_prompts_that_can_never_be_answered_are_permanent() {
5637 for message in [
5638 "fatal: could not read Username for 'https://github.com': No such device or address",
5639 "fatal: could not read Password for 'https://someuser@github.com': \
5640 No such device or address",
5641 "fatal: could not read Username for 'https://github.com': terminal prompts disabled",
5642 "Host key verification failed.\nfatal: Could not read from remote repository.",
5643 ] {
5644 let (_, retriable) = classify_push_error(message);
5645 assert!(
5646 !retriable,
5647 "a credential that will never appear must not be retried: {message}"
5648 );
5649 }
5650 let (_, retriable) =
5653 classify_push_error("! [rejected] goalpool/g_pw -> goalpool/g_pw (non-fast-forward)");
5654 assert!(retriable);
5655 }
5656
5657 #[test]
5661 fn git_children_cannot_open_a_terminal_prompt() {
5662 let dir = tempfile::tempdir().unwrap();
5663 git(dir.path(), &["init", "-q", "-b", "main"]).unwrap();
5664 let seen = git(
5667 dir.path(),
5668 &[
5669 "-c",
5670 "alias.envprobe=!printf %s \"${GIT_TERMINAL_PROMPT-unset}\"",
5671 "envprobe",
5672 ],
5673 )
5674 .unwrap();
5675 assert_eq!(
5676 seen.trim(),
5677 "0",
5678 "GIT_TERMINAL_PROMPT must be 0 for every git this module runs"
5679 );
5680 }
5681
5682 #[test]
5685 fn a_subprocess_that_never_finishes_is_killed_and_reported() {
5686 let mut cmd = std::process::Command::new("sleep");
5687 cmd.arg("60");
5688 let started = std::time::Instant::now();
5689 let err = run_capped_for(cmd, std::time::Duration::from_millis(300)).err();
5690 assert!(
5691 matches!(err, Some(RunFailure::TimedOut(_))),
5692 "the child must be killed, not waited on"
5693 );
5694 assert!(
5695 started.elapsed() < std::time::Duration::from_secs(20),
5696 "the kill must not wait for the child's own exit"
5697 );
5698 }
5699
5700 #[test]
5704 fn branch_mode_re_delivers_a_clean_worktree_whose_head_is_ahead_of_the_base() {
5705 let f = fixture();
5706 let c = contract();
5707 let wt = f.cut("s1", "main");
5708 std::fs::write(wt.join("x.txt"), "work").unwrap();
5709 let first =
5711 publish_branch_headless(&f.repo, &wt, "r1", "make x exist", &c, "main", None).unwrap();
5712 assert_eq!(first, "car/coder/r1");
5713
5714 let second =
5716 publish_branch_headless(&f.repo, &wt, "r2", "make x exist", &c, "main", None).unwrap();
5717 assert_eq!(second, "car/coder/r2");
5718 assert_eq!(
5719 git(&f.repo, &["rev-parse", "car/coder/r1"]).unwrap().trim(),
5720 git(&f.repo, &["rev-parse", "car/coder/r2"]).unwrap().trim(),
5721 "the same commit is re-delivered, not redone"
5722 );
5723 }
5724
5725 #[test]
5729 fn branch_mode_still_refuses_a_clean_worktree_that_holds_no_work() {
5730 let f = fixture();
5731 let c = contract();
5732 let wt = f.cut("s1", "main");
5733 let err =
5734 publish_branch_headless(&f.repo, &wt, "r1", "noop", &c, "main", None).unwrap_err();
5735 assert!(err.contains("nothing to deliver"), "{err}");
5736 }
5737
5738 #[test]
5742 fn a_crlf_intent_still_stops_at_its_blank_line() {
5743 let intent =
5744 "Add the retry shim\r\n\r\nPointers:\r\n- see src/net.rs\r\n- and the docs\r\n";
5745 let subject = subject_from_intent(intent);
5746 assert_eq!(subject, "Add the retry shim");
5747 assert!(!subject.contains('\r'), "{subject:?}");
5748 assert!(!subject_from_intent("a\rb\r\nc").contains('\r'));
5750 }
5751
5752 #[test]
5756 fn forge_command_shapes_elide_the_title_and_body() {
5757 for args in [
5758 gh_pr_create_args("head", "main", "a title", "a very long body", false),
5759 az_pr_create_args("head", "main", "a title", "a very long body", false),
5760 ] {
5761 let shape = gh_subcommand_shape(&args);
5762 assert!(!shape.contains("a title"), "{shape}");
5763 assert!(!shape.contains("a very long body"), "{shape}");
5764 assert!(shape.contains("--title"), "{shape}");
5765 }
5766 }
5767}