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