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 body = checkpoint_body(intent, contract, provenance);
225 git(
226 worktree,
227 &[
228 "-c",
229 "user.name=car-coder",
230 "-c",
231 "user.email=coder@parslee.ai",
232 "commit",
233 "-m",
234 &subject,
235 "-m",
236 &body,
237 ],
238 )?;
239 Ok(CommitOutcome::Made(
240 git(worktree, &["rev-parse", "HEAD"])?.trim().to_string(),
241 ))
242}
243
244fn checkpoint_body(intent: &str, contract: &OutcomeContract, provenance: Option<&str>) -> String {
245 let mut body = format!(
246 "Authored by CAR Coder.\n\nIntent:\n{}\n\nOutcome contract (all checks passed):\n{}",
247 intent.trim(),
248 contract.render()
249 );
250 if let Some(provenance) = provenance {
251 body.push_str("\n\n");
252 body.push_str(provenance);
253 }
254 body
255}
256
257fn require_commit(outcome: CommitOutcome) -> Result<String, String> {
260 match outcome {
261 CommitOutcome::Made(sha) => Ok(sha),
262 CommitOutcome::NothingToCommit => {
263 Err("no changes to deliver — the worktree is clean".to_string())
264 }
265 }
266}
267
268fn head_beyond_base(worktree: &Path, base_branch: &str) -> Result<String, String> {
292 let head = git(worktree, &["rev-parse", "HEAD"])?.trim().to_string();
293 let base_ref = ["origin/", ""]
294 .iter()
295 .map(|p| format!("{p}{base_branch}"))
296 .find(|r| git(worktree, &["rev-parse", "--verify", "--quiet", r]).is_ok())
297 .ok_or_else(|| {
298 format!(
299 "the worktree is clean and neither origin/{base_branch} nor {base_branch} \
300 resolves, so whether there is anything to deliver cannot be determined"
301 )
302 })?;
303 if git(worktree, &["merge-base", "--is-ancestor", &head, &base_ref]).is_ok() {
304 return Err(format!(
305 "nothing to deliver: the worktree is clean and its HEAD ({head}) is already \
306 contained in {base_ref}, so there is no work to deliver"
307 ));
308 }
309 Ok(head)
310}
311
312#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
315pub struct CheckoutIdentity {
316 pub head: String,
317 pub reference: String,
318}
319
320impl CheckoutIdentity {
321 pub fn read(repo: &Path) -> Result<Self, String> {
322 Ok(Self {
323 head: git(repo, &["rev-parse", "HEAD"])?.trim().into(),
324 reference: git(repo, &["rev-parse", "--symbolic-full-name", "HEAD"])?
325 .trim()
326 .into(),
327 })
328 }
329}
330
331#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
335pub struct ReviewIdentity {
336 pub head: String,
337 pub tree: String,
338}
339
340impl ReviewIdentity {
341 pub fn read(worktree: &Path) -> Result<Self, String> {
342 if !git(worktree, &["diff", "--name-only", "-z", "--"])?.is_empty()
343 || !git(
344 worktree,
345 &["ls-files", "--others", "--exclude-standard", "-z"],
346 )?
347 .is_empty()
348 {
349 return Err("Task files changed after the review diff was staged. Review and verify the updated work before delivery.".into());
350 }
351 Ok(Self {
352 head: git(worktree, &["rev-parse", "HEAD"])?.trim().into(),
353 tree: git(worktree, &["write-tree"])?.trim().into(),
354 })
355 }
356
357 pub fn validate(&self, worktree: &Path) -> Result<(), String> {
358 if &Self::read(worktree)? != self {
359 return Err("Task revision or staged files changed after review. Review and verify the updated work before delivery.".into());
360 }
361 Ok(())
362 }
363}
364
365pub(super) fn snapshot_checkout(repo: &Path, session_id: &str) -> Result<Option<String>, String> {
369 if session_id.is_empty()
370 || !session_id
371 .bytes()
372 .all(|c| c.is_ascii_alphanumeric() || c == b'-')
373 {
374 return Err("invalid checkout snapshot session id".into());
375 }
376 if !git(repo, &["ls-files", "--unmerged"])?.is_empty() {
377 return Err("Resolve the checkout's merge conflicts before starting a coding task.".into());
378 }
379 let identity = CheckoutIdentity::read(repo)?;
380 let directory = tempfile::tempdir().map_err(|e| e.to_string())?;
381 let index = directory.path().join("index");
382 let isolated = |args: &[&str]| -> Result<String, String> {
383 git_bytes_with_index(repo, args, Some(&index))
384 .map(|bytes| String::from_utf8_lossy(&bytes).trim().to_string())
385 };
386 isolated(&["read-tree", &identity.head])?;
387 isolated(&["add", "-A", "--", "."])?;
388 let tree = isolated(&["write-tree"])?;
389 if CheckoutIdentity::read(repo)? != identity {
390 return Err("Checkout revision changed while capturing task inputs; start again.".into());
391 }
392 if tree == git(repo, &["rev-parse", "HEAD^{tree}"])?.trim() {
393 return Ok(None);
394 }
395 let commit = git(
396 repo,
397 &[
398 "-c",
399 "user.name=car-coder",
400 "-c",
401 "user.email=coder@parslee.ai",
402 "commit-tree",
403 &tree,
404 "-p",
405 &identity.head,
406 "-m",
407 "CAR Coder: preserve checkout inputs before task",
408 ],
409 )?
410 .trim()
411 .to_string();
412 git(
413 repo,
414 &[
415 "update-ref",
416 &format!("refs/car/coder-inputs/{session_id}"),
417 &commit,
418 ],
419 )?;
420 Ok(Some(commit))
421}
422
423pub(super) fn apply_to_checkout(
427 repo: &Path,
428 worktree: &Path,
429 session_id: &str,
430 expected: &CheckoutIdentity,
431 intent: &str,
432 contract: &OutcomeContract,
433 provenance: Option<&str>,
434) -> Result<(String, bool), String> {
435 if session_id.is_empty()
436 || !session_id
437 .bytes()
438 .all(|c| c.is_ascii_alphanumeric() || c == b'-')
439 {
440 return Err("invalid checkout delivery session id".into());
441 }
442 if &CheckoutIdentity::read(repo)? != expected {
443 return Err("checkout revision or branch changed since this task started; work remains in its worktree. Review the new checkout before applying".into());
444 }
445 git(worktree, &["add", "-AN"])?;
446 let patch = git_bytes(
447 worktree,
448 &[
449 "-c",
450 "core.quotePath=true",
451 "-c",
452 "core.autocrlf=false",
453 "diff",
454 "HEAD",
455 "--binary",
456 "--no-ext-diff",
457 "--no-textconv",
458 ],
459 )?;
460 if patch.is_empty() {
461 return Err("no changes to apply".into());
462 }
463 let patch_file = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?;
464 std::fs::write(patch_file.path(), &patch).map_err(|e| e.to_string())?;
465 let patch_path = patch_file
466 .path()
467 .to_str()
468 .ok_or("non-UTF8 temporary patch path")?;
469 let already_applied = if git(
470 repo,
471 &["apply", "--check", "--whitespace=nowarn", patch_path],
472 )
473 .is_ok()
474 {
475 false
476 } else if git(
477 repo,
478 &[
479 "apply",
480 "--reverse",
481 "--check",
482 "--whitespace=nowarn",
483 patch_path,
484 ],
485 )
486 .is_ok()
487 {
488 true
489 } else {
490 return Err("reviewed changes conflict with the current checkout; no changes applied. Existing edits and the result worktree are preserved".into());
491 };
492 git(worktree, &["add", "-A"])?;
496 let tree = git(worktree, &["write-tree"])?;
497 let parent = git(worktree, &["rev-parse", "HEAD"])?;
498 let commit = git(
499 worktree,
500 &[
501 "-c",
502 "user.name=car-coder",
503 "-c",
504 "user.email=coder@parslee.ai",
505 "commit-tree",
506 tree.trim(),
507 "-p",
508 parent.trim(),
509 "-m",
510 &subject_from_intent(intent),
511 "-m",
512 &checkpoint_body(intent, contract, provenance),
513 ],
514 )?
515 .trim()
516 .to_string();
517 let checkpoint = format!("refs/car/coder/{session_id}");
518 git(repo, &["update-ref", &checkpoint, &commit])?;
519 if &CheckoutIdentity::read(repo)? != expected {
521 return Err(
522 "checkout changed during delivery preparation; changes remain in the worktree".into(),
523 );
524 }
525 if !already_applied {
526 git(repo, &["apply", "--whitespace=nowarn", patch_path]).map_err(|e| {
527 format!("checkout apply failed; retain the result worktree for recovery: {e}")
528 })?;
529 }
530 Ok((commit, already_applied))
531}
532
533pub fn publish_branch(
535 repo: &Path,
536 worktree: &Path,
537 short_id: &str,
538 intent: &str,
539 contract: &OutcomeContract,
540 provenance: Option<&str>,
541) -> Result<String, String> {
542 publish_branch_with_commit(repo, worktree, short_id, intent, contract, provenance)
543 .map(|(branch, _)| branch)
544}
545
546pub(super) fn publish_branch_with_commit(
548 repo: &Path,
549 worktree: &Path,
550 short_id: &str,
551 intent: &str,
552 contract: &OutcomeContract,
553 provenance: Option<&str>,
554) -> Result<(String, String), String> {
555 let commit = require_commit(commit_worktree(worktree, intent, contract, provenance)?)?;
556 let branch = format!("car/coder/{short_id}");
557 git(repo, &["branch", &branch, &commit])?;
560 Ok((branch, commit))
561}
562
563fn changed_paths(repo: &Path, from: &str, to: &str) -> Result<Vec<String>, String> {
565 let raw = git(
566 repo,
567 &[
568 "-c",
569 "core.quotePath=false",
570 "diff",
571 "--name-only",
572 "--no-renames",
573 "-z",
574 from,
575 to,
576 ],
577 )?;
578 Ok(raw
579 .split('\0')
580 .filter(|path| !path.is_empty())
581 .map(str::to_string)
582 .collect())
583}
584
585pub(super) fn publish_branch_off_snapshot(
600 repo: &Path,
601 worktree: &Path,
602 short_id: &str,
603 intent: &str,
604 contract: &OutcomeContract,
605 provenance: Option<&str>,
606 snapshot: &str,
607) -> Result<(String, String), String> {
608 let checkout_head = git(repo, &["rev-parse", &format!("{snapshot}^")])
609 .map_err(|e| format!("the task's private input snapshot has no parent commit: {e}"))?
610 .trim()
611 .to_string();
612 let user_paths = changed_paths(repo, &checkout_head, snapshot)?;
613 let mut agent_paths = changed_paths(worktree, snapshot, "HEAD")?;
618 agent_paths.extend(worktree_changed_paths(worktree)?);
619 let mut overlap: Vec<&String> = agent_paths
620 .iter()
621 .filter(|path| user_paths.contains(path))
622 .collect();
623 overlap.sort();
624 overlap.dedup();
625 if !overlap.is_empty() {
626 let named: Vec<&str> = overlap.iter().take(5).map(|p| p.as_str()).collect();
627 return Err(format!(
628 "your checkout has uncommitted changes to {} and this task changed the same \
629 file(s), so a branch cannot be published without carrying your work into it. \
630 Apply the result to your checkout instead, or commit/stash those changes and \
631 run the task again — the result is preserved in its worktree either way",
632 named.join(", ")
633 ));
634 }
635 let commit = require_commit(commit_worktree(worktree, intent, contract, provenance)?)?;
636 let patch = git_bytes(
637 repo,
638 &[
639 "-c",
640 "core.quotePath=true",
641 "-c",
642 "core.autocrlf=false",
643 "diff",
644 "--binary",
645 "--no-renames",
646 "--no-ext-diff",
647 "--no-textconv",
648 snapshot,
649 &commit,
650 ],
651 )?;
652 if patch.is_empty() {
653 return Err(
654 "no changes to deliver — the result is identical to the files this task started from"
655 .into(),
656 );
657 }
658 let patch_file = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?;
659 std::fs::write(patch_file.path(), &patch).map_err(|e| e.to_string())?;
660 let patch_path = patch_file
661 .path()
662 .to_str()
663 .ok_or("non-UTF8 temporary patch path")?;
664 let directory = tempfile::tempdir().map_err(|e| e.to_string())?;
665 let index = directory.path().join("index");
666 let isolated = |args: &[&str]| -> Result<String, String> {
667 git_bytes_with_index(repo, args, Some(&index))
668 .map(|bytes| String::from_utf8_lossy(&bytes).trim().to_string())
669 };
670 isolated(&["read-tree", &checkout_head])?;
671 isolated(&["apply", "--cached", "--whitespace=nowarn", patch_path]).map_err(|e| {
672 format!("the reviewed changes do not apply to your checkout's revision: {e}")
673 })?;
674 let tree = isolated(&["write-tree"])?;
675 let message = git(repo, &["log", "-1", "--format=%B", &commit])?;
676 let delivered = git(
677 repo,
678 &[
679 "-c",
680 "user.name=car-coder",
681 "-c",
682 "user.email=coder@parslee.ai",
683 "commit-tree",
684 &tree,
685 "-p",
686 &checkout_head,
687 "-m",
688 message.trim(),
689 ],
690 )?
691 .trim()
692 .to_string();
693 let branch = format!("car/coder/{short_id}");
694 git(repo, &["branch", &branch, &delivered])?;
695 Ok((branch, delivered))
696}
697
698fn worktree_changed_paths(worktree: &Path) -> Result<Vec<String>, String> {
702 let raw = git(
703 worktree,
704 &[
705 "status",
706 "--porcelain",
707 "-z",
708 "--no-renames",
709 "--untracked-files=all",
710 ],
711 )?;
712 Ok(raw
713 .split('\0')
714 .filter(|entry| entry.len() > 3)
715 .map(|entry| entry[3..].to_string())
717 .collect())
718}
719
720pub fn publish_branch_headless(
737 repo: &Path,
738 worktree: &Path,
739 short_id: &str,
740 intent: &str,
741 contract: &OutcomeContract,
742 base_branch: &str,
743 provenance: Option<&str>,
744) -> Result<String, String> {
745 let commit = match commit_worktree(worktree, intent, contract, provenance)? {
746 CommitOutcome::Made(sha) => sha,
747 CommitOutcome::NothingToCommit => head_beyond_base(worktree, base_branch)?,
748 };
749 let branch = format!("car/coder/{short_id}");
750 git(repo, &["branch", &branch, &commit])?;
751 Ok(branch)
752}
753
754pub fn commit_to_main(
761 repo: &Path,
762 worktree: &Path,
763 intent: &str,
764 contract: &OutcomeContract,
765 provenance: Option<&str>,
766) -> Result<String, String> {
767 let commit = require_commit(commit_worktree(worktree, intent, contract, provenance)?)?;
768 git(repo, &["merge", "--ff-only", &commit]).map_err(|e| {
769 format!("could not fast-forward the project's main branch (it moved since the session started): {e}")
770 })?;
771 Ok(commit)
772}
773
774#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
776pub struct StagedDiff {
777 pub stat: String,
780 pub patch: String,
782 pub truncated: bool,
786 pub full_bytes: usize,
789 pub changed_paths: Vec<String>,
795}
796
797pub fn stage_and_diff(worktree: &Path, patch_cap_bytes: usize) -> Result<StagedDiff, String> {
806 git(worktree, &["add", "-A"])?;
807 read_staged_diff(worktree, patch_cap_bytes)
808}
809
810pub(super) fn read_staged_diff(
812 worktree: &Path,
813 patch_cap_bytes: usize,
814) -> Result<StagedDiff, String> {
815 let stat = git(worktree, &["diff", "--cached", "--stat"])?;
816 let patch = git(worktree, &["diff", "--cached"])?;
817 let names = git(
818 worktree,
819 &[
820 "-c",
826 "core.quotepath=false",
827 "diff",
828 "--cached",
829 "--name-status",
838 "-z",
839 ],
840 )?;
841 let changed_paths = parse_name_status_z(&names);
842 let full_bytes = patch.len();
843 Ok(StagedDiff {
844 patch: super::shell_tool::tail(&patch, patch_cap_bytes),
845 truncated: full_bytes > patch_cap_bytes,
846 full_bytes,
847 stat,
848 changed_paths,
849 })
850}
851
852fn parse_name_status_z(raw: &str) -> Vec<String> {
863 let mut out = Vec::new();
864 let mut fields = raw.split('\0').filter(|f| !f.is_empty());
865 while let Some(status) = fields.next() {
866 let bytes = status.as_bytes();
875 let well_formed = status.len() <= 4
876 && matches!(
877 bytes[0],
878 b'A' | b'C' | b'D' | b'M' | b'R' | b'T' | b'U' | b'X' | b'B'
879 )
880 && status[1..].bytes().all(|b| b.is_ascii_digit());
881 if !well_formed {
882 tracing::warn!(
883 status = %status,
884 "unexpected field in `git diff --name-status -z`; changed-path list truncated \
885 rather than risk a desynchronized parse"
886 );
887 break;
888 }
889 let two_paths = bytes[0] == b'R' || bytes[0] == b'C';
891 let Some(first) = fields.next() else {
892 tracing::warn!(status = %status, "name-status stream ended mid-entry");
893 break;
894 };
895 out.push(first.to_string());
896 if two_paths {
897 match fields.next() {
898 Some(second) => out.push(second.to_string()),
899 None => {
902 tracing::warn!(status = %status, "rename/copy entry missing its destination");
903 break;
904 }
905 }
906 }
907 }
908 out.sort();
909 out.dedup();
910 out
911}
912
913pub(crate) fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
925 git_bytes(dir, args).map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
926}
927
928fn git_bytes(dir: &Path, args: &[&str]) -> Result<Vec<u8>, String> {
929 git_bytes_with_index(dir, args, None)
930}
931
932fn git_bytes_with_index(
933 dir: &Path,
934 args: &[&str],
935 index: Option<&Path>,
936) -> Result<Vec<u8>, String> {
937 let mut cmd = std::process::Command::new("git");
938 cmd.env_remove("GIT_DIR")
939 .env_remove("GIT_WORK_TREE")
940 .env_remove("GIT_INDEX_FILE")
941 .env_remove("GIT_OBJECT_DIRECTORY")
942 .env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES")
943 .env_remove("GIT_COMMON_DIR")
944 .arg("-C")
945 .arg(dir)
946 .args(args);
947 if let Some(index) = index {
948 cmd.env("GIT_INDEX_FILE", index);
949 }
950 no_interactive_prompts(&mut cmd);
951 let out = run_capped(cmd).map_err(|e| match e {
952 RunFailure::Spawn(io) => format!("git {args:?}: {io}"),
953 RunFailure::TimedOut(secs) => format!(
954 "git {args:?} timed out after {secs}s and was killed; treat it as a transport failure"
955 ),
956 })?;
957 if out.status.success() {
958 Ok(out.stdout)
959 } else {
960 Err(format!(
961 "git {args:?} failed: {}",
962 String::from_utf8_lossy(&out.stderr).trim()
963 ))
964 }
965}
966
967fn no_interactive_prompts(cmd: &mut std::process::Command) {
984 cmd.env("GIT_TERMINAL_PROMPT", "0")
985 .env("GIT_ASKPASS", "")
986 .env("SSH_ASKPASS", "")
987 .env("SSH_ASKPASS_REQUIRE", "never");
988}
989
990const SUBPROCESS_TIMEOUT_SECS: u64 = 900;
996
997enum RunFailure {
999 Spawn(std::io::Error),
1001 TimedOut(u64),
1003}
1004
1005fn run_capped(cmd: std::process::Command) -> Result<std::process::Output, RunFailure> {
1020 run_capped_for(cmd, std::time::Duration::from_secs(SUBPROCESS_TIMEOUT_SECS))
1021}
1022
1023fn run_capped_for(
1027 mut cmd: std::process::Command,
1028 timeout: std::time::Duration,
1029) -> Result<std::process::Output, RunFailure> {
1030 use std::io::Read as _;
1031 use std::process::Stdio;
1032
1033 let mut child = cmd
1034 .stdin(Stdio::null())
1035 .stdout(Stdio::piped())
1036 .stderr(Stdio::piped())
1037 .spawn()
1038 .map_err(RunFailure::Spawn)?;
1039
1040 let mut child_out = child.stdout.take().expect("stdout piped");
1041 let mut child_err = child.stderr.take().expect("stderr piped");
1042 let out_reader = std::thread::spawn(move || {
1043 let mut buf = Vec::new();
1044 let _ = child_out.read_to_end(&mut buf);
1045 buf
1046 });
1047 let err_reader = std::thread::spawn(move || {
1048 let mut buf = Vec::new();
1049 let _ = child_err.read_to_end(&mut buf);
1050 buf
1051 });
1052
1053 let deadline = std::time::Instant::now() + timeout;
1054 let status = loop {
1055 match child.try_wait() {
1056 Ok(Some(status)) => break status,
1057 Ok(None) => {}
1058 Err(e) => return Err(RunFailure::Spawn(e)),
1059 }
1060 if std::time::Instant::now() >= deadline {
1061 let _ = child.kill();
1062 let _ = child.wait();
1063 return Err(RunFailure::TimedOut(timeout.as_secs()));
1064 }
1065 std::thread::sleep(std::time::Duration::from_millis(25));
1066 };
1067
1068 Ok(std::process::Output {
1069 status,
1070 stdout: out_reader.join().unwrap_or_default(),
1071 stderr: err_reader.join().unwrap_or_default(),
1072 })
1073}
1074
1075#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1093#[serde(rename_all = "lowercase")]
1094pub enum PrAction {
1095 Opened,
1097 Updated,
1099}
1100
1101impl PrAction {
1102 pub fn as_str(&self) -> &'static str {
1104 match self {
1105 PrAction::Opened => "opened",
1106 PrAction::Updated => "updated",
1107 }
1108 }
1109}
1110
1111impl std::fmt::Display for PrAction {
1112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1113 f.write_str(self.as_str())
1114 }
1115}
1116
1117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1123pub enum PrState {
1124 Open,
1126 ClosedUnmerged,
1129 Merged,
1131}
1132
1133#[derive(Debug, Clone, PartialEq)]
1135pub struct PrRecord {
1136 pub number: u64,
1137 pub state: PrState,
1138 pub url: String,
1139 pub is_draft: bool,
1140 pub base: String,
1152}
1153
1154pub struct PrDelivery<'a> {
1157 pub repo: &'a Path,
1160 pub worktree: &'a Path,
1163 pub target_branch: &'a str,
1166 pub base_branch: &'a str,
1168 pub draft: bool,
1172 pub intent: &'a str,
1174 pub contract: &'a OutcomeContract,
1177 pub body: &'a str,
1181 pub provenance: Option<&'a str>,
1190}
1191
1192#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1194#[serde(rename_all = "lowercase")]
1195pub enum CiState {
1196 Green,
1197 Pending,
1198 Red,
1199}
1200
1201#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1203pub struct CiCheck {
1204 pub name: String,
1205 pub state: CiState,
1206}
1207
1208#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1210pub struct CiSummary {
1211 pub head_sha: String,
1214 pub state: CiState,
1215 pub checks: Vec<CiCheck>,
1217 #[serde(default, skip_serializing_if = "Option::is_none")]
1219 pub observation_error: Option<String>,
1220}
1221
1222impl CiSummary {
1223 fn from_checks(head_sha: &str, checks: Vec<(String, CiState)>) -> Self {
1224 let mut by_name = std::collections::BTreeMap::<String, CiState>::new();
1228 for (name, state) in checks {
1229 by_name
1230 .entry(name)
1231 .and_modify(|current| {
1232 if ci_severity(state) > ci_severity(*current) {
1233 *current = state;
1234 }
1235 })
1236 .or_insert(state);
1237 }
1238
1239 let checks: Vec<CiCheck> = by_name
1240 .into_iter()
1241 .map(|(name, state)| CiCheck { name, state })
1242 .collect();
1243 let state = if checks.iter().any(|check| check.state == CiState::Red) {
1244 CiState::Red
1245 } else if checks.is_empty() || checks.iter().any(|check| check.state == CiState::Pending) {
1246 CiState::Pending
1249 } else {
1250 CiState::Green
1251 };
1252 Self {
1253 head_sha: head_sha.to_string(),
1254 state,
1255 checks,
1256 observation_error: None,
1257 }
1258 }
1259
1260 fn names_with_state(&self, state: CiState) -> Vec<String> {
1261 self.checks
1262 .iter()
1263 .filter(|check| check.state == state)
1264 .map(|check| check.name.clone())
1265 .collect()
1266 }
1267}
1268
1269fn ci_severity(state: CiState) -> u8 {
1270 match state {
1271 CiState::Green => 0,
1272 CiState::Pending => 1,
1273 CiState::Red => 2,
1274 }
1275}
1276
1277#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1279pub struct PrDeliveryOutcome {
1280 pub branch: String,
1282 pub commit: String,
1284 pub pushed: bool,
1287 pub pr_number: u64,
1288 pub pr_url: String,
1289 pub pr_action: PrAction,
1290 pub draft: bool,
1295 pub ci: CiSummary,
1297}
1298
1299impl PrDeliveryOutcome {
1300 pub fn delivery_report(&self) -> String {
1303 if let Some(error) = &self.ci.observation_error {
1304 return format!("delivered; CI unavailable at {}: {error}", self.commit);
1305 }
1306 match self.ci.state {
1307 CiState::Green if self.draft => format!(
1308 "delivered with green checks at {}; pull request remains draft",
1309 self.ci.head_sha
1310 ),
1311 CiState::Green => format!(
1312 "delivered with green checks and ready for review at {}",
1313 self.ci.head_sha
1314 ),
1315 CiState::Pending => {
1316 let names = named_checks_or(
1317 &self.ci.names_with_state(CiState::Pending),
1318 "no checks reported yet",
1319 );
1320 format!(
1321 "delivered with pending checks at {}: {names}",
1322 self.ci.head_sha
1323 )
1324 }
1325 CiState::Red => {
1326 let names =
1327 named_checks_or(&self.ci.names_with_state(CiState::Red), "unknown check");
1328 format!("delivered red on {names} at {}", self.ci.head_sha)
1329 }
1330 }
1331 }
1332}
1333
1334fn named_checks_or(names: &[String], fallback: &str) -> String {
1335 if names.is_empty() {
1336 fallback.to_string()
1337 } else {
1338 names.join(", ")
1339 }
1340}
1341
1342#[derive(Debug, Clone, PartialEq)]
1349pub enum DeliveryFailure {
1350 Preflight { reason: String },
1365 Commit { reason: String },
1368 Push { reason: String, retriable: bool },
1372 Pr { reason: String, retriable: bool },
1380}
1381
1382impl DeliveryFailure {
1383 pub fn stage(&self) -> &'static str {
1385 match self {
1386 DeliveryFailure::Preflight { .. } => "preflight",
1387 DeliveryFailure::Commit { .. } => "commit",
1388 DeliveryFailure::Push { .. } => "push",
1389 DeliveryFailure::Pr { .. } => "pr",
1390 }
1391 }
1392
1393 pub fn retriable(&self) -> bool {
1396 match self {
1397 DeliveryFailure::Preflight { .. } | DeliveryFailure::Commit { .. } => false,
1398 DeliveryFailure::Push { retriable, .. } | DeliveryFailure::Pr { retriable, .. } => {
1399 *retriable
1400 }
1401 }
1402 }
1403
1404 pub fn reason(&self) -> &str {
1406 match self {
1407 DeliveryFailure::Preflight { reason }
1408 | DeliveryFailure::Commit { reason }
1409 | DeliveryFailure::Push { reason, .. }
1410 | DeliveryFailure::Pr { reason, .. } => reason,
1411 }
1412 }
1413}
1414
1415impl std::fmt::Display for DeliveryFailure {
1416 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1417 write!(f, "{} failed: {}", self.stage(), self.reason())
1418 }
1419}
1420
1421impl std::error::Error for DeliveryFailure {}
1422
1423pub trait ForgeClient: Send + Sync {
1430 fn auth_status(&self) -> Result<(), ForgeError>;
1432 fn list_prs_for_head(&self, dir: &Path, head_branch: &str)
1434 -> Result<Vec<PrRecord>, ForgeError>;
1435 fn create_pr(
1436 &self,
1437 dir: &Path,
1438 head_branch: &str,
1439 base_branch: &str,
1440 title: &str,
1441 body: &str,
1442 draft: bool,
1443 ) -> Result<PrRecord, ForgeError>;
1444 fn set_pr_body(&self, dir: &Path, number: u64, body: &str) -> Result<(), ForgeError>;
1446 fn reopen_pr(&self, _dir: &Path, _number: u64) -> Result<(), ForgeError> {
1450 Err(ForgeError::local(
1451 "this forge client does not implement pull-request reopening",
1452 ))
1453 }
1454 fn ci_for_sha(&self, dir: &Path, number: u64, head_sha: &str) -> Result<CiSummary, ForgeError>;
1456}
1457
1458pub use ForgeClient as GitHubApi;
1461
1462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1464pub enum ForgeKind {
1465 GitHub,
1466 AzureDevOps,
1467}
1468
1469impl ForgeKind {
1470 fn as_str(self) -> &'static str {
1471 match self {
1472 Self::GitHub => "github",
1473 Self::AzureDevOps => "azure-devops",
1474 }
1475 }
1476}
1477
1478pub const FORGE_OVERRIDE_ENV: &str = "CAR_CODER_FORGE";
1480
1481trait ForgeCommandRunner: Send + Sync {
1482 fn run(&self, dir: &Path, program: &str, args: &[String]) -> Result<String, ForgeError>;
1483}
1484
1485struct ProcessForgeCommandRunner;
1486
1487impl ForgeCommandRunner for ProcessForgeCommandRunner {
1488 fn run(&self, dir: &Path, program: &str, args: &[String]) -> Result<String, ForgeError> {
1489 run_forge_cli(dir, program, args)
1490 }
1491}
1492
1493pub struct GhCli {
1495 runner: Option<Arc<dyn ForgeCommandRunner>>,
1496}
1497
1498#[allow(non_upper_case_globals)]
1502pub const GhCli: GhCli = GhCli { runner: None };
1503
1504impl Default for GhCli {
1505 fn default() -> Self {
1506 GhCli
1507 }
1508}
1509
1510impl GhCli {
1511 fn runner(&self) -> &dyn ForgeCommandRunner {
1512 self.runner.as_deref().unwrap_or(&ProcessForgeCommandRunner)
1513 }
1514}
1515
1516pub struct AzureDevOpsCli {
1518 runner: Arc<dyn ForgeCommandRunner>,
1519 auth_dir: Option<PathBuf>,
1520}
1521
1522impl Default for AzureDevOpsCli {
1523 fn default() -> Self {
1524 Self {
1525 runner: Arc::new(ProcessForgeCommandRunner),
1526 auth_dir: None,
1527 }
1528 }
1529}
1530
1531impl AzureDevOpsCli {
1532 fn for_repo(repo: &Path) -> Self {
1533 Self {
1534 runner: Arc::new(ProcessForgeCommandRunner),
1535 auth_dir: Some(repo.to_path_buf()),
1536 }
1537 }
1538
1539 fn auth_dir(&self) -> &Path {
1540 self.auth_dir.as_deref().unwrap_or(Path::new("."))
1541 }
1542}
1543
1544#[cfg(test)]
1545impl GhCli {
1546 fn with_runner(runner: Arc<dyn ForgeCommandRunner>) -> Self {
1547 Self {
1548 runner: Some(runner),
1549 }
1550 }
1551}
1552
1553#[cfg(test)]
1554impl AzureDevOpsCli {
1555 fn with_runner(runner: Arc<dyn ForgeCommandRunner>, repo: &Path) -> Self {
1556 Self {
1557 runner,
1558 auth_dir: Some(repo.to_path_buf()),
1559 }
1560 }
1561}
1562
1563fn gh_auth_status_args() -> Vec<String> {
1566 vec!["auth".into(), "status".into()]
1567}
1568
1569fn gh_repo_args(dir: &Path) -> Vec<String> {
1586 match git(dir, &["remote", "get-url", "origin"])
1587 .ok()
1588 .and_then(|url| parse_github_repo_spec(url.trim()))
1589 {
1590 Some(spec) => vec!["--repo".into(), spec],
1591 None => Vec::new(),
1592 }
1593}
1594
1595fn parse_github_repo_spec(url: &str) -> Option<String> {
1599 let after_scheme = url.split_once("://").map(|(_, rest)| rest);
1602 let (host_part, path) = match after_scheme {
1603 Some(rest) => rest.split_once('/')?,
1604 None if url.starts_with('/') || url.starts_with('.') => return None,
1606 None => url.split_once(':')?,
1607 };
1608 let host = host_part
1609 .rsplit('@')
1610 .next()?
1611 .split(':')
1612 .next()?
1613 .to_ascii_lowercase();
1614 if host.is_empty() {
1615 return None;
1616 }
1617 let path = path
1618 .trim_matches('/')
1619 .strip_suffix(".git")
1620 .unwrap_or(path.trim_matches('/'));
1621 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
1622 let [owner, name] = segments[..] else {
1625 return None;
1626 };
1627 if host == "github.com" {
1628 Some(format!("{owner}/{name}"))
1629 } else {
1630 Some(format!("{host}/{owner}/{name}"))
1631 }
1632}
1633
1634fn gh_pr_list_args(head_branch: &str) -> Vec<String> {
1636 vec![
1637 "pr".into(),
1638 "list".into(),
1639 "--head".into(),
1640 head_branch.to_string(),
1641 "--state".into(),
1642 "all".into(),
1643 "--json".into(),
1644 "number,state,url,isDraft,isCrossRepository,baseRefName".into(),
1657 "--limit".into(),
1667 "100".into(),
1668 ]
1669}
1670
1671fn gh_pr_create_args(
1673 head_branch: &str,
1674 base_branch: &str,
1675 title: &str,
1676 body: &str,
1677 draft: bool,
1678) -> Vec<String> {
1679 let mut args = vec![
1680 "pr".into(),
1681 "create".into(),
1682 "--head".into(),
1683 head_branch.to_string(),
1684 "--base".into(),
1685 base_branch.to_string(),
1686 "--title".into(),
1687 title.to_string(),
1688 "--body".into(),
1689 body.to_string(),
1690 ];
1691 if draft {
1692 args.push("--draft".into());
1693 }
1694 args
1695}
1696
1697fn gh_pr_reopen_args(number: u64) -> Vec<String> {
1699 vec!["pr".into(), "reopen".into(), number.to_string()]
1700}
1701
1702fn gh_pr_checks_args(number: u64) -> Vec<String> {
1704 vec![
1705 "pr".into(),
1706 "view".into(),
1707 number.to_string(),
1708 "--json".into(),
1709 "headRefOid,statusCheckRollup".into(),
1710 ]
1711}
1712
1713fn az_common_tail(output: &str) -> Vec<String> {
1714 vec![
1715 "--detect".into(),
1716 "true".into(),
1717 "--output".into(),
1718 output.into(),
1719 "--only-show-errors".into(),
1720 ]
1721}
1722
1723fn az_auth_status_args() -> Vec<String> {
1724 let mut args = vec!["repos".into(), "list".into()];
1725 args.extend(az_common_tail("json"));
1726 args
1727}
1728
1729fn az_pr_list_args(head_branch: &str) -> Vec<String> {
1730 let mut args = vec![
1731 "repos".into(),
1732 "pr".into(),
1733 "list".into(),
1734 "--source-branch".into(),
1735 head_branch.into(),
1736 "--status".into(),
1737 "all".into(),
1738 "--top".into(),
1739 "100".into(),
1740 "--include-links".into(),
1741 "true".into(),
1742 ];
1743 args.extend(az_common_tail("json"));
1744 args
1745}
1746
1747fn az_pr_create_args(
1748 head_branch: &str,
1749 base_branch: &str,
1750 title: &str,
1751 body: &str,
1752 draft: bool,
1753) -> Vec<String> {
1754 let mut args = vec![
1755 "repos".into(),
1756 "pr".into(),
1757 "create".into(),
1758 "--source-branch".into(),
1759 head_branch.into(),
1760 "--target-branch".into(),
1761 base_branch.into(),
1762 "--title".into(),
1763 title.into(),
1764 "--description".into(),
1765 body.into(),
1766 "--draft".into(),
1767 draft.to_string(),
1768 ];
1769 args.extend(az_common_tail("json"));
1770 args
1771}
1772
1773fn az_pr_update_args(number: u64, body: &str) -> Vec<String> {
1774 let mut args = vec![
1775 "repos".into(),
1776 "pr".into(),
1777 "update".into(),
1778 "--id".into(),
1779 number.to_string(),
1780 "--description".into(),
1781 body.into(),
1782 ];
1783 args.extend(az_common_tail("none"));
1784 args
1785}
1786
1787fn az_pr_reopen_args(number: u64) -> Vec<String> {
1788 let mut args = vec![
1789 "repos".into(),
1790 "pr".into(),
1791 "update".into(),
1792 "--id".into(),
1793 number.to_string(),
1794 "--status".into(),
1795 "active".into(),
1796 ];
1797 args.extend(az_common_tail("none"));
1798 args
1799}
1800
1801fn az_pr_show_args(number: u64) -> Vec<String> {
1802 let mut args = vec![
1803 "repos".into(),
1804 "pr".into(),
1805 "show".into(),
1806 "--id".into(),
1807 number.to_string(),
1808 ];
1809 args.extend(az_common_tail("json"));
1810 args
1811}
1812
1813fn az_pr_policy_list_args(number: u64) -> Vec<String> {
1814 let mut args = vec![
1815 "repos".into(),
1816 "pr".into(),
1817 "policy".into(),
1818 "list".into(),
1819 "--id".into(),
1820 number.to_string(),
1821 "--top".into(),
1822 "100".into(),
1823 ];
1824 args.extend(az_common_tail("json"));
1825 args
1826}
1827
1828const FORCE_MARKER: char = '+';
1841
1842fn push_args(commit: &str, target_branch: &str) -> Vec<String> {
1850 vec![
1851 "push".into(),
1852 "origin".into(),
1853 format!("{commit}:refs/heads/{target_branch}"),
1854 ]
1855}
1856
1857#[derive(Debug, Clone)]
1862pub struct ForgeError {
1863 pub message: String,
1865 pub stderr: String,
1867}
1868
1869pub type GhError = ForgeError;
1871
1872impl std::fmt::Display for ForgeError {
1873 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1874 f.write_str(&self.message)
1875 }
1876}
1877
1878impl ForgeError {
1879 fn local(message: impl Into<String>) -> Self {
1880 let message = message.into();
1881 Self {
1882 stderr: message.clone(),
1883 message,
1884 }
1885 }
1886}
1887
1888fn run_forge_cli(dir: &Path, program: &str, args: &[String]) -> Result<String, ForgeError> {
1890 let mut cmd = std::process::Command::new(program);
1891 cmd.current_dir(dir).args(args);
1892 no_interactive_prompts(&mut cmd);
1893 if program == "gh" {
1894 cmd.env("GH_PROMPT_DISABLED", "1");
1896 }
1897 let out = run_capped(cmd).map_err(|e| match e {
1898 RunFailure::Spawn(io) if io.kind() == std::io::ErrorKind::NotFound => {
1899 if program == "gh" {
1900 ForgeError::local(
1901 "`gh` not found on PATH — install the GitHub CLI (https://cli.github.com) \
1902 and authenticate it",
1903 )
1904 } else {
1905 ForgeError::local(
1906 "`az` not found on PATH — install Azure CLI and the azure-devops extension, \
1907 then authenticate it",
1908 )
1909 }
1910 }
1911 RunFailure::Spawn(io) => ForgeError::local(format!(
1912 "failed to run `{program} {}`: {io}",
1913 gh_subcommand_shape(args)
1914 )),
1915 RunFailure::TimedOut(secs) => ForgeError::local(format!(
1916 "`{program} {}` timed out after {secs}s and was killed",
1917 gh_subcommand_shape(args)
1918 )),
1919 })?;
1920 if out.status.success() {
1921 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
1922 } else {
1923 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
1924 Err(ForgeError {
1925 message: format!("{program} {} failed: {stderr}", gh_subcommand_shape(args)),
1926 stderr,
1927 })
1928 }
1929}
1930
1931pub(super) fn gh(dir: &Path, args: &[String]) -> Result<String, GhError> {
1933 run_forge_cli(dir, "gh", args)
1934}
1935
1936fn gh_subcommand_shape(args: &[String]) -> String {
1945 let mut out: Vec<String> = Vec::with_capacity(args.len());
1946 let mut elide_next = false;
1947 for arg in args {
1948 if std::mem::take(&mut elide_next) {
1949 out.push("<…>".to_string());
1950 continue;
1951 }
1952 if arg.starts_with("--") {
1953 elide_next = matches!(arg.as_str(), "--title" | "--body" | "--description");
1954 }
1955 out.push(arg.clone());
1956 }
1957 out.join(" ")
1958}
1959
1960fn parse_pr_list(raw: &str) -> Result<Vec<PrRecord>, String> {
1970 let value: serde_json::Value = serde_json::from_str(raw.trim())
1971 .map_err(|e| format!("could not parse `gh pr list` JSON: {e}"))?;
1972 let items = value
1973 .as_array()
1974 .ok_or_else(|| "`gh pr list` did not return a JSON array".to_string())?;
1975 let mut out = Vec::with_capacity(items.len());
1976 for item in items {
1977 if item
1981 .get("isCrossRepository")
1982 .and_then(|c| c.as_bool())
1983 .unwrap_or(false)
1984 {
1985 continue;
1986 }
1987 let number = item
1988 .get("number")
1989 .and_then(|n| n.as_u64())
1990 .ok_or_else(|| "pull request entry has no numeric `number`".to_string())?;
1991 let raw_state = item
1992 .get("state")
1993 .and_then(|s| s.as_str())
1994 .ok_or_else(|| "pull request entry has no `state`".to_string())?;
1995 let state = match raw_state.to_ascii_uppercase().as_str() {
1999 "OPEN" => PrState::Open,
2000 "CLOSED" => PrState::ClosedUnmerged,
2001 "MERGED" => PrState::Merged,
2002 other => return Err(format!("unrecognized pull request state `{other}`")),
2003 };
2004 out.push(PrRecord {
2005 number,
2006 state,
2007 url: item
2008 .get("url")
2009 .and_then(|u| u.as_str())
2010 .unwrap_or_default()
2011 .to_string(),
2012 is_draft: item
2013 .get("isDraft")
2014 .and_then(|d| d.as_bool())
2015 .unwrap_or(false),
2016 base: item
2022 .get("baseRefName")
2023 .and_then(|b| b.as_str())
2024 .ok_or_else(|| "pull request entry has no `baseRefName`".to_string())?
2025 .to_string(),
2026 });
2027 }
2028 Ok(out)
2029}
2030
2031fn parse_github_ci_summary(raw: &str, expected_head_sha: &str) -> Result<CiSummary, String> {
2033 let value: serde_json::Value = serde_json::from_str(raw.trim())
2034 .map_err(|e| format!("could not parse `gh pr view` CI JSON: {e}"))?;
2035 let actual_head = value
2036 .get("headRefOid")
2037 .and_then(|v| v.as_str())
2038 .ok_or_else(|| "`gh pr view` CI response has no `headRefOid`".to_string())?;
2039 if actual_head != expected_head_sha {
2040 return Err(format!(
2041 "pull request head moved while delivery was reading CI: expected {expected_head_sha}, found {actual_head}"
2042 ));
2043 }
2044
2045 let rollup = match value.get("statusCheckRollup") {
2046 None | Some(serde_json::Value::Null) => &[][..],
2047 Some(serde_json::Value::Array(items)) => items.as_slice(),
2048 Some(_) => {
2049 return Err("`gh pr view` CI response has a non-array `statusCheckRollup`".to_string())
2050 }
2051 };
2052 let mut checks = Vec::with_capacity(rollup.len());
2053 for item in rollup {
2054 let kind = item
2055 .get("__typename")
2056 .and_then(|v| v.as_str())
2057 .ok_or_else(|| "CI rollup entry has no `__typename`".to_string())?;
2058 let (name, state) = match kind {
2059 "CheckRun" => {
2060 let name = required_string(item, "name", "CI rollup entry")?;
2061 let status = item
2062 .get("status")
2063 .and_then(|v| v.as_str())
2064 .ok_or_else(|| format!("check run `{name}` has no `status`"))?;
2065 let state = if status.eq_ignore_ascii_case("COMPLETED") {
2066 let conclusion =
2067 item.get("conclusion")
2068 .and_then(|v| v.as_str())
2069 .ok_or_else(|| {
2070 format!("completed check run `{name}` has no `conclusion`")
2071 })?;
2072 match conclusion.to_ascii_uppercase().as_str() {
2073 "SUCCESS" | "NEUTRAL" | "SKIPPED" => CiState::Green,
2074 "ACTION_REQUIRED" | "CANCELLED" | "FAILURE" | "STALE"
2075 | "STARTUP_FAILURE" | "TIMED_OUT" => CiState::Red,
2076 other => {
2077 return Err(format!(
2078 "check run `{name}` has unrecognized conclusion `{other}`"
2079 ))
2080 }
2081 }
2082 } else {
2083 CiState::Pending
2086 };
2087 (name, state)
2088 }
2089 "StatusContext" => {
2090 let name = required_string(item, "context", "CI rollup entry")?;
2091 let raw_state = item
2092 .get("state")
2093 .and_then(|v| v.as_str())
2094 .ok_or_else(|| format!("status context `{name}` has no `state`"))?;
2095 let state = match raw_state.to_ascii_uppercase().as_str() {
2096 "SUCCESS" => CiState::Green,
2097 "EXPECTED" | "PENDING" => CiState::Pending,
2098 "ERROR" | "FAILURE" => CiState::Red,
2099 other => {
2100 return Err(format!(
2101 "status context `{name}` has unrecognized state `{other}`"
2102 ))
2103 }
2104 };
2105 (name, state)
2106 }
2107 other => return Err(format!("unrecognized CI rollup entry type `{other}`")),
2108 };
2109 checks.push((name, state));
2110 }
2111 Ok(CiSummary::from_checks(expected_head_sha, checks))
2112}
2113
2114fn required_string(
2115 value: &serde_json::Value,
2116 field: &str,
2117 subject: &str,
2118) -> Result<String, String> {
2119 value
2120 .get(field)
2121 .and_then(|v| v.as_str())
2122 .filter(|s| !s.trim().is_empty())
2123 .map(str::to_string)
2124 .ok_or_else(|| format!("{subject} has no non-empty `{field}`"))
2125}
2126
2127fn strip_heads_prefix(name: &str) -> String {
2128 name.strip_prefix("refs/heads/").unwrap_or(name).to_string()
2129}
2130
2131fn azure_pr_url(value: &serde_json::Value, number: u64) -> String {
2132 value
2133 .pointer("/_links/web/href")
2134 .and_then(|v| v.as_str())
2135 .or_else(|| value.get("remoteUrl").and_then(|v| v.as_str()))
2136 .map(str::to_string)
2137 .or_else(|| {
2138 value
2139 .pointer("/repository/webUrl")
2140 .and_then(|v| v.as_str())
2141 .map(|base| format!("{}/pullrequest/{number}", base.trim_end_matches('/')))
2142 })
2143 .or_else(|| {
2144 value
2145 .get("url")
2146 .and_then(|v| v.as_str())
2147 .map(str::to_string)
2148 })
2149 .unwrap_or_default()
2150}
2151
2152fn parse_azure_pr(value: &serde_json::Value) -> Result<PrRecord, String> {
2153 let number = value
2154 .get("pullRequestId")
2155 .and_then(|v| v.as_u64())
2156 .ok_or_else(|| "Azure DevOps pull request has no numeric `pullRequestId`".to_string())?;
2157 let raw_state = value
2158 .get("status")
2159 .and_then(|v| v.as_str())
2160 .ok_or_else(|| "Azure DevOps pull request has no `status`".to_string())?;
2161 let state = match raw_state.to_ascii_lowercase().as_str() {
2162 "active" => PrState::Open,
2163 "abandoned" => PrState::ClosedUnmerged,
2164 "completed" => PrState::Merged,
2165 other => {
2166 return Err(format!(
2167 "unrecognized Azure DevOps pull request status `{other}`"
2168 ))
2169 }
2170 };
2171 let target = required_string(value, "targetRefName", "Azure DevOps pull request")?;
2172 Ok(PrRecord {
2173 number,
2174 state,
2175 url: azure_pr_url(value, number),
2176 is_draft: value
2177 .get("isDraft")
2178 .and_then(|v| v.as_bool())
2179 .unwrap_or(false),
2180 base: strip_heads_prefix(&target),
2181 })
2182}
2183
2184fn parse_azure_pr_list(raw: &str) -> Result<Vec<PrRecord>, String> {
2185 let value: serde_json::Value = serde_json::from_str(raw.trim())
2186 .map_err(|e| format!("could not parse `az repos pr list` JSON: {e}"))?;
2187 let items = value
2188 .as_array()
2189 .ok_or_else(|| "`az repos pr list` did not return a JSON array".to_string())?;
2190 items.iter().map(parse_azure_pr).collect()
2191}
2192
2193fn azure_pr_head(pr_raw: &str) -> Result<String, String> {
2194 let pr: serde_json::Value = serde_json::from_str(pr_raw.trim())
2195 .map_err(|e| format!("could not parse `az repos pr show` JSON: {e}"))?;
2196 pr.pointer("/lastMergeSourceCommit/commitId")
2197 .and_then(|v| v.as_str())
2198 .map(str::to_string)
2199 .ok_or_else(|| {
2200 "`az repos pr show` response has no `lastMergeSourceCommit.commitId`".to_string()
2201 })
2202}
2203
2204fn parse_azure_ci_summary(
2205 pr_before_raw: &str,
2206 policies_raw: &str,
2207 pr_after_raw: &str,
2208 expected_head_sha: &str,
2209) -> Result<CiSummary, String> {
2210 let before_head = azure_pr_head(pr_before_raw)?;
2214 let after_head = azure_pr_head(pr_after_raw)?;
2215 if before_head != expected_head_sha || after_head != expected_head_sha {
2216 let actual = if before_head != expected_head_sha {
2217 before_head
2218 } else {
2219 after_head
2220 };
2221 return Err(format!(
2222 "pull request head moved while delivery was reading CI: expected {expected_head_sha}, found {actual}"
2223 ));
2224 }
2225
2226 let policies: serde_json::Value = serde_json::from_str(policies_raw.trim())
2227 .map_err(|e| format!("could not parse `az repos pr policy list` JSON: {e}"))?;
2228 let items = policies
2229 .as_array()
2230 .ok_or_else(|| "`az repos pr policy list` did not return a JSON array".to_string())?;
2231 let mut checks = Vec::with_capacity(items.len());
2232 for item in items {
2233 let name = item
2234 .pointer("/configuration/type/displayName")
2235 .and_then(|v| v.as_str())
2236 .or_else(|| item.pointer("/type/displayName").and_then(|v| v.as_str()))
2237 .or_else(|| item.pointer("/context/name").and_then(|v| v.as_str()))
2238 .filter(|s| !s.trim().is_empty())
2239 .map(str::to_string)
2240 .or_else(|| {
2241 item.get("evaluationId")
2242 .and_then(|v| v.as_str())
2243 .map(|id| format!("policy {id}"))
2244 })
2245 .ok_or_else(|| "Azure DevOps policy has no name or evaluation id".to_string())?;
2246 let raw_state = item
2247 .get("status")
2248 .and_then(|v| v.as_str())
2249 .ok_or_else(|| format!("Azure DevOps policy `{name}` has no `status`"))?;
2250 let state = match raw_state.to_ascii_lowercase().as_str() {
2251 "approved" | "notapplicable" => CiState::Green,
2252 "queued" | "running" => CiState::Pending,
2253 "rejected" | "broken" => CiState::Red,
2254 other => {
2255 return Err(format!(
2256 "Azure DevOps policy `{name}` has unrecognized status `{other}`"
2257 ))
2258 }
2259 };
2260 checks.push((name, state));
2261 }
2262 Ok(CiSummary::from_checks(expected_head_sha, checks))
2263}
2264
2265fn pr_number_from_url(url: &str) -> Result<u64, String> {
2268 url.trim()
2269 .rsplit('/')
2270 .find(|seg| !seg.is_empty())
2271 .and_then(|seg| seg.parse::<u64>().ok())
2272 .ok_or_else(|| format!("could not read a pull request number out of `{url}`"))
2273}
2274
2275impl ForgeClient for GhCli {
2276 fn auth_status(&self) -> Result<(), ForgeError> {
2277 self.runner()
2280 .run(Path::new("."), "gh", &gh_auth_status_args())
2281 .map(|_| ())
2282 .map_err(|e| ForgeError {
2283 message: format!(
2284 "no usable GitHub credential: `gh auth status` failed. Authenticate with \
2285 `gh auth login`, or set GH_TOKEN (or GITHUB_TOKEN) in this process's \
2286 environment. Underlying error: {e}"
2287 ),
2288 stderr: e.stderr,
2289 })
2290 }
2291
2292 fn list_prs_for_head(
2293 &self,
2294 dir: &Path,
2295 head_branch: &str,
2296 ) -> Result<Vec<PrRecord>, ForgeError> {
2297 let mut args = gh_repo_args(dir);
2298 args.extend(gh_pr_list_args(head_branch));
2299 parse_pr_list(&self.runner().run(dir, "gh", &args)?).map_err(ForgeError::local)
2300 }
2301
2302 fn create_pr(
2303 &self,
2304 dir: &Path,
2305 head_branch: &str,
2306 base_branch: &str,
2307 title: &str,
2308 body: &str,
2309 draft: bool,
2310 ) -> Result<PrRecord, ForgeError> {
2311 let mut args = gh_repo_args(dir);
2312 args.extend(gh_pr_create_args(
2313 head_branch,
2314 base_branch,
2315 title,
2316 body,
2317 draft,
2318 ));
2319 let url = self.runner().run(dir, "gh", &args)?;
2320 Ok(PrRecord {
2321 number: pr_number_from_url(&url).map_err(ForgeError::local)?,
2322 state: PrState::Open,
2323 url: url.trim().to_string(),
2324 is_draft: draft,
2325 base: base_branch.to_string(),
2326 })
2327 }
2328
2329 fn set_pr_body(&self, dir: &Path, number: u64, body: &str) -> Result<(), ForgeError> {
2330 let mut args = gh_repo_args(dir);
2331 args.extend([
2332 "pr".to_string(),
2333 "edit".to_string(),
2334 number.to_string(),
2335 "--body".to_string(),
2336 body.to_string(),
2337 ]);
2338 self.runner().run(dir, "gh", &args).map(|_| ())
2339 }
2340
2341 fn reopen_pr(&self, dir: &Path, number: u64) -> Result<(), ForgeError> {
2342 let mut args = gh_repo_args(dir);
2343 args.extend(gh_pr_reopen_args(number));
2344 self.runner().run(dir, "gh", &args).map(|_| ())
2345 }
2346
2347 fn ci_for_sha(&self, dir: &Path, number: u64, head_sha: &str) -> Result<CiSummary, ForgeError> {
2348 let mut args = gh_repo_args(dir);
2349 args.extend(gh_pr_checks_args(number));
2350 let raw = self.runner().run(dir, "gh", &args)?;
2351 parse_github_ci_summary(&raw, head_sha).map_err(ForgeError::local)
2352 }
2353}
2354
2355impl ForgeClient for AzureDevOpsCli {
2356 fn auth_status(&self) -> Result<(), ForgeError> {
2357 self.runner
2358 .run(self.auth_dir(), "az", &az_auth_status_args())
2359 .map(|_| ())
2360 .map_err(|e| ForgeError {
2361 message: format!(
2362 "no usable Azure DevOps credential: `az repos list` failed. Install the \
2363 azure-devops extension and authenticate with `az login` or \
2364 AZURE_DEVOPS_EXT_PAT. Underlying error: {e}"
2365 ),
2366 stderr: e.stderr,
2367 })
2368 }
2369
2370 fn list_prs_for_head(
2371 &self,
2372 dir: &Path,
2373 head_branch: &str,
2374 ) -> Result<Vec<PrRecord>, ForgeError> {
2375 let raw = self.runner.run(dir, "az", &az_pr_list_args(head_branch))?;
2376 parse_azure_pr_list(&raw).map_err(ForgeError::local)
2377 }
2378
2379 fn create_pr(
2380 &self,
2381 dir: &Path,
2382 head_branch: &str,
2383 base_branch: &str,
2384 title: &str,
2385 body: &str,
2386 draft: bool,
2387 ) -> Result<PrRecord, ForgeError> {
2388 let raw = self.runner.run(
2389 dir,
2390 "az",
2391 &az_pr_create_args(head_branch, base_branch, title, body, draft),
2392 )?;
2393 let value: serde_json::Value = serde_json::from_str(raw.trim()).map_err(|e| {
2394 ForgeError::local(format!("could not parse `az repos pr create` JSON: {e}"))
2395 })?;
2396 parse_azure_pr(&value).map_err(ForgeError::local)
2397 }
2398
2399 fn set_pr_body(&self, dir: &Path, number: u64, body: &str) -> Result<(), ForgeError> {
2400 self.runner
2401 .run(dir, "az", &az_pr_update_args(number, body))
2402 .map(|_| ())
2403 }
2404
2405 fn reopen_pr(&self, dir: &Path, number: u64) -> Result<(), ForgeError> {
2406 self.runner
2407 .run(dir, "az", &az_pr_reopen_args(number))
2408 .map(|_| ())
2409 }
2410
2411 fn ci_for_sha(&self, dir: &Path, number: u64, head_sha: &str) -> Result<CiSummary, ForgeError> {
2412 let before = self.runner.run(dir, "az", &az_pr_show_args(number))?;
2413 let policies = self
2414 .runner
2415 .run(dir, "az", &az_pr_policy_list_args(number))?;
2416 let after = self.runner.run(dir, "az", &az_pr_show_args(number))?;
2417 parse_azure_ci_summary(&before, &policies, &after, head_sha).map_err(ForgeError::local)
2418 }
2419}
2420
2421fn remote_host(remote_url: &str) -> Option<String> {
2422 let raw = remote_url.trim();
2423 let authority = if let Some((_, rest)) = raw.split_once("://") {
2424 rest.split('/').next()?
2425 } else {
2426 if raw.starts_with('/') || raw.starts_with('.') {
2427 return None;
2428 }
2429 let (left, _) = raw.split_once(':')?;
2430 if left.len() == 1 {
2432 return None;
2433 }
2434 left
2435 };
2436 authority
2437 .rsplit('@')
2438 .next()?
2439 .split(':')
2440 .next()
2441 .filter(|host| !host.is_empty())
2442 .map(str::to_ascii_lowercase)
2443}
2444
2445fn forge_kind_from_remote(
2446 remote_url: &str,
2447 override_value: Option<&str>,
2448) -> Result<ForgeKind, String> {
2449 if let Some(value) = override_value {
2450 return match value.trim().to_ascii_lowercase().as_str() {
2451 "github" | "gh" => Ok(ForgeKind::GitHub),
2452 "azure-devops" | "azure_devops" | "azdo" => Ok(ForgeKind::AzureDevOps),
2453 other => Err(format!(
2454 "unsupported {FORGE_OVERRIDE_ENV} value `{other}`; use `github` or `azure-devops`"
2455 )),
2456 };
2457 }
2458
2459 let host = remote_host(remote_url).ok_or_else(|| {
2460 format!(
2461 "cannot identify a pull-request forge from origin `{remote_url}`; set \
2462 {FORGE_OVERRIDE_ENV}=github or {FORGE_OVERRIDE_ENV}=azure-devops"
2463 )
2464 })?;
2465 if matches!(host.as_str(), "github.com" | "ssh.github.com") {
2466 Ok(ForgeKind::GitHub)
2467 } else if matches!(host.as_str(), "dev.azure.com" | "ssh.dev.azure.com")
2468 || host.ends_with(".visualstudio.com")
2469 {
2470 Ok(ForgeKind::AzureDevOps)
2471 } else {
2472 Err(format!(
2473 "cannot identify a pull-request forge for origin host `{host}`; set \
2474 {FORGE_OVERRIDE_ENV}=github or {FORGE_OVERRIDE_ENV}=azure-devops"
2475 ))
2476 }
2477}
2478
2479fn selected_forge(repo: &Path) -> Result<Box<dyn ForgeClient>, String> {
2480 let remote_url = git(repo, &["remote", "get-url", "origin"])
2481 .map_err(|e| format!("cannot read origin remote for forge selection: {e}"))?;
2482 let override_value = match std::env::var(FORGE_OVERRIDE_ENV) {
2483 Ok(value) => Some(value),
2484 Err(std::env::VarError::NotPresent) => None,
2485 Err(std::env::VarError::NotUnicode(_)) => {
2486 return Err(format!(
2487 "{FORGE_OVERRIDE_ENV} is not valid UTF-8; use `github` or `azure-devops`"
2488 ))
2489 }
2490 };
2491 let kind = forge_kind_from_remote(&remote_url, override_value.as_deref())?;
2492 tracing::debug!(forge = kind.as_str(), remote = %remote_url.trim(), "selected PR forge");
2493 Ok(match kind {
2494 ForgeKind::GitHub => Box::new(GhCli::default()),
2495 ForgeKind::AzureDevOps => Box::new(AzureDevOpsCli::for_repo(repo)),
2496 })
2497}
2498
2499pub fn validate_branch_name(label: &str, name: &str) -> Result<(), String> {
2516 if name.is_empty() {
2517 return Err(format!("{label} is empty"));
2518 }
2519 if name.starts_with('-') {
2520 return Err(format!(
2521 "{label} `{name}` starts with '-', which git and gh would read as a flag"
2522 ));
2523 }
2524 if name.starts_with(FORCE_MARKER) {
2525 return Err(format!(
2526 "{label} `{name}` starts with `{FORCE_MARKER}`, git's force marker in a refspec"
2527 ));
2528 }
2529 if let Some(bad) = name
2530 .chars()
2531 .find(|c| c.is_whitespace() || c.is_control() || "~^:?*[]\\".contains(*c))
2532 {
2533 return Err(format!(
2534 "{label} `{name}` contains `{bad}`, which is not legal in a git ref name"
2535 ));
2536 }
2537 if name.contains("..")
2543 || name.ends_with('/')
2544 || name.starts_with('/')
2545 || name.ends_with(".lock")
2546 || name.split('/').any(|c| c.ends_with(".lock"))
2550 || name.ends_with('.')
2551 || name.contains("//")
2552 || name.contains("@{")
2553 || name.split('/').any(|c| c.is_empty() || c.starts_with('.'))
2554 {
2555 return Err(format!("{label} `{name}` is not a legal git ref name"));
2556 }
2557 Ok(())
2558}
2559
2560fn classify_push_error(err: &str) -> (String, bool) {
2588 let low = err.to_ascii_lowercase();
2589
2590 let refused = low.contains("permission denied")
2591 || (low.contains("permission to") && low.contains("denied"))
2592 || low.contains("returned error: 403")
2600 || low.contains("status code 403")
2601 || low.contains("http 403")
2602 || low.contains("error 403")
2603 || low.contains("authentication failed")
2604 || low.contains("could not read username")
2618 || low.contains("could not read password")
2619 || low.contains("terminal prompts disabled")
2624 || low.contains("host key verification failed")
2628 || low.contains("returned error: 401")
2632 || low.contains("status code 401")
2633 || low.contains("http 401")
2634 || low.contains("error 401")
2635 || low.contains("repository not found")
2645 || low.contains("does not appear to be a git repository")
2650 || low.contains("pre-receive hook declined")
2653 || low.contains("protected branch")
2654 || low.contains("refusing to allow")
2659 || low.contains("workflow' scope")
2660 || low.contains("shallow update not allowed")
2661 || low.contains("file size limit")
2662 || low.contains("exists; cannot create")
2670 || low.contains("push declined")
2674 || mentions_github_policy_code(&low);
2675 if refused {
2676 return (
2677 format!("push refused for credential/permission reasons: {err}"),
2678 false,
2679 );
2680 }
2681
2682 let moved = low.contains("non-fast-forward")
2683 || low.contains("fetch first")
2684 || low.contains("[rejected]")
2685 || low.contains("remote rejected")
2686 || low.contains("cannot lock ref")
2687 || low.contains("failed to update ref");
2688 if moved {
2689 return (
2690 format!(
2691 "non-fast-forward: the target branch moved since this worktree was cut \
2692 (lost a push race) — {err}"
2693 ),
2694 true,
2695 );
2696 }
2697
2698 (err.to_string(), true)
2701}
2702
2703fn mentions_github_policy_code(low: &str) -> bool {
2722 let bytes = low.as_bytes();
2723 bytes.windows(6).enumerate().any(|(i, w)| {
2724 w[0] == b'g'
2725 && w[1] == b'h'
2726 && w[2..5].iter().all(u8::is_ascii_digit)
2727 && w[5] == b':'
2728 && (i == 0 || !bytes[i - 1].is_ascii_alphanumeric())
2729 })
2730}
2731
2732fn classify_pr_error(err: &str) -> (String, bool) {
2747 let low = err.to_ascii_lowercase();
2748 let permanent = low.contains("no commits between")
2759 || low.contains("draft pull requests are not supported")
2760 || low.contains("must be a collaborator")
2761 || low.contains("no such remote")
2762 || low.contains("could not resolve to a repository");
2763 (err.to_string(), !permanent)
2764}
2765
2766fn pr_failure(e: GhError) -> DeliveryFailure {
2773 let (_, retriable) = classify_pr_error(&e.stderr);
2774 DeliveryFailure::Pr {
2775 reason: e.message,
2776 retriable,
2777 }
2778}
2779
2780pub fn ambiguous_head_refusal(
2798 prs: &[PrRecord],
2799 target_branch: &str,
2800 base_branch: &str,
2801) -> Option<String> {
2802 let foreign_open: Vec<&PrRecord> = prs
2803 .iter()
2804 .filter(|p| p.state == PrState::Open && p.base != base_branch)
2805 .collect();
2806 if foreign_open.is_empty() {
2807 return None;
2808 }
2809 let described = foreign_open
2810 .iter()
2811 .map(|p| format!("#{} into `{}`", p.number, p.base))
2812 .collect::<Vec<_>>()
2813 .join(", ");
2814 let numbers = foreign_open
2815 .iter()
2816 .map(|p| format!("#{}", p.number))
2817 .collect::<Vec<_>>()
2818 .join(", ");
2819 let plural = if foreign_open.len() == 1 { "" } else { "s" };
2820 Some(format!(
2821 "branch `{target_branch}` already has open pull request{plural} {described} — not into \
2822 `{base_branch}`, this run's base. Pushing this round's commits to `{target_branch}` \
2823 would add them to {numbers} as well, because a pull request tracks its head branch. \
2824 Close {numbers}, or deliver to a different --target-branch"
2825 ))
2826}
2827
2828pub fn closed_pr_refusal(
2863 prs: &[PrRecord],
2864 target_branch: &str,
2865 base_branch: &str,
2866) -> Option<String> {
2867 if prs
2870 .iter()
2871 .any(|p| p.state == PrState::Open && p.base == base_branch)
2872 {
2873 return None;
2874 }
2875 let closed: Vec<&PrRecord> = prs
2876 .iter()
2877 .filter(|p| p.state == PrState::ClosedUnmerged && p.base == base_branch)
2878 .collect();
2879 if closed.is_empty() {
2880 return None;
2881 }
2882 let numbers = closed
2883 .iter()
2884 .map(|p| format!("#{}", p.number))
2885 .collect::<Vec<_>>()
2886 .join(", ");
2887 let plural = if closed.len() == 1 { "" } else { "s" };
2888 let was = if closed.len() == 1 { "was" } else { "were" };
2889 Some(format!(
2890 "pull request{plural} {numbers} from `{target_branch}` into `{base_branch}` {was} \
2891 closed — the runtime does not reopen a pull request it did not close. Reopen {numbers} \
2892 yourself to continue on this branch, or deliver to a different --target-branch"
2893 ))
2894}
2895
2896pub fn delivery_head_refusal(
2911 prs: &[PrRecord],
2912 target_branch: &str,
2913 base_branch: &str,
2914) -> Option<String> {
2915 ambiguous_head_refusal(prs, target_branch, base_branch)
2916 .or_else(|| closed_pr_refusal(prs, target_branch, base_branch))
2917}
2918
2919pub fn deliver_pr(d: PrDelivery<'_>) -> Result<PrDeliveryOutcome, DeliveryFailure> {
3004 let forge = selected_forge(d.repo).map_err(|reason| DeliveryFailure::Preflight { reason })?;
3005 deliver_pr_with(d, forge.as_ref())
3006}
3007
3008pub fn deliver_pr_with(
3011 d: PrDelivery<'_>,
3012 forge: &dyn ForgeClient,
3013) -> Result<PrDeliveryOutcome, DeliveryFailure> {
3014 validate_branch_name("target branch", d.target_branch)
3016 .map_err(|reason| DeliveryFailure::Preflight { reason })?;
3017 validate_branch_name("base branch", d.base_branch)
3018 .map_err(|reason| DeliveryFailure::Preflight { reason })?;
3019 if d.target_branch == d.base_branch {
3029 return Err(DeliveryFailure::Preflight {
3030 reason: format!(
3031 "target branch and base branch are both `{}`; delivering would push \
3032 unreviewed work directly onto the base instead of opening a pull request",
3033 d.target_branch
3034 ),
3035 });
3036 }
3037 forge
3038 .auth_status()
3039 .map_err(|e| DeliveryFailure::Preflight {
3040 reason: e.to_string(),
3041 })?;
3042
3043 let listed = forge
3069 .list_prs_for_head(d.repo, d.target_branch)
3070 .map_err(pr_failure)?;
3071 if let Some(reason) = delivery_head_refusal(&listed, d.target_branch, d.base_branch) {
3072 return Err(DeliveryFailure::Preflight { reason });
3073 }
3074
3075 let commit = match commit_worktree(d.worktree, d.intent, d.contract, d.provenance) {
3077 Ok(CommitOutcome::Made(c)) => c,
3078 Ok(CommitOutcome::NothingToCommit) => head_beyond_base(d.worktree, d.base_branch)
3079 .map_err(|reason| DeliveryFailure::Commit { reason })?,
3080 Err(reason) => return Err(DeliveryFailure::Commit { reason }),
3081 };
3082
3083 let args = push_args(&commit, d.target_branch);
3085 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
3086 if let Err(e) = git(d.worktree, &arg_refs) {
3087 let (reason, retriable) = classify_push_error(&e);
3088 return Err(DeliveryFailure::Push { reason, retriable });
3089 }
3090
3091 let existing: Vec<&PrRecord> = listed.iter().filter(|p| p.base == d.base_branch).collect();
3109
3110 let open = existing
3117 .iter()
3118 .filter(|p| p.state == PrState::Open)
3119 .max_by_key(|p| p.number);
3120
3121 let (record, action) = if let Some(pr) = open {
3122 forge
3125 .set_pr_body(d.repo, pr.number, d.body)
3126 .map_err(pr_failure)?;
3127 ((*pr).clone(), PrAction::Updated)
3128 } else {
3129 let created = forge
3132 .create_pr(
3133 d.repo,
3134 d.target_branch,
3135 d.base_branch,
3136 &subject_from_intent(d.intent),
3137 d.body,
3138 d.draft,
3139 )
3140 .map_err(pr_failure)?;
3141 (created, PrAction::Opened)
3142 };
3143
3144 let ci = forge
3151 .ci_for_sha(d.repo, record.number, &commit)
3152 .unwrap_or_else(|error| {
3153 let mut ci = CiSummary::from_checks(&commit, Vec::new());
3154 ci.observation_error = Some(error.message);
3155 ci
3156 });
3157
3158 Ok(PrDeliveryOutcome {
3159 branch: d.target_branch.to_string(),
3160 commit,
3161 pushed: true,
3162 pr_number: record.number,
3163 pr_url: record.url,
3164 pr_action: action,
3165 draft: record.is_draft,
3166 ci,
3167 })
3168}
3169
3170#[cfg(test)]
3171mod tests {
3172 use super::*;
3173 use crate::coder::contract::ContractCheck;
3174
3175 fn contract() -> OutcomeContract {
3176 OutcomeContract {
3177 allow_credentials: false,
3178 description: "x exists".into(),
3179 checks: vec![ContractCheck {
3180 name: "exists".into(),
3181 command: "test -f x.txt".into(),
3182 expect_exit_zero: true,
3183 output_contains: None,
3184 timeout_secs: 10,
3185 baseline: false,
3186 differential: None,
3187 }],
3188 }
3189 }
3190
3191 fn placement(subtask: &str, worker: Option<&str>, remote: bool) -> car_multi::Placement {
3192 car_multi::Placement {
3193 subtask_id: subtask.to_string(),
3194 worker_id: worker.map(str::to_string),
3195 remote,
3196 attempts: Vec::new(),
3197 }
3198 }
3199
3200 fn landed(subtask: &str, files: &[&str]) -> IntegratedSubtask {
3201 IntegratedSubtask {
3202 subtask_id: subtask.to_string(),
3203 files: files.iter().map(|f| f.to_string()).collect(),
3204 }
3205 }
3206
3207 #[test]
3208 fn a_local_run_renders_no_trailers() {
3209 assert_eq!(placement_provenance(&[], &[], false), None);
3210 assert_eq!(
3214 placement_provenance(
3215 &[placement("s1", Some("this-host"), false)],
3216 &[landed("s1", &["a.rs"])],
3217 false
3218 ),
3219 None
3220 );
3221 }
3222
3223 #[test]
3229 fn workers_that_ran_but_whose_patches_never_landed_are_not_credited() {
3230 let ran_everywhere = [
3231 placement("s1", Some("studio"), true),
3232 placement("s2", Some("laptop"), true),
3233 ];
3234 assert_eq!(
3235 placement_provenance(&ran_everywhere, &[], false),
3236 None,
3237 "nothing was integrated, so nothing may be claimed"
3238 );
3239
3240 let rendered =
3242 placement_provenance(&ran_everywhere, &[landed("s1", &["a.rs"])], false).unwrap();
3243 assert!(rendered.contains("worker=studio"), "{rendered}");
3244 assert!(
3245 !rendered.contains("laptop"),
3246 "a rejected patch's worker must not appear: {rendered}"
3247 );
3248 }
3249
3250 #[test]
3251 fn a_locally_repaired_union_says_so_rather_than_crediting_the_fleet_alone() {
3252 let rendered = placement_provenance(
3253 &[placement("s1", Some("studio"), true)],
3254 &[landed("s1", &["a.rs"])],
3255 true,
3256 )
3257 .unwrap();
3258 assert!(rendered.contains("repaired-locally=true"), "{rendered}");
3259 }
3260
3261 #[test]
3262 fn trailers_name_the_machine_and_the_files_it_wrote() {
3263 let rendered = placement_provenance(
3264 &[
3265 placement("s1", Some("studio"), true),
3266 placement("s2", Some("this-host"), false),
3267 ],
3268 &[landed("s1", &["src/a.rs", "src/b.rs"]), landed("s2", &[])],
3269 false,
3270 )
3271 .expect("a distributed run renders");
3272
3273 assert!(
3276 rendered.contains("CAR-Placement: subtask=s1 worker=studio remote=true"),
3277 "{rendered}"
3278 );
3279 assert!(rendered.contains("files=src/a.rs,src/b.rs"), "{rendered}");
3282 assert!(
3283 rendered.contains("CAR-Placement: subtask=s2 worker=this-host remote=false"),
3284 "{rendered}"
3285 );
3286 assert!(rendered.lines().all(|l| l.starts_with("CAR-Placement:")));
3287 }
3288
3289 #[test]
3294 fn nothing_from_a_peer_can_forge_a_trailer_or_break_the_commit() {
3295 let hostile = "studio\n\nSigned-off-by: Someone <x@y.z>";
3296 let rendered = placement_provenance(
3297 &[placement("s1", Some(hostile), true)],
3298 &[landed("s1", &["a.rs"])],
3299 false,
3300 )
3301 .unwrap();
3302 assert!(!rendered.contains('\n') || rendered.lines().count() == 1);
3303 assert!(
3304 !rendered.contains("Signed-off-by:\n") && rendered.lines().count() == 1,
3305 "a peer must not be able to add a paragraph: {rendered}"
3306 );
3307
3308 let rendered = placement_provenance(
3310 &[placement("s\u{0}1", Some("a\u{0}b"), true)],
3311 &[landed("s\u{0}1", &["x.rs"])],
3312 false,
3313 )
3314 .unwrap();
3315 assert!(!rendered.contains('\u{0}'), "{rendered}");
3316
3317 let long = "w".repeat(100_000);
3319 let rendered = placement_provenance(
3320 &[placement("s1", Some(&long), true)],
3321 &[landed("s1", &["x.rs"])],
3322 false,
3323 )
3324 .unwrap();
3325 assert!(rendered.len() < 400, "len {}", rendered.len());
3326 }
3327
3328 #[test]
3332 fn peer_failure_prose_never_reaches_the_commit() {
3333 let mut p = placement("s1", Some("studio"), true);
3334 p.attempts = vec![car_multi::FailedAttempt {
3335 worker_id: "laptop".into(),
3336 error: "SECRET-STDERR-abcdef".into(),
3337 }];
3338 let rendered = placement_provenance(&[p], &[landed("s1", &["a.rs"])], false).unwrap();
3339 assert!(!rendered.contains("SECRET-STDERR"), "{rendered}");
3340 }
3341
3342 #[test]
3343 fn a_distributed_deliverys_commit_body_says_where_each_subtask_ran() {
3344 let repo_dir = tempfile::tempdir().unwrap();
3345 let repo = repo_dir.path();
3346 init_repo(repo);
3347 let ws = tempfile::tempdir().unwrap();
3348 git(
3349 repo,
3350 &["worktree", "add", "--detach", &ws.path().to_string_lossy()],
3351 )
3352 .unwrap();
3353 std::fs::write(ws.path().join("x.txt"), "made across the fleet").unwrap();
3354
3355 let provenance = placement_provenance(
3356 &[placement("s1", Some("studio"), true)],
3357 &[landed("s1", &["x.txt"])],
3358 false,
3359 )
3360 .unwrap();
3361 let branch = publish_branch(
3362 repo,
3363 ws.path(),
3364 "fleet0001",
3365 "spread this out",
3366 &contract(),
3367 Some(&provenance),
3368 )
3369 .unwrap();
3370
3371 let message = git(repo, &["log", "-1", "--format=%B", &branch]).unwrap();
3375 assert!(message.contains("Authored by CAR Coder."), "{message}");
3376 assert!(
3377 message.contains("CAR-Placement: subtask=s1 worker=studio remote=true"),
3378 "{message}"
3379 );
3380 assert!(message.contains("Outcome contract"), "{message}");
3382
3383 let trailers = git(
3386 repo,
3387 &[
3388 "log",
3389 "-1",
3390 "--format=%(trailers:key=CAR-Placement,valueonly)",
3391 &branch,
3392 ],
3393 )
3394 .unwrap();
3395 assert!(trailers.contains("subtask=s1 worker=studio"), "{trailers}");
3396 }
3397
3398 #[test]
3402 fn a_local_deliverys_commit_body_is_unchanged() {
3403 let repo_dir = tempfile::tempdir().unwrap();
3404 let repo = repo_dir.path();
3405 init_repo(repo);
3406 let ws = tempfile::tempdir().unwrap();
3407 git(
3408 repo,
3409 &["worktree", "add", "--detach", &ws.path().to_string_lossy()],
3410 )
3411 .unwrap();
3412 std::fs::write(ws.path().join("x.txt"), "made here").unwrap();
3413
3414 let branch =
3415 publish_branch(repo, ws.path(), "local001", "do it", &contract(), None).unwrap();
3416 let message = git(repo, &["log", "-1", "--format=%B", &branch]).unwrap();
3417 assert!(message.contains("Authored by CAR Coder."), "{message}");
3418 assert!(
3419 !message.contains("CAR-Placement"),
3420 "a local run claims nothing about a fleet: {message}"
3421 );
3422 }
3423
3424 fn init_repo(dir: &Path) {
3425 for args in [
3426 vec!["init", "-q", "-b", "main"],
3427 vec!["config", "core.autocrlf", "false"],
3433 vec![
3434 "-c",
3435 "user.name=t",
3436 "-c",
3437 "user.email=t@t",
3438 "commit",
3439 "-q",
3440 "--allow-empty",
3441 "-m",
3442 "init",
3443 ],
3444 ] {
3445 let out = std::process::Command::new("git")
3446 .arg("-C")
3447 .arg(dir)
3448 .args(&args)
3449 .output()
3450 .unwrap();
3451 assert!(
3452 out.status.success(),
3453 "{}",
3454 String::from_utf8_lossy(&out.stderr)
3455 );
3456 }
3457 }
3458
3459 #[test]
3460 fn review_identity_detects_changes_without_rewriting_the_index() {
3461 let repo = tempfile::tempdir().unwrap();
3462 init_repo(repo.path());
3463 std::fs::write(repo.path().join("result.txt"), "verified\n").unwrap();
3464 git(repo.path(), &["add", "result.txt"]).unwrap();
3465 let identity = ReviewIdentity::read(repo.path()).unwrap();
3466 identity.validate(repo.path()).unwrap();
3467
3468 std::fs::write(repo.path().join("result.txt"), "changed\n").unwrap();
3469 assert!(identity
3470 .validate(repo.path())
3471 .unwrap_err()
3472 .contains("changed"));
3473 assert_eq!(
3474 git(repo.path(), &["write-tree"]).unwrap().trim(),
3475 identity.tree
3476 );
3477 git(repo.path(), &["add", "result.txt"]).unwrap();
3478 assert!(identity
3479 .validate(repo.path())
3480 .unwrap_err()
3481 .contains("staged"));
3482
3483 std::fs::write(repo.path().join("result.txt"), "verified\n").unwrap();
3484 git(repo.path(), &["add", "result.txt"]).unwrap();
3485 std::fs::write(repo.path().join("unreviewed.txt"), "extra\n").unwrap();
3486 assert!(identity
3487 .validate(repo.path())
3488 .unwrap_err()
3489 .contains("changed"));
3490 std::fs::remove_file(repo.path().join("unreviewed.txt")).unwrap();
3491 identity.validate(repo.path()).unwrap();
3492
3493 git(
3494 repo.path(),
3495 &[
3496 "-c",
3497 "user.name=t",
3498 "-c",
3499 "user.email=t@t",
3500 "commit",
3501 "-qm",
3502 "moved HEAD",
3503 ],
3504 )
3505 .unwrap();
3506 assert!(identity
3507 .validate(repo.path())
3508 .unwrap_err()
3509 .contains("revision"));
3510 }
3511
3512 #[test]
3513 fn checkout_snapshot_keeps_user_inputs_out_of_the_agent_diff() {
3514 let repo = tempfile::tempdir().unwrap();
3515 init_repo(repo.path());
3516 let identity = CheckoutIdentity::read(repo.path()).unwrap();
3517 assert!(snapshot_checkout(repo.path(), "clean").unwrap().is_none());
3518 std::fs::write(repo.path().join("personal.txt"), "staged").unwrap();
3519 git(repo.path(), &["add", "personal.txt"]).unwrap();
3520 std::fs::write(repo.path().join("personal.txt"), "current input").unwrap();
3521 std::fs::write(repo.path().join("AGENTS.md"), "preserve public names").unwrap();
3522 std::fs::write(repo.path().join(".gitignore"), "cache.txt\n").unwrap();
3523 std::fs::write(repo.path().join("cache.txt"), "ignored").unwrap();
3524 let index = git(repo.path(), &["diff", "--cached", "--binary"]).unwrap();
3525 let base = snapshot_checkout(repo.path(), "snapshot-test")
3526 .unwrap()
3527 .unwrap();
3528 let dir = tempfile::tempdir().unwrap();
3529 let ws = car_multi::AgentWorkspace::provision(
3530 &car_multi::WorkspaceConfig::git_worktree_at(repo.path(), dir.path()).with_rev(base),
3531 "task",
3532 )
3533 .unwrap();
3534 assert_eq!(
3535 std::fs::read_to_string(ws.path().join("personal.txt")).unwrap(),
3536 "current input"
3537 );
3538 assert!(ws.path().join("AGENTS.md").exists());
3539 assert!(!ws.path().join("cache.txt").exists());
3540 assert!(git(ws.path(), &["status", "--porcelain"])
3541 .unwrap()
3542 .is_empty());
3543 std::fs::write(
3544 ws.path().join("personal.txt"),
3545 "current input plus agent edit",
3546 )
3547 .unwrap();
3548 let diff = git(ws.path(), &["diff", "HEAD"]).unwrap();
3549 assert!(diff.contains("-current input"));
3550 assert!(!diff.contains("AGENTS.md"));
3551 apply_to_checkout(
3552 repo.path(),
3553 ws.path(),
3554 "snapshot-test",
3555 &identity,
3556 "extend input",
3557 &contract(),
3558 None,
3559 )
3560 .unwrap();
3561 assert_eq!(CheckoutIdentity::read(repo.path()).unwrap(), identity);
3562 assert_eq!(
3563 git(repo.path(), &["diff", "--cached", "--binary"]).unwrap(),
3564 index
3565 );
3566 assert_eq!(
3567 std::fs::read_to_string(repo.path().join("personal.txt")).unwrap(),
3568 "current input plus agent edit"
3569 );
3570 assert!(git(repo.path(), &["branch", "--list", "car/coder/*"])
3571 .unwrap()
3572 .is_empty());
3573 }
3574
3575 #[test]
3576 fn checkout_delivery_preserves_dirty_files_index_and_branch_and_can_retry() {
3577 let repo = tempfile::tempdir().unwrap();
3578 init_repo(repo.path());
3579 let identity = CheckoutIdentity::read(repo.path()).unwrap();
3580 let base = tempfile::tempdir().unwrap();
3581 let ws = car_multi::AgentWorkspace::provision(
3582 &car_multi::WorkspaceConfig::git_worktree_at(repo.path(), base.path()),
3583 "checkout-test",
3584 )
3585 .unwrap();
3586 std::fs::write(repo.path().join("personal.txt"), "staged user edit").unwrap();
3587 git(repo.path(), &["add", "personal.txt"]).unwrap();
3588 std::fs::write(repo.path().join("personal.txt"), "unstaged user edit").unwrap();
3589 std::fs::write(repo.path().join("untracked.txt"), "user work").unwrap();
3590 let index = git(repo.path(), &["diff", "--cached", "--binary"]).unwrap();
3591 let branches = git(repo.path(), &["for-each-ref", "refs/heads"]).unwrap();
3592 std::fs::write(ws.path().join("result.txt"), "reviewed change").unwrap();
3593 std::fs::write(ws.path().join("legacy.txt"), b"legacy \xff\n").unwrap();
3594 let (commit, already) = apply_to_checkout(
3595 repo.path(),
3596 ws.path(),
3597 "coder-checkout-test",
3598 &identity,
3599 "add result",
3600 &contract(),
3601 None,
3602 )
3603 .unwrap();
3604 assert!(!already);
3605 assert_eq!(
3606 std::fs::read(repo.path().join("legacy.txt")).unwrap(),
3607 b"legacy \xff\n"
3608 );
3609 assert_eq!(CheckoutIdentity::read(repo.path()).unwrap(), identity);
3610 assert_eq!(
3611 git(repo.path(), &["diff", "--cached", "--binary"]).unwrap(),
3612 index
3613 );
3614 assert_eq!(
3615 git(repo.path(), &["for-each-ref", "refs/heads"]).unwrap(),
3616 branches
3617 );
3618 assert_eq!(
3619 std::fs::read_to_string(repo.path().join("personal.txt")).unwrap(),
3620 "unstaged user edit"
3621 );
3622 assert_eq!(
3623 std::fs::read_to_string(repo.path().join("untracked.txt")).unwrap(),
3624 "user work"
3625 );
3626 assert_eq!(
3627 std::fs::read_to_string(repo.path().join("result.txt")).unwrap(),
3628 "reviewed change"
3629 );
3630 assert_eq!(
3631 git(repo.path(), &["show", &format!("{commit}:result.txt")]).unwrap(),
3632 "reviewed change"
3633 );
3634 assert!(
3635 apply_to_checkout(
3636 repo.path(),
3637 ws.path(),
3638 "coder-checkout-test",
3639 &identity,
3640 "add result",
3641 &contract(),
3642 None,
3643 )
3644 .unwrap()
3645 .1
3646 );
3647 }
3648
3649 #[test]
3650 fn checkout_delivery_conflict_does_not_apply_a_partial_patch() {
3651 let repo = tempfile::tempdir().unwrap();
3652 init_repo(repo.path());
3653 let identity = CheckoutIdentity::read(repo.path()).unwrap();
3654 let base = tempfile::tempdir().unwrap();
3655 let ws = car_multi::AgentWorkspace::provision(
3656 &car_multi::WorkspaceConfig::git_worktree_at(repo.path(), base.path()),
3657 "conflict-test",
3658 )
3659 .unwrap();
3660 std::fs::write(ws.path().join("a-new.txt"), "must not partially apply").unwrap();
3661 std::fs::write(ws.path().join("z-conflict.txt"), "agent version").unwrap();
3662 std::fs::write(repo.path().join("z-conflict.txt"), "user version").unwrap();
3663 assert!(apply_to_checkout(
3664 repo.path(),
3665 ws.path(),
3666 "coder-conflict",
3667 &identity,
3668 "add files",
3669 &contract(),
3670 None,
3671 )
3672 .is_err());
3673 assert!(!repo.path().join("a-new.txt").exists());
3674 assert_eq!(
3675 std::fs::read_to_string(repo.path().join("z-conflict.txt")).unwrap(),
3676 "user version"
3677 );
3678 assert!(ws.path().join("a-new.txt").exists());
3679 let changed = CheckoutIdentity {
3680 head: "different".into(),
3681 reference: identity.reference,
3682 };
3683 assert!(apply_to_checkout(
3684 repo.path(),
3685 ws.path(),
3686 "coder-conflict",
3687 &changed,
3688 "add files",
3689 &contract(),
3690 None,
3691 )
3692 .unwrap_err()
3693 .contains("checkout revision or branch changed"));
3694 }
3695
3696 #[test]
3697 fn publishes_branch_without_touching_user_checkout() {
3698 let repo_dir = tempfile::tempdir().unwrap();
3699 let repo = repo_dir.path();
3700 init_repo(repo);
3701
3702 let wt_base = tempfile::tempdir().unwrap();
3704 let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
3705 let ws = car_multi::AgentWorkspace::provision(&config, "coder-merge-test").unwrap();
3706
3707 std::fs::write(ws.path().join("x.txt"), "made by coder").unwrap();
3708 let branch = publish_branch(
3709 repo,
3710 ws.path(),
3711 "abc12345",
3712 "create x.txt with content",
3713 &contract(),
3714 None,
3715 )
3716 .unwrap();
3717 assert_eq!(branch, "car/coder/abc12345");
3718
3719 let show = git(repo, &["show", &format!("{branch}:x.txt")]).unwrap();
3721 assert_eq!(show, "made by coder");
3722 let author = git(repo, &["log", "-1", "--format=%an", &branch]).unwrap();
3724 assert_eq!(author.trim(), "car-coder");
3725 let status = git(repo, &["status", "--porcelain"]).unwrap();
3727 assert!(status.is_empty(), "user checkout dirtied: {status}");
3728 assert!(!repo.join("x.txt").exists());
3729 }
3730
3731 #[test]
3732 fn clean_worktree_refuses_to_publish() {
3733 let repo_dir = tempfile::tempdir().unwrap();
3734 init_repo(repo_dir.path());
3735 let wt_base = tempfile::tempdir().unwrap();
3736 let config = car_multi::WorkspaceConfig::git_worktree_at(repo_dir.path(), wt_base.path());
3737 let ws = car_multi::AgentWorkspace::provision(&config, "coder-clean-test").unwrap();
3738
3739 let err = publish_branch(repo_dir.path(), ws.path(), "def", "noop", &contract(), None)
3740 .unwrap_err();
3741 assert!(err.contains("no changes"), "{err}");
3742 }
3743
3744 #[test]
3745 fn long_intent_is_truncated_in_subject() {
3746 let repo_dir = tempfile::tempdir().unwrap();
3747 let repo = repo_dir.path();
3748 init_repo(repo);
3749 let wt_base = tempfile::tempdir().unwrap();
3750 let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
3751 let ws = car_multi::AgentWorkspace::provision(&config, "coder-long-test").unwrap();
3752 std::fs::write(ws.path().join("y.txt"), "y").unwrap();
3753
3754 let long_intent = "a very ".repeat(40) + "long intent";
3755 let branch =
3756 publish_branch(repo, ws.path(), "fff", &long_intent, &contract(), None).unwrap();
3757 let subject = git(repo, &["log", "-1", "--format=%s", &branch]).unwrap();
3758 assert!(subject.trim().len() <= 72);
3759 assert!(subject.contains("..."));
3760 }
3761
3762 #[test]
3763 fn commit_to_main_fast_forwards_the_checkout() {
3764 let repo_dir = tempfile::tempdir().unwrap();
3765 let repo = repo_dir.path();
3766 init_repo(repo);
3767 let wt_base = tempfile::tempdir().unwrap();
3768 let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
3769 let ws = car_multi::AgentWorkspace::provision(&config, "coder-main-test").unwrap();
3770 std::fs::write(ws.path().join("z.txt"), "managed").unwrap();
3771
3772 let commit = commit_to_main(repo, ws.path(), "add z", &contract(), None).unwrap();
3773 let head = git(repo, &["rev-parse", "HEAD"]).unwrap();
3775 assert_eq!(head.trim(), commit);
3776 assert_eq!(
3777 std::fs::read_to_string(repo.join("z.txt")).unwrap(),
3778 "managed"
3779 );
3780 assert!(git(repo, &["branch", "--list", "car/coder/*"])
3782 .unwrap()
3783 .is_empty());
3784 }
3785
3786 #[test]
3787 fn commit_to_main_errors_when_main_moved() {
3788 let repo_dir = tempfile::tempdir().unwrap();
3789 let repo = repo_dir.path();
3790 init_repo(repo);
3791 let wt_base = tempfile::tempdir().unwrap();
3792 let config = car_multi::WorkspaceConfig::git_worktree_at(repo, wt_base.path());
3793 let ws = car_multi::AgentWorkspace::provision(&config, "coder-moved-test").unwrap();
3794 std::fs::write(ws.path().join("a.txt"), "from session").unwrap();
3795
3796 std::fs::write(repo.join("b.txt"), "concurrent").unwrap();
3799 for args in [
3800 vec!["-c", "user.name=t", "-c", "user.email=t@t", "add", "-A"],
3801 vec![
3802 "-c",
3803 "user.name=t",
3804 "-c",
3805 "user.email=t@t",
3806 "commit",
3807 "-q",
3808 "-m",
3809 "concurrent",
3810 ],
3811 ] {
3812 assert!(std::process::Command::new("git")
3813 .arg("-C")
3814 .arg(repo)
3815 .args(&args)
3816 .output()
3817 .unwrap()
3818 .status
3819 .success());
3820 }
3821
3822 let err = commit_to_main(repo, ws.path(), "add a", &contract(), None).unwrap_err();
3823 assert!(err.contains("fast-forward"), "{err}");
3824 }
3825
3826 #[test]
3834 fn a_rename_reports_both_endpoints_not_just_the_destination() {
3835 let paths = parse_name_status_z("R100\0secrets/key.txt\0public_key.txt\0");
3836 assert!(
3837 paths.contains(&"secrets/key.txt".to_string()),
3838 "the source directory must not vanish: {paths:?}"
3839 );
3840 assert!(paths.contains(&"public_key.txt".to_string()), "{paths:?}");
3841 assert_eq!(paths.len(), 2);
3842 }
3843
3844 #[test]
3846 fn a_copy_also_reports_both_endpoints() {
3847 let paths = parse_name_status_z("C75\0src/a.rs\0src/b.rs\0");
3848 assert_eq!(paths, vec!["src/a.rs".to_string(), "src/b.rs".to_string()]);
3849 }
3850
3851 #[test]
3854 fn mixed_entries_stay_in_sync_after_a_rename() {
3855 let paths = parse_name_status_z("M\0src/a.rs\0R100\0old/x.rs\0new/x.rs\0A\0src/z.rs\0");
3856 assert_eq!(
3857 paths,
3858 vec![
3859 "new/x.rs".to_string(),
3860 "old/x.rs".to_string(),
3861 "src/a.rs".to_string(),
3862 "src/z.rs".to_string(),
3863 ]
3864 );
3865 }
3866
3867 #[test]
3870 fn a_newline_in_a_filename_does_not_forge_an_entry() {
3871 let paths = parse_name_status_z("A\0we\nird.txt\0");
3872 assert_eq!(paths, vec!["we\nird.txt".to_string()]);
3873 }
3874
3875 #[test]
3876 fn an_empty_diff_yields_no_paths() {
3877 assert!(parse_name_status_z("").is_empty());
3878 }
3879
3880 #[test]
3884 fn type_change_and_unmerged_are_single_path_entries() {
3885 assert_eq!(
3886 parse_name_status_z("T\0src/link.txt\0M\0src/after.rs\0"),
3887 vec!["src/after.rs".to_string(), "src/link.txt".to_string()]
3888 );
3889 assert_eq!(
3890 parse_name_status_z("U\0conflict.txt\0"),
3891 vec!["conflict.txt".to_string()]
3892 );
3893 }
3894
3895 #[test]
3899 fn an_unrecognized_status_bails_instead_of_desynchronizing() {
3900 assert!(parse_name_status_z("Z9\0a.txt\0b.txt\0").is_empty());
3902 assert_eq!(
3904 parse_name_status_z("M\0good.rs\0Z9\0a.txt\0"),
3905 vec!["good.rs".to_string()]
3906 );
3907 }
3908
3909 #[test]
3912 fn a_rename_missing_its_destination_bails() {
3913 assert_eq!(
3914 parse_name_status_z("R100\0only-one.txt\0"),
3915 vec!["only-one.txt".to_string()]
3916 );
3917 }
3918
3919 #[test]
3921 fn stage_and_diff_sees_a_renamed_out_of_directory_source() {
3922 let dir = tempfile::tempdir().unwrap();
3923 let repo = dir.path();
3924 for args in [
3925 vec!["init", "-q", "."],
3926 vec!["config", "user.email", "t@t"],
3927 vec!["config", "user.name", "t"],
3928 ] {
3929 git(repo, &args).unwrap();
3930 }
3931 std::fs::create_dir(repo.join("secrets")).unwrap();
3932 std::fs::write(repo.join("secrets/key.txt"), "k").unwrap();
3933 git(repo, &["add", "-A"]).unwrap();
3934 git(repo, &["commit", "-qm", "init"]).unwrap();
3935 std::fs::rename(repo.join("secrets/key.txt"), repo.join("public_key.txt")).unwrap();
3936
3937 let diff = stage_and_diff(repo, 64 * 1024).unwrap();
3938 assert!(
3939 diff.changed_paths.iter().any(|p| p.starts_with("secrets/")),
3940 "the source directory must appear: {:?}",
3941 diff.changed_paths
3942 );
3943 assert_eq!(
3946 diff.changed_paths,
3947 vec!["public_key.txt".to_string(), "secrets/key.txt".to_string()],
3948 "both endpoints, and nothing else"
3949 );
3950 }
3951
3952 use std::path::PathBuf;
3955 use std::sync::Mutex;
3956
3957 const MERGE_RS_SOURCE: &str = include_str!("merge.rs");
3961
3962 #[derive(Debug, Clone, PartialEq, Eq)]
3963 struct ForgeCall {
3964 program: String,
3965 args: Vec<String>,
3966 }
3967
3968 struct FakeForgeCommands {
3969 responses: Mutex<std::collections::VecDeque<Result<String, ForgeError>>>,
3970 calls: Mutex<Vec<ForgeCall>>,
3971 }
3972
3973 impl FakeForgeCommands {
3974 fn answers(responses: &[&str]) -> Arc<Self> {
3975 Arc::new(Self {
3976 responses: Mutex::new(responses.iter().map(|s| Ok((*s).to_string())).collect()),
3977 calls: Mutex::new(Vec::new()),
3978 })
3979 }
3980
3981 fn calls(&self) -> Vec<ForgeCall> {
3982 self.calls.lock().unwrap().clone()
3983 }
3984 }
3985
3986 impl ForgeCommandRunner for FakeForgeCommands {
3987 fn run(&self, _dir: &Path, program: &str, args: &[String]) -> Result<String, ForgeError> {
3988 self.calls.lock().unwrap().push(ForgeCall {
3989 program: program.to_string(),
3990 args: args.to_vec(),
3991 });
3992 self.responses
3993 .lock()
3994 .unwrap()
3995 .pop_front()
3996 .expect("a scripted forge response")
3997 }
3998 }
3999
4000 fn repo_with_remote(remote: &str) -> tempfile::TempDir {
4001 let repo = tempfile::tempdir().unwrap();
4002 git(repo.path(), &["init", "-q"]).unwrap();
4003 git(repo.path(), &["remote", "add", "origin", remote]).unwrap();
4004 repo
4005 }
4006
4007 #[test]
4008 fn remote_url_selects_github_or_azure_and_unknown_names_the_override() {
4009 for remote in [
4010 "https://github.com/acme/widgets.git",
4011 "git@github.com:acme/widgets.git",
4012 ] {
4013 assert_eq!(
4014 forge_kind_from_remote(remote, None).unwrap(),
4015 ForgeKind::GitHub
4016 );
4017 }
4018 for remote in [
4019 "https://dev.azure.com/acme/platform/_git/widgets",
4020 "git@ssh.dev.azure.com:v3/acme/platform/widgets",
4021 "https://acme.visualstudio.com/platform/_git/widgets",
4022 ] {
4023 assert_eq!(
4024 forge_kind_from_remote(remote, None).unwrap(),
4025 ForgeKind::AzureDevOps
4026 );
4027 }
4028 let error =
4029 forge_kind_from_remote("ssh://git@git.example.test/acme/widgets", None).unwrap_err();
4030 assert!(error.contains(FORGE_OVERRIDE_ENV), "{error}");
4031 assert_eq!(
4032 forge_kind_from_remote(
4033 "ssh://git@git.example.test/acme/widgets",
4034 Some("azure-devops")
4035 )
4036 .unwrap(),
4037 ForgeKind::AzureDevOps
4038 );
4039 }
4040
4041 #[test]
4042 fn github_client_uses_the_existing_cli_contract_through_a_fake_runner() {
4043 let repo = repo_with_remote("https://github.com/acme/widgets.git");
4044 let runner = FakeForgeCommands::answers(&[
4045 "",
4046 r#"[{"number":7,"state":"OPEN","url":"https://github.com/acme/widgets/pull/7","isDraft":false,"isCrossRepository":false,"baseRefName":"main"}]"#,
4047 "https://github.com/acme/widgets/pull/8",
4048 "",
4049 "",
4050 r#"{"headRefOid":"abc123","statusCheckRollup":[{"__typename":"CheckRun","name":"test","status":"COMPLETED","conclusion":"SUCCESS"},{"__typename":"StatusContext","context":"legacy","state":"PENDING"}]}"#,
4051 ]);
4052 let github = GhCli::with_runner(runner.clone());
4053
4054 github.auth_status().unwrap();
4055 let listed = github.list_prs_for_head(repo.path(), "car/work").unwrap();
4056 assert_eq!(listed[0].number, 7);
4057 let created = github
4058 .create_pr(repo.path(), "car/work", "main", "title", "body", true)
4059 .unwrap();
4060 assert_eq!(created.number, 8);
4061 github.set_pr_body(repo.path(), 7, "new body").unwrap();
4062 github.reopen_pr(repo.path(), 7).unwrap();
4063 let ci = github.ci_for_sha(repo.path(), 7, "abc123").unwrap();
4064 assert_eq!(ci.state, CiState::Pending);
4065 assert_eq!(ci.checks.len(), 2);
4066
4067 let calls = runner.calls();
4068 assert_eq!(calls.len(), 6);
4069 assert!(calls.iter().all(|call| call.program == "gh"));
4070 assert_eq!(calls[0].args, gh_auth_status_args());
4071 let mut expected_list = gh_repo_args(repo.path());
4072 expected_list.extend(gh_pr_list_args("car/work"));
4073 assert_eq!(calls[1].args, expected_list);
4074 let mut expected_create = gh_repo_args(repo.path());
4075 expected_create.extend(gh_pr_create_args("car/work", "main", "title", "body", true));
4076 assert_eq!(calls[2].args, expected_create);
4077 let mut expected_edit = gh_repo_args(repo.path());
4078 expected_edit.extend([
4079 "pr".to_string(),
4080 "edit".to_string(),
4081 "7".to_string(),
4082 "--body".to_string(),
4083 "new body".to_string(),
4084 ]);
4085 assert_eq!(calls[3].args, expected_edit);
4086 let mut expected_reopen = gh_repo_args(repo.path());
4087 expected_reopen.extend(gh_pr_reopen_args(7));
4088 assert_eq!(calls[4].args, expected_reopen);
4089 let mut expected_checks = gh_repo_args(repo.path());
4090 expected_checks.extend(gh_pr_checks_args(7));
4091 assert_eq!(calls[5].args, expected_checks);
4092 }
4093
4094 #[test]
4095 fn azure_client_creates_lists_updates_and_reads_checks_through_a_fake_runner() {
4096 let repo = repo_with_remote("https://dev.azure.com/acme/platform/_git/widgets");
4097 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"}}}]"#;
4098 let created = r#"{"pullRequestId":42,"status":"active","isDraft":true,"targetRefName":"refs/heads/main","repository":{"webUrl":"https://dev.azure.com/acme/platform/_git/widgets"}}"#;
4099 let shown = r#"{"lastMergeSourceCommit":{"commitId":"def456"}}"#;
4100 let policies = r#"[
4101 {"status":"approved","configuration":{"type":{"displayName":"Build"}}},
4102 {"status":"running","configuration":{"type":{"displayName":"Security"}}},
4103 {"status":"rejected","configuration":{"type":{"displayName":"Windows"}}}
4104 ]"#;
4105 let runner =
4106 FakeForgeCommands::answers(&["[]", listed, created, "", "", shown, policies, shown]);
4107 let azure = AzureDevOpsCli::with_runner(runner.clone(), repo.path());
4108
4109 azure.auth_status().unwrap();
4110 let prs = azure.list_prs_for_head(repo.path(), "car/work").unwrap();
4111 assert_eq!(prs[0].number, 41);
4112 assert_eq!(prs[0].base, "main");
4113 let pr = azure
4114 .create_pr(repo.path(), "car/work", "main", "title", "body", true)
4115 .unwrap();
4116 assert_eq!(pr.number, 42);
4117 assert!(pr.is_draft);
4118 assert_eq!(
4119 pr.url,
4120 "https://dev.azure.com/acme/platform/_git/widgets/pullrequest/42"
4121 );
4122 azure.set_pr_body(repo.path(), 41, "new body").unwrap();
4123 azure.reopen_pr(repo.path(), 41).unwrap();
4124 let ci = azure.ci_for_sha(repo.path(), 41, "def456").unwrap();
4125 assert_eq!(ci.state, CiState::Red);
4126 assert_eq!(
4127 ci.checks,
4128 vec![
4129 CiCheck {
4130 name: "Build".into(),
4131 state: CiState::Green,
4132 },
4133 CiCheck {
4134 name: "Security".into(),
4135 state: CiState::Pending,
4136 },
4137 CiCheck {
4138 name: "Windows".into(),
4139 state: CiState::Red,
4140 },
4141 ]
4142 );
4143
4144 let calls = runner.calls();
4145 assert_eq!(calls.len(), 8);
4146 assert!(calls.iter().all(|call| call.program == "az"));
4147 assert_eq!(calls[0].args, az_auth_status_args());
4148 assert_eq!(calls[1].args, az_pr_list_args("car/work"));
4149 assert_eq!(
4150 calls[2].args,
4151 az_pr_create_args("car/work", "main", "title", "body", true)
4152 );
4153 assert_eq!(calls[3].args, az_pr_update_args(41, "new body"));
4154 assert_eq!(calls[4].args, az_pr_reopen_args(41));
4155 assert_eq!(calls[5].args, az_pr_show_args(41));
4156 assert_eq!(calls[6].args, az_pr_policy_list_args(41));
4157 assert_eq!(calls[7].args, az_pr_show_args(41));
4158 }
4159
4160 #[test]
4161 fn azure_check_read_refuses_a_moved_head() {
4162 let error = parse_azure_ci_summary(
4163 r#"{"lastMergeSourceCommit":{"commitId":"delivered"}}"#,
4164 "[]",
4165 r#"{"lastMergeSourceCommit":{"commitId":"newer"}}"#,
4166 "delivered",
4167 )
4168 .unwrap_err();
4169 assert!(error.contains("expected delivered, found newer"), "{error}");
4170 }
4171
4172 struct FakeGh {
4175 auth: Result<(), GhError>,
4176 prs: Mutex<Vec<PrRecord>>,
4177 calls: Mutex<Vec<String>>,
4178 next_number: Mutex<u64>,
4179 fail_list: Mutex<Option<String>>,
4187 fail_create: Mutex<Option<String>>,
4188 fail_set_body: Mutex<Option<String>>,
4189 fail_ci: Mutex<Option<String>>,
4190 checks: Mutex<Vec<(String, CiState)>>,
4191 }
4192
4193 fn force_char_offenders(src: &str) -> Vec<usize> {
4206 let production = src.split_once("mod tests {").map(|(h, _)| h).unwrap_or(src);
4207 let needle: String = ['\'', '+', '\''].iter().collect();
4208 production
4209 .lines()
4210 .enumerate()
4211 .filter(|(_, line)| line.contains(needle.as_str()))
4212 .filter(|(_, line)| !line.contains("FORCE_MARKER: char"))
4213 .map(|(i, _)| i + 1)
4214 .collect()
4215 }
4216
4217 fn gh_err(message: &str, stderr: &str) -> GhError {
4220 GhError {
4221 message: message.to_string(),
4222 stderr: stderr.to_string(),
4223 }
4224 }
4225
4226 impl FakeGh {
4227 fn ok() -> Self {
4228 Self {
4229 auth: Ok(()),
4230 prs: Mutex::new(Vec::new()),
4231 calls: Mutex::new(Vec::new()),
4232 next_number: Mutex::new(101),
4233 fail_list: Mutex::new(None),
4234 fail_create: Mutex::new(None),
4235 fail_set_body: Mutex::new(None),
4236 fail_ci: Mutex::new(None),
4237 checks: Mutex::new(vec![
4238 ("lint".to_string(), CiState::Green),
4239 ("test".to_string(), CiState::Green),
4240 ]),
4241 }
4242 }
4243
4244 fn no_credential() -> Self {
4245 Self {
4246 auth: Err(gh_err(
4247 "no usable GitHub credential: `gh auth status` failed. Authenticate with \
4248 `gh auth login`, or set GH_TOKEN (or GITHUB_TOKEN)",
4249 "gh: To get started with GitHub CLI, please run: gh auth login",
4250 )),
4251 ..Self::ok()
4252 }
4253 }
4254
4255 fn with_prs(prs: Vec<PrRecord>) -> Self {
4256 Self {
4257 prs: Mutex::new(prs),
4258 ..Self::ok()
4259 }
4260 }
4261
4262 fn with_checks(checks: Vec<(&str, CiState)>) -> Self {
4263 let me = Self::ok();
4264 *me.checks.lock().unwrap() = checks
4265 .into_iter()
4266 .map(|(name, state)| (name.to_string(), state))
4267 .collect();
4268 me
4269 }
4270
4271 fn failing_set_body(stderr: &str) -> Self {
4273 let me = Self::ok();
4274 *me.fail_set_body.lock().unwrap() = Some(stderr.to_string());
4275 me
4276 }
4277
4278 fn calls(&self) -> Vec<String> {
4279 self.calls.lock().unwrap().clone()
4280 }
4281 }
4282
4283 fn fake_gh_failure(command: &str, stderr: &str, body: &str) -> GhError {
4287 gh_err(
4288 &format!("gh {command} --body {body} failed: {stderr}"),
4289 stderr,
4290 )
4291 }
4292
4293 impl GitHubApi for FakeGh {
4294 fn auth_status(&self) -> Result<(), GhError> {
4295 self.calls.lock().unwrap().push("auth_status".into());
4296 self.auth.clone().map_err(|e| e.clone())
4297 }
4298
4299 fn list_prs_for_head(&self, _dir: &Path, head: &str) -> Result<Vec<PrRecord>, GhError> {
4300 self.calls.lock().unwrap().push(format!("list {head}"));
4301 if let Some(stderr) = self.fail_list.lock().unwrap().clone() {
4302 return Err(gh_err(&format!("gh pr list failed: {stderr}"), &stderr));
4303 }
4304 Ok(self.prs.lock().unwrap().clone())
4305 }
4306
4307 fn create_pr(
4308 &self,
4309 _dir: &Path,
4310 head: &str,
4311 base: &str,
4312 title: &str,
4313 body: &str,
4314 draft: bool,
4315 ) -> Result<PrRecord, GhError> {
4316 self.calls.lock().unwrap().push(format!(
4317 "create head={head} base={base} draft={draft} title={title} body={body}"
4318 ));
4319 if let Some(stderr) = self.fail_create.lock().unwrap().clone() {
4320 return Err(fake_gh_failure("pr create", &stderr, body));
4321 }
4322 let mut n = self.next_number.lock().unwrap();
4323 let record = PrRecord {
4324 number: *n,
4325 state: PrState::Open,
4326 url: format!("https://github.com/acme/repo/pull/{n}"),
4327 is_draft: draft,
4328 base: base.to_string(),
4329 };
4330 *n += 1;
4331 self.prs.lock().unwrap().push(record.clone());
4332 Ok(record)
4333 }
4334
4335 fn set_pr_body(&self, _dir: &Path, number: u64, body: &str) -> Result<(), GhError> {
4336 self.calls
4337 .lock()
4338 .unwrap()
4339 .push(format!("set_body {number} {body}"));
4340 if let Some(stderr) = self.fail_set_body.lock().unwrap().clone() {
4341 return Err(fake_gh_failure("pr edit", &stderr, body));
4342 }
4343 Ok(())
4344 }
4345
4346 fn reopen_pr(&self, _dir: &Path, number: u64) -> Result<(), GhError> {
4347 self.calls.lock().unwrap().push(format!("reopen {number}"));
4348 Ok(())
4349 }
4350
4351 fn ci_for_sha(
4352 &self,
4353 _dir: &Path,
4354 number: u64,
4355 head_sha: &str,
4356 ) -> Result<CiSummary, GhError> {
4357 self.calls
4358 .lock()
4359 .unwrap()
4360 .push(format!("ci {number} {head_sha}"));
4361 if let Some(stderr) = self.fail_ci.lock().unwrap().clone() {
4362 return Err(gh_err(&format!("gh pr view failed: {stderr}"), &stderr));
4363 }
4364 Ok(CiSummary::from_checks(
4365 head_sha,
4366 self.checks.lock().unwrap().clone(),
4367 ))
4368 }
4369 }
4370
4371 struct Fixture {
4374 origin: PathBuf,
4375 repo: PathBuf,
4376 wt_base: PathBuf,
4377 _dirs: Vec<tempfile::TempDir>,
4378 }
4379
4380 fn fixture() -> Fixture {
4381 let origin_dir = tempfile::tempdir().unwrap();
4382 let repo_dir = tempfile::tempdir().unwrap();
4383 let wt_dir = tempfile::tempdir().unwrap();
4384 let origin = origin_dir.path().to_path_buf();
4385 let repo = repo_dir.path().to_path_buf();
4386
4387 git(&origin, &["init", "-q", "--bare", "-b", "main"]).unwrap();
4388 git(&repo, &["init", "-q", "-b", "main"]).unwrap();
4389 git(&repo, &["config", "user.name", "t"]).unwrap();
4390 git(&repo, &["config", "user.email", "t@t"]).unwrap();
4391 std::fs::write(repo.join("README.md"), "seed").unwrap();
4392 git(&repo, &["add", "-A"]).unwrap();
4393 git(&repo, &["commit", "-qm", "seed"]).unwrap();
4394 git(
4395 &repo,
4396 &["remote", "add", "origin", origin.to_str().unwrap()],
4397 )
4398 .unwrap();
4399 git(&repo, &["push", "-q", "origin", "main"]).unwrap();
4400
4401 Fixture {
4402 origin,
4403 repo,
4404 wt_base: wt_dir.path().to_path_buf(),
4405 _dirs: vec![origin_dir, repo_dir, wt_dir],
4406 }
4407 }
4408
4409 impl Fixture {
4410 fn cut(&self, name: &str, from_ref: &str) -> PathBuf {
4412 let path = self.wt_base.join(name);
4413 git(
4414 &self.repo,
4415 &[
4416 "worktree",
4417 "add",
4418 "--detach",
4419 "-q",
4420 path.to_str().unwrap(),
4421 from_ref,
4422 ],
4423 )
4424 .unwrap();
4425 path
4426 }
4427
4428 fn origin_head(&self, branch: &str) -> Option<String> {
4431 git(
4432 &self.origin,
4433 &["rev-parse", "--verify", &format!("refs/heads/{branch}")],
4434 )
4435 .ok()
4436 .map(|s| s.trim().to_string())
4437 }
4438 }
4439
4440 fn delivery<'a>(
4441 f: &'a Fixture,
4442 worktree: &'a Path,
4443 contract: &'a OutcomeContract,
4444 target: &'a str,
4445 draft: bool,
4446 body: &'a str,
4447 ) -> PrDelivery<'a> {
4448 PrDelivery {
4449 repo: &f.repo,
4450 worktree,
4451 target_branch: target,
4452 base_branch: "main",
4453 draft,
4454 intent: "make x exist",
4455 contract,
4456 body,
4457 provenance: None,
4458 }
4459 }
4460
4461 const TARGET: &str = "goalpool/g_abc123";
4462
4463 #[test]
4464 fn github_rollup_combines_check_runs_and_status_contexts_for_the_exact_head() {
4465 let raw = r#"{
4466 "headRefOid":"abc123",
4467 "statusCheckRollup":[
4468 {"__typename":"CheckRun","name":"lint","status":"COMPLETED","conclusion":"SUCCESS"},
4469 {"__typename":"CheckRun","name":"tests","status":"IN_PROGRESS","conclusion":""},
4470 {"__typename":"StatusContext","context":"deploy","state":"FAILURE"},
4471 {"__typename":"StatusContext","context":"lint","state":"PENDING"}
4472 ]
4473 }"#;
4474
4475 let summary = parse_github_ci_summary(raw, "abc123").unwrap();
4476 assert_eq!(summary.head_sha, "abc123");
4477 assert_eq!(summary.state, CiState::Red);
4478 assert_eq!(
4480 summary.checks,
4481 vec![
4482 CiCheck {
4483 name: "deploy".into(),
4484 state: CiState::Red,
4485 },
4486 CiCheck {
4487 name: "lint".into(),
4488 state: CiState::Pending,
4489 },
4490 CiCheck {
4491 name: "tests".into(),
4492 state: CiState::Pending,
4493 },
4494 ]
4495 );
4496 }
4497
4498 #[test]
4499 fn github_rollup_with_no_checks_is_pending_not_green() {
4500 let summary = parse_github_ci_summary(
4501 r#"{"headRefOid":"abc123","statusCheckRollup":null}"#,
4502 "abc123",
4503 )
4504 .unwrap();
4505 assert_eq!(summary.state, CiState::Pending);
4506 assert!(summary.checks.is_empty());
4507 }
4508
4509 #[test]
4510 fn github_rollup_refuses_ci_from_a_different_head() {
4511 let err = parse_github_ci_summary(
4512 r#"{"headRefOid":"newer","statusCheckRollup":[]}"#,
4513 "delivered",
4514 )
4515 .unwrap_err();
4516 assert!(err.contains("expected delivered, found newer"), "{err}");
4517 }
4518
4519 #[test]
4520 fn ci_lookup_failure_preserves_successful_publication() {
4521 let f = fixture();
4522 let c = contract();
4523 let wt = f.cut("s1", "main");
4524 std::fs::write(wt.join("x.txt"), "made by coder").unwrap();
4525 let gh = FakeGh::ok();
4526 *gh.fail_ci.lock().unwrap() = Some("HTTP 503".into());
4527 let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "body"), &gh).unwrap();
4528 assert!(out.pushed);
4529 assert_eq!(out.pr_number, 101);
4530 assert_eq!(out.pr_action, PrAction::Opened);
4531 assert!(!out.pr_url.is_empty());
4532 assert_eq!(out.ci.head_sha, out.commit);
4533 assert_eq!(out.ci.state, CiState::Pending);
4534 assert!(out.ci.checks.is_empty());
4535 assert!(out.delivery_report().contains("CI unavailable"));
4536 assert!(out.delivery_report().contains("HTTP 503"));
4537 assert_eq!(gh.prs.lock().unwrap().len(), 1);
4538 }
4539
4540 #[test]
4541 fn a_green_delivery_pushes_the_commit_and_opens_one_pr() {
4542 let f = fixture();
4543 let c = contract();
4544 let wt = f.cut("s1", "main");
4545 std::fs::write(wt.join("x.txt"), "made by coder").unwrap();
4546
4547 let gh = FakeGh::ok();
4548 let out =
4549 deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "round 1 body"), &gh).unwrap();
4550
4551 assert!(out.pushed);
4552 assert_eq!(out.branch, TARGET);
4553 assert_eq!(out.pr_action, PrAction::Opened);
4554 assert_eq!(out.pr_number, 101);
4555 assert!(out.draft, "a draft was requested at create time");
4556 assert_eq!(out.ci.state, CiState::Green);
4557 assert_eq!(out.ci.head_sha, out.commit);
4558 assert_eq!(
4559 out.ci.checks,
4560 [
4561 CiCheck {
4562 name: "lint".into(),
4563 state: CiState::Green,
4564 },
4565 CiCheck {
4566 name: "test".into(),
4567 state: CiState::Green,
4568 },
4569 ]
4570 );
4571 assert_eq!(
4572 out.delivery_report(),
4573 format!(
4574 "delivered with green checks at {}; pull request remains draft",
4575 out.commit
4576 )
4577 );
4578
4579 assert_eq!(f.origin_head(TARGET).as_deref(), Some(out.commit.as_str()));
4581 assert_eq!(
4582 git(&f.origin, &["show", &format!("refs/heads/{TARGET}:x.txt")]).unwrap(),
4583 "made by coder"
4584 );
4585 assert_eq!(
4586 git(
4587 &f.origin,
4588 &[
4589 "log",
4590 "-1",
4591 "--format=%an <%ae>",
4592 &format!("refs/heads/{TARGET}")
4593 ]
4594 )
4595 .unwrap()
4596 .trim(),
4597 "car-coder <coder@parslee.ai>"
4598 );
4599 assert_eq!(gh.calls()[0], "auth_status");
4602 assert_eq!(gh.calls().last(), Some(&format!("ci 101 {}", out.commit)));
4603 }
4604
4605 #[test]
4606 fn a_red_delivery_names_failed_checks_at_the_delivered_head() {
4607 let f = fixture();
4608 let c = contract();
4609 let wt = f.cut("red", "main");
4610 std::fs::write(wt.join("x.txt"), "made by coder").unwrap();
4611 let gh = FakeGh::with_checks(vec![
4612 ("lint", CiState::Green),
4613 ("windows", CiState::Red),
4614 ("test", CiState::Pending),
4615 ]);
4616
4617 let out =
4618 deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "red delivery"), &gh).unwrap();
4619
4620 assert_eq!(out.ci.state, CiState::Red);
4621 assert!(out.ci.checks.contains(&CiCheck {
4622 name: "windows".into(),
4623 state: CiState::Red
4624 }));
4625 assert!(out.ci.checks.contains(&CiCheck {
4626 name: "test".into(),
4627 state: CiState::Pending
4628 }));
4629 assert_eq!(
4630 out.delivery_report(),
4631 format!("delivered red on windows at {}", out.commit)
4632 );
4633 }
4634
4635 #[test]
4636 fn a_second_delivery_appends_to_the_same_branch_and_the_same_pr() {
4637 let f = fixture();
4638 let c = contract();
4639
4640 let wt1 = f.cut("s1", "main");
4641 std::fs::write(wt1.join("x.txt"), "round one").unwrap();
4642 let gh1 = FakeGh::ok();
4643 let first =
4644 deliver_pr_with(delivery(&f, &wt1, &c, TARGET, true, "round 1 body"), &gh1).unwrap();
4645
4646 git(&f.repo, &["fetch", "-q", "origin"]).unwrap();
4648 let wt2 = f.cut("s2", &format!("origin/{TARGET}"));
4649 std::fs::write(wt2.join("y.txt"), "round two").unwrap();
4650
4651 let gh2 = FakeGh::with_prs(vec![PrRecord {
4653 number: 101,
4654 state: PrState::Open,
4655 url: "https://github.com/acme/repo/pull/101".into(),
4656 is_draft: true,
4657 base: "main".into(),
4658 }]);
4659 let second =
4660 deliver_pr_with(delivery(&f, &wt2, &c, TARGET, true, "round 2 body"), &gh2).unwrap();
4661
4662 assert_eq!(second.pr_action, PrAction::Updated);
4663 assert_eq!(second.pr_number, 101);
4664 assert_ne!(second.commit, first.commit);
4665 assert!(
4666 !gh2.calls().iter().any(|c| c.starts_with("create")),
4667 "a second PR must never be created for the same branch: {:?}",
4668 gh2.calls()
4669 );
4670 assert!(gh2.calls().iter().any(|c| c == "set_body 101 round 2 body"));
4671
4672 assert_eq!(
4674 git(
4675 &f.origin,
4676 &["rev-list", "--count", &format!("refs/heads/{TARGET}")]
4677 )
4678 .unwrap()
4679 .trim(),
4680 "3"
4681 );
4682 assert_eq!(
4683 git(
4684 &f.origin,
4685 &[
4686 "rev-list",
4687 "--count",
4688 "--merges",
4689 &format!("refs/heads/{TARGET}")
4690 ]
4691 )
4692 .unwrap()
4693 .trim(),
4694 "0"
4695 );
4696 assert!(git(
4698 &f.origin,
4699 &["merge-base", "--is-ancestor", &first.commit, &second.commit]
4700 )
4701 .is_ok());
4702 let mut branches: Vec<String> = git(
4704 &f.origin,
4705 &["for-each-ref", "--format=%(refname:short)", "refs/heads/"],
4706 )
4707 .unwrap()
4708 .lines()
4709 .map(|l| l.to_string())
4710 .collect();
4711 branches.sort();
4712 assert_eq!(branches, vec![TARGET.to_string(), "main".to_string()]);
4713 }
4714
4715 #[test]
4716 fn a_non_fast_forward_is_retriable_and_leaves_the_remote_alone() {
4717 let f = fixture();
4718 let c = contract();
4719
4720 let wt1 = f.cut("s1", "main");
4721 std::fs::write(wt1.join("x.txt"), "round one").unwrap();
4722 let first =
4723 deliver_pr_with(delivery(&f, &wt1, &c, TARGET, true, "b1"), &FakeGh::ok()).unwrap();
4724
4725 let wt2 = f.cut("stale", "main");
4728 std::fs::write(wt2.join("z.txt"), "stale round").unwrap();
4729 let gh = FakeGh::with_prs(vec![PrRecord {
4730 number: 101,
4731 state: PrState::Open,
4732 url: "https://github.com/acme/repo/pull/101".into(),
4733 is_draft: true,
4734 base: "main".into(),
4735 }]);
4736 let err = deliver_pr_with(delivery(&f, &wt2, &c, TARGET, true, "b2"), &gh).unwrap_err();
4737
4738 assert_eq!(err.stage(), "push");
4739 assert!(err.retriable(), "{err}");
4740 assert!(
4741 matches!(
4742 err,
4743 DeliveryFailure::Push {
4744 retriable: true,
4745 ..
4746 }
4747 ),
4748 "{err:?}"
4749 );
4750 assert!(
4751 err.reason().contains("non-fast-forward"),
4752 "the reason must name the condition: {}",
4753 err.reason()
4754 );
4755 assert_eq!(
4757 f.origin_head(TARGET).as_deref(),
4758 Some(first.commit.as_str())
4759 );
4760 assert!(
4762 !gh.calls().iter().any(|c| c.starts_with("create")),
4763 "{:?}",
4764 gh.calls()
4765 );
4766 }
4767
4768 #[test]
4769 fn a_missing_credential_fails_preflight_and_touches_nothing() {
4770 let f = fixture();
4771 let c = contract();
4772 let wt = f.cut("s1", "main");
4773 std::fs::write(wt.join("x.txt"), "never delivered").unwrap();
4774
4775 let gh = FakeGh::no_credential();
4776 let err = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "body"), &gh).unwrap_err();
4777
4778 assert_eq!(err.stage(), "preflight");
4779 assert!(!err.retriable(), "a missing credential is not retriable");
4780 assert!(
4781 err.reason().contains("GH_TOKEN") || err.reason().contains("gh auth"),
4782 "the failure must name the missing credential: {}",
4783 err.reason()
4784 );
4785 assert!(
4787 f.origin_head(TARGET).is_none(),
4788 "the remote gained a branch"
4789 );
4790 assert!(
4791 !git(&wt, &["status", "--porcelain"])
4792 .unwrap()
4793 .trim()
4794 .is_empty(),
4795 "the worktree was committed despite the preflight failure"
4796 );
4797 assert_eq!(gh.calls(), vec!["auth_status".to_string()]);
4798 }
4799
4800 #[test]
4806 fn a_closed_unmerged_pull_request_parks_the_round() {
4807 let f = fixture();
4808 let c = contract();
4809 let wt = f.cut("s1", "main");
4810 std::fs::write(wt.join("x.txt"), "again").unwrap();
4811
4812 let gh = FakeGh::with_prs(vec![PrRecord {
4813 number: 55,
4814 state: PrState::ClosedUnmerged,
4815 url: "https://github.com/acme/repo/pull/55".into(),
4816 is_draft: false,
4817 base: "main".into(),
4818 }]);
4819 let err = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "revived"), &gh).unwrap_err();
4820
4821 assert!(
4822 matches!(err, DeliveryFailure::Preflight { .. }),
4823 "a closed pull request is refused before the commit: {err:?}"
4824 );
4825 assert!(
4826 !err.retriable(),
4827 "retrying changes nothing — a human reopens #55 or picks another target branch"
4828 );
4829 assert!(
4831 err.reason().contains("#55") && err.reason().contains("Reopen"),
4832 "{}",
4833 err.reason()
4834 );
4835
4836 assert_eq!(
4839 gh.calls(),
4840 vec!["auth_status".to_string(), format!("list {TARGET}")]
4841 );
4842 assert_eq!(f.origin_head(TARGET), None, "nothing may be pushed");
4844 assert!(
4845 !git(&wt, &["status", "--porcelain"])
4846 .unwrap()
4847 .trim()
4848 .is_empty(),
4849 "the worktree was committed despite the preflight failure"
4850 );
4851 }
4852
4853 #[test]
4858 fn a_reviewers_edited_body_survives_the_next_round() {
4859 let f = fixture();
4860 let c = contract();
4861 let wt = f.cut("s1", "main");
4862 std::fs::write(wt.join("x.txt"), "round two").unwrap();
4863
4864 let gh = FakeGh::with_prs(vec![PrRecord {
4865 number: 40,
4866 state: PrState::ClosedUnmerged,
4867 url: "https://github.com/acme/repo/pull/40".into(),
4868 is_draft: false,
4869 base: "main".into(),
4870 }]);
4871 let err = deliver_pr_with(
4872 delivery(&f, &wt, &c, TARGET, false, "round two's generated body"),
4873 &gh,
4874 )
4875 .unwrap_err();
4876
4877 assert_eq!(err.stage(), "preflight");
4878 assert!(
4879 !gh.calls().iter().any(|c| c.starts_with("set_body")),
4880 "the reviewer's description was rewritten: {:?}",
4881 gh.calls()
4882 );
4883 assert!(
4890 !gh.calls()
4891 .iter()
4892 .any(|c| c.contains("round two's generated body")),
4893 "this round's body reached GitHub: {:?}",
4894 gh.calls()
4895 );
4896 }
4897
4898 #[test]
4906 fn a_superseding_open_pull_request_beats_the_closed_one() {
4907 let f = fixture();
4908 let c = contract();
4909 let wt = f.cut("s1", "main");
4910 std::fs::write(wt.join("x.txt"), "carried forward").unwrap();
4911
4912 let gh = FakeGh::with_prs(vec![
4913 PrRecord {
4914 number: 40,
4915 state: PrState::ClosedUnmerged,
4916 url: "https://github.com/acme/repo/pull/40".into(),
4917 is_draft: false,
4918 base: "main".into(),
4919 },
4920 PrRecord {
4921 number: 55,
4922 state: PrState::Open,
4923 url: "https://github.com/acme/repo/pull/55".into(),
4924 is_draft: false,
4925 base: "main".into(),
4926 },
4927 ]);
4928
4929 let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap();
4930
4931 assert_eq!(out.pr_action, PrAction::Updated);
4932 assert_eq!(out.pr_number, 55, "the live pull request receives the push");
4933 assert!(out.pushed);
4934 assert!(
4935 gh.calls().iter().any(|c| c.starts_with("set_body 55")),
4936 "{:?}",
4937 gh.calls()
4938 );
4939 assert!(
4942 !gh.calls().iter().any(|c| c.starts_with("create")),
4943 "{:?}",
4944 gh.calls()
4945 );
4946 assert!(
4947 !gh.calls().iter().any(|c| c.contains(" 40 ")),
4948 "{:?}",
4949 gh.calls()
4950 );
4951 }
4952
4953 #[test]
4957 fn the_close_veto_is_suppressed_only_by_an_open_pr_into_the_same_base() {
4958 let closed = PrRecord {
4959 number: 40,
4960 state: PrState::ClosedUnmerged,
4961 url: "u40".into(),
4962 is_draft: false,
4963 base: "main".into(),
4964 };
4965 let open_same = PrRecord {
4966 number: 55,
4967 state: PrState::Open,
4968 url: "u55".into(),
4969 is_draft: false,
4970 base: "main".into(),
4971 };
4972 let open_other = PrRecord {
4973 base: "release/2.1".into(),
4974 ..open_same.clone()
4975 };
4976
4977 assert!(
4978 closed_pr_refusal(std::slice::from_ref(&closed), TARGET, "main").is_some(),
4979 "a lone closed pull request still parks the round"
4980 );
4981 assert!(
4982 closed_pr_refusal(&[closed.clone(), open_same], TARGET, "main").is_none(),
4983 "the open pull request into `main` supersedes the close"
4984 );
4985 assert!(
4986 closed_pr_refusal(&[closed, open_other], TARGET, "main").is_some(),
4987 "an open pull request into ANOTHER base says nothing about this base"
4988 );
4989 }
4990
4991 #[test]
4992 fn a_merged_pr_does_not_block_a_new_one() {
4993 let f = fixture();
4994 let c = contract();
4995 let wt = f.cut("s1", "main");
4996 std::fs::write(wt.join("x.txt"), "next chapter").unwrap();
4997
4998 let gh = FakeGh::with_prs(vec![PrRecord {
4999 number: 9,
5000 state: PrState::Merged,
5001 url: "https://github.com/acme/repo/pull/9".into(),
5002 is_draft: false,
5003 base: "main".into(),
5004 }]);
5005 let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "fresh"), &gh).unwrap();
5006
5007 assert_eq!(out.pr_action, PrAction::Opened);
5008 assert!(gh.calls().iter().any(|c| c.starts_with("create")));
5011 }
5012
5013 #[test]
5014 fn an_updated_pr_never_has_its_draft_state_flipped() {
5015 let f = fixture();
5016 let c = contract();
5017 let wt = f.cut("s1", "main");
5018 std::fs::write(wt.join("x.txt"), "more work").unwrap();
5019
5020 let gh = FakeGh::with_prs(vec![PrRecord {
5022 number: 77,
5023 state: PrState::Open,
5024 url: "https://github.com/acme/repo/pull/77".into(),
5025 is_draft: false,
5026 base: "main".into(),
5027 }]);
5028 let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "body"), &gh).unwrap();
5030
5031 assert_eq!(out.pr_action, PrAction::Updated);
5032 assert!(
5033 !out.draft,
5034 "delivery must report the PR's real state, not re-draft a ready PR"
5035 );
5036 }
5037
5038 #[test]
5039 fn a_clean_worktree_redelivers_head_after_an_earlier_push_failure() {
5040 let f = fixture();
5041 let c = contract();
5042
5043 let wt = f.cut("s1", "main");
5045 std::fs::write(wt.join("x.txt"), "work").unwrap();
5046 git(
5047 &f.repo,
5048 &["remote", "set-url", "origin", "/nonexistent/nope.git"],
5049 )
5050 .unwrap();
5051 let err =
5052 deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "b"), &FakeGh::ok()).unwrap_err();
5053 assert_eq!(err.stage(), "push");
5054 assert!(
5057 !err.retriable(),
5058 "a missing remote cannot be fixed by trying again: {err}"
5059 );
5060 let committed = git(&wt, &["rev-parse", "HEAD"]).unwrap().trim().to_string();
5061
5062 git(
5065 &f.repo,
5066 &["remote", "set-url", "origin", f.origin.to_str().unwrap()],
5067 )
5068 .unwrap();
5069 let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "b"), &FakeGh::ok()).unwrap();
5070 assert_eq!(out.commit, committed);
5071 assert_eq!(f.origin_head(TARGET).as_deref(), Some(committed.as_str()));
5072 }
5073
5074 #[test]
5075 fn a_branch_name_that_looks_like_a_flag_is_refused_at_preflight() {
5076 let f = fixture();
5077 let c = contract();
5078 let wt = f.cut("s1", "main");
5079 std::fs::write(wt.join("x.txt"), "x").unwrap();
5080
5081 for bad in [
5082 "--upload-pack=touch /tmp/pwn",
5083 "goalpool/../../etc",
5084 "has space",
5085 ] {
5086 let err =
5087 deliver_pr_with(delivery(&f, &wt, &c, bad, true, "b"), &FakeGh::ok()).unwrap_err();
5088 assert_eq!(err.stage(), "preflight", "for `{bad}`: {err}");
5089 }
5090 let plus = format!("{}goalpool/x", '+');
5092 let err =
5093 deliver_pr_with(delivery(&f, &wt, &c, &plus, true, "b"), &FakeGh::ok()).unwrap_err();
5094 assert_eq!(err.stage(), "preflight", "{err}");
5095 }
5096
5097 #[test]
5104 fn no_force_push_token_appears_anywhere_in_this_file() {
5105 let dashes = "-".repeat(2);
5106 let plain = format!("{dashes}{}", "force");
5107 let lease = format!("{plain}-with-lease");
5108 let plus_refspec: String = ['"', '+'].iter().collect();
5121 let short_flag: String = ['"', '-', 'f', '"'].iter().collect();
5124 for needle in [
5125 plain.as_str(),
5126 lease.as_str(),
5127 plus_refspec.as_str(),
5128 short_flag.as_str(),
5129 ] {
5130 assert!(
5131 !MERGE_RS_SOURCE.contains(needle),
5132 "`{needle}` must appear nowhere on the delivery path"
5133 );
5134 }
5135
5136 let production = MERGE_RS_SOURCE
5149 .split_once("mod tests {")
5150 .map(|(head, _)| head)
5151 .unwrap_or(MERGE_RS_SOURCE);
5152 for tok in [
5158 ['"', 'r', 'e', 'b', 'a', 's', 'e', '"']
5159 .iter()
5160 .collect::<String>(),
5161 ['"', '-', '-', 'r', 'e', 'b', 'a', 's', 'e', '"']
5162 .iter()
5163 .collect::<String>(),
5164 ] {
5165 assert!(
5166 !production.contains(tok.as_str()),
5167 "delivery must never rebase: `{tok}`"
5168 );
5169 }
5170
5171 assert_eq!(
5172 force_char_offenders(MERGE_RS_SOURCE),
5173 Vec::<usize>::new(),
5174 "a char-literal plus on the delivery path is a force refspec"
5175 );
5176 }
5177
5178 #[test]
5189 fn the_force_char_scanner_catches_the_spellings_that_slipped_past_it() {
5190 let wrapped = "fn f() {\n let plus = '+';\n let refspec = format!(\n \
5193 \"{plus}{commit}:refs/heads/{branch}\"\n );\n}\n";
5194 assert!(
5195 !force_char_offenders(wrapped).is_empty(),
5196 "a plus bound to a name and interpolated is still a force refspec"
5197 );
5198 let inline = "fn f() { let r = format!(\"{}{commit}:refs/heads/{b}\", '+'); }\n";
5200 assert!(!force_char_offenders(inline).is_empty());
5201 let only_in_tests = "fn f() {}\nmod tests {\n let plus = '+';\n}\n";
5204 assert!(force_char_offenders(only_in_tests).is_empty());
5205 }
5206
5207 #[test]
5211 fn delivery_never_marks_a_pull_request_ready_for_review() {
5212 let ready_arg = String::from('"') + "read" + "y" + "\"";
5214 assert!(
5215 !MERGE_RS_SOURCE.contains(&ready_arg),
5216 "the runtime must not flip a pull request out of draft"
5217 );
5218 }
5219
5220 #[test]
5223 fn no_shell_invocation_appears_on_the_delivery_path() {
5224 for needle in ["Command::new(\"sh\")", "Command::new(\"bash\")"] {
5225 assert!(
5226 !MERGE_RS_SOURCE.contains(needle),
5227 "`{needle}` would reintroduce shell interpolation"
5228 );
5229 }
5230 }
5231
5232 #[test]
5233 fn delivery_has_no_pull_request_merge_invocation() {
5234 let production = MERGE_RS_SOURCE
5235 .split_once("mod tests {")
5236 .map(|(head, _)| head)
5237 .unwrap_or(MERGE_RS_SOURCE);
5238 let lines: Vec<&str> = production.lines().collect();
5239 for (index, line) in lines.iter().enumerate() {
5240 if line.contains("\"pr\"") {
5241 let end = (index + 4).min(lines.len());
5242 let window = lines[index..end].join("\n");
5243 assert!(
5244 !window.contains("\"merge\""),
5245 "pull-request merge invocation at production line {}",
5246 index + 1
5247 );
5248 }
5249 }
5250 }
5251
5252 #[test]
5255 fn pr_check_read_requests_the_rollup_and_head_sha() {
5256 assert_eq!(
5257 gh_pr_checks_args(41),
5258 ["pr", "view", "41", "--json", "headRefOid,statusCheckRollup"]
5259 );
5260 }
5261
5262 #[test]
5263 fn draft_adds_the_draft_flag_and_nothing_else_does() {
5264 let with = gh_pr_create_args("h", "main", "t", "b", true);
5265 assert!(with.contains(&"--draft".to_string()), "{with:?}");
5266 let without = gh_pr_create_args("h", "main", "t", "b", false);
5267 assert!(!without.contains(&"--draft".to_string()), "{without:?}");
5268 assert!(with.windows(2).any(|w| w[0] == "--body" && w[1] == "b"));
5270 assert!(with.windows(2).any(|w| w[0] == "--title" && w[1] == "t"));
5271 }
5272
5273 #[test]
5274 fn the_push_refspec_is_append_only_and_fully_qualified() {
5275 let args = push_args("abc123", "goalpool/g_1");
5276 assert_eq!(
5277 args,
5278 vec![
5279 "push".to_string(),
5280 "origin".to_string(),
5281 "abc123:refs/heads/goalpool/g_1".to_string(),
5282 ]
5283 );
5284 let plus = '+';
5285 assert!(
5286 !args.iter().any(|a| a.starts_with(plus)),
5287 "a leading plus is git's force marker: {args:?}"
5288 );
5289
5290 let mut child = std::process::Command::new("git")
5294 .args(["check-ref-format", "refs/heads/goalpool/g_1"])
5295 .spawn()
5296 .expect("spawn git check-ref-format fixture");
5297 let status = child.wait().expect("reap git fixture child");
5298 assert!(status.success(), "git rejected the destination ref");
5299 }
5300
5301 #[test]
5306 fn pr_list_asks_for_every_state_of_one_head_branch() {
5307 let args = gh_pr_list_args("goalpool/g_1");
5308 assert!(args
5309 .windows(2)
5310 .any(|w| w[0] == "--head" && w[1] == "goalpool/g_1"));
5311 assert!(args.windows(2).any(|w| w[0] == "--state" && w[1] == "all"));
5312 let limit: u32 = args
5313 .windows(2)
5314 .find(|w| w[0] == "--limit")
5315 .map(|w| w[1].parse().expect("--limit is a number"))
5316 .expect("an explicit --limit, or gh silently pages at 30");
5317 assert!(
5318 limit >= 100,
5319 "the head listing must not be truncated below 100: {args:?}"
5320 );
5321 }
5322
5323 #[test]
5326 fn pr_list_json_maps_merged_apart_from_closed() {
5327 let prs = parse_pr_list(
5328 r#"[{"number":1,"state":"OPEN","url":"u1","isDraft":true,"baseRefName":"main"},
5329 {"number":2,"state":"CLOSED","url":"u2","isDraft":false,"baseRefName":"main"},
5330 {"number":3,"state":"MERGED","url":"u3","isDraft":false,"baseRefName":"main"}]"#,
5331 )
5332 .unwrap();
5333 assert_eq!(prs[0].state, PrState::Open);
5334 assert!(prs[0].is_draft);
5335 assert_eq!(prs[1].state, PrState::ClosedUnmerged);
5336 assert_eq!(prs[2].state, PrState::Merged);
5337 }
5338
5339 #[test]
5340 fn an_unknown_pr_state_is_an_error_not_a_guess() {
5341 assert!(
5342 parse_pr_list(r#"[{"number":1,"state":"WAT","url":"u","baseRefName":"main"}]"#)
5343 .is_err()
5344 );
5345 assert!(
5349 parse_pr_list(r#"[{"number":1,"state":"OPEN","url":"u","isDraft":false}]"#).is_err(),
5350 "a pull request with no baseRefName cannot be reconciled against a base"
5351 );
5352 assert!(parse_pr_list("not json").is_err());
5353 assert!(parse_pr_list("[]").unwrap().is_empty());
5354 }
5355
5356 #[test]
5357 fn a_pr_number_is_read_off_the_created_url() {
5358 assert_eq!(
5359 pr_number_from_url("https://github.com/acme/repo/pull/4821\n").unwrap(),
5360 4821
5361 );
5362 assert!(pr_number_from_url("https://github.com/acme/repo").is_err());
5363 }
5364
5365 #[test]
5366 fn push_errors_split_into_retriable_and_not() {
5367 let (reason, retriable) = classify_push_error("! [rejected] abc -> b (non-fast-forward)");
5368 assert!(retriable);
5369 assert!(reason.contains("non-fast-forward"));
5370
5371 let (_, retriable) = classify_push_error("remote: Permission denied to car-coder.");
5372 assert!(!retriable, "a permission refusal must not be retried");
5373
5374 let (_, retriable) = classify_push_error(
5381 "ssh: Could not resolve hostname github.com: nodename nor servname provided, \
5382 or not known\nfatal: Could not read from remote repository.\n\nPlease make \
5383 sure you have the correct access rights\nand the repository exists.",
5384 );
5385 assert!(retriable, "transport failures are worth another round");
5386 }
5387
5388 #[test]
5400 fn a_sha_containing_403_is_not_mistaken_for_a_permission_refusal() {
5401 let (reason, retriable) = classify_push_error(
5402 "! [remote rejected] goalpool/g_1 -> goalpool/g_1 (cannot lock ref \
5403 'refs/heads/goalpool/g_1': is at a4973f07ba3815b8d45b86a7e9633d9fbc5e4403 \
5404 but expected b1c2d3e4f5061728394a5b6c7d8e9f0011223344)",
5405 );
5406 assert!(
5407 retriable,
5408 "a lost push race is retriable; the digits 403 inside a SHA are not an HTTP status"
5409 );
5410 assert!(
5411 reason.contains("race") || reason.contains("moved"),
5412 "the reason must name what actually happened: {reason}"
5413 );
5414 }
5415
5416 #[test]
5424 fn gits_lost_race_wording_is_recognised_rather_than_defaulted() {
5425 for message in [
5426 "! [remote rejected] main -> main (failed to update ref)",
5427 "error: cannot lock ref 'refs/heads/goalpool/g_1': is at aaa but expected bbb",
5428 "! [rejected] abc -> b (fetch first)",
5429 ] {
5430 let (reason, retriable) = classify_push_error(message);
5431 assert!(retriable, "{message}");
5432 assert!(
5433 reason.contains("moved") || reason.contains("race"),
5434 "the reason must tell an operator the branch moved, not echo git: {reason}"
5435 );
5436 }
5437 }
5438
5439 #[test]
5448 fn a_403_in_a_branch_or_repo_name_is_not_a_permission_refusal() {
5449 for message in [
5450 "! [rejected] abc1234 -> feature-403 (non-fast-forward)",
5451 "! [rejected] abc -> goalpool/g_403 (fetch first)",
5452 "! [remote rejected] x -> release_403 (failed to update ref)",
5453 ] {
5454 let (reason, retriable) = classify_push_error(message);
5455 assert!(retriable, "must stay a retriable race: {message}");
5456 assert!(
5457 reason.contains("moved") || reason.contains("race"),
5458 "{reason}"
5459 );
5460 }
5461 let (_, retriable) =
5463 classify_push_error("fatal: unable to access 'https://github.com/org/repo-403.git/'");
5464 assert!(retriable, "a repo name is not a status code");
5465 }
5466
5467 #[test]
5474 fn permanent_server_refusals_inside_remote_rejected_are_not_retried() {
5475 for message in [
5476 "! [remote rejected] b -> b (refusing to allow an OAuth App to create or update \
5477 workflow '.github/workflows/x.yml' without 'workflow' scope)",
5478 "! [remote rejected] b -> b (shallow update not allowed)",
5479 "remote: error: GH001: Large files detected. File exceeds GitHub's file size limit \
5480 of 100.00 MB",
5481 "remote: error: cannot lock ref 'refs/heads/goalpool/g_1': \
5486 'refs/heads/goalpool' exists; cannot create 'refs/heads/goalpool/g_1'\n \
5487 ! [remote rejected] HEAD -> goalpool/g_1 (failed to update ref)",
5488 ] {
5489 let (_, retriable) = classify_push_error(message);
5490 assert!(!retriable, "permanent, must not be retried: {message}");
5491 }
5492 }
5493
5494 #[test]
5511 fn a_repository_rule_violation_is_permanent_not_a_lost_race() {
5512 let (reason, retriable) = classify_push_error(
5513 "remote: error: GH013: Repository rule violations found for \
5514 refs/heads/goalpool/g_1.\nremote:\nremote: - GITHUB PUSH PROTECTION\nremote: \
5515 —— GitHub Personal Access Token ————————————————\nremote:\n \
5516 ! [remote rejected] goalpool/g_1 -> goalpool/g_1 (push declined due to \
5517 repository rule violations)\nerror: failed to push some refs to \
5518 'https://github.com/o/r.git'",
5519 );
5520 assert!(
5521 !retriable,
5522 "a ruleset block cannot be got past by pushing the same commit again"
5523 );
5524 assert!(
5525 !reason.contains("race") && !reason.contains("moved"),
5526 "and it must not be described as a lost push race: {reason}"
5527 );
5528
5529 let (_, retriable) = classify_push_error(
5532 "! [remote rejected] b -> b (push declined due to repository rule violations)",
5533 );
5534 assert!(!retriable);
5535
5536 for code in ["GH009", "GH011", "GH013"] {
5538 let (_, retriable) =
5539 classify_push_error(&format!("remote: error: {code}: blocked by policy"));
5540 assert!(!retriable, "{code} must be read as a policy refusal");
5541 }
5542 }
5543
5544 #[test]
5551 fn a_github_code_in_a_branch_name_is_not_a_policy_refusal() {
5552 for message in [
5553 "! [rejected] abc -> fix-gh013-secret-scanning (non-fast-forward)",
5554 "! [remote rejected] x -> gh001 (failed to update ref)",
5555 "fatal: unable to access 'https://github.com/org/gh013.git/'",
5556 ] {
5557 let (_, retriable) = classify_push_error(message);
5558 assert!(retriable, "a name is not a status code: {message}");
5559 }
5560 }
5561
5562 #[test]
5564 fn permanent_pr_reconciliation_failures_are_not_retriable() {
5565 for message in [
5566 "GraphQL: No commits between main and goalpool/g_1",
5567 "GraphQL: Draft pull requests are not supported in this repository",
5568 ] {
5569 let (_, retriable) = classify_pr_error(message);
5570 assert!(!retriable, "permanent, must not be retried: {message}");
5571 }
5572
5573 let (_, retriable) = classify_pr_error(
5580 "a pull request for branch \"goalpool/g_1\" into branch \"main\" already exists: #7",
5581 );
5582 assert!(
5583 retriable,
5584 "the error proves a usable pull request exists; the next round adopts it"
5585 );
5586 let (_, retriable) = classify_pr_error("502 Bad Gateway");
5589 assert!(retriable);
5590 }
5591
5592 #[test]
5605 fn a_paragraph_break_in_the_intent_ends_the_commit_subject() {
5606 let f = fixture();
5607 let c = contract();
5608 let wt = f.cut("subject-check", "main");
5609 std::fs::write(wt.join("greet.js"), "console.log('hi');\n").unwrap();
5610
5611 let summary = "Goal: implement greet() in the scratch repo";
5612 let body_text = "Read the goal brief /tmp/car-e2e-2/gp-home/logs/brief.md and \
5613 follow it exactly. This pointer text must not reach the subject.";
5614 let intent = format!("{summary}\n\n{body_text}");
5615
5616 let out = deliver_pr_with(
5617 PrDelivery {
5618 repo: &f.repo,
5619 worktree: &wt,
5620 target_branch: "goalpool/g_subject",
5621 base_branch: "main",
5622 draft: true,
5623 intent: &intent,
5624 contract: &c,
5625 body: "b",
5626 provenance: None,
5627 },
5628 &FakeGh::ok(),
5629 )
5630 .expect("delivery succeeds");
5631
5632 let subject = git(&f.repo, &["log", "-1", "--format=%s", &out.commit]).unwrap();
5633 assert_eq!(
5634 subject.trim(),
5635 summary,
5636 "the subject must be exactly the first paragraph"
5637 );
5638 assert!(
5639 !subject.contains("goal brief"),
5640 "the pointer text must not ride along: {subject}"
5641 );
5642
5643 let body = git(&f.repo, &["log", "-1", "--format=%b", &out.commit]).unwrap();
5645 assert!(
5646 body.contains("goal brief"),
5647 "the remainder belongs in the body: {body}"
5648 );
5649 }
5650
5651 #[test]
5654 fn a_single_paragraph_intent_still_truncates_as_before() {
5655 let short = "Add a --verbose flag to the CLI";
5656 assert_eq!(subject_from_intent(short), short);
5657
5658 let long = "Add a --verbose flag to the export subcommand and thread it through \
5659 every downstream call site so the whole pipeline reports progress";
5660 let subject = subject_from_intent(long);
5661 assert!(subject.ends_with("..."), "{subject}");
5662 assert!(subject.len() <= 72, "len {}: {subject}", subject.len());
5663 assert!(!subject.contains('\n'));
5664
5665 assert_eq!(
5667 subject_from_intent("wrapped over\ntwo lines\n\nbody here"),
5668 "wrapped over two lines"
5669 );
5670 }
5671
5672 #[test]
5681 fn no_runtime_bookkeeping_reaches_the_delivered_tree() {
5682 let f = fixture();
5683 let c = contract();
5684 let wt = f.cut("marker-check", "main");
5685
5686 let gitdir = std::fs::read_to_string(wt.join(".git"))
5689 .ok()
5690 .and_then(|m| {
5691 m.trim()
5692 .strip_prefix("gitdir:")
5693 .map(|g| g.trim().to_string())
5694 })
5695 .map(PathBuf::from)
5696 .expect("a worktree's .git is a file holding a gitdir pointer");
5697 std::fs::write(gitdir.join("car-code-task"), "claimed\n").unwrap();
5698
5699 std::fs::write(wt.join("greet.js"), "console.log('hi');\n").unwrap();
5700 let out = deliver_pr_with(
5701 delivery(&f, &wt, &c, "goalpool/g_marker", true, "b"),
5702 &FakeGh::ok(),
5703 )
5704 .expect("delivery succeeds");
5705
5706 let tree = git(&f.origin, &["ls-tree", "-r", "--name-only", &out.commit]).unwrap();
5707 assert!(
5708 tree.contains("greet.js"),
5709 "the actual work must be there: {tree}"
5710 );
5711 for bookkeeping in ["car-code-task", ".car-code-task"] {
5712 assert!(
5713 !tree.contains(bookkeeping),
5714 "`{bookkeeping}` is runtime bookkeeping and must not reach a reviewed diff: {tree}"
5715 );
5716 }
5717 }
5718
5719 #[test]
5728 fn a_clean_worktree_at_the_base_is_refused_rather_than_pushed_empty() {
5729 let f = fixture();
5730 let c = contract();
5731 let wt = f.cut("empty-round", "main");
5734 let base_tip = git(&wt, &["rev-parse", "HEAD"]).unwrap().trim().to_string();
5735
5736 let err = deliver_pr_with(
5737 delivery(&f, &wt, &c, "goalpool/g_empty", true, "b"),
5738 &FakeGh::ok(),
5739 )
5740 .expect_err("an empty round must not open a pull request");
5741 assert_eq!(err.stage(), "commit", "{err}");
5742 assert!(
5743 err.reason().contains("nothing to deliver"),
5744 "{}",
5745 err.reason()
5746 );
5747 assert!(err.reason().contains(&base_tip), "{}", err.reason());
5748 assert!(!err.retriable(), "an empty round will be empty again");
5749
5750 assert!(
5752 git(
5753 &f.origin,
5754 &[
5755 "rev-parse",
5756 "--verify",
5757 "--quiet",
5758 "refs/heads/goalpool/g_empty"
5759 ]
5760 )
5761 .is_err(),
5762 "no stray branch may be created for an empty round"
5763 );
5764 }
5765
5766 #[test]
5773 fn a_policy_refusal_wrapped_in_rejection_wording_is_not_a_race() {
5774 let (_, retriable) =
5775 classify_push_error("! [remote rejected] main -> main (pre-receive hook declined)");
5776 assert!(!retriable, "branch protection is not worth retrying");
5777 }
5778
5779 #[test]
5781 fn a_genuine_403_is_still_a_non_retriable_refusal() {
5782 for message in [
5783 "fatal: unable to access 'https://github.com/o/r/': The requested URL returned error: 403",
5784 "remote: Permission to o/r.git denied to car-coder.",
5785 "fatal: Authentication failed for 'https://github.com/o/r/'",
5786 "fatal: could not read Username for 'https://github.com'",
5787 ] {
5788 let (_, retriable) = classify_push_error(message);
5789 assert!(!retriable, "must not be retried: {message}");
5790 }
5791 }
5792
5793 #[test]
5799 fn delivering_onto_the_base_branch_is_refused_before_anything_is_pushed() {
5800 let f = fixture();
5801 let c = contract();
5802 let wt = f.cut("s1", "main");
5803 std::fs::write(wt.join("x.txt"), "unreviewed").unwrap();
5804
5805 let gh = FakeGh::ok();
5806 let err = deliver_pr_with(delivery(&f, &wt, &c, "main", true, "b"), &gh).unwrap_err();
5807
5808 assert_eq!(err.stage(), "preflight", "{err}");
5809 assert!(!err.retriable());
5810 assert!(err.reason().contains("base"), "{err}");
5811 assert!(
5814 git(&wt, &["log", "-1", "--format=%an"])
5815 .unwrap()
5816 .trim()
5817 .ne("car-coder"),
5818 "the worktree must not have been committed"
5819 );
5820 assert!(gh.calls().is_empty(), "{:?}", gh.calls());
5821 }
5822
5823 #[test]
5828 fn a_commit_failure_is_not_mistaken_for_a_clean_worktree() {
5829 let f = fixture();
5830 let c = contract();
5831
5832 let wt = f.cut("s1", "main");
5835 std::fs::write(wt.join("x.txt"), "round one").unwrap();
5836 let first =
5837 deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "b"), &FakeGh::ok()).unwrap();
5838
5839 std::fs::write(
5843 wt.join("y.txt"),
5844 "round two — the work that must not be lost",
5845 )
5846 .unwrap();
5847 git(&f.repo, &["config", "commit.gpgsign", "true"]).unwrap();
5848 git(&f.repo, &["config", "gpg.program", "/nonexistent/gpg"]).unwrap();
5849
5850 let intent = "fix delivery so it reports 'no changes to deliver' correctly";
5851 let d = PrDelivery {
5852 intent,
5853 ..delivery(&f, &wt, &c, TARGET, true, "b")
5854 };
5855 let err = deliver_pr_with(d, &FakeGh::ok()).unwrap_err();
5856
5857 assert_eq!(err.stage(), "commit", "{err}");
5858 assert_eq!(
5861 f.origin_head(TARGET).as_deref(),
5862 Some(first.commit.as_str()),
5863 "the stale commit must not be re-delivered as if it were round 2"
5864 );
5865 }
5866
5867 #[test]
5871 fn a_cross_repository_pull_request_is_not_adopted() {
5872 let raw = r#"[
5873 {"number":200,"state":"OPEN","url":"https://github.com/acme/repo/pull/200",
5874 "isDraft":false,"isCrossRepository":true,"baseRefName":"main"},
5875 {"number":7,"state":"OPEN","url":"https://github.com/acme/repo/pull/7",
5876 "isDraft":false,"isCrossRepository":false,"baseRefName":"main"}
5877 ]"#;
5878 let prs = parse_pr_list(raw).unwrap();
5879 assert_eq!(
5880 prs.iter().map(|p| p.number).collect::<Vec<_>>(),
5881 vec![7],
5882 "only the same-repository pull request may be reconciled"
5883 );
5884 assert!(gh_pr_list_args("b")
5886 .iter()
5887 .any(|a| a == "number,state,url,isDraft,isCrossRepository,baseRefName"));
5888 }
5889
5890 #[test]
5894 fn the_github_repository_is_taken_from_the_same_remote_the_push_uses() {
5895 for (url, expect) in [
5896 ("https://github.com/acme/repo.git", Some("acme/repo")),
5897 ("https://github.com/acme/repo", Some("acme/repo")),
5898 ("git@github.com:acme/repo.git", Some("acme/repo")),
5899 ("ssh://git@github.com/acme/repo.git", Some("acme/repo")),
5900 (
5901 "https://x-token@github.com/acme/repo.git",
5902 Some("acme/repo"),
5903 ),
5904 (
5907 "git@github.example.com:acme/repo.git",
5908 Some("github.example.com/acme/repo"),
5909 ),
5910 ("/srv/mirrors/repo.git", None),
5913 ("../sibling", None),
5914 ] {
5915 assert_eq!(
5916 parse_github_repo_spec(url).as_deref(),
5917 expect,
5918 "for `{url}`"
5919 );
5920 }
5921
5922 let f = fixture();
5925 assert!(gh_repo_args(&f.repo).is_empty());
5926 git(
5927 &f.repo,
5928 &[
5929 "remote",
5930 "set-url",
5931 "origin",
5932 "git@github.com:acme/repo.git",
5933 ],
5934 )
5935 .unwrap();
5936 assert_eq!(
5937 gh_repo_args(&f.repo),
5938 vec!["--repo".to_string(), "acme/repo".to_string()]
5939 );
5940 }
5941
5942 #[test]
5946 fn a_404_or_401_push_failure_is_permanent_not_a_race() {
5947 for message in [
5948 "remote: Repository not found.\nfatal: repository \
5949 'https://github.com/o/private.git/' not found",
5950 "ERROR: Repository not found.\nfatal: Could not read from remote repository.",
5951 "fatal: unable to access 'https://github.com/o/r/': The requested URL returned \
5952 error: 401",
5953 "fatal: 'origin' does not appear to be a git repository",
5954 ] {
5955 let (_, retriable) = classify_push_error(message);
5956 assert!(!retriable, "must not be retried forever: {message}");
5957 }
5958 let (_, retriable) =
5960 classify_push_error("! [rejected] goalpool/g_404 -> goalpool/g_404 (non-fast-forward)");
5961 assert!(retriable, "a moved branch is still worth another round");
5962 }
5963
5964 #[test]
5969 fn git_does_not_inherit_the_repository_from_the_environment() {
5970 let production = MERGE_RS_SOURCE
5971 .split_once("mod tests {")
5972 .map(|(head, _)| head)
5973 .unwrap_or(MERGE_RS_SOURCE);
5974 for var in ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"] {
5975 assert!(
5976 production.contains(&format!(".env_remove(\"{var}\")")),
5977 "`git()` must clear {var}, which otherwise overrides `-C`"
5978 );
5979 }
5980 }
5981
5982 #[test]
5987 fn a_worktree_holding_only_untracked_work_is_not_read_as_clean() {
5988 let f = fixture();
5989 let c = contract();
5990 git(&f.repo, &["config", "status.showUntrackedFiles", "no"]).unwrap();
5991 let wt = f.cut("s1", "main");
5992 std::fs::write(wt.join("brand-new.txt"), "a day of work").unwrap();
5993
5994 let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, true, "b"), &FakeGh::ok()).unwrap();
5995
5996 assert_eq!(
5997 git(
5998 &f.origin,
5999 &["show", &format!("refs/heads/{TARGET}:brand-new.txt")]
6000 )
6001 .unwrap(),
6002 "a day of work",
6003 "the untracked work must be in the delivered commit"
6004 );
6005 assert_eq!(out.pr_action, PrAction::Opened);
6006 }
6007
6008 #[test]
6020 fn a_permanent_phrase_in_the_body_cannot_make_a_transient_failure_permanent() {
6021 let f = fixture();
6022 let c = contract();
6023
6024 let wt1 = f.cut("s1", "main");
6026 std::fs::write(wt1.join("x.txt"), "one").unwrap();
6027 deliver_pr_with(delivery(&f, &wt1, &c, TARGET, false, "one"), &FakeGh::ok()).unwrap();
6028
6029 git(&f.repo, &["fetch", "-q", "origin"]).unwrap();
6031 let wt2 = f.cut("s2", &format!("origin/{TARGET}"));
6032 std::fs::write(wt2.join("y.txt"), "two").unwrap();
6033
6034 let body = "This round fixes the 'no commits between' error on empty deliveries.";
6035 let gh = FakeGh::failing_set_body("HTTP 502: Bad Gateway (https://api.github.com/…)");
6036 *gh.prs.lock().unwrap() = vec![PrRecord {
6037 number: 101,
6038 state: PrState::Open,
6039 url: "https://github.com/acme/repo/pull/101".into(),
6040 is_draft: false,
6041 base: "main".into(),
6042 }];
6043
6044 let err = deliver_pr_with(delivery(&f, &wt2, &c, TARGET, false, body), &gh).unwrap_err();
6045 assert!(
6046 err.retriable(),
6047 "a 502 is retriable however the body is worded: {err:?}"
6048 );
6049
6050 let gh2 = FakeGh::failing_set_body("GraphQL: No commits between main and goalpool/g_1");
6053 *gh2.prs.lock().unwrap() = vec![PrRecord {
6054 number: 101,
6055 state: PrState::Open,
6056 url: "https://github.com/acme/repo/pull/101".into(),
6057 is_draft: false,
6058 base: "main".into(),
6059 }];
6060 let wt3 = f.cut("s3", &format!("origin/{TARGET}"));
6061 std::fs::write(wt3.join("z.txt"), "three").unwrap();
6062 let err2 =
6063 deliver_pr_with(delivery(&f, &wt3, &c, TARGET, false, "plain"), &gh2).unwrap_err();
6064 assert!(!err2.retriable(), "{err2:?}");
6065 }
6066
6067 #[test]
6080 fn an_open_pull_request_into_another_base_is_refused_rather_than_reconciled() {
6081 let f = fixture();
6082 let c = contract();
6083 let wt = f.cut("s1", "main");
6084 std::fs::write(wt.join("x.txt"), "work").unwrap();
6085
6086 let gh = FakeGh::with_prs(vec![
6089 PrRecord {
6090 number: 10,
6091 state: PrState::Open,
6092 url: "https://github.com/acme/repo/pull/10".into(),
6093 is_draft: false,
6094 base: "main".into(),
6095 },
6096 PrRecord {
6097 number: 12,
6098 state: PrState::Open,
6099 url: "https://github.com/acme/repo/pull/12".into(),
6100 is_draft: false,
6101 base: "release/2.1".into(),
6102 },
6103 ]);
6104
6105 let err =
6106 deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap_err();
6107
6108 assert!(
6109 matches!(err, DeliveryFailure::Preflight { .. }),
6110 "an ambiguous head is refused before the commit: {err:?}"
6111 );
6112 assert!(
6113 !err.retriable(),
6114 "retrying changes nothing — a human closes #12 or picks another target branch"
6115 );
6116 assert!(
6118 err.reason().contains("#12") && err.reason().contains("release/2.1"),
6119 "{}",
6120 err.reason()
6121 );
6122
6123 assert_eq!(f.origin_head(TARGET), None, "nothing may be pushed");
6126 assert!(
6127 !gh.calls()
6128 .iter()
6129 .any(|c| c.starts_with("set_body") || c.starts_with("create")),
6130 "{:?}",
6131 gh.calls()
6132 );
6133 }
6134
6135 #[test]
6140 fn a_human_pull_request_into_another_base_parks_delivery_before_the_push() {
6141 let f = fixture();
6142 let c = contract();
6143 let wt = f.cut("s1", "main");
6144 std::fs::write(wt.join("x.txt"), "unreviewed model output").unwrap();
6145
6146 let gh = FakeGh::with_prs(vec![PrRecord {
6147 number: 200,
6148 state: PrState::Open,
6149 url: "https://github.com/acme/repo/pull/200".into(),
6150 is_draft: false,
6151 base: "release/2.1".into(),
6152 }]);
6153
6154 let err =
6155 deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap_err();
6156
6157 assert_eq!(err.stage(), "preflight");
6158 assert!(!err.retriable(), "{err:?}");
6159 assert!(err.reason().contains("#200"), "{}", err.reason());
6160 assert_eq!(
6161 f.origin_head(TARGET),
6162 None,
6163 "the model's commits must never reach a branch #200 tracks"
6164 );
6165 assert!(
6166 !gh.calls().iter().any(|c| c.starts_with("create")),
6167 "no second pull request is opened to paper over the refusal: {:?}",
6168 gh.calls()
6169 );
6170 }
6171
6172 #[test]
6175 fn a_changed_base_is_refused_while_the_old_pull_request_is_open() {
6176 let f = fixture();
6177 let c = contract();
6178 let wt = f.cut("s1", "main");
6179 std::fs::write(wt.join("x.txt"), "work").unwrap();
6180
6181 let gh = FakeGh::with_prs(vec![PrRecord {
6182 number: 10,
6183 state: PrState::Open,
6184 url: "https://github.com/acme/repo/pull/10".into(),
6185 is_draft: false,
6186 base: "main".into(),
6187 }]);
6188 let mut d = delivery(&f, &wt, &c, TARGET, false, "body");
6189 d.base_branch = "release/2.1";
6190
6191 let err = deliver_pr_with(d, &gh).unwrap_err();
6192 assert_eq!(err.stage(), "preflight");
6193 assert!(err.reason().contains("#10"), "{}", err.reason());
6194 assert_eq!(f.origin_head(TARGET), None);
6195 }
6196
6197 #[test]
6200 fn a_changed_base_opens_its_own_pull_request_once_the_old_one_is_closed() {
6201 let f = fixture();
6202 let c = contract();
6203 let wt = f.cut("s1", "main");
6204 std::fs::write(wt.join("x.txt"), "work").unwrap();
6205
6206 let gh = FakeGh::with_prs(vec![PrRecord {
6207 number: 10,
6208 state: PrState::ClosedUnmerged,
6209 url: "https://github.com/acme/repo/pull/10".into(),
6210 is_draft: false,
6211 base: "main".into(),
6212 }]);
6213 let mut d = delivery(&f, &wt, &c, TARGET, false, "body");
6214 d.base_branch = "release/2.1";
6215
6216 let out = deliver_pr_with(d, &gh).unwrap();
6217 assert_eq!(out.pr_action, PrAction::Opened);
6218 assert_ne!(out.pr_number, 10);
6219 assert!(
6220 gh.calls()
6221 .iter()
6222 .any(|c| c.contains("create head=") && c.contains("base=release/2.1")),
6223 "{:?}",
6224 gh.calls()
6225 );
6226 assert!(
6230 !gh.calls().iter().any(|c| c.starts_with("set_body")),
6231 "{:?}",
6232 gh.calls()
6233 );
6234 }
6235
6236 #[test]
6239 fn a_merged_pull_request_into_another_base_does_not_park_delivery() {
6240 let f = fixture();
6241 let c = contract();
6242 let wt = f.cut("s1", "main");
6243 std::fs::write(wt.join("x.txt"), "work").unwrap();
6244
6245 let gh = FakeGh::with_prs(vec![
6246 PrRecord {
6247 number: 10,
6248 state: PrState::Open,
6249 url: "https://github.com/acme/repo/pull/10".into(),
6250 is_draft: false,
6251 base: "main".into(),
6252 },
6253 PrRecord {
6254 number: 12,
6255 state: PrState::Merged,
6256 url: "https://github.com/acme/repo/pull/12".into(),
6257 is_draft: false,
6258 base: "release/2.1".into(),
6259 },
6260 ]);
6261
6262 let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap();
6263 assert_eq!(out.pr_action, PrAction::Updated);
6264 assert_eq!(out.pr_number, 10);
6265 }
6266
6267 #[test]
6272 fn a_cross_repository_pull_request_does_not_park_delivery() {
6273 let parsed = parse_pr_list(
6274 r#"[
6275 {"number": 77, "state": "OPEN", "url": "u77", "isDraft": false,
6276 "baseRefName": "release/2.1", "isCrossRepository": true},
6277 {"number": 10, "state": "OPEN", "url": "u10", "isDraft": false,
6278 "baseRefName": "main", "isCrossRepository": false}
6279 ]"#,
6280 )
6281 .unwrap();
6282 assert_eq!(parsed.len(), 1, "the fork entry is dropped: {parsed:?}");
6283
6284 let f = fixture();
6285 let c = contract();
6286 let wt = f.cut("s1", "main");
6287 std::fs::write(wt.join("x.txt"), "work").unwrap();
6288 let gh = FakeGh::with_prs(parsed);
6289
6290 let out = deliver_pr_with(delivery(&f, &wt, &c, TARGET, false, "round body"), &gh).unwrap();
6291 assert_eq!(out.pr_action, PrAction::Updated);
6292 assert_eq!(out.pr_number, 10);
6293 }
6294
6295 #[test]
6299 fn credential_prompts_that_can_never_be_answered_are_permanent() {
6300 for message in [
6301 "fatal: could not read Username for 'https://github.com': No such device or address",
6302 "fatal: could not read Password for 'https://someuser@github.com': \
6303 No such device or address",
6304 "fatal: could not read Username for 'https://github.com': terminal prompts disabled",
6305 "Host key verification failed.\nfatal: Could not read from remote repository.",
6306 ] {
6307 let (_, retriable) = classify_push_error(message);
6308 assert!(
6309 !retriable,
6310 "a credential that will never appear must not be retried: {message}"
6311 );
6312 }
6313 let (_, retriable) =
6316 classify_push_error("! [rejected] goalpool/g_pw -> goalpool/g_pw (non-fast-forward)");
6317 assert!(retriable);
6318 }
6319
6320 #[test]
6324 fn git_children_cannot_open_a_terminal_prompt() {
6325 let dir = tempfile::tempdir().unwrap();
6326 git(dir.path(), &["init", "-q", "-b", "main"]).unwrap();
6327 let seen = git(
6330 dir.path(),
6331 &[
6332 "-c",
6333 "alias.envprobe=!printf %s \"${GIT_TERMINAL_PROMPT-unset}\"",
6334 "envprobe",
6335 ],
6336 )
6337 .unwrap();
6338 assert_eq!(
6339 seen.trim(),
6340 "0",
6341 "GIT_TERMINAL_PROMPT must be 0 for every git this module runs"
6342 );
6343 }
6344
6345 #[test]
6348 fn a_subprocess_that_never_finishes_is_killed_and_reported() {
6349 let mut cmd = std::process::Command::new("sleep");
6350 cmd.arg("60");
6351 let started = std::time::Instant::now();
6352 let err = run_capped_for(cmd, std::time::Duration::from_millis(300)).err();
6353 assert!(
6354 matches!(err, Some(RunFailure::TimedOut(_))),
6355 "the child must be killed, not waited on"
6356 );
6357 assert!(
6358 started.elapsed() < std::time::Duration::from_secs(20),
6359 "the kill must not wait for the child's own exit"
6360 );
6361 }
6362
6363 #[test]
6367 fn branch_mode_re_delivers_a_clean_worktree_whose_head_is_ahead_of_the_base() {
6368 let f = fixture();
6369 let c = contract();
6370 let wt = f.cut("s1", "main");
6371 std::fs::write(wt.join("x.txt"), "work").unwrap();
6372 let first =
6374 publish_branch_headless(&f.repo, &wt, "r1", "make x exist", &c, "main", None).unwrap();
6375 assert_eq!(first, "car/coder/r1");
6376
6377 let second =
6379 publish_branch_headless(&f.repo, &wt, "r2", "make x exist", &c, "main", None).unwrap();
6380 assert_eq!(second, "car/coder/r2");
6381 assert_eq!(
6382 git(&f.repo, &["rev-parse", "car/coder/r1"]).unwrap().trim(),
6383 git(&f.repo, &["rev-parse", "car/coder/r2"]).unwrap().trim(),
6384 "the same commit is re-delivered, not redone"
6385 );
6386 }
6387
6388 #[test]
6392 fn branch_mode_still_refuses_a_clean_worktree_that_holds_no_work() {
6393 let f = fixture();
6394 let c = contract();
6395 let wt = f.cut("s1", "main");
6396 let err =
6397 publish_branch_headless(&f.repo, &wt, "r1", "noop", &c, "main", None).unwrap_err();
6398 assert!(err.contains("nothing to deliver"), "{err}");
6399 }
6400
6401 #[test]
6405 fn a_crlf_intent_still_stops_at_its_blank_line() {
6406 let intent =
6407 "Add the retry shim\r\n\r\nPointers:\r\n- see src/net.rs\r\n- and the docs\r\n";
6408 let subject = subject_from_intent(intent);
6409 assert_eq!(subject, "Add the retry shim");
6410 assert!(!subject.contains('\r'), "{subject:?}");
6411 assert!(!subject_from_intent("a\rb\r\nc").contains('\r'));
6413 }
6414
6415 #[test]
6419 fn forge_command_shapes_elide_the_title_and_body() {
6420 for args in [
6421 gh_pr_create_args("head", "main", "a title", "a very long body", false),
6422 az_pr_create_args("head", "main", "a title", "a very long body", false),
6423 ] {
6424 let shape = gh_subcommand_shape(&args);
6425 assert!(!shape.contains("a title"), "{shape}");
6426 assert!(!shape.contains("a very long body"), "{shape}");
6427 assert!(shape.contains("--title"), "{shape}");
6428 }
6429 }
6430}