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