Skip to main content

orch/engine/runner/
git_ops.rs

1//! Git operations — auto-commit, push, PR creation.
2//!
3//! These run after the agent completes to ensure all changes
4//! are committed, pushed, and a PR is created.
5//!
6//! All GitHub API operations use [`GhHttp`] exclusively. The `gh` CLI
7//! is not used as a fallback to ensure consistent behavior across
8//! environments and simplify testing.
9
10use 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
18/// Format a task ID for display in PR/issue footers.
19///
20/// GitHub task IDs are displayed as `#123` (creates a GH issue hyperlink).
21/// Internal task IDs (`internal:13`) are displayed as `internal-13` (no `#`
22/// to avoid creating a false issue link).
23pub(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/// Errors that can occur during PR creation.
32#[derive(Debug, thiserror::Error)]
33pub enum PrCreateError {
34    #[error("GitHub API error: {0}")]
35    ApiError(#[from] anyhow::Error),
36}
37
38/// Result type for PR creation operations.
39pub type PrCreateResult<T> = Result<T, PrCreateError>;
40
41/// Check if the current branch has any commits ahead of the default branch.
42pub 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            // If we can't determine, assume there ARE commits ahead so we
66            // attempt a push (better to try and fail loudly than silently
67            // skip push and lose the PR creation path).
68            true
69        }
70    }
71}
72
73/// Check if there are uncommitted changes in the working directory.
74pub async fn has_changes(dir: &Path) -> bool {
75    // Check for staged, unstaged, and untracked files
76    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
113/// Options for [`stash_rebase_restore`].
114pub(crate) struct StashRebaseOpts {
115    /// Disable GPG commit signing during the rebase (passes `-c commit.gpgsign=false`).
116    pub disable_gpg_signing: bool,
117    /// Abort the rebase (via `git rebase --abort`) and restore the stash when the
118    /// rebase fails.  Set to `false` when the caller intentionally leaves the working
119    /// tree in the in-progress-rebase state so an agent can resolve conflicts.
120    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/// Outcome returned by [`stash_rebase_restore`].
133#[derive(Debug)]
134pub(crate) enum RebaseOutcome {
135    /// Rebase completed successfully.
136    Succeeded,
137    /// Rebase failed.  When `abort_on_failure` is true the rebase was aborted and any
138    /// stash was restored; when false the working tree is left in the in-progress-rebase
139    /// state.
140    Failed(String),
141    /// A stash operation failed (push failed, or OID could not be captured); the
142    /// rebase was skipped to preserve the dirty working tree.
143    Skipped(String),
144}
145
146/// Stash uncommitted changes, rebase onto `target_ref`, then restore the stash.
147///
148/// This is the single canonical implementation of the stash → rebase → restore
149/// pattern used in multiple call sites.  Callers are responsible for any
150/// `git fetch` that should precede the rebase.
151///
152/// # Concurrency safety
153/// Git stashes are repo-global; multiple worktrees share the same stash stack.
154/// This function captures the stash OID immediately after pushing and applies by
155/// that exact ref — never via `stash pop` — to avoid restoring a stash that
156/// belongs to a different concurrently-running worktree.
157///
158/// # Stash failure handling
159/// If the stash push fails, or the OID cannot be captured after a successful push,
160/// the rebase is **skipped** and [`RebaseOutcome::Skipped`] is returned.  This
161/// preserves the dirty working tree intact rather than risking data loss.
162pub(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                // Capture the OID of the stash object just created so we can
188                // apply it back by that exact ref, regardless of any stashes
189                // that other worktrees may push between now and then.
190                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                    // Stash was created but we can't track its ref.
203                    // Pop immediately to avoid orphaning the changes, then skip.
204                    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
309/// Apply a stash entry by its OID and drop it from the stack on success.
310///
311/// On failure the entry is preserved so it can be recovered manually.
312async 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
375/// Resolve the git author identity from config, falling back to `{agent}[bot]`.
376fn 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
383/// Auto-commit any uncommitted changes.
384pub 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    // Check for changes first (before creating span to avoid Send issues)
392    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    // git add -A
402    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    // git commit — override author/committer to match the agent identity
414    let (git_name, git_email) = git_identity(agent);
415    let commit = Command::new("git")
416        // Same policy as the rebase path: never depend on the operator's signing agent
417        .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        // Unstage files so has_changes() sees them as unstaged on the next
430        // check and the workflow can retry the commit cleanly.
431        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
487/// Maximum number of commits to replay during a rebase.
488///
489/// If a branch has diverged so far from the default branch that it would
490/// require replaying more than this many commits, we skip the rebase entirely.
491/// This prevents degenerate cases (e.g. a branch forked from the very first
492/// commit that now sits 300+ commits behind main) from producing hundreds of
493/// add/add conflicts that block the agent and leave the worktree unusable.
494const MAX_REBASE_COMMITS: usize = 50;
495
496/// Rebase the current branch on top of `origin/{branch}`.
497///
498/// Used for push-failure recovery: when another agent has pushed to the same
499/// branch, we fetch the latest remote state and rebase local commits on top.
500/// Returns `Ok(true)` if rebase succeeded, `Ok(false)` if there was nothing to
501/// rebase, and `Err` if rebase failed (conflicts).
502///
503/// Similar to `rebase_on_default` but targets the feature branch instead of
504/// the default branch, and is used after push failures rather than at startup.
505pub async fn rebase_on_branch(dir: &Path, branch: &str) -> anyhow::Result<bool> {
506    let origin_branch = format!("origin/{branch}");
507
508    // Fetch the remote branch to get latest state.
509    // 2-minute timeout mirrors the push timeout to prevent indefinite stalls.
510    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    // Check if there are local commits to rebase.
532    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        // No local commits to rebase — nothing to do.
557        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
580/// Rebase the current branch on the default branch.
581///
582/// Run before the agent starts to ensure the worktree has the latest code.
583/// Fetches both the default branch and the current branch so agents never
584/// need to run `git fetch` themselves (important for sandboxed agents like
585/// codex whose workspace-write sandbox blocks writes outside the worktree).
586/// Non-fatal: if rebase fails (conflicts), the agent may still be able to work.
587pub async fn rebase_on_default(dir: &Path, default_branch: &str) {
588    // auto_merge leaves a conflicted rebase in place for the agent, aborting it here would undo that
589    if rebase_in_progress(dir).await {
590        tracing::info!("rebase already in progress, skipping pre-dispatch rebase");
591        return;
592    }
593
594    // Fetch current branch (for retries with existing remote commits)
595    // and default branch (for rebase) in one call.
596    let _ = Command::new("git")
597        .args(["fetch", "origin"])
598        .current_dir(dir)
599        .output_with_context()
600        .await;
601
602    // Count commits that would be replayed.  If the branch diverged too far
603    // back we skip the rebase: replaying hundreds of historical commits produces
604    // massive add/add conflicts (the branch adds files that main already has)
605    // and leaves the worktree stuck.
606    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
671/// Build git config environment variables that inject GitHub token credentials.
672///
673/// This handles two scenarios:
674/// - **SSH remotes** (`git@github.com:user/repo.git`): rewritten to
675///   `https://x-access-token:TOKEN@github.com/…` via `insteadOf`.
676/// - **HTTPS remotes** (`https://github.com/user/repo.git`): rewritten to
677///   `https://x-access-token:TOKEN@github.com/…` via a second `insteadOf`.
678///
679/// When no token is available, falls back to the legacy SSH→HTTPS conversion
680/// without credentials (works for repos that use credential helpers or SSH keys).
681///
682/// Returns a `Vec<(String, String)>` of environment variable key-value pairs.
683/// These are NOT visible in the process list (`ps`), unlike `-c` args.
684///
685/// Git environment variables:
686/// - `GIT_CONFIG_COUNT` — number of config entries
687/// - `GIT_CONFIG_KEY_N` — config key for entry N
688/// - `GIT_CONFIG_VALUE_N` — config value for entry N
689pub(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
722/// Returns `true` if the push error indicates that the GitHub token lacks the
723/// `workflow` OAuth scope, which is required to push changes to
724/// `.github/workflows/` files.
725///
726/// GitHub rejects pushes with: "refusing to allow an OAuth App to create or
727/// update workflow `…` without `workflow` scope"
728pub(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
736/// Remove `.github/workflows/` files from committed changes and amend the
737/// commits so the branch can be pushed without the `workflow` OAuth scope.
738///
739/// Strategy: find all commits ahead of `origin/{default_branch}`, identify
740/// which ones touch `.github/workflows/`, and rewrite them to exclude those
741/// files. Uses `git filter-branch`-style approach via interactive rebase
742/// with `exec` steps.
743///
744/// Returns `Ok(true)` if workflow files were removed and commits rewritten,
745/// `Ok(false)` if no workflow files were found in the commits.
746async fn strip_workflow_files(dir: &Path, default_branch: &str) -> anyhow::Result<bool> {
747    // Find workflow files in commits ahead of the default branch
748    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    // Remove workflow files from the index and amend each commit.
776    // We use a soft reset → remove files → re-commit approach for simplicity.
777    //
778    // Count commits ahead of default branch
779    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    // Save the commit messages before resetting.
799    // Use a null byte as the record prefix — null bytes cannot appear in git commit
800    // messages, so this separator is unambiguous (unlike a string like ---COMMIT_SEP---
801    // which an agent could write into a commit body, corrupting the parse).
802    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    // Parse commits in reverse order (oldest first).
814    // Each record starts with \0 (from %x00 in the format), so split on \0 and
815    // discard any leading empty fragment before the first record.
816    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    // Soft reset to the base (origin/default_branch)
836    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    // Remove workflow files from the index
848    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    // Re-commit all remaining changes as a single commit preserving the first message.
864    // This squashes multiple commits but preserves the work minus workflow files.
865    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        // Reset back to where we were — nothing to push
879        // Use the original HEAD from the first (newest) commit
880        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    // Use the first commit's message (oldest commit)
891    let commit_msg = if commits.len() == 1 {
892        commits[0].1.clone()
893    } else {
894        // Combine messages when squashing
895        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        // Same policy as the rebase path: never depend on the operator's signing agent
909        .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        // Recovery: restore the original commits so the worktree isn't stuck
921        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
940/// Push the branch to origin.
941pub 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        &current
945    } else {
946        branch
947    };
948
949    // Guard against empty branch names (prevents git config corruption)
950    if branch_to_push.is_empty() {
951        anyhow::bail!("cannot push: branch name is empty");
952    }
953
954    // Skip push for main/master
955    if branch_to_push == "main" || branch_to_push == "master" {
956        return Ok(false);
957    }
958
959    // Check if there are commits to push and whether the remote branch exists
960    let (remote_exists, has_unpushed) =
961        check_push_needed(dir, branch_to_push, default_branch).await;
962    if remote_exists && !has_unpushed {
963        // Remote already has this branch and it's up to date — nothing to push.
964        // Return true so callers know the branch IS on the remote and PR creation
965        // is safe to attempt (e.g. a PR may already exist or can be created).
966        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        // Branch doesn't exist on the remote and has no commits ahead of the
974        // default branch — push it anyway so the branch is created on the remote.
975        // PR creation will then fail with "No commits between …" (422) which is
976        // handled downstream, rather than with "head invalid" (422) which is
977        // harder to distinguish from a real error.
978        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    // First attempt: normal push (no force).
989    // lfs.locksverify=false skips the auxiliary LFS locks/verify roundtrip in
990    // the pre-push hook; agents never hold LFS locks, and a transient timeout
991    // on that endpoint must not fail the whole push.
992    // A 2-minute timeout prevents an indefinitely-stalled push from blocking
993    // post-processing and triggering a stuck-task re-dispatch race.
994    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    // Transient network blip (timeout, reset) rather than a ref rejection —
1021    // retry the identical push once before classifying the run as failed.
1022    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    // Handle workflow scope error: the token lacks the `workflow` OAuth scope
1064    // required to push changes to `.github/workflows/`. Strip workflow files
1065    // from commits and retry the push.
1066    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                // Workflow files stripped, retry push
1075                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 still failing with workflow scope, bail with clear message
1107                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                // No workflow files found to strip (shouldn't happen if error was detected)
1119                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 non-fast-forward, the branch was likely rebased on the default branch
1137    // by `rebase_on_default()` before the agent started — the local history is
1138    // correct but diverges from the remote. Force-push with lease to update it
1139    // without pulling (which would duplicate commits from main).
1140    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        // Check if force push also hit the workflow scope error
1168        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        anyhow::bail!("force push failed: {stderr}");
1222    }
1223
1224    anyhow::bail!("push failed: {stderr}")
1225}
1226
1227fn push_needs_rebase(stderr: &str) -> bool {
1228    let lower = stderr.to_ascii_lowercase();
1229    lower.contains("non-fast-forward")
1230        || lower.contains("rejected")
1231        || lower.contains("fetch first")
1232        || lower.contains("behind")
1233}
1234
1235/// True when a push failure looks like a transient network error (dial
1236/// timeout, i/o timeout, connection reset) rather than a ref rejection.
1237/// A refused ref must not be retried, but a blipped endpoint deserves one
1238/// immediate retry before the run is classified as `push_failed`.
1239fn is_transient_network_error(stderr: &str) -> bool {
1240    let lower = stderr.to_ascii_lowercase();
1241    lower.contains("i/o timeout")
1242        || lower.contains("dial tcp")
1243        || lower.contains("connection reset")
1244        || lower.contains("connection refused")
1245        || lower.contains("no route to host")
1246        || lower.contains("temporary failure in name resolution")
1247        || lower.contains("eof")
1248}
1249
1250/// Strip credentials from push stderr before it is logged or returned:
1251/// URL userinfo (`https://x-access-token:TOKEN@…`) plus any secrets caught
1252/// by the leak patterns, so a failing push can never write a token to logs.
1253fn sanitize_push_error(stderr: &str) -> String {
1254    static URL_USERINFO: LazyLock<Regex> =
1255        LazyLock::new(|| Regex::new(r"://[^/@\s]+@").expect("valid url userinfo regex"));
1256    let no_userinfo = URL_USERINFO.replace_all(stderr, "://***@");
1257    crate::security::redact(&no_userinfo)
1258}
1259
1260/// Create a PR if one doesn't already exist.
1261///
1262/// Uses [`GhHttp`] exclusively for GitHub API operations. No `gh` CLI
1263/// fallback is used, ensuring consistent behavior across environments.
1264///
1265/// # Returns
1266/// - `Ok(url)` - PR URL (newly created or already existed)
1267/// - `Err(PrCreateError)` - API error or other failure
1268#[allow(clippy::too_many_arguments)]
1269pub async fn create_pr_if_needed(
1270    dir: &Path,
1271    branch: &str,
1272    title: &str,
1273    summary: &str,
1274    accomplished: &[String],
1275    remaining: &[String],
1276    files: &[String],
1277    task_id: &str,
1278    agent: &str,
1279    model: Option<&str>,
1280    repo: &str,
1281    base_branch: &str,
1282) -> PrCreateResult<String> {
1283    let gh = GhHttp::new()?;
1284
1285    // Check if PR already exists using GhHttp API
1286    match gh.get_pr_number(repo, branch).await {
1287        Ok(Some(pr_number)) => {
1288            tracing::info!(task_id, pr = pr_number, "PR already exists");
1289            // Append attribution footer if the agent created the PR without one.
1290            append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model).await;
1291            // Fetch the existing PR to get its URL so the caller can store pr_number.
1292            let pr = match gh.get_pr(repo, pr_number).await {
1293                Ok(pr) => pr,
1294                Err(e) => {
1295                    tracing::warn!(task_id, error = %e, "get_pr API call failed after detecting existing PR");
1296                    return Err(PrCreateError::ApiError(e));
1297                }
1298            };
1299            return Ok(pr.html_url);
1300        }
1301        Ok(None) => {
1302            // No PR exists, proceed to create one
1303        }
1304        Err(e) => {
1305            tracing::warn!(task_id, error = %e, "get_pr_number API call failed");
1306            return Err(PrCreateError::ApiError(e));
1307        }
1308    }
1309
1310    // Build PR body
1311    let mut body = format!(
1312        "## Summary\n\n{}",
1313        if summary.is_empty() { title } else { summary }
1314    );
1315
1316    if !accomplished.is_empty() {
1317        body.push_str("\n\n### What was done\n\n");
1318        for item in accomplished {
1319            body.push_str(&format!("- {item}\n"));
1320        }
1321    }
1322
1323    if !remaining.is_empty() {
1324        body.push_str("\n\n### Remaining\n\n");
1325        for item in remaining {
1326            body.push_str(&format!("- {item}\n"));
1327        }
1328    }
1329
1330    // When the agent didn't self-report files, derive them from git.
1331    let git_files: Vec<String>;
1332    let effective_files: &[String] = if files.is_empty() {
1333        match tokio::process::Command::new("git")
1334            .args([
1335                "diff",
1336                &format!("origin/{base_branch}...HEAD"),
1337                "--name-only",
1338            ])
1339            .current_dir(dir)
1340            .output()
1341            .await
1342        {
1343            Ok(output) if output.status.success() => {
1344                git_files = String::from_utf8_lossy(&output.stdout)
1345                    .lines()
1346                    .filter(|l| !l.is_empty())
1347                    .map(|l| l.to_string())
1348                    .collect();
1349                &git_files
1350            }
1351            _ => files,
1352        }
1353    } else {
1354        files
1355    };
1356
1357    if !effective_files.is_empty() {
1358        body.push_str("\n\n### Files changed\n\n");
1359        for file in effective_files {
1360            body.push_str(&format!("- `{file}`\n"));
1361        }
1362    }
1363
1364    // Link PR to the issue so GitHub shows the connection in the sidebar.
1365    // Only for external tasks (issue numbers), not internal ones.
1366    if !task_id.starts_with("internal:") {
1367        if let Ok(issue_num) = task_id.parse::<u64>() {
1368            body.push_str(&format!("\n\nCloses #{issue_num}"));
1369        }
1370    }
1371
1372    let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1373    let task_ref = format_task_ref(task_id);
1374    body.push_str(&format!(
1375        "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1376    ));
1377
1378    // Always use the short task title for the PR title (summary goes in body)
1379    let pr_title = title;
1380
1381    // Create PR using GhHttp API with exponential backoff retry for transient 5xx errors.
1382    const MAX_RETRIES: u32 = 3;
1383    let mut last_error: Option<anyhow::Error> = None;
1384    let mut created_url: Option<String> = None;
1385    let mut had_transient_error = false;
1386    let mut effective_base = base_branch.to_string();
1387    let mut tried_api_fallback = false;
1388
1389    for attempt in 1..=MAX_RETRIES {
1390        match gh
1391            .create_pr(repo, pr_title, &body, branch, &effective_base)
1392            .await
1393        {
1394            Ok(u) => {
1395                created_url = Some(u);
1396                break;
1397            }
1398            Err(e) => {
1399                let err_str = format!("{e}");
1400                if is_transient_github_error(&err_str) {
1401                    had_transient_error = true;
1402                    if attempt < MAX_RETRIES {
1403                        let delay = std::time::Duration::from_secs(2u64.pow(attempt));
1404                        tracing::warn!(
1405                            task_id,
1406                            attempt,
1407                            delay_secs = delay.as_secs(),
1408                            error = %e,
1409                            "transient GitHub API error during PR creation — retrying"
1410                        );
1411                        tokio::time::sleep(delay).await;
1412                    }
1413                } else if is_invalid_base_error(&err_str) && !tried_api_fallback {
1414                    // Local base branch detection was wrong (e.g. "remotes/origin/main").
1415                    // Query GitHub API for the canonical default branch and retry once.
1416                    tried_api_fallback = true;
1417                    tracing::warn!(
1418                        task_id,
1419                        base_branch = %effective_base,
1420                        error = %e,
1421                        "PR creation failed with invalid base — fetching default branch from GitHub API"
1422                    );
1423                    match gh.get_default_branch(repo).await {
1424                        Ok(api_default) if api_default != effective_base => {
1425                            tracing::info!(
1426                                task_id,
1427                                old_base = %effective_base,
1428                                new_base = %api_default,
1429                                "retrying PR creation with GitHub API default branch"
1430                            );
1431                            effective_base = api_default;
1432                            continue;
1433                        }
1434                        Ok(api_default) => {
1435                            tracing::warn!(
1436                                task_id,
1437                                base_branch = %api_default,
1438                                "GitHub API default branch matches local — no fallback possible"
1439                            );
1440                        }
1441                        Err(api_err) => {
1442                            tracing::warn!(
1443                                task_id,
1444                                error = %api_err,
1445                                "failed to fetch default branch from GitHub API"
1446                            );
1447                        }
1448                    }
1449                }
1450                last_error = Some(e);
1451            }
1452        }
1453    }
1454
1455    let url = match created_url {
1456        Some(u) => u,
1457        None => {
1458            let e = last_error.unwrap_or_else(|| {
1459                anyhow::anyhow!("PR creation failed after retries (no error captured)")
1460            });
1461            let err_str = format!("{e}");
1462            // For transient 5xx errors, GitHub may have created the PR despite returning
1463            // an error (e.g. 502 after the write succeeded). Re-check for an existing PR
1464            // before propagating the failure so we don't orphan the PR from the task.
1465            // Also check when the final error is non-transient but a prior attempt had a
1466            // transient error — the PR may have been created on that earlier attempt (e.g.
1467            // attempt 1 returns 502, attempt 2 returns 422 "already exists").
1468            if is_transient_github_error(&err_str) || had_transient_error {
1469                tracing::warn!(
1470                    task_id,
1471                    error = %e,
1472                    "transient GitHub API error after all retries — checking if PR was actually created"
1473                );
1474                match gh.get_pr_number(repo, branch).await {
1475                    Ok(Some(pr_number)) => {
1476                        tracing::info!(
1477                            task_id,
1478                            pr = pr_number,
1479                            "PR was created despite transient errors — recovering"
1480                        );
1481                        append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model)
1482                            .await;
1483                        match gh.get_pr(repo, pr_number).await {
1484                            Ok(pr) => pr.html_url,
1485                            Err(get_err) => {
1486                                tracing::warn!(
1487                                    task_id,
1488                                    error = %get_err,
1489                                    "failed to fetch PR URL after transient-error recovery"
1490                                );
1491                                return Err(PrCreateError::ApiError(e));
1492                            }
1493                        }
1494                    }
1495                    Ok(None) => {
1496                        tracing::warn!(
1497                            task_id,
1498                            "PR was not created after all retries — propagating original error"
1499                        );
1500                        return Err(PrCreateError::ApiError(e));
1501                    }
1502                    Err(check_err) => {
1503                        tracing::warn!(
1504                            task_id,
1505                            error = %check_err,
1506                            "failed to verify PR existence after transient error retries — propagating original error"
1507                        );
1508                        return Err(PrCreateError::ApiError(e));
1509                    }
1510                }
1511            } else {
1512                return Err(PrCreateError::ApiError(e));
1513            }
1514        }
1515    };
1516
1517    tracing::info!(task_id, pr_url = %url, "created PR via GhHttp API");
1518
1519    Ok(url)
1520}
1521
1522/// Returns true if the error string indicates a transient GitHub API failure
1523/// (HTTP 5xx, network errors, DNS failures, TLS issues, rate limits), where
1524/// the operation may succeed on retry.
1525pub(crate) fn is_transient_github_error(err_str: &str) -> bool {
1526    // Treat transport/send failures and explicit GhHttp server-error messages
1527    // as transient. Older callers relied on parsing the "failed (NNN)" pattern
1528    // to detect 5xx — keep that, but also recognize the newer wrappers.
1529    if err_str.contains("HTTP send failed") {
1530        return true;
1531    }
1532    if err_str.contains("GitHub API server error") {
1533        return true;
1534    }
1535
1536    // Check for HTTP 5xx status codes
1537    if extract_github_http_status(err_str)
1538        .map(|s| (500..600).contains(&s))
1539        .unwrap_or(false)
1540    {
1541        return true;
1542    }
1543
1544    // Check for common network-level transient errors
1545    let lower = err_str.to_lowercase();
1546
1547    // Connection and transport errors
1548    if lower.contains("connection reset")
1549        || lower.contains("broken pipe")
1550        || lower.contains("connection refused")
1551        || lower.contains("connection closed")
1552        || lower.contains("transport error")
1553        || lower.contains("failed to connect")
1554        || lower.contains("failed to connect to github.com port")
1555        || lower.contains("curl: (7) failed to connect")
1556        || lower.contains("couldn't connect to server")
1557        || lower.contains("could not connect to server")
1558    {
1559        return true;
1560    }
1561
1562    // Timeout errors
1563    if lower.contains("timeout") {
1564        return true;
1565    }
1566
1567    // DNS resolution errors
1568    if lower.contains("dns error")
1569        || lower.contains("resolve")
1570        || lower.contains("name resolution")
1571        || lower.contains("no such host")
1572    {
1573        return true;
1574    }
1575
1576    // TLS/SSL errors
1577    if lower.contains("tls handshake")
1578        || lower.contains("certificate")
1579        || lower.contains("ssl error")
1580    {
1581        return true;
1582    }
1583
1584    // Network unreachable errors
1585    if lower.contains("network is unreachable")
1586        || lower.contains("host unreachable")
1587        || lower.contains("temporary failure")
1588    {
1589        return true;
1590    }
1591
1592    // EOF errors (connection dropped mid-request)
1593    if lower.contains("unexpected eof") || lower.contains("unexpected end of file") {
1594        return true;
1595    }
1596
1597    // Internal circuit-breaker state (orch's own GitHub 5xx protection)
1598    if lower.contains("circuit-breaker") || lower.contains("circuit breaker") {
1599        return true;
1600    }
1601
1602    // Rate limit errors (transient with cooldown/retry)
1603    if lower.contains("rate limit") || lower.contains("too many requests") {
1604        return true;
1605    }
1606
1607    false
1608}
1609
1610/// Returns true when the error indicates the PR base branch is invalid.
1611///
1612/// GitHub returns HTTP 422 with `PullRequest.base invalid` when the base
1613/// branch name is not a valid ref in the repository. This is a terminal
1614/// configuration error — retrying with the same base will never succeed.
1615pub(crate) fn is_invalid_base_error(err_str: &str) -> bool {
1616    let lower = err_str.to_lowercase();
1617    lower.contains("422") && lower.contains("base") && lower.contains("invalid")
1618}
1619
1620/// Returns true when an error looks like a transient *GitHub API/CLI* failure.
1621///
1622/// This is stricter than [`is_transient_github_error`]: in addition to matching
1623/// a transient transport pattern, it requires GitHub-specific context in the
1624/// message. This prevents generic agent/runtime transport failures (for
1625/// example "broken pipe" from a model stream disconnect) from being treated as
1626/// GitHub retry signals in review reroute logic.
1627pub(crate) fn is_transient_github_api_error(err_str: &str) -> bool {
1628    if !is_transient_github_error(err_str) {
1629        return false;
1630    }
1631
1632    if extract_github_http_status(err_str).is_some() {
1633        return true;
1634    }
1635
1636    let lower = err_str.to_lowercase();
1637    lower.contains("github")
1638        || lower.contains("api.github.com")
1639        || lower.contains("gh api")
1640        || lower.contains("gh pr")
1641        || lower.contains("gh error")
1642        // `circuit-breaker` is orch's internal GitHub 5xx protection, set
1643        // exclusively by GhHttp.  It can never originate from agent/runtime
1644        // failures, so treating it as a GitHub-context marker is safe even
1645        // when upstream wrapping strips the "GitHub API" prefix.
1646        || lower.contains("circuit-breaker")
1647        || lower.contains("circuit breaker")
1648}
1649
1650/// Extract the HTTP status code from a GhHttp error string.
1651///
1652/// GhHttp formats errors as: `"GitHub API POST https://... failed (NNN): body"`
1653/// This parses the numeric status code from that format.
1654fn extract_github_http_status(err_str: &str) -> Option<u16> {
1655    let marker = "failed (";
1656    let start = err_str.find(marker)? + marker.len();
1657    let rest = &err_str[start..];
1658    let end = rest.find(')')?;
1659    rest[..end].parse().ok()
1660}
1661
1662/// Append the Orch attribution footer to an existing PR body if not already present.
1663///
1664/// Called when the agent created the PR directly (bypassing orch's
1665/// `create_pr_if_needed`), so the footer was never added by `build_pr_body`.
1666async fn append_pr_footer_if_missing(
1667    gh: &GhHttp,
1668    repo: &str,
1669    pr_number: u64,
1670    task_id: &str,
1671    agent: &str,
1672    model: Option<&str>,
1673) {
1674    let pr = match gh.get_pr(repo, pr_number).await {
1675        Ok(pr) => pr,
1676        Err(e) => {
1677            tracing::debug!(task_id, error = %e, "failed to fetch PR for footer check");
1678            return;
1679        }
1680    };
1681
1682    let body = pr.body.unwrap_or_default();
1683    if body.contains("via [Orch]") {
1684        return; // Footer already present
1685    }
1686
1687    let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1688    let task_ref = format_task_ref(task_id);
1689    let footer = format!(
1690        "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1691    );
1692    let new_body = format!("{body}{footer}");
1693
1694    if let Err(e) = gh.update_pr_body(repo, pr_number, &new_body).await {
1695        tracing::warn!(task_id, error = %e, "failed to append footer to PR (non-fatal)");
1696    } else {
1697        tracing::info!(
1698            task_id,
1699            pr = pr_number,
1700            "appended attribution footer to agent-created PR"
1701        );
1702    }
1703}
1704
1705/// Get the current branch name.
1706async fn get_current_branch(dir: &Path) -> String {
1707    let output = Command::new("git")
1708        .args(["branch", "--show-current"])
1709        .current_dir(dir)
1710        .output_with_context()
1711        .await;
1712
1713    match output {
1714        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
1715        _ => String::new(),
1716    }
1717}
1718
1719/// Count the number of changed files in the working directory.
1720pub async fn count_changed_files(dir: &Path) -> anyhow::Result<usize> {
1721    // Count modified and new files (excluding deleted)
1722    let output = Command::new("git")
1723        .args(["status", "--porcelain"])
1724        .current_dir(dir)
1725        .output_with_context()
1726        .await?;
1727
1728    let status_output = String::from_utf8_lossy(&output.stdout);
1729    let count = status_output
1730        .lines()
1731        .filter(|line| {
1732            let prefix = line.get(0..2).unwrap_or("");
1733            // Count modified (M), added (A), renamed (R), copied (C), untracked (??)
1734            prefix.starts_with('M')
1735                || prefix.starts_with('A')
1736                || prefix.starts_with('R')
1737                || prefix.starts_with('C')
1738                || prefix.starts_with('?')
1739        })
1740        .count();
1741
1742    Ok(count)
1743}
1744
1745/// Check if there are unpushed commits.
1746/// Returns `(remote_exists, has_unpushed_commits)`.
1747///
1748/// - `remote_exists` — whether `origin/<branch>` is known to the local repo.
1749/// - `has_unpushed_commits` — whether there are local commits not yet on the
1750///   remote.  When the remote branch doesn't exist the comparison is against
1751///   `origin/<default_branch>` so we can still detect new work.
1752async fn check_push_needed(dir: &Path, branch: &str, default_branch: &str) -> (bool, bool) {
1753    // Check if remote tracking branch exists
1754    let remote_exists = Command::new("git")
1755        .args(["rev-parse", &format!("origin/{branch}")])
1756        .current_dir(dir)
1757        .output_with_context()
1758        .await
1759        .map(|o| o.status.success())
1760        .unwrap_or(false);
1761
1762    let compare_ref = if remote_exists {
1763        format!("origin/{branch}..HEAD")
1764    } else {
1765        // Compare against default branch (passed in, NOT detected from
1766        // current HEAD — in a worktree HEAD is the feature branch itself)
1767        format!("origin/{default_branch}..HEAD")
1768    };
1769
1770    tracing::debug!(
1771        branch,
1772        compare_ref,
1773        remote_exists,
1774        "checking unpushed commits"
1775    );
1776
1777    let output = Command::new("git")
1778        .args(["log", &compare_ref, "--oneline"])
1779        .current_dir(dir)
1780        .output_with_context()
1781        .await;
1782
1783    let has_unpushed = match output {
1784        Ok(o) if o.status.success() => {
1785            let out = String::from_utf8_lossy(&o.stdout);
1786            let has = !out.trim().is_empty();
1787            tracing::debug!(branch, has_unpushed = has, "unpushed commits check result");
1788            has
1789        }
1790        _ => {
1791            tracing::debug!(branch, "git log failed for compare ref, assuming unpushed");
1792            // If we can't determine, assume there ARE commits to push
1793            // (better to attempt push and let it fail than silently skip)
1794            true
1795        }
1796    };
1797
1798    (remote_exists, has_unpushed)
1799}
1800
1801#[cfg(test)]
1802mod tests {
1803    use super::*;
1804
1805    #[test]
1806    fn pr_create_error_display() {
1807        let err = PrCreateError::ApiError(anyhow::anyhow!("test error"));
1808        assert_eq!(format!("{}", err), "GitHub API error: test error");
1809    }
1810
1811    #[test]
1812    fn pr_create_error_from_anyhow() {
1813        let anyhow_err = anyhow::anyhow!("source error");
1814        let pr_err: PrCreateError = anyhow_err.into();
1815        assert!(matches!(pr_err, PrCreateError::ApiError(_)));
1816    }
1817
1818    #[test]
1819    fn build_git_auth_env_without_token_falls_back_to_ssh_https_conversion() {
1820        // Pass None directly to test the no-token path without relying on env vars
1821        // or the gh CLI, keeping the test deterministic regardless of local auth state.
1822        let env = build_git_auth_env_for_token(None);
1823
1824        // When no token is available the fallback must include the SSH insteadOf rule.
1825        // The SSH→HTTPS conversion sets the target URL (the rewrite destination) to git@github.com:.
1826        let has_ssh_rule = env
1827            .iter()
1828            .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "git@github.com:");
1829        assert!(
1830            has_ssh_rule,
1831            "expected SSH insteadOf fallback (value is git@github.com:), got: {env:?}"
1832        );
1833    }
1834
1835    #[test]
1836    fn build_git_auth_env_with_token_covers_both_ssh_and_https() {
1837        // Pass a pre-built token directly to test the token path without touching env vars.
1838        let env = build_git_auth_env_for_token(Some("ghp_testtoken1234".into()));
1839
1840        // Must contain the token in the auth URL (the token is in the value of GIT_CONFIG_KEY_N).
1841        let has_token = env
1842            .iter()
1843            .any(|(_, v)| v.contains("x-access-token:ghp_testtoken1234@github.com"));
1844        assert!(has_token, "expected token in auth URL, got: {env:?}");
1845        // Must cover HTTPS origins (GIT_CONFIG_VALUE_0 is the HTTPS source URL).
1846        let has_https_rule = env
1847            .iter()
1848            .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "https://github.com/");
1849        assert!(
1850            has_https_rule,
1851            "expected HTTPS insteadOf rule, got: {env:?}"
1852        );
1853        // Must cover SSH origins (GIT_CONFIG_VALUE_1 is the SSH source URL).
1854        let has_ssh_rule = env
1855            .iter()
1856            .any(|(k, v)| k == "GIT_CONFIG_VALUE_1" && v == "git@github.com:");
1857        assert!(has_ssh_rule, "expected SSH insteadOf rule, got: {env:?}");
1858    }
1859
1860    #[test]
1861    fn find_stash_ref_by_hash_returns_matching_reference() {
1862        let stash_list = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{1}\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb stash@{0}";
1863
1864        assert_eq!(
1865            find_stash_ref_by_hash(stash_list, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
1866            Some("stash@{1}".to_string())
1867        );
1868        assert_eq!(
1869            find_stash_ref_by_hash(stash_list, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
1870            Some("stash@{0}".to_string())
1871        );
1872    }
1873
1874    #[test]
1875    fn find_stash_ref_by_hash_returns_none_when_missing_or_malformed() {
1876        let stash_list = "malformed-line\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{2}";
1877
1878        assert_eq!(
1879            find_stash_ref_by_hash(stash_list, "cccccccccccccccccccccccccccccccccccccccc"),
1880            None
1881        );
1882        assert_eq!(find_stash_ref_by_hash("", "anything"), None);
1883    }
1884
1885    #[test]
1886    fn push_needs_rebase_detects_common_rejections() {
1887        assert!(push_needs_rebase(
1888            "! [rejected] branch -> branch (fetch first)"
1889        ));
1890        assert!(push_needs_rebase("non-fast-forward update was rejected"));
1891        assert!(push_needs_rebase(
1892            "push rejected because the remote contains work"
1893        ));
1894    }
1895
1896    #[test]
1897    fn is_transient_network_error_detects_lfs_locks_timeout() {
1898        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";
1899        assert!(is_transient_network_error(lfs_err));
1900        assert!(is_transient_network_error(
1901            "dial tcp 140.82.112.4:443: connect: connection refused"
1902        ));
1903        assert!(is_transient_network_error(
1904            "read tcp 10.0.0.1:51234->140.82.112.4:443: read: connection reset by peer"
1905        ));
1906    }
1907
1908    #[test]
1909    fn is_transient_network_error_rejects_ref_rejections() {
1910        assert!(!is_transient_network_error(
1911            "! [rejected] branch -> branch (fetch first)"
1912        ));
1913        assert!(!is_transient_network_error(
1914            "remote: error: GH006: Protected branch update failed"
1915        ));
1916    }
1917
1918    #[test]
1919    fn sanitize_push_error_strips_url_credentials() {
1920        // Shape must not look like a real GitHub PAT so gitleaks stays clean
1921        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";
1922        let sanitized = sanitize_push_error(raw);
1923        assert!(!sanitized.contains("test-secret-1234567890abcdef"));
1924        assert!(sanitized.contains("://***@github.com"));
1925        assert!(sanitized.contains("i/o timeout"));
1926    }
1927
1928    #[test]
1929    fn extract_github_http_status_parses_ghhttp_error_format() {
1930        let err =
1931            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
1932        assert_eq!(extract_github_http_status(err), Some(502));
1933
1934        let err500 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (500): Internal Server Error";
1935        assert_eq!(extract_github_http_status(err500), Some(500));
1936
1937        let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
1938        assert_eq!(extract_github_http_status(err422), Some(422));
1939
1940        let not_github = "some other error";
1941        assert_eq!(extract_github_http_status(not_github), None);
1942    }
1943
1944    #[test]
1945    fn is_transient_github_error_matches_5xx_only() {
1946        let err502 =
1947            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
1948        assert!(is_transient_github_error(err502));
1949
1950        let err503 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (503): Service Unavailable";
1951        assert!(is_transient_github_error(err503));
1952
1953        let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
1954        assert!(!is_transient_github_error(err422));
1955
1956        let err404 =
1957            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (404): Not Found";
1958        assert!(!is_transient_github_error(err404));
1959    }
1960
1961    #[test]
1962    fn is_transient_github_error_matches_connection_errors() {
1963        // Connection-related transient errors
1964        assert!(is_transient_github_error("connection refused"));
1965        assert!(is_transient_github_error("Connection reset by peer"));
1966        assert!(is_transient_github_error("broken pipe"));
1967        assert!(is_transient_github_error("connection closed unexpectedly"));
1968        assert!(is_transient_github_error(
1969            "transport error: connection lost"
1970        ));
1971
1972        // Git/Curl connect-failure patterns (review push failures)
1973        assert!(is_transient_github_error(
1974            "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"
1975        ));
1976        assert!(is_transient_github_error(
1977            "Failed to connect to github.com port 443: Connection timed out"
1978        ));
1979        assert!(is_transient_github_error(
1980            "Failed to connect to github.com port 443: Connection refused"
1981        ));
1982        assert!(is_transient_github_error(
1983            "curl: (7) Failed to connect to github.com port 443: Couldn't connect to server"
1984        ));
1985        assert!(is_transient_github_error(
1986            "fatal: unable to access 'https://github.com/owner/repo.git/': Couldn't connect to server"
1987        ));
1988    }
1989
1990    #[test]
1991    fn is_transient_github_error_matches_dns_errors() {
1992        // DNS resolution failures
1993        assert!(is_transient_github_error("dns error: failed to lookup"));
1994        assert!(is_transient_github_error("failed to resolve hostname"));
1995        assert!(is_transient_github_error("name resolution failed"));
1996        assert!(is_transient_github_error("no such host: api.github.com"));
1997    }
1998
1999    #[test]
2000    fn is_transient_github_error_matches_tls_errors() {
2001        // TLS/SSL handshake failures
2002        assert!(is_transient_github_error("tls handshake failed"));
2003        assert!(is_transient_github_error("certificate verification failed"));
2004        assert!(is_transient_github_error("ssl error: unknown ca"));
2005    }
2006
2007    #[test]
2008    fn is_transient_github_error_matches_network_unreachable() {
2009        // Network unreachable errors
2010        assert!(is_transient_github_error("network is unreachable"));
2011        assert!(is_transient_github_error("host unreachable"));
2012        assert!(is_transient_github_error(
2013            "temporary failure in name resolution"
2014        ));
2015    }
2016
2017    #[test]
2018    fn is_transient_github_error_matches_eof_errors() {
2019        // EOF/connection dropped errors
2020        assert!(is_transient_github_error("unexpected eof while reading"));
2021        assert!(is_transient_github_error("unexpected end of file"));
2022    }
2023
2024    #[test]
2025    fn is_transient_github_error_matches_rate_limit() {
2026        // Rate limit errors (transient with cooldown)
2027        assert!(is_transient_github_error("rate limit exceeded"));
2028        assert!(is_transient_github_error("429 Too Many Requests"));
2029        assert!(is_transient_github_error("too many requests, retry later"));
2030    }
2031
2032    #[test]
2033    fn is_transient_github_error_matches_timeout() {
2034        // Timeout errors
2035        assert!(is_transient_github_error("operation timeout"));
2036        assert!(is_transient_github_error("request timeout"));
2037    }
2038
2039    #[test]
2040    fn is_transient_github_error_rejects_non_transient() {
2041        // Non-transient errors should not match
2042        assert!(!is_transient_github_error("permission denied"));
2043        assert!(!is_transient_github_error("file not found"));
2044        assert!(!is_transient_github_error("invalid argument"));
2045        assert!(!is_transient_github_error("authentication failed"));
2046        assert!(!is_transient_github_error("bad credentials"));
2047    }
2048
2049    #[test]
2050    fn is_transient_github_api_error_requires_github_context() {
2051        assert!(is_transient_github_api_error(
2052            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway"
2053        ));
2054        assert!(is_transient_github_api_error(
2055            "gh error: transport error: connection reset by peer"
2056        ));
2057        assert!(is_transient_github_api_error(
2058            "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"
2059        ));
2060        assert!(!is_transient_github_api_error(
2061            "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)"
2062        ));
2063        assert!(!is_transient_github_api_error(
2064            "agent stream failed: failed to connect to local relay"
2065        ));
2066    }
2067
2068    #[test]
2069    fn is_transient_github_api_error_matches_circuit_breaker() {
2070        // Full message from GhHttp
2071        assert!(is_transient_github_api_error(
2072            "GitHub API transient 5xx circuit-breaker active for 240s"
2073        ));
2074        // Wrapped through PrCreateError + response_handler
2075        assert!(is_transient_github_api_error(
2076            "create PR failed: GitHub API error: GitHub API transient 5xx circuit-breaker active"
2077        ));
2078        // Shortened form where upstream wrapping stripped the "GitHub API" prefix —
2079        // circuit-breaker alone must still be recognized as a GitHub signal.
2080        assert!(is_transient_github_api_error(
2081            "create PR failed: circuit-breaker"
2082        ));
2083        // Spaced variant
2084        assert!(is_transient_github_api_error(
2085            "create PR failed: circuit breaker tripped"
2086        ));
2087    }
2088
2089    #[tokio::test]
2090    async fn auto_commit_unstages_files_on_commit_failure() {
2091        // Create a temp git repo with one initial commit
2092        let dir =
2093            std::env::temp_dir().join(format!("orch_test_auto_commit_{}", std::process::id()));
2094        let _ = std::fs::remove_dir_all(&dir);
2095        std::fs::create_dir_all(&dir).unwrap();
2096
2097        // Init repo
2098        let _ = Command::new("git")
2099            .args(["init"])
2100            .current_dir(&dir)
2101            .output()
2102            .await;
2103        let _ = Command::new("git")
2104            .args(["config", "user.email", "test@test.com"])
2105            .current_dir(&dir)
2106            .output()
2107            .await;
2108        let _ = Command::new("git")
2109            .args(["config", "user.name", "Test"])
2110            .current_dir(&dir)
2111            .output()
2112            .await;
2113        // Create initial file and commit
2114        std::fs::write(dir.join("init.txt"), "init").unwrap();
2115        let _ = Command::new("git")
2116            .args(["add", "init.txt"])
2117            .current_dir(&dir)
2118            .output()
2119            .await;
2120        let _ = Command::new("git")
2121            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2122            .current_dir(&dir)
2123            .output()
2124            .await;
2125
2126        // Create a new file (unstaged)
2127        std::fs::write(dir.join("new_file.txt"), "content").unwrap();
2128
2129        // Verify has_changes sees it
2130        assert!(has_changes(&dir).await);
2131
2132        // auto_commit should return false (no git user.email configured for this
2133        // test repo would cause commit to fail if we hadn't set it, but we did —
2134        // so this test verifies the happy path too).
2135        // For this test, the commit should succeed since we set user.email/name.
2136        let result = auto_commit(&dir, "1", "Test title", "test", 1).await;
2137        assert!(result.unwrap());
2138
2139        // Verify no changes remain
2140        assert!(!has_changes(&dir).await);
2141
2142        // Cleanup
2143        let _ = std::fs::remove_dir_all(&dir);
2144    }
2145
2146    #[tokio::test]
2147    async fn auto_commit_ignores_repo_signing_config() {
2148        // Repo demands signing via a signer that always fails — auto_commit
2149        // must still commit (it passes -c commit.gpgsign=false)
2150        let dir =
2151            std::env::temp_dir().join(format!("orch_test_auto_commit_gpg_{}", std::process::id()));
2152        let _ = std::fs::remove_dir_all(&dir);
2153        std::fs::create_dir_all(&dir).unwrap();
2154
2155        for args in [
2156            vec!["init"],
2157            vec!["config", "user.email", "test@test.com"],
2158            vec!["config", "user.name", "Test"],
2159            vec!["config", "commit.gpgsign", "true"],
2160            vec!["config", "gpg.program", "/usr/bin/false"],
2161        ] {
2162            let _ = Command::new("git")
2163                .args(&args)
2164                .current_dir(&dir)
2165                .output()
2166                .await;
2167        }
2168        std::fs::write(dir.join("init.txt"), "init").unwrap();
2169        let _ = Command::new("git")
2170            .args(["add", "init.txt"])
2171            .current_dir(&dir)
2172            .output()
2173            .await;
2174        let _ = Command::new("git")
2175            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2176            .current_dir(&dir)
2177            .output()
2178            .await;
2179
2180        std::fs::write(dir.join("signed_off.txt"), "content").unwrap();
2181        assert!(has_changes(&dir).await);
2182
2183        let result = auto_commit(&dir, "3", "Test title", "test", 1).await;
2184        assert!(result.unwrap());
2185        assert!(!has_changes(&dir).await);
2186
2187        let _ = std::fs::remove_dir_all(&dir);
2188    }
2189
2190    #[tokio::test]
2191    async fn auto_commit_unstages_on_pre_commit_hook_failure() {
2192        // Create a temp git repo where commit always fails (via pre-commit hook)
2193        let dir =
2194            std::env::temp_dir().join(format!("orch_test_auto_commit_hook_{}", std::process::id()));
2195        let _ = std::fs::remove_dir_all(&dir);
2196        std::fs::create_dir_all(&dir).unwrap();
2197
2198        // Init repo
2199        let _ = Command::new("git")
2200            .args(["init"])
2201            .current_dir(&dir)
2202            .output()
2203            .await;
2204        let _ = Command::new("git")
2205            .args(["config", "user.email", "test@test.com"])
2206            .current_dir(&dir)
2207            .output()
2208            .await;
2209        let _ = Command::new("git")
2210            .args(["config", "user.name", "Test"])
2211            .current_dir(&dir)
2212            .output()
2213            .await;
2214        // Create initial commit
2215        std::fs::write(dir.join("init.txt"), "init").unwrap();
2216        let _ = Command::new("git")
2217            .args(["add", "init.txt"])
2218            .current_dir(&dir)
2219            .output()
2220            .await;
2221        let _ = Command::new("git")
2222            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2223            .current_dir(&dir)
2224            .output()
2225            .await;
2226
2227        // Install a pre-commit hook that always fails
2228        let hooks_dir = dir.join(".git/hooks");
2229        std::fs::create_dir_all(&hooks_dir).unwrap();
2230        std::fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\nexit 1\n").unwrap();
2231        #[cfg(unix)]
2232        {
2233            use std::os::unix::fs::PermissionsExt;
2234            let mut perms = std::fs::metadata(hooks_dir.join("pre-commit"))
2235                .unwrap()
2236                .permissions();
2237            perms.set_mode(0o755);
2238            std::fs::set_permissions(hooks_dir.join("pre-commit"), perms).unwrap();
2239        }
2240
2241        // Create a new file
2242        std::fs::write(dir.join("staged_file.txt"), "content").unwrap();
2243
2244        // Verify has_changes sees it
2245        assert!(has_changes(&dir).await);
2246
2247        // auto_commit should return false (pre-commit hook blocks it)
2248        let result = auto_commit(&dir, "2", "Test title", "test", 1).await;
2249        assert!(!result.unwrap());
2250
2251        // After commit failure, files should be unstaged — has_changes should
2252        // still return true because the unstaged file is still there.
2253        assert!(has_changes(&dir).await);
2254
2255        // Verify the file is NOT staged (git diff --cached --quiet should succeed)
2256        let cached = Command::new("git")
2257            .args(["diff", "--cached", "--quiet"])
2258            .current_dir(&dir)
2259            .status()
2260            .await;
2261        assert!(
2262            cached.map(|s| s.success()).unwrap_or(false),
2263            "files should be unstaged after commit failure"
2264        );
2265
2266        // Cleanup
2267        let _ = std::fs::remove_dir_all(&dir);
2268    }
2269
2270    #[test]
2271    fn is_workflow_scope_error_detects_backtick_format() {
2272        let err = "! [remote rejected] branch -> branch (refusing to allow an OAuth App to create or update workflow `.github/workflows/ci.yml` without `workflow` scope)";
2273        assert!(is_workflow_scope_error(err));
2274    }
2275
2276    #[test]
2277    fn is_workflow_scope_error_detects_single_quote_format() {
2278        let err = "refusing to allow an OAuth App to create or update workflow '.github/workflows/ci.yml' without 'workflow' scope";
2279        assert!(is_workflow_scope_error(err));
2280    }
2281
2282    #[test]
2283    fn is_workflow_scope_error_detects_refusing_to_allow_variant() {
2284        let err = "remote: refusing to allow a Personal Access Token to create or update workflow `.github/workflows/scan.yml` without `workflow` scope";
2285        assert!(is_workflow_scope_error(err));
2286    }
2287
2288    #[test]
2289    fn is_workflow_scope_error_rejects_unrelated_errors() {
2290        assert!(!is_workflow_scope_error("non-fast-forward"));
2291        assert!(!is_workflow_scope_error("permission denied"));
2292        assert!(!is_workflow_scope_error("authentication failed"));
2293        assert!(!is_workflow_scope_error("rejected"));
2294    }
2295
2296    #[tokio::test]
2297    async fn strip_workflow_files_removes_workflow_from_commits() {
2298        // Create a temp git repo simulating the scenario
2299        let dir = std::env::temp_dir().join(format!("orch_test_strip_wf_{}", std::process::id()));
2300        let _ = std::fs::remove_dir_all(&dir);
2301        std::fs::create_dir_all(&dir).unwrap();
2302
2303        // Init repo with initial commit
2304        let _ = Command::new("git")
2305            .args(["init"])
2306            .current_dir(&dir)
2307            .output()
2308            .await;
2309        let _ = Command::new("git")
2310            .args(["config", "user.email", "test@test.com"])
2311            .current_dir(&dir)
2312            .output()
2313            .await;
2314        let _ = Command::new("git")
2315            .args(["config", "user.name", "Test"])
2316            .current_dir(&dir)
2317            .output()
2318            .await;
2319        std::fs::write(dir.join("init.txt"), "init").unwrap();
2320        let _ = Command::new("git")
2321            .args(["add", "."])
2322            .current_dir(&dir)
2323            .output()
2324            .await;
2325        let _ = Command::new("git")
2326            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2327            .current_dir(&dir)
2328            .output()
2329            .await;
2330
2331        // Create a "remote" by adding the repo as its own origin (for rev-list)
2332        let _ = Command::new("git")
2333            .args(["remote", "add", "origin", dir.to_str().unwrap()])
2334            .current_dir(&dir)
2335            .output()
2336            .await;
2337        let _ = Command::new("git")
2338            .args(["fetch", "origin"])
2339            .current_dir(&dir)
2340            .output()
2341            .await;
2342
2343        // Create a branch off main
2344        let _ = Command::new("git")
2345            .args(["checkout", "-b", "feature"])
2346            .current_dir(&dir)
2347            .output()
2348            .await;
2349
2350        // Add both a normal file and a workflow file
2351        std::fs::write(dir.join("feature.txt"), "feature work").unwrap();
2352        let wf_dir = dir.join(".github/workflows");
2353        std::fs::create_dir_all(&wf_dir).unwrap();
2354        std::fs::write(wf_dir.join("ci.yml"), "name: CI\non: push").unwrap();
2355        let _ = Command::new("git")
2356            .args(["add", "."])
2357            .current_dir(&dir)
2358            .output()
2359            .await;
2360        let _ = Command::new("git")
2361            .args([
2362                "-c",
2363                "commit.gpgsign=false",
2364                "commit",
2365                "-m",
2366                "add feature and workflow",
2367            ])
2368            .current_dir(&dir)
2369            .output()
2370            .await;
2371
2372        // Get the default branch name
2373        let branch_out = Command::new("git")
2374            .args(["rev-parse", "--abbrev-ref", "origin/HEAD"])
2375            .current_dir(&dir)
2376            .output()
2377            .await;
2378        // Fallback to "main" if origin/HEAD is not set
2379        let default_branch = branch_out
2380            .ok()
2381            .filter(|o| o.status.success())
2382            .map(|o| {
2383                String::from_utf8_lossy(&o.stdout)
2384                    .trim()
2385                    .strip_prefix("origin/")
2386                    .unwrap_or("main")
2387                    .to_string()
2388            })
2389            .unwrap_or_else(|| "main".to_string());
2390
2391        // Strip workflow files
2392        let stripped = strip_workflow_files(&dir, &default_branch).await.unwrap();
2393        assert!(stripped, "should have stripped workflow files");
2394
2395        // Verify workflow file is no longer in the committed tree
2396        let ls_output = Command::new("git")
2397            .args(["ls-tree", "-r", "--name-only", "HEAD"])
2398            .current_dir(&dir)
2399            .output()
2400            .await
2401            .unwrap();
2402        let files = String::from_utf8_lossy(&ls_output.stdout);
2403        assert!(
2404            !files.contains(".github/workflows/ci.yml"),
2405            "workflow file should be removed from commits"
2406        );
2407        assert!(
2408            files.contains("feature.txt"),
2409            "non-workflow files should be preserved"
2410        );
2411
2412        // Cleanup
2413        let _ = std::fs::remove_dir_all(&dir);
2414    }
2415
2416    #[tokio::test]
2417    async fn strip_workflow_files_returns_false_when_no_workflows() {
2418        let dir =
2419            std::env::temp_dir().join(format!("orch_test_strip_wf_none_{}", std::process::id()));
2420        let _ = std::fs::remove_dir_all(&dir);
2421        std::fs::create_dir_all(&dir).unwrap();
2422
2423        // Init repo
2424        let _ = Command::new("git")
2425            .args(["init"])
2426            .current_dir(&dir)
2427            .output()
2428            .await;
2429        let _ = Command::new("git")
2430            .args(["config", "user.email", "test@test.com"])
2431            .current_dir(&dir)
2432            .output()
2433            .await;
2434        let _ = Command::new("git")
2435            .args(["config", "user.name", "Test"])
2436            .current_dir(&dir)
2437            .output()
2438            .await;
2439        std::fs::write(dir.join("init.txt"), "init").unwrap();
2440        let _ = Command::new("git")
2441            .args(["add", "."])
2442            .current_dir(&dir)
2443            .output()
2444            .await;
2445        let _ = Command::new("git")
2446            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2447            .current_dir(&dir)
2448            .output()
2449            .await;
2450
2451        let _ = Command::new("git")
2452            .args(["remote", "add", "origin", dir.to_str().unwrap()])
2453            .current_dir(&dir)
2454            .output()
2455            .await;
2456        let _ = Command::new("git")
2457            .args(["fetch", "origin"])
2458            .current_dir(&dir)
2459            .output()
2460            .await;
2461
2462        let _ = Command::new("git")
2463            .args(["checkout", "-b", "feature2"])
2464            .current_dir(&dir)
2465            .output()
2466            .await;
2467
2468        // Only non-workflow files
2469        std::fs::write(dir.join("feature.txt"), "feature").unwrap();
2470        let _ = Command::new("git")
2471            .args(["add", "."])
2472            .current_dir(&dir)
2473            .output()
2474            .await;
2475        let _ = Command::new("git")
2476            .args([
2477                "-c",
2478                "commit.gpgsign=false",
2479                "commit",
2480                "-m",
2481                "add feature only",
2482            ])
2483            .current_dir(&dir)
2484            .output()
2485            .await;
2486
2487        let stripped = strip_workflow_files(&dir, "main").await.unwrap();
2488        assert!(!stripped, "should not strip when no workflow files exist");
2489
2490        // Cleanup
2491        let _ = std::fs::remove_dir_all(&dir);
2492    }
2493
2494    #[test]
2495    fn is_invalid_base_error_detects_base_invalid() {
2496        assert!(is_invalid_base_error(
2497            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): {\"errors\":[{\"resource\":\"PullRequest\",\"field\":\"base\",\"code\":\"invalid\"}]}"
2498        ));
2499        assert!(is_invalid_base_error(
2500            "GitHub API POST .../pulls failed (422): Base field is invalid"
2501        ));
2502    }
2503
2504    #[test]
2505    fn is_invalid_base_error_rejects_non_base_errors() {
2506        // No-commits error should NOT match
2507        assert!(!is_invalid_base_error(
2508            "GitHub API POST .../pulls failed (422): No commits between main and branch"
2509        ));
2510        // Head invalid should NOT match
2511        assert!(!is_invalid_base_error(
2512            "GitHub API POST .../pulls failed (422): head is not a valid ref"
2513        ));
2514        // Transient 5xx should NOT match
2515        assert!(!is_invalid_base_error(
2516            "GitHub API POST .../pulls failed (502): Bad Gateway"
2517        ));
2518        // 404 should NOT match
2519        assert!(!is_invalid_base_error(
2520            "GitHub API POST .../pulls failed (404): Not Found"
2521        ));
2522    }
2523}