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) fn build_git_auth_env() -> Vec<(String, String)> {
690 let token = crate::github::token::TokenResolver::default_env()
691 .get_token_sync()
692 .ok()
693 .flatten();
694 build_git_auth_env_for_token(token)
695}
696
697fn build_git_auth_env_for_token(token: Option<String>) -> Vec<(String, String)> {
698 match token {
699 Some(t) if !t.is_empty() => {
700 let authed = format!("url.https://x-access-token:{t}@github.com/.insteadOf");
701 vec![
702 ("GIT_CONFIG_COUNT".into(), "2".into()),
703 ("GIT_CONFIG_KEY_0".into(), authed.clone()),
704 ("GIT_CONFIG_VALUE_0".into(), "https://github.com/".into()),
705 ("GIT_CONFIG_KEY_1".into(), authed),
706 ("GIT_CONFIG_VALUE_1".into(), "git@github.com:".into()),
707 ]
708 }
709 _ => {
710 vec![
711 ("GIT_CONFIG_COUNT".into(), "1".into()),
712 (
713 "GIT_CONFIG_KEY_0".into(),
714 "url.https://github.com/.insteadOf".into(),
715 ),
716 ("GIT_CONFIG_VALUE_0".into(), "git@github.com:".into()),
717 ]
718 }
719 }
720}
721
722pub(crate) fn is_workflow_scope_error(stderr: &str) -> bool {
729 stderr.contains("without `workflow` scope")
730 || stderr.contains("without 'workflow' scope")
731 || (stderr.contains("refusing to allow")
732 && stderr.contains("workflow")
733 && stderr.contains("scope"))
734}
735
736async fn strip_workflow_files(dir: &Path, default_branch: &str) -> anyhow::Result<bool> {
747 let output = Command::new("git")
749 .args([
750 "diff",
751 "--name-only",
752 &format!("origin/{default_branch}...HEAD"),
753 "--",
754 ".github/workflows/",
755 ])
756 .current_dir(dir)
757 .output_with_context()
758 .await?;
759
760 let workflow_files: Vec<String> = String::from_utf8_lossy(&output.stdout)
761 .lines()
762 .filter(|l| !l.is_empty())
763 .map(|s| s.to_string())
764 .collect();
765
766 if workflow_files.is_empty() {
767 return Ok(false);
768 }
769
770 tracing::info!(
771 files = ?workflow_files,
772 "stripping workflow files from commits to bypass missing workflow scope"
773 );
774
775 let count_output = Command::new("git")
780 .args([
781 "rev-list",
782 "--count",
783 &format!("origin/{default_branch}..HEAD"),
784 ])
785 .current_dir(dir)
786 .output_with_context()
787 .await?;
788
789 let commit_count: usize = String::from_utf8_lossy(&count_output.stdout)
790 .trim()
791 .parse()
792 .unwrap_or(0);
793
794 if commit_count == 0 {
795 return Ok(false);
796 }
797
798 let log_output = Command::new("git")
803 .args([
804 "log",
805 "--format=%x00%H%n%B",
806 &format!("origin/{default_branch}..HEAD"),
807 ])
808 .current_dir(dir)
809 .output_with_context()
810 .await?;
811 let log_text = String::from_utf8_lossy(&log_output.stdout).to_string();
812
813 let commits: Vec<(String, String)> = log_text
817 .split('\0')
818 .filter(|s| !s.trim().is_empty())
819 .map(|block| {
820 let block = block.trim();
821 let first_nl = block.find('\n').unwrap_or(block.len());
822 let hash = block[..first_nl].to_string();
823 let msg = block[first_nl..].trim().to_string();
824 (hash, msg)
825 })
826 .collect::<Vec<_>>()
827 .into_iter()
828 .rev()
829 .collect();
830
831 if commits.is_empty() {
832 return Ok(false);
833 }
834
835 let reset = Command::new("git")
837 .args(["reset", "--soft", &format!("origin/{default_branch}")])
838 .current_dir(dir)
839 .output_with_context()
840 .await?;
841
842 if !reset.status.success() {
843 let stderr = String::from_utf8_lossy(&reset.stderr);
844 anyhow::bail!("git reset --soft failed during workflow file stripping: {stderr}");
845 }
846
847 let mut rm_args = vec!["rm", "--cached", "--ignore-unmatch", "--"];
849 let file_refs: Vec<&str> = workflow_files.iter().map(|s| s.as_str()).collect();
850 rm_args.extend(file_refs);
851
852 let rm = Command::new("git")
853 .args(&rm_args)
854 .current_dir(dir)
855 .output_with_context()
856 .await?;
857
858 if !rm.status.success() {
859 let stderr = String::from_utf8_lossy(&rm.stderr);
860 tracing::warn!("git rm --cached for workflow files failed (non-fatal): {stderr}");
861 }
862
863 let has_staged = Command::new("git")
866 .args(["diff", "--cached", "--quiet"])
867 .current_dir(dir)
868 .status()
869 .await
870 .map(|s| !s.success())
871 .unwrap_or(false);
872
873 if !has_staged {
874 tracing::warn!(
875 "after removing workflow files, no staged changes remain — \
876 the only changes were workflow files"
877 );
878 if let Some((hash, _)) = commits.last() {
881 let _ = Command::new("git")
882 .args(["reset", "--soft", hash])
883 .current_dir(dir)
884 .output_with_context()
885 .await;
886 }
887 return Ok(false);
888 }
889
890 let commit_msg = if commits.len() == 1 {
892 commits[0].1.clone()
893 } else {
894 let mut combined = String::new();
896 for (i, (_, msg)) in commits.iter().enumerate() {
897 if i > 0 {
898 combined.push_str("\n\n");
899 }
900 combined.push_str(msg);
901 }
902 combined.push_str("\n\n[workflow files stripped: token lacks `workflow` scope]");
903 combined
904 };
905
906 let (git_name, git_email) = git_identity("orchestrator");
907 let commit = Command::new("git")
908 .args(["-c", "commit.gpgsign=false", "commit", "-m", &commit_msg])
910 .env("GIT_AUTHOR_NAME", &git_name)
911 .env("GIT_COMMITTER_NAME", &git_name)
912 .env("GIT_AUTHOR_EMAIL", &git_email)
913 .env("GIT_COMMITTER_EMAIL", &git_email)
914 .current_dir(dir)
915 .output_with_context()
916 .await?;
917
918 if !commit.status.success() {
919 let stderr = String::from_utf8_lossy(&commit.stderr);
920 if let Some((original_head, _)) = commits.last() {
922 let _ = Command::new("git")
923 .args(["reset", "--hard", original_head])
924 .current_dir(dir)
925 .output_with_context()
926 .await;
927 }
928 anyhow::bail!("git commit failed during workflow file stripping: {stderr}");
929 }
930
931 tracing::info!(
932 stripped_files = ?workflow_files,
933 original_commits = commit_count,
934 "successfully stripped workflow files from commits"
935 );
936
937 Ok(true)
938}
939
940pub async fn push_branch(dir: &Path, branch: &str, default_branch: &str) -> anyhow::Result<bool> {
942 let current = get_current_branch(dir).await;
943 let branch_to_push = if !current.is_empty() {
944 ¤t
945 } else {
946 branch
947 };
948
949 if branch_to_push.is_empty() {
951 anyhow::bail!("cannot push: branch name is empty");
952 }
953
954 if branch_to_push == "main" || branch_to_push == "master" {
956 return Ok(false);
957 }
958
959 let (remote_exists, has_unpushed) =
961 check_push_needed(dir, branch_to_push, default_branch).await;
962 if remote_exists && !has_unpushed {
963 tracing::debug!(
967 branch = branch_to_push,
968 "remote branch exists and is up to date, skipping push"
969 );
970 return Ok(true);
971 }
972 if !remote_exists && !has_unpushed {
973 tracing::debug!(
979 branch = branch_to_push,
980 "remote branch missing with no new commits — pushing to create it on remote"
981 );
982 }
983
984 tracing::info!(branch = branch_to_push, "pushing branch");
985
986 let auth_env = build_git_auth_env();
987
988 let output = tokio::time::timeout(
995 std::time::Duration::from_secs(120),
996 Command::new("git")
997 .args([
998 "-c",
999 "lfs.locksverify=false",
1000 "push",
1001 "-u",
1002 "origin",
1003 branch_to_push,
1004 ])
1005 .kill_on_drop(true)
1006 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1007 .current_dir(dir)
1008 .output(),
1009 )
1010 .await
1011 .map_err(|_| anyhow::anyhow!("git push timed out after 120s"))??;
1012
1013 if output.status.success() {
1014 tracing::info!(branch = branch_to_push, "push succeeded");
1015 return Ok(true);
1016 }
1017
1018 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1019
1020 if is_transient_network_error(&stderr) {
1023 tracing::warn!(
1024 branch = branch_to_push,
1025 stderr = %sanitize_push_error(&stderr),
1026 "push failed with transient network error — retrying once"
1027 );
1028 let retry_output = tokio::time::timeout(
1029 std::time::Duration::from_secs(120),
1030 Command::new("git")
1031 .args([
1032 "-c",
1033 "lfs.locksverify=false",
1034 "push",
1035 "-u",
1036 "origin",
1037 branch_to_push,
1038 ])
1039 .kill_on_drop(true)
1040 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1041 .current_dir(dir)
1042 .output(),
1043 )
1044 .await
1045 .map_err(|_| anyhow::anyhow!("git push (network retry) timed out after 120s"))??;
1046
1047 if retry_output.status.success() {
1048 tracing::info!(branch = branch_to_push, "push succeeded on network retry");
1049 return Ok(true);
1050 }
1051
1052 let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
1053 .trim()
1054 .to_string();
1055 tracing::warn!(
1056 branch = branch_to_push,
1057 stderr = %sanitize_push_error(&retry_stderr),
1058 "push retry also failed"
1059 );
1060 anyhow::bail!("push failed: {}", sanitize_push_error(&retry_stderr));
1061 }
1062
1063 if is_workflow_scope_error(&stderr) {
1067 tracing::warn!(
1068 branch = branch_to_push,
1069 "push rejected: token lacks `workflow` scope — stripping workflow files and retrying"
1070 );
1071
1072 match strip_workflow_files(dir, default_branch).await {
1073 Ok(true) => {
1074 let retry_output = tokio::time::timeout(
1076 std::time::Duration::from_secs(120),
1077 Command::new("git")
1078 .args([
1079 "-c",
1080 "lfs.locksverify=false",
1081 "push",
1082 "-u",
1083 "origin",
1084 branch_to_push,
1085 ])
1086 .kill_on_drop(true)
1087 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1088 .current_dir(dir)
1089 .output(),
1090 )
1091 .await
1092 .map_err(|_| anyhow::anyhow!("git push (retry) timed out after 120s"))??;
1093
1094 if retry_output.status.success() {
1095 tracing::info!(
1096 branch = branch_to_push,
1097 "push succeeded after stripping workflow files"
1098 );
1099 return Ok(true);
1100 }
1101
1102 let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
1103 .trim()
1104 .to_string();
1105
1106 if is_workflow_scope_error(&retry_stderr) {
1108 anyhow::bail!(
1109 "push failed: token lacks `workflow` OAuth scope and workflow files \
1110 could not be fully stripped — add `workflow` scope to your GitHub \
1111 token or use a GitHub App for authentication"
1112 );
1113 }
1114
1115 anyhow::bail!("push failed after stripping workflow files: {retry_stderr}");
1116 }
1117 Ok(false) => {
1118 anyhow::bail!(
1120 "push failed: token lacks `workflow` OAuth scope — add `workflow` scope \
1121 to your GitHub token or use a GitHub App for authentication. \
1122 Error: {stderr}"
1123 );
1124 }
1125 Err(e) => {
1126 tracing::error!(error = %e, "failed to strip workflow files from commits");
1127 anyhow::bail!(
1128 "push failed: token lacks `workflow` OAuth scope and automatic \
1129 recovery failed ({e}) — add `workflow` scope to your GitHub token \
1130 or use a GitHub App for authentication"
1131 );
1132 }
1133 }
1134 }
1135
1136 if push_needs_rebase(&stderr) && remote_exists {
1141 tracing::info!(
1142 branch = branch_to_push,
1143 "push rejected (non-fast-forward), force-pushing with lease"
1144 );
1145 let output = Command::new("git")
1146 .args([
1147 "-c",
1148 "lfs.locksverify=false",
1149 "push",
1150 "--force-with-lease",
1151 "-u",
1152 "origin",
1153 branch_to_push,
1154 ])
1155 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1156 .current_dir(dir)
1157 .output_with_context()
1158 .await?;
1159
1160 if output.status.success() {
1161 tracing::info!(branch = branch_to_push, "force push succeeded");
1162 return Ok(true);
1163 }
1164
1165 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1166
1167 if is_workflow_scope_error(&stderr) {
1169 tracing::warn!(
1170 branch = branch_to_push,
1171 "force push also rejected due to workflow scope — stripping and retrying"
1172 );
1173 match strip_workflow_files(dir, default_branch).await {
1174 Ok(true) => {
1175 let retry_output = Command::new("git")
1176 .args([
1177 "-c",
1178 "lfs.locksverify=false",
1179 "push",
1180 "--force-with-lease",
1181 "-u",
1182 "origin",
1183 branch_to_push,
1184 ])
1185 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1186 .current_dir(dir)
1187 .output_with_context()
1188 .await?;
1189
1190 if retry_output.status.success() {
1191 tracing::info!(
1192 branch = branch_to_push,
1193 "force push succeeded after stripping workflow files"
1194 );
1195 return Ok(true);
1196 }
1197
1198 let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
1199 .trim()
1200 .to_string();
1201 anyhow::bail!(
1202 "force push failed after stripping workflow files: {retry_stderr}"
1203 );
1204 }
1205 Ok(false) => {
1206 anyhow::bail!(
1207 "force push failed: token lacks `workflow` OAuth scope — \
1208 add `workflow` scope to your GitHub token or use a GitHub App. \
1209 Error: {stderr}"
1210 );
1211 }
1212 Err(e) => {
1213 anyhow::bail!(
1214 "force push failed: token lacks `workflow` scope and recovery \
1215 failed ({e})"
1216 );
1217 }
1218 }
1219 }
1220
1221 if is_stale_lease_error(&stderr) && rebase_on_branch(dir, branch_to_push).await? {
1224 let output = Command::new("git")
1225 .args([
1226 "-c",
1227 "lfs.locksverify=false",
1228 "push",
1229 "-u",
1230 "origin",
1231 branch_to_push,
1232 ])
1233 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1234 .current_dir(dir)
1235 .output_with_context()
1236 .await?;
1237 if output.status.success() {
1238 tracing::info!(
1239 branch = branch_to_push,
1240 "push succeeded after rebasing on the moved remote branch"
1241 );
1242 return Ok(true);
1243 }
1244
1245 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1246 anyhow::bail!("push failed after rebasing on the moved remote branch: {stderr}");
1247 }
1248
1249 if is_stale_lease_error(&stderr) {
1250 tracing::info!(
1251 branch = branch_to_push,
1252 "remote branch already holds every local commit, nothing to push"
1253 );
1254 return Ok(true);
1255 }
1256
1257 anyhow::bail!("force push failed: {stderr}");
1258 }
1259
1260 anyhow::bail!("push failed: {stderr}")
1261}
1262
1263fn push_needs_rebase(stderr: &str) -> bool {
1264 let lower = stderr.to_ascii_lowercase();
1265 lower.contains("non-fast-forward")
1266 || lower.contains("rejected")
1267 || lower.contains("fetch first")
1268 || lower.contains("behind")
1269}
1270
1271fn is_stale_lease_error(stderr: &str) -> bool {
1273 let lower = stderr.to_ascii_lowercase();
1274 lower.contains("stale info") || lower.contains("remote ref updated since checkout")
1275}
1276
1277fn is_transient_network_error(stderr: &str) -> bool {
1282 let lower = stderr.to_ascii_lowercase();
1283 lower.contains("i/o timeout")
1284 || lower.contains("dial tcp")
1285 || lower.contains("connection reset")
1286 || lower.contains("connection refused")
1287 || lower.contains("no route to host")
1288 || lower.contains("temporary failure in name resolution")
1289 || lower.contains("eof")
1290}
1291
1292fn sanitize_push_error(stderr: &str) -> String {
1296 static URL_USERINFO: LazyLock<Regex> =
1297 LazyLock::new(|| Regex::new(r"://[^/@\s]+@").expect("valid url userinfo regex"));
1298 let no_userinfo = URL_USERINFO.replace_all(stderr, "://***@");
1299 crate::security::redact(&no_userinfo)
1300}
1301
1302fn strip_push_and_pr_creation(text: &str) -> String {
1305 let mut kept_lines: Vec<String> = Vec::new();
1306 for line in text.lines() {
1307 if line.trim().is_empty() {
1308 if kept_lines.last().is_some_and(|l| !l.is_empty()) {
1309 kept_lines.push(String::new());
1310 }
1311 continue;
1312 }
1313
1314 let indent_len = line.len() - line.trim_start().len();
1315 let indent = &line[..indent_len];
1316 let trimmed = line.trim();
1317 let (marker, rest) = match list_marker(trimmed) {
1318 Some((m, r)) => (m, r),
1319 None => ("", trimmed),
1320 };
1321
1322 let kept: Vec<&str> = split_statements(rest)
1323 .into_iter()
1324 .filter(|s| !is_push_or_pr_creation_statement(s))
1325 .collect();
1326 if kept.is_empty() {
1327 continue;
1328 }
1329 kept_lines.push(format!("{indent}{marker}{}", kept.join(" ")));
1330 }
1331 while kept_lines.last().is_some_and(String::is_empty) {
1332 kept_lines.pop();
1333 }
1334 kept_lines.join("\n")
1335}
1336
1337fn split_statements(text: &str) -> Vec<&str> {
1338 static SEP: LazyLock<Regex> =
1339 LazyLock::new(|| Regex::new(r"(\.{2,}|(?:[.!?]))\s+").expect("valid statement separator"));
1340 let mut statements = Vec::new();
1341 let mut last = 0;
1342 for cap in SEP.captures_iter(text) {
1343 let full = cap.get(0).expect("full separator match");
1344 let punct = cap.get(1).expect("punctuation capture");
1345 let ellipsis = punct.as_str().len() >= 2 && punct.as_str().bytes().all(|b| b == b'.');
1346 let end = if ellipsis { punct.start() } else { punct.end() };
1347 let stmt = text[last..end].trim();
1348 if !stmt.is_empty() {
1349 statements.push(stmt);
1350 }
1351 last = full.end();
1352 }
1353 let rest = text[last..].trim();
1354 if !rest.is_empty() {
1355 statements.push(rest);
1356 }
1357 statements
1358}
1359
1360fn list_marker(line: &str) -> Option<(&str, &str)> {
1361 for prefix in ["- ", "* ", "+ "] {
1362 if let Some(rest) = line.strip_prefix(prefix) {
1363 return Some((prefix, rest));
1364 }
1365 }
1366 let dot = line.find(". ")?;
1367 if !line[..dot].is_empty() && line[..dot].bytes().all(|b| b.is_ascii_digit()) {
1368 Some((&line[..dot + 2], &line[dot + 2..]))
1369 } else {
1370 None
1371 }
1372}
1373
1374fn is_push_or_pr_creation_statement(text: &str) -> bool {
1375 static PAT: LazyLock<Regex> = LazyLock::new(|| {
1376 Regex::new(concat!(
1377 r"(?i)",
1378 r"\bgit\s+push\b",
1379 r"|\bgh\s+pr\s+create\b",
1380 r"|\bgh\s+pr\s+open\b",
1381 r"|\bpush\s+(was\s+)?(denied|blocked|rejected)\b",
1382 r"|\bpush\s+permission\b",
1383 r"|\bcouldn['’]?t\s+push\b",
1384 r"|\bcould\s+not\s+push\b",
1385 r"|\bcan['’]?t\s+push\b",
1386 r"|\bcannot\s+push\b",
1387 r"|\bdidn['’]?t\s+push\b",
1388 r"|\bdid\s+not\s+push\b",
1389 r"|\bnothing\s+is\s+on\s+the\s+remote\b",
1390 r"|\bnot\s+on\s+the\s+remote\b",
1391 r"|\bopen(?:ing)?\s+(?:the|a)\s+pr\b",
1392 r"|\bcreat(?:e|ing)\s+(?:the|a)\s+pr\b",
1393 r"|\bopen(?:ing)?\s+(?:the|a)\s+pull\s+request\b",
1394 r"|\bcreat(?:e|ing)\s+(?:the|a)\s+pull\s+request\b",
1395 ))
1396 .expect("valid push/pr-creation regex")
1397 });
1398 PAT.is_match(text)
1399}
1400
1401fn cleaned_pr_items(items: &[String]) -> Vec<String> {
1402 items
1403 .iter()
1404 .filter_map(|item| {
1405 let cleaned = strip_push_and_pr_creation(item);
1406 (!cleaned.is_empty()).then_some(cleaned)
1407 })
1408 .collect()
1409}
1410
1411#[allow(clippy::too_many_arguments)]
1413fn build_pr_body(
1414 title: &str,
1415 summary: &str,
1416 accomplished: &[String],
1417 remaining: &[String],
1418 files: &[String],
1419 task_id: &str,
1420 agent: &str,
1421 model: Option<&str>,
1422) -> String {
1423 let cleaned_summary = strip_push_and_pr_creation(summary);
1424 let summary_text = if cleaned_summary.is_empty() {
1425 title
1426 } else {
1427 cleaned_summary.as_str()
1428 };
1429 let accomplished = cleaned_pr_items(accomplished);
1430 let remaining = cleaned_pr_items(remaining);
1431
1432 let mut body = format!("## Summary\n\n{summary_text}");
1433
1434 if !accomplished.is_empty() {
1435 body.push_str("\n\n### What was done\n\n");
1436 for item in &accomplished {
1437 body.push_str(&format!("- {item}\n"));
1438 }
1439 }
1440
1441 if !remaining.is_empty() {
1442 body.push_str("\n\n### Remaining\n\n");
1443 for item in &remaining {
1444 body.push_str(&format!("- {item}\n"));
1445 }
1446 }
1447
1448 if !files.is_empty() {
1449 body.push_str("\n\n### Files changed\n\n");
1450 for file in files {
1451 body.push_str(&format!("- `{file}`\n"));
1452 }
1453 }
1454
1455 if !task_id.starts_with("internal:") {
1456 if let Ok(issue_num) = task_id.parse::<u64>() {
1457 body.push_str(&format!("\n\nCloses #{issue_num}"));
1458 }
1459 }
1460
1461 let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1462 let task_ref = format_task_ref(task_id);
1463 body.push_str(&format!(
1464 "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1465 ));
1466 body
1467}
1468
1469#[allow(clippy::too_many_arguments)]
1478pub async fn create_pr_if_needed(
1479 dir: &Path,
1480 branch: &str,
1481 title: &str,
1482 summary: &str,
1483 accomplished: &[String],
1484 remaining: &[String],
1485 files: &[String],
1486 task_id: &str,
1487 agent: &str,
1488 model: Option<&str>,
1489 repo: &str,
1490 base_branch: &str,
1491) -> PrCreateResult<String> {
1492 let gh = GhHttp::new()?;
1493
1494 match gh.get_pr_number(repo, branch).await {
1496 Ok(Some(pr_number)) => {
1497 tracing::info!(task_id, pr = pr_number, "PR already exists");
1498 append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model).await;
1500 let pr = match gh.get_pr(repo, pr_number).await {
1502 Ok(pr) => pr,
1503 Err(e) => {
1504 tracing::warn!(task_id, error = %e, "get_pr API call failed after detecting existing PR");
1505 return Err(PrCreateError::ApiError(e));
1506 }
1507 };
1508 return Ok(pr.html_url);
1509 }
1510 Ok(None) => {
1511 }
1513 Err(e) => {
1514 tracing::warn!(task_id, error = %e, "get_pr_number API call failed");
1515 return Err(PrCreateError::ApiError(e));
1516 }
1517 }
1518
1519 let git_files: Vec<String>;
1521 let effective_files: &[String] = if files.is_empty() {
1522 match tokio::process::Command::new("git")
1523 .args([
1524 "diff",
1525 &format!("origin/{base_branch}...HEAD"),
1526 "--name-only",
1527 ])
1528 .current_dir(dir)
1529 .output()
1530 .await
1531 {
1532 Ok(output) if output.status.success() => {
1533 git_files = String::from_utf8_lossy(&output.stdout)
1534 .lines()
1535 .filter(|l| !l.is_empty())
1536 .map(|l| l.to_string())
1537 .collect();
1538 &git_files
1539 }
1540 _ => files,
1541 }
1542 } else {
1543 files
1544 };
1545
1546 let body = build_pr_body(
1547 title,
1548 summary,
1549 accomplished,
1550 remaining,
1551 effective_files,
1552 task_id,
1553 agent,
1554 model,
1555 );
1556
1557 let pr_title = title;
1559
1560 const MAX_RETRIES: u32 = 3;
1562 let mut last_error: Option<anyhow::Error> = None;
1563 let mut created_url: Option<String> = None;
1564 let mut had_transient_error = false;
1565 let mut effective_base = base_branch.to_string();
1566 let mut tried_api_fallback = false;
1567
1568 for attempt in 1..=MAX_RETRIES {
1569 match gh
1570 .create_pr(repo, pr_title, &body, branch, &effective_base)
1571 .await
1572 {
1573 Ok(u) => {
1574 created_url = Some(u);
1575 break;
1576 }
1577 Err(e) => {
1578 let err_str = format!("{e}");
1579 if is_transient_github_error(&err_str) {
1580 had_transient_error = true;
1581 if attempt < MAX_RETRIES {
1582 let delay = std::time::Duration::from_secs(2u64.pow(attempt));
1583 tracing::warn!(
1584 task_id,
1585 attempt,
1586 delay_secs = delay.as_secs(),
1587 error = %e,
1588 "transient GitHub API error during PR creation — retrying"
1589 );
1590 tokio::time::sleep(delay).await;
1591 }
1592 } else if is_invalid_base_error(&err_str) && !tried_api_fallback {
1593 tried_api_fallback = true;
1596 tracing::warn!(
1597 task_id,
1598 base_branch = %effective_base,
1599 error = %e,
1600 "PR creation failed with invalid base — fetching default branch from GitHub API"
1601 );
1602 match gh.get_default_branch(repo).await {
1603 Ok(api_default) if api_default != effective_base => {
1604 tracing::info!(
1605 task_id,
1606 old_base = %effective_base,
1607 new_base = %api_default,
1608 "retrying PR creation with GitHub API default branch"
1609 );
1610 effective_base = api_default;
1611 continue;
1612 }
1613 Ok(api_default) => {
1614 tracing::warn!(
1615 task_id,
1616 base_branch = %api_default,
1617 "GitHub API default branch matches local — no fallback possible"
1618 );
1619 }
1620 Err(api_err) => {
1621 tracing::warn!(
1622 task_id,
1623 error = %api_err,
1624 "failed to fetch default branch from GitHub API"
1625 );
1626 }
1627 }
1628 }
1629 last_error = Some(e);
1630 }
1631 }
1632 }
1633
1634 let url = match created_url {
1635 Some(u) => u,
1636 None => {
1637 let e = last_error.unwrap_or_else(|| {
1638 anyhow::anyhow!("PR creation failed after retries (no error captured)")
1639 });
1640 let err_str = format!("{e}");
1641 if is_transient_github_error(&err_str) || had_transient_error {
1648 tracing::warn!(
1649 task_id,
1650 error = %e,
1651 "transient GitHub API error after all retries — checking if PR was actually created"
1652 );
1653 match gh.get_pr_number(repo, branch).await {
1654 Ok(Some(pr_number)) => {
1655 tracing::info!(
1656 task_id,
1657 pr = pr_number,
1658 "PR was created despite transient errors — recovering"
1659 );
1660 append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model)
1661 .await;
1662 match gh.get_pr(repo, pr_number).await {
1663 Ok(pr) => pr.html_url,
1664 Err(get_err) => {
1665 tracing::warn!(
1666 task_id,
1667 error = %get_err,
1668 "failed to fetch PR URL after transient-error recovery"
1669 );
1670 return Err(PrCreateError::ApiError(e));
1671 }
1672 }
1673 }
1674 Ok(None) => {
1675 tracing::warn!(
1676 task_id,
1677 "PR was not created after all retries — propagating original error"
1678 );
1679 return Err(PrCreateError::ApiError(e));
1680 }
1681 Err(check_err) => {
1682 tracing::warn!(
1683 task_id,
1684 error = %check_err,
1685 "failed to verify PR existence after transient error retries — propagating original error"
1686 );
1687 return Err(PrCreateError::ApiError(e));
1688 }
1689 }
1690 } else {
1691 return Err(PrCreateError::ApiError(e));
1692 }
1693 }
1694 };
1695
1696 tracing::info!(task_id, pr_url = %url, "created PR via GhHttp API");
1697
1698 Ok(url)
1699}
1700
1701pub(crate) fn is_transient_github_error(err_str: &str) -> bool {
1705 if err_str.contains("HTTP send failed") {
1709 return true;
1710 }
1711 if err_str.contains("GitHub API server error") {
1712 return true;
1713 }
1714
1715 if extract_github_http_status(err_str)
1717 .map(|s| (500..600).contains(&s))
1718 .unwrap_or(false)
1719 {
1720 return true;
1721 }
1722
1723 let lower = err_str.to_lowercase();
1725
1726 if lower.contains("connection reset")
1728 || lower.contains("broken pipe")
1729 || lower.contains("connection refused")
1730 || lower.contains("connection closed")
1731 || lower.contains("transport error")
1732 || lower.contains("failed to connect")
1733 || lower.contains("failed to connect to github.com port")
1734 || lower.contains("curl: (7) failed to connect")
1735 || lower.contains("couldn't connect to server")
1736 || lower.contains("could not connect to server")
1737 {
1738 return true;
1739 }
1740
1741 if lower.contains("timeout") {
1743 return true;
1744 }
1745
1746 if lower.contains("dns error")
1748 || lower.contains("resolve")
1749 || lower.contains("name resolution")
1750 || lower.contains("no such host")
1751 {
1752 return true;
1753 }
1754
1755 if lower.contains("tls handshake")
1757 || lower.contains("certificate")
1758 || lower.contains("ssl error")
1759 {
1760 return true;
1761 }
1762
1763 if lower.contains("network is unreachable")
1765 || lower.contains("host unreachable")
1766 || lower.contains("temporary failure")
1767 {
1768 return true;
1769 }
1770
1771 if lower.contains("unexpected eof") || lower.contains("unexpected end of file") {
1773 return true;
1774 }
1775
1776 if lower.contains("circuit-breaker") || lower.contains("circuit breaker") {
1778 return true;
1779 }
1780
1781 if lower.contains("rate limit") || lower.contains("too many requests") {
1783 return true;
1784 }
1785
1786 false
1787}
1788
1789pub(crate) fn is_invalid_base_error(err_str: &str) -> bool {
1795 let lower = err_str.to_lowercase();
1796 lower.contains("422") && lower.contains("base") && lower.contains("invalid")
1797}
1798
1799pub(crate) fn is_transient_github_api_error(err_str: &str) -> bool {
1807 if !is_transient_github_error(err_str) {
1808 return false;
1809 }
1810
1811 if extract_github_http_status(err_str).is_some() {
1812 return true;
1813 }
1814
1815 let lower = err_str.to_lowercase();
1816 lower.contains("github")
1817 || lower.contains("api.github.com")
1818 || lower.contains("gh api")
1819 || lower.contains("gh pr")
1820 || lower.contains("gh error")
1821 || lower.contains("circuit-breaker")
1826 || lower.contains("circuit breaker")
1827}
1828
1829fn extract_github_http_status(err_str: &str) -> Option<u16> {
1834 let marker = "failed (";
1835 let start = err_str.find(marker)? + marker.len();
1836 let rest = &err_str[start..];
1837 let end = rest.find(')')?;
1838 rest[..end].parse().ok()
1839}
1840
1841async fn append_pr_footer_if_missing(
1846 gh: &GhHttp,
1847 repo: &str,
1848 pr_number: u64,
1849 task_id: &str,
1850 agent: &str,
1851 model: Option<&str>,
1852) {
1853 let pr = match gh.get_pr(repo, pr_number).await {
1854 Ok(pr) => pr,
1855 Err(e) => {
1856 tracing::debug!(task_id, error = %e, "failed to fetch PR for footer check");
1857 return;
1858 }
1859 };
1860
1861 let body = pr.body.unwrap_or_default();
1862 if body.contains("via [Orch]") {
1863 return; }
1865
1866 let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1867 let task_ref = format_task_ref(task_id);
1868 let footer = format!(
1869 "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1870 );
1871 let new_body = format!("{body}{footer}");
1872
1873 if let Err(e) = gh.update_pr_body(repo, pr_number, &new_body).await {
1874 tracing::warn!(task_id, error = %e, "failed to append footer to PR (non-fatal)");
1875 } else {
1876 tracing::info!(
1877 task_id,
1878 pr = pr_number,
1879 "appended attribution footer to agent-created PR"
1880 );
1881 }
1882}
1883
1884async fn get_current_branch(dir: &Path) -> String {
1886 let output = Command::new("git")
1887 .args(["branch", "--show-current"])
1888 .current_dir(dir)
1889 .output_with_context()
1890 .await;
1891
1892 match output {
1893 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
1894 _ => String::new(),
1895 }
1896}
1897
1898pub async fn count_changed_files(dir: &Path) -> anyhow::Result<usize> {
1900 let output = Command::new("git")
1902 .args(["status", "--porcelain"])
1903 .current_dir(dir)
1904 .output_with_context()
1905 .await?;
1906
1907 let status_output = String::from_utf8_lossy(&output.stdout);
1908 let count = status_output
1909 .lines()
1910 .filter(|line| {
1911 let prefix = line.get(0..2).unwrap_or("");
1912 prefix.starts_with('M')
1914 || prefix.starts_with('A')
1915 || prefix.starts_with('R')
1916 || prefix.starts_with('C')
1917 || prefix.starts_with('?')
1918 })
1919 .count();
1920
1921 Ok(count)
1922}
1923
1924async fn check_push_needed(dir: &Path, branch: &str, default_branch: &str) -> (bool, bool) {
1932 let remote_exists = Command::new("git")
1934 .args(["rev-parse", &format!("origin/{branch}")])
1935 .current_dir(dir)
1936 .output_with_context()
1937 .await
1938 .map(|o| o.status.success())
1939 .unwrap_or(false);
1940
1941 let compare_ref = if remote_exists {
1942 format!("origin/{branch}..HEAD")
1943 } else {
1944 format!("origin/{default_branch}..HEAD")
1947 };
1948
1949 tracing::debug!(
1950 branch,
1951 compare_ref,
1952 remote_exists,
1953 "checking unpushed commits"
1954 );
1955
1956 let output = Command::new("git")
1957 .args(["log", &compare_ref, "--oneline"])
1958 .current_dir(dir)
1959 .output_with_context()
1960 .await;
1961
1962 let has_unpushed = match output {
1963 Ok(o) if o.status.success() => {
1964 let out = String::from_utf8_lossy(&o.stdout);
1965 let has = !out.trim().is_empty();
1966 tracing::debug!(branch, has_unpushed = has, "unpushed commits check result");
1967 has
1968 }
1969 _ => {
1970 tracing::debug!(branch, "git log failed for compare ref, assuming unpushed");
1971 true
1974 }
1975 };
1976
1977 (remote_exists, has_unpushed)
1978}
1979
1980#[cfg(test)]
1981mod tests {
1982 use super::*;
1983
1984 #[test]
1985 fn pr_create_error_display() {
1986 let err = PrCreateError::ApiError(anyhow::anyhow!("test error"));
1987 assert_eq!(format!("{}", err), "GitHub API error: test error");
1988 }
1989
1990 #[test]
1991 fn pr_create_error_from_anyhow() {
1992 let anyhow_err = anyhow::anyhow!("source error");
1993 let pr_err: PrCreateError = anyhow_err.into();
1994 assert!(matches!(pr_err, PrCreateError::ApiError(_)));
1995 }
1996
1997 #[test]
1998 fn build_git_auth_env_without_token_falls_back_to_ssh_https_conversion() {
1999 let env = build_git_auth_env_for_token(None);
2002
2003 let has_ssh_rule = env
2006 .iter()
2007 .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "git@github.com:");
2008 assert!(
2009 has_ssh_rule,
2010 "expected SSH insteadOf fallback (value is git@github.com:), got: {env:?}"
2011 );
2012 }
2013
2014 #[test]
2015 fn build_git_auth_env_with_token_covers_both_ssh_and_https() {
2016 let env = build_git_auth_env_for_token(Some("ghp_testtoken1234".into()));
2018
2019 let has_token = env
2021 .iter()
2022 .any(|(_, v)| v.contains("x-access-token:ghp_testtoken1234@github.com"));
2023 assert!(has_token, "expected token in auth URL, got: {env:?}");
2024 let has_https_rule = env
2026 .iter()
2027 .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "https://github.com/");
2028 assert!(
2029 has_https_rule,
2030 "expected HTTPS insteadOf rule, got: {env:?}"
2031 );
2032 let has_ssh_rule = env
2034 .iter()
2035 .any(|(k, v)| k == "GIT_CONFIG_VALUE_1" && v == "git@github.com:");
2036 assert!(has_ssh_rule, "expected SSH insteadOf rule, got: {env:?}");
2037 }
2038
2039 #[test]
2040 fn find_stash_ref_by_hash_returns_matching_reference() {
2041 let stash_list = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{1}\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb stash@{0}";
2042
2043 assert_eq!(
2044 find_stash_ref_by_hash(stash_list, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
2045 Some("stash@{1}".to_string())
2046 );
2047 assert_eq!(
2048 find_stash_ref_by_hash(stash_list, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
2049 Some("stash@{0}".to_string())
2050 );
2051 }
2052
2053 #[test]
2054 fn find_stash_ref_by_hash_returns_none_when_missing_or_malformed() {
2055 let stash_list = "malformed-line\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{2}";
2056
2057 assert_eq!(
2058 find_stash_ref_by_hash(stash_list, "cccccccccccccccccccccccccccccccccccccccc"),
2059 None
2060 );
2061 assert_eq!(find_stash_ref_by_hash("", "anything"), None);
2062 }
2063
2064 #[test]
2065 fn push_needs_rebase_detects_common_rejections() {
2066 assert!(push_needs_rebase(
2067 "! [rejected] branch -> branch (fetch first)"
2068 ));
2069 assert!(push_needs_rebase("non-fast-forward update was rejected"));
2070 assert!(push_needs_rebase(
2071 "push rejected because the remote contains work"
2072 ));
2073 }
2074
2075 #[test]
2076 fn is_transient_network_error_detects_lfs_locks_timeout() {
2077 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";
2078 assert!(is_transient_network_error(lfs_err));
2079 assert!(is_transient_network_error(
2080 "dial tcp 140.82.112.4:443: connect: connection refused"
2081 ));
2082 assert!(is_transient_network_error(
2083 "read tcp 10.0.0.1:51234->140.82.112.4:443: read: connection reset by peer"
2084 ));
2085 }
2086
2087 #[test]
2088 fn is_transient_network_error_rejects_ref_rejections() {
2089 assert!(!is_transient_network_error(
2090 "! [rejected] branch -> branch (fetch first)"
2091 ));
2092 assert!(!is_transient_network_error(
2093 "remote: error: GH006: Protected branch update failed"
2094 ));
2095 }
2096
2097 #[test]
2098 fn sanitize_push_error_strips_url_credentials() {
2099 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";
2101 let sanitized = sanitize_push_error(raw);
2102 assert!(!sanitized.contains("test-secret-1234567890abcdef"));
2103 assert!(sanitized.contains("://***@github.com"));
2104 assert!(sanitized.contains("i/o timeout"));
2105 }
2106
2107 #[test]
2108 fn extract_github_http_status_parses_ghhttp_error_format() {
2109 let err =
2110 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
2111 assert_eq!(extract_github_http_status(err), Some(502));
2112
2113 let err500 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (500): Internal Server Error";
2114 assert_eq!(extract_github_http_status(err500), Some(500));
2115
2116 let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
2117 assert_eq!(extract_github_http_status(err422), Some(422));
2118
2119 let not_github = "some other error";
2120 assert_eq!(extract_github_http_status(not_github), None);
2121 }
2122
2123 #[test]
2124 fn is_transient_github_error_matches_5xx_only() {
2125 let err502 =
2126 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
2127 assert!(is_transient_github_error(err502));
2128
2129 let err503 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (503): Service Unavailable";
2130 assert!(is_transient_github_error(err503));
2131
2132 let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
2133 assert!(!is_transient_github_error(err422));
2134
2135 let err404 =
2136 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (404): Not Found";
2137 assert!(!is_transient_github_error(err404));
2138 }
2139
2140 #[test]
2141 fn is_transient_github_error_matches_connection_errors() {
2142 assert!(is_transient_github_error("connection refused"));
2144 assert!(is_transient_github_error("Connection reset by peer"));
2145 assert!(is_transient_github_error("broken pipe"));
2146 assert!(is_transient_github_error("connection closed unexpectedly"));
2147 assert!(is_transient_github_error(
2148 "transport error: connection lost"
2149 ));
2150
2151 assert!(is_transient_github_error(
2153 "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"
2154 ));
2155 assert!(is_transient_github_error(
2156 "Failed to connect to github.com port 443: Connection timed out"
2157 ));
2158 assert!(is_transient_github_error(
2159 "Failed to connect to github.com port 443: Connection refused"
2160 ));
2161 assert!(is_transient_github_error(
2162 "curl: (7) Failed to connect to github.com port 443: Couldn't connect to server"
2163 ));
2164 assert!(is_transient_github_error(
2165 "fatal: unable to access 'https://github.com/owner/repo.git/': Couldn't connect to server"
2166 ));
2167 }
2168
2169 #[test]
2170 fn is_transient_github_error_matches_dns_errors() {
2171 assert!(is_transient_github_error("dns error: failed to lookup"));
2173 assert!(is_transient_github_error("failed to resolve hostname"));
2174 assert!(is_transient_github_error("name resolution failed"));
2175 assert!(is_transient_github_error("no such host: api.github.com"));
2176 }
2177
2178 #[test]
2179 fn is_transient_github_error_matches_tls_errors() {
2180 assert!(is_transient_github_error("tls handshake failed"));
2182 assert!(is_transient_github_error("certificate verification failed"));
2183 assert!(is_transient_github_error("ssl error: unknown ca"));
2184 }
2185
2186 #[test]
2187 fn is_transient_github_error_matches_network_unreachable() {
2188 assert!(is_transient_github_error("network is unreachable"));
2190 assert!(is_transient_github_error("host unreachable"));
2191 assert!(is_transient_github_error(
2192 "temporary failure in name resolution"
2193 ));
2194 }
2195
2196 #[test]
2197 fn is_transient_github_error_matches_eof_errors() {
2198 assert!(is_transient_github_error("unexpected eof while reading"));
2200 assert!(is_transient_github_error("unexpected end of file"));
2201 }
2202
2203 #[test]
2204 fn is_transient_github_error_matches_rate_limit() {
2205 assert!(is_transient_github_error("rate limit exceeded"));
2207 assert!(is_transient_github_error("429 Too Many Requests"));
2208 assert!(is_transient_github_error("too many requests, retry later"));
2209 }
2210
2211 #[test]
2212 fn is_transient_github_error_matches_timeout() {
2213 assert!(is_transient_github_error("operation timeout"));
2215 assert!(is_transient_github_error("request timeout"));
2216 }
2217
2218 #[test]
2219 fn is_transient_github_error_rejects_non_transient() {
2220 assert!(!is_transient_github_error("permission denied"));
2222 assert!(!is_transient_github_error("file not found"));
2223 assert!(!is_transient_github_error("invalid argument"));
2224 assert!(!is_transient_github_error("authentication failed"));
2225 assert!(!is_transient_github_error("bad credentials"));
2226 }
2227
2228 #[test]
2229 fn is_transient_github_api_error_requires_github_context() {
2230 assert!(is_transient_github_api_error(
2231 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway"
2232 ));
2233 assert!(is_transient_github_api_error(
2234 "gh error: transport error: connection reset by peer"
2235 ));
2236 assert!(is_transient_github_api_error(
2237 "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"
2238 ));
2239 assert!(!is_transient_github_api_error(
2240 "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)"
2241 ));
2242 assert!(!is_transient_github_api_error(
2243 "agent stream failed: failed to connect to local relay"
2244 ));
2245 }
2246
2247 #[test]
2248 fn is_transient_github_api_error_matches_circuit_breaker() {
2249 assert!(is_transient_github_api_error(
2251 "GitHub API transient 5xx circuit-breaker active for 240s"
2252 ));
2253 assert!(is_transient_github_api_error(
2255 "create PR failed: GitHub API error: GitHub API transient 5xx circuit-breaker active"
2256 ));
2257 assert!(is_transient_github_api_error(
2260 "create PR failed: circuit-breaker"
2261 ));
2262 assert!(is_transient_github_api_error(
2264 "create PR failed: circuit breaker tripped"
2265 ));
2266 }
2267
2268 #[tokio::test]
2269 async fn auto_commit_unstages_files_on_commit_failure() {
2270 let dir =
2272 std::env::temp_dir().join(format!("orch_test_auto_commit_{}", std::process::id()));
2273 let _ = std::fs::remove_dir_all(&dir);
2274 std::fs::create_dir_all(&dir).unwrap();
2275
2276 let _ = Command::new("git")
2278 .args(["init"])
2279 .current_dir(&dir)
2280 .output()
2281 .await;
2282 let _ = Command::new("git")
2283 .args(["config", "user.email", "test@test.com"])
2284 .current_dir(&dir)
2285 .output()
2286 .await;
2287 let _ = Command::new("git")
2288 .args(["config", "user.name", "Test"])
2289 .current_dir(&dir)
2290 .output()
2291 .await;
2292 std::fs::write(dir.join("init.txt"), "init").unwrap();
2294 let _ = Command::new("git")
2295 .args(["add", "init.txt"])
2296 .current_dir(&dir)
2297 .output()
2298 .await;
2299 let _ = Command::new("git")
2300 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2301 .current_dir(&dir)
2302 .output()
2303 .await;
2304
2305 std::fs::write(dir.join("new_file.txt"), "content").unwrap();
2307
2308 assert!(has_changes(&dir).await);
2310
2311 let result = auto_commit(&dir, "1", "Test title", "test", 1).await;
2316 assert!(result.unwrap());
2317
2318 assert!(!has_changes(&dir).await);
2320
2321 let _ = std::fs::remove_dir_all(&dir);
2323 }
2324
2325 #[tokio::test]
2326 async fn auto_commit_ignores_repo_signing_config() {
2327 let dir =
2330 std::env::temp_dir().join(format!("orch_test_auto_commit_gpg_{}", std::process::id()));
2331 let _ = std::fs::remove_dir_all(&dir);
2332 std::fs::create_dir_all(&dir).unwrap();
2333
2334 for args in [
2335 vec!["init"],
2336 vec!["config", "user.email", "test@test.com"],
2337 vec!["config", "user.name", "Test"],
2338 vec!["config", "commit.gpgsign", "true"],
2339 vec!["config", "gpg.program", "/usr/bin/false"],
2340 ] {
2341 let _ = Command::new("git")
2342 .args(&args)
2343 .current_dir(&dir)
2344 .output()
2345 .await;
2346 }
2347 std::fs::write(dir.join("init.txt"), "init").unwrap();
2348 let _ = Command::new("git")
2349 .args(["add", "init.txt"])
2350 .current_dir(&dir)
2351 .output()
2352 .await;
2353 let _ = Command::new("git")
2354 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2355 .current_dir(&dir)
2356 .output()
2357 .await;
2358
2359 std::fs::write(dir.join("signed_off.txt"), "content").unwrap();
2360 assert!(has_changes(&dir).await);
2361
2362 let result = auto_commit(&dir, "3", "Test title", "test", 1).await;
2363 assert!(result.unwrap());
2364 assert!(!has_changes(&dir).await);
2365
2366 let _ = std::fs::remove_dir_all(&dir);
2367 }
2368
2369 #[tokio::test]
2370 async fn auto_commit_unstages_on_pre_commit_hook_failure() {
2371 let dir =
2373 std::env::temp_dir().join(format!("orch_test_auto_commit_hook_{}", std::process::id()));
2374 let _ = std::fs::remove_dir_all(&dir);
2375 std::fs::create_dir_all(&dir).unwrap();
2376
2377 let _ = Command::new("git")
2379 .args(["init"])
2380 .current_dir(&dir)
2381 .output()
2382 .await;
2383 let _ = Command::new("git")
2384 .args(["config", "user.email", "test@test.com"])
2385 .current_dir(&dir)
2386 .output()
2387 .await;
2388 let _ = Command::new("git")
2389 .args(["config", "user.name", "Test"])
2390 .current_dir(&dir)
2391 .output()
2392 .await;
2393 std::fs::write(dir.join("init.txt"), "init").unwrap();
2395 let _ = Command::new("git")
2396 .args(["add", "init.txt"])
2397 .current_dir(&dir)
2398 .output()
2399 .await;
2400 let _ = Command::new("git")
2401 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2402 .current_dir(&dir)
2403 .output()
2404 .await;
2405
2406 let hooks_dir = dir.join(".git/hooks");
2408 std::fs::create_dir_all(&hooks_dir).unwrap();
2409 std::fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\nexit 1\n").unwrap();
2410 #[cfg(unix)]
2411 {
2412 use std::os::unix::fs::PermissionsExt;
2413 let mut perms = std::fs::metadata(hooks_dir.join("pre-commit"))
2414 .unwrap()
2415 .permissions();
2416 perms.set_mode(0o755);
2417 std::fs::set_permissions(hooks_dir.join("pre-commit"), perms).unwrap();
2418 }
2419
2420 std::fs::write(dir.join("staged_file.txt"), "content").unwrap();
2422
2423 assert!(has_changes(&dir).await);
2425
2426 let result = auto_commit(&dir, "2", "Test title", "test", 1).await;
2428 assert!(!result.unwrap());
2429
2430 assert!(has_changes(&dir).await);
2433
2434 let cached = Command::new("git")
2436 .args(["diff", "--cached", "--quiet"])
2437 .current_dir(&dir)
2438 .status()
2439 .await;
2440 assert!(
2441 cached.map(|s| s.success()).unwrap_or(false),
2442 "files should be unstaged after commit failure"
2443 );
2444
2445 let _ = std::fs::remove_dir_all(&dir);
2447 }
2448
2449 #[test]
2450 fn is_workflow_scope_error_detects_backtick_format() {
2451 let err = "! [remote rejected] branch -> branch (refusing to allow an OAuth App to create or update workflow `.github/workflows/ci.yml` without `workflow` scope)";
2452 assert!(is_workflow_scope_error(err));
2453 }
2454
2455 #[test]
2456 fn is_workflow_scope_error_detects_single_quote_format() {
2457 let err = "refusing to allow an OAuth App to create or update workflow '.github/workflows/ci.yml' without 'workflow' scope";
2458 assert!(is_workflow_scope_error(err));
2459 }
2460
2461 #[test]
2462 fn is_workflow_scope_error_detects_refusing_to_allow_variant() {
2463 let err = "remote: refusing to allow a Personal Access Token to create or update workflow `.github/workflows/scan.yml` without `workflow` scope";
2464 assert!(is_workflow_scope_error(err));
2465 }
2466
2467 #[test]
2468 fn is_workflow_scope_error_rejects_unrelated_errors() {
2469 assert!(!is_workflow_scope_error("non-fast-forward"));
2470 assert!(!is_workflow_scope_error("permission denied"));
2471 assert!(!is_workflow_scope_error("authentication failed"));
2472 assert!(!is_workflow_scope_error("rejected"));
2473 }
2474
2475 #[tokio::test]
2476 async fn strip_workflow_files_removes_workflow_from_commits() {
2477 let dir = std::env::temp_dir().join(format!("orch_test_strip_wf_{}", std::process::id()));
2479 let _ = std::fs::remove_dir_all(&dir);
2480 std::fs::create_dir_all(&dir).unwrap();
2481
2482 let _ = Command::new("git")
2484 .args(["init"])
2485 .current_dir(&dir)
2486 .output()
2487 .await;
2488 let _ = Command::new("git")
2489 .args(["config", "user.email", "test@test.com"])
2490 .current_dir(&dir)
2491 .output()
2492 .await;
2493 let _ = Command::new("git")
2494 .args(["config", "user.name", "Test"])
2495 .current_dir(&dir)
2496 .output()
2497 .await;
2498 std::fs::write(dir.join("init.txt"), "init").unwrap();
2499 let _ = Command::new("git")
2500 .args(["add", "."])
2501 .current_dir(&dir)
2502 .output()
2503 .await;
2504 let _ = Command::new("git")
2505 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2506 .current_dir(&dir)
2507 .output()
2508 .await;
2509
2510 let _ = Command::new("git")
2512 .args(["remote", "add", "origin", dir.to_str().unwrap()])
2513 .current_dir(&dir)
2514 .output()
2515 .await;
2516 let _ = Command::new("git")
2517 .args(["fetch", "origin"])
2518 .current_dir(&dir)
2519 .output()
2520 .await;
2521
2522 let _ = Command::new("git")
2524 .args(["checkout", "-b", "feature"])
2525 .current_dir(&dir)
2526 .output()
2527 .await;
2528
2529 std::fs::write(dir.join("feature.txt"), "feature work").unwrap();
2531 let wf_dir = dir.join(".github/workflows");
2532 std::fs::create_dir_all(&wf_dir).unwrap();
2533 std::fs::write(wf_dir.join("ci.yml"), "name: CI\non: push").unwrap();
2534 let _ = Command::new("git")
2535 .args(["add", "."])
2536 .current_dir(&dir)
2537 .output()
2538 .await;
2539 let _ = Command::new("git")
2540 .args([
2541 "-c",
2542 "commit.gpgsign=false",
2543 "commit",
2544 "-m",
2545 "add feature and workflow",
2546 ])
2547 .current_dir(&dir)
2548 .output()
2549 .await;
2550
2551 let branch_out = Command::new("git")
2553 .args(["rev-parse", "--abbrev-ref", "origin/HEAD"])
2554 .current_dir(&dir)
2555 .output()
2556 .await;
2557 let default_branch = branch_out
2559 .ok()
2560 .filter(|o| o.status.success())
2561 .map(|o| {
2562 String::from_utf8_lossy(&o.stdout)
2563 .trim()
2564 .strip_prefix("origin/")
2565 .unwrap_or("main")
2566 .to_string()
2567 })
2568 .unwrap_or_else(|| "main".to_string());
2569
2570 let stripped = strip_workflow_files(&dir, &default_branch).await.unwrap();
2572 assert!(stripped, "should have stripped workflow files");
2573
2574 let ls_output = Command::new("git")
2576 .args(["ls-tree", "-r", "--name-only", "HEAD"])
2577 .current_dir(&dir)
2578 .output()
2579 .await
2580 .unwrap();
2581 let files = String::from_utf8_lossy(&ls_output.stdout);
2582 assert!(
2583 !files.contains(".github/workflows/ci.yml"),
2584 "workflow file should be removed from commits"
2585 );
2586 assert!(
2587 files.contains("feature.txt"),
2588 "non-workflow files should be preserved"
2589 );
2590
2591 let _ = std::fs::remove_dir_all(&dir);
2593 }
2594
2595 #[tokio::test]
2596 async fn strip_workflow_files_returns_false_when_no_workflows() {
2597 let dir =
2598 std::env::temp_dir().join(format!("orch_test_strip_wf_none_{}", std::process::id()));
2599 let _ = std::fs::remove_dir_all(&dir);
2600 std::fs::create_dir_all(&dir).unwrap();
2601
2602 let _ = Command::new("git")
2604 .args(["init"])
2605 .current_dir(&dir)
2606 .output()
2607 .await;
2608 let _ = Command::new("git")
2609 .args(["config", "user.email", "test@test.com"])
2610 .current_dir(&dir)
2611 .output()
2612 .await;
2613 let _ = Command::new("git")
2614 .args(["config", "user.name", "Test"])
2615 .current_dir(&dir)
2616 .output()
2617 .await;
2618 std::fs::write(dir.join("init.txt"), "init").unwrap();
2619 let _ = Command::new("git")
2620 .args(["add", "."])
2621 .current_dir(&dir)
2622 .output()
2623 .await;
2624 let _ = Command::new("git")
2625 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2626 .current_dir(&dir)
2627 .output()
2628 .await;
2629
2630 let _ = Command::new("git")
2631 .args(["remote", "add", "origin", dir.to_str().unwrap()])
2632 .current_dir(&dir)
2633 .output()
2634 .await;
2635 let _ = Command::new("git")
2636 .args(["fetch", "origin"])
2637 .current_dir(&dir)
2638 .output()
2639 .await;
2640
2641 let _ = Command::new("git")
2642 .args(["checkout", "-b", "feature2"])
2643 .current_dir(&dir)
2644 .output()
2645 .await;
2646
2647 std::fs::write(dir.join("feature.txt"), "feature").unwrap();
2649 let _ = Command::new("git")
2650 .args(["add", "."])
2651 .current_dir(&dir)
2652 .output()
2653 .await;
2654 let _ = Command::new("git")
2655 .args([
2656 "-c",
2657 "commit.gpgsign=false",
2658 "commit",
2659 "-m",
2660 "add feature only",
2661 ])
2662 .current_dir(&dir)
2663 .output()
2664 .await;
2665
2666 let stripped = strip_workflow_files(&dir, "main").await.unwrap();
2667 assert!(!stripped, "should not strip when no workflow files exist");
2668
2669 let _ = std::fs::remove_dir_all(&dir);
2671 }
2672
2673 #[test]
2674 fn is_invalid_base_error_detects_base_invalid() {
2675 assert!(is_invalid_base_error(
2676 "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): {\"errors\":[{\"resource\":\"PullRequest\",\"field\":\"base\",\"code\":\"invalid\"}]}"
2677 ));
2678 assert!(is_invalid_base_error(
2679 "GitHub API POST .../pulls failed (422): Base field is invalid"
2680 ));
2681 }
2682
2683 #[test]
2684 fn is_invalid_base_error_rejects_non_base_errors() {
2685 assert!(!is_invalid_base_error(
2687 "GitHub API POST .../pulls failed (422): No commits between main and branch"
2688 ));
2689 assert!(!is_invalid_base_error(
2691 "GitHub API POST .../pulls failed (422): head is not a valid ref"
2692 ));
2693 assert!(!is_invalid_base_error(
2695 "GitHub API POST .../pulls failed (502): Bad Gateway"
2696 ));
2697 assert!(!is_invalid_base_error(
2699 "GitHub API POST .../pulls failed (404): Not Found"
2700 ));
2701 }
2702
2703 fn pr_summary(body: &str) -> &str {
2704 let rest = body
2705 .strip_prefix("## Summary\n\n")
2706 .expect("PR body starts with ## Summary");
2707 rest.split("\n\n").next().unwrap()
2708 }
2709
2710 fn assert_no_push_or_pr_creation(body: &str) {
2711 let lower = body.to_ascii_lowercase();
2712 for needle in [
2713 "git push",
2714 "gh pr create",
2715 "did not push",
2716 "couldn't push",
2717 "could not push",
2718 "push was denied",
2719 "push was blocked",
2720 "push permission",
2721 "nothing is on the remote",
2722 "open the pr",
2723 ] {
2724 assert!(
2725 !lower.contains(needle),
2726 "PR body still contains {needle:?}:\n{body}"
2727 );
2728 }
2729 }
2730
2731 #[test]
2732 fn build_pr_body_strips_push_and_pr_creation_statements() {
2733 let examples = [
2734 (
2735 concat!(
2736 "Added the lesson list page. ",
2737 "I did not push or open the PR, because the push was blocked on a permission prompt. ",
2738 "Run `git push -u origin HEAD`, then `gh pr create`.",
2739 ),
2740 "Added the lesson list page.",
2741 ),
2742 (
2743 "It is committed locally ... The push was denied, so nothing is on the remote yet.",
2744 "It is committed locally",
2745 ),
2746 (
2747 concat!(
2748 "Fixed the quiz scoring. ",
2749 "I couldn't push it because the push permission was denied.",
2750 ),
2751 "Fixed the quiz scoring.",
2752 ),
2753 ];
2754 for (summary, work) in examples {
2755 let body = build_pr_body(
2756 "Fix the widget",
2757 summary,
2758 &[
2759 "Wrote the lesson list".into(),
2760 "Could not push because the push permission was denied".into(),
2761 ],
2762 &["Run `git push -u origin HEAD`".into()],
2763 &[],
2764 "16",
2765 "claude",
2766 None,
2767 );
2768 assert_eq!(pr_summary(&body), work);
2769 assert!(body.contains("Wrote the lesson list"));
2770 assert!(!body.contains("### Remaining"));
2771 assert_no_push_or_pr_creation(&body);
2772 }
2773 }
2774
2775 #[test]
2776 fn build_pr_body_keeps_summary_without_push_text() {
2777 let summary = "Added retry logic for GitHub 5xx errors.";
2778 let accomplished = vec!["Extracted the PR body builder".into()];
2779 let remaining = vec!["Watch CI after merge".into()];
2780 let body = build_pr_body(
2781 "Fix the widget",
2782 summary,
2783 &accomplished,
2784 &remaining,
2785 &["src/engine/runner/git_ops.rs".into()],
2786 "16",
2787 "claude",
2788 Some("sonnet"),
2789 );
2790 assert_eq!(pr_summary(&body), summary);
2791 assert!(body.contains("### What was done\n\n- Extracted the PR body builder\n"));
2792 assert!(body.contains("### Remaining\n\n- Watch CI after merge\n"));
2793 assert!(body.contains("- `src/engine/runner/git_ops.rs`"));
2794 assert!(body.contains(" using `sonnet`"));
2795 }
2796
2797 fn sh_git(dir: &std::path::Path, args: &[&str]) {
2798 let out = std::process::Command::new("git")
2799 .args([
2800 "-c",
2801 "commit.gpgsign=false",
2802 "-c",
2803 "user.name=T",
2804 "-c",
2805 "user.email=t@t",
2806 ])
2807 .args(args)
2808 .current_dir(dir)
2809 .output()
2810 .unwrap();
2811 assert!(
2812 out.status.success(),
2813 "git {args:?}: {}",
2814 String::from_utf8_lossy(&out.stderr)
2815 );
2816 }
2817
2818 fn commit_file(dir: &std::path::Path, name: &str) {
2819 std::fs::write(dir.join(name), name).unwrap();
2820 sh_git(dir, &["add", name]);
2821 sh_git(dir, &["commit", "-m", name]);
2822 }
2823
2824 #[tokio::test]
2830 async fn push_branch_recovers_from_stale_lease_after_remote_moved() {
2831 let temp = tempfile::TempDir::new().unwrap();
2832 let bare = temp.path().join("origin.git");
2833 let a = temp.path().join("a");
2834 let b = temp.path().join("b");
2835 std::fs::create_dir_all(&bare).unwrap();
2836 sh_git(&bare, &["init", "--bare", "-b", "main"]);
2837 sh_git(temp.path(), &["clone", bare.to_str().unwrap(), "a"]);
2838 sh_git(&a, &["config", "user.name", "T"]);
2840 sh_git(&a, &["config", "user.email", "t@t"]);
2841 commit_file(&a, "base.txt");
2842 sh_git(&a, &["push", "origin", "main"]);
2843 sh_git(&a, &["checkout", "-b", "feat"]);
2844 commit_file(&a, "feature.txt");
2845 sh_git(&a, &["push", "-u", "origin", "feat"]);
2846
2847 sh_git(temp.path(), &["clone", bare.to_str().unwrap(), "b"]);
2848 commit_file(&b, "main-change.txt");
2849 sh_git(&b, &["push", "origin", "main"]);
2850 sh_git(&b, &["checkout", "feat"]);
2851 commit_file(&b, "update-branch.txt");
2852 sh_git(&b, &["push", "origin", "feat"]);
2853
2854 sh_git(&a, &["fetch", "origin", "main"]);
2856 sh_git(&a, &["rebase", "origin/main"]);
2857
2858 assert!(push_branch(&a, "feat", "main").await.unwrap());
2859
2860 sh_git(&b, &["fetch", "origin", "feat"]);
2861 sh_git(&b, &["checkout", "origin/feat"]);
2862 for name in [
2863 "base.txt",
2864 "feature.txt",
2865 "main-change.txt",
2866 "update-branch.txt",
2867 ] {
2868 assert!(b.join(name).exists(), "remote feat lost {name}");
2869 }
2870 }
2871
2872 #[test]
2873 fn stale_lease_error_matches_git_messages() {
2874 assert!(is_stale_lease_error(
2875 "! [rejected] feat -> feat (stale info)"
2876 ));
2877 assert!(is_stale_lease_error(
2878 "! [rejected] feat -> feat (remote ref updated since checkout)"
2879 ));
2880 assert!(!is_stale_lease_error(
2881 "! [rejected] feat -> feat (fetch first)"
2882 ));
2883 }
2884}