1use crate::cmd::CommandErrorContext;
11use crate::github::http::GhHttp;
12use regex::Regex;
13use std::path::Path;
14use std::sync::LazyLock;
15
16use tokio::process::Command;
17
18pub(crate) fn format_task_ref(task_id: &str) -> String {
24 if task_id.starts_with("internal:") {
25 task_id.replace(':', "-")
26 } else {
27 format!("#{task_id}")
28 }
29}
30
31#[derive(Debug, thiserror::Error)]
33pub enum PrCreateError {
34 #[error("GitHub API error: {0}")]
35 ApiError(#[from] anyhow::Error),
36}
37
38pub type PrCreateResult<T> = Result<T, PrCreateError>;
40
41pub async fn has_commits_ahead(dir: &Path, default_branch: &str) -> bool {
43 let output = Command::new("git")
44 .args([
45 "rev-list",
46 "--count",
47 &format!("origin/{default_branch}..HEAD"),
48 ])
49 .current_dir(dir)
50 .output()
51 .await;
52 match output {
53 Ok(o) if o.status.success() => {
54 let count: u32 = String::from_utf8_lossy(&o.stdout)
55 .trim()
56 .parse()
57 .unwrap_or(0);
58 count > 0
59 }
60 _ => {
61 tracing::debug!(
62 default_branch,
63 "git rev-list failed for compare ref, assuming commits ahead"
64 );
65 true
69 }
70 }
71}
72
73pub async fn has_changes(dir: &Path) -> bool {
75 let diff = Command::new("git")
77 .args(["diff", "--quiet"])
78 .current_dir(dir)
79 .status()
80 .await;
81
82 let cached = Command::new("git")
83 .args(["diff", "--cached", "--quiet"])
84 .current_dir(dir)
85 .status()
86 .await;
87
88 let untracked = Command::new("git")
89 .args(["ls-files", "--others", "--exclude-standard"])
90 .current_dir(dir)
91 .output_with_context()
92 .await;
93
94 let has_diff = diff.map(|s| !s.success()).unwrap_or(false);
95 let has_cached = cached.map(|s| !s.success()).unwrap_or(false);
96 let has_untracked = untracked
97 .map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
98 .unwrap_or(false);
99
100 has_diff || has_cached || has_untracked
101}
102
103pub(crate) fn find_stash_ref_by_hash(stash_list: &str, stash_hash: &str) -> Option<String> {
104 stash_list.lines().find_map(|line| {
105 let mut parts = line.split_whitespace();
106 match (parts.next(), parts.next()) {
107 (Some(hash), Some(reference)) if hash == stash_hash => Some(reference.to_string()),
108 _ => None,
109 }
110 })
111}
112
113pub(crate) struct StashRebaseOpts {
115 pub disable_gpg_signing: bool,
117 pub abort_on_failure: bool,
121}
122
123impl Default for StashRebaseOpts {
124 fn default() -> Self {
125 Self {
126 disable_gpg_signing: false,
127 abort_on_failure: true,
128 }
129 }
130}
131
132#[derive(Debug)]
134pub(crate) enum RebaseOutcome {
135 Succeeded,
137 Failed(String),
141 Skipped(String),
144}
145
146pub(crate) async fn stash_rebase_restore(
163 dir: &Path,
164 target_ref: &str,
165 opts: StashRebaseOpts,
166) -> anyhow::Result<RebaseOutcome> {
167 let stash_ref: Option<String> = if has_changes(dir).await {
168 let stash_out = Command::new("git")
169 .args(["stash", "push", "--include-untracked", "-m", "orch-rebase"])
170 .current_dir(dir)
171 .output_with_context()
172 .await;
173
174 match stash_out {
175 Ok(o) if o.status.success() => {
176 let stdout_preview = String::from_utf8_lossy(&o.stdout)
177 .lines()
178 .next()
179 .unwrap_or("")
180 .to_string();
181 tracing::debug!(
182 dir = %dir.display(),
183 stdout = %stdout_preview,
184 "git stash push succeeded before rebase"
185 );
186
187 let ref_out = Command::new("git")
191 .args(["rev-parse", "refs/stash@{0}"])
192 .current_dir(dir)
193 .output_with_context()
194 .await;
195 let stash_hash = ref_out
196 .ok()
197 .filter(|o| o.status.success())
198 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
199 .filter(|s| !s.is_empty());
200
201 if stash_hash.is_none() {
202 tracing::warn!(
205 dir = %dir.display(),
206 "git stash succeeded but rev-parse failed — popping stash and skipping rebase"
207 );
208 let _ = Command::new("git")
209 .args(["stash", "pop"])
210 .current_dir(dir)
211 .output_with_context()
212 .await;
213 return Ok(RebaseOutcome::Skipped(
214 "stash OID capture failed".to_string(),
215 ));
216 }
217 stash_hash
218 }
219 Ok(o) => {
220 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
221 tracing::warn!(
222 dir = %dir.display(),
223 code = ?o.status.code(),
224 stderr = %stderr,
225 "git stash push failed — skipping rebase to preserve dirty state"
226 );
227 return Ok(RebaseOutcome::Skipped(format!(
228 "stash push failed: {stderr}"
229 )));
230 }
231 Err(e) => {
232 tracing::warn!(
233 dir = %dir.display(),
234 error = %e,
235 "failed to run git stash push — skipping rebase to preserve dirty state"
236 );
237 return Ok(RebaseOutcome::Skipped(format!("stash push error: {e}")));
238 }
239 }
240 } else {
241 None
242 };
243
244 let mut rebase_args: Vec<&str> = Vec::new();
245 if opts.disable_gpg_signing {
246 rebase_args.extend(["-c", "commit.gpgsign=false"]);
247 }
248 rebase_args.extend(["rebase", target_ref]);
249
250 let output = Command::new("git")
251 .args(&rebase_args)
252 .current_dir(dir)
253 .output_with_context()
254 .await;
255
256 match output {
257 Ok(o) if o.status.success() => {
258 tracing::debug!(dir = %dir.display(), target_ref, "rebase succeeded");
259 restore_stash_by_hash(dir, stash_ref.as_deref()).await;
260 Ok(RebaseOutcome::Succeeded)
261 }
262 Ok(o) => {
263 let stderr = String::from_utf8_lossy(&o.stderr).to_string();
264 tracing::warn!(dir = %dir.display(), target_ref, err = %stderr, "rebase failed");
265 if opts.abort_on_failure {
266 let abort = Command::new("git")
267 .args(["rebase", "--abort"])
268 .current_dir(dir)
269 .output_with_context()
270 .await;
271 match abort {
272 Err(e) => {
273 tracing::warn!(dir = %dir.display(), error = %e, "git rebase --abort failed — worktree may be in inconsistent state")
274 }
275 Ok(o) if !o.status.success() => {
276 let stderr = String::from_utf8_lossy(&o.stderr);
277 tracing::warn!(dir = %dir.display(), stderr = %stderr, "git rebase --abort returned non-zero — worktree may be in inconsistent state");
278 }
279 _ => {}
280 }
281 }
282 restore_stash_by_hash(dir, stash_ref.as_deref()).await;
283 Ok(RebaseOutcome::Failed(stderr))
284 }
285 Err(e) => {
286 if opts.abort_on_failure {
287 let abort = Command::new("git")
288 .args(["rebase", "--abort"])
289 .current_dir(dir)
290 .output_with_context()
291 .await;
292 match abort {
293 Err(e) => {
294 tracing::warn!(dir = %dir.display(), error = %e, "git rebase --abort failed — worktree may be in inconsistent state")
295 }
296 Ok(o) if !o.status.success() => {
297 let stderr = String::from_utf8_lossy(&o.stderr);
298 tracing::warn!(dir = %dir.display(), stderr = %stderr, "git rebase --abort returned non-zero — worktree may be in inconsistent state");
299 }
300 _ => {}
301 }
302 }
303 restore_stash_by_hash(dir, stash_ref.as_deref()).await;
304 Err(e)
305 }
306 }
307}
308
309async fn restore_stash_by_hash(dir: &Path, stash_hash: Option<&str>) {
313 let Some(stash_hash) = stash_hash else {
314 return;
315 };
316
317 let apply = Command::new("git")
318 .args(["stash", "apply", stash_hash])
319 .current_dir(dir)
320 .output_with_context()
321 .await;
322 if apply.map(|o| o.status.success()).unwrap_or(false) {
323 let list = Command::new("git")
324 .args(["stash", "list", "--format=%H %gd"])
325 .current_dir(dir)
326 .output_with_context()
327 .await;
328 if let Ok(list_out) = list {
329 if list_out.status.success() {
330 let list_str = String::from_utf8_lossy(&list_out.stdout);
331 if let Some(stash_ref) = find_stash_ref_by_hash(&list_str, stash_hash) {
332 match Command::new("git")
333 .args(["stash", "drop", &stash_ref])
334 .current_dir(dir)
335 .output_with_context()
336 .await
337 {
338 Ok(drop_out) if !drop_out.status.success() => {
339 let stderr = String::from_utf8_lossy(&drop_out.stderr);
340 tracing::warn!(
341 dir = %dir.display(),
342 stash_ref = %stash_ref,
343 err = %stderr,
344 "stash drop failed after apply — entry may remain on stack"
345 );
346 }
347 Err(e) => {
348 tracing::warn!(
349 dir = %dir.display(),
350 stash_ref = %stash_ref,
351 error = %e,
352 "stash drop command failed after apply — entry may remain on stack"
353 );
354 }
355 _ => {}
356 }
357 } else {
358 tracing::warn!(
359 dir = %dir.display(),
360 stash = %stash_hash,
361 "stash applied but could not find ref by hash — stash entry may be orphaned on stack"
362 );
363 }
364 }
365 }
366 } else {
367 tracing::warn!(
368 dir = %dir.display(),
369 stash = %stash_hash,
370 "stash apply failed after rebase — stash preserved for manual recovery"
371 );
372 }
373}
374
375fn git_identity(agent: &str) -> (String, String) {
377 let name = crate::config::get("git.name").unwrap_or_else(|_| format!("{agent}[bot]"));
378 let email = crate::config::get("git.email")
379 .unwrap_or_else(|_| format!("{agent}[bot]@users.noreply.github.com"));
380 (name, email)
381}
382
383pub async fn auto_commit(
385 dir: &Path,
386 task_id: &str,
387 title: &str,
388 agent: &str,
389 attempt: u32,
390) -> anyhow::Result<bool> {
391 if !has_changes(dir).await {
393 return Ok(false);
394 }
395
396 tracing::info!(task_id, "auto-committing uncommitted changes");
397
398 let task_ref = format_task_ref(task_id);
399 let commit_msg = format!("{title}\n\nTask {task_ref}\nAgent: {agent}\nAttempt: {attempt}");
400
401 let add = Command::new("git")
403 .args(["add", "-A"])
404 .current_dir(dir)
405 .output_with_context()
406 .await?;
407
408 if !add.status.success() {
409 tracing::warn!(task_id, "git add -A failed");
410 return Ok(false);
411 }
412
413 let (git_name, git_email) = git_identity(agent);
415 let commit = Command::new("git")
416 .args(["-c", "commit.gpgsign=false", "commit", "-m", &commit_msg])
418 .env("GIT_AUTHOR_NAME", &git_name)
419 .env("GIT_COMMITTER_NAME", &git_name)
420 .env("GIT_AUTHOR_EMAIL", &git_email)
421 .env("GIT_COMMITTER_EMAIL", &git_email)
422 .current_dir(dir)
423 .output_with_context()
424 .await?;
425
426 if !commit.status.success() {
427 let stderr = String::from_utf8_lossy(&commit.stderr);
428 tracing::warn!(task_id, err = %stderr, "git commit failed");
429 let restore = Command::new("git")
432 .args(["restore", "--staged", "."])
433 .current_dir(dir)
434 .output_with_context()
435 .await;
436 match restore {
437 Ok(o) if o.status.success() => {}
438 Ok(o) => {
439 let restore_stderr = String::from_utf8_lossy(&o.stderr).to_string();
440 tracing::warn!(
441 task_id,
442 stderr = %restore_stderr,
443 "git restore --staged failed after commit failure; falling back to git reset HEAD -- ."
444 );
445 let reset = Command::new("git")
446 .args(["reset", "HEAD", "--", "."])
447 .current_dir(dir)
448 .output_with_context()
449 .await?;
450 if !reset.status.success() {
451 anyhow::bail!(
452 "git commit failed and cleanup failed (git restore --staged + git reset HEAD -- .): commit stderr: {}; restore stderr: {}; reset stderr: {}",
453 stderr.trim(),
454 restore_stderr.trim(),
455 String::from_utf8_lossy(&reset.stderr).trim()
456 );
457 }
458 }
459 Err(e) => {
460 tracing::warn!(
461 task_id,
462 error = %e,
463 "git restore --staged command failed after commit failure; falling back to git reset HEAD -- ."
464 );
465 let reset = Command::new("git")
466 .args(["reset", "HEAD", "--", "."])
467 .current_dir(dir)
468 .output_with_context()
469 .await?;
470 if !reset.status.success() {
471 anyhow::bail!(
472 "git commit failed and cleanup failed (git restore --staged command error + git reset HEAD -- .): commit stderr: {}; restore error: {}; reset stderr: {}",
473 stderr.trim(),
474 e,
475 String::from_utf8_lossy(&reset.stderr).trim()
476 );
477 }
478 }
479 }
480 return Ok(false);
481 }
482
483 tracing::info!(task_id, "auto-commit succeeded");
484 Ok(true)
485}
486
487const MAX_REBASE_COMMITS: usize = 50;
495
496pub async fn rebase_on_branch(dir: &Path, branch: &str) -> anyhow::Result<bool> {
506 let origin_branch = format!("origin/{branch}");
507
508 let fetch = tokio::time::timeout(
511 std::time::Duration::from_secs(120),
512 Command::new("git")
513 .args(["fetch", "origin", branch])
514 .kill_on_drop(true)
515 .current_dir(dir)
516 .output(),
517 )
518 .await
519 .map_err(|_| anyhow::anyhow!("git fetch timed out after 120s"))
520 .and_then(|r| r.map_err(Into::into));
521
522 match fetch {
523 Ok(o) if o.status.success() => {}
524 Ok(o) => {
525 let stderr = String::from_utf8_lossy(&o.stderr);
526 anyhow::bail!("failed to fetch origin/{branch}: {stderr}");
527 }
528 Err(e) => anyhow::bail!("failed to fetch origin/{branch}: {e}"),
529 }
530
531 let count_output = Command::new("git")
533 .args(["rev-list", "--count", &format!("{origin_branch}..HEAD")])
534 .current_dir(dir)
535 .output_with_context()
536 .await;
537
538 let commit_count: usize = match count_output {
539 Ok(o) if o.status.success() => {
540 let raw = String::from_utf8_lossy(&o.stdout);
541 raw.trim().parse().map_err(|e| {
542 anyhow::anyhow!(
543 "failed to parse commit count from git rev-list output {:?}: {e}",
544 raw.trim()
545 )
546 })?
547 }
548 Ok(o) => {
549 let stderr = String::from_utf8_lossy(&o.stderr);
550 anyhow::bail!("git rev-list --count failed: {stderr}");
551 }
552 Err(e) => anyhow::bail!("failed to run git rev-list --count: {e}"),
553 };
554
555 if commit_count == 0 {
556 return Ok(false);
558 }
559
560 if commit_count > MAX_REBASE_COMMITS {
561 anyhow::bail!(
562 "refusing to rebase {commit_count} commits on origin/{branch} (max {})",
563 MAX_REBASE_COMMITS
564 );
565 }
566
567 tracing::info!(
568 branch = branch,
569 commit_count,
570 "rebasing local commits on origin/{branch}"
571 );
572
573 match stash_rebase_restore(dir, &origin_branch, StashRebaseOpts::default()).await? {
574 RebaseOutcome::Succeeded => Ok(true),
575 RebaseOutcome::Failed(e) => anyhow::bail!("rebase failed: {e}"),
576 RebaseOutcome::Skipped(reason) => anyhow::bail!("rebase skipped: {reason}"),
577 }
578}
579
580pub async fn rebase_on_default(dir: &Path, default_branch: &str) {
588 if rebase_in_progress(dir).await {
590 tracing::info!("rebase already in progress, skipping pre-dispatch rebase");
591 return;
592 }
593
594 let _ = Command::new("git")
597 .args(["fetch", "origin"])
598 .current_dir(dir)
599 .output_with_context()
600 .await;
601
602 let commit_count = Command::new("git")
607 .args([
608 "rev-list",
609 "--count",
610 &format!("origin/{default_branch}..HEAD"),
611 ])
612 .current_dir(dir)
613 .output_with_context()
614 .await
615 .ok()
616 .and_then(|o| {
617 String::from_utf8_lossy(&o.stdout)
618 .trim()
619 .parse::<usize>()
620 .ok()
621 })
622 .unwrap_or(0);
623
624 if commit_count > MAX_REBASE_COMMITS {
625 tracing::warn!(
626 default_branch,
627 commit_count,
628 max = MAX_REBASE_COMMITS,
629 "skipping rebase: branch has too many commits to replay safely"
630 );
631 return;
632 }
633
634 match stash_rebase_restore(
635 dir,
636 &format!("origin/{default_branch}"),
637 StashRebaseOpts::default(),
638 )
639 .await
640 {
641 Ok(RebaseOutcome::Succeeded) => {
642 tracing::debug!(default_branch, "rebased worktree on default branch")
643 }
644 Ok(RebaseOutcome::Failed(e)) => {
645 tracing::warn!(default_branch, err = %e, "rebase failed, continuing with current state")
646 }
647 Ok(RebaseOutcome::Skipped(reason)) => {
648 tracing::warn!(default_branch, %reason, "stash failed before rebase, continuing with current state")
649 }
650 Err(e) => tracing::warn!(err = %e, "rebase error"),
651 }
652}
653
654async fn rebase_in_progress(dir: &Path) -> bool {
655 for name in ["rebase-merge", "rebase-apply"] {
656 let out = Command::new("git")
657 .args(["rev-parse", "--git-path", name])
658 .current_dir(dir)
659 .output_with_context()
660 .await;
661 if let Ok(o) = out {
662 let p = String::from_utf8_lossy(&o.stdout).trim().to_string();
663 if !p.is_empty() && dir.join(p).exists() {
664 return true;
665 }
666 }
667 }
668 false
669}
670
671pub(crate) async fn build_git_auth_env() -> Vec<(String, String)> {
690 let token = crate::github::token::shared()
691 .get_token()
692 .await
693 .ok()
694 .flatten();
695 build_git_auth_env_for_token(token)
696}
697
698fn build_git_auth_env_for_token(token: Option<String>) -> Vec<(String, String)> {
699 match token {
700 Some(t) if !t.is_empty() => {
701 let authed = format!("url.https://x-access-token:{t}@github.com/.insteadOf");
702 vec![
703 ("GIT_CONFIG_COUNT".into(), "2".into()),
704 ("GIT_CONFIG_KEY_0".into(), authed.clone()),
705 ("GIT_CONFIG_VALUE_0".into(), "https://github.com/".into()),
706 ("GIT_CONFIG_KEY_1".into(), authed),
707 ("GIT_CONFIG_VALUE_1".into(), "git@github.com:".into()),
708 ]
709 }
710 _ => {
711 vec![
712 ("GIT_CONFIG_COUNT".into(), "1".into()),
713 (
714 "GIT_CONFIG_KEY_0".into(),
715 "url.https://github.com/.insteadOf".into(),
716 ),
717 ("GIT_CONFIG_VALUE_0".into(), "git@github.com:".into()),
718 ]
719 }
720 }
721}
722
723pub(crate) fn is_workflow_scope_error(stderr: &str) -> bool {
730 stderr.contains("without `workflow` scope")
731 || stderr.contains("without 'workflow' scope")
732 || (stderr.contains("refusing to allow")
733 && stderr.contains("workflow")
734 && stderr.contains("scope"))
735}
736
737async fn strip_workflow_files(dir: &Path, default_branch: &str) -> anyhow::Result<bool> {
748 let output = Command::new("git")
750 .args([
751 "diff",
752 "--name-only",
753 &format!("origin/{default_branch}...HEAD"),
754 "--",
755 ".github/workflows/",
756 ])
757 .current_dir(dir)
758 .output_with_context()
759 .await?;
760
761 let workflow_files: Vec<String> = String::from_utf8_lossy(&output.stdout)
762 .lines()
763 .filter(|l| !l.is_empty())
764 .map(|s| s.to_string())
765 .collect();
766
767 if workflow_files.is_empty() {
768 return Ok(false);
769 }
770
771 tracing::info!(
772 files = ?workflow_files,
773 "stripping workflow files from commits to bypass missing workflow scope"
774 );
775
776 let count_output = Command::new("git")
781 .args([
782 "rev-list",
783 "--count",
784 &format!("origin/{default_branch}..HEAD"),
785 ])
786 .current_dir(dir)
787 .output_with_context()
788 .await?;
789
790 let commit_count: usize = String::from_utf8_lossy(&count_output.stdout)
791 .trim()
792 .parse()
793 .unwrap_or(0);
794
795 if commit_count == 0 {
796 return Ok(false);
797 }
798
799 let log_output = Command::new("git")
804 .args([
805 "log",
806 "--format=%x00%H%n%B",
807 &format!("origin/{default_branch}..HEAD"),
808 ])
809 .current_dir(dir)
810 .output_with_context()
811 .await?;
812 let log_text = String::from_utf8_lossy(&log_output.stdout).to_string();
813
814 let commits: Vec<(String, String)> = log_text
818 .split('\0')
819 .filter(|s| !s.trim().is_empty())
820 .map(|block| {
821 let block = block.trim();
822 let first_nl = block.find('\n').unwrap_or(block.len());
823 let hash = block[..first_nl].to_string();
824 let msg = block[first_nl..].trim().to_string();
825 (hash, msg)
826 })
827 .collect::<Vec<_>>()
828 .into_iter()
829 .rev()
830 .collect();
831
832 if commits.is_empty() {
833 return Ok(false);
834 }
835
836 let reset = Command::new("git")
838 .args(["reset", "--soft", &format!("origin/{default_branch}")])
839 .current_dir(dir)
840 .output_with_context()
841 .await?;
842
843 if !reset.status.success() {
844 let stderr = String::from_utf8_lossy(&reset.stderr);
845 anyhow::bail!("git reset --soft failed during workflow file stripping: {stderr}");
846 }
847
848 let mut rm_args = vec!["rm", "--cached", "--ignore-unmatch", "--"];
850 let file_refs: Vec<&str> = workflow_files.iter().map(|s| s.as_str()).collect();
851 rm_args.extend(file_refs);
852
853 let rm = Command::new("git")
854 .args(&rm_args)
855 .current_dir(dir)
856 .output_with_context()
857 .await?;
858
859 if !rm.status.success() {
860 let stderr = String::from_utf8_lossy(&rm.stderr);
861 tracing::warn!("git rm --cached for workflow files failed (non-fatal): {stderr}");
862 }
863
864 let has_staged = Command::new("git")
867 .args(["diff", "--cached", "--quiet"])
868 .current_dir(dir)
869 .status()
870 .await
871 .map(|s| !s.success())
872 .unwrap_or(false);
873
874 if !has_staged {
875 tracing::warn!(
876 "after removing workflow files, no staged changes remain — \
877 the only changes were workflow files"
878 );
879 if let Some((hash, _)) = commits.last() {
882 let _ = Command::new("git")
883 .args(["reset", "--soft", hash])
884 .current_dir(dir)
885 .output_with_context()
886 .await;
887 }
888 return Ok(false);
889 }
890
891 let commit_msg = if commits.len() == 1 {
893 commits[0].1.clone()
894 } else {
895 let mut combined = String::new();
897 for (i, (_, msg)) in commits.iter().enumerate() {
898 if i > 0 {
899 combined.push_str("\n\n");
900 }
901 combined.push_str(msg);
902 }
903 combined.push_str("\n\n[workflow files stripped: token lacks `workflow` scope]");
904 combined
905 };
906
907 let (git_name, git_email) = git_identity("orchestrator");
908 let commit = Command::new("git")
909 .args(["-c", "commit.gpgsign=false", "commit", "-m", &commit_msg])
911 .env("GIT_AUTHOR_NAME", &git_name)
912 .env("GIT_COMMITTER_NAME", &git_name)
913 .env("GIT_AUTHOR_EMAIL", &git_email)
914 .env("GIT_COMMITTER_EMAIL", &git_email)
915 .current_dir(dir)
916 .output_with_context()
917 .await?;
918
919 if !commit.status.success() {
920 let stderr = String::from_utf8_lossy(&commit.stderr);
921 if let Some((original_head, _)) = commits.last() {
923 let _ = Command::new("git")
924 .args(["reset", "--hard", original_head])
925 .current_dir(dir)
926 .output_with_context()
927 .await;
928 }
929 anyhow::bail!("git commit failed during workflow file stripping: {stderr}");
930 }
931
932 tracing::info!(
933 stripped_files = ?workflow_files,
934 original_commits = commit_count,
935 "successfully stripped workflow files from commits"
936 );
937
938 Ok(true)
939}
940
941pub async fn push_branch(dir: &Path, branch: &str, default_branch: &str) -> anyhow::Result<bool> {
943 let current = get_current_branch(dir).await;
944 let branch_to_push = if !current.is_empty() {
945 ¤t
946 } else {
947 branch
948 };
949
950 if branch_to_push.is_empty() {
952 anyhow::bail!("cannot push: branch name is empty");
953 }
954
955 if branch_to_push == "main" || branch_to_push == "master" {
957 return Ok(false);
958 }
959
960 let (remote_exists, has_unpushed) =
962 check_push_needed(dir, branch_to_push, default_branch).await;
963 if remote_exists && !has_unpushed {
964 tracing::debug!(
968 branch = branch_to_push,
969 "remote branch exists and is up to date, skipping push"
970 );
971 return Ok(true);
972 }
973 if !remote_exists && !has_unpushed {
974 tracing::debug!(
980 branch = branch_to_push,
981 "remote branch missing with no new commits — pushing to create it on remote"
982 );
983 }
984
985 tracing::info!(branch = branch_to_push, "pushing branch");
986
987 let auth_env = build_git_auth_env().await;
988
989 let output = tokio::time::timeout(
996 std::time::Duration::from_secs(120),
997 Command::new("git")
998 .args([
999 "-c",
1000 "lfs.locksverify=false",
1001 "push",
1002 "-u",
1003 "origin",
1004 branch_to_push,
1005 ])
1006 .kill_on_drop(true)
1007 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1008 .current_dir(dir)
1009 .output(),
1010 )
1011 .await
1012 .map_err(|_| anyhow::anyhow!("git push timed out after 120s"))??;
1013
1014 if output.status.success() {
1015 tracing::info!(branch = branch_to_push, "push succeeded");
1016 return Ok(true);
1017 }
1018
1019 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1020
1021 if is_transient_network_error(&stderr) {
1024 tracing::warn!(
1025 branch = branch_to_push,
1026 stderr = %sanitize_push_error(&stderr),
1027 "push failed with transient network error — retrying once"
1028 );
1029 let retry_output = tokio::time::timeout(
1030 std::time::Duration::from_secs(120),
1031 Command::new("git")
1032 .args([
1033 "-c",
1034 "lfs.locksverify=false",
1035 "push",
1036 "-u",
1037 "origin",
1038 branch_to_push,
1039 ])
1040 .kill_on_drop(true)
1041 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1042 .current_dir(dir)
1043 .output(),
1044 )
1045 .await
1046 .map_err(|_| anyhow::anyhow!("git push (network retry) timed out after 120s"))??;
1047
1048 if retry_output.status.success() {
1049 tracing::info!(branch = branch_to_push, "push succeeded on network retry");
1050 return Ok(true);
1051 }
1052
1053 let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
1054 .trim()
1055 .to_string();
1056 tracing::warn!(
1057 branch = branch_to_push,
1058 stderr = %sanitize_push_error(&retry_stderr),
1059 "push retry also failed"
1060 );
1061 anyhow::bail!("push failed: {}", sanitize_push_error(&retry_stderr));
1062 }
1063
1064 if is_workflow_scope_error(&stderr) {
1068 tracing::warn!(
1069 branch = branch_to_push,
1070 "push rejected: token lacks `workflow` scope — stripping workflow files and retrying"
1071 );
1072
1073 match strip_workflow_files(dir, default_branch).await {
1074 Ok(true) => {
1075 let retry_output = tokio::time::timeout(
1077 std::time::Duration::from_secs(120),
1078 Command::new("git")
1079 .args([
1080 "-c",
1081 "lfs.locksverify=false",
1082 "push",
1083 "-u",
1084 "origin",
1085 branch_to_push,
1086 ])
1087 .kill_on_drop(true)
1088 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1089 .current_dir(dir)
1090 .output(),
1091 )
1092 .await
1093 .map_err(|_| anyhow::anyhow!("git push (retry) timed out after 120s"))??;
1094
1095 if retry_output.status.success() {
1096 tracing::info!(
1097 branch = branch_to_push,
1098 "push succeeded after stripping workflow files"
1099 );
1100 return Ok(true);
1101 }
1102
1103 let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
1104 .trim()
1105 .to_string();
1106
1107 if is_workflow_scope_error(&retry_stderr) {
1109 anyhow::bail!(
1110 "push failed: token lacks `workflow` OAuth scope and workflow files \
1111 could not be fully stripped — add `workflow` scope to your GitHub \
1112 token or use a GitHub App for authentication"
1113 );
1114 }
1115
1116 anyhow::bail!("push failed after stripping workflow files: {retry_stderr}");
1117 }
1118 Ok(false) => {
1119 anyhow::bail!(
1121 "push failed: token lacks `workflow` OAuth scope — add `workflow` scope \
1122 to your GitHub token or use a GitHub App for authentication. \
1123 Error: {stderr}"
1124 );
1125 }
1126 Err(e) => {
1127 tracing::error!(error = %e, "failed to strip workflow files from commits");
1128 anyhow::bail!(
1129 "push failed: token lacks `workflow` OAuth scope and automatic \
1130 recovery failed ({e}) — add `workflow` scope to your GitHub token \
1131 or use a GitHub App for authentication"
1132 );
1133 }
1134 }
1135 }
1136
1137 if push_needs_rebase(&stderr) && remote_exists {
1142 tracing::info!(
1143 branch = branch_to_push,
1144 "push rejected (non-fast-forward), force-pushing with lease"
1145 );
1146 let output = Command::new("git")
1147 .args([
1148 "-c",
1149 "lfs.locksverify=false",
1150 "push",
1151 "--force-with-lease",
1152 "-u",
1153 "origin",
1154 branch_to_push,
1155 ])
1156 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1157 .current_dir(dir)
1158 .output_with_context()
1159 .await?;
1160
1161 if output.status.success() {
1162 tracing::info!(branch = branch_to_push, "force push succeeded");
1163 return Ok(true);
1164 }
1165
1166 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1167
1168 if is_workflow_scope_error(&stderr) {
1170 tracing::warn!(
1171 branch = branch_to_push,
1172 "force push also rejected due to workflow scope — stripping and retrying"
1173 );
1174 match strip_workflow_files(dir, default_branch).await {
1175 Ok(true) => {
1176 let retry_output = Command::new("git")
1177 .args([
1178 "-c",
1179 "lfs.locksverify=false",
1180 "push",
1181 "--force-with-lease",
1182 "-u",
1183 "origin",
1184 branch_to_push,
1185 ])
1186 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1187 .current_dir(dir)
1188 .output_with_context()
1189 .await?;
1190
1191 if retry_output.status.success() {
1192 tracing::info!(
1193 branch = branch_to_push,
1194 "force push succeeded after stripping workflow files"
1195 );
1196 return Ok(true);
1197 }
1198
1199 let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
1200 .trim()
1201 .to_string();
1202 anyhow::bail!(
1203 "force push failed after stripping workflow files: {retry_stderr}"
1204 );
1205 }
1206 Ok(false) => {
1207 anyhow::bail!(
1208 "force push failed: token lacks `workflow` OAuth scope — \
1209 add `workflow` scope to your GitHub token or use a GitHub App. \
1210 Error: {stderr}"
1211 );
1212 }
1213 Err(e) => {
1214 anyhow::bail!(
1215 "force push failed: token lacks `workflow` scope and recovery \
1216 failed ({e})"
1217 );
1218 }
1219 }
1220 }
1221
1222 if is_stale_lease_error(&stderr) && rebase_on_branch(dir, branch_to_push).await? {
1225 let output = Command::new("git")
1226 .args([
1227 "-c",
1228 "lfs.locksverify=false",
1229 "push",
1230 "-u",
1231 "origin",
1232 branch_to_push,
1233 ])
1234 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1235 .current_dir(dir)
1236 .output_with_context()
1237 .await?;
1238 if output.status.success() {
1239 tracing::info!(
1240 branch = branch_to_push,
1241 "push succeeded after rebasing on the moved remote branch"
1242 );
1243 return Ok(true);
1244 }
1245
1246 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1247 anyhow::bail!("push failed after rebasing on the moved remote branch: {stderr}");
1248 }
1249
1250 if is_stale_lease_error(&stderr) {
1251 tracing::info!(
1252 branch = branch_to_push,
1253 "remote branch already holds every local commit, nothing to push"
1254 );
1255 return Ok(true);
1256 }
1257
1258 anyhow::bail!("force push failed: {stderr}");
1259 }
1260
1261 anyhow::bail!("push failed: {stderr}")
1262}
1263
1264fn push_needs_rebase(stderr: &str) -> bool {
1265 let lower = stderr.to_ascii_lowercase();
1266 lower.contains("non-fast-forward")
1267 || lower.contains("rejected")
1268 || lower.contains("fetch first")
1269 || lower.contains("behind")
1270}
1271
1272fn is_stale_lease_error(stderr: &str) -> bool {
1274 let lower = stderr.to_ascii_lowercase();
1275 lower.contains("stale info") || lower.contains("remote ref updated since checkout")
1276}
1277
1278fn is_transient_network_error(stderr: &str) -> bool {
1283 let lower = stderr.to_ascii_lowercase();
1284 lower.contains("i/o timeout")
1285 || lower.contains("dial tcp")
1286 || lower.contains("connection reset")
1287 || lower.contains("connection refused")
1288 || lower.contains("no route to host")
1289 || lower.contains("temporary failure in name resolution")
1290 || lower.contains("eof")
1291}
1292
1293fn sanitize_push_error(stderr: &str) -> String {
1297 static URL_USERINFO: LazyLock<Regex> =
1298 LazyLock::new(|| Regex::new(r"://[^/@\s]+@").expect("valid url userinfo regex"));
1299 let no_userinfo = URL_USERINFO.replace_all(stderr, "://***@");
1300 crate::security::redact(&no_userinfo)
1301}
1302
1303fn strip_push_and_pr_creation(text: &str) -> String {
1306 let mut kept_lines: Vec<String> = Vec::new();
1307 for line in text.lines() {
1308 if line.trim().is_empty() {
1309 if kept_lines.last().is_some_and(|l| !l.is_empty()) {
1310 kept_lines.push(String::new());
1311 }
1312 continue;
1313 }
1314
1315 let indent_len = line.len() - line.trim_start().len();
1316 let indent = &line[..indent_len];
1317 let trimmed = line.trim();
1318 let (marker, rest) = match list_marker(trimmed) {
1319 Some((m, r)) => (m, r),
1320 None => ("", trimmed),
1321 };
1322
1323 let kept: Vec<&str> = split_statements(rest)
1324 .into_iter()
1325 .filter(|s| !is_push_or_pr_creation_statement(s))
1326 .collect();
1327 if kept.is_empty() {
1328 continue;
1329 }
1330 kept_lines.push(format!("{indent}{marker}{}", kept.join(" ")));
1331 }
1332 while kept_lines.last().is_some_and(String::is_empty) {
1333 kept_lines.pop();
1334 }
1335 kept_lines.join("\n")
1336}
1337
1338fn split_statements(text: &str) -> Vec<&str> {
1339 static SEP: LazyLock<Regex> =
1340 LazyLock::new(|| Regex::new(r"(\.{2,}|(?:[.!?]))\s+").expect("valid statement separator"));
1341 let mut statements = Vec::new();
1342 let mut last = 0;
1343 for cap in SEP.captures_iter(text) {
1344 let full = cap.get(0).expect("full separator match");
1345 let punct = cap.get(1).expect("punctuation capture");
1346 let ellipsis = punct.as_str().len() >= 2 && punct.as_str().bytes().all(|b| b == b'.');
1347 let end = if ellipsis { punct.start() } else { punct.end() };
1348 let stmt = text[last..end].trim();
1349 if !stmt.is_empty() {
1350 statements.push(stmt);
1351 }
1352 last = full.end();
1353 }
1354 let rest = text[last..].trim();
1355 if !rest.is_empty() {
1356 statements.push(rest);
1357 }
1358 statements
1359}
1360
1361fn list_marker(line: &str) -> Option<(&str, &str)> {
1362 for prefix in ["- ", "* ", "+ "] {
1363 if let Some(rest) = line.strip_prefix(prefix) {
1364 return Some((prefix, rest));
1365 }
1366 }
1367 let dot = line.find(". ")?;
1368 if !line[..dot].is_empty() && line[..dot].bytes().all(|b| b.is_ascii_digit()) {
1369 Some((&line[..dot + 2], &line[dot + 2..]))
1370 } else {
1371 None
1372 }
1373}
1374
1375fn is_push_or_pr_creation_statement(text: &str) -> bool {
1376 static PAT: LazyLock<Regex> = LazyLock::new(|| {
1377 Regex::new(concat!(
1378 r"(?i)",
1379 r"\bgit\s+push\b",
1380 r"|\bgh\s+pr\s+create\b",
1381 r"|\bgh\s+pr\s+open\b",
1382 r"|\bpush\s+(was\s+)?(denied|blocked|rejected)\b",
1383 r"|\bpush\s+permission\b",
1384 r"|\bcouldn['’]?t\s+push\b",
1385 r"|\bcould\s+not\s+push\b",
1386 r"|\bcan['’]?t\s+push\b",
1387 r"|\bcannot\s+push\b",
1388 r"|\bdidn['’]?t\s+push\b",
1389 r"|\bdid\s+not\s+push\b",
1390 r"|\bnothing\s+is\s+on\s+the\s+remote\b",
1391 r"|\bnot\s+on\s+the\s+remote\b",
1392 r"|\bopen(?:ing)?\s+(?:the|a)\s+pr\b",
1393 r"|\bcreat(?:e|ing)\s+(?:the|a)\s+pr\b",
1394 r"|\bopen(?:ing)?\s+(?:the|a)\s+pull\s+request\b",
1395 r"|\bcreat(?:e|ing)\s+(?:the|a)\s+pull\s+request\b",
1396 ))
1397 .expect("valid push/pr-creation regex")
1398 });
1399 PAT.is_match(text)
1400}
1401
1402fn cleaned_pr_items(items: &[String]) -> Vec<String> {
1403 items
1404 .iter()
1405 .filter_map(|item| {
1406 let cleaned = strip_push_and_pr_creation(item);
1407 (!cleaned.is_empty()).then_some(cleaned)
1408 })
1409 .collect()
1410}
1411
1412#[allow(clippy::too_many_arguments)]
1414fn build_pr_body(
1415 title: &str,
1416 summary: &str,
1417 accomplished: &[String],
1418 remaining: &[String],
1419 files: &[String],
1420 task_id: &str,
1421 agent: &str,
1422 model: Option<&str>,
1423) -> String {
1424 let cleaned_summary = strip_push_and_pr_creation(summary);
1425 let summary_text = if cleaned_summary.is_empty() {
1426 title
1427 } else {
1428 cleaned_summary.as_str()
1429 };
1430 let accomplished = cleaned_pr_items(accomplished);
1431 let remaining = cleaned_pr_items(remaining);
1432
1433 let mut body = format!("## Summary\n\n{summary_text}");
1434
1435 if !accomplished.is_empty() {
1436 body.push_str("\n\n### What was done\n\n");
1437 for item in &accomplished {
1438 body.push_str(&format!("- {item}\n"));
1439 }
1440 }
1441
1442 if !remaining.is_empty() {
1443 body.push_str("\n\n### Remaining\n\n");
1444 for item in &remaining {
1445 body.push_str(&format!("- {item}\n"));
1446 }
1447 }
1448
1449 if !files.is_empty() {
1450 body.push_str("\n\n### Files changed\n\n");
1451 for file in files {
1452 body.push_str(&format!("- `{file}`\n"));
1453 }
1454 }
1455
1456 if !task_id.starts_with("internal:") {
1457 if let Ok(issue_num) = task_id.parse::<u64>() {
1458 body.push_str(&format!("\n\nCloses #{issue_num}"));
1459 }
1460 }
1461
1462 let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1463 let task_ref = format_task_ref(task_id);
1464 body.push_str(&format!(
1465 "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1466 ));
1467 body
1468}
1469
1470#[allow(clippy::too_many_arguments)]
1479pub async fn create_pr_if_needed(
1480 dir: &Path,
1481 branch: &str,
1482 title: &str,
1483 summary: &str,
1484 accomplished: &[String],
1485 remaining: &[String],
1486 files: &[String],
1487 task_id: &str,
1488 agent: &str,
1489 model: Option<&str>,
1490 repo: &str,
1491 base_branch: &str,
1492) -> PrCreateResult<String> {
1493 let gh = GhHttp::new()?;
1494
1495 match gh.get_pr_number(repo, branch).await {
1497 Ok(Some(pr_number)) => {
1498 tracing::info!(task_id, pr = pr_number, "PR already exists");
1499 append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model).await;
1501 let pr = match gh.get_pr(repo, pr_number).await {
1503 Ok(pr) => pr,
1504 Err(e) => {
1505 tracing::warn!(task_id, error = %e, "get_pr API call failed after detecting existing PR");
1506 return Err(PrCreateError::ApiError(e));
1507 }
1508 };
1509 return Ok(pr.html_url);
1510 }
1511 Ok(None) => {
1512 }
1514 Err(e) => {
1515 tracing::warn!(task_id, error = %e, "get_pr_number API call failed");
1516 return Err(PrCreateError::ApiError(e));
1517 }
1518 }
1519
1520 let git_files: Vec<String>;
1522 let effective_files: &[String] = if files.is_empty() {
1523 match tokio::process::Command::new("git")
1524 .args([
1525 "diff",
1526 &format!("origin/{base_branch}...HEAD"),
1527 "--name-only",
1528 ])
1529 .current_dir(dir)
1530 .output()
1531 .await
1532 {
1533 Ok(output) if output.status.success() => {
1534 git_files = String::from_utf8_lossy(&output.stdout)
1535 .lines()
1536 .filter(|l| !l.is_empty())
1537 .map(|l| l.to_string())
1538 .collect();
1539 &git_files
1540 }
1541 _ => files,
1542 }
1543 } else {
1544 files
1545 };
1546
1547 let body = build_pr_body(
1548 title,
1549 summary,
1550 accomplished,
1551 remaining,
1552 effective_files,
1553 task_id,
1554 agent,
1555 model,
1556 );
1557
1558 let pr_title = title;
1560
1561 const MAX_RETRIES: u32 = 3;
1563 let mut last_error: Option<anyhow::Error> = None;
1564 let mut created_url: Option<String> = None;
1565 let mut had_transient_error = false;
1566 let mut effective_base = base_branch.to_string();
1567 let mut tried_api_fallback = false;
1568
1569 for attempt in 1..=MAX_RETRIES {
1570 match gh
1571 .create_pr(repo, pr_title, &body, branch, &effective_base)
1572 .await
1573 {
1574 Ok(u) => {
1575 created_url = Some(u);
1576 break;
1577 }
1578 Err(e) => {
1579 let err_str = format!("{e}");
1580 if is_transient_github_error(&err_str) {
1581 had_transient_error = true;
1582 if attempt < MAX_RETRIES {
1583 let delay = std::time::Duration::from_secs(2u64.pow(attempt));
1584 tracing::warn!(
1585 task_id,
1586 attempt,
1587 delay_secs = delay.as_secs(),
1588 error = %e,
1589 "transient GitHub API error during PR creation — retrying"
1590 );
1591 tokio::time::sleep(delay).await;
1592 }
1593 } else if is_invalid_base_error(&err_str) && !tried_api_fallback {
1594 tried_api_fallback = true;
1597 tracing::warn!(
1598 task_id,
1599 base_branch = %effective_base,
1600 error = %e,
1601 "PR creation failed with invalid base — fetching default branch from GitHub API"
1602 );
1603 match gh.get_default_branch(repo).await {
1604 Ok(api_default) if api_default != effective_base => {
1605 tracing::info!(
1606 task_id,
1607 old_base = %effective_base,
1608 new_base = %api_default,
1609 "retrying PR creation with GitHub API default branch"
1610 );
1611 effective_base = api_default;
1612 continue;
1613 }
1614 Ok(api_default) => {
1615 tracing::warn!(
1616 task_id,
1617 base_branch = %api_default,
1618 "GitHub API default branch matches local — no fallback possible"
1619 );
1620 }
1621 Err(api_err) => {
1622 tracing::warn!(
1623 task_id,
1624 error = %api_err,
1625 "failed to fetch default branch from GitHub API"
1626 );
1627 }
1628 }
1629 }
1630 last_error = Some(e);
1631 }
1632 }
1633 }
1634
1635 let url = match created_url {
1636 Some(u) => u,
1637 None => {
1638 let e = last_error.unwrap_or_else(|| {
1639 anyhow::anyhow!("PR creation failed after retries (no error captured)")
1640 });
1641 let err_str = format!("{e}");
1642 if is_transient_github_error(&err_str) || had_transient_error {
1649 tracing::warn!(
1650 task_id,
1651 error = %e,
1652 "transient GitHub API error after all retries — checking if PR was actually created"
1653 );
1654 match gh.get_pr_number(repo, branch).await {
1655 Ok(Some(pr_number)) => {
1656 tracing::info!(
1657 task_id,
1658 pr = pr_number,
1659 "PR was created despite transient errors — recovering"
1660 );
1661 append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model)
1662 .await;
1663 match gh.get_pr(repo, pr_number).await {
1664 Ok(pr) => pr.html_url,
1665 Err(get_err) => {
1666 tracing::warn!(
1667 task_id,
1668 error = %get_err,
1669 "failed to fetch PR URL after transient-error recovery"
1670 );
1671 return Err(PrCreateError::ApiError(e));
1672 }
1673 }
1674 }
1675 Ok(None) => {
1676 tracing::warn!(
1677 task_id,
1678 "PR was not created after all retries — propagating original error"
1679 );
1680 return Err(PrCreateError::ApiError(e));
1681 }
1682 Err(check_err) => {
1683 tracing::warn!(
1684 task_id,
1685 error = %check_err,
1686 "failed to verify PR existence after transient error retries — propagating original error"
1687 );
1688 return Err(PrCreateError::ApiError(e));
1689 }
1690 }
1691 } else {
1692 return Err(PrCreateError::ApiError(e));
1693 }
1694 }
1695 };
1696
1697 tracing::info!(task_id, pr_url = %url, "created PR via GhHttp API");
1698
1699 Ok(url)
1700}
1701
1702pub(crate) fn is_transient_github_error(err_str: &str) -> bool {
1706 if err_str.contains("HTTP send failed") {
1710 return true;
1711 }
1712 if err_str.contains("GitHub API server error") {
1713 return true;
1714 }
1715
1716 if extract_github_http_status(err_str)
1718 .map(|s| (500..600).contains(&s))
1719 .unwrap_or(false)
1720 {
1721 return true;
1722 }
1723
1724 let lower = err_str.to_lowercase();
1726
1727 if lower.contains("connection reset")
1729 || lower.contains("broken pipe")
1730 || lower.contains("connection refused")
1731 || lower.contains("connection closed")
1732 || lower.contains("transport error")
1733 || lower.contains("failed to connect")
1734 || lower.contains("failed to connect to github.com port")
1735 || lower.contains("curl: (7) failed to connect")
1736 || lower.contains("couldn't connect to server")
1737 || lower.contains("could not connect to server")
1738 {
1739 return true;
1740 }
1741
1742 if lower.contains("timeout") {
1744 return true;
1745 }
1746
1747 if lower.contains("dns error")
1749 || lower.contains("resolve")
1750 || lower.contains("name resolution")
1751 || lower.contains("no such host")
1752 {
1753 return true;
1754 }
1755
1756 if lower.contains("tls handshake")
1758 || lower.contains("certificate")
1759 || lower.contains("ssl error")
1760 {
1761 return true;
1762 }
1763
1764 if lower.contains("network is unreachable")
1766 || lower.contains("host unreachable")
1767 || lower.contains("temporary failure")
1768 {
1769 return true;
1770 }
1771
1772 if lower.contains("unexpected eof") || lower.contains("unexpected end of file") {
1774 return true;
1775 }
1776
1777 if lower.contains("circuit-breaker") || lower.contains("circuit breaker") {
1779 return true;
1780 }
1781
1782 if lower.contains("rate limit") || lower.contains("too many requests") {
1784 return true;
1785 }
1786
1787 false
1788}
1789
1790pub(crate) fn is_invalid_base_error(err_str: &str) -> bool {
1796 let lower = err_str.to_lowercase();
1797 lower.contains("422") && lower.contains("base") && lower.contains("invalid")
1798}
1799
1800pub(crate) fn is_transient_github_api_error(err_str: &str) -> bool {
1808 if !is_transient_github_error(err_str) {
1809 return false;
1810 }
1811
1812 if extract_github_http_status(err_str).is_some() {
1813 return true;
1814 }
1815
1816 let lower = err_str.to_lowercase();
1817 lower.contains("github")
1818 || lower.contains("api.github.com")
1819 || lower.contains("gh api")
1820 || lower.contains("gh pr")
1821 || lower.contains("gh error")
1822 || lower.contains("circuit-breaker")
1827 || lower.contains("circuit breaker")
1828}
1829
1830fn extract_github_http_status(err_str: &str) -> Option<u16> {
1835 let marker = "failed (";
1836 let start = err_str.find(marker)? + marker.len();
1837 let rest = &err_str[start..];
1838 let end = rest.find(')')?;
1839 rest[..end].parse().ok()
1840}
1841
1842async fn append_pr_footer_if_missing(
1847 gh: &GhHttp,
1848 repo: &str,
1849 pr_number: u64,
1850 task_id: &str,
1851 agent: &str,
1852 model: Option<&str>,
1853) {
1854 let pr = match gh.get_pr(repo, pr_number).await {
1855 Ok(pr) => pr,
1856 Err(e) => {
1857 tracing::debug!(task_id, error = %e, "failed to fetch PR for footer check");
1858 return;
1859 }
1860 };
1861
1862 let body = pr.body.unwrap_or_default();
1863 if body.contains("via [Orch]") {
1864 return; }
1866
1867 let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1868 let task_ref = format_task_ref(task_id);
1869 let footer = format!(
1870 "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1871 );
1872 let new_body = format!("{body}{footer}");
1873
1874 if let Err(e) = gh.update_pr_body(repo, pr_number, &new_body).await {
1875 tracing::warn!(task_id, error = %e, "failed to append footer to PR (non-fatal)");
1876 } else {
1877 tracing::info!(
1878 task_id,
1879 pr = pr_number,
1880 "appended attribution footer to agent-created PR"
1881 );
1882 }
1883}
1884
1885async fn get_current_branch(dir: &Path) -> String {
1887 let output = Command::new("git")
1888 .args(["branch", "--show-current"])
1889 .current_dir(dir)
1890 .output_with_context()
1891 .await;
1892
1893 match output {
1894 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
1895 _ => String::new(),
1896 }
1897}
1898
1899pub async fn count_changed_files(dir: &Path) -> anyhow::Result<usize> {
1901 let output = Command::new("git")
1903 .args(["status", "--porcelain"])
1904 .current_dir(dir)
1905 .output_with_context()
1906 .await?;
1907
1908 let status_output = String::from_utf8_lossy(&output.stdout);
1909 let count = status_output
1910 .lines()
1911 .filter(|line| {
1912 let prefix = line.get(0..2).unwrap_or("");
1913 prefix.starts_with('M')
1915 || prefix.starts_with('A')
1916 || prefix.starts_with('R')
1917 || prefix.starts_with('C')
1918 || prefix.starts_with('?')
1919 })
1920 .count();
1921
1922 Ok(count)
1923}
1924
1925async fn check_push_needed(dir: &Path, branch: &str, default_branch: &str) -> (bool, bool) {
1933 let remote_exists = Command::new("git")
1935 .args(["rev-parse", &format!("origin/{branch}")])
1936 .current_dir(dir)
1937 .output_with_context()
1938 .await
1939 .map(|o| o.status.success())
1940 .unwrap_or(false);
1941
1942 let compare_ref = if remote_exists {
1943 format!("origin/{branch}..HEAD")
1944 } else {
1945 format!("origin/{default_branch}..HEAD")
1948 };
1949
1950 tracing::debug!(
1951 branch,
1952 compare_ref,
1953 remote_exists,
1954 "checking unpushed commits"
1955 );
1956
1957 let output = Command::new("git")
1958 .args(["log", &compare_ref, "--oneline"])
1959 .current_dir(dir)
1960 .output_with_context()
1961 .await;
1962
1963 let has_unpushed = match output {
1964 Ok(o) if o.status.success() => {
1965 let out = String::from_utf8_lossy(&o.stdout);
1966 let has = !out.trim().is_empty();
1967 tracing::debug!(branch, has_unpushed = has, "unpushed commits check result");
1968 has
1969 }
1970 _ => {
1971 tracing::debug!(branch, "git log failed for compare ref, assuming unpushed");
1972 true
1975 }
1976 };
1977
1978 (remote_exists, has_unpushed)
1979}
1980
1981#[cfg(test)]
1982mod tests {
1983 use super::*;
1984
1985 #[test]
1986 fn pr_create_error_display() {
1987 let err = PrCreateError::ApiError(anyhow::anyhow!("test error"));
1988 assert_eq!(format!("{}", err), "GitHub API error: test error");
1989 }
1990
1991 #[test]
1992 fn pr_create_error_from_anyhow() {
1993 let anyhow_err = anyhow::anyhow!("source error");
1994 let pr_err: PrCreateError = anyhow_err.into();
1995 assert!(matches!(pr_err, PrCreateError::ApiError(_)));
1996 }
1997
1998 #[test]
1999 fn build_git_auth_env_without_token_falls_back_to_ssh_https_conversion() {
2000 let env = build_git_auth_env_for_token(None);
2003
2004 let has_ssh_rule = env
2007 .iter()
2008 .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "git@github.com:");
2009 assert!(
2010 has_ssh_rule,
2011 "expected SSH insteadOf fallback (value is git@github.com:), got: {env:?}"
2012 );
2013 }
2014
2015 #[test]
2016 fn build_git_auth_env_with_token_covers_both_ssh_and_https() {
2017 let env = build_git_auth_env_for_token(Some("ghp_testtoken1234".into()));
2019
2020 let has_token = env
2022 .iter()
2023 .any(|(_, v)| v.contains("x-access-token:ghp_testtoken1234@github.com"));
2024 assert!(has_token, "expected token in auth URL, got: {env:?}");
2025 let has_https_rule = env
2027 .iter()
2028 .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "https://github.com/");
2029 assert!(
2030 has_https_rule,
2031 "expected HTTPS insteadOf rule, got: {env:?}"
2032 );
2033 let has_ssh_rule = env
2035 .iter()
2036 .any(|(k, v)| k == "GIT_CONFIG_VALUE_1" && v == "git@github.com:");
2037 assert!(has_ssh_rule, "expected SSH insteadOf rule, got: {env:?}");
2038 }
2039
2040 #[test]
2041 fn find_stash_ref_by_hash_returns_matching_reference() {
2042 let stash_list = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{1}\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb stash@{0}";
2043
2044 assert_eq!(
2045 find_stash_ref_by_hash(stash_list, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
2046 Some("stash@{1}".to_string())
2047 );
2048 assert_eq!(
2049 find_stash_ref_by_hash(stash_list, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
2050 Some("stash@{0}".to_string())
2051 );
2052 }
2053
2054 #[test]
2055 fn find_stash_ref_by_hash_returns_none_when_missing_or_malformed() {
2056 let stash_list = "malformed-line\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{2}";
2057
2058 assert_eq!(
2059 find_stash_ref_by_hash(stash_list, "cccccccccccccccccccccccccccccccccccccccc"),
2060 None
2061 );
2062 assert_eq!(find_stash_ref_by_hash("", "anything"), None);
2063 }
2064
2065 #[test]
2066 fn push_needs_rebase_detects_common_rejections() {
2067 assert!(push_needs_rebase(
2068 "! [rejected] branch -> branch (fetch first)"
2069 ));
2070 assert!(push_needs_rebase("non-fast-forward update was rejected"));
2071 assert!(push_needs_rebase(
2072 "push rejected because the remote contains work"
2073 ));
2074 }
2075
2076 #[test]
2077 fn is_transient_network_error_detects_lfs_locks_timeout() {
2078 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";
2079 assert!(is_transient_network_error(lfs_err));
2080 assert!(is_transient_network_error(
2081 "dial tcp 140.82.112.4:443: connect: connection refused"
2082 ));
2083 assert!(is_transient_network_error(
2084 "read tcp 10.0.0.1:51234->140.82.112.4:443: read: connection reset by peer"
2085 ));
2086 }
2087
2088 #[test]
2089 fn is_transient_network_error_rejects_ref_rejections() {
2090 assert!(!is_transient_network_error(
2091 "! [rejected] branch -> branch (fetch first)"
2092 ));
2093 assert!(!is_transient_network_error(
2094 "remote: error: GH006: Protected branch update failed"
2095 ));
2096 }
2097
2098 #[test]
2099 fn sanitize_push_error_strips_url_credentials() {
2100 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";
2102 let sanitized = sanitize_push_error(raw);
2103 assert!(!sanitized.contains("test-secret-1234567890abcdef"));
2104 assert!(sanitized.contains("://***@github.com"));
2105 assert!(sanitized.contains("i/o timeout"));
2106 }
2107
2108 #[test]
2109 fn extract_github_http_status_parses_ghhttp_error_format() {
2110 let err =
2111 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
2112 assert_eq!(extract_github_http_status(err), Some(502));
2113
2114 let err500 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (500): Internal Server Error";
2115 assert_eq!(extract_github_http_status(err500), Some(500));
2116
2117 let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
2118 assert_eq!(extract_github_http_status(err422), Some(422));
2119
2120 let not_github = "some other error";
2121 assert_eq!(extract_github_http_status(not_github), None);
2122 }
2123
2124 #[test]
2125 fn is_transient_github_error_matches_5xx_only() {
2126 let err502 =
2127 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
2128 assert!(is_transient_github_error(err502));
2129
2130 let err503 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (503): Service Unavailable";
2131 assert!(is_transient_github_error(err503));
2132
2133 let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
2134 assert!(!is_transient_github_error(err422));
2135
2136 let err404 =
2137 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (404): Not Found";
2138 assert!(!is_transient_github_error(err404));
2139 }
2140
2141 #[test]
2142 fn is_transient_github_error_matches_connection_errors() {
2143 assert!(is_transient_github_error("connection refused"));
2145 assert!(is_transient_github_error("Connection reset by peer"));
2146 assert!(is_transient_github_error("broken pipe"));
2147 assert!(is_transient_github_error("connection closed unexpectedly"));
2148 assert!(is_transient_github_error(
2149 "transport error: connection lost"
2150 ));
2151
2152 assert!(is_transient_github_error(
2154 "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"
2155 ));
2156 assert!(is_transient_github_error(
2157 "Failed to connect to github.com port 443: Connection timed out"
2158 ));
2159 assert!(is_transient_github_error(
2160 "Failed to connect to github.com port 443: Connection refused"
2161 ));
2162 assert!(is_transient_github_error(
2163 "curl: (7) Failed to connect to github.com port 443: Couldn't connect to server"
2164 ));
2165 assert!(is_transient_github_error(
2166 "fatal: unable to access 'https://github.com/owner/repo.git/': Couldn't connect to server"
2167 ));
2168 }
2169
2170 #[test]
2171 fn is_transient_github_error_matches_dns_errors() {
2172 assert!(is_transient_github_error("dns error: failed to lookup"));
2174 assert!(is_transient_github_error("failed to resolve hostname"));
2175 assert!(is_transient_github_error("name resolution failed"));
2176 assert!(is_transient_github_error("no such host: api.github.com"));
2177 }
2178
2179 #[test]
2180 fn is_transient_github_error_matches_tls_errors() {
2181 assert!(is_transient_github_error("tls handshake failed"));
2183 assert!(is_transient_github_error("certificate verification failed"));
2184 assert!(is_transient_github_error("ssl error: unknown ca"));
2185 }
2186
2187 #[test]
2188 fn is_transient_github_error_matches_network_unreachable() {
2189 assert!(is_transient_github_error("network is unreachable"));
2191 assert!(is_transient_github_error("host unreachable"));
2192 assert!(is_transient_github_error(
2193 "temporary failure in name resolution"
2194 ));
2195 }
2196
2197 #[test]
2198 fn is_transient_github_error_matches_eof_errors() {
2199 assert!(is_transient_github_error("unexpected eof while reading"));
2201 assert!(is_transient_github_error("unexpected end of file"));
2202 }
2203
2204 #[test]
2205 fn is_transient_github_error_matches_rate_limit() {
2206 assert!(is_transient_github_error("rate limit exceeded"));
2208 assert!(is_transient_github_error("429 Too Many Requests"));
2209 assert!(is_transient_github_error("too many requests, retry later"));
2210 }
2211
2212 #[test]
2213 fn is_transient_github_error_matches_timeout() {
2214 assert!(is_transient_github_error("operation timeout"));
2216 assert!(is_transient_github_error("request timeout"));
2217 }
2218
2219 #[test]
2220 fn is_transient_github_error_rejects_non_transient() {
2221 assert!(!is_transient_github_error("permission denied"));
2223 assert!(!is_transient_github_error("file not found"));
2224 assert!(!is_transient_github_error("invalid argument"));
2225 assert!(!is_transient_github_error("authentication failed"));
2226 assert!(!is_transient_github_error("bad credentials"));
2227 }
2228
2229 #[test]
2230 fn is_transient_github_api_error_requires_github_context() {
2231 assert!(is_transient_github_api_error(
2232 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway"
2233 ));
2234 assert!(is_transient_github_api_error(
2235 "gh error: transport error: connection reset by peer"
2236 ));
2237 assert!(is_transient_github_api_error(
2238 "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"
2239 ));
2240 assert!(!is_transient_github_api_error(
2241 "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)"
2242 ));
2243 assert!(!is_transient_github_api_error(
2244 "agent stream failed: failed to connect to local relay"
2245 ));
2246 }
2247
2248 #[test]
2249 fn is_transient_github_api_error_matches_circuit_breaker() {
2250 assert!(is_transient_github_api_error(
2252 "GitHub API transient 5xx circuit-breaker active for 240s"
2253 ));
2254 assert!(is_transient_github_api_error(
2256 "create PR failed: GitHub API error: GitHub API transient 5xx circuit-breaker active"
2257 ));
2258 assert!(is_transient_github_api_error(
2261 "create PR failed: circuit-breaker"
2262 ));
2263 assert!(is_transient_github_api_error(
2265 "create PR failed: circuit breaker tripped"
2266 ));
2267 }
2268
2269 #[tokio::test]
2270 async fn auto_commit_unstages_files_on_commit_failure() {
2271 let dir =
2273 std::env::temp_dir().join(format!("orch_test_auto_commit_{}", std::process::id()));
2274 let _ = std::fs::remove_dir_all(&dir);
2275 std::fs::create_dir_all(&dir).unwrap();
2276
2277 let _ = Command::new("git")
2279 .args(["init"])
2280 .current_dir(&dir)
2281 .output()
2282 .await;
2283 let _ = Command::new("git")
2284 .args(["config", "user.email", "test@test.com"])
2285 .current_dir(&dir)
2286 .output()
2287 .await;
2288 let _ = Command::new("git")
2289 .args(["config", "user.name", "Test"])
2290 .current_dir(&dir)
2291 .output()
2292 .await;
2293 std::fs::write(dir.join("init.txt"), "init").unwrap();
2295 let _ = Command::new("git")
2296 .args(["add", "init.txt"])
2297 .current_dir(&dir)
2298 .output()
2299 .await;
2300 let _ = Command::new("git")
2301 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2302 .current_dir(&dir)
2303 .output()
2304 .await;
2305
2306 std::fs::write(dir.join("new_file.txt"), "content").unwrap();
2308
2309 assert!(has_changes(&dir).await);
2311
2312 let result = auto_commit(&dir, "1", "Test title", "test", 1).await;
2317 assert!(result.unwrap());
2318
2319 assert!(!has_changes(&dir).await);
2321
2322 let _ = std::fs::remove_dir_all(&dir);
2324 }
2325
2326 #[tokio::test]
2327 async fn auto_commit_ignores_repo_signing_config() {
2328 let dir =
2331 std::env::temp_dir().join(format!("orch_test_auto_commit_gpg_{}", std::process::id()));
2332 let _ = std::fs::remove_dir_all(&dir);
2333 std::fs::create_dir_all(&dir).unwrap();
2334
2335 for args in [
2336 vec!["init"],
2337 vec!["config", "user.email", "test@test.com"],
2338 vec!["config", "user.name", "Test"],
2339 vec!["config", "commit.gpgsign", "true"],
2340 vec!["config", "gpg.program", "/usr/bin/false"],
2341 ] {
2342 let _ = Command::new("git")
2343 .args(&args)
2344 .current_dir(&dir)
2345 .output()
2346 .await;
2347 }
2348 std::fs::write(dir.join("init.txt"), "init").unwrap();
2349 let _ = Command::new("git")
2350 .args(["add", "init.txt"])
2351 .current_dir(&dir)
2352 .output()
2353 .await;
2354 let _ = Command::new("git")
2355 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2356 .current_dir(&dir)
2357 .output()
2358 .await;
2359
2360 std::fs::write(dir.join("signed_off.txt"), "content").unwrap();
2361 assert!(has_changes(&dir).await);
2362
2363 let result = auto_commit(&dir, "3", "Test title", "test", 1).await;
2364 assert!(result.unwrap());
2365 assert!(!has_changes(&dir).await);
2366
2367 let _ = std::fs::remove_dir_all(&dir);
2368 }
2369
2370 #[tokio::test]
2371 async fn auto_commit_unstages_on_pre_commit_hook_failure() {
2372 let dir =
2374 std::env::temp_dir().join(format!("orch_test_auto_commit_hook_{}", std::process::id()));
2375 let _ = std::fs::remove_dir_all(&dir);
2376 std::fs::create_dir_all(&dir).unwrap();
2377
2378 let _ = Command::new("git")
2380 .args(["init"])
2381 .current_dir(&dir)
2382 .output()
2383 .await;
2384 let _ = Command::new("git")
2385 .args(["config", "user.email", "test@test.com"])
2386 .current_dir(&dir)
2387 .output()
2388 .await;
2389 let _ = Command::new("git")
2390 .args(["config", "user.name", "Test"])
2391 .current_dir(&dir)
2392 .output()
2393 .await;
2394 std::fs::write(dir.join("init.txt"), "init").unwrap();
2396 let _ = Command::new("git")
2397 .args(["add", "init.txt"])
2398 .current_dir(&dir)
2399 .output()
2400 .await;
2401 let _ = Command::new("git")
2402 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2403 .current_dir(&dir)
2404 .output()
2405 .await;
2406
2407 let hooks_dir = dir.join(".git/hooks");
2409 std::fs::create_dir_all(&hooks_dir).unwrap();
2410 std::fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\nexit 1\n").unwrap();
2411 #[cfg(unix)]
2412 {
2413 use std::os::unix::fs::PermissionsExt;
2414 let mut perms = std::fs::metadata(hooks_dir.join("pre-commit"))
2415 .unwrap()
2416 .permissions();
2417 perms.set_mode(0o755);
2418 std::fs::set_permissions(hooks_dir.join("pre-commit"), perms).unwrap();
2419 }
2420
2421 std::fs::write(dir.join("staged_file.txt"), "content").unwrap();
2423
2424 assert!(has_changes(&dir).await);
2426
2427 let result = auto_commit(&dir, "2", "Test title", "test", 1).await;
2429 assert!(!result.unwrap());
2430
2431 assert!(has_changes(&dir).await);
2434
2435 let cached = Command::new("git")
2437 .args(["diff", "--cached", "--quiet"])
2438 .current_dir(&dir)
2439 .status()
2440 .await;
2441 assert!(
2442 cached.map(|s| s.success()).unwrap_or(false),
2443 "files should be unstaged after commit failure"
2444 );
2445
2446 let _ = std::fs::remove_dir_all(&dir);
2448 }
2449
2450 #[test]
2451 fn is_workflow_scope_error_detects_backtick_format() {
2452 let err = "! [remote rejected] branch -> branch (refusing to allow an OAuth App to create or update workflow `.github/workflows/ci.yml` without `workflow` scope)";
2453 assert!(is_workflow_scope_error(err));
2454 }
2455
2456 #[test]
2457 fn is_workflow_scope_error_detects_single_quote_format() {
2458 let err = "refusing to allow an OAuth App to create or update workflow '.github/workflows/ci.yml' without 'workflow' scope";
2459 assert!(is_workflow_scope_error(err));
2460 }
2461
2462 #[test]
2463 fn is_workflow_scope_error_detects_refusing_to_allow_variant() {
2464 let err = "remote: refusing to allow a Personal Access Token to create or update workflow `.github/workflows/scan.yml` without `workflow` scope";
2465 assert!(is_workflow_scope_error(err));
2466 }
2467
2468 #[test]
2469 fn is_workflow_scope_error_rejects_unrelated_errors() {
2470 assert!(!is_workflow_scope_error("non-fast-forward"));
2471 assert!(!is_workflow_scope_error("permission denied"));
2472 assert!(!is_workflow_scope_error("authentication failed"));
2473 assert!(!is_workflow_scope_error("rejected"));
2474 }
2475
2476 #[tokio::test]
2477 async fn strip_workflow_files_removes_workflow_from_commits() {
2478 let dir = std::env::temp_dir().join(format!("orch_test_strip_wf_{}", std::process::id()));
2480 let _ = std::fs::remove_dir_all(&dir);
2481 std::fs::create_dir_all(&dir).unwrap();
2482
2483 let _ = Command::new("git")
2485 .args(["init"])
2486 .current_dir(&dir)
2487 .output()
2488 .await;
2489 let _ = Command::new("git")
2490 .args(["config", "user.email", "test@test.com"])
2491 .current_dir(&dir)
2492 .output()
2493 .await;
2494 let _ = Command::new("git")
2495 .args(["config", "user.name", "Test"])
2496 .current_dir(&dir)
2497 .output()
2498 .await;
2499 std::fs::write(dir.join("init.txt"), "init").unwrap();
2500 let _ = Command::new("git")
2501 .args(["add", "."])
2502 .current_dir(&dir)
2503 .output()
2504 .await;
2505 let _ = Command::new("git")
2506 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2507 .current_dir(&dir)
2508 .output()
2509 .await;
2510
2511 let _ = Command::new("git")
2513 .args(["remote", "add", "origin", dir.to_str().unwrap()])
2514 .current_dir(&dir)
2515 .output()
2516 .await;
2517 let _ = Command::new("git")
2518 .args(["fetch", "origin"])
2519 .current_dir(&dir)
2520 .output()
2521 .await;
2522
2523 let _ = Command::new("git")
2525 .args(["checkout", "-b", "feature"])
2526 .current_dir(&dir)
2527 .output()
2528 .await;
2529
2530 std::fs::write(dir.join("feature.txt"), "feature work").unwrap();
2532 let wf_dir = dir.join(".github/workflows");
2533 std::fs::create_dir_all(&wf_dir).unwrap();
2534 std::fs::write(wf_dir.join("ci.yml"), "name: CI\non: push").unwrap();
2535 let _ = Command::new("git")
2536 .args(["add", "."])
2537 .current_dir(&dir)
2538 .output()
2539 .await;
2540 let _ = Command::new("git")
2541 .args([
2542 "-c",
2543 "commit.gpgsign=false",
2544 "commit",
2545 "-m",
2546 "add feature and workflow",
2547 ])
2548 .current_dir(&dir)
2549 .output()
2550 .await;
2551
2552 let branch_out = Command::new("git")
2554 .args(["rev-parse", "--abbrev-ref", "origin/HEAD"])
2555 .current_dir(&dir)
2556 .output()
2557 .await;
2558 let default_branch = branch_out
2560 .ok()
2561 .filter(|o| o.status.success())
2562 .map(|o| {
2563 String::from_utf8_lossy(&o.stdout)
2564 .trim()
2565 .strip_prefix("origin/")
2566 .unwrap_or("main")
2567 .to_string()
2568 })
2569 .unwrap_or_else(|| "main".to_string());
2570
2571 let stripped = strip_workflow_files(&dir, &default_branch).await.unwrap();
2573 assert!(stripped, "should have stripped workflow files");
2574
2575 let ls_output = Command::new("git")
2577 .args(["ls-tree", "-r", "--name-only", "HEAD"])
2578 .current_dir(&dir)
2579 .output()
2580 .await
2581 .unwrap();
2582 let files = String::from_utf8_lossy(&ls_output.stdout);
2583 assert!(
2584 !files.contains(".github/workflows/ci.yml"),
2585 "workflow file should be removed from commits"
2586 );
2587 assert!(
2588 files.contains("feature.txt"),
2589 "non-workflow files should be preserved"
2590 );
2591
2592 let _ = std::fs::remove_dir_all(&dir);
2594 }
2595
2596 #[tokio::test]
2597 async fn strip_workflow_files_returns_false_when_no_workflows() {
2598 let dir =
2599 std::env::temp_dir().join(format!("orch_test_strip_wf_none_{}", std::process::id()));
2600 let _ = std::fs::remove_dir_all(&dir);
2601 std::fs::create_dir_all(&dir).unwrap();
2602
2603 let _ = Command::new("git")
2605 .args(["init"])
2606 .current_dir(&dir)
2607 .output()
2608 .await;
2609 let _ = Command::new("git")
2610 .args(["config", "user.email", "test@test.com"])
2611 .current_dir(&dir)
2612 .output()
2613 .await;
2614 let _ = Command::new("git")
2615 .args(["config", "user.name", "Test"])
2616 .current_dir(&dir)
2617 .output()
2618 .await;
2619 std::fs::write(dir.join("init.txt"), "init").unwrap();
2620 let _ = Command::new("git")
2621 .args(["add", "."])
2622 .current_dir(&dir)
2623 .output()
2624 .await;
2625 let _ = Command::new("git")
2626 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2627 .current_dir(&dir)
2628 .output()
2629 .await;
2630
2631 let _ = Command::new("git")
2632 .args(["remote", "add", "origin", dir.to_str().unwrap()])
2633 .current_dir(&dir)
2634 .output()
2635 .await;
2636 let _ = Command::new("git")
2637 .args(["fetch", "origin"])
2638 .current_dir(&dir)
2639 .output()
2640 .await;
2641
2642 let _ = Command::new("git")
2643 .args(["checkout", "-b", "feature2"])
2644 .current_dir(&dir)
2645 .output()
2646 .await;
2647
2648 std::fs::write(dir.join("feature.txt"), "feature").unwrap();
2650 let _ = Command::new("git")
2651 .args(["add", "."])
2652 .current_dir(&dir)
2653 .output()
2654 .await;
2655 let _ = Command::new("git")
2656 .args([
2657 "-c",
2658 "commit.gpgsign=false",
2659 "commit",
2660 "-m",
2661 "add feature only",
2662 ])
2663 .current_dir(&dir)
2664 .output()
2665 .await;
2666
2667 let stripped = strip_workflow_files(&dir, "main").await.unwrap();
2668 assert!(!stripped, "should not strip when no workflow files exist");
2669
2670 let _ = std::fs::remove_dir_all(&dir);
2672 }
2673
2674 #[test]
2675 fn is_invalid_base_error_detects_base_invalid() {
2676 assert!(is_invalid_base_error(
2677 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): {\"errors\":[{\"resource\":\"PullRequest\",\"field\":\"base\",\"code\":\"invalid\"}]}"
2678 ));
2679 assert!(is_invalid_base_error(
2680 "GitHub API POST .../pulls failed (422): Base field is invalid"
2681 ));
2682 }
2683
2684 #[test]
2685 fn is_invalid_base_error_rejects_non_base_errors() {
2686 assert!(!is_invalid_base_error(
2688 "GitHub API POST .../pulls failed (422): No commits between main and branch"
2689 ));
2690 assert!(!is_invalid_base_error(
2692 "GitHub API POST .../pulls failed (422): head is not a valid ref"
2693 ));
2694 assert!(!is_invalid_base_error(
2696 "GitHub API POST .../pulls failed (502): Bad Gateway"
2697 ));
2698 assert!(!is_invalid_base_error(
2700 "GitHub API POST .../pulls failed (404): Not Found"
2701 ));
2702 }
2703
2704 fn pr_summary(body: &str) -> &str {
2705 let rest = body
2706 .strip_prefix("## Summary\n\n")
2707 .expect("PR body starts with ## Summary");
2708 rest.split("\n\n").next().unwrap()
2709 }
2710
2711 fn assert_no_push_or_pr_creation(body: &str) {
2712 let lower = body.to_ascii_lowercase();
2713 for needle in [
2714 "git push",
2715 "gh pr create",
2716 "did not push",
2717 "couldn't push",
2718 "could not push",
2719 "push was denied",
2720 "push was blocked",
2721 "push permission",
2722 "nothing is on the remote",
2723 "open the pr",
2724 ] {
2725 assert!(
2726 !lower.contains(needle),
2727 "PR body still contains {needle:?}:\n{body}"
2728 );
2729 }
2730 }
2731
2732 #[test]
2733 fn build_pr_body_strips_push_and_pr_creation_statements() {
2734 let examples = [
2735 (
2736 concat!(
2737 "Added the lesson list page. ",
2738 "I did not push or open the PR, because the push was blocked on a permission prompt. ",
2739 "Run `git push -u origin HEAD`, then `gh pr create`.",
2740 ),
2741 "Added the lesson list page.",
2742 ),
2743 (
2744 "It is committed locally ... The push was denied, so nothing is on the remote yet.",
2745 "It is committed locally",
2746 ),
2747 (
2748 concat!(
2749 "Fixed the quiz scoring. ",
2750 "I couldn't push it because the push permission was denied.",
2751 ),
2752 "Fixed the quiz scoring.",
2753 ),
2754 ];
2755 for (summary, work) in examples {
2756 let body = build_pr_body(
2757 "Fix the widget",
2758 summary,
2759 &[
2760 "Wrote the lesson list".into(),
2761 "Could not push because the push permission was denied".into(),
2762 ],
2763 &["Run `git push -u origin HEAD`".into()],
2764 &[],
2765 "16",
2766 "claude",
2767 None,
2768 );
2769 assert_eq!(pr_summary(&body), work);
2770 assert!(body.contains("Wrote the lesson list"));
2771 assert!(!body.contains("### Remaining"));
2772 assert_no_push_or_pr_creation(&body);
2773 }
2774 }
2775
2776 #[test]
2777 fn build_pr_body_keeps_summary_without_push_text() {
2778 let summary = "Added retry logic for GitHub 5xx errors.";
2779 let accomplished = vec!["Extracted the PR body builder".into()];
2780 let remaining = vec!["Watch CI after merge".into()];
2781 let body = build_pr_body(
2782 "Fix the widget",
2783 summary,
2784 &accomplished,
2785 &remaining,
2786 &["src/engine/runner/git_ops.rs".into()],
2787 "16",
2788 "claude",
2789 Some("sonnet"),
2790 );
2791 assert_eq!(pr_summary(&body), summary);
2792 assert!(body.contains("### What was done\n\n- Extracted the PR body builder\n"));
2793 assert!(body.contains("### Remaining\n\n- Watch CI after merge\n"));
2794 assert!(body.contains("- `src/engine/runner/git_ops.rs`"));
2795 assert!(body.contains(" using `sonnet`"));
2796 }
2797
2798 fn sh_git(dir: &std::path::Path, args: &[&str]) {
2799 let out = std::process::Command::new("git")
2800 .args([
2801 "-c",
2802 "commit.gpgsign=false",
2803 "-c",
2804 "user.name=T",
2805 "-c",
2806 "user.email=t@t",
2807 ])
2808 .args(args)
2809 .current_dir(dir)
2810 .output()
2811 .unwrap();
2812 assert!(
2813 out.status.success(),
2814 "git {args:?}: {}",
2815 String::from_utf8_lossy(&out.stderr)
2816 );
2817 }
2818
2819 fn commit_file(dir: &std::path::Path, name: &str) {
2820 std::fs::write(dir.join(name), name).unwrap();
2821 sh_git(dir, &["add", name]);
2822 sh_git(dir, &["commit", "-m", name]);
2823 }
2824
2825 #[tokio::test]
2831 async fn push_branch_recovers_from_stale_lease_after_remote_moved() {
2832 let temp = tempfile::TempDir::new().unwrap();
2833 let bare = temp.path().join("origin.git");
2834 let a = temp.path().join("a");
2835 let b = temp.path().join("b");
2836 std::fs::create_dir_all(&bare).unwrap();
2837 sh_git(&bare, &["init", "--bare", "-b", "main"]);
2838 sh_git(temp.path(), &["clone", bare.to_str().unwrap(), "a"]);
2839 sh_git(&a, &["config", "user.name", "T"]);
2841 sh_git(&a, &["config", "user.email", "t@t"]);
2842 commit_file(&a, "base.txt");
2843 sh_git(&a, &["push", "origin", "main"]);
2844 sh_git(&a, &["checkout", "-b", "feat"]);
2845 commit_file(&a, "feature.txt");
2846 sh_git(&a, &["push", "-u", "origin", "feat"]);
2847
2848 sh_git(temp.path(), &["clone", bare.to_str().unwrap(), "b"]);
2849 commit_file(&b, "main-change.txt");
2850 sh_git(&b, &["push", "origin", "main"]);
2851 sh_git(&b, &["checkout", "feat"]);
2852 commit_file(&b, "update-branch.txt");
2853 sh_git(&b, &["push", "origin", "feat"]);
2854
2855 sh_git(&a, &["fetch", "origin", "main"]);
2857 sh_git(&a, &["rebase", "origin/main"]);
2858
2859 assert!(push_branch(&a, "feat", "main").await.unwrap());
2860
2861 sh_git(&b, &["fetch", "origin", "feat"]);
2862 sh_git(&b, &["checkout", "origin/feat"]);
2863 for name in [
2864 "base.txt",
2865 "feature.txt",
2866 "main-change.txt",
2867 "update-branch.txt",
2868 ] {
2869 assert!(b.join(name).exists(), "remote feat lost {name}");
2870 }
2871 }
2872
2873 #[test]
2874 fn stale_lease_error_matches_git_messages() {
2875 assert!(is_stale_lease_error(
2876 "! [rejected] feat -> feat (stale info)"
2877 ));
2878 assert!(is_stale_lease_error(
2879 "! [rejected] feat -> feat (remote ref updated since checkout)"
2880 ));
2881 assert!(!is_stale_lease_error(
2882 "! [rejected] feat -> feat (fetch first)"
2883 ));
2884 }
2885}