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
948async fn note_pushed_head(dir: &Path, branch: &str) {
949 if let Some(sha) = git_stdout(dir, &["rev-parse", "--verify", "HEAD"]).await {
950 write_push_lease(dir, branch, &sha).await;
951 }
952}
953
954async fn is_github_branch_update(dir: &Path, tip: &str, lease: &str, default_branch: &str) -> bool {
957 let base = format!("origin/{default_branch}");
958 let Some(line) = git_stdout(dir, &["rev-list", "--parents", "-n", "1", tip]).await else {
959 return false;
960 };
961 let parents: Vec<&str> = line.split_whitespace().skip(1).collect();
962 if let [first, second] = parents[..] {
963 return first == lease && is_ancestor(dir, second, &base).await;
964 }
965
966 let Some(cherry) = git_stdout(dir, &["cherry", lease, tip, &base]).await else {
968 return false;
969 };
970 is_ancestor(dir, &base, tip).await
971 && !cherry.is_empty()
972 && cherry.lines().all(|l| l.starts_with('-'))
973}
974
975async fn expected_force_sha(
976 dir: &Path,
977 branch: &str,
978 default_branch: &str,
979) -> anyhow::Result<String> {
980 let tip = remote_tracking_sha(dir, branch).await;
981 match read_push_lease(dir, branch).await {
982 Some(lease) if lease == LEASE_ABSENT => anyhow::bail!(
983 "force push rejected: remote branch {branch} was absent when the lease was recorded"
984 ),
985 Some(lease) => match tip {
986 Some(tip)
987 if tip != lease
988 && is_github_branch_update(dir, &tip, &lease, default_branch).await =>
989 {
990 tracing::info!(branch, %lease, %tip, "remote tip is GitHub's update of the leased branch, leasing it");
991 Ok(tip)
992 }
993 _ => Ok(lease),
994 },
995 None => {
996 let Some(tip) = tip else {
997 anyhow::bail!("force push rejected: no remote sha recorded for {branch}");
998 };
999 let fallbacks = LEASE_FALLBACKS.fetch_add(1, Ordering::Relaxed) + 1;
1000 tracing::warn!(
1001 branch,
1002 fallbacks,
1003 "no push lease recorded, using the fetched origin tip"
1004 );
1005 Ok(tip)
1006 }
1007 }
1008}
1009
1010pub(crate) async fn force_push_leased(
1012 dir: &Path,
1013 branch: &str,
1014 default_branch: &str,
1015) -> anyhow::Result<()> {
1016 let expected = expected_force_sha(dir, branch, default_branch).await?;
1017 let lease = format!("--force-with-lease={branch}:{expected}");
1018 tracing::info!(branch, expected_sha = %expected, "force-pushing with an expected remote sha");
1019 let output = git()
1020 .await
1021 .args([
1022 "-c",
1023 "lfs.locksverify=false",
1024 "push",
1025 &lease,
1026 "-u",
1027 "origin",
1028 branch,
1029 ])
1030 .current_dir(dir)
1031 .kill_on_drop(true)
1032 .output_with_context()
1033 .await?;
1034 if output.status.success() {
1035 note_pushed_head(dir, branch).await;
1036 return Ok(());
1037 }
1038
1039 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1040 anyhow::bail!("{}", sanitize_push_error(&stderr))
1041}
1042
1043pub async fn push_branch(dir: &Path, branch: &str, default_branch: &str) -> anyhow::Result<bool> {
1045 let current = get_current_branch(dir).await;
1046 let branch_to_push = if current.is_empty() { branch } else { ¤t };
1047
1048 if branch_to_push.is_empty() {
1050 anyhow::bail!("cannot push: branch name is empty");
1051 }
1052
1053 if branch_to_push == "main" || branch_to_push == "master" {
1055 return Ok(false);
1056 }
1057
1058 let (remote_exists, has_unpushed) =
1060 check_push_needed(dir, branch_to_push, default_branch).await;
1061 if remote_exists && !has_unpushed {
1062 tracing::debug!(
1066 branch = branch_to_push,
1067 "remote branch exists and is up to date, skipping push"
1068 );
1069 note_pushed_head(dir, branch_to_push).await;
1070 return Ok(true);
1071 }
1072 if !remote_exists && !has_unpushed {
1073 tracing::debug!(
1079 branch = branch_to_push,
1080 "remote branch missing with no new commits — pushing to create it on remote"
1081 );
1082 }
1083
1084 tracing::info!(branch = branch_to_push, "pushing branch");
1085
1086 let output = tokio::time::timeout(
1093 std::time::Duration::from_secs(120),
1094 git()
1095 .await
1096 .args([
1097 "-c",
1098 "lfs.locksverify=false",
1099 "push",
1100 "-u",
1101 "origin",
1102 branch_to_push,
1103 ])
1104 .kill_on_drop(true)
1105 .current_dir(dir)
1106 .output(),
1107 )
1108 .await
1109 .map_err(|_| anyhow::anyhow!("git push timed out after 120s"))??;
1110
1111 if output.status.success() {
1112 tracing::info!(branch = branch_to_push, "push succeeded");
1113 note_pushed_head(dir, branch_to_push).await;
1114 return Ok(true);
1115 }
1116
1117 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1118
1119 if is_transient_network_error(&stderr) {
1122 tracing::warn!(
1123 branch = branch_to_push,
1124 stderr = %sanitize_push_error(&stderr),
1125 "push failed with transient network error — retrying once"
1126 );
1127 let retry_output = tokio::time::timeout(
1128 std::time::Duration::from_secs(120),
1129 git()
1130 .await
1131 .args([
1132 "-c",
1133 "lfs.locksverify=false",
1134 "push",
1135 "-u",
1136 "origin",
1137 branch_to_push,
1138 ])
1139 .kill_on_drop(true)
1140 .current_dir(dir)
1141 .output(),
1142 )
1143 .await
1144 .map_err(|_| anyhow::anyhow!("git push (network retry) timed out after 120s"))??;
1145
1146 if retry_output.status.success() {
1147 tracing::info!(branch = branch_to_push, "push succeeded on network retry");
1148 note_pushed_head(dir, branch_to_push).await;
1149 return Ok(true);
1150 }
1151
1152 let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
1153 .trim()
1154 .to_string();
1155 tracing::warn!(
1156 branch = branch_to_push,
1157 stderr = %sanitize_push_error(&retry_stderr),
1158 "push retry also failed"
1159 );
1160 anyhow::bail!("push failed: {}", sanitize_push_error(&retry_stderr));
1161 }
1162
1163 if is_workflow_scope_error(&stderr) {
1167 tracing::warn!(
1168 branch = branch_to_push,
1169 "push rejected: token lacks `workflow` scope — stripping workflow files and retrying"
1170 );
1171
1172 match strip_workflow_files(dir, default_branch).await {
1173 Ok(true) => {
1174 let retry_output = tokio::time::timeout(
1176 std::time::Duration::from_secs(120),
1177 git()
1178 .await
1179 .args([
1180 "-c",
1181 "lfs.locksverify=false",
1182 "push",
1183 "-u",
1184 "origin",
1185 branch_to_push,
1186 ])
1187 .kill_on_drop(true)
1188 .current_dir(dir)
1189 .output(),
1190 )
1191 .await
1192 .map_err(|_| anyhow::anyhow!("git push (retry) timed out after 120s"))??;
1193
1194 if retry_output.status.success() {
1195 tracing::info!(
1196 branch = branch_to_push,
1197 "push succeeded after stripping workflow files"
1198 );
1199 note_pushed_head(dir, branch_to_push).await;
1200 return Ok(true);
1201 }
1202
1203 let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
1204 .trim()
1205 .to_string();
1206
1207 if is_workflow_scope_error(&retry_stderr) {
1209 anyhow::bail!(
1210 "push failed: token lacks `workflow` OAuth scope and workflow files \
1211 could not be fully stripped — add `workflow` scope to your GitHub \
1212 token or use a GitHub App for authentication"
1213 );
1214 }
1215
1216 anyhow::bail!("push failed after stripping workflow files: {retry_stderr}");
1217 }
1218 Ok(false) => {
1219 anyhow::bail!(
1221 "push failed: token lacks `workflow` OAuth scope — add `workflow` scope \
1222 to your GitHub token or use a GitHub App for authentication. \
1223 Error: {stderr}"
1224 );
1225 }
1226 Err(e) => {
1227 tracing::error!(error = %e, "failed to strip workflow files from commits");
1228 anyhow::bail!(
1229 "push failed: token lacks `workflow` OAuth scope and automatic \
1230 recovery failed ({e}) — add `workflow` scope to your GitHub token \
1231 or use a GitHub App for authentication"
1232 );
1233 }
1234 }
1235 }
1236
1237 if push_needs_rebase(&stderr) && remote_exists {
1240 tracing::info!(
1241 branch = branch_to_push,
1242 "push rejected (non-fast-forward), force-pushing with lease"
1243 );
1244 let stderr = match force_push_leased(dir, branch_to_push, default_branch).await {
1245 Ok(()) => {
1246 tracing::info!(branch = branch_to_push, "force push succeeded");
1247 return Ok(true);
1248 }
1249 Err(e) => e.to_string(),
1250 };
1251
1252 if is_workflow_scope_error(&stderr) {
1254 tracing::warn!(
1255 branch = branch_to_push,
1256 "force push also rejected due to workflow scope — stripping and retrying"
1257 );
1258 match strip_workflow_files(dir, default_branch).await {
1259 Ok(true) => {
1260 if let Err(e) = force_push_leased(dir, branch_to_push, default_branch).await {
1261 anyhow::bail!("force push failed after stripping workflow files: {e}");
1262 }
1263
1264 tracing::info!(
1265 branch = branch_to_push,
1266 "force push succeeded after stripping workflow files"
1267 );
1268 return Ok(true);
1269 }
1270 Ok(false) => {
1271 anyhow::bail!(
1272 "force push failed: token lacks `workflow` OAuth scope — \
1273 add `workflow` scope to your GitHub token or use a GitHub App. \
1274 Error: {stderr}"
1275 );
1276 }
1277 Err(e) => {
1278 anyhow::bail!(
1279 "force push failed: token lacks `workflow` scope and recovery \
1280 failed ({e})"
1281 );
1282 }
1283 }
1284 }
1285
1286 if is_stale_lease_error(&stderr) && rebase_on_branch(dir, branch_to_push).await? {
1289 let output = git()
1290 .await
1291 .args([
1292 "-c",
1293 "lfs.locksverify=false",
1294 "push",
1295 "-u",
1296 "origin",
1297 branch_to_push,
1298 ])
1299 .current_dir(dir)
1300 .output_with_context()
1301 .await?;
1302 if output.status.success() {
1303 tracing::info!(
1304 branch = branch_to_push,
1305 "push succeeded after rebasing on the moved remote branch"
1306 );
1307 note_pushed_head(dir, branch_to_push).await;
1308 return Ok(true);
1309 }
1310
1311 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1312 anyhow::bail!("push failed after rebasing on the moved remote branch: {stderr}");
1313 }
1314
1315 if is_stale_lease_error(&stderr) {
1316 tracing::info!(
1317 branch = branch_to_push,
1318 "remote branch already holds every local commit, nothing to push"
1319 );
1320 return Ok(true);
1321 }
1322
1323 anyhow::bail!("force push failed: {stderr}");
1324 }
1325
1326 anyhow::bail!("push failed: {stderr}")
1327}
1328
1329fn push_needs_rebase(stderr: &str) -> bool {
1330 let lower = stderr.to_ascii_lowercase();
1331 lower.contains("non-fast-forward")
1332 || lower.contains("rejected")
1333 || lower.contains("fetch first")
1334 || lower.contains("behind")
1335}
1336
1337fn is_stale_lease_error(stderr: &str) -> bool {
1339 let lower = stderr.to_ascii_lowercase();
1340 lower.contains("stale info") || lower.contains("remote ref updated since checkout")
1341}
1342
1343fn is_transient_network_error(stderr: &str) -> bool {
1348 let lower = stderr.to_ascii_lowercase();
1349 lower.contains("i/o timeout")
1350 || lower.contains("dial tcp")
1351 || lower.contains("connection reset")
1352 || lower.contains("connection refused")
1353 || lower.contains("no route to host")
1354 || lower.contains("temporary failure in name resolution")
1355 || lower.contains("eof")
1356}
1357
1358fn sanitize_push_error(stderr: &str) -> String {
1362 static URL_USERINFO: LazyLock<Regex> =
1363 LazyLock::new(|| Regex::new(r"://[^/@\s]+@").expect("valid url userinfo regex"));
1364 let no_userinfo = URL_USERINFO.replace_all(stderr, "://***@");
1365 crate::security::redact(&no_userinfo)
1366}
1367
1368fn strip_push_and_pr_creation(text: &str) -> String {
1371 let mut kept_lines: Vec<String> = Vec::new();
1372 for line in text.lines() {
1373 if line.trim().is_empty() {
1374 if kept_lines.last().is_some_and(|l| !l.is_empty()) {
1375 kept_lines.push(String::new());
1376 }
1377 continue;
1378 }
1379
1380 let indent_len = line.len() - line.trim_start().len();
1381 let indent = &line[..indent_len];
1382 let trimmed = line.trim();
1383 let (marker, rest) = match list_marker(trimmed) {
1384 Some((m, r)) => (m, r),
1385 None => ("", trimmed),
1386 };
1387
1388 let kept: Vec<&str> = split_statements(rest)
1389 .into_iter()
1390 .filter(|s| !is_push_or_pr_creation_statement(s))
1391 .collect();
1392 if kept.is_empty() {
1393 continue;
1394 }
1395 kept_lines.push(format!("{indent}{marker}{}", kept.join(" ")));
1396 }
1397 while kept_lines.last().is_some_and(String::is_empty) {
1398 kept_lines.pop();
1399 }
1400 kept_lines.join("\n")
1401}
1402
1403fn split_statements(text: &str) -> Vec<&str> {
1404 static SEP: LazyLock<Regex> =
1405 LazyLock::new(|| Regex::new(r"(\.{2,}|(?:[.!?]))\s+").expect("valid statement separator"));
1406 let mut statements = Vec::new();
1407 let mut last = 0;
1408 for cap in SEP.captures_iter(text) {
1409 let full = cap.get(0).expect("full separator match");
1410 let punct = cap.get(1).expect("punctuation capture");
1411 let ellipsis = punct.as_str().len() >= 2 && punct.as_str().bytes().all(|b| b == b'.');
1412 let end = if ellipsis { punct.start() } else { punct.end() };
1413 let stmt = text[last..end].trim();
1414 if !stmt.is_empty() {
1415 statements.push(stmt);
1416 }
1417 last = full.end();
1418 }
1419 let rest = text[last..].trim();
1420 if !rest.is_empty() {
1421 statements.push(rest);
1422 }
1423 statements
1424}
1425
1426fn list_marker(line: &str) -> Option<(&str, &str)> {
1427 for prefix in ["- ", "* ", "+ "] {
1428 if let Some(rest) = line.strip_prefix(prefix) {
1429 return Some((prefix, rest));
1430 }
1431 }
1432 let dot = line.find(". ")?;
1433 if !line[..dot].is_empty() && line[..dot].bytes().all(|b| b.is_ascii_digit()) {
1434 Some((&line[..dot + 2], &line[dot + 2..]))
1435 } else {
1436 None
1437 }
1438}
1439
1440fn is_push_or_pr_creation_statement(text: &str) -> bool {
1441 static PAT: LazyLock<Regex> = LazyLock::new(|| {
1442 Regex::new(concat!(
1443 r"(?i)",
1444 r"\bgit\s+push\b",
1445 r"|\bgh\s+pr\s+create\b",
1446 r"|\bgh\s+pr\s+open\b",
1447 r"|\bpush\s+(was\s+)?(denied|blocked|rejected)\b",
1448 r"|\bpush\s+permission\b",
1449 r"|\bcouldn['’]?t\s+push\b",
1450 r"|\bcould\s+not\s+push\b",
1451 r"|\bcan['’]?t\s+push\b",
1452 r"|\bcannot\s+push\b",
1453 r"|\bdidn['’]?t\s+push\b",
1454 r"|\bdid\s+not\s+push\b",
1455 r"|\bnothing\s+is\s+on\s+the\s+remote\b",
1456 r"|\bnot\s+on\s+the\s+remote\b",
1457 r"|\bopen(?:ing)?\s+(?:the|a)\s+pr\b",
1458 r"|\bcreat(?:e|ing)\s+(?:the|a)\s+pr\b",
1459 r"|\bopen(?:ing)?\s+(?:the|a)\s+pull\s+request\b",
1460 r"|\bcreat(?:e|ing)\s+(?:the|a)\s+pull\s+request\b",
1461 ))
1462 .expect("valid push/pr-creation regex")
1463 });
1464 PAT.is_match(text)
1465}
1466
1467fn cleaned_pr_items(items: &[String]) -> Vec<String> {
1468 items
1469 .iter()
1470 .filter_map(|item| {
1471 let cleaned = strip_push_and_pr_creation(item);
1472 (!cleaned.is_empty()).then_some(cleaned)
1473 })
1474 .collect()
1475}
1476
1477#[expect(
1479 clippy::too_many_arguments,
1480 reason = "each argument is one section of the PR body"
1481)]
1482fn build_pr_body(
1483 title: &str,
1484 summary: &str,
1485 accomplished: &[String],
1486 remaining: &[String],
1487 files: &[String],
1488 task_id: &str,
1489 agent: &str,
1490 model: Option<&str>,
1491) -> String {
1492 use std::fmt::Write as _;
1493 let cleaned_summary = strip_push_and_pr_creation(summary);
1494 let summary_text = if cleaned_summary.is_empty() {
1495 title
1496 } else {
1497 cleaned_summary.as_str()
1498 };
1499 let accomplished = cleaned_pr_items(accomplished);
1500 let remaining = cleaned_pr_items(remaining);
1501
1502 let mut body = format!("## Summary\n\n{summary_text}");
1503
1504 if !accomplished.is_empty() {
1505 body.push_str("\n\n### What was done\n\n");
1506 for item in &accomplished {
1507 let _ = writeln!(body, "- {item}");
1508 }
1509 }
1510
1511 if !remaining.is_empty() {
1512 body.push_str("\n\n### Remaining\n\n");
1513 for item in &remaining {
1514 let _ = writeln!(body, "- {item}");
1515 }
1516 }
1517
1518 if !files.is_empty() {
1519 body.push_str("\n\n### Files changed\n\n");
1520 for file in files {
1521 let _ = writeln!(body, "- `{file}`");
1522 }
1523 }
1524
1525 if !task_id.starts_with("internal:")
1526 && let Ok(issue_num) = task_id.parse::<u64>()
1527 {
1528 let _ = write!(body, "\n\nCloses #{issue_num}");
1529 }
1530
1531 let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1532 let task_ref = format_task_ref(task_id);
1533 let _ = write!(
1534 body,
1535 "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1536 );
1537 body
1538}
1539
1540#[expect(
1549 clippy::too_many_arguments,
1550 reason = "the PR needs its branch, body sections, attribution and target together"
1551)]
1552pub async fn create_pr_if_needed(
1553 dir: &Path,
1554 branch: &str,
1555 title: &str,
1556 summary: &str,
1557 accomplished: &[String],
1558 remaining: &[String],
1559 files: &[String],
1560 task_id: &str,
1561 agent: &str,
1562 model: Option<&str>,
1563 repo: &str,
1564 base_branch: &str,
1565) -> PrCreateResult<String> {
1566 const MAX_RETRIES: u32 = 3;
1568 let gh = GhHttp::new()?;
1569
1570 match gh.get_pr_number(repo, branch).await {
1572 Ok(Some(pr_number)) => {
1573 tracing::info!(task_id, pr = pr_number, "PR already exists");
1574 append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model).await;
1576 let pr = match gh.get_pr(repo, pr_number).await {
1578 Ok(pr) => pr,
1579 Err(e) => {
1580 tracing::warn!(task_id, error = %e, "get_pr API call failed after detecting existing PR");
1581 return Err(PrCreateError::ApiError(e));
1582 }
1583 };
1584 return Ok(pr.html_url);
1585 }
1586 Ok(None) => {
1587 }
1589 Err(e) => {
1590 tracing::warn!(task_id, error = %e, "get_pr_number API call failed");
1591 return Err(PrCreateError::ApiError(e));
1592 }
1593 }
1594
1595 let git_files: Vec<String>;
1597 let effective_files: &[String] = if files.is_empty() {
1598 match tokio::process::Command::new("git")
1599 .args([
1600 "diff",
1601 &format!("origin/{base_branch}...HEAD"),
1602 "--name-only",
1603 ])
1604 .current_dir(dir)
1605 .output()
1606 .await
1607 {
1608 Ok(output) if output.status.success() => {
1609 git_files = String::from_utf8_lossy(&output.stdout)
1610 .lines()
1611 .filter(|l| !l.is_empty())
1612 .map(std::string::ToString::to_string)
1613 .collect();
1614 &git_files
1615 }
1616 _ => files,
1617 }
1618 } else {
1619 files
1620 };
1621
1622 let body = build_pr_body(
1623 title,
1624 summary,
1625 accomplished,
1626 remaining,
1627 effective_files,
1628 task_id,
1629 agent,
1630 model,
1631 );
1632
1633 let pr_title = title;
1635
1636 let mut last_error: Option<anyhow::Error> = None;
1638 let mut created_url: Option<String> = None;
1639 let mut had_transient_error = false;
1640 let mut effective_base = base_branch.to_string();
1641 let mut tried_api_fallback = false;
1642
1643 for attempt in 1..=MAX_RETRIES {
1644 match gh
1645 .create_pr(repo, pr_title, &body, branch, &effective_base)
1646 .await
1647 {
1648 Ok(u) => {
1649 created_url = Some(u);
1650 break;
1651 }
1652 Err(e) => {
1653 let err_str = format!("{e}");
1654 if is_transient_github_error(&err_str) {
1655 had_transient_error = true;
1656 if attempt < MAX_RETRIES {
1657 let delay = std::time::Duration::from_secs(2u64.pow(attempt));
1658 tracing::warn!(
1659 task_id,
1660 attempt,
1661 delay_secs = delay.as_secs(),
1662 error = %e,
1663 "transient GitHub API error during PR creation — retrying"
1664 );
1665 tokio::time::sleep(delay).await;
1666 }
1667 } else if is_invalid_base_error(&err_str) && !tried_api_fallback {
1668 tried_api_fallback = true;
1671 tracing::warn!(
1672 task_id,
1673 base_branch = %effective_base,
1674 error = %e,
1675 "PR creation failed with invalid base — fetching default branch from GitHub API"
1676 );
1677 match gh.get_default_branch(repo).await {
1678 Ok(api_default) if api_default != effective_base => {
1679 tracing::info!(
1680 task_id,
1681 old_base = %effective_base,
1682 new_base = %api_default,
1683 "retrying PR creation with GitHub API default branch"
1684 );
1685 effective_base = api_default;
1686 continue;
1687 }
1688 Ok(api_default) => {
1689 tracing::warn!(
1690 task_id,
1691 base_branch = %api_default,
1692 "GitHub API default branch matches local — no fallback possible"
1693 );
1694 }
1695 Err(api_err) => {
1696 tracing::warn!(
1697 task_id,
1698 error = %api_err,
1699 "failed to fetch default branch from GitHub API"
1700 );
1701 }
1702 }
1703 }
1704 last_error = Some(e);
1705 }
1706 }
1707 }
1708
1709 let url = if let Some(u) = created_url {
1710 u
1711 } else {
1712 let e = last_error.unwrap_or_else(|| {
1713 anyhow::anyhow!("PR creation failed after retries (no error captured)")
1714 });
1715 let err_str = format!("{e}");
1716 if is_transient_github_error(&err_str) || had_transient_error {
1723 tracing::warn!(
1724 task_id,
1725 error = %e,
1726 "transient GitHub API error after all retries — checking if PR was actually created"
1727 );
1728 match gh.get_pr_number(repo, branch).await {
1729 Ok(Some(pr_number)) => {
1730 tracing::info!(
1731 task_id,
1732 pr = pr_number,
1733 "PR was created despite transient errors — recovering"
1734 );
1735 append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model).await;
1736 match gh.get_pr(repo, pr_number).await {
1737 Ok(pr) => pr.html_url,
1738 Err(get_err) => {
1739 tracing::warn!(
1740 task_id,
1741 error = %get_err,
1742 "failed to fetch PR URL after transient-error recovery"
1743 );
1744 return Err(PrCreateError::ApiError(e));
1745 }
1746 }
1747 }
1748 Ok(None) => {
1749 tracing::warn!(
1750 task_id,
1751 "PR was not created after all retries — propagating original error"
1752 );
1753 return Err(PrCreateError::ApiError(e));
1754 }
1755 Err(check_err) => {
1756 tracing::warn!(
1757 task_id,
1758 error = %check_err,
1759 "failed to verify PR existence after transient error retries — propagating original error"
1760 );
1761 return Err(PrCreateError::ApiError(e));
1762 }
1763 }
1764 } else {
1765 return Err(PrCreateError::ApiError(e));
1766 }
1767 };
1768
1769 tracing::info!(task_id, pr_url = %url, "created PR via GhHttp API");
1770
1771 Ok(url)
1772}
1773
1774pub(crate) fn is_transient_github_error(err_str: &str) -> bool {
1778 if err_str.contains("HTTP send failed") {
1782 return true;
1783 }
1784 if err_str.contains("GitHub API server error") {
1785 return true;
1786 }
1787
1788 if extract_github_http_status(err_str).is_some_and(|s| (500..600).contains(&s)) {
1790 return true;
1791 }
1792
1793 let lower = err_str.to_lowercase();
1795
1796 if lower.contains("connection reset")
1798 || lower.contains("broken pipe")
1799 || lower.contains("connection refused")
1800 || lower.contains("connection closed")
1801 || lower.contains("transport error")
1802 || lower.contains("failed to connect")
1803 || lower.contains("failed to connect to github.com port")
1804 || lower.contains("curl: (7) failed to connect")
1805 || lower.contains("couldn't connect to server")
1806 || lower.contains("could not connect to server")
1807 {
1808 return true;
1809 }
1810
1811 if lower.contains("timeout") {
1813 return true;
1814 }
1815
1816 if lower.contains("dns error")
1818 || lower.contains("resolve")
1819 || lower.contains("name resolution")
1820 || lower.contains("no such host")
1821 {
1822 return true;
1823 }
1824
1825 if lower.contains("tls handshake")
1827 || lower.contains("certificate")
1828 || lower.contains("ssl error")
1829 {
1830 return true;
1831 }
1832
1833 if lower.contains("network is unreachable")
1835 || lower.contains("host unreachable")
1836 || lower.contains("temporary failure")
1837 {
1838 return true;
1839 }
1840
1841 if lower.contains("unexpected eof") || lower.contains("unexpected end of file") {
1843 return true;
1844 }
1845
1846 if lower.contains("circuit-breaker") || lower.contains("circuit breaker") {
1848 return true;
1849 }
1850
1851 if lower.contains("rate limit") || lower.contains("too many requests") {
1853 return true;
1854 }
1855
1856 false
1857}
1858
1859pub(crate) fn is_invalid_base_error(err_str: &str) -> bool {
1865 let lower = err_str.to_lowercase();
1866 lower.contains("422") && lower.contains("base") && lower.contains("invalid")
1867}
1868
1869pub(crate) fn is_transient_github_api_error(err_str: &str) -> bool {
1877 if !is_transient_github_error(err_str) {
1878 return false;
1879 }
1880
1881 if extract_github_http_status(err_str).is_some() {
1882 return true;
1883 }
1884
1885 let lower = err_str.to_lowercase();
1886 lower.contains("github")
1887 || lower.contains("api.github.com")
1888 || lower.contains("gh api")
1889 || lower.contains("gh pr")
1890 || lower.contains("gh error")
1891 || lower.contains("circuit-breaker")
1896 || lower.contains("circuit breaker")
1897}
1898
1899fn extract_github_http_status(err_str: &str) -> Option<u16> {
1904 let marker = "failed (";
1905 let start = err_str.find(marker)? + marker.len();
1906 let rest = &err_str[start..];
1907 let end = rest.find(')')?;
1908 rest[..end].parse().ok()
1909}
1910
1911async fn append_pr_footer_if_missing(
1916 gh: &GhHttp,
1917 repo: &str,
1918 pr_number: u64,
1919 task_id: &str,
1920 agent: &str,
1921 model: Option<&str>,
1922) {
1923 let pr = match gh.get_pr(repo, pr_number).await {
1924 Ok(pr) => pr,
1925 Err(e) => {
1926 tracing::debug!(task_id, error = %e, "failed to fetch PR for footer check");
1927 return;
1928 }
1929 };
1930
1931 let body = pr.body.unwrap_or_default();
1932 if body.contains("via [Orch]") {
1933 return; }
1935
1936 let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1937 let task_ref = format_task_ref(task_id);
1938 let footer = format!(
1939 "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1940 );
1941 let new_body = format!("{body}{footer}");
1942
1943 match gh.update_pr_body(repo, pr_number, &new_body).await {
1944 Err(e) => {
1945 tracing::warn!(task_id, error = %e, "failed to append footer to PR (non-fatal)");
1946 }
1947 _ => {
1948 tracing::info!(
1949 task_id,
1950 pr = pr_number,
1951 "appended attribution footer to agent-created PR"
1952 );
1953 }
1954 }
1955}
1956
1957pub(crate) async fn get_current_branch(dir: &Path) -> String {
1959 let output = Command::new("git")
1960 .args(["branch", "--show-current"])
1961 .current_dir(dir)
1962 .output_with_context()
1963 .await;
1964
1965 match output {
1966 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
1967 _ => String::new(),
1968 }
1969}
1970
1971pub async fn count_changed_files(dir: &Path) -> anyhow::Result<usize> {
1973 let output = Command::new("git")
1975 .args(["status", "--porcelain"])
1976 .current_dir(dir)
1977 .output_with_context()
1978 .await?;
1979
1980 let status_output = String::from_utf8_lossy(&output.stdout);
1981 let count = status_output
1982 .lines()
1983 .filter(|line| {
1984 let prefix = line.get(0..2).unwrap_or("");
1985 prefix.starts_with('M')
1987 || prefix.starts_with('A')
1988 || prefix.starts_with('R')
1989 || prefix.starts_with('C')
1990 || prefix.starts_with('?')
1991 })
1992 .count();
1993
1994 Ok(count)
1995}
1996
1997async fn check_push_needed(dir: &Path, branch: &str, default_branch: &str) -> (bool, bool) {
2005 let remote_exists = Command::new("git")
2007 .args(["rev-parse", &format!("origin/{branch}")])
2008 .current_dir(dir)
2009 .output_with_context()
2010 .await
2011 .is_ok_and(|o| o.status.success());
2012
2013 let compare_ref = if remote_exists {
2014 format!("origin/{branch}..HEAD")
2015 } else {
2016 format!("origin/{default_branch}..HEAD")
2019 };
2020
2021 tracing::debug!(
2022 branch,
2023 compare_ref,
2024 remote_exists,
2025 "checking unpushed commits"
2026 );
2027
2028 let output = Command::new("git")
2029 .args(["log", &compare_ref, "--oneline"])
2030 .current_dir(dir)
2031 .output_with_context()
2032 .await;
2033
2034 let has_unpushed = match output {
2035 Ok(o) if o.status.success() => {
2036 let out = String::from_utf8_lossy(&o.stdout);
2037 let has = !out.trim().is_empty();
2038 tracing::debug!(branch, has_unpushed = has, "unpushed commits check result");
2039 has
2040 }
2041 _ => {
2042 tracing::debug!(branch, "git log failed for compare ref, assuming unpushed");
2043 true
2046 }
2047 };
2048
2049 (remote_exists, has_unpushed)
2050}
2051
2052#[cfg(test)]
2053mod tests {
2054 use super::*;
2055
2056 #[test]
2057 fn pr_create_error_display() {
2058 let err = PrCreateError::ApiError(anyhow::anyhow!("test error"));
2059 assert_eq!(format!("{err}"), "GitHub API error: test error");
2060 }
2061
2062 #[test]
2063 fn pr_create_error_from_anyhow() {
2064 let anyhow_err = anyhow::anyhow!("source error");
2065 let pr_err: PrCreateError = anyhow_err.into();
2066 assert!(matches!(pr_err, PrCreateError::ApiError(_)));
2067 }
2068
2069 #[test]
2070 fn git_env_never_signs_with_or_without_a_token() {
2071 for env in [
2072 build_git_auth_env_for_token(None),
2073 build_git_auth_env_for_token(Some("ghp_testtoken1234".into())),
2074 ] {
2075 let get = |key: &str| env.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone());
2076 let count: usize = get("GIT_CONFIG_COUNT").unwrap().parse().unwrap();
2077 let config: Vec<(String, String)> = (0..count)
2078 .map(|n| {
2079 (
2080 get(&format!("GIT_CONFIG_KEY_{n}")).unwrap(),
2081 get(&format!("GIT_CONFIG_VALUE_{n}")).unwrap(),
2082 )
2083 })
2084 .collect();
2085 assert!(config.contains(&("commit.gpgsign".into(), "false".into())));
2086 assert!(config.contains(&("tag.gpgsign".into(), "false".into())));
2087 }
2088 }
2089
2090 #[test]
2091 fn build_git_auth_env_without_token_falls_back_to_ssh_https_conversion() {
2092 let env = build_git_auth_env_for_token(None);
2095
2096 let has_ssh_rule = env
2099 .iter()
2100 .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "git@github.com:");
2101 assert!(
2102 has_ssh_rule,
2103 "expected SSH insteadOf fallback (value is git@github.com:), got: {env:?}"
2104 );
2105 }
2106
2107 #[test]
2108 fn build_git_auth_env_with_token_covers_both_ssh_and_https() {
2109 let env = build_git_auth_env_for_token(Some("ghp_testtoken1234".into()));
2111
2112 let has_token = env
2114 .iter()
2115 .any(|(_, v)| v.contains("x-access-token:ghp_testtoken1234@github.com"));
2116 assert!(has_token, "expected token in auth URL, got: {env:?}");
2117 let has_https_rule = env
2119 .iter()
2120 .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "https://github.com/");
2121 assert!(
2122 has_https_rule,
2123 "expected HTTPS insteadOf rule, got: {env:?}"
2124 );
2125 let has_ssh_rule = env
2127 .iter()
2128 .any(|(k, v)| k == "GIT_CONFIG_VALUE_1" && v == "git@github.com:");
2129 assert!(has_ssh_rule, "expected SSH insteadOf rule, got: {env:?}");
2130 }
2131
2132 #[test]
2133 fn find_stash_ref_by_hash_returns_matching_reference() {
2134 let stash_list = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{1}\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb stash@{0}";
2135
2136 assert_eq!(
2137 find_stash_ref_by_hash(stash_list, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
2138 Some("stash@{1}".to_string())
2139 );
2140 assert_eq!(
2141 find_stash_ref_by_hash(stash_list, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
2142 Some("stash@{0}".to_string())
2143 );
2144 }
2145
2146 #[test]
2147 fn find_stash_ref_by_hash_returns_none_when_missing_or_malformed() {
2148 let stash_list = "malformed-line\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{2}";
2149
2150 assert_eq!(
2151 find_stash_ref_by_hash(stash_list, "cccccccccccccccccccccccccccccccccccccccc"),
2152 None
2153 );
2154 assert_eq!(find_stash_ref_by_hash("", "anything"), None);
2155 }
2156
2157 #[test]
2158 fn push_needs_rebase_detects_common_rejections() {
2159 assert!(push_needs_rebase(
2160 "! [rejected] branch -> branch (fetch first)"
2161 ));
2162 assert!(push_needs_rebase("non-fast-forward update was rejected"));
2163 assert!(push_needs_rebase(
2164 "push rejected because the remote contains work"
2165 ));
2166 }
2167
2168 #[test]
2169 fn is_transient_network_error_detects_lfs_locks_timeout() {
2170 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";
2171 assert!(is_transient_network_error(lfs_err));
2172 assert!(is_transient_network_error(
2173 "dial tcp 140.82.112.4:443: connect: connection refused"
2174 ));
2175 assert!(is_transient_network_error(
2176 "read tcp 10.0.0.1:51234->140.82.112.4:443: read: connection reset by peer"
2177 ));
2178 }
2179
2180 #[test]
2181 fn is_transient_network_error_rejects_ref_rejections() {
2182 assert!(!is_transient_network_error(
2183 "! [rejected] branch -> branch (fetch first)"
2184 ));
2185 assert!(!is_transient_network_error(
2186 "remote: error: GH006: Protected branch update failed"
2187 ));
2188 }
2189
2190 #[test]
2191 fn sanitize_push_error_strips_url_credentials() {
2192 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";
2194 let sanitized = sanitize_push_error(raw);
2195 assert!(!sanitized.contains("test-secret-1234567890abcdef"));
2196 assert!(sanitized.contains("://***@github.com"));
2197 assert!(sanitized.contains("i/o timeout"));
2198 }
2199
2200 #[test]
2201 fn extract_github_http_status_parses_ghhttp_error_format() {
2202 let err =
2203 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
2204 assert_eq!(extract_github_http_status(err), Some(502));
2205
2206 let err500 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (500): Internal Server Error";
2207 assert_eq!(extract_github_http_status(err500), Some(500));
2208
2209 let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
2210 assert_eq!(extract_github_http_status(err422), Some(422));
2211
2212 let not_github = "some other error";
2213 assert_eq!(extract_github_http_status(not_github), None);
2214 }
2215
2216 #[test]
2217 fn is_transient_github_error_matches_5xx_only() {
2218 let err502 =
2219 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
2220 assert!(is_transient_github_error(err502));
2221
2222 let err503 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (503): Service Unavailable";
2223 assert!(is_transient_github_error(err503));
2224
2225 let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
2226 assert!(!is_transient_github_error(err422));
2227
2228 let err404 =
2229 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (404): Not Found";
2230 assert!(!is_transient_github_error(err404));
2231 }
2232
2233 #[test]
2234 fn is_transient_github_error_matches_connection_errors() {
2235 assert!(is_transient_github_error("connection refused"));
2237 assert!(is_transient_github_error("Connection reset by peer"));
2238 assert!(is_transient_github_error("broken pipe"));
2239 assert!(is_transient_github_error("connection closed unexpectedly"));
2240 assert!(is_transient_github_error(
2241 "transport error: connection lost"
2242 ));
2243
2244 assert!(is_transient_github_error(
2246 "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"
2247 ));
2248 assert!(is_transient_github_error(
2249 "Failed to connect to github.com port 443: Connection timed out"
2250 ));
2251 assert!(is_transient_github_error(
2252 "Failed to connect to github.com port 443: Connection refused"
2253 ));
2254 assert!(is_transient_github_error(
2255 "curl: (7) Failed to connect to github.com port 443: Couldn't connect to server"
2256 ));
2257 assert!(is_transient_github_error(
2258 "fatal: unable to access 'https://github.com/owner/repo.git/': Couldn't connect to server"
2259 ));
2260 }
2261
2262 #[test]
2263 fn is_transient_github_error_matches_dns_errors() {
2264 assert!(is_transient_github_error("dns error: failed to lookup"));
2266 assert!(is_transient_github_error("failed to resolve hostname"));
2267 assert!(is_transient_github_error("name resolution failed"));
2268 assert!(is_transient_github_error("no such host: api.github.com"));
2269 }
2270
2271 #[test]
2272 fn is_transient_github_error_matches_tls_errors() {
2273 assert!(is_transient_github_error("tls handshake failed"));
2275 assert!(is_transient_github_error("certificate verification failed"));
2276 assert!(is_transient_github_error("ssl error: unknown ca"));
2277 }
2278
2279 #[test]
2280 fn is_transient_github_error_matches_network_unreachable() {
2281 assert!(is_transient_github_error("network is unreachable"));
2283 assert!(is_transient_github_error("host unreachable"));
2284 assert!(is_transient_github_error(
2285 "temporary failure in name resolution"
2286 ));
2287 }
2288
2289 #[test]
2290 fn is_transient_github_error_matches_eof_errors() {
2291 assert!(is_transient_github_error("unexpected eof while reading"));
2293 assert!(is_transient_github_error("unexpected end of file"));
2294 }
2295
2296 #[test]
2297 fn is_transient_github_error_matches_rate_limit() {
2298 assert!(is_transient_github_error("rate limit exceeded"));
2300 assert!(is_transient_github_error("429 Too Many Requests"));
2301 assert!(is_transient_github_error("too many requests, retry later"));
2302 }
2303
2304 #[test]
2305 fn is_transient_github_error_matches_timeout() {
2306 assert!(is_transient_github_error("operation timeout"));
2308 assert!(is_transient_github_error("request timeout"));
2309 }
2310
2311 #[test]
2312 fn is_transient_github_error_rejects_non_transient() {
2313 assert!(!is_transient_github_error("permission denied"));
2315 assert!(!is_transient_github_error("file not found"));
2316 assert!(!is_transient_github_error("invalid argument"));
2317 assert!(!is_transient_github_error("authentication failed"));
2318 assert!(!is_transient_github_error("bad credentials"));
2319 }
2320
2321 #[test]
2322 fn is_transient_github_api_error_requires_github_context() {
2323 assert!(is_transient_github_api_error(
2324 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway"
2325 ));
2326 assert!(is_transient_github_api_error(
2327 "gh error: transport error: connection reset by peer"
2328 ));
2329 assert!(is_transient_github_api_error(
2330 "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"
2331 ));
2332 assert!(!is_transient_github_api_error(
2333 "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)"
2334 ));
2335 assert!(!is_transient_github_api_error(
2336 "agent stream failed: failed to connect to local relay"
2337 ));
2338 }
2339
2340 #[test]
2341 fn is_transient_github_api_error_matches_circuit_breaker() {
2342 assert!(is_transient_github_api_error(
2344 "GitHub API transient 5xx circuit-breaker active for 240s"
2345 ));
2346 assert!(is_transient_github_api_error(
2348 "create PR failed: GitHub API error: GitHub API transient 5xx circuit-breaker active"
2349 ));
2350 assert!(is_transient_github_api_error(
2353 "create PR failed: circuit-breaker"
2354 ));
2355 assert!(is_transient_github_api_error(
2357 "create PR failed: circuit breaker tripped"
2358 ));
2359 }
2360
2361 #[tokio::test]
2362 async fn auto_commit_unstages_files_on_commit_failure() {
2363 let dir =
2365 std::env::temp_dir().join(format!("orch_test_auto_commit_{}", std::process::id()));
2366 let _ = std::fs::remove_dir_all(&dir);
2367 std::fs::create_dir_all(&dir).unwrap();
2368
2369 let _ = Command::new("git")
2371 .args(["init"])
2372 .current_dir(&dir)
2373 .output()
2374 .await;
2375 let _ = Command::new("git")
2376 .args(["config", "user.email", "test@test.com"])
2377 .current_dir(&dir)
2378 .output()
2379 .await;
2380 let _ = Command::new("git")
2381 .args(["config", "user.name", "Test"])
2382 .current_dir(&dir)
2383 .output()
2384 .await;
2385 std::fs::write(dir.join("init.txt"), "init").unwrap();
2387 let _ = Command::new("git")
2388 .args(["add", "init.txt"])
2389 .current_dir(&dir)
2390 .output()
2391 .await;
2392 let _ = Command::new("git")
2393 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2394 .current_dir(&dir)
2395 .output()
2396 .await;
2397
2398 std::fs::write(dir.join("new_file.txt"), "content").unwrap();
2400
2401 assert!(has_changes(&dir).await);
2403
2404 let result = auto_commit(
2409 &dir,
2410 "1",
2411 "Test title",
2412 "test",
2413 1,
2414 super::super::commit::When::Always,
2415 )
2416 .await;
2417 assert!(result.unwrap());
2418
2419 assert!(!has_changes(&dir).await);
2421
2422 let _ = std::fs::remove_dir_all(&dir);
2424 }
2425
2426 #[tokio::test]
2427 async fn auto_commit_ignores_repo_signing_config() {
2428 let dir =
2431 std::env::temp_dir().join(format!("orch_test_auto_commit_gpg_{}", std::process::id()));
2432 let _ = std::fs::remove_dir_all(&dir);
2433 std::fs::create_dir_all(&dir).unwrap();
2434
2435 for args in [
2436 vec!["init"],
2437 vec!["config", "user.email", "test@test.com"],
2438 vec!["config", "user.name", "Test"],
2439 vec!["config", "commit.gpgsign", "true"],
2440 vec!["config", "gpg.program", "/usr/bin/false"],
2441 ] {
2442 let _ = Command::new("git")
2443 .args(&args)
2444 .current_dir(&dir)
2445 .output()
2446 .await;
2447 }
2448 std::fs::write(dir.join("init.txt"), "init").unwrap();
2449 let _ = Command::new("git")
2450 .args(["add", "init.txt"])
2451 .current_dir(&dir)
2452 .output()
2453 .await;
2454 let _ = Command::new("git")
2455 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2456 .current_dir(&dir)
2457 .output()
2458 .await;
2459
2460 std::fs::write(dir.join("signed_off.txt"), "content").unwrap();
2461 assert!(has_changes(&dir).await);
2462
2463 let result = auto_commit(
2464 &dir,
2465 "3",
2466 "Test title",
2467 "test",
2468 1,
2469 super::super::commit::When::Always,
2470 )
2471 .await;
2472 assert!(result.unwrap());
2473 assert!(!has_changes(&dir).await);
2474
2475 let _ = std::fs::remove_dir_all(&dir);
2476 }
2477
2478 #[tokio::test]
2479 async fn auto_commit_unstages_on_pre_commit_hook_failure() {
2480 let dir =
2482 std::env::temp_dir().join(format!("orch_test_auto_commit_hook_{}", std::process::id()));
2483 let _ = std::fs::remove_dir_all(&dir);
2484 std::fs::create_dir_all(&dir).unwrap();
2485
2486 let _ = Command::new("git")
2488 .args(["init"])
2489 .current_dir(&dir)
2490 .output()
2491 .await;
2492 let _ = Command::new("git")
2493 .args(["config", "user.email", "test@test.com"])
2494 .current_dir(&dir)
2495 .output()
2496 .await;
2497 let _ = Command::new("git")
2498 .args(["config", "user.name", "Test"])
2499 .current_dir(&dir)
2500 .output()
2501 .await;
2502 std::fs::write(dir.join("init.txt"), "init").unwrap();
2504 let _ = Command::new("git")
2505 .args(["add", "init.txt"])
2506 .current_dir(&dir)
2507 .output()
2508 .await;
2509 let _ = Command::new("git")
2510 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2511 .current_dir(&dir)
2512 .output()
2513 .await;
2514
2515 let hooks_dir = dir.join(".git/hooks");
2517 std::fs::create_dir_all(&hooks_dir).unwrap();
2518 std::fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\nexit 1\n").unwrap();
2519 #[cfg(unix)]
2520 {
2521 use std::os::unix::fs::PermissionsExt;
2522 let mut perms = std::fs::metadata(hooks_dir.join("pre-commit"))
2523 .unwrap()
2524 .permissions();
2525 perms.set_mode(0o755);
2526 std::fs::set_permissions(hooks_dir.join("pre-commit"), perms).unwrap();
2527 }
2528
2529 std::fs::write(dir.join("staged_file.txt"), "content").unwrap();
2531
2532 assert!(has_changes(&dir).await);
2534
2535 let result = auto_commit(
2537 &dir,
2538 "2",
2539 "Test title",
2540 "test",
2541 1,
2542 super::super::commit::When::Always,
2543 )
2544 .await;
2545 assert!(!result.unwrap());
2546
2547 assert!(has_changes(&dir).await);
2550
2551 let cached = Command::new("git")
2553 .args(["diff", "--cached", "--quiet"])
2554 .current_dir(&dir)
2555 .status()
2556 .await;
2557 assert!(
2558 cached.is_ok_and(|s| s.success()),
2559 "files should be unstaged after commit failure"
2560 );
2561
2562 let _ = std::fs::remove_dir_all(&dir);
2564 }
2565
2566 #[test]
2567 fn is_workflow_scope_error_detects_backtick_format() {
2568 let err = "! [remote rejected] branch -> branch (refusing to allow an OAuth App to create or update workflow `.github/workflows/ci.yml` without `workflow` scope)";
2569 assert!(is_workflow_scope_error(err));
2570 }
2571
2572 #[test]
2573 fn is_workflow_scope_error_detects_single_quote_format() {
2574 let err = "refusing to allow an OAuth App to create or update workflow '.github/workflows/ci.yml' without 'workflow' scope";
2575 assert!(is_workflow_scope_error(err));
2576 }
2577
2578 #[test]
2579 fn is_workflow_scope_error_detects_refusing_to_allow_variant() {
2580 let err = "remote: refusing to allow a Personal Access Token to create or update workflow `.github/workflows/scan.yml` without `workflow` scope";
2581 assert!(is_workflow_scope_error(err));
2582 }
2583
2584 #[test]
2585 fn is_workflow_scope_error_rejects_unrelated_errors() {
2586 assert!(!is_workflow_scope_error("non-fast-forward"));
2587 assert!(!is_workflow_scope_error("permission denied"));
2588 assert!(!is_workflow_scope_error("authentication failed"));
2589 assert!(!is_workflow_scope_error("rejected"));
2590 }
2591
2592 #[tokio::test]
2593 async fn strip_workflow_files_removes_workflow_from_commits() {
2594 let dir = std::env::temp_dir().join(format!("orch_test_strip_wf_{}", std::process::id()));
2596 let _ = std::fs::remove_dir_all(&dir);
2597 std::fs::create_dir_all(&dir).unwrap();
2598
2599 let _ = Command::new("git")
2601 .args(["init"])
2602 .current_dir(&dir)
2603 .output()
2604 .await;
2605 let _ = Command::new("git")
2606 .args(["config", "user.email", "test@test.com"])
2607 .current_dir(&dir)
2608 .output()
2609 .await;
2610 let _ = Command::new("git")
2611 .args(["config", "user.name", "Test"])
2612 .current_dir(&dir)
2613 .output()
2614 .await;
2615 std::fs::write(dir.join("init.txt"), "init").unwrap();
2616 let _ = Command::new("git")
2617 .args(["add", "."])
2618 .current_dir(&dir)
2619 .output()
2620 .await;
2621 let _ = Command::new("git")
2622 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2623 .current_dir(&dir)
2624 .output()
2625 .await;
2626
2627 let _ = Command::new("git")
2629 .args(["remote", "add", "origin", dir.to_str().unwrap()])
2630 .current_dir(&dir)
2631 .output()
2632 .await;
2633 let _ = Command::new("git")
2634 .args(["fetch", "origin"])
2635 .current_dir(&dir)
2636 .output()
2637 .await;
2638
2639 let _ = Command::new("git")
2641 .args(["checkout", "-b", "feature"])
2642 .current_dir(&dir)
2643 .output()
2644 .await;
2645
2646 std::fs::write(dir.join("feature.txt"), "feature work").unwrap();
2648 let wf_dir = dir.join(".github/workflows");
2649 std::fs::create_dir_all(&wf_dir).unwrap();
2650 std::fs::write(wf_dir.join("ci.yml"), "name: CI\non: push").unwrap();
2651 let _ = Command::new("git")
2652 .args(["add", "."])
2653 .current_dir(&dir)
2654 .output()
2655 .await;
2656 let _ = Command::new("git")
2657 .args([
2658 "-c",
2659 "commit.gpgsign=false",
2660 "commit",
2661 "-m",
2662 "add feature and workflow",
2663 ])
2664 .current_dir(&dir)
2665 .output()
2666 .await;
2667
2668 let branch_out = Command::new("git")
2670 .args(["rev-parse", "--abbrev-ref", "origin/HEAD"])
2671 .current_dir(&dir)
2672 .output()
2673 .await;
2674 let default_branch = branch_out.ok().filter(|o| o.status.success()).map_or_else(
2676 || "main".to_string(),
2677 |o| {
2678 String::from_utf8_lossy(&o.stdout)
2679 .trim()
2680 .strip_prefix("origin/")
2681 .unwrap_or("main")
2682 .to_string()
2683 },
2684 );
2685
2686 let stripped = strip_workflow_files(&dir, &default_branch).await.unwrap();
2688 assert!(stripped, "should have stripped workflow files");
2689
2690 let ls_output = Command::new("git")
2692 .args(["ls-tree", "-r", "--name-only", "HEAD"])
2693 .current_dir(&dir)
2694 .output()
2695 .await
2696 .unwrap();
2697 let files = String::from_utf8_lossy(&ls_output.stdout);
2698 assert!(
2699 !files.contains(".github/workflows/ci.yml"),
2700 "workflow file should be removed from commits"
2701 );
2702 assert!(
2703 files.contains("feature.txt"),
2704 "non-workflow files should be preserved"
2705 );
2706
2707 let _ = std::fs::remove_dir_all(&dir);
2709 }
2710
2711 #[tokio::test]
2712 async fn strip_workflow_files_returns_false_when_no_workflows() {
2713 let dir =
2714 std::env::temp_dir().join(format!("orch_test_strip_wf_none_{}", std::process::id()));
2715 let _ = std::fs::remove_dir_all(&dir);
2716 std::fs::create_dir_all(&dir).unwrap();
2717
2718 let _ = Command::new("git")
2720 .args(["init"])
2721 .current_dir(&dir)
2722 .output()
2723 .await;
2724 let _ = Command::new("git")
2725 .args(["config", "user.email", "test@test.com"])
2726 .current_dir(&dir)
2727 .output()
2728 .await;
2729 let _ = Command::new("git")
2730 .args(["config", "user.name", "Test"])
2731 .current_dir(&dir)
2732 .output()
2733 .await;
2734 std::fs::write(dir.join("init.txt"), "init").unwrap();
2735 let _ = Command::new("git")
2736 .args(["add", "."])
2737 .current_dir(&dir)
2738 .output()
2739 .await;
2740 let _ = Command::new("git")
2741 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2742 .current_dir(&dir)
2743 .output()
2744 .await;
2745
2746 let _ = Command::new("git")
2747 .args(["remote", "add", "origin", dir.to_str().unwrap()])
2748 .current_dir(&dir)
2749 .output()
2750 .await;
2751 let _ = Command::new("git")
2752 .args(["fetch", "origin"])
2753 .current_dir(&dir)
2754 .output()
2755 .await;
2756
2757 let _ = Command::new("git")
2758 .args(["checkout", "-b", "feature2"])
2759 .current_dir(&dir)
2760 .output()
2761 .await;
2762
2763 std::fs::write(dir.join("feature.txt"), "feature").unwrap();
2765 let _ = Command::new("git")
2766 .args(["add", "."])
2767 .current_dir(&dir)
2768 .output()
2769 .await;
2770 let _ = Command::new("git")
2771 .args([
2772 "-c",
2773 "commit.gpgsign=false",
2774 "commit",
2775 "-m",
2776 "add feature only",
2777 ])
2778 .current_dir(&dir)
2779 .output()
2780 .await;
2781
2782 let stripped = strip_workflow_files(&dir, "main").await.unwrap();
2783 assert!(!stripped, "should not strip when no workflow files exist");
2784
2785 let _ = std::fs::remove_dir_all(&dir);
2787 }
2788
2789 #[test]
2790 fn is_invalid_base_error_detects_base_invalid() {
2791 assert!(is_invalid_base_error(
2792 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): {\"errors\":[{\"resource\":\"PullRequest\",\"field\":\"base\",\"code\":\"invalid\"}]}"
2793 ));
2794 assert!(is_invalid_base_error(
2795 "GitHub API POST .../pulls failed (422): Base field is invalid"
2796 ));
2797 }
2798
2799 #[test]
2800 fn is_invalid_base_error_rejects_non_base_errors() {
2801 assert!(!is_invalid_base_error(
2803 "GitHub API POST .../pulls failed (422): No commits between main and branch"
2804 ));
2805 assert!(!is_invalid_base_error(
2807 "GitHub API POST .../pulls failed (422): head is not a valid ref"
2808 ));
2809 assert!(!is_invalid_base_error(
2811 "GitHub API POST .../pulls failed (502): Bad Gateway"
2812 ));
2813 assert!(!is_invalid_base_error(
2815 "GitHub API POST .../pulls failed (404): Not Found"
2816 ));
2817 }
2818
2819 fn pr_summary(body: &str) -> &str {
2820 let rest = body
2821 .strip_prefix("## Summary\n\n")
2822 .expect("PR body starts with ## Summary");
2823 rest.split("\n\n").next().unwrap()
2824 }
2825
2826 fn assert_no_push_or_pr_creation(body: &str) {
2827 let lower = body.to_ascii_lowercase();
2828 for needle in [
2829 "git push",
2830 "gh pr create",
2831 "did not push",
2832 "couldn't push",
2833 "could not push",
2834 "push was denied",
2835 "push was blocked",
2836 "push permission",
2837 "nothing is on the remote",
2838 "open the pr",
2839 ] {
2840 assert!(
2841 !lower.contains(needle),
2842 "PR body still contains {needle:?}:\n{body}"
2843 );
2844 }
2845 }
2846
2847 #[test]
2848 fn build_pr_body_strips_push_and_pr_creation_statements() {
2849 let examples = [
2850 (
2851 concat!(
2852 "Added the lesson list page. ",
2853 "I did not push or open the PR, because the push was blocked on a permission prompt. ",
2854 "Run `git push -u origin HEAD`, then `gh pr create`.",
2855 ),
2856 "Added the lesson list page.",
2857 ),
2858 (
2859 "It is committed locally ... The push was denied, so nothing is on the remote yet.",
2860 "It is committed locally",
2861 ),
2862 (
2863 concat!(
2864 "Fixed the quiz scoring. ",
2865 "I couldn't push it because the push permission was denied.",
2866 ),
2867 "Fixed the quiz scoring.",
2868 ),
2869 ];
2870 for (summary, work) in examples {
2871 let body = build_pr_body(
2872 "Fix the widget",
2873 summary,
2874 &[
2875 "Wrote the lesson list".into(),
2876 "Could not push because the push permission was denied".into(),
2877 ],
2878 &["Run `git push -u origin HEAD`".into()],
2879 &[],
2880 "16",
2881 "claude",
2882 None,
2883 );
2884 assert_eq!(pr_summary(&body), work);
2885 assert!(body.contains("Wrote the lesson list"));
2886 assert!(!body.contains("### Remaining"));
2887 assert_no_push_or_pr_creation(&body);
2888 }
2889 }
2890
2891 #[test]
2892 fn build_pr_body_keeps_summary_without_push_text() {
2893 let summary = "Added retry logic for GitHub 5xx errors.";
2894 let accomplished = vec!["Extracted the PR body builder".into()];
2895 let remaining = vec!["Watch CI after merge".into()];
2896 let body = build_pr_body(
2897 "Fix the widget",
2898 summary,
2899 &accomplished,
2900 &remaining,
2901 &["src/engine/runner/git_ops.rs".into()],
2902 "16",
2903 "claude",
2904 Some("sonnet"),
2905 );
2906 assert_eq!(pr_summary(&body), summary);
2907 assert!(body.contains("### What was done\n\n- Extracted the PR body builder\n"));
2908 assert!(body.contains("### Remaining\n\n- Watch CI after merge\n"));
2909 assert!(body.contains("- `src/engine/runner/git_ops.rs`"));
2910 assert!(body.contains(" using `sonnet`"));
2911 }
2912
2913 fn sh_git(dir: &std::path::Path, args: &[&str]) {
2914 let out = std::process::Command::new("git")
2915 .args([
2916 "-c",
2917 "commit.gpgsign=false",
2918 "-c",
2919 "user.name=T",
2920 "-c",
2921 "user.email=t@t",
2922 ])
2923 .args(args)
2924 .current_dir(dir)
2925 .output()
2926 .unwrap();
2927 assert!(
2928 out.status.success(),
2929 "git {args:?}: {}",
2930 String::from_utf8_lossy(&out.stderr)
2931 );
2932 }
2933
2934 fn commit_file(dir: &std::path::Path, name: &str) {
2935 std::fs::write(dir.join(name), name).unwrap();
2936 sh_git(dir, &["add", name]);
2937 sh_git(dir, &["commit", "-m", name]);
2938 }
2939
2940 #[tokio::test]
2946 async fn push_branch_recovers_from_stale_lease_after_remote_moved() {
2947 let temp = tempfile::TempDir::new().unwrap();
2948 let bare = temp.path().join("origin.git");
2949 let a = temp.path().join("a");
2950 let b = temp.path().join("b");
2951 std::fs::create_dir_all(&bare).unwrap();
2952 sh_git(&bare, &["init", "--bare", "-b", "main"]);
2953 sh_git(temp.path(), &["clone", bare.to_str().unwrap(), "a"]);
2954 sh_git(&a, &["config", "user.name", "T"]);
2956 sh_git(&a, &["config", "user.email", "t@t"]);
2957 commit_file(&a, "base.txt");
2958 sh_git(&a, &["push", "origin", "main"]);
2959 sh_git(&a, &["checkout", "-b", "feat"]);
2960 commit_file(&a, "feature.txt");
2961 sh_git(&a, &["push", "-u", "origin", "feat"]);
2962
2963 sh_git(temp.path(), &["clone", bare.to_str().unwrap(), "b"]);
2964 commit_file(&b, "main-change.txt");
2965 sh_git(&b, &["push", "origin", "main"]);
2966 sh_git(&b, &["checkout", "feat"]);
2967 commit_file(&b, "update-branch.txt");
2968 sh_git(&b, &["push", "origin", "feat"]);
2969
2970 sh_git(&a, &["fetch", "origin", "main"]);
2972 sh_git(&a, &["rebase", "origin/main"]);
2973
2974 assert!(push_branch(&a, "feat", "main").await.unwrap());
2975
2976 sh_git(&b, &["fetch", "origin", "feat"]);
2977 sh_git(&b, &["checkout", "origin/feat"]);
2978 for name in [
2979 "base.txt",
2980 "feature.txt",
2981 "main-change.txt",
2982 "update-branch.txt",
2983 ] {
2984 assert!(b.join(name).exists(), "remote feat lost {name}");
2985 }
2986 }
2987
2988 #[test]
2989 fn stale_lease_error_matches_git_messages() {
2990 assert!(is_stale_lease_error(
2991 "! [rejected] feat -> feat (stale info)"
2992 ));
2993 assert!(is_stale_lease_error(
2994 "! [rejected] feat -> feat (remote ref updated since checkout)"
2995 ));
2996 assert!(!is_stale_lease_error(
2997 "! [rejected] feat -> feat (fetch first)"
2998 ));
2999 }
3000
3001 fn git_ok(dir: &std::path::Path, args: &[&str]) {
3002 let out = std::process::Command::new("git")
3003 .args(args)
3004 .current_dir(dir)
3005 .output()
3006 .unwrap();
3007 assert!(
3008 out.status.success(),
3009 "git {} failed: {}",
3010 args.join(" "),
3011 String::from_utf8_lossy(&out.stderr)
3012 );
3013 }
3014
3015 fn git_sha(dir: &std::path::Path, rev: &str) -> String {
3016 let out = std::process::Command::new("git")
3017 .args(["rev-parse", rev])
3018 .current_dir(dir)
3019 .output()
3020 .unwrap();
3021 assert!(out.status.success(), "git rev-parse {rev} failed");
3022 String::from_utf8_lossy(&out.stdout).trim().to_string()
3023 }
3024
3025 fn init_identity(dir: &std::path::Path) {
3026 git_ok(dir, &["config", "user.email", "test@example.com"]);
3027 git_ok(dir, &["config", "user.name", "Test"]);
3028 git_ok(dir, &["config", "commit.gpgsign", "false"]);
3029 }
3030
3031 fn write_commit(dir: &std::path::Path, file: &str, body: &str, msg: &str) {
3032 std::fs::write(dir.join(file), body).unwrap();
3033 git_ok(dir, &["add", file]);
3034 git_ok(dir, &["commit", "-m", msg]);
3035 }
3036
3037 fn publish_feature(root: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
3039 let bare = root.join("bare.git");
3040 let a = root.join("a");
3041 git_ok(root, &["init", "-b", "main", a.to_str().unwrap()]);
3042 init_identity(&a);
3043 write_commit(&a, "README.md", "base\n", "base");
3044 git_ok(
3045 root,
3046 &["init", "--bare", "-b", "main", bare.to_str().unwrap()],
3047 );
3048 git_ok(&a, &["remote", "add", "origin", bare.to_str().unwrap()]);
3049 git_ok(&a, &["push", "-u", "origin", "main"]);
3050 git_ok(&a, &["checkout", "-b", "feature"]);
3051 write_commit(&a, "feature.txt", "feature\n", "feature");
3052 git_ok(&a, &["push", "-u", "origin", "feature"]);
3053 (bare, a)
3054 }
3055
3056 fn clone_on(
3057 root: &std::path::Path,
3058 bare: &std::path::Path,
3059 name: &str,
3060 branch: &str,
3061 ) -> std::path::PathBuf {
3062 let dir = root.join(name);
3063 git_ok(
3064 root,
3065 &["clone", bare.to_str().unwrap(), dir.to_str().unwrap()],
3066 );
3067 init_identity(&dir);
3068 if branch != "main" {
3069 git_ok(
3070 &dir,
3071 &["checkout", "-b", branch, &format!("origin/{branch}")],
3072 );
3073 }
3074
3075 dir
3076 }
3077
3078 #[tokio::test]
3079 async fn leased_force_push_does_not_replace_a_newer_remote_tip() {
3080 let root = tempfile::tempdir().unwrap();
3081 let (bare, a) = publish_feature(root.path());
3082 let b = clone_on(root.path(), &bare, "b", "feature");
3083 record_push_lease(&b, "feature", "main").await;
3084 let leased = git_sha(&b, "refs/remotes/origin/feature");
3085
3086 write_commit(&a, "feature.txt", "from a\n", "from a");
3087 git_ok(&a, &["push", "origin", "feature"]);
3088 let newer = git_sha(&a, "HEAD");
3089
3090 git_ok(&b, &["fetch", "origin"]);
3091 write_commit(&b, "feature.txt", "from b\n", "from b");
3092 let pushed = push_branch(&b, "feature", "main").await;
3093 assert!(
3094 pushed.is_err(),
3095 "force push should be rejected, got {pushed:?}"
3096 );
3097 assert_eq!(git_sha(&bare, "refs/heads/feature"), newer);
3098
3099 record_push_lease(&b, "feature", "main").await;
3101 assert_eq!(read_push_lease(&b, "feature").await, Some(leased));
3102 let again = push_branch(&b, "feature", "main").await;
3103 assert!(
3104 again.is_err(),
3105 "second force push should stay rejected, got {again:?}"
3106 );
3107 assert_eq!(git_sha(&bare, "refs/heads/feature"), newer);
3108 }
3109
3110 #[tokio::test]
3111 async fn leased_force_push_allows_rewrite_of_the_recorded_tip() {
3112 let root = tempfile::tempdir().unwrap();
3113 let (bare, a) = publish_feature(root.path());
3114 record_push_lease(&a, "feature", "main").await;
3115 std::fs::write(a.join("feature.txt"), "rewritten\n").unwrap();
3116 git_ok(&a, &["commit", "-am", "--amend", "-m", "feature rewritten"]);
3117
3118 let pushed = push_branch(&a, "feature", "main").await;
3119 assert!(
3120 pushed.is_ok(),
3121 "rewrite of the leased tip should push, got {pushed:?}"
3122 );
3123 assert_eq!(git_sha(&bare, "refs/heads/feature"), git_sha(&a, "HEAD"));
3124 assert_eq!(
3125 read_push_lease(&a, "feature").await,
3126 Some(git_sha(&a, "HEAD"))
3127 );
3128 }
3129
3130 #[tokio::test]
3131 async fn push_after_update_branch_merge_force_pushes_the_rebased_branch() {
3132 let root = tempfile::tempdir().unwrap();
3133 let (bare, a) = publish_feature(root.path());
3134 record_push_lease(&a, "feature", "main").await;
3135
3136 let c = clone_on(root.path(), &bare, "c", "main");
3138 write_commit(&c, "main.txt", "main\n", "main moves");
3139 git_ok(&c, &["push", "origin", "main"]);
3140 git_ok(&c, &["checkout", "-b", "feature", "origin/feature"]);
3141 git_ok(
3142 &c,
3143 &[
3144 "merge",
3145 "--no-ff",
3146 "-m",
3147 "Merge main into feature",
3148 "origin/main",
3149 ],
3150 );
3151 git_ok(&c, &["push", "origin", "feature"]);
3152
3153 rebase_on_default(&a, "main").await;
3154 write_commit(&a, "feature.txt", "review fix\n", "review fix");
3155 let pushed = push_branch(&a, "feature", "main").await;
3156 assert!(
3157 pushed.is_ok(),
3158 "push after update-branch should succeed, got {pushed:?}"
3159 );
3160 assert_eq!(git_sha(&bare, "refs/heads/feature"), git_sha(&a, "HEAD"));
3161 }
3162
3163 #[tokio::test]
3164 async fn agent_push_moves_the_lease_to_a_tip_head_contains() {
3165 let root = tempfile::tempdir().unwrap();
3166 let (_bare, a) = publish_feature(root.path());
3167 record_push_lease(&a, "feature", "main").await;
3168
3169 write_commit(&a, "feature.txt", "agent step\n", "agent step");
3170 git_ok(&a, &["push", "origin", "feature"]);
3171 record_push_lease(&a, "feature", "main").await;
3172 assert_eq!(
3173 read_push_lease(&a, "feature").await,
3174 Some(git_sha(&a, "HEAD"))
3175 );
3176 }
3177
3178 #[tokio::test]
3179 async fn record_push_lease_skips_the_default_branch() {
3180 let root = tempfile::tempdir().unwrap();
3181 let (_bare, a) = publish_feature(root.path());
3182 git_ok(&a, &["checkout", "main"]);
3183 record_push_lease(&a, "main", "main").await;
3184 assert_eq!(read_push_lease(&a, "main").await, None);
3185 }
3186
3187 fn github_update_by_merge(root: &std::path::Path, bare: &std::path::Path, name: &str) {
3189 let c = clone_on(root, bare, name, "main");
3190 write_commit(&c, "main.txt", "main\n", "main moves");
3191 git_ok(&c, &["push", "origin", "main"]);
3192 git_ok(&c, &["checkout", "-b", "feature", "origin/feature"]);
3193 git_ok(
3194 &c,
3195 &[
3196 "merge",
3197 "--no-ff",
3198 "-m",
3199 "Merge main into feature",
3200 "origin/main",
3201 ],
3202 );
3203 git_ok(&c, &["push", "origin", "feature"]);
3204 }
3205
3206 fn github_update_by_rebase(
3208 root: &std::path::Path,
3209 bare: &std::path::Path,
3210 name: &str,
3211 ) -> std::path::PathBuf {
3212 let c = clone_on(root, bare, name, "main");
3213 write_commit(&c, "main.txt", "main\n", "main moves");
3214 git_ok(&c, &["push", "origin", "main"]);
3215 git_ok(&c, &["checkout", "-b", "feature", "origin/feature"]);
3216 git_ok(&c, &["rebase", "origin/main"]);
3217 git_ok(&c, &["push", "--force", "origin", "feature"]);
3218 c
3219 }
3220
3221 fn remote_has(root: &std::path::Path, bare: &std::path::Path, file: &str) -> bool {
3222 let check = root.join("check");
3223 if !check.exists() {
3224 git_ok(
3225 root,
3226 &["clone", bare.to_str().unwrap(), check.to_str().unwrap()],
3227 );
3228 }
3229
3230 git_ok(&check, &["fetch", "origin"]);
3231 git_ok(&check, &["checkout", "--detach", "origin/feature"]);
3232 check.join(file).exists()
3233 }
3234
3235 #[tokio::test]
3236 async fn push_after_update_branch_rebase_force_pushes_the_rebased_branch() {
3237 let root = tempfile::tempdir().unwrap();
3238 let (bare, a) = publish_feature(root.path());
3239 record_push_lease(&a, "feature", "main").await;
3240 github_update_by_rebase(root.path(), &bare, "c");
3241
3242 rebase_on_default(&a, "main").await;
3243 write_commit(&a, "fix.txt", "review fix\n", "review fix");
3244 let pushed = push_branch(&a, "feature", "main").await;
3245 assert!(
3246 pushed.is_ok(),
3247 "push after a rebase update should succeed, got {pushed:?}"
3248 );
3249 assert_eq!(git_sha(&bare, "refs/heads/feature"), git_sha(&a, "HEAD"));
3250 }
3251
3252 #[tokio::test]
3253 async fn rebase_update_with_a_foreign_commit_keeps_the_foreign_commit() {
3254 let root = tempfile::tempdir().unwrap();
3255 let (bare, a) = publish_feature(root.path());
3256 record_push_lease(&a, "feature", "main").await;
3257 let c = github_update_by_rebase(root.path(), &bare, "c");
3258 write_commit(&c, "other.txt", "other machine\n", "other machine");
3259 git_ok(&c, &["push", "origin", "feature"]);
3260
3261 rebase_on_default(&a, "main").await;
3262 write_commit(&a, "fix.txt", "review fix\n", "review fix");
3263 let _ = push_branch(&a, "feature", "main").await;
3264 assert!(
3265 remote_has(root.path(), &bare, "other.txt"),
3266 "the other machine's commit was dropped"
3267 );
3268 }
3269
3270 #[tokio::test]
3271 async fn merge_of_a_non_default_branch_is_not_a_github_update() {
3272 let root = tempfile::tempdir().unwrap();
3273 let (bare, a) = publish_feature(root.path());
3274 record_push_lease(&a, "feature", "main").await;
3275 let c = clone_on(root.path(), &bare, "c", "feature");
3276 git_ok(&c, &["checkout", "-b", "side", "origin/main"]);
3277 write_commit(&c, "side.txt", "side\n", "side work");
3278 git_ok(&c, &["checkout", "feature"]);
3279 git_ok(&c, &["merge", "--no-ff", "-m", "Merge side", "side"]);
3280 git_ok(&c, &["push", "origin", "feature"]);
3281
3282 rebase_on_default(&a, "main").await;
3283 write_commit(&a, "fix.txt", "review fix\n", "review fix");
3284 let _ = push_branch(&a, "feature", "main").await;
3285 assert!(
3286 remote_has(root.path(), &bare, "side.txt"),
3287 "the merged side branch was dropped"
3288 );
3289 }
3290
3291 #[tokio::test]
3292 async fn update_merge_on_top_of_a_foreign_push_keeps_the_foreign_commit() {
3293 let root = tempfile::tempdir().unwrap();
3294 let (bare, a) = publish_feature(root.path());
3295 record_push_lease(&a, "feature", "main").await;
3296 let c = clone_on(root.path(), &bare, "c", "feature");
3297 write_commit(&c, "other.txt", "other machine\n", "other machine");
3298 git_ok(&c, &["push", "origin", "feature"]);
3299 github_update_by_merge(root.path(), &bare, "d");
3300
3301 rebase_on_default(&a, "main").await;
3302 write_commit(&a, "fix.txt", "review fix\n", "review fix");
3303 let _ = push_branch(&a, "feature", "main").await;
3304 assert!(
3305 remote_has(root.path(), &bare, "other.txt"),
3306 "the other machine's commit was dropped"
3307 );
3308 }
3309
3310 #[tokio::test]
3311 async fn force_push_leased_accepts_github_updates() {
3312 for by_rebase in [false, true] {
3313 let root = tempfile::tempdir().unwrap();
3314 let (bare, a) = publish_feature(root.path());
3315 record_push_lease(&a, "feature", "main").await;
3316 if by_rebase {
3317 github_update_by_rebase(root.path(), &bare, "c");
3318 } else {
3319 github_update_by_merge(root.path(), &bare, "c");
3320 }
3321
3322 git_ok(&a, &["fetch", "origin"]);
3323 git_ok(&a, &["rebase", "origin/main"]);
3324 let pushed = force_push_leased(&a, "feature", "main").await;
3325 assert!(pushed.is_ok(), "by_rebase={by_rebase}: {pushed:?}");
3326 assert_eq!(git_sha(&bare, "refs/heads/feature"), git_sha(&a, "HEAD"));
3327 }
3328 }
3329
3330 #[tokio::test]
3331 async fn force_push_leased_refuses_a_foreign_push() {
3332 let root = tempfile::tempdir().unwrap();
3333 let (bare, a) = publish_feature(root.path());
3334 record_push_lease(&a, "feature", "main").await;
3335 let c = clone_on(root.path(), &bare, "c", "feature");
3336 write_commit(&c, "other.txt", "other machine\n", "other machine");
3337 git_ok(&c, &["push", "origin", "feature"]);
3338 let foreign = git_sha(&c, "HEAD");
3339
3340 git_ok(&a, &["fetch", "origin"]);
3341 record_push_lease(&a, "feature", "main").await;
3342 std::fs::write(a.join("feature.txt"), "rewritten\n").unwrap();
3343 git_ok(&a, &["commit", "-am", "--amend", "-m", "rewritten"]);
3344 assert!(force_push_leased(&a, "feature", "main").await.is_err());
3345 assert_eq!(git_sha(&bare, "refs/heads/feature"), foreign);
3346 }
3347
3348 #[tokio::test]
3349 async fn force_push_leased_refuses_a_branch_created_after_an_absent_lease() {
3350 let root = tempfile::tempdir().unwrap();
3351 let (bare, a) = publish_feature(root.path());
3352 git_ok(&a, &["checkout", "-b", "fresh", "main"]);
3353 record_push_lease(&a, "fresh", "main").await;
3354 assert_eq!(
3355 read_push_lease(&a, "fresh").await.as_deref(),
3356 Some(LEASE_ABSENT)
3357 );
3358
3359 let c = clone_on(root.path(), &bare, "c", "main");
3360 git_ok(&c, &["checkout", "-b", "fresh"]);
3361 write_commit(&c, "other.txt", "other machine\n", "other machine");
3362 git_ok(&c, &["push", "origin", "fresh"]);
3363 let foreign = git_sha(&c, "HEAD");
3364
3365 git_ok(&a, &["fetch", "origin"]);
3366 write_commit(&a, "mine.txt", "mine\n", "mine");
3367 assert!(force_push_leased(&a, "fresh", "main").await.is_err());
3368 assert_eq!(git_sha(&bare, "refs/heads/fresh"), foreign);
3369 }
3370
3371 #[tokio::test]
3372 async fn first_push_records_the_pushed_head_as_the_lease() {
3373 let root = tempfile::tempdir().unwrap();
3374 let (_bare, a) = publish_feature(root.path());
3375 git_ok(&a, &["checkout", "-b", "fresh", "main"]);
3376 write_commit(&a, "mine.txt", "mine\n", "mine");
3377 assert!(push_branch(&a, "fresh", "main").await.unwrap());
3378 assert_eq!(
3379 read_push_lease(&a, "fresh").await,
3380 Some(git_sha(&a, "HEAD"))
3381 );
3382 }
3383
3384 #[tokio::test]
3385 async fn missing_lease_falls_back_to_the_fetched_tip_and_counts_it() {
3386 let root = tempfile::tempdir().unwrap();
3387 let (_bare, a) = publish_feature(root.path());
3388 let before = LEASE_FALLBACKS.load(Ordering::Relaxed);
3389 let expected = expected_force_sha(&a, "feature", "main").await.unwrap();
3390 assert_eq!(expected, git_sha(&a, "refs/remotes/origin/feature"));
3391 assert!(LEASE_FALLBACKS.load(Ordering::Relaxed) > before);
3392 }
3393
3394 #[tokio::test]
3395 async fn agent_push_then_foreign_push_keeps_the_agent_tip_as_lease() {
3396 let root = tempfile::tempdir().unwrap();
3397 let (bare, a) = publish_feature(root.path());
3398 record_push_lease(&a, "feature", "main").await;
3399 write_commit(&a, "step.txt", "agent step\n", "agent step");
3400 git_ok(&a, &["push", "origin", "feature"]);
3401 let agent_tip = git_sha(&a, "HEAD");
3402
3403 let c = clone_on(root.path(), &bare, "c", "feature");
3404 write_commit(&c, "other.txt", "other machine\n", "other machine");
3405 git_ok(&c, &["push", "origin", "feature"]);
3406
3407 record_push_lease(&a, "feature", "main").await;
3409 git_ok(&a, &["fetch", "origin"]);
3410 record_push_lease(&a, "feature", "main").await;
3411 assert_eq!(read_push_lease(&a, "feature").await, Some(agent_tip));
3412 let foreign = git_sha(&bare, "refs/heads/feature");
3413 git_ok(&a, &["commit", "--amend", "-m", "agent step rewritten"]);
3414 assert!(force_push_leased(&a, "feature", "main").await.is_err());
3415 assert_eq!(git_sha(&bare, "refs/heads/feature"), foreign);
3416 }
3417
3418 #[tokio::test]
3419 async fn branch_without_remote_and_head_without_twins_are_not_github_updates() {
3420 let root = tempfile::tempdir().unwrap();
3421 let (_bare, a) = publish_feature(root.path());
3422 let lease = git_sha(&a, "HEAD");
3423 let main = git_sha(&a, "origin/main");
3424 assert!(!is_github_branch_update(&a, &main, &lease, "main").await);
3426 assert!(
3427 !is_github_branch_update(
3428 &a,
3429 "0000000000000000000000000000000000000000",
3430 &lease,
3431 "main"
3432 )
3433 .await
3434 );
3435 }
3436}