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