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/// Drop sentences and list items about pushing or opening the PR
1261/// Agents report they could not push, orch already pushed, so those lines read as false on GitHub
1262fn strip_push_and_pr_creation(text: &str) -> String {
1263    let mut kept_lines: Vec<String> = Vec::new();
1264    for line in text.lines() {
1265        if line.trim().is_empty() {
1266            if kept_lines.last().is_some_and(|l| !l.is_empty()) {
1267                kept_lines.push(String::new());
1268            }
1269            continue;
1270        }
1271
1272        let indent_len = line.len() - line.trim_start().len();
1273        let indent = &line[..indent_len];
1274        let trimmed = line.trim();
1275        let (marker, rest) = match list_marker(trimmed) {
1276            Some((m, r)) => (m, r),
1277            None => ("", trimmed),
1278        };
1279
1280        let kept: Vec<&str> = split_statements(rest)
1281            .into_iter()
1282            .filter(|s| !is_push_or_pr_creation_statement(s))
1283            .collect();
1284        if kept.is_empty() {
1285            continue;
1286        }
1287        kept_lines.push(format!("{indent}{marker}{}", kept.join(" ")));
1288    }
1289    while kept_lines.last().is_some_and(String::is_empty) {
1290        kept_lines.pop();
1291    }
1292    kept_lines.join("\n")
1293}
1294
1295fn split_statements(text: &str) -> Vec<&str> {
1296    static SEP: LazyLock<Regex> =
1297        LazyLock::new(|| Regex::new(r"(\.{2,}|(?:[.!?]))\s+").expect("valid statement separator"));
1298    let mut statements = Vec::new();
1299    let mut last = 0;
1300    for cap in SEP.captures_iter(text) {
1301        let full = cap.get(0).expect("full separator match");
1302        let punct = cap.get(1).expect("punctuation capture");
1303        let ellipsis = punct.as_str().len() >= 2 && punct.as_str().bytes().all(|b| b == b'.');
1304        let end = if ellipsis { punct.start() } else { punct.end() };
1305        let stmt = text[last..end].trim();
1306        if !stmt.is_empty() {
1307            statements.push(stmt);
1308        }
1309        last = full.end();
1310    }
1311    let rest = text[last..].trim();
1312    if !rest.is_empty() {
1313        statements.push(rest);
1314    }
1315    statements
1316}
1317
1318fn list_marker(line: &str) -> Option<(&str, &str)> {
1319    for prefix in ["- ", "* ", "+ "] {
1320        if let Some(rest) = line.strip_prefix(prefix) {
1321            return Some((prefix, rest));
1322        }
1323    }
1324    let dot = line.find(". ")?;
1325    if !line[..dot].is_empty() && line[..dot].bytes().all(|b| b.is_ascii_digit()) {
1326        Some((&line[..dot + 2], &line[dot + 2..]))
1327    } else {
1328        None
1329    }
1330}
1331
1332fn is_push_or_pr_creation_statement(text: &str) -> bool {
1333    static PAT: LazyLock<Regex> = LazyLock::new(|| {
1334        Regex::new(concat!(
1335            r"(?i)",
1336            r"\bgit\s+push\b",
1337            r"|\bgh\s+pr\s+create\b",
1338            r"|\bgh\s+pr\s+open\b",
1339            r"|\bpush\s+(was\s+)?(denied|blocked|rejected)\b",
1340            r"|\bpush\s+permission\b",
1341            r"|\bcouldn['’]?t\s+push\b",
1342            r"|\bcould\s+not\s+push\b",
1343            r"|\bcan['’]?t\s+push\b",
1344            r"|\bcannot\s+push\b",
1345            r"|\bdidn['’]?t\s+push\b",
1346            r"|\bdid\s+not\s+push\b",
1347            r"|\bnothing\s+is\s+on\s+the\s+remote\b",
1348            r"|\bnot\s+on\s+the\s+remote\b",
1349            r"|\bopen(?:ing)?\s+(?:the|a)\s+pr\b",
1350            r"|\bcreat(?:e|ing)\s+(?:the|a)\s+pr\b",
1351            r"|\bopen(?:ing)?\s+(?:the|a)\s+pull\s+request\b",
1352            r"|\bcreat(?:e|ing)\s+(?:the|a)\s+pull\s+request\b",
1353        ))
1354        .expect("valid push/pr-creation regex")
1355    });
1356    PAT.is_match(text)
1357}
1358
1359fn cleaned_pr_items(items: &[String]) -> Vec<String> {
1360    items
1361        .iter()
1362        .filter_map(|item| {
1363            let cleaned = strip_push_and_pr_creation(item);
1364            (!cleaned.is_empty()).then_some(cleaned)
1365        })
1366        .collect()
1367}
1368
1369/// Format the GitHub PR description from the agent summary, stripping push and PR-creation statements
1370#[allow(clippy::too_many_arguments)]
1371fn build_pr_body(
1372    title: &str,
1373    summary: &str,
1374    accomplished: &[String],
1375    remaining: &[String],
1376    files: &[String],
1377    task_id: &str,
1378    agent: &str,
1379    model: Option<&str>,
1380) -> String {
1381    let cleaned_summary = strip_push_and_pr_creation(summary);
1382    let summary_text = if cleaned_summary.is_empty() {
1383        title
1384    } else {
1385        cleaned_summary.as_str()
1386    };
1387    let accomplished = cleaned_pr_items(accomplished);
1388    let remaining = cleaned_pr_items(remaining);
1389
1390    let mut body = format!("## Summary\n\n{summary_text}");
1391
1392    if !accomplished.is_empty() {
1393        body.push_str("\n\n### What was done\n\n");
1394        for item in &accomplished {
1395            body.push_str(&format!("- {item}\n"));
1396        }
1397    }
1398
1399    if !remaining.is_empty() {
1400        body.push_str("\n\n### Remaining\n\n");
1401        for item in &remaining {
1402            body.push_str(&format!("- {item}\n"));
1403        }
1404    }
1405
1406    if !files.is_empty() {
1407        body.push_str("\n\n### Files changed\n\n");
1408        for file in files {
1409            body.push_str(&format!("- `{file}`\n"));
1410        }
1411    }
1412
1413    if !task_id.starts_with("internal:") {
1414        if let Ok(issue_num) = task_id.parse::<u64>() {
1415            body.push_str(&format!("\n\nCloses #{issue_num}"));
1416        }
1417    }
1418
1419    let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1420    let task_ref = format_task_ref(task_id);
1421    body.push_str(&format!(
1422        "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1423    ));
1424    body
1425}
1426
1427/// Create a PR if one doesn't already exist.
1428///
1429/// Uses [`GhHttp`] exclusively for GitHub API operations. No `gh` CLI
1430/// fallback is used, ensuring consistent behavior across environments.
1431///
1432/// # Returns
1433/// - `Ok(url)` - PR URL (newly created or already existed)
1434/// - `Err(PrCreateError)` - API error or other failure
1435#[allow(clippy::too_many_arguments)]
1436pub async fn create_pr_if_needed(
1437    dir: &Path,
1438    branch: &str,
1439    title: &str,
1440    summary: &str,
1441    accomplished: &[String],
1442    remaining: &[String],
1443    files: &[String],
1444    task_id: &str,
1445    agent: &str,
1446    model: Option<&str>,
1447    repo: &str,
1448    base_branch: &str,
1449) -> PrCreateResult<String> {
1450    let gh = GhHttp::new()?;
1451
1452    // Check if PR already exists using GhHttp API
1453    match gh.get_pr_number(repo, branch).await {
1454        Ok(Some(pr_number)) => {
1455            tracing::info!(task_id, pr = pr_number, "PR already exists");
1456            // Append attribution footer if the agent created the PR without one.
1457            append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model).await;
1458            // Fetch the existing PR to get its URL so the caller can store pr_number.
1459            let pr = match gh.get_pr(repo, pr_number).await {
1460                Ok(pr) => pr,
1461                Err(e) => {
1462                    tracing::warn!(task_id, error = %e, "get_pr API call failed after detecting existing PR");
1463                    return Err(PrCreateError::ApiError(e));
1464                }
1465            };
1466            return Ok(pr.html_url);
1467        }
1468        Ok(None) => {
1469            // No PR exists, proceed to create one
1470        }
1471        Err(e) => {
1472            tracing::warn!(task_id, error = %e, "get_pr_number API call failed");
1473            return Err(PrCreateError::ApiError(e));
1474        }
1475    }
1476
1477    // When the agent didn't self-report files, derive them from git.
1478    let git_files: Vec<String>;
1479    let effective_files: &[String] = if files.is_empty() {
1480        match tokio::process::Command::new("git")
1481            .args([
1482                "diff",
1483                &format!("origin/{base_branch}...HEAD"),
1484                "--name-only",
1485            ])
1486            .current_dir(dir)
1487            .output()
1488            .await
1489        {
1490            Ok(output) if output.status.success() => {
1491                git_files = String::from_utf8_lossy(&output.stdout)
1492                    .lines()
1493                    .filter(|l| !l.is_empty())
1494                    .map(|l| l.to_string())
1495                    .collect();
1496                &git_files
1497            }
1498            _ => files,
1499        }
1500    } else {
1501        files
1502    };
1503
1504    let body = build_pr_body(
1505        title,
1506        summary,
1507        accomplished,
1508        remaining,
1509        effective_files,
1510        task_id,
1511        agent,
1512        model,
1513    );
1514
1515    // Always use the short task title for the PR title (summary goes in body)
1516    let pr_title = title;
1517
1518    // Create PR using GhHttp API with exponential backoff retry for transient 5xx errors.
1519    const MAX_RETRIES: u32 = 3;
1520    let mut last_error: Option<anyhow::Error> = None;
1521    let mut created_url: Option<String> = None;
1522    let mut had_transient_error = false;
1523    let mut effective_base = base_branch.to_string();
1524    let mut tried_api_fallback = false;
1525
1526    for attempt in 1..=MAX_RETRIES {
1527        match gh
1528            .create_pr(repo, pr_title, &body, branch, &effective_base)
1529            .await
1530        {
1531            Ok(u) => {
1532                created_url = Some(u);
1533                break;
1534            }
1535            Err(e) => {
1536                let err_str = format!("{e}");
1537                if is_transient_github_error(&err_str) {
1538                    had_transient_error = true;
1539                    if attempt < MAX_RETRIES {
1540                        let delay = std::time::Duration::from_secs(2u64.pow(attempt));
1541                        tracing::warn!(
1542                            task_id,
1543                            attempt,
1544                            delay_secs = delay.as_secs(),
1545                            error = %e,
1546                            "transient GitHub API error during PR creation — retrying"
1547                        );
1548                        tokio::time::sleep(delay).await;
1549                    }
1550                } else if is_invalid_base_error(&err_str) && !tried_api_fallback {
1551                    // Local base branch detection was wrong (e.g. "remotes/origin/main").
1552                    // Query GitHub API for the canonical default branch and retry once.
1553                    tried_api_fallback = true;
1554                    tracing::warn!(
1555                        task_id,
1556                        base_branch = %effective_base,
1557                        error = %e,
1558                        "PR creation failed with invalid base — fetching default branch from GitHub API"
1559                    );
1560                    match gh.get_default_branch(repo).await {
1561                        Ok(api_default) if api_default != effective_base => {
1562                            tracing::info!(
1563                                task_id,
1564                                old_base = %effective_base,
1565                                new_base = %api_default,
1566                                "retrying PR creation with GitHub API default branch"
1567                            );
1568                            effective_base = api_default;
1569                            continue;
1570                        }
1571                        Ok(api_default) => {
1572                            tracing::warn!(
1573                                task_id,
1574                                base_branch = %api_default,
1575                                "GitHub API default branch matches local — no fallback possible"
1576                            );
1577                        }
1578                        Err(api_err) => {
1579                            tracing::warn!(
1580                                task_id,
1581                                error = %api_err,
1582                                "failed to fetch default branch from GitHub API"
1583                            );
1584                        }
1585                    }
1586                }
1587                last_error = Some(e);
1588            }
1589        }
1590    }
1591
1592    let url = match created_url {
1593        Some(u) => u,
1594        None => {
1595            let e = last_error.unwrap_or_else(|| {
1596                anyhow::anyhow!("PR creation failed after retries (no error captured)")
1597            });
1598            let err_str = format!("{e}");
1599            // For transient 5xx errors, GitHub may have created the PR despite returning
1600            // an error (e.g. 502 after the write succeeded). Re-check for an existing PR
1601            // before propagating the failure so we don't orphan the PR from the task.
1602            // Also check when the final error is non-transient but a prior attempt had a
1603            // transient error — the PR may have been created on that earlier attempt (e.g.
1604            // attempt 1 returns 502, attempt 2 returns 422 "already exists").
1605            if is_transient_github_error(&err_str) || had_transient_error {
1606                tracing::warn!(
1607                    task_id,
1608                    error = %e,
1609                    "transient GitHub API error after all retries — checking if PR was actually created"
1610                );
1611                match gh.get_pr_number(repo, branch).await {
1612                    Ok(Some(pr_number)) => {
1613                        tracing::info!(
1614                            task_id,
1615                            pr = pr_number,
1616                            "PR was created despite transient errors — recovering"
1617                        );
1618                        append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model)
1619                            .await;
1620                        match gh.get_pr(repo, pr_number).await {
1621                            Ok(pr) => pr.html_url,
1622                            Err(get_err) => {
1623                                tracing::warn!(
1624                                    task_id,
1625                                    error = %get_err,
1626                                    "failed to fetch PR URL after transient-error recovery"
1627                                );
1628                                return Err(PrCreateError::ApiError(e));
1629                            }
1630                        }
1631                    }
1632                    Ok(None) => {
1633                        tracing::warn!(
1634                            task_id,
1635                            "PR was not created after all retries — propagating original error"
1636                        );
1637                        return Err(PrCreateError::ApiError(e));
1638                    }
1639                    Err(check_err) => {
1640                        tracing::warn!(
1641                            task_id,
1642                            error = %check_err,
1643                            "failed to verify PR existence after transient error retries — propagating original error"
1644                        );
1645                        return Err(PrCreateError::ApiError(e));
1646                    }
1647                }
1648            } else {
1649                return Err(PrCreateError::ApiError(e));
1650            }
1651        }
1652    };
1653
1654    tracing::info!(task_id, pr_url = %url, "created PR via GhHttp API");
1655
1656    Ok(url)
1657}
1658
1659/// Returns true if the error string indicates a transient GitHub API failure
1660/// (HTTP 5xx, network errors, DNS failures, TLS issues, rate limits), where
1661/// the operation may succeed on retry.
1662pub(crate) fn is_transient_github_error(err_str: &str) -> bool {
1663    // Treat transport/send failures and explicit GhHttp server-error messages
1664    // as transient. Older callers relied on parsing the "failed (NNN)" pattern
1665    // to detect 5xx — keep that, but also recognize the newer wrappers.
1666    if err_str.contains("HTTP send failed") {
1667        return true;
1668    }
1669    if err_str.contains("GitHub API server error") {
1670        return true;
1671    }
1672
1673    // Check for HTTP 5xx status codes
1674    if extract_github_http_status(err_str)
1675        .map(|s| (500..600).contains(&s))
1676        .unwrap_or(false)
1677    {
1678        return true;
1679    }
1680
1681    // Check for common network-level transient errors
1682    let lower = err_str.to_lowercase();
1683
1684    // Connection and transport errors
1685    if lower.contains("connection reset")
1686        || lower.contains("broken pipe")
1687        || lower.contains("connection refused")
1688        || lower.contains("connection closed")
1689        || lower.contains("transport error")
1690        || lower.contains("failed to connect")
1691        || lower.contains("failed to connect to github.com port")
1692        || lower.contains("curl: (7) failed to connect")
1693        || lower.contains("couldn't connect to server")
1694        || lower.contains("could not connect to server")
1695    {
1696        return true;
1697    }
1698
1699    // Timeout errors
1700    if lower.contains("timeout") {
1701        return true;
1702    }
1703
1704    // DNS resolution errors
1705    if lower.contains("dns error")
1706        || lower.contains("resolve")
1707        || lower.contains("name resolution")
1708        || lower.contains("no such host")
1709    {
1710        return true;
1711    }
1712
1713    // TLS/SSL errors
1714    if lower.contains("tls handshake")
1715        || lower.contains("certificate")
1716        || lower.contains("ssl error")
1717    {
1718        return true;
1719    }
1720
1721    // Network unreachable errors
1722    if lower.contains("network is unreachable")
1723        || lower.contains("host unreachable")
1724        || lower.contains("temporary failure")
1725    {
1726        return true;
1727    }
1728
1729    // EOF errors (connection dropped mid-request)
1730    if lower.contains("unexpected eof") || lower.contains("unexpected end of file") {
1731        return true;
1732    }
1733
1734    // Internal circuit-breaker state (orch's own GitHub 5xx protection)
1735    if lower.contains("circuit-breaker") || lower.contains("circuit breaker") {
1736        return true;
1737    }
1738
1739    // Rate limit errors (transient with cooldown/retry)
1740    if lower.contains("rate limit") || lower.contains("too many requests") {
1741        return true;
1742    }
1743
1744    false
1745}
1746
1747/// Returns true when the error indicates the PR base branch is invalid.
1748///
1749/// GitHub returns HTTP 422 with `PullRequest.base invalid` when the base
1750/// branch name is not a valid ref in the repository. This is a terminal
1751/// configuration error — retrying with the same base will never succeed.
1752pub(crate) fn is_invalid_base_error(err_str: &str) -> bool {
1753    let lower = err_str.to_lowercase();
1754    lower.contains("422") && lower.contains("base") && lower.contains("invalid")
1755}
1756
1757/// Returns true when an error looks like a transient *GitHub API/CLI* failure.
1758///
1759/// This is stricter than [`is_transient_github_error`]: in addition to matching
1760/// a transient transport pattern, it requires GitHub-specific context in the
1761/// message. This prevents generic agent/runtime transport failures (for
1762/// example "broken pipe" from a model stream disconnect) from being treated as
1763/// GitHub retry signals in review reroute logic.
1764pub(crate) fn is_transient_github_api_error(err_str: &str) -> bool {
1765    if !is_transient_github_error(err_str) {
1766        return false;
1767    }
1768
1769    if extract_github_http_status(err_str).is_some() {
1770        return true;
1771    }
1772
1773    let lower = err_str.to_lowercase();
1774    lower.contains("github")
1775        || lower.contains("api.github.com")
1776        || lower.contains("gh api")
1777        || lower.contains("gh pr")
1778        || lower.contains("gh error")
1779        // `circuit-breaker` is orch's internal GitHub 5xx protection, set
1780        // exclusively by GhHttp.  It can never originate from agent/runtime
1781        // failures, so treating it as a GitHub-context marker is safe even
1782        // when upstream wrapping strips the "GitHub API" prefix.
1783        || lower.contains("circuit-breaker")
1784        || lower.contains("circuit breaker")
1785}
1786
1787/// Extract the HTTP status code from a GhHttp error string.
1788///
1789/// GhHttp formats errors as: `"GitHub API POST https://... failed (NNN): body"`
1790/// This parses the numeric status code from that format.
1791fn extract_github_http_status(err_str: &str) -> Option<u16> {
1792    let marker = "failed (";
1793    let start = err_str.find(marker)? + marker.len();
1794    let rest = &err_str[start..];
1795    let end = rest.find(')')?;
1796    rest[..end].parse().ok()
1797}
1798
1799/// Append the Orch attribution footer to an existing PR body if not already present.
1800///
1801/// Called when the agent created the PR directly (bypassing orch's
1802/// `create_pr_if_needed`), so the footer was never added by `build_pr_body`.
1803async fn append_pr_footer_if_missing(
1804    gh: &GhHttp,
1805    repo: &str,
1806    pr_number: u64,
1807    task_id: &str,
1808    agent: &str,
1809    model: Option<&str>,
1810) {
1811    let pr = match gh.get_pr(repo, pr_number).await {
1812        Ok(pr) => pr,
1813        Err(e) => {
1814            tracing::debug!(task_id, error = %e, "failed to fetch PR for footer check");
1815            return;
1816        }
1817    };
1818
1819    let body = pr.body.unwrap_or_default();
1820    if body.contains("via [Orch]") {
1821        return; // Footer already present
1822    }
1823
1824    let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1825    let task_ref = format_task_ref(task_id);
1826    let footer = format!(
1827        "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1828    );
1829    let new_body = format!("{body}{footer}");
1830
1831    if let Err(e) = gh.update_pr_body(repo, pr_number, &new_body).await {
1832        tracing::warn!(task_id, error = %e, "failed to append footer to PR (non-fatal)");
1833    } else {
1834        tracing::info!(
1835            task_id,
1836            pr = pr_number,
1837            "appended attribution footer to agent-created PR"
1838        );
1839    }
1840}
1841
1842/// Get the current branch name.
1843async fn get_current_branch(dir: &Path) -> String {
1844    let output = Command::new("git")
1845        .args(["branch", "--show-current"])
1846        .current_dir(dir)
1847        .output_with_context()
1848        .await;
1849
1850    match output {
1851        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
1852        _ => String::new(),
1853    }
1854}
1855
1856/// Count the number of changed files in the working directory.
1857pub async fn count_changed_files(dir: &Path) -> anyhow::Result<usize> {
1858    // Count modified and new files (excluding deleted)
1859    let output = Command::new("git")
1860        .args(["status", "--porcelain"])
1861        .current_dir(dir)
1862        .output_with_context()
1863        .await?;
1864
1865    let status_output = String::from_utf8_lossy(&output.stdout);
1866    let count = status_output
1867        .lines()
1868        .filter(|line| {
1869            let prefix = line.get(0..2).unwrap_or("");
1870            // Count modified (M), added (A), renamed (R), copied (C), untracked (??)
1871            prefix.starts_with('M')
1872                || prefix.starts_with('A')
1873                || prefix.starts_with('R')
1874                || prefix.starts_with('C')
1875                || prefix.starts_with('?')
1876        })
1877        .count();
1878
1879    Ok(count)
1880}
1881
1882/// Check if there are unpushed commits.
1883/// Returns `(remote_exists, has_unpushed_commits)`.
1884///
1885/// - `remote_exists` — whether `origin/<branch>` is known to the local repo.
1886/// - `has_unpushed_commits` — whether there are local commits not yet on the
1887///   remote.  When the remote branch doesn't exist the comparison is against
1888///   `origin/<default_branch>` so we can still detect new work.
1889async fn check_push_needed(dir: &Path, branch: &str, default_branch: &str) -> (bool, bool) {
1890    // Check if remote tracking branch exists
1891    let remote_exists = Command::new("git")
1892        .args(["rev-parse", &format!("origin/{branch}")])
1893        .current_dir(dir)
1894        .output_with_context()
1895        .await
1896        .map(|o| o.status.success())
1897        .unwrap_or(false);
1898
1899    let compare_ref = if remote_exists {
1900        format!("origin/{branch}..HEAD")
1901    } else {
1902        // Compare against default branch (passed in, NOT detected from
1903        // current HEAD — in a worktree HEAD is the feature branch itself)
1904        format!("origin/{default_branch}..HEAD")
1905    };
1906
1907    tracing::debug!(
1908        branch,
1909        compare_ref,
1910        remote_exists,
1911        "checking unpushed commits"
1912    );
1913
1914    let output = Command::new("git")
1915        .args(["log", &compare_ref, "--oneline"])
1916        .current_dir(dir)
1917        .output_with_context()
1918        .await;
1919
1920    let has_unpushed = match output {
1921        Ok(o) if o.status.success() => {
1922            let out = String::from_utf8_lossy(&o.stdout);
1923            let has = !out.trim().is_empty();
1924            tracing::debug!(branch, has_unpushed = has, "unpushed commits check result");
1925            has
1926        }
1927        _ => {
1928            tracing::debug!(branch, "git log failed for compare ref, assuming unpushed");
1929            // If we can't determine, assume there ARE commits to push
1930            // (better to attempt push and let it fail than silently skip)
1931            true
1932        }
1933    };
1934
1935    (remote_exists, has_unpushed)
1936}
1937
1938#[cfg(test)]
1939mod tests {
1940    use super::*;
1941
1942    #[test]
1943    fn pr_create_error_display() {
1944        let err = PrCreateError::ApiError(anyhow::anyhow!("test error"));
1945        assert_eq!(format!("{}", err), "GitHub API error: test error");
1946    }
1947
1948    #[test]
1949    fn pr_create_error_from_anyhow() {
1950        let anyhow_err = anyhow::anyhow!("source error");
1951        let pr_err: PrCreateError = anyhow_err.into();
1952        assert!(matches!(pr_err, PrCreateError::ApiError(_)));
1953    }
1954
1955    #[test]
1956    fn build_git_auth_env_without_token_falls_back_to_ssh_https_conversion() {
1957        // Pass None directly to test the no-token path without relying on env vars
1958        // or the gh CLI, keeping the test deterministic regardless of local auth state.
1959        let env = build_git_auth_env_for_token(None);
1960
1961        // When no token is available the fallback must include the SSH insteadOf rule.
1962        // The SSH→HTTPS conversion sets the target URL (the rewrite destination) to git@github.com:.
1963        let has_ssh_rule = env
1964            .iter()
1965            .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "git@github.com:");
1966        assert!(
1967            has_ssh_rule,
1968            "expected SSH insteadOf fallback (value is git@github.com:), got: {env:?}"
1969        );
1970    }
1971
1972    #[test]
1973    fn build_git_auth_env_with_token_covers_both_ssh_and_https() {
1974        // Pass a pre-built token directly to test the token path without touching env vars.
1975        let env = build_git_auth_env_for_token(Some("ghp_testtoken1234".into()));
1976
1977        // Must contain the token in the auth URL (the token is in the value of GIT_CONFIG_KEY_N).
1978        let has_token = env
1979            .iter()
1980            .any(|(_, v)| v.contains("x-access-token:ghp_testtoken1234@github.com"));
1981        assert!(has_token, "expected token in auth URL, got: {env:?}");
1982        // Must cover HTTPS origins (GIT_CONFIG_VALUE_0 is the HTTPS source URL).
1983        let has_https_rule = env
1984            .iter()
1985            .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "https://github.com/");
1986        assert!(
1987            has_https_rule,
1988            "expected HTTPS insteadOf rule, got: {env:?}"
1989        );
1990        // Must cover SSH origins (GIT_CONFIG_VALUE_1 is the SSH source URL).
1991        let has_ssh_rule = env
1992            .iter()
1993            .any(|(k, v)| k == "GIT_CONFIG_VALUE_1" && v == "git@github.com:");
1994        assert!(has_ssh_rule, "expected SSH insteadOf rule, got: {env:?}");
1995    }
1996
1997    #[test]
1998    fn find_stash_ref_by_hash_returns_matching_reference() {
1999        let stash_list = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{1}\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb stash@{0}";
2000
2001        assert_eq!(
2002            find_stash_ref_by_hash(stash_list, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
2003            Some("stash@{1}".to_string())
2004        );
2005        assert_eq!(
2006            find_stash_ref_by_hash(stash_list, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
2007            Some("stash@{0}".to_string())
2008        );
2009    }
2010
2011    #[test]
2012    fn find_stash_ref_by_hash_returns_none_when_missing_or_malformed() {
2013        let stash_list = "malformed-line\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{2}";
2014
2015        assert_eq!(
2016            find_stash_ref_by_hash(stash_list, "cccccccccccccccccccccccccccccccccccccccc"),
2017            None
2018        );
2019        assert_eq!(find_stash_ref_by_hash("", "anything"), None);
2020    }
2021
2022    #[test]
2023    fn push_needs_rebase_detects_common_rejections() {
2024        assert!(push_needs_rebase(
2025            "! [rejected] branch -> branch (fetch first)"
2026        ));
2027        assert!(push_needs_rebase("non-fast-forward update was rejected"));
2028        assert!(push_needs_rebase(
2029            "push rejected because the remote contains work"
2030        ));
2031    }
2032
2033    #[test]
2034    fn is_transient_network_error_detects_lfs_locks_timeout() {
2035        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";
2036        assert!(is_transient_network_error(lfs_err));
2037        assert!(is_transient_network_error(
2038            "dial tcp 140.82.112.4:443: connect: connection refused"
2039        ));
2040        assert!(is_transient_network_error(
2041            "read tcp 10.0.0.1:51234->140.82.112.4:443: read: connection reset by peer"
2042        ));
2043    }
2044
2045    #[test]
2046    fn is_transient_network_error_rejects_ref_rejections() {
2047        assert!(!is_transient_network_error(
2048            "! [rejected] branch -> branch (fetch first)"
2049        ));
2050        assert!(!is_transient_network_error(
2051            "remote: error: GH006: Protected branch update failed"
2052        ));
2053    }
2054
2055    #[test]
2056    fn sanitize_push_error_strips_url_credentials() {
2057        // Shape must not look like a real GitHub PAT so gitleaks stays clean
2058        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";
2059        let sanitized = sanitize_push_error(raw);
2060        assert!(!sanitized.contains("test-secret-1234567890abcdef"));
2061        assert!(sanitized.contains("://***@github.com"));
2062        assert!(sanitized.contains("i/o timeout"));
2063    }
2064
2065    #[test]
2066    fn extract_github_http_status_parses_ghhttp_error_format() {
2067        let err =
2068            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
2069        assert_eq!(extract_github_http_status(err), Some(502));
2070
2071        let err500 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (500): Internal Server Error";
2072        assert_eq!(extract_github_http_status(err500), Some(500));
2073
2074        let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
2075        assert_eq!(extract_github_http_status(err422), Some(422));
2076
2077        let not_github = "some other error";
2078        assert_eq!(extract_github_http_status(not_github), None);
2079    }
2080
2081    #[test]
2082    fn is_transient_github_error_matches_5xx_only() {
2083        let err502 =
2084            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
2085        assert!(is_transient_github_error(err502));
2086
2087        let err503 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (503): Service Unavailable";
2088        assert!(is_transient_github_error(err503));
2089
2090        let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
2091        assert!(!is_transient_github_error(err422));
2092
2093        let err404 =
2094            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (404): Not Found";
2095        assert!(!is_transient_github_error(err404));
2096    }
2097
2098    #[test]
2099    fn is_transient_github_error_matches_connection_errors() {
2100        // Connection-related transient errors
2101        assert!(is_transient_github_error("connection refused"));
2102        assert!(is_transient_github_error("Connection reset by peer"));
2103        assert!(is_transient_github_error("broken pipe"));
2104        assert!(is_transient_github_error("connection closed unexpectedly"));
2105        assert!(is_transient_github_error(
2106            "transport error: connection lost"
2107        ));
2108
2109        // Git/Curl connect-failure patterns (review push failures)
2110        assert!(is_transient_github_error(
2111            "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"
2112        ));
2113        assert!(is_transient_github_error(
2114            "Failed to connect to github.com port 443: Connection timed out"
2115        ));
2116        assert!(is_transient_github_error(
2117            "Failed to connect to github.com port 443: Connection refused"
2118        ));
2119        assert!(is_transient_github_error(
2120            "curl: (7) Failed to connect to github.com port 443: Couldn't connect to server"
2121        ));
2122        assert!(is_transient_github_error(
2123            "fatal: unable to access 'https://github.com/owner/repo.git/': Couldn't connect to server"
2124        ));
2125    }
2126
2127    #[test]
2128    fn is_transient_github_error_matches_dns_errors() {
2129        // DNS resolution failures
2130        assert!(is_transient_github_error("dns error: failed to lookup"));
2131        assert!(is_transient_github_error("failed to resolve hostname"));
2132        assert!(is_transient_github_error("name resolution failed"));
2133        assert!(is_transient_github_error("no such host: api.github.com"));
2134    }
2135
2136    #[test]
2137    fn is_transient_github_error_matches_tls_errors() {
2138        // TLS/SSL handshake failures
2139        assert!(is_transient_github_error("tls handshake failed"));
2140        assert!(is_transient_github_error("certificate verification failed"));
2141        assert!(is_transient_github_error("ssl error: unknown ca"));
2142    }
2143
2144    #[test]
2145    fn is_transient_github_error_matches_network_unreachable() {
2146        // Network unreachable errors
2147        assert!(is_transient_github_error("network is unreachable"));
2148        assert!(is_transient_github_error("host unreachable"));
2149        assert!(is_transient_github_error(
2150            "temporary failure in name resolution"
2151        ));
2152    }
2153
2154    #[test]
2155    fn is_transient_github_error_matches_eof_errors() {
2156        // EOF/connection dropped errors
2157        assert!(is_transient_github_error("unexpected eof while reading"));
2158        assert!(is_transient_github_error("unexpected end of file"));
2159    }
2160
2161    #[test]
2162    fn is_transient_github_error_matches_rate_limit() {
2163        // Rate limit errors (transient with cooldown)
2164        assert!(is_transient_github_error("rate limit exceeded"));
2165        assert!(is_transient_github_error("429 Too Many Requests"));
2166        assert!(is_transient_github_error("too many requests, retry later"));
2167    }
2168
2169    #[test]
2170    fn is_transient_github_error_matches_timeout() {
2171        // Timeout errors
2172        assert!(is_transient_github_error("operation timeout"));
2173        assert!(is_transient_github_error("request timeout"));
2174    }
2175
2176    #[test]
2177    fn is_transient_github_error_rejects_non_transient() {
2178        // Non-transient errors should not match
2179        assert!(!is_transient_github_error("permission denied"));
2180        assert!(!is_transient_github_error("file not found"));
2181        assert!(!is_transient_github_error("invalid argument"));
2182        assert!(!is_transient_github_error("authentication failed"));
2183        assert!(!is_transient_github_error("bad credentials"));
2184    }
2185
2186    #[test]
2187    fn is_transient_github_api_error_requires_github_context() {
2188        assert!(is_transient_github_api_error(
2189            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway"
2190        ));
2191        assert!(is_transient_github_api_error(
2192            "gh error: transport error: connection reset by peer"
2193        ));
2194        assert!(is_transient_github_api_error(
2195            "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"
2196        ));
2197        assert!(!is_transient_github_api_error(
2198            "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)"
2199        ));
2200        assert!(!is_transient_github_api_error(
2201            "agent stream failed: failed to connect to local relay"
2202        ));
2203    }
2204
2205    #[test]
2206    fn is_transient_github_api_error_matches_circuit_breaker() {
2207        // Full message from GhHttp
2208        assert!(is_transient_github_api_error(
2209            "GitHub API transient 5xx circuit-breaker active for 240s"
2210        ));
2211        // Wrapped through PrCreateError + response_handler
2212        assert!(is_transient_github_api_error(
2213            "create PR failed: GitHub API error: GitHub API transient 5xx circuit-breaker active"
2214        ));
2215        // Shortened form where upstream wrapping stripped the "GitHub API" prefix —
2216        // circuit-breaker alone must still be recognized as a GitHub signal.
2217        assert!(is_transient_github_api_error(
2218            "create PR failed: circuit-breaker"
2219        ));
2220        // Spaced variant
2221        assert!(is_transient_github_api_error(
2222            "create PR failed: circuit breaker tripped"
2223        ));
2224    }
2225
2226    #[tokio::test]
2227    async fn auto_commit_unstages_files_on_commit_failure() {
2228        // Create a temp git repo with one initial commit
2229        let dir =
2230            std::env::temp_dir().join(format!("orch_test_auto_commit_{}", std::process::id()));
2231        let _ = std::fs::remove_dir_all(&dir);
2232        std::fs::create_dir_all(&dir).unwrap();
2233
2234        // Init repo
2235        let _ = Command::new("git")
2236            .args(["init"])
2237            .current_dir(&dir)
2238            .output()
2239            .await;
2240        let _ = Command::new("git")
2241            .args(["config", "user.email", "test@test.com"])
2242            .current_dir(&dir)
2243            .output()
2244            .await;
2245        let _ = Command::new("git")
2246            .args(["config", "user.name", "Test"])
2247            .current_dir(&dir)
2248            .output()
2249            .await;
2250        // Create initial file and commit
2251        std::fs::write(dir.join("init.txt"), "init").unwrap();
2252        let _ = Command::new("git")
2253            .args(["add", "init.txt"])
2254            .current_dir(&dir)
2255            .output()
2256            .await;
2257        let _ = Command::new("git")
2258            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2259            .current_dir(&dir)
2260            .output()
2261            .await;
2262
2263        // Create a new file (unstaged)
2264        std::fs::write(dir.join("new_file.txt"), "content").unwrap();
2265
2266        // Verify has_changes sees it
2267        assert!(has_changes(&dir).await);
2268
2269        // auto_commit should return false (no git user.email configured for this
2270        // test repo would cause commit to fail if we hadn't set it, but we did —
2271        // so this test verifies the happy path too).
2272        // For this test, the commit should succeed since we set user.email/name.
2273        let result = auto_commit(&dir, "1", "Test title", "test", 1).await;
2274        assert!(result.unwrap());
2275
2276        // Verify no changes remain
2277        assert!(!has_changes(&dir).await);
2278
2279        // Cleanup
2280        let _ = std::fs::remove_dir_all(&dir);
2281    }
2282
2283    #[tokio::test]
2284    async fn auto_commit_ignores_repo_signing_config() {
2285        // Repo demands signing via a signer that always fails — auto_commit
2286        // must still commit (it passes -c commit.gpgsign=false)
2287        let dir =
2288            std::env::temp_dir().join(format!("orch_test_auto_commit_gpg_{}", std::process::id()));
2289        let _ = std::fs::remove_dir_all(&dir);
2290        std::fs::create_dir_all(&dir).unwrap();
2291
2292        for args in [
2293            vec!["init"],
2294            vec!["config", "user.email", "test@test.com"],
2295            vec!["config", "user.name", "Test"],
2296            vec!["config", "commit.gpgsign", "true"],
2297            vec!["config", "gpg.program", "/usr/bin/false"],
2298        ] {
2299            let _ = Command::new("git")
2300                .args(&args)
2301                .current_dir(&dir)
2302                .output()
2303                .await;
2304        }
2305        std::fs::write(dir.join("init.txt"), "init").unwrap();
2306        let _ = Command::new("git")
2307            .args(["add", "init.txt"])
2308            .current_dir(&dir)
2309            .output()
2310            .await;
2311        let _ = Command::new("git")
2312            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2313            .current_dir(&dir)
2314            .output()
2315            .await;
2316
2317        std::fs::write(dir.join("signed_off.txt"), "content").unwrap();
2318        assert!(has_changes(&dir).await);
2319
2320        let result = auto_commit(&dir, "3", "Test title", "test", 1).await;
2321        assert!(result.unwrap());
2322        assert!(!has_changes(&dir).await);
2323
2324        let _ = std::fs::remove_dir_all(&dir);
2325    }
2326
2327    #[tokio::test]
2328    async fn auto_commit_unstages_on_pre_commit_hook_failure() {
2329        // Create a temp git repo where commit always fails (via pre-commit hook)
2330        let dir =
2331            std::env::temp_dir().join(format!("orch_test_auto_commit_hook_{}", std::process::id()));
2332        let _ = std::fs::remove_dir_all(&dir);
2333        std::fs::create_dir_all(&dir).unwrap();
2334
2335        // Init repo
2336        let _ = Command::new("git")
2337            .args(["init"])
2338            .current_dir(&dir)
2339            .output()
2340            .await;
2341        let _ = Command::new("git")
2342            .args(["config", "user.email", "test@test.com"])
2343            .current_dir(&dir)
2344            .output()
2345            .await;
2346        let _ = Command::new("git")
2347            .args(["config", "user.name", "Test"])
2348            .current_dir(&dir)
2349            .output()
2350            .await;
2351        // Create initial commit
2352        std::fs::write(dir.join("init.txt"), "init").unwrap();
2353        let _ = Command::new("git")
2354            .args(["add", "init.txt"])
2355            .current_dir(&dir)
2356            .output()
2357            .await;
2358        let _ = Command::new("git")
2359            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2360            .current_dir(&dir)
2361            .output()
2362            .await;
2363
2364        // Install a pre-commit hook that always fails
2365        let hooks_dir = dir.join(".git/hooks");
2366        std::fs::create_dir_all(&hooks_dir).unwrap();
2367        std::fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\nexit 1\n").unwrap();
2368        #[cfg(unix)]
2369        {
2370            use std::os::unix::fs::PermissionsExt;
2371            let mut perms = std::fs::metadata(hooks_dir.join("pre-commit"))
2372                .unwrap()
2373                .permissions();
2374            perms.set_mode(0o755);
2375            std::fs::set_permissions(hooks_dir.join("pre-commit"), perms).unwrap();
2376        }
2377
2378        // Create a new file
2379        std::fs::write(dir.join("staged_file.txt"), "content").unwrap();
2380
2381        // Verify has_changes sees it
2382        assert!(has_changes(&dir).await);
2383
2384        // auto_commit should return false (pre-commit hook blocks it)
2385        let result = auto_commit(&dir, "2", "Test title", "test", 1).await;
2386        assert!(!result.unwrap());
2387
2388        // After commit failure, files should be unstaged — has_changes should
2389        // still return true because the unstaged file is still there.
2390        assert!(has_changes(&dir).await);
2391
2392        // Verify the file is NOT staged (git diff --cached --quiet should succeed)
2393        let cached = Command::new("git")
2394            .args(["diff", "--cached", "--quiet"])
2395            .current_dir(&dir)
2396            .status()
2397            .await;
2398        assert!(
2399            cached.map(|s| s.success()).unwrap_or(false),
2400            "files should be unstaged after commit failure"
2401        );
2402
2403        // Cleanup
2404        let _ = std::fs::remove_dir_all(&dir);
2405    }
2406
2407    #[test]
2408    fn is_workflow_scope_error_detects_backtick_format() {
2409        let err = "! [remote rejected] branch -> branch (refusing to allow an OAuth App to create or update workflow `.github/workflows/ci.yml` without `workflow` scope)";
2410        assert!(is_workflow_scope_error(err));
2411    }
2412
2413    #[test]
2414    fn is_workflow_scope_error_detects_single_quote_format() {
2415        let err = "refusing to allow an OAuth App to create or update workflow '.github/workflows/ci.yml' without 'workflow' scope";
2416        assert!(is_workflow_scope_error(err));
2417    }
2418
2419    #[test]
2420    fn is_workflow_scope_error_detects_refusing_to_allow_variant() {
2421        let err = "remote: refusing to allow a Personal Access Token to create or update workflow `.github/workflows/scan.yml` without `workflow` scope";
2422        assert!(is_workflow_scope_error(err));
2423    }
2424
2425    #[test]
2426    fn is_workflow_scope_error_rejects_unrelated_errors() {
2427        assert!(!is_workflow_scope_error("non-fast-forward"));
2428        assert!(!is_workflow_scope_error("permission denied"));
2429        assert!(!is_workflow_scope_error("authentication failed"));
2430        assert!(!is_workflow_scope_error("rejected"));
2431    }
2432
2433    #[tokio::test]
2434    async fn strip_workflow_files_removes_workflow_from_commits() {
2435        // Create a temp git repo simulating the scenario
2436        let dir = std::env::temp_dir().join(format!("orch_test_strip_wf_{}", std::process::id()));
2437        let _ = std::fs::remove_dir_all(&dir);
2438        std::fs::create_dir_all(&dir).unwrap();
2439
2440        // Init repo with initial commit
2441        let _ = Command::new("git")
2442            .args(["init"])
2443            .current_dir(&dir)
2444            .output()
2445            .await;
2446        let _ = Command::new("git")
2447            .args(["config", "user.email", "test@test.com"])
2448            .current_dir(&dir)
2449            .output()
2450            .await;
2451        let _ = Command::new("git")
2452            .args(["config", "user.name", "Test"])
2453            .current_dir(&dir)
2454            .output()
2455            .await;
2456        std::fs::write(dir.join("init.txt"), "init").unwrap();
2457        let _ = Command::new("git")
2458            .args(["add", "."])
2459            .current_dir(&dir)
2460            .output()
2461            .await;
2462        let _ = Command::new("git")
2463            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2464            .current_dir(&dir)
2465            .output()
2466            .await;
2467
2468        // Create a "remote" by adding the repo as its own origin (for rev-list)
2469        let _ = Command::new("git")
2470            .args(["remote", "add", "origin", dir.to_str().unwrap()])
2471            .current_dir(&dir)
2472            .output()
2473            .await;
2474        let _ = Command::new("git")
2475            .args(["fetch", "origin"])
2476            .current_dir(&dir)
2477            .output()
2478            .await;
2479
2480        // Create a branch off main
2481        let _ = Command::new("git")
2482            .args(["checkout", "-b", "feature"])
2483            .current_dir(&dir)
2484            .output()
2485            .await;
2486
2487        // Add both a normal file and a workflow file
2488        std::fs::write(dir.join("feature.txt"), "feature work").unwrap();
2489        let wf_dir = dir.join(".github/workflows");
2490        std::fs::create_dir_all(&wf_dir).unwrap();
2491        std::fs::write(wf_dir.join("ci.yml"), "name: CI\non: push").unwrap();
2492        let _ = Command::new("git")
2493            .args(["add", "."])
2494            .current_dir(&dir)
2495            .output()
2496            .await;
2497        let _ = Command::new("git")
2498            .args([
2499                "-c",
2500                "commit.gpgsign=false",
2501                "commit",
2502                "-m",
2503                "add feature and workflow",
2504            ])
2505            .current_dir(&dir)
2506            .output()
2507            .await;
2508
2509        // Get the default branch name
2510        let branch_out = Command::new("git")
2511            .args(["rev-parse", "--abbrev-ref", "origin/HEAD"])
2512            .current_dir(&dir)
2513            .output()
2514            .await;
2515        // Fallback to "main" if origin/HEAD is not set
2516        let default_branch = branch_out
2517            .ok()
2518            .filter(|o| o.status.success())
2519            .map(|o| {
2520                String::from_utf8_lossy(&o.stdout)
2521                    .trim()
2522                    .strip_prefix("origin/")
2523                    .unwrap_or("main")
2524                    .to_string()
2525            })
2526            .unwrap_or_else(|| "main".to_string());
2527
2528        // Strip workflow files
2529        let stripped = strip_workflow_files(&dir, &default_branch).await.unwrap();
2530        assert!(stripped, "should have stripped workflow files");
2531
2532        // Verify workflow file is no longer in the committed tree
2533        let ls_output = Command::new("git")
2534            .args(["ls-tree", "-r", "--name-only", "HEAD"])
2535            .current_dir(&dir)
2536            .output()
2537            .await
2538            .unwrap();
2539        let files = String::from_utf8_lossy(&ls_output.stdout);
2540        assert!(
2541            !files.contains(".github/workflows/ci.yml"),
2542            "workflow file should be removed from commits"
2543        );
2544        assert!(
2545            files.contains("feature.txt"),
2546            "non-workflow files should be preserved"
2547        );
2548
2549        // Cleanup
2550        let _ = std::fs::remove_dir_all(&dir);
2551    }
2552
2553    #[tokio::test]
2554    async fn strip_workflow_files_returns_false_when_no_workflows() {
2555        let dir =
2556            std::env::temp_dir().join(format!("orch_test_strip_wf_none_{}", std::process::id()));
2557        let _ = std::fs::remove_dir_all(&dir);
2558        std::fs::create_dir_all(&dir).unwrap();
2559
2560        // Init repo
2561        let _ = Command::new("git")
2562            .args(["init"])
2563            .current_dir(&dir)
2564            .output()
2565            .await;
2566        let _ = Command::new("git")
2567            .args(["config", "user.email", "test@test.com"])
2568            .current_dir(&dir)
2569            .output()
2570            .await;
2571        let _ = Command::new("git")
2572            .args(["config", "user.name", "Test"])
2573            .current_dir(&dir)
2574            .output()
2575            .await;
2576        std::fs::write(dir.join("init.txt"), "init").unwrap();
2577        let _ = Command::new("git")
2578            .args(["add", "."])
2579            .current_dir(&dir)
2580            .output()
2581            .await;
2582        let _ = Command::new("git")
2583            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2584            .current_dir(&dir)
2585            .output()
2586            .await;
2587
2588        let _ = Command::new("git")
2589            .args(["remote", "add", "origin", dir.to_str().unwrap()])
2590            .current_dir(&dir)
2591            .output()
2592            .await;
2593        let _ = Command::new("git")
2594            .args(["fetch", "origin"])
2595            .current_dir(&dir)
2596            .output()
2597            .await;
2598
2599        let _ = Command::new("git")
2600            .args(["checkout", "-b", "feature2"])
2601            .current_dir(&dir)
2602            .output()
2603            .await;
2604
2605        // Only non-workflow files
2606        std::fs::write(dir.join("feature.txt"), "feature").unwrap();
2607        let _ = Command::new("git")
2608            .args(["add", "."])
2609            .current_dir(&dir)
2610            .output()
2611            .await;
2612        let _ = Command::new("git")
2613            .args([
2614                "-c",
2615                "commit.gpgsign=false",
2616                "commit",
2617                "-m",
2618                "add feature only",
2619            ])
2620            .current_dir(&dir)
2621            .output()
2622            .await;
2623
2624        let stripped = strip_workflow_files(&dir, "main").await.unwrap();
2625        assert!(!stripped, "should not strip when no workflow files exist");
2626
2627        // Cleanup
2628        let _ = std::fs::remove_dir_all(&dir);
2629    }
2630
2631    #[test]
2632    fn is_invalid_base_error_detects_base_invalid() {
2633        assert!(is_invalid_base_error(
2634            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): {\"errors\":[{\"resource\":\"PullRequest\",\"field\":\"base\",\"code\":\"invalid\"}]}"
2635        ));
2636        assert!(is_invalid_base_error(
2637            "GitHub API POST .../pulls failed (422): Base field is invalid"
2638        ));
2639    }
2640
2641    #[test]
2642    fn is_invalid_base_error_rejects_non_base_errors() {
2643        // No-commits error should NOT match
2644        assert!(!is_invalid_base_error(
2645            "GitHub API POST .../pulls failed (422): No commits between main and branch"
2646        ));
2647        // Head invalid should NOT match
2648        assert!(!is_invalid_base_error(
2649            "GitHub API POST .../pulls failed (422): head is not a valid ref"
2650        ));
2651        // Transient 5xx should NOT match
2652        assert!(!is_invalid_base_error(
2653            "GitHub API POST .../pulls failed (502): Bad Gateway"
2654        ));
2655        // 404 should NOT match
2656        assert!(!is_invalid_base_error(
2657            "GitHub API POST .../pulls failed (404): Not Found"
2658        ));
2659    }
2660
2661    fn pr_summary(body: &str) -> &str {
2662        let rest = body
2663            .strip_prefix("## Summary\n\n")
2664            .expect("PR body starts with ## Summary");
2665        rest.split("\n\n").next().unwrap()
2666    }
2667
2668    fn assert_no_push_or_pr_creation(body: &str) {
2669        let lower = body.to_ascii_lowercase();
2670        for needle in [
2671            "git push",
2672            "gh pr create",
2673            "did not push",
2674            "couldn't push",
2675            "could not push",
2676            "push was denied",
2677            "push was blocked",
2678            "push permission",
2679            "nothing is on the remote",
2680            "open the pr",
2681        ] {
2682            assert!(
2683                !lower.contains(needle),
2684                "PR body still contains {needle:?}:\n{body}"
2685            );
2686        }
2687    }
2688
2689    #[test]
2690    fn build_pr_body_strips_push_and_pr_creation_statements() {
2691        let examples = [
2692            (
2693                concat!(
2694                    "Added the lesson list page. ",
2695                    "I did not push or open the PR, because the push was blocked on a permission prompt. ",
2696                    "Run `git push -u origin HEAD`, then `gh pr create`.",
2697                ),
2698                "Added the lesson list page.",
2699            ),
2700            (
2701                "It is committed locally ... The push was denied, so nothing is on the remote yet.",
2702                "It is committed locally",
2703            ),
2704            (
2705                concat!(
2706                    "Fixed the quiz scoring. ",
2707                    "I couldn't push it because the push permission was denied.",
2708                ),
2709                "Fixed the quiz scoring.",
2710            ),
2711        ];
2712        for (summary, work) in examples {
2713            let body = build_pr_body(
2714                "Fix the widget",
2715                summary,
2716                &[
2717                    "Wrote the lesson list".into(),
2718                    "Could not push because the push permission was denied".into(),
2719                ],
2720                &["Run `git push -u origin HEAD`".into()],
2721                &[],
2722                "16",
2723                "claude",
2724                None,
2725            );
2726            assert_eq!(pr_summary(&body), work);
2727            assert!(body.contains("Wrote the lesson list"));
2728            assert!(!body.contains("### Remaining"));
2729            assert_no_push_or_pr_creation(&body);
2730        }
2731    }
2732
2733    #[test]
2734    fn build_pr_body_keeps_summary_without_push_text() {
2735        let summary = "Added retry logic for GitHub 5xx errors.";
2736        let accomplished = vec!["Extracted the PR body builder".into()];
2737        let remaining = vec!["Watch CI after merge".into()];
2738        let body = build_pr_body(
2739            "Fix the widget",
2740            summary,
2741            &accomplished,
2742            &remaining,
2743            &["src/engine/runner/git_ops.rs".into()],
2744            "16",
2745            "claude",
2746            Some("sonnet"),
2747        );
2748        assert_eq!(pr_summary(&body), summary);
2749        assert!(body.contains("### What was done\n\n- Extracted the PR body builder\n"));
2750        assert!(body.contains("### Remaining\n\n- Watch CI after merge\n"));
2751        assert!(body.contains("- `src/engine/runner/git_ops.rs`"));
2752        assert!(body.contains(" using `sonnet`"));
2753    }
2754}