1use crate::cmd::CommandErrorContext;
11use crate::github::http::GhHttp;
12use regex::Regex;
13use std::path::Path;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::LazyLock;
16
17use tokio::process::Command;
18
19pub(crate) fn format_task_ref(task_id: &str) -> String {
25 if task_id.starts_with("internal:") {
26 task_id.replace(':', "-")
27 } else {
28 format!("#{task_id}")
29 }
30}
31
32#[derive(Debug, thiserror::Error)]
34pub enum PrCreateError {
35 #[error("GitHub API error: {0}")]
36 ApiError(#[from] anyhow::Error),
37}
38
39pub type PrCreateResult<T> = Result<T, PrCreateError>;
41
42pub async fn has_commits_ahead(dir: &Path, default_branch: &str) -> bool {
44 let output = Command::new("git")
45 .args([
46 "rev-list",
47 "--count",
48 &format!("origin/{default_branch}..HEAD"),
49 ])
50 .current_dir(dir)
51 .output()
52 .await;
53 match output {
54 Ok(o) if o.status.success() => {
55 let count: u32 = String::from_utf8_lossy(&o.stdout)
56 .trim()
57 .parse()
58 .unwrap_or(0);
59 count > 0
60 }
61 _ => {
62 tracing::debug!(
63 default_branch,
64 "git rev-list failed for compare ref, assuming commits ahead"
65 );
66 true
70 }
71 }
72}
73
74pub async fn has_changes(dir: &Path) -> bool {
76 let diff = Command::new("git")
78 .args(["diff", "--quiet"])
79 .current_dir(dir)
80 .status()
81 .await;
82
83 let cached = Command::new("git")
84 .args(["diff", "--cached", "--quiet"])
85 .current_dir(dir)
86 .status()
87 .await;
88
89 let untracked = Command::new("git")
90 .args(["ls-files", "--others", "--exclude-standard"])
91 .current_dir(dir)
92 .output_with_context()
93 .await;
94
95 let has_diff = diff.map(|s| !s.success()).unwrap_or(false);
96 let has_cached = cached.map(|s| !s.success()).unwrap_or(false);
97 let has_untracked = untracked
98 .map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
99 .unwrap_or(false);
100
101 has_diff || has_cached || has_untracked
102}
103
104pub(crate) fn find_stash_ref_by_hash(stash_list: &str, stash_hash: &str) -> Option<String> {
105 stash_list.lines().find_map(|line| {
106 let mut parts = line.split_whitespace();
107 match (parts.next(), parts.next()) {
108 (Some(hash), Some(reference)) if hash == stash_hash => Some(reference.to_string()),
109 _ => None,
110 }
111 })
112}
113
114pub(crate) struct StashRebaseOpts {
116 pub abort_on_failure: bool,
120}
121
122impl Default for StashRebaseOpts {
123 fn default() -> Self {
124 Self {
125 abort_on_failure: true,
126 }
127 }
128}
129
130#[derive(Debug)]
132pub(crate) enum RebaseOutcome {
133 Succeeded,
135 Failed(String),
139 Skipped(String),
142}
143
144pub(crate) async fn stash_rebase_restore(
161 dir: &Path,
162 target_ref: &str,
163 opts: StashRebaseOpts,
164) -> anyhow::Result<RebaseOutcome> {
165 let stash_ref: Option<String> = if has_changes(dir).await {
166 let stash_out = Command::new("git")
167 .args(["stash", "push", "--include-untracked", "-m", "orch-rebase"])
168 .current_dir(dir)
169 .output_with_context()
170 .await;
171
172 match stash_out {
173 Ok(o) if o.status.success() => {
174 let stdout_preview = String::from_utf8_lossy(&o.stdout)
175 .lines()
176 .next()
177 .unwrap_or("")
178 .to_string();
179 tracing::debug!(
180 dir = %dir.display(),
181 stdout = %stdout_preview,
182 "git stash push succeeded before rebase"
183 );
184
185 let ref_out = Command::new("git")
189 .args(["rev-parse", "refs/stash@{0}"])
190 .current_dir(dir)
191 .output_with_context()
192 .await;
193 let stash_hash = ref_out
194 .ok()
195 .filter(|o| o.status.success())
196 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
197 .filter(|s| !s.is_empty());
198
199 if stash_hash.is_none() {
200 tracing::warn!(
203 dir = %dir.display(),
204 "git stash succeeded but rev-parse failed — popping stash and skipping rebase"
205 );
206 let _ = Command::new("git")
207 .args(["stash", "pop"])
208 .current_dir(dir)
209 .output_with_context()
210 .await;
211 return Ok(RebaseOutcome::Skipped(
212 "stash OID capture failed".to_string(),
213 ));
214 }
215 stash_hash
216 }
217 Ok(o) => {
218 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
219 tracing::warn!(
220 dir = %dir.display(),
221 code = ?o.status.code(),
222 stderr = %stderr,
223 "git stash push failed — skipping rebase to preserve dirty state"
224 );
225 return Ok(RebaseOutcome::Skipped(format!(
226 "stash push failed: {stderr}"
227 )));
228 }
229 Err(e) => {
230 tracing::warn!(
231 dir = %dir.display(),
232 error = %e,
233 "failed to run git stash push — skipping rebase to preserve dirty state"
234 );
235 return Ok(RebaseOutcome::Skipped(format!("stash push error: {e}")));
236 }
237 }
238 } else {
239 None
240 };
241
242 let output = git()
243 .await
244 .args(["rebase", target_ref])
245 .current_dir(dir)
246 .output_with_context()
247 .await;
248
249 match output {
250 Ok(o) if o.status.success() => {
251 tracing::debug!(dir = %dir.display(), target_ref, "rebase succeeded");
252 restore_stash_by_hash(dir, stash_ref.as_deref()).await;
253 Ok(RebaseOutcome::Succeeded)
254 }
255 Ok(o) => {
256 let stderr = String::from_utf8_lossy(&o.stderr).to_string();
257 tracing::warn!(dir = %dir.display(), target_ref, err = %stderr, "rebase failed");
258 if opts.abort_on_failure {
259 let abort = Command::new("git")
260 .args(["rebase", "--abort"])
261 .current_dir(dir)
262 .output_with_context()
263 .await;
264 match abort {
265 Err(e) => {
266 tracing::warn!(dir = %dir.display(), error = %e, "git rebase --abort failed — worktree may be in inconsistent state")
267 }
268 Ok(o) if !o.status.success() => {
269 let stderr = String::from_utf8_lossy(&o.stderr);
270 tracing::warn!(dir = %dir.display(), stderr = %stderr, "git rebase --abort returned non-zero — worktree may be in inconsistent state");
271 }
272 _ => {}
273 }
274 }
275 restore_stash_by_hash(dir, stash_ref.as_deref()).await;
276 Ok(RebaseOutcome::Failed(stderr))
277 }
278 Err(e) => {
279 if opts.abort_on_failure {
280 let abort = Command::new("git")
281 .args(["rebase", "--abort"])
282 .current_dir(dir)
283 .output_with_context()
284 .await;
285 match abort {
286 Err(e) => {
287 tracing::warn!(dir = %dir.display(), error = %e, "git rebase --abort failed — worktree may be in inconsistent state")
288 }
289 Ok(o) if !o.status.success() => {
290 let stderr = String::from_utf8_lossy(&o.stderr);
291 tracing::warn!(dir = %dir.display(), stderr = %stderr, "git rebase --abort returned non-zero — worktree may be in inconsistent state");
292 }
293 _ => {}
294 }
295 }
296 restore_stash_by_hash(dir, stash_ref.as_deref()).await;
297 Err(e)
298 }
299 }
300}
301
302async fn restore_stash_by_hash(dir: &Path, stash_hash: Option<&str>) {
306 let Some(stash_hash) = stash_hash else {
307 return;
308 };
309
310 let apply = Command::new("git")
311 .args(["stash", "apply", stash_hash])
312 .current_dir(dir)
313 .output_with_context()
314 .await;
315 if apply.map(|o| o.status.success()).unwrap_or(false) {
316 let list = Command::new("git")
317 .args(["stash", "list", "--format=%H %gd"])
318 .current_dir(dir)
319 .output_with_context()
320 .await;
321 if let Ok(list_out) = list {
322 if list_out.status.success() {
323 let list_str = String::from_utf8_lossy(&list_out.stdout);
324 if let Some(stash_ref) = find_stash_ref_by_hash(&list_str, stash_hash) {
325 match Command::new("git")
326 .args(["stash", "drop", &stash_ref])
327 .current_dir(dir)
328 .output_with_context()
329 .await
330 {
331 Ok(drop_out) if !drop_out.status.success() => {
332 let stderr = String::from_utf8_lossy(&drop_out.stderr);
333 tracing::warn!(
334 dir = %dir.display(),
335 stash_ref = %stash_ref,
336 err = %stderr,
337 "stash drop failed after apply — entry may remain on stack"
338 );
339 }
340 Err(e) => {
341 tracing::warn!(
342 dir = %dir.display(),
343 stash_ref = %stash_ref,
344 error = %e,
345 "stash drop command failed after apply — entry may remain on stack"
346 );
347 }
348 _ => {}
349 }
350 } else {
351 tracing::warn!(
352 dir = %dir.display(),
353 stash = %stash_hash,
354 "stash applied but could not find ref by hash — stash entry may be orphaned on stack"
355 );
356 }
357 }
358 }
359 } else {
360 tracing::warn!(
361 dir = %dir.display(),
362 stash = %stash_hash,
363 "stash apply failed after rebase — stash preserved for manual recovery"
364 );
365 }
366}
367
368pub async fn auto_commit(
370 dir: &Path,
371 task_id: &str,
372 title: &str,
373 agent: &str,
374 attempt: u32,
375 when: super::commit::When,
376) -> anyhow::Result<bool> {
377 if !has_changes(dir).await {
378 return Ok(false);
379 }
380
381 tracing::info!(task_id, "auto-committing uncommitted changes");
382 let task_ref = format_task_ref(task_id);
383 let message = format!("{title}\n\nTask {task_ref}\nAgent: {agent}\nAttempt: {attempt}");
384 match super::commit::commit_all(dir, &message, agent, when).await? {
385 super::commit::Outcome::Committed => {
386 tracing::info!(task_id, "auto-commit succeeded");
387 Ok(true)
388 }
389 super::commit::Outcome::Nothing => Ok(false),
390 super::commit::Outcome::Refused {
391 by: super::commit::Tool::Git,
392 reason,
393 } => {
394 tracing::warn!(task_id, err = %reason, "git commit failed");
395 Ok(false)
396 }
397 super::commit::Outcome::Refused { reason, .. } => anyhow::bail!(reason),
398 }
399}
400
401const MAX_REBASE_COMMITS: usize = 50;
409
410pub async fn rebase_on_branch(dir: &Path, branch: &str) -> anyhow::Result<bool> {
420 let origin_branch = format!("origin/{branch}");
421
422 let fetch = tokio::time::timeout(
425 std::time::Duration::from_secs(120),
426 git()
427 .await
428 .args(["fetch", "origin", branch])
429 .kill_on_drop(true)
430 .current_dir(dir)
431 .output(),
432 )
433 .await
434 .map_err(|_| anyhow::anyhow!("git fetch timed out after 120s"))
435 .and_then(|r| r.map_err(Into::into));
436
437 match fetch {
438 Ok(o) if o.status.success() => {}
439 Ok(o) => {
440 let stderr = String::from_utf8_lossy(&o.stderr);
441 anyhow::bail!("failed to fetch origin/{branch}: {stderr}");
442 }
443 Err(e) => anyhow::bail!("failed to fetch origin/{branch}: {e}"),
444 }
445
446 let count_output = Command::new("git")
448 .args(["rev-list", "--count", &format!("{origin_branch}..HEAD")])
449 .current_dir(dir)
450 .output_with_context()
451 .await;
452
453 let commit_count: usize = match count_output {
454 Ok(o) if o.status.success() => {
455 let raw = String::from_utf8_lossy(&o.stdout);
456 raw.trim().parse().map_err(|e| {
457 anyhow::anyhow!(
458 "failed to parse commit count from git rev-list output {:?}: {e}",
459 raw.trim()
460 )
461 })?
462 }
463 Ok(o) => {
464 let stderr = String::from_utf8_lossy(&o.stderr);
465 anyhow::bail!("git rev-list --count failed: {stderr}");
466 }
467 Err(e) => anyhow::bail!("failed to run git rev-list --count: {e}"),
468 };
469
470 if commit_count == 0 {
471 return Ok(false);
473 }
474
475 if commit_count > MAX_REBASE_COMMITS {
476 anyhow::bail!(
477 "refusing to rebase {commit_count} commits on origin/{branch} (max {})",
478 MAX_REBASE_COMMITS
479 );
480 }
481
482 tracing::info!(
483 branch = branch,
484 commit_count,
485 "rebasing local commits on origin/{branch}"
486 );
487
488 match stash_rebase_restore(dir, &origin_branch, StashRebaseOpts::default()).await? {
489 RebaseOutcome::Succeeded => Ok(true),
490 RebaseOutcome::Failed(e) => anyhow::bail!("rebase failed: {e}"),
491 RebaseOutcome::Skipped(reason) => anyhow::bail!("rebase skipped: {reason}"),
492 }
493}
494
495pub async fn rebase_on_default(dir: &Path, default_branch: &str) {
503 if rebase_in_progress(dir).await {
505 tracing::info!("rebase already in progress, skipping pre-dispatch rebase");
506 return;
507 }
508
509 let branch = get_current_branch(dir).await;
511 record_push_lease(dir, &branch, default_branch).await;
512
513 let _ = git()
516 .await
517 .args(["fetch", "origin"])
518 .current_dir(dir)
519 .output_with_context()
520 .await;
521
522 let commit_count = Command::new("git")
527 .args([
528 "rev-list",
529 "--count",
530 &format!("origin/{default_branch}..HEAD"),
531 ])
532 .current_dir(dir)
533 .output_with_context()
534 .await
535 .ok()
536 .and_then(|o| {
537 String::from_utf8_lossy(&o.stdout)
538 .trim()
539 .parse::<usize>()
540 .ok()
541 })
542 .unwrap_or(0);
543
544 if commit_count > MAX_REBASE_COMMITS {
545 tracing::warn!(
546 default_branch,
547 commit_count,
548 max = MAX_REBASE_COMMITS,
549 "skipping rebase: branch has too many commits to replay safely"
550 );
551 return;
552 }
553
554 match stash_rebase_restore(
555 dir,
556 &format!("origin/{default_branch}"),
557 StashRebaseOpts::default(),
558 )
559 .await
560 {
561 Ok(RebaseOutcome::Succeeded) => {
562 tracing::debug!(default_branch, "rebased worktree on default branch")
563 }
564 Ok(RebaseOutcome::Failed(e)) => {
565 tracing::warn!(default_branch, err = %e, "rebase failed, continuing with current state")
566 }
567 Ok(RebaseOutcome::Skipped(reason)) => {
568 tracing::warn!(default_branch, %reason, "stash failed before rebase, continuing with current state")
569 }
570 Err(e) => tracing::warn!(err = %e, "rebase error"),
571 }
572}
573
574async fn rebase_in_progress(dir: &Path) -> bool {
575 for name in ["rebase-merge", "rebase-apply"] {
576 let out = Command::new("git")
577 .args(["rev-parse", "--git-path", name])
578 .current_dir(dir)
579 .output_with_context()
580 .await;
581 if let Ok(o) = out {
582 let p = String::from_utf8_lossy(&o.stdout).trim().to_string();
583 if !p.is_empty() && dir.join(p).exists() {
584 return true;
585 }
586 }
587 }
588 false
589}
590
591pub(crate) fn remote_change_tools() -> Vec<String> {
594 [
595 "Bash(git remote set-url*)",
596 "Bash(git remote add*)",
597 "Bash(git remote remove*)",
598 "Bash(git remote rm*)",
599 "Bash(git remote rename*)",
600 "Bash(git config*remote.*)",
601 "Bash(git config*url.*)",
602 ]
603 .map(String::from)
604 .to_vec()
605}
606
607pub(crate) async fn git() -> Command {
610 let mut cmd = Command::new("git");
611 cmd.envs(build_git_auth_env().await).kill_on_drop(true);
612 cmd
613}
614
615pub(crate) async fn build_git_auth_env() -> Vec<(String, String)> {
636 let token = crate::github::token::shared()
637 .get_token()
638 .await
639 .ok()
640 .flatten();
641 build_git_auth_env_for_token(token)
642}
643
644fn build_git_auth_env_for_token(token: Option<String>) -> Vec<(String, String)> {
645 let mut config: Vec<(String, String)> = match token {
646 Some(t) if !t.is_empty() => {
647 let authed = format!("url.https://x-access-token:{t}@github.com/.insteadOf");
648 vec![
649 (authed.clone(), "https://github.com/".into()),
650 (authed, "git@github.com:".into()),
651 ]
652 }
653 _ => vec![(
654 "url.https://github.com/.insteadOf".into(),
655 "git@github.com:".into(),
656 )],
657 };
658 config.push(("commit.gpgsign".into(), "false".into()));
660 config.push(("tag.gpgsign".into(), "false".into()));
661
662 let mut env = vec![("GIT_CONFIG_COUNT".to_string(), config.len().to_string())];
663 for (n, (key, value)) in config.into_iter().enumerate() {
664 env.push((format!("GIT_CONFIG_KEY_{n}"), key));
665 env.push((format!("GIT_CONFIG_VALUE_{n}"), value));
666 }
667 env
668}
669
670pub(crate) fn is_workflow_scope_error(stderr: &str) -> bool {
677 stderr.contains("without `workflow` scope")
678 || stderr.contains("without 'workflow' scope")
679 || (stderr.contains("refusing to allow")
680 && stderr.contains("workflow")
681 && stderr.contains("scope"))
682}
683
684async fn strip_workflow_files(dir: &Path, default_branch: &str) -> anyhow::Result<bool> {
695 let output = Command::new("git")
697 .args([
698 "diff",
699 "--name-only",
700 &format!("origin/{default_branch}...HEAD"),
701 "--",
702 ".github/workflows/",
703 ])
704 .current_dir(dir)
705 .output_with_context()
706 .await?;
707
708 let workflow_files: Vec<String> = String::from_utf8_lossy(&output.stdout)
709 .lines()
710 .filter(|l| !l.is_empty())
711 .map(|s| s.to_string())
712 .collect();
713
714 if workflow_files.is_empty() {
715 return Ok(false);
716 }
717
718 tracing::info!(
719 files = ?workflow_files,
720 "stripping workflow files from commits to bypass missing workflow scope"
721 );
722
723 let count_output = Command::new("git")
728 .args([
729 "rev-list",
730 "--count",
731 &format!("origin/{default_branch}..HEAD"),
732 ])
733 .current_dir(dir)
734 .output_with_context()
735 .await?;
736
737 let commit_count: usize = String::from_utf8_lossy(&count_output.stdout)
738 .trim()
739 .parse()
740 .unwrap_or(0);
741
742 if commit_count == 0 {
743 return Ok(false);
744 }
745
746 let log_output = Command::new("git")
751 .args([
752 "log",
753 "--format=%x00%H%n%B",
754 &format!("origin/{default_branch}..HEAD"),
755 ])
756 .current_dir(dir)
757 .output_with_context()
758 .await?;
759 let log_text = String::from_utf8_lossy(&log_output.stdout).to_string();
760
761 let commits: Vec<(String, String)> = log_text
765 .split('\0')
766 .filter(|s| !s.trim().is_empty())
767 .map(|block| {
768 let block = block.trim();
769 let first_nl = block.find('\n').unwrap_or(block.len());
770 let hash = block[..first_nl].to_string();
771 let msg = block[first_nl..].trim().to_string();
772 (hash, msg)
773 })
774 .collect::<Vec<_>>()
775 .into_iter()
776 .rev()
777 .collect();
778
779 if commits.is_empty() {
780 return Ok(false);
781 }
782
783 let reset = Command::new("git")
785 .args(["reset", "--soft", &format!("origin/{default_branch}")])
786 .current_dir(dir)
787 .output_with_context()
788 .await?;
789
790 if !reset.status.success() {
791 let stderr = String::from_utf8_lossy(&reset.stderr);
792 anyhow::bail!("git reset --soft failed during workflow file stripping: {stderr}");
793 }
794
795 let mut rm_args = vec!["rm", "--cached", "--ignore-unmatch", "--"];
797 let file_refs: Vec<&str> = workflow_files.iter().map(|s| s.as_str()).collect();
798 rm_args.extend(file_refs);
799
800 let rm = Command::new("git")
801 .args(&rm_args)
802 .current_dir(dir)
803 .output_with_context()
804 .await?;
805
806 if !rm.status.success() {
807 let stderr = String::from_utf8_lossy(&rm.stderr);
808 tracing::warn!("git rm --cached for workflow files failed (non-fatal): {stderr}");
809 }
810
811 let has_staged = Command::new("git")
814 .args(["diff", "--cached", "--quiet"])
815 .current_dir(dir)
816 .status()
817 .await
818 .map(|s| !s.success())
819 .unwrap_or(false);
820
821 if !has_staged {
822 tracing::warn!(
823 "after removing workflow files, no staged changes remain — \
824 the only changes were workflow files"
825 );
826 if let Some((hash, _)) = commits.last() {
829 let _ = Command::new("git")
830 .args(["reset", "--soft", hash])
831 .current_dir(dir)
832 .output_with_context()
833 .await;
834 }
835 return Ok(false);
836 }
837
838 let commit_msg = if commits.len() == 1 {
840 commits[0].1.clone()
841 } else {
842 let mut combined = String::new();
844 for (i, (_, msg)) in commits.iter().enumerate() {
845 if i > 0 {
846 combined.push_str("\n\n");
847 }
848 combined.push_str(msg);
849 }
850 combined.push_str("\n\n[workflow files stripped: token lacks `workflow` scope]");
851 combined
852 };
853
854 let commit = super::commit::git_commit(dir, &commit_msg, "orchestrator").await?;
856
857 if !commit.status.success() {
858 let stderr = String::from_utf8_lossy(&commit.stderr);
859 if let Some((original_head, _)) = commits.last() {
861 let _ = Command::new("git")
862 .args(["reset", "--hard", original_head])
863 .current_dir(dir)
864 .output_with_context()
865 .await;
866 }
867 anyhow::bail!("git commit failed during workflow file stripping: {stderr}");
868 }
869
870 tracing::info!(
871 stripped_files = ?workflow_files,
872 original_commits = commit_count,
873 "successfully stripped workflow files from commits"
874 );
875
876 Ok(true)
877}
878
879const LEASE_ABSENT: &str = "absent";
880
881static LEASE_FALLBACKS: AtomicU64 = AtomicU64::new(0);
883
884async fn git_stdout(dir: &Path, args: &[&str]) -> Option<String> {
885 let out = Command::new("git")
886 .args(args)
887 .current_dir(dir)
888 .output_with_context()
889 .await
890 .ok()?;
891 if !out.status.success() {
892 return None;
893 }
894
895 Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
896}
897
898fn lease_key(branch: &str) -> String {
899 format!("branch.{branch}.orchPushLease")
900}
901
902async fn read_push_lease(dir: &Path, branch: &str) -> Option<String> {
903 git_stdout(dir, &["config", "--get", &lease_key(branch)]).await
904}
905
906async fn write_push_lease(dir: &Path, branch: &str, value: &str) {
907 if git_stdout(dir, &["config", &lease_key(branch), value])
908 .await
909 .is_none()
910 {
911 tracing::warn!(branch, "failed to record push lease");
912 }
913}
914
915async fn remote_tracking_sha(dir: &Path, branch: &str) -> Option<String> {
916 let rev = format!("refs/remotes/origin/{branch}");
917 git_stdout(dir, &["rev-parse", "--verify", "--quiet", &rev])
918 .await
919 .filter(|sha| crate::github::http::is_full_commit_sha(sha))
920}
921
922async fn is_ancestor(dir: &Path, ancestor: &str, descendant: &str) -> bool {
923 git_stdout(dir, &["merge-base", "--is-ancestor", ancestor, descendant])
924 .await
925 .is_some()
926}
927
928pub(crate) async fn record_push_lease(dir: &Path, branch: &str, default_branch: &str) {
931 if branch.is_empty() || branch == default_branch {
932 return;
933 }
934
935 let tip = remote_tracking_sha(dir, branch).await;
936 match (read_push_lease(dir, branch).await, tip) {
937 (Some(lease), Some(tip)) if lease != tip && is_ancestor(dir, &tip, "HEAD").await => {
938 write_push_lease(dir, branch, &tip).await;
939 }
940 (Some(_), _) => {}
941 (None, tip) => {
942 let expect = tip.unwrap_or_else(|| LEASE_ABSENT.to_string());
943 write_push_lease(dir, branch, &expect).await;
944 }
945 }
946}
947
948async fn note_pushed_head(dir: &Path, branch: &str) {
949 if let Some(sha) = git_stdout(dir, &["rev-parse", "--verify", "HEAD"]).await {
950 write_push_lease(dir, branch, &sha).await;
951 }
952}
953
954async fn is_github_branch_update(dir: &Path, tip: &str, lease: &str, default_branch: &str) -> bool {
957 let base = format!("origin/{default_branch}");
958 let Some(line) = git_stdout(dir, &["rev-list", "--parents", "-n", "1", tip]).await else {
959 return false;
960 };
961 let parents: Vec<&str> = line.split_whitespace().skip(1).collect();
962 if let [first, second] = parents[..] {
963 return first == lease && is_ancestor(dir, second, &base).await;
964 }
965
966 let Some(cherry) = git_stdout(dir, &["cherry", lease, tip, &base]).await else {
968 return false;
969 };
970 is_ancestor(dir, &base, tip).await
971 && !cherry.is_empty()
972 && cherry.lines().all(|l| l.starts_with('-'))
973}
974
975async fn expected_force_sha(
976 dir: &Path,
977 branch: &str,
978 default_branch: &str,
979) -> anyhow::Result<String> {
980 let tip = remote_tracking_sha(dir, branch).await;
981 match read_push_lease(dir, branch).await {
982 Some(lease) if lease == LEASE_ABSENT => anyhow::bail!(
983 "force push rejected: remote branch {branch} was absent when the lease was recorded"
984 ),
985 Some(lease) => match tip {
986 Some(tip)
987 if tip != lease
988 && is_github_branch_update(dir, &tip, &lease, default_branch).await =>
989 {
990 tracing::info!(branch, %lease, %tip, "remote tip is GitHub's update of the leased branch, leasing it");
991 Ok(tip)
992 }
993 _ => Ok(lease),
994 },
995 None => {
996 let Some(tip) = tip else {
997 anyhow::bail!("force push rejected: no remote sha recorded for {branch}");
998 };
999 let fallbacks = LEASE_FALLBACKS.fetch_add(1, Ordering::Relaxed) + 1;
1000 tracing::warn!(
1001 branch,
1002 fallbacks,
1003 "no push lease recorded, using the fetched origin tip"
1004 );
1005 Ok(tip)
1006 }
1007 }
1008}
1009
1010pub(crate) async fn force_push_leased(
1012 dir: &Path,
1013 branch: &str,
1014 default_branch: &str,
1015) -> anyhow::Result<()> {
1016 let expected = expected_force_sha(dir, branch, default_branch).await?;
1017 let lease = format!("--force-with-lease={branch}:{expected}");
1018 tracing::info!(branch, expected_sha = %expected, "force-pushing with an expected remote sha");
1019 let output = git()
1020 .await
1021 .args([
1022 "-c",
1023 "lfs.locksverify=false",
1024 "push",
1025 &lease,
1026 "-u",
1027 "origin",
1028 branch,
1029 ])
1030 .current_dir(dir)
1031 .kill_on_drop(true)
1032 .output_with_context()
1033 .await?;
1034 if output.status.success() {
1035 note_pushed_head(dir, branch).await;
1036 return Ok(());
1037 }
1038
1039 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1040 anyhow::bail!("{}", sanitize_push_error(&stderr))
1041}
1042
1043pub async fn push_branch(dir: &Path, branch: &str, default_branch: &str) -> anyhow::Result<bool> {
1045 let current = get_current_branch(dir).await;
1046 let branch_to_push = if !current.is_empty() {
1047 ¤t
1048 } else {
1049 branch
1050 };
1051
1052 if branch_to_push.is_empty() {
1054 anyhow::bail!("cannot push: branch name is empty");
1055 }
1056
1057 if branch_to_push == "main" || branch_to_push == "master" {
1059 return Ok(false);
1060 }
1061
1062 let (remote_exists, has_unpushed) =
1064 check_push_needed(dir, branch_to_push, default_branch).await;
1065 if remote_exists && !has_unpushed {
1066 tracing::debug!(
1070 branch = branch_to_push,
1071 "remote branch exists and is up to date, skipping push"
1072 );
1073 note_pushed_head(dir, branch_to_push).await;
1074 return Ok(true);
1075 }
1076 if !remote_exists && !has_unpushed {
1077 tracing::debug!(
1083 branch = branch_to_push,
1084 "remote branch missing with no new commits — pushing to create it on remote"
1085 );
1086 }
1087
1088 tracing::info!(branch = branch_to_push, "pushing branch");
1089
1090 let output = tokio::time::timeout(
1097 std::time::Duration::from_secs(120),
1098 git()
1099 .await
1100 .args([
1101 "-c",
1102 "lfs.locksverify=false",
1103 "push",
1104 "-u",
1105 "origin",
1106 branch_to_push,
1107 ])
1108 .kill_on_drop(true)
1109 .current_dir(dir)
1110 .output(),
1111 )
1112 .await
1113 .map_err(|_| anyhow::anyhow!("git push timed out after 120s"))??;
1114
1115 if output.status.success() {
1116 tracing::info!(branch = branch_to_push, "push succeeded");
1117 note_pushed_head(dir, branch_to_push).await;
1118 return Ok(true);
1119 }
1120
1121 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1122
1123 if is_transient_network_error(&stderr) {
1126 tracing::warn!(
1127 branch = branch_to_push,
1128 stderr = %sanitize_push_error(&stderr),
1129 "push failed with transient network error — retrying once"
1130 );
1131 let retry_output = tokio::time::timeout(
1132 std::time::Duration::from_secs(120),
1133 git()
1134 .await
1135 .args([
1136 "-c",
1137 "lfs.locksverify=false",
1138 "push",
1139 "-u",
1140 "origin",
1141 branch_to_push,
1142 ])
1143 .kill_on_drop(true)
1144 .current_dir(dir)
1145 .output(),
1146 )
1147 .await
1148 .map_err(|_| anyhow::anyhow!("git push (network retry) timed out after 120s"))??;
1149
1150 if retry_output.status.success() {
1151 tracing::info!(branch = branch_to_push, "push succeeded on network retry");
1152 note_pushed_head(dir, branch_to_push).await;
1153 return Ok(true);
1154 }
1155
1156 let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
1157 .trim()
1158 .to_string();
1159 tracing::warn!(
1160 branch = branch_to_push,
1161 stderr = %sanitize_push_error(&retry_stderr),
1162 "push retry also failed"
1163 );
1164 anyhow::bail!("push failed: {}", sanitize_push_error(&retry_stderr));
1165 }
1166
1167 if is_workflow_scope_error(&stderr) {
1171 tracing::warn!(
1172 branch = branch_to_push,
1173 "push rejected: token lacks `workflow` scope — stripping workflow files and retrying"
1174 );
1175
1176 match strip_workflow_files(dir, default_branch).await {
1177 Ok(true) => {
1178 let retry_output = tokio::time::timeout(
1180 std::time::Duration::from_secs(120),
1181 git()
1182 .await
1183 .args([
1184 "-c",
1185 "lfs.locksverify=false",
1186 "push",
1187 "-u",
1188 "origin",
1189 branch_to_push,
1190 ])
1191 .kill_on_drop(true)
1192 .current_dir(dir)
1193 .output(),
1194 )
1195 .await
1196 .map_err(|_| anyhow::anyhow!("git push (retry) timed out after 120s"))??;
1197
1198 if retry_output.status.success() {
1199 tracing::info!(
1200 branch = branch_to_push,
1201 "push succeeded after stripping workflow files"
1202 );
1203 note_pushed_head(dir, branch_to_push).await;
1204 return Ok(true);
1205 }
1206
1207 let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
1208 .trim()
1209 .to_string();
1210
1211 if is_workflow_scope_error(&retry_stderr) {
1213 anyhow::bail!(
1214 "push failed: token lacks `workflow` OAuth scope and workflow files \
1215 could not be fully stripped — add `workflow` scope to your GitHub \
1216 token or use a GitHub App for authentication"
1217 );
1218 }
1219
1220 anyhow::bail!("push failed after stripping workflow files: {retry_stderr}");
1221 }
1222 Ok(false) => {
1223 anyhow::bail!(
1225 "push failed: token lacks `workflow` OAuth scope — add `workflow` scope \
1226 to your GitHub token or use a GitHub App for authentication. \
1227 Error: {stderr}"
1228 );
1229 }
1230 Err(e) => {
1231 tracing::error!(error = %e, "failed to strip workflow files from commits");
1232 anyhow::bail!(
1233 "push failed: token lacks `workflow` OAuth scope and automatic \
1234 recovery failed ({e}) — add `workflow` scope to your GitHub token \
1235 or use a GitHub App for authentication"
1236 );
1237 }
1238 }
1239 }
1240
1241 if push_needs_rebase(&stderr) && remote_exists {
1244 tracing::info!(
1245 branch = branch_to_push,
1246 "push rejected (non-fast-forward), force-pushing with lease"
1247 );
1248 let stderr = match force_push_leased(dir, branch_to_push, default_branch).await {
1249 Ok(()) => {
1250 tracing::info!(branch = branch_to_push, "force push succeeded");
1251 return Ok(true);
1252 }
1253 Err(e) => e.to_string(),
1254 };
1255
1256 if is_workflow_scope_error(&stderr) {
1258 tracing::warn!(
1259 branch = branch_to_push,
1260 "force push also rejected due to workflow scope — stripping and retrying"
1261 );
1262 match strip_workflow_files(dir, default_branch).await {
1263 Ok(true) => {
1264 if let Err(e) = force_push_leased(dir, branch_to_push, default_branch).await {
1265 anyhow::bail!("force push failed after stripping workflow files: {e}");
1266 }
1267
1268 tracing::info!(
1269 branch = branch_to_push,
1270 "force push succeeded after stripping workflow files"
1271 );
1272 return Ok(true);
1273 }
1274 Ok(false) => {
1275 anyhow::bail!(
1276 "force push failed: token lacks `workflow` OAuth scope — \
1277 add `workflow` scope to your GitHub token or use a GitHub App. \
1278 Error: {stderr}"
1279 );
1280 }
1281 Err(e) => {
1282 anyhow::bail!(
1283 "force push failed: token lacks `workflow` scope and recovery \
1284 failed ({e})"
1285 );
1286 }
1287 }
1288 }
1289
1290 if is_stale_lease_error(&stderr) && rebase_on_branch(dir, branch_to_push).await? {
1293 let output = git()
1294 .await
1295 .args([
1296 "-c",
1297 "lfs.locksverify=false",
1298 "push",
1299 "-u",
1300 "origin",
1301 branch_to_push,
1302 ])
1303 .current_dir(dir)
1304 .output_with_context()
1305 .await?;
1306 if output.status.success() {
1307 tracing::info!(
1308 branch = branch_to_push,
1309 "push succeeded after rebasing on the moved remote branch"
1310 );
1311 note_pushed_head(dir, branch_to_push).await;
1312 return Ok(true);
1313 }
1314
1315 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1316 anyhow::bail!("push failed after rebasing on the moved remote branch: {stderr}");
1317 }
1318
1319 if is_stale_lease_error(&stderr) {
1320 tracing::info!(
1321 branch = branch_to_push,
1322 "remote branch already holds every local commit, nothing to push"
1323 );
1324 return Ok(true);
1325 }
1326
1327 anyhow::bail!("force push failed: {stderr}");
1328 }
1329
1330 anyhow::bail!("push failed: {stderr}")
1331}
1332
1333fn push_needs_rebase(stderr: &str) -> bool {
1334 let lower = stderr.to_ascii_lowercase();
1335 lower.contains("non-fast-forward")
1336 || lower.contains("rejected")
1337 || lower.contains("fetch first")
1338 || lower.contains("behind")
1339}
1340
1341fn is_stale_lease_error(stderr: &str) -> bool {
1343 let lower = stderr.to_ascii_lowercase();
1344 lower.contains("stale info") || lower.contains("remote ref updated since checkout")
1345}
1346
1347fn is_transient_network_error(stderr: &str) -> bool {
1352 let lower = stderr.to_ascii_lowercase();
1353 lower.contains("i/o timeout")
1354 || lower.contains("dial tcp")
1355 || lower.contains("connection reset")
1356 || lower.contains("connection refused")
1357 || lower.contains("no route to host")
1358 || lower.contains("temporary failure in name resolution")
1359 || lower.contains("eof")
1360}
1361
1362fn sanitize_push_error(stderr: &str) -> String {
1366 static URL_USERINFO: LazyLock<Regex> =
1367 LazyLock::new(|| Regex::new(r"://[^/@\s]+@").expect("valid url userinfo regex"));
1368 let no_userinfo = URL_USERINFO.replace_all(stderr, "://***@");
1369 crate::security::redact(&no_userinfo)
1370}
1371
1372fn strip_push_and_pr_creation(text: &str) -> String {
1375 let mut kept_lines: Vec<String> = Vec::new();
1376 for line in text.lines() {
1377 if line.trim().is_empty() {
1378 if kept_lines.last().is_some_and(|l| !l.is_empty()) {
1379 kept_lines.push(String::new());
1380 }
1381 continue;
1382 }
1383
1384 let indent_len = line.len() - line.trim_start().len();
1385 let indent = &line[..indent_len];
1386 let trimmed = line.trim();
1387 let (marker, rest) = match list_marker(trimmed) {
1388 Some((m, r)) => (m, r),
1389 None => ("", trimmed),
1390 };
1391
1392 let kept: Vec<&str> = split_statements(rest)
1393 .into_iter()
1394 .filter(|s| !is_push_or_pr_creation_statement(s))
1395 .collect();
1396 if kept.is_empty() {
1397 continue;
1398 }
1399 kept_lines.push(format!("{indent}{marker}{}", kept.join(" ")));
1400 }
1401 while kept_lines.last().is_some_and(String::is_empty) {
1402 kept_lines.pop();
1403 }
1404 kept_lines.join("\n")
1405}
1406
1407fn split_statements(text: &str) -> Vec<&str> {
1408 static SEP: LazyLock<Regex> =
1409 LazyLock::new(|| Regex::new(r"(\.{2,}|(?:[.!?]))\s+").expect("valid statement separator"));
1410 let mut statements = Vec::new();
1411 let mut last = 0;
1412 for cap in SEP.captures_iter(text) {
1413 let full = cap.get(0).expect("full separator match");
1414 let punct = cap.get(1).expect("punctuation capture");
1415 let ellipsis = punct.as_str().len() >= 2 && punct.as_str().bytes().all(|b| b == b'.');
1416 let end = if ellipsis { punct.start() } else { punct.end() };
1417 let stmt = text[last..end].trim();
1418 if !stmt.is_empty() {
1419 statements.push(stmt);
1420 }
1421 last = full.end();
1422 }
1423 let rest = text[last..].trim();
1424 if !rest.is_empty() {
1425 statements.push(rest);
1426 }
1427 statements
1428}
1429
1430fn list_marker(line: &str) -> Option<(&str, &str)> {
1431 for prefix in ["- ", "* ", "+ "] {
1432 if let Some(rest) = line.strip_prefix(prefix) {
1433 return Some((prefix, rest));
1434 }
1435 }
1436 let dot = line.find(". ")?;
1437 if !line[..dot].is_empty() && line[..dot].bytes().all(|b| b.is_ascii_digit()) {
1438 Some((&line[..dot + 2], &line[dot + 2..]))
1439 } else {
1440 None
1441 }
1442}
1443
1444fn is_push_or_pr_creation_statement(text: &str) -> bool {
1445 static PAT: LazyLock<Regex> = LazyLock::new(|| {
1446 Regex::new(concat!(
1447 r"(?i)",
1448 r"\bgit\s+push\b",
1449 r"|\bgh\s+pr\s+create\b",
1450 r"|\bgh\s+pr\s+open\b",
1451 r"|\bpush\s+(was\s+)?(denied|blocked|rejected)\b",
1452 r"|\bpush\s+permission\b",
1453 r"|\bcouldn['’]?t\s+push\b",
1454 r"|\bcould\s+not\s+push\b",
1455 r"|\bcan['’]?t\s+push\b",
1456 r"|\bcannot\s+push\b",
1457 r"|\bdidn['’]?t\s+push\b",
1458 r"|\bdid\s+not\s+push\b",
1459 r"|\bnothing\s+is\s+on\s+the\s+remote\b",
1460 r"|\bnot\s+on\s+the\s+remote\b",
1461 r"|\bopen(?:ing)?\s+(?:the|a)\s+pr\b",
1462 r"|\bcreat(?:e|ing)\s+(?:the|a)\s+pr\b",
1463 r"|\bopen(?:ing)?\s+(?:the|a)\s+pull\s+request\b",
1464 r"|\bcreat(?:e|ing)\s+(?:the|a)\s+pull\s+request\b",
1465 ))
1466 .expect("valid push/pr-creation regex")
1467 });
1468 PAT.is_match(text)
1469}
1470
1471fn cleaned_pr_items(items: &[String]) -> Vec<String> {
1472 items
1473 .iter()
1474 .filter_map(|item| {
1475 let cleaned = strip_push_and_pr_creation(item);
1476 (!cleaned.is_empty()).then_some(cleaned)
1477 })
1478 .collect()
1479}
1480
1481#[allow(clippy::too_many_arguments)]
1483fn build_pr_body(
1484 title: &str,
1485 summary: &str,
1486 accomplished: &[String],
1487 remaining: &[String],
1488 files: &[String],
1489 task_id: &str,
1490 agent: &str,
1491 model: Option<&str>,
1492) -> String {
1493 let cleaned_summary = strip_push_and_pr_creation(summary);
1494 let summary_text = if cleaned_summary.is_empty() {
1495 title
1496 } else {
1497 cleaned_summary.as_str()
1498 };
1499 let accomplished = cleaned_pr_items(accomplished);
1500 let remaining = cleaned_pr_items(remaining);
1501
1502 let mut body = format!("## Summary\n\n{summary_text}");
1503
1504 if !accomplished.is_empty() {
1505 body.push_str("\n\n### What was done\n\n");
1506 for item in &accomplished {
1507 body.push_str(&format!("- {item}\n"));
1508 }
1509 }
1510
1511 if !remaining.is_empty() {
1512 body.push_str("\n\n### Remaining\n\n");
1513 for item in &remaining {
1514 body.push_str(&format!("- {item}\n"));
1515 }
1516 }
1517
1518 if !files.is_empty() {
1519 body.push_str("\n\n### Files changed\n\n");
1520 for file in files {
1521 body.push_str(&format!("- `{file}`\n"));
1522 }
1523 }
1524
1525 if !task_id.starts_with("internal:") {
1526 if let Ok(issue_num) = task_id.parse::<u64>() {
1527 body.push_str(&format!("\n\nCloses #{issue_num}"));
1528 }
1529 }
1530
1531 let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1532 let task_ref = format_task_ref(task_id);
1533 body.push_str(&format!(
1534 "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1535 ));
1536 body
1537}
1538
1539#[allow(clippy::too_many_arguments)]
1548pub async fn create_pr_if_needed(
1549 dir: &Path,
1550 branch: &str,
1551 title: &str,
1552 summary: &str,
1553 accomplished: &[String],
1554 remaining: &[String],
1555 files: &[String],
1556 task_id: &str,
1557 agent: &str,
1558 model: Option<&str>,
1559 repo: &str,
1560 base_branch: &str,
1561) -> PrCreateResult<String> {
1562 let gh = GhHttp::new()?;
1563
1564 match gh.get_pr_number(repo, branch).await {
1566 Ok(Some(pr_number)) => {
1567 tracing::info!(task_id, pr = pr_number, "PR already exists");
1568 append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model).await;
1570 let pr = match gh.get_pr(repo, pr_number).await {
1572 Ok(pr) => pr,
1573 Err(e) => {
1574 tracing::warn!(task_id, error = %e, "get_pr API call failed after detecting existing PR");
1575 return Err(PrCreateError::ApiError(e));
1576 }
1577 };
1578 return Ok(pr.html_url);
1579 }
1580 Ok(None) => {
1581 }
1583 Err(e) => {
1584 tracing::warn!(task_id, error = %e, "get_pr_number API call failed");
1585 return Err(PrCreateError::ApiError(e));
1586 }
1587 }
1588
1589 let git_files: Vec<String>;
1591 let effective_files: &[String] = if files.is_empty() {
1592 match tokio::process::Command::new("git")
1593 .args([
1594 "diff",
1595 &format!("origin/{base_branch}...HEAD"),
1596 "--name-only",
1597 ])
1598 .current_dir(dir)
1599 .output()
1600 .await
1601 {
1602 Ok(output) if output.status.success() => {
1603 git_files = String::from_utf8_lossy(&output.stdout)
1604 .lines()
1605 .filter(|l| !l.is_empty())
1606 .map(|l| l.to_string())
1607 .collect();
1608 &git_files
1609 }
1610 _ => files,
1611 }
1612 } else {
1613 files
1614 };
1615
1616 let body = build_pr_body(
1617 title,
1618 summary,
1619 accomplished,
1620 remaining,
1621 effective_files,
1622 task_id,
1623 agent,
1624 model,
1625 );
1626
1627 let pr_title = title;
1629
1630 const MAX_RETRIES: u32 = 3;
1632 let mut last_error: Option<anyhow::Error> = None;
1633 let mut created_url: Option<String> = None;
1634 let mut had_transient_error = false;
1635 let mut effective_base = base_branch.to_string();
1636 let mut tried_api_fallback = false;
1637
1638 for attempt in 1..=MAX_RETRIES {
1639 match gh
1640 .create_pr(repo, pr_title, &body, branch, &effective_base)
1641 .await
1642 {
1643 Ok(u) => {
1644 created_url = Some(u);
1645 break;
1646 }
1647 Err(e) => {
1648 let err_str = format!("{e}");
1649 if is_transient_github_error(&err_str) {
1650 had_transient_error = true;
1651 if attempt < MAX_RETRIES {
1652 let delay = std::time::Duration::from_secs(2u64.pow(attempt));
1653 tracing::warn!(
1654 task_id,
1655 attempt,
1656 delay_secs = delay.as_secs(),
1657 error = %e,
1658 "transient GitHub API error during PR creation — retrying"
1659 );
1660 tokio::time::sleep(delay).await;
1661 }
1662 } else if is_invalid_base_error(&err_str) && !tried_api_fallback {
1663 tried_api_fallback = true;
1666 tracing::warn!(
1667 task_id,
1668 base_branch = %effective_base,
1669 error = %e,
1670 "PR creation failed with invalid base — fetching default branch from GitHub API"
1671 );
1672 match gh.get_default_branch(repo).await {
1673 Ok(api_default) if api_default != effective_base => {
1674 tracing::info!(
1675 task_id,
1676 old_base = %effective_base,
1677 new_base = %api_default,
1678 "retrying PR creation with GitHub API default branch"
1679 );
1680 effective_base = api_default;
1681 continue;
1682 }
1683 Ok(api_default) => {
1684 tracing::warn!(
1685 task_id,
1686 base_branch = %api_default,
1687 "GitHub API default branch matches local — no fallback possible"
1688 );
1689 }
1690 Err(api_err) => {
1691 tracing::warn!(
1692 task_id,
1693 error = %api_err,
1694 "failed to fetch default branch from GitHub API"
1695 );
1696 }
1697 }
1698 }
1699 last_error = Some(e);
1700 }
1701 }
1702 }
1703
1704 let url = match created_url {
1705 Some(u) => u,
1706 None => {
1707 let e = last_error.unwrap_or_else(|| {
1708 anyhow::anyhow!("PR creation failed after retries (no error captured)")
1709 });
1710 let err_str = format!("{e}");
1711 if is_transient_github_error(&err_str) || had_transient_error {
1718 tracing::warn!(
1719 task_id,
1720 error = %e,
1721 "transient GitHub API error after all retries — checking if PR was actually created"
1722 );
1723 match gh.get_pr_number(repo, branch).await {
1724 Ok(Some(pr_number)) => {
1725 tracing::info!(
1726 task_id,
1727 pr = pr_number,
1728 "PR was created despite transient errors — recovering"
1729 );
1730 append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model)
1731 .await;
1732 match gh.get_pr(repo, pr_number).await {
1733 Ok(pr) => pr.html_url,
1734 Err(get_err) => {
1735 tracing::warn!(
1736 task_id,
1737 error = %get_err,
1738 "failed to fetch PR URL after transient-error recovery"
1739 );
1740 return Err(PrCreateError::ApiError(e));
1741 }
1742 }
1743 }
1744 Ok(None) => {
1745 tracing::warn!(
1746 task_id,
1747 "PR was not created after all retries — propagating original error"
1748 );
1749 return Err(PrCreateError::ApiError(e));
1750 }
1751 Err(check_err) => {
1752 tracing::warn!(
1753 task_id,
1754 error = %check_err,
1755 "failed to verify PR existence after transient error retries — propagating original error"
1756 );
1757 return Err(PrCreateError::ApiError(e));
1758 }
1759 }
1760 } else {
1761 return Err(PrCreateError::ApiError(e));
1762 }
1763 }
1764 };
1765
1766 tracing::info!(task_id, pr_url = %url, "created PR via GhHttp API");
1767
1768 Ok(url)
1769}
1770
1771pub(crate) fn is_transient_github_error(err_str: &str) -> bool {
1775 if err_str.contains("HTTP send failed") {
1779 return true;
1780 }
1781 if err_str.contains("GitHub API server error") {
1782 return true;
1783 }
1784
1785 if extract_github_http_status(err_str)
1787 .map(|s| (500..600).contains(&s))
1788 .unwrap_or(false)
1789 {
1790 return true;
1791 }
1792
1793 let lower = err_str.to_lowercase();
1795
1796 if lower.contains("connection reset")
1798 || lower.contains("broken pipe")
1799 || lower.contains("connection refused")
1800 || lower.contains("connection closed")
1801 || lower.contains("transport error")
1802 || lower.contains("failed to connect")
1803 || lower.contains("failed to connect to github.com port")
1804 || lower.contains("curl: (7) failed to connect")
1805 || lower.contains("couldn't connect to server")
1806 || lower.contains("could not connect to server")
1807 {
1808 return true;
1809 }
1810
1811 if lower.contains("timeout") {
1813 return true;
1814 }
1815
1816 if lower.contains("dns error")
1818 || lower.contains("resolve")
1819 || lower.contains("name resolution")
1820 || lower.contains("no such host")
1821 {
1822 return true;
1823 }
1824
1825 if lower.contains("tls handshake")
1827 || lower.contains("certificate")
1828 || lower.contains("ssl error")
1829 {
1830 return true;
1831 }
1832
1833 if lower.contains("network is unreachable")
1835 || lower.contains("host unreachable")
1836 || lower.contains("temporary failure")
1837 {
1838 return true;
1839 }
1840
1841 if lower.contains("unexpected eof") || lower.contains("unexpected end of file") {
1843 return true;
1844 }
1845
1846 if lower.contains("circuit-breaker") || lower.contains("circuit breaker") {
1848 return true;
1849 }
1850
1851 if lower.contains("rate limit") || lower.contains("too many requests") {
1853 return true;
1854 }
1855
1856 false
1857}
1858
1859pub(crate) fn is_invalid_base_error(err_str: &str) -> bool {
1865 let lower = err_str.to_lowercase();
1866 lower.contains("422") && lower.contains("base") && lower.contains("invalid")
1867}
1868
1869pub(crate) fn is_transient_github_api_error(err_str: &str) -> bool {
1877 if !is_transient_github_error(err_str) {
1878 return false;
1879 }
1880
1881 if extract_github_http_status(err_str).is_some() {
1882 return true;
1883 }
1884
1885 let lower = err_str.to_lowercase();
1886 lower.contains("github")
1887 || lower.contains("api.github.com")
1888 || lower.contains("gh api")
1889 || lower.contains("gh pr")
1890 || lower.contains("gh error")
1891 || lower.contains("circuit-breaker")
1896 || lower.contains("circuit breaker")
1897}
1898
1899fn extract_github_http_status(err_str: &str) -> Option<u16> {
1904 let marker = "failed (";
1905 let start = err_str.find(marker)? + marker.len();
1906 let rest = &err_str[start..];
1907 let end = rest.find(')')?;
1908 rest[..end].parse().ok()
1909}
1910
1911async fn append_pr_footer_if_missing(
1916 gh: &GhHttp,
1917 repo: &str,
1918 pr_number: u64,
1919 task_id: &str,
1920 agent: &str,
1921 model: Option<&str>,
1922) {
1923 let pr = match gh.get_pr(repo, pr_number).await {
1924 Ok(pr) => pr,
1925 Err(e) => {
1926 tracing::debug!(task_id, error = %e, "failed to fetch PR for footer check");
1927 return;
1928 }
1929 };
1930
1931 let body = pr.body.unwrap_or_default();
1932 if body.contains("via [Orch]") {
1933 return; }
1935
1936 let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1937 let task_ref = format_task_ref(task_id);
1938 let footer = format!(
1939 "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1940 );
1941 let new_body = format!("{body}{footer}");
1942
1943 if let Err(e) = gh.update_pr_body(repo, pr_number, &new_body).await {
1944 tracing::warn!(task_id, error = %e, "failed to append footer to PR (non-fatal)");
1945 } else {
1946 tracing::info!(
1947 task_id,
1948 pr = pr_number,
1949 "appended attribution footer to agent-created PR"
1950 );
1951 }
1952}
1953
1954pub(crate) async fn get_current_branch(dir: &Path) -> String {
1956 let output = Command::new("git")
1957 .args(["branch", "--show-current"])
1958 .current_dir(dir)
1959 .output_with_context()
1960 .await;
1961
1962 match output {
1963 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
1964 _ => String::new(),
1965 }
1966}
1967
1968pub async fn count_changed_files(dir: &Path) -> anyhow::Result<usize> {
1970 let output = Command::new("git")
1972 .args(["status", "--porcelain"])
1973 .current_dir(dir)
1974 .output_with_context()
1975 .await?;
1976
1977 let status_output = String::from_utf8_lossy(&output.stdout);
1978 let count = status_output
1979 .lines()
1980 .filter(|line| {
1981 let prefix = line.get(0..2).unwrap_or("");
1982 prefix.starts_with('M')
1984 || prefix.starts_with('A')
1985 || prefix.starts_with('R')
1986 || prefix.starts_with('C')
1987 || prefix.starts_with('?')
1988 })
1989 .count();
1990
1991 Ok(count)
1992}
1993
1994async fn check_push_needed(dir: &Path, branch: &str, default_branch: &str) -> (bool, bool) {
2002 let remote_exists = Command::new("git")
2004 .args(["rev-parse", &format!("origin/{branch}")])
2005 .current_dir(dir)
2006 .output_with_context()
2007 .await
2008 .map(|o| o.status.success())
2009 .unwrap_or(false);
2010
2011 let compare_ref = if remote_exists {
2012 format!("origin/{branch}..HEAD")
2013 } else {
2014 format!("origin/{default_branch}..HEAD")
2017 };
2018
2019 tracing::debug!(
2020 branch,
2021 compare_ref,
2022 remote_exists,
2023 "checking unpushed commits"
2024 );
2025
2026 let output = Command::new("git")
2027 .args(["log", &compare_ref, "--oneline"])
2028 .current_dir(dir)
2029 .output_with_context()
2030 .await;
2031
2032 let has_unpushed = match output {
2033 Ok(o) if o.status.success() => {
2034 let out = String::from_utf8_lossy(&o.stdout);
2035 let has = !out.trim().is_empty();
2036 tracing::debug!(branch, has_unpushed = has, "unpushed commits check result");
2037 has
2038 }
2039 _ => {
2040 tracing::debug!(branch, "git log failed for compare ref, assuming unpushed");
2041 true
2044 }
2045 };
2046
2047 (remote_exists, has_unpushed)
2048}
2049
2050#[cfg(test)]
2051mod tests {
2052 use super::*;
2053
2054 #[test]
2055 fn pr_create_error_display() {
2056 let err = PrCreateError::ApiError(anyhow::anyhow!("test error"));
2057 assert_eq!(format!("{}", err), "GitHub API error: test error");
2058 }
2059
2060 #[test]
2061 fn pr_create_error_from_anyhow() {
2062 let anyhow_err = anyhow::anyhow!("source error");
2063 let pr_err: PrCreateError = anyhow_err.into();
2064 assert!(matches!(pr_err, PrCreateError::ApiError(_)));
2065 }
2066
2067 #[test]
2068 fn git_env_never_signs_with_or_without_a_token() {
2069 for env in [
2070 build_git_auth_env_for_token(None),
2071 build_git_auth_env_for_token(Some("ghp_testtoken1234".into())),
2072 ] {
2073 let get = |key: &str| env.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone());
2074 let count: usize = get("GIT_CONFIG_COUNT").unwrap().parse().unwrap();
2075 let config: Vec<(String, String)> = (0..count)
2076 .map(|n| {
2077 (
2078 get(&format!("GIT_CONFIG_KEY_{n}")).unwrap(),
2079 get(&format!("GIT_CONFIG_VALUE_{n}")).unwrap(),
2080 )
2081 })
2082 .collect();
2083 assert!(config.contains(&("commit.gpgsign".into(), "false".into())));
2084 assert!(config.contains(&("tag.gpgsign".into(), "false".into())));
2085 }
2086 }
2087
2088 #[test]
2089 fn build_git_auth_env_without_token_falls_back_to_ssh_https_conversion() {
2090 let env = build_git_auth_env_for_token(None);
2093
2094 let has_ssh_rule = env
2097 .iter()
2098 .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "git@github.com:");
2099 assert!(
2100 has_ssh_rule,
2101 "expected SSH insteadOf fallback (value is git@github.com:), got: {env:?}"
2102 );
2103 }
2104
2105 #[test]
2106 fn build_git_auth_env_with_token_covers_both_ssh_and_https() {
2107 let env = build_git_auth_env_for_token(Some("ghp_testtoken1234".into()));
2109
2110 let has_token = env
2112 .iter()
2113 .any(|(_, v)| v.contains("x-access-token:ghp_testtoken1234@github.com"));
2114 assert!(has_token, "expected token in auth URL, got: {env:?}");
2115 let has_https_rule = env
2117 .iter()
2118 .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "https://github.com/");
2119 assert!(
2120 has_https_rule,
2121 "expected HTTPS insteadOf rule, got: {env:?}"
2122 );
2123 let has_ssh_rule = env
2125 .iter()
2126 .any(|(k, v)| k == "GIT_CONFIG_VALUE_1" && v == "git@github.com:");
2127 assert!(has_ssh_rule, "expected SSH insteadOf rule, got: {env:?}");
2128 }
2129
2130 #[test]
2131 fn find_stash_ref_by_hash_returns_matching_reference() {
2132 let stash_list = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{1}\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb stash@{0}";
2133
2134 assert_eq!(
2135 find_stash_ref_by_hash(stash_list, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
2136 Some("stash@{1}".to_string())
2137 );
2138 assert_eq!(
2139 find_stash_ref_by_hash(stash_list, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
2140 Some("stash@{0}".to_string())
2141 );
2142 }
2143
2144 #[test]
2145 fn find_stash_ref_by_hash_returns_none_when_missing_or_malformed() {
2146 let stash_list = "malformed-line\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{2}";
2147
2148 assert_eq!(
2149 find_stash_ref_by_hash(stash_list, "cccccccccccccccccccccccccccccccccccccccc"),
2150 None
2151 );
2152 assert_eq!(find_stash_ref_by_hash("", "anything"), None);
2153 }
2154
2155 #[test]
2156 fn push_needs_rebase_detects_common_rejections() {
2157 assert!(push_needs_rebase(
2158 "! [rejected] branch -> branch (fetch first)"
2159 ));
2160 assert!(push_needs_rebase("non-fast-forward update was rejected"));
2161 assert!(push_needs_rebase(
2162 "push rejected because the remote contains work"
2163 ));
2164 }
2165
2166 #[test]
2167 fn is_transient_network_error_detects_lfs_locks_timeout() {
2168 let lfs_err = "Post \"https://x-access-token:***@github.com/o/r.git/info/lfs/locks/verify\": dial tcp 140.82.112.4:443: i/o timeout";
2169 assert!(is_transient_network_error(lfs_err));
2170 assert!(is_transient_network_error(
2171 "dial tcp 140.82.112.4:443: connect: connection refused"
2172 ));
2173 assert!(is_transient_network_error(
2174 "read tcp 10.0.0.1:51234->140.82.112.4:443: read: connection reset by peer"
2175 ));
2176 }
2177
2178 #[test]
2179 fn is_transient_network_error_rejects_ref_rejections() {
2180 assert!(!is_transient_network_error(
2181 "! [rejected] branch -> branch (fetch first)"
2182 ));
2183 assert!(!is_transient_network_error(
2184 "remote: error: GH006: Protected branch update failed"
2185 ));
2186 }
2187
2188 #[test]
2189 fn sanitize_push_error_strips_url_credentials() {
2190 let raw = "Post \"https://x-access-token:test-secret-1234567890abcdef@github.com/o/r.git/info/lfs/locks/verify\": dial tcp 140.82.112.4:443: i/o timeout";
2192 let sanitized = sanitize_push_error(raw);
2193 assert!(!sanitized.contains("test-secret-1234567890abcdef"));
2194 assert!(sanitized.contains("://***@github.com"));
2195 assert!(sanitized.contains("i/o timeout"));
2196 }
2197
2198 #[test]
2199 fn extract_github_http_status_parses_ghhttp_error_format() {
2200 let err =
2201 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
2202 assert_eq!(extract_github_http_status(err), Some(502));
2203
2204 let err500 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (500): Internal Server Error";
2205 assert_eq!(extract_github_http_status(err500), Some(500));
2206
2207 let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
2208 assert_eq!(extract_github_http_status(err422), Some(422));
2209
2210 let not_github = "some other error";
2211 assert_eq!(extract_github_http_status(not_github), None);
2212 }
2213
2214 #[test]
2215 fn is_transient_github_error_matches_5xx_only() {
2216 let err502 =
2217 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
2218 assert!(is_transient_github_error(err502));
2219
2220 let err503 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (503): Service Unavailable";
2221 assert!(is_transient_github_error(err503));
2222
2223 let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
2224 assert!(!is_transient_github_error(err422));
2225
2226 let err404 =
2227 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (404): Not Found";
2228 assert!(!is_transient_github_error(err404));
2229 }
2230
2231 #[test]
2232 fn is_transient_github_error_matches_connection_errors() {
2233 assert!(is_transient_github_error("connection refused"));
2235 assert!(is_transient_github_error("Connection reset by peer"));
2236 assert!(is_transient_github_error("broken pipe"));
2237 assert!(is_transient_github_error("connection closed unexpectedly"));
2238 assert!(is_transient_github_error(
2239 "transport error: connection lost"
2240 ));
2241
2242 assert!(is_transient_github_error(
2244 "fatal: unable to access 'https://github.com/owner/repo.git/': Failed to connect to github.com port 443 after 20990 ms: Couldn't connect to server"
2245 ));
2246 assert!(is_transient_github_error(
2247 "Failed to connect to github.com port 443: Connection timed out"
2248 ));
2249 assert!(is_transient_github_error(
2250 "Failed to connect to github.com port 443: Connection refused"
2251 ));
2252 assert!(is_transient_github_error(
2253 "curl: (7) Failed to connect to github.com port 443: Couldn't connect to server"
2254 ));
2255 assert!(is_transient_github_error(
2256 "fatal: unable to access 'https://github.com/owner/repo.git/': Couldn't connect to server"
2257 ));
2258 }
2259
2260 #[test]
2261 fn is_transient_github_error_matches_dns_errors() {
2262 assert!(is_transient_github_error("dns error: failed to lookup"));
2264 assert!(is_transient_github_error("failed to resolve hostname"));
2265 assert!(is_transient_github_error("name resolution failed"));
2266 assert!(is_transient_github_error("no such host: api.github.com"));
2267 }
2268
2269 #[test]
2270 fn is_transient_github_error_matches_tls_errors() {
2271 assert!(is_transient_github_error("tls handshake failed"));
2273 assert!(is_transient_github_error("certificate verification failed"));
2274 assert!(is_transient_github_error("ssl error: unknown ca"));
2275 }
2276
2277 #[test]
2278 fn is_transient_github_error_matches_network_unreachable() {
2279 assert!(is_transient_github_error("network is unreachable"));
2281 assert!(is_transient_github_error("host unreachable"));
2282 assert!(is_transient_github_error(
2283 "temporary failure in name resolution"
2284 ));
2285 }
2286
2287 #[test]
2288 fn is_transient_github_error_matches_eof_errors() {
2289 assert!(is_transient_github_error("unexpected eof while reading"));
2291 assert!(is_transient_github_error("unexpected end of file"));
2292 }
2293
2294 #[test]
2295 fn is_transient_github_error_matches_rate_limit() {
2296 assert!(is_transient_github_error("rate limit exceeded"));
2298 assert!(is_transient_github_error("429 Too Many Requests"));
2299 assert!(is_transient_github_error("too many requests, retry later"));
2300 }
2301
2302 #[test]
2303 fn is_transient_github_error_matches_timeout() {
2304 assert!(is_transient_github_error("operation timeout"));
2306 assert!(is_transient_github_error("request timeout"));
2307 }
2308
2309 #[test]
2310 fn is_transient_github_error_rejects_non_transient() {
2311 assert!(!is_transient_github_error("permission denied"));
2313 assert!(!is_transient_github_error("file not found"));
2314 assert!(!is_transient_github_error("invalid argument"));
2315 assert!(!is_transient_github_error("authentication failed"));
2316 assert!(!is_transient_github_error("bad credentials"));
2317 }
2318
2319 #[test]
2320 fn is_transient_github_api_error_requires_github_context() {
2321 assert!(is_transient_github_api_error(
2322 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway"
2323 ));
2324 assert!(is_transient_github_api_error(
2325 "gh error: transport error: connection reset by peer"
2326 ));
2327 assert!(is_transient_github_api_error(
2328 "push failed: fatal: unable to access 'https://github.com/owner/repo.git/': Failed to connect to github.com port 443 after 20990 ms: Couldn't connect to server"
2329 ));
2330 assert!(!is_transient_github_api_error(
2331 "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)"
2332 ));
2333 assert!(!is_transient_github_api_error(
2334 "agent stream failed: failed to connect to local relay"
2335 ));
2336 }
2337
2338 #[test]
2339 fn is_transient_github_api_error_matches_circuit_breaker() {
2340 assert!(is_transient_github_api_error(
2342 "GitHub API transient 5xx circuit-breaker active for 240s"
2343 ));
2344 assert!(is_transient_github_api_error(
2346 "create PR failed: GitHub API error: GitHub API transient 5xx circuit-breaker active"
2347 ));
2348 assert!(is_transient_github_api_error(
2351 "create PR failed: circuit-breaker"
2352 ));
2353 assert!(is_transient_github_api_error(
2355 "create PR failed: circuit breaker tripped"
2356 ));
2357 }
2358
2359 #[tokio::test]
2360 async fn auto_commit_unstages_files_on_commit_failure() {
2361 let dir =
2363 std::env::temp_dir().join(format!("orch_test_auto_commit_{}", std::process::id()));
2364 let _ = std::fs::remove_dir_all(&dir);
2365 std::fs::create_dir_all(&dir).unwrap();
2366
2367 let _ = Command::new("git")
2369 .args(["init"])
2370 .current_dir(&dir)
2371 .output()
2372 .await;
2373 let _ = Command::new("git")
2374 .args(["config", "user.email", "test@test.com"])
2375 .current_dir(&dir)
2376 .output()
2377 .await;
2378 let _ = Command::new("git")
2379 .args(["config", "user.name", "Test"])
2380 .current_dir(&dir)
2381 .output()
2382 .await;
2383 std::fs::write(dir.join("init.txt"), "init").unwrap();
2385 let _ = Command::new("git")
2386 .args(["add", "init.txt"])
2387 .current_dir(&dir)
2388 .output()
2389 .await;
2390 let _ = Command::new("git")
2391 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2392 .current_dir(&dir)
2393 .output()
2394 .await;
2395
2396 std::fs::write(dir.join("new_file.txt"), "content").unwrap();
2398
2399 assert!(has_changes(&dir).await);
2401
2402 let result = auto_commit(
2407 &dir,
2408 "1",
2409 "Test title",
2410 "test",
2411 1,
2412 super::super::commit::When::Always,
2413 )
2414 .await;
2415 assert!(result.unwrap());
2416
2417 assert!(!has_changes(&dir).await);
2419
2420 let _ = std::fs::remove_dir_all(&dir);
2422 }
2423
2424 #[tokio::test]
2425 async fn auto_commit_ignores_repo_signing_config() {
2426 let dir =
2429 std::env::temp_dir().join(format!("orch_test_auto_commit_gpg_{}", std::process::id()));
2430 let _ = std::fs::remove_dir_all(&dir);
2431 std::fs::create_dir_all(&dir).unwrap();
2432
2433 for args in [
2434 vec!["init"],
2435 vec!["config", "user.email", "test@test.com"],
2436 vec!["config", "user.name", "Test"],
2437 vec!["config", "commit.gpgsign", "true"],
2438 vec!["config", "gpg.program", "/usr/bin/false"],
2439 ] {
2440 let _ = Command::new("git")
2441 .args(&args)
2442 .current_dir(&dir)
2443 .output()
2444 .await;
2445 }
2446 std::fs::write(dir.join("init.txt"), "init").unwrap();
2447 let _ = Command::new("git")
2448 .args(["add", "init.txt"])
2449 .current_dir(&dir)
2450 .output()
2451 .await;
2452 let _ = Command::new("git")
2453 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2454 .current_dir(&dir)
2455 .output()
2456 .await;
2457
2458 std::fs::write(dir.join("signed_off.txt"), "content").unwrap();
2459 assert!(has_changes(&dir).await);
2460
2461 let result = auto_commit(
2462 &dir,
2463 "3",
2464 "Test title",
2465 "test",
2466 1,
2467 super::super::commit::When::Always,
2468 )
2469 .await;
2470 assert!(result.unwrap());
2471 assert!(!has_changes(&dir).await);
2472
2473 let _ = std::fs::remove_dir_all(&dir);
2474 }
2475
2476 #[tokio::test]
2477 async fn auto_commit_unstages_on_pre_commit_hook_failure() {
2478 let dir =
2480 std::env::temp_dir().join(format!("orch_test_auto_commit_hook_{}", std::process::id()));
2481 let _ = std::fs::remove_dir_all(&dir);
2482 std::fs::create_dir_all(&dir).unwrap();
2483
2484 let _ = Command::new("git")
2486 .args(["init"])
2487 .current_dir(&dir)
2488 .output()
2489 .await;
2490 let _ = Command::new("git")
2491 .args(["config", "user.email", "test@test.com"])
2492 .current_dir(&dir)
2493 .output()
2494 .await;
2495 let _ = Command::new("git")
2496 .args(["config", "user.name", "Test"])
2497 .current_dir(&dir)
2498 .output()
2499 .await;
2500 std::fs::write(dir.join("init.txt"), "init").unwrap();
2502 let _ = Command::new("git")
2503 .args(["add", "init.txt"])
2504 .current_dir(&dir)
2505 .output()
2506 .await;
2507 let _ = Command::new("git")
2508 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2509 .current_dir(&dir)
2510 .output()
2511 .await;
2512
2513 let hooks_dir = dir.join(".git/hooks");
2515 std::fs::create_dir_all(&hooks_dir).unwrap();
2516 std::fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\nexit 1\n").unwrap();
2517 #[cfg(unix)]
2518 {
2519 use std::os::unix::fs::PermissionsExt;
2520 let mut perms = std::fs::metadata(hooks_dir.join("pre-commit"))
2521 .unwrap()
2522 .permissions();
2523 perms.set_mode(0o755);
2524 std::fs::set_permissions(hooks_dir.join("pre-commit"), perms).unwrap();
2525 }
2526
2527 std::fs::write(dir.join("staged_file.txt"), "content").unwrap();
2529
2530 assert!(has_changes(&dir).await);
2532
2533 let result = auto_commit(
2535 &dir,
2536 "2",
2537 "Test title",
2538 "test",
2539 1,
2540 super::super::commit::When::Always,
2541 )
2542 .await;
2543 assert!(!result.unwrap());
2544
2545 assert!(has_changes(&dir).await);
2548
2549 let cached = Command::new("git")
2551 .args(["diff", "--cached", "--quiet"])
2552 .current_dir(&dir)
2553 .status()
2554 .await;
2555 assert!(
2556 cached.map(|s| s.success()).unwrap_or(false),
2557 "files should be unstaged after commit failure"
2558 );
2559
2560 let _ = std::fs::remove_dir_all(&dir);
2562 }
2563
2564 #[test]
2565 fn is_workflow_scope_error_detects_backtick_format() {
2566 let err = "! [remote rejected] branch -> branch (refusing to allow an OAuth App to create or update workflow `.github/workflows/ci.yml` without `workflow` scope)";
2567 assert!(is_workflow_scope_error(err));
2568 }
2569
2570 #[test]
2571 fn is_workflow_scope_error_detects_single_quote_format() {
2572 let err = "refusing to allow an OAuth App to create or update workflow '.github/workflows/ci.yml' without 'workflow' scope";
2573 assert!(is_workflow_scope_error(err));
2574 }
2575
2576 #[test]
2577 fn is_workflow_scope_error_detects_refusing_to_allow_variant() {
2578 let err = "remote: refusing to allow a Personal Access Token to create or update workflow `.github/workflows/scan.yml` without `workflow` scope";
2579 assert!(is_workflow_scope_error(err));
2580 }
2581
2582 #[test]
2583 fn is_workflow_scope_error_rejects_unrelated_errors() {
2584 assert!(!is_workflow_scope_error("non-fast-forward"));
2585 assert!(!is_workflow_scope_error("permission denied"));
2586 assert!(!is_workflow_scope_error("authentication failed"));
2587 assert!(!is_workflow_scope_error("rejected"));
2588 }
2589
2590 #[tokio::test]
2591 async fn strip_workflow_files_removes_workflow_from_commits() {
2592 let dir = std::env::temp_dir().join(format!("orch_test_strip_wf_{}", std::process::id()));
2594 let _ = std::fs::remove_dir_all(&dir);
2595 std::fs::create_dir_all(&dir).unwrap();
2596
2597 let _ = Command::new("git")
2599 .args(["init"])
2600 .current_dir(&dir)
2601 .output()
2602 .await;
2603 let _ = Command::new("git")
2604 .args(["config", "user.email", "test@test.com"])
2605 .current_dir(&dir)
2606 .output()
2607 .await;
2608 let _ = Command::new("git")
2609 .args(["config", "user.name", "Test"])
2610 .current_dir(&dir)
2611 .output()
2612 .await;
2613 std::fs::write(dir.join("init.txt"), "init").unwrap();
2614 let _ = Command::new("git")
2615 .args(["add", "."])
2616 .current_dir(&dir)
2617 .output()
2618 .await;
2619 let _ = Command::new("git")
2620 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2621 .current_dir(&dir)
2622 .output()
2623 .await;
2624
2625 let _ = Command::new("git")
2627 .args(["remote", "add", "origin", dir.to_str().unwrap()])
2628 .current_dir(&dir)
2629 .output()
2630 .await;
2631 let _ = Command::new("git")
2632 .args(["fetch", "origin"])
2633 .current_dir(&dir)
2634 .output()
2635 .await;
2636
2637 let _ = Command::new("git")
2639 .args(["checkout", "-b", "feature"])
2640 .current_dir(&dir)
2641 .output()
2642 .await;
2643
2644 std::fs::write(dir.join("feature.txt"), "feature work").unwrap();
2646 let wf_dir = dir.join(".github/workflows");
2647 std::fs::create_dir_all(&wf_dir).unwrap();
2648 std::fs::write(wf_dir.join("ci.yml"), "name: CI\non: push").unwrap();
2649 let _ = Command::new("git")
2650 .args(["add", "."])
2651 .current_dir(&dir)
2652 .output()
2653 .await;
2654 let _ = Command::new("git")
2655 .args([
2656 "-c",
2657 "commit.gpgsign=false",
2658 "commit",
2659 "-m",
2660 "add feature and workflow",
2661 ])
2662 .current_dir(&dir)
2663 .output()
2664 .await;
2665
2666 let branch_out = Command::new("git")
2668 .args(["rev-parse", "--abbrev-ref", "origin/HEAD"])
2669 .current_dir(&dir)
2670 .output()
2671 .await;
2672 let default_branch = branch_out
2674 .ok()
2675 .filter(|o| o.status.success())
2676 .map(|o| {
2677 String::from_utf8_lossy(&o.stdout)
2678 .trim()
2679 .strip_prefix("origin/")
2680 .unwrap_or("main")
2681 .to_string()
2682 })
2683 .unwrap_or_else(|| "main".to_string());
2684
2685 let stripped = strip_workflow_files(&dir, &default_branch).await.unwrap();
2687 assert!(stripped, "should have stripped workflow files");
2688
2689 let ls_output = Command::new("git")
2691 .args(["ls-tree", "-r", "--name-only", "HEAD"])
2692 .current_dir(&dir)
2693 .output()
2694 .await
2695 .unwrap();
2696 let files = String::from_utf8_lossy(&ls_output.stdout);
2697 assert!(
2698 !files.contains(".github/workflows/ci.yml"),
2699 "workflow file should be removed from commits"
2700 );
2701 assert!(
2702 files.contains("feature.txt"),
2703 "non-workflow files should be preserved"
2704 );
2705
2706 let _ = std::fs::remove_dir_all(&dir);
2708 }
2709
2710 #[tokio::test]
2711 async fn strip_workflow_files_returns_false_when_no_workflows() {
2712 let dir =
2713 std::env::temp_dir().join(format!("orch_test_strip_wf_none_{}", std::process::id()));
2714 let _ = std::fs::remove_dir_all(&dir);
2715 std::fs::create_dir_all(&dir).unwrap();
2716
2717 let _ = Command::new("git")
2719 .args(["init"])
2720 .current_dir(&dir)
2721 .output()
2722 .await;
2723 let _ = Command::new("git")
2724 .args(["config", "user.email", "test@test.com"])
2725 .current_dir(&dir)
2726 .output()
2727 .await;
2728 let _ = Command::new("git")
2729 .args(["config", "user.name", "Test"])
2730 .current_dir(&dir)
2731 .output()
2732 .await;
2733 std::fs::write(dir.join("init.txt"), "init").unwrap();
2734 let _ = Command::new("git")
2735 .args(["add", "."])
2736 .current_dir(&dir)
2737 .output()
2738 .await;
2739 let _ = Command::new("git")
2740 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2741 .current_dir(&dir)
2742 .output()
2743 .await;
2744
2745 let _ = Command::new("git")
2746 .args(["remote", "add", "origin", dir.to_str().unwrap()])
2747 .current_dir(&dir)
2748 .output()
2749 .await;
2750 let _ = Command::new("git")
2751 .args(["fetch", "origin"])
2752 .current_dir(&dir)
2753 .output()
2754 .await;
2755
2756 let _ = Command::new("git")
2757 .args(["checkout", "-b", "feature2"])
2758 .current_dir(&dir)
2759 .output()
2760 .await;
2761
2762 std::fs::write(dir.join("feature.txt"), "feature").unwrap();
2764 let _ = Command::new("git")
2765 .args(["add", "."])
2766 .current_dir(&dir)
2767 .output()
2768 .await;
2769 let _ = Command::new("git")
2770 .args([
2771 "-c",
2772 "commit.gpgsign=false",
2773 "commit",
2774 "-m",
2775 "add feature only",
2776 ])
2777 .current_dir(&dir)
2778 .output()
2779 .await;
2780
2781 let stripped = strip_workflow_files(&dir, "main").await.unwrap();
2782 assert!(!stripped, "should not strip when no workflow files exist");
2783
2784 let _ = std::fs::remove_dir_all(&dir);
2786 }
2787
2788 #[test]
2789 fn is_invalid_base_error_detects_base_invalid() {
2790 assert!(is_invalid_base_error(
2791 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): {\"errors\":[{\"resource\":\"PullRequest\",\"field\":\"base\",\"code\":\"invalid\"}]}"
2792 ));
2793 assert!(is_invalid_base_error(
2794 "GitHub API POST .../pulls failed (422): Base field is invalid"
2795 ));
2796 }
2797
2798 #[test]
2799 fn is_invalid_base_error_rejects_non_base_errors() {
2800 assert!(!is_invalid_base_error(
2802 "GitHub API POST .../pulls failed (422): No commits between main and branch"
2803 ));
2804 assert!(!is_invalid_base_error(
2806 "GitHub API POST .../pulls failed (422): head is not a valid ref"
2807 ));
2808 assert!(!is_invalid_base_error(
2810 "GitHub API POST .../pulls failed (502): Bad Gateway"
2811 ));
2812 assert!(!is_invalid_base_error(
2814 "GitHub API POST .../pulls failed (404): Not Found"
2815 ));
2816 }
2817
2818 fn pr_summary(body: &str) -> &str {
2819 let rest = body
2820 .strip_prefix("## Summary\n\n")
2821 .expect("PR body starts with ## Summary");
2822 rest.split("\n\n").next().unwrap()
2823 }
2824
2825 fn assert_no_push_or_pr_creation(body: &str) {
2826 let lower = body.to_ascii_lowercase();
2827 for needle in [
2828 "git push",
2829 "gh pr create",
2830 "did not push",
2831 "couldn't push",
2832 "could not push",
2833 "push was denied",
2834 "push was blocked",
2835 "push permission",
2836 "nothing is on the remote",
2837 "open the pr",
2838 ] {
2839 assert!(
2840 !lower.contains(needle),
2841 "PR body still contains {needle:?}:\n{body}"
2842 );
2843 }
2844 }
2845
2846 #[test]
2847 fn build_pr_body_strips_push_and_pr_creation_statements() {
2848 let examples = [
2849 (
2850 concat!(
2851 "Added the lesson list page. ",
2852 "I did not push or open the PR, because the push was blocked on a permission prompt. ",
2853 "Run `git push -u origin HEAD`, then `gh pr create`.",
2854 ),
2855 "Added the lesson list page.",
2856 ),
2857 (
2858 "It is committed locally ... The push was denied, so nothing is on the remote yet.",
2859 "It is committed locally",
2860 ),
2861 (
2862 concat!(
2863 "Fixed the quiz scoring. ",
2864 "I couldn't push it because the push permission was denied.",
2865 ),
2866 "Fixed the quiz scoring.",
2867 ),
2868 ];
2869 for (summary, work) in examples {
2870 let body = build_pr_body(
2871 "Fix the widget",
2872 summary,
2873 &[
2874 "Wrote the lesson list".into(),
2875 "Could not push because the push permission was denied".into(),
2876 ],
2877 &["Run `git push -u origin HEAD`".into()],
2878 &[],
2879 "16",
2880 "claude",
2881 None,
2882 );
2883 assert_eq!(pr_summary(&body), work);
2884 assert!(body.contains("Wrote the lesson list"));
2885 assert!(!body.contains("### Remaining"));
2886 assert_no_push_or_pr_creation(&body);
2887 }
2888 }
2889
2890 #[test]
2891 fn build_pr_body_keeps_summary_without_push_text() {
2892 let summary = "Added retry logic for GitHub 5xx errors.";
2893 let accomplished = vec!["Extracted the PR body builder".into()];
2894 let remaining = vec!["Watch CI after merge".into()];
2895 let body = build_pr_body(
2896 "Fix the widget",
2897 summary,
2898 &accomplished,
2899 &remaining,
2900 &["src/engine/runner/git_ops.rs".into()],
2901 "16",
2902 "claude",
2903 Some("sonnet"),
2904 );
2905 assert_eq!(pr_summary(&body), summary);
2906 assert!(body.contains("### What was done\n\n- Extracted the PR body builder\n"));
2907 assert!(body.contains("### Remaining\n\n- Watch CI after merge\n"));
2908 assert!(body.contains("- `src/engine/runner/git_ops.rs`"));
2909 assert!(body.contains(" using `sonnet`"));
2910 }
2911
2912 fn sh_git(dir: &std::path::Path, args: &[&str]) {
2913 let out = std::process::Command::new("git")
2914 .args([
2915 "-c",
2916 "commit.gpgsign=false",
2917 "-c",
2918 "user.name=T",
2919 "-c",
2920 "user.email=t@t",
2921 ])
2922 .args(args)
2923 .current_dir(dir)
2924 .output()
2925 .unwrap();
2926 assert!(
2927 out.status.success(),
2928 "git {args:?}: {}",
2929 String::from_utf8_lossy(&out.stderr)
2930 );
2931 }
2932
2933 fn commit_file(dir: &std::path::Path, name: &str) {
2934 std::fs::write(dir.join(name), name).unwrap();
2935 sh_git(dir, &["add", name]);
2936 sh_git(dir, &["commit", "-m", name]);
2937 }
2938
2939 #[tokio::test]
2945 async fn push_branch_recovers_from_stale_lease_after_remote_moved() {
2946 let temp = tempfile::TempDir::new().unwrap();
2947 let bare = temp.path().join("origin.git");
2948 let a = temp.path().join("a");
2949 let b = temp.path().join("b");
2950 std::fs::create_dir_all(&bare).unwrap();
2951 sh_git(&bare, &["init", "--bare", "-b", "main"]);
2952 sh_git(temp.path(), &["clone", bare.to_str().unwrap(), "a"]);
2953 sh_git(&a, &["config", "user.name", "T"]);
2955 sh_git(&a, &["config", "user.email", "t@t"]);
2956 commit_file(&a, "base.txt");
2957 sh_git(&a, &["push", "origin", "main"]);
2958 sh_git(&a, &["checkout", "-b", "feat"]);
2959 commit_file(&a, "feature.txt");
2960 sh_git(&a, &["push", "-u", "origin", "feat"]);
2961
2962 sh_git(temp.path(), &["clone", bare.to_str().unwrap(), "b"]);
2963 commit_file(&b, "main-change.txt");
2964 sh_git(&b, &["push", "origin", "main"]);
2965 sh_git(&b, &["checkout", "feat"]);
2966 commit_file(&b, "update-branch.txt");
2967 sh_git(&b, &["push", "origin", "feat"]);
2968
2969 sh_git(&a, &["fetch", "origin", "main"]);
2971 sh_git(&a, &["rebase", "origin/main"]);
2972
2973 assert!(push_branch(&a, "feat", "main").await.unwrap());
2974
2975 sh_git(&b, &["fetch", "origin", "feat"]);
2976 sh_git(&b, &["checkout", "origin/feat"]);
2977 for name in [
2978 "base.txt",
2979 "feature.txt",
2980 "main-change.txt",
2981 "update-branch.txt",
2982 ] {
2983 assert!(b.join(name).exists(), "remote feat lost {name}");
2984 }
2985 }
2986
2987 #[test]
2988 fn stale_lease_error_matches_git_messages() {
2989 assert!(is_stale_lease_error(
2990 "! [rejected] feat -> feat (stale info)"
2991 ));
2992 assert!(is_stale_lease_error(
2993 "! [rejected] feat -> feat (remote ref updated since checkout)"
2994 ));
2995 assert!(!is_stale_lease_error(
2996 "! [rejected] feat -> feat (fetch first)"
2997 ));
2998 }
2999
3000 fn git_ok(dir: &std::path::Path, args: &[&str]) {
3001 let out = std::process::Command::new("git")
3002 .args(args)
3003 .current_dir(dir)
3004 .output()
3005 .unwrap();
3006 assert!(
3007 out.status.success(),
3008 "git {} failed: {}",
3009 args.join(" "),
3010 String::from_utf8_lossy(&out.stderr)
3011 );
3012 }
3013
3014 fn git_sha(dir: &std::path::Path, rev: &str) -> String {
3015 let out = std::process::Command::new("git")
3016 .args(["rev-parse", rev])
3017 .current_dir(dir)
3018 .output()
3019 .unwrap();
3020 assert!(out.status.success(), "git rev-parse {rev} failed");
3021 String::from_utf8_lossy(&out.stdout).trim().to_string()
3022 }
3023
3024 fn init_identity(dir: &std::path::Path) {
3025 git_ok(dir, &["config", "user.email", "test@example.com"]);
3026 git_ok(dir, &["config", "user.name", "Test"]);
3027 git_ok(dir, &["config", "commit.gpgsign", "false"]);
3028 }
3029
3030 fn write_commit(dir: &std::path::Path, file: &str, body: &str, msg: &str) {
3031 std::fs::write(dir.join(file), body).unwrap();
3032 git_ok(dir, &["add", file]);
3033 git_ok(dir, &["commit", "-m", msg]);
3034 }
3035
3036 fn publish_feature(root: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
3038 let bare = root.join("bare.git");
3039 let a = root.join("a");
3040 git_ok(root, &["init", "-b", "main", a.to_str().unwrap()]);
3041 init_identity(&a);
3042 write_commit(&a, "README.md", "base\n", "base");
3043 git_ok(
3044 root,
3045 &["init", "--bare", "-b", "main", bare.to_str().unwrap()],
3046 );
3047 git_ok(&a, &["remote", "add", "origin", bare.to_str().unwrap()]);
3048 git_ok(&a, &["push", "-u", "origin", "main"]);
3049 git_ok(&a, &["checkout", "-b", "feature"]);
3050 write_commit(&a, "feature.txt", "feature\n", "feature");
3051 git_ok(&a, &["push", "-u", "origin", "feature"]);
3052 (bare, a)
3053 }
3054
3055 fn clone_on(
3056 root: &std::path::Path,
3057 bare: &std::path::Path,
3058 name: &str,
3059 branch: &str,
3060 ) -> std::path::PathBuf {
3061 let dir = root.join(name);
3062 git_ok(
3063 root,
3064 &["clone", bare.to_str().unwrap(), dir.to_str().unwrap()],
3065 );
3066 init_identity(&dir);
3067 if branch != "main" {
3068 git_ok(
3069 &dir,
3070 &["checkout", "-b", branch, &format!("origin/{branch}")],
3071 );
3072 }
3073
3074 dir
3075 }
3076
3077 #[tokio::test]
3078 async fn leased_force_push_does_not_replace_a_newer_remote_tip() {
3079 let root = tempfile::tempdir().unwrap();
3080 let (bare, a) = publish_feature(root.path());
3081 let b = clone_on(root.path(), &bare, "b", "feature");
3082 record_push_lease(&b, "feature", "main").await;
3083 let leased = git_sha(&b, "refs/remotes/origin/feature");
3084
3085 write_commit(&a, "feature.txt", "from a\n", "from a");
3086 git_ok(&a, &["push", "origin", "feature"]);
3087 let newer = git_sha(&a, "HEAD");
3088
3089 git_ok(&b, &["fetch", "origin"]);
3090 write_commit(&b, "feature.txt", "from b\n", "from b");
3091 let pushed = push_branch(&b, "feature", "main").await;
3092 assert!(
3093 pushed.is_err(),
3094 "force push should be rejected, got {pushed:?}"
3095 );
3096 assert_eq!(git_sha(&bare, "refs/heads/feature"), newer);
3097
3098 record_push_lease(&b, "feature", "main").await;
3100 assert_eq!(read_push_lease(&b, "feature").await, Some(leased));
3101 let again = push_branch(&b, "feature", "main").await;
3102 assert!(
3103 again.is_err(),
3104 "second force push should stay rejected, got {again:?}"
3105 );
3106 assert_eq!(git_sha(&bare, "refs/heads/feature"), newer);
3107 }
3108
3109 #[tokio::test]
3110 async fn leased_force_push_allows_rewrite_of_the_recorded_tip() {
3111 let root = tempfile::tempdir().unwrap();
3112 let (bare, a) = publish_feature(root.path());
3113 record_push_lease(&a, "feature", "main").await;
3114 std::fs::write(a.join("feature.txt"), "rewritten\n").unwrap();
3115 git_ok(&a, &["commit", "-am", "--amend", "-m", "feature rewritten"]);
3116
3117 let pushed = push_branch(&a, "feature", "main").await;
3118 assert!(
3119 pushed.is_ok(),
3120 "rewrite of the leased tip should push, got {pushed:?}"
3121 );
3122 assert_eq!(git_sha(&bare, "refs/heads/feature"), git_sha(&a, "HEAD"));
3123 assert_eq!(
3124 read_push_lease(&a, "feature").await,
3125 Some(git_sha(&a, "HEAD"))
3126 );
3127 }
3128
3129 #[tokio::test]
3130 async fn push_after_update_branch_merge_force_pushes_the_rebased_branch() {
3131 let root = tempfile::tempdir().unwrap();
3132 let (bare, a) = publish_feature(root.path());
3133 record_push_lease(&a, "feature", "main").await;
3134
3135 let c = clone_on(root.path(), &bare, "c", "main");
3137 write_commit(&c, "main.txt", "main\n", "main moves");
3138 git_ok(&c, &["push", "origin", "main"]);
3139 git_ok(&c, &["checkout", "-b", "feature", "origin/feature"]);
3140 git_ok(
3141 &c,
3142 &[
3143 "merge",
3144 "--no-ff",
3145 "-m",
3146 "Merge main into feature",
3147 "origin/main",
3148 ],
3149 );
3150 git_ok(&c, &["push", "origin", "feature"]);
3151
3152 rebase_on_default(&a, "main").await;
3153 write_commit(&a, "feature.txt", "review fix\n", "review fix");
3154 let pushed = push_branch(&a, "feature", "main").await;
3155 assert!(
3156 pushed.is_ok(),
3157 "push after update-branch should succeed, got {pushed:?}"
3158 );
3159 assert_eq!(git_sha(&bare, "refs/heads/feature"), git_sha(&a, "HEAD"));
3160 }
3161
3162 #[tokio::test]
3163 async fn agent_push_moves_the_lease_to_a_tip_head_contains() {
3164 let root = tempfile::tempdir().unwrap();
3165 let (_bare, a) = publish_feature(root.path());
3166 record_push_lease(&a, "feature", "main").await;
3167
3168 write_commit(&a, "feature.txt", "agent step\n", "agent step");
3169 git_ok(&a, &["push", "origin", "feature"]);
3170 record_push_lease(&a, "feature", "main").await;
3171 assert_eq!(
3172 read_push_lease(&a, "feature").await,
3173 Some(git_sha(&a, "HEAD"))
3174 );
3175 }
3176
3177 #[tokio::test]
3178 async fn record_push_lease_skips_the_default_branch() {
3179 let root = tempfile::tempdir().unwrap();
3180 let (_bare, a) = publish_feature(root.path());
3181 git_ok(&a, &["checkout", "main"]);
3182 record_push_lease(&a, "main", "main").await;
3183 assert_eq!(read_push_lease(&a, "main").await, None);
3184 }
3185
3186 fn github_update_by_merge(root: &std::path::Path, bare: &std::path::Path, name: &str) {
3188 let c = clone_on(root, bare, name, "main");
3189 write_commit(&c, "main.txt", "main\n", "main moves");
3190 git_ok(&c, &["push", "origin", "main"]);
3191 git_ok(&c, &["checkout", "-b", "feature", "origin/feature"]);
3192 git_ok(
3193 &c,
3194 &[
3195 "merge",
3196 "--no-ff",
3197 "-m",
3198 "Merge main into feature",
3199 "origin/main",
3200 ],
3201 );
3202 git_ok(&c, &["push", "origin", "feature"]);
3203 }
3204
3205 fn github_update_by_rebase(
3207 root: &std::path::Path,
3208 bare: &std::path::Path,
3209 name: &str,
3210 ) -> std::path::PathBuf {
3211 let c = clone_on(root, bare, name, "main");
3212 write_commit(&c, "main.txt", "main\n", "main moves");
3213 git_ok(&c, &["push", "origin", "main"]);
3214 git_ok(&c, &["checkout", "-b", "feature", "origin/feature"]);
3215 git_ok(&c, &["rebase", "origin/main"]);
3216 git_ok(&c, &["push", "--force", "origin", "feature"]);
3217 c
3218 }
3219
3220 fn remote_has(root: &std::path::Path, bare: &std::path::Path, file: &str) -> bool {
3221 let check = root.join("check");
3222 if !check.exists() {
3223 git_ok(
3224 root,
3225 &["clone", bare.to_str().unwrap(), check.to_str().unwrap()],
3226 );
3227 }
3228
3229 git_ok(&check, &["fetch", "origin"]);
3230 git_ok(&check, &["checkout", "--detach", "origin/feature"]);
3231 check.join(file).exists()
3232 }
3233
3234 #[tokio::test]
3235 async fn push_after_update_branch_rebase_force_pushes_the_rebased_branch() {
3236 let root = tempfile::tempdir().unwrap();
3237 let (bare, a) = publish_feature(root.path());
3238 record_push_lease(&a, "feature", "main").await;
3239 github_update_by_rebase(root.path(), &bare, "c");
3240
3241 rebase_on_default(&a, "main").await;
3242 write_commit(&a, "fix.txt", "review fix\n", "review fix");
3243 let pushed = push_branch(&a, "feature", "main").await;
3244 assert!(
3245 pushed.is_ok(),
3246 "push after a rebase update should succeed, got {pushed:?}"
3247 );
3248 assert_eq!(git_sha(&bare, "refs/heads/feature"), git_sha(&a, "HEAD"));
3249 }
3250
3251 #[tokio::test]
3252 async fn rebase_update_with_a_foreign_commit_keeps_the_foreign_commit() {
3253 let root = tempfile::tempdir().unwrap();
3254 let (bare, a) = publish_feature(root.path());
3255 record_push_lease(&a, "feature", "main").await;
3256 let c = github_update_by_rebase(root.path(), &bare, "c");
3257 write_commit(&c, "other.txt", "other machine\n", "other machine");
3258 git_ok(&c, &["push", "origin", "feature"]);
3259
3260 rebase_on_default(&a, "main").await;
3261 write_commit(&a, "fix.txt", "review fix\n", "review fix");
3262 let _ = push_branch(&a, "feature", "main").await;
3263 assert!(
3264 remote_has(root.path(), &bare, "other.txt"),
3265 "the other machine's commit was dropped"
3266 );
3267 }
3268
3269 #[tokio::test]
3270 async fn merge_of_a_non_default_branch_is_not_a_github_update() {
3271 let root = tempfile::tempdir().unwrap();
3272 let (bare, a) = publish_feature(root.path());
3273 record_push_lease(&a, "feature", "main").await;
3274 let c = clone_on(root.path(), &bare, "c", "feature");
3275 git_ok(&c, &["checkout", "-b", "side", "origin/main"]);
3276 write_commit(&c, "side.txt", "side\n", "side work");
3277 git_ok(&c, &["checkout", "feature"]);
3278 git_ok(&c, &["merge", "--no-ff", "-m", "Merge side", "side"]);
3279 git_ok(&c, &["push", "origin", "feature"]);
3280
3281 rebase_on_default(&a, "main").await;
3282 write_commit(&a, "fix.txt", "review fix\n", "review fix");
3283 let _ = push_branch(&a, "feature", "main").await;
3284 assert!(
3285 remote_has(root.path(), &bare, "side.txt"),
3286 "the merged side branch was dropped"
3287 );
3288 }
3289
3290 #[tokio::test]
3291 async fn update_merge_on_top_of_a_foreign_push_keeps_the_foreign_commit() {
3292 let root = tempfile::tempdir().unwrap();
3293 let (bare, a) = publish_feature(root.path());
3294 record_push_lease(&a, "feature", "main").await;
3295 let c = clone_on(root.path(), &bare, "c", "feature");
3296 write_commit(&c, "other.txt", "other machine\n", "other machine");
3297 git_ok(&c, &["push", "origin", "feature"]);
3298 github_update_by_merge(root.path(), &bare, "d");
3299
3300 rebase_on_default(&a, "main").await;
3301 write_commit(&a, "fix.txt", "review fix\n", "review fix");
3302 let _ = push_branch(&a, "feature", "main").await;
3303 assert!(
3304 remote_has(root.path(), &bare, "other.txt"),
3305 "the other machine's commit was dropped"
3306 );
3307 }
3308
3309 #[tokio::test]
3310 async fn force_push_leased_accepts_github_updates() {
3311 for by_rebase in [false, true] {
3312 let root = tempfile::tempdir().unwrap();
3313 let (bare, a) = publish_feature(root.path());
3314 record_push_lease(&a, "feature", "main").await;
3315 if by_rebase {
3316 github_update_by_rebase(root.path(), &bare, "c");
3317 } else {
3318 github_update_by_merge(root.path(), &bare, "c");
3319 }
3320
3321 git_ok(&a, &["fetch", "origin"]);
3322 git_ok(&a, &["rebase", "origin/main"]);
3323 let pushed = force_push_leased(&a, "feature", "main").await;
3324 assert!(pushed.is_ok(), "by_rebase={by_rebase}: {pushed:?}");
3325 assert_eq!(git_sha(&bare, "refs/heads/feature"), git_sha(&a, "HEAD"));
3326 }
3327 }
3328
3329 #[tokio::test]
3330 async fn force_push_leased_refuses_a_foreign_push() {
3331 let root = tempfile::tempdir().unwrap();
3332 let (bare, a) = publish_feature(root.path());
3333 record_push_lease(&a, "feature", "main").await;
3334 let c = clone_on(root.path(), &bare, "c", "feature");
3335 write_commit(&c, "other.txt", "other machine\n", "other machine");
3336 git_ok(&c, &["push", "origin", "feature"]);
3337 let foreign = git_sha(&c, "HEAD");
3338
3339 git_ok(&a, &["fetch", "origin"]);
3340 record_push_lease(&a, "feature", "main").await;
3341 std::fs::write(a.join("feature.txt"), "rewritten\n").unwrap();
3342 git_ok(&a, &["commit", "-am", "--amend", "-m", "rewritten"]);
3343 assert!(force_push_leased(&a, "feature", "main").await.is_err());
3344 assert_eq!(git_sha(&bare, "refs/heads/feature"), foreign);
3345 }
3346
3347 #[tokio::test]
3348 async fn force_push_leased_refuses_a_branch_created_after_an_absent_lease() {
3349 let root = tempfile::tempdir().unwrap();
3350 let (bare, a) = publish_feature(root.path());
3351 git_ok(&a, &["checkout", "-b", "fresh", "main"]);
3352 record_push_lease(&a, "fresh", "main").await;
3353 assert_eq!(
3354 read_push_lease(&a, "fresh").await.as_deref(),
3355 Some(LEASE_ABSENT)
3356 );
3357
3358 let c = clone_on(root.path(), &bare, "c", "main");
3359 git_ok(&c, &["checkout", "-b", "fresh"]);
3360 write_commit(&c, "other.txt", "other machine\n", "other machine");
3361 git_ok(&c, &["push", "origin", "fresh"]);
3362 let foreign = git_sha(&c, "HEAD");
3363
3364 git_ok(&a, &["fetch", "origin"]);
3365 write_commit(&a, "mine.txt", "mine\n", "mine");
3366 assert!(force_push_leased(&a, "fresh", "main").await.is_err());
3367 assert_eq!(git_sha(&bare, "refs/heads/fresh"), foreign);
3368 }
3369
3370 #[tokio::test]
3371 async fn first_push_records_the_pushed_head_as_the_lease() {
3372 let root = tempfile::tempdir().unwrap();
3373 let (_bare, a) = publish_feature(root.path());
3374 git_ok(&a, &["checkout", "-b", "fresh", "main"]);
3375 write_commit(&a, "mine.txt", "mine\n", "mine");
3376 assert!(push_branch(&a, "fresh", "main").await.unwrap());
3377 assert_eq!(
3378 read_push_lease(&a, "fresh").await,
3379 Some(git_sha(&a, "HEAD"))
3380 );
3381 }
3382
3383 #[tokio::test]
3384 async fn missing_lease_falls_back_to_the_fetched_tip_and_counts_it() {
3385 let root = tempfile::tempdir().unwrap();
3386 let (_bare, a) = publish_feature(root.path());
3387 let before = LEASE_FALLBACKS.load(Ordering::Relaxed);
3388 let expected = expected_force_sha(&a, "feature", "main").await.unwrap();
3389 assert_eq!(expected, git_sha(&a, "refs/remotes/origin/feature"));
3390 assert!(LEASE_FALLBACKS.load(Ordering::Relaxed) > before);
3391 }
3392
3393 #[tokio::test]
3394 async fn agent_push_then_foreign_push_keeps_the_agent_tip_as_lease() {
3395 let root = tempfile::tempdir().unwrap();
3396 let (bare, a) = publish_feature(root.path());
3397 record_push_lease(&a, "feature", "main").await;
3398 write_commit(&a, "step.txt", "agent step\n", "agent step");
3399 git_ok(&a, &["push", "origin", "feature"]);
3400 let agent_tip = git_sha(&a, "HEAD");
3401
3402 let c = clone_on(root.path(), &bare, "c", "feature");
3403 write_commit(&c, "other.txt", "other machine\n", "other machine");
3404 git_ok(&c, &["push", "origin", "feature"]);
3405
3406 record_push_lease(&a, "feature", "main").await;
3408 git_ok(&a, &["fetch", "origin"]);
3409 record_push_lease(&a, "feature", "main").await;
3410 assert_eq!(read_push_lease(&a, "feature").await, Some(agent_tip));
3411 let foreign = git_sha(&bare, "refs/heads/feature");
3412 git_ok(&a, &["commit", "--amend", "-m", "agent step rewritten"]);
3413 assert!(force_push_leased(&a, "feature", "main").await.is_err());
3414 assert_eq!(git_sha(&bare, "refs/heads/feature"), foreign);
3415 }
3416
3417 #[tokio::test]
3418 async fn branch_without_remote_and_head_without_twins_are_not_github_updates() {
3419 let root = tempfile::tempdir().unwrap();
3420 let (_bare, a) = publish_feature(root.path());
3421 let lease = git_sha(&a, "HEAD");
3422 let main = git_sha(&a, "origin/main");
3423 assert!(!is_github_branch_update(&a, &main, &lease, "main").await);
3425 assert!(
3426 !is_github_branch_update(
3427 &a,
3428 "0000000000000000000000000000000000000000",
3429 &lease,
3430 "main"
3431 )
3432 .await
3433 );
3434 }
3435}