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