Skip to main content

orch/engine/runner/
git_ops.rs

1//! Git operations — auto-commit, push, PR creation.
2//!
3//! These run after the agent completes to ensure all changes
4//! are committed, pushed, and a PR is created.
5//!
6//! All GitHub API operations use [`GhHttp`] exclusively. The `gh` CLI
7//! is not used as a fallback to ensure consistent behavior across
8//! environments and simplify testing.
9
10use crate::cmd::CommandErrorContext;
11use crate::github::http::GhHttp;
12use regex::Regex;
13use std::path::Path;
14use std::sync::LazyLock;
15
16use tokio::process::Command;
17
18/// Format a task ID for display in PR/issue footers.
19///
20/// GitHub task IDs are displayed as `#123` (creates a GH issue hyperlink).
21/// Internal task IDs (`internal:13`) are displayed as `internal-13` (no `#`
22/// to avoid creating a false issue link).
23pub(crate) fn format_task_ref(task_id: &str) -> String {
24    if task_id.starts_with("internal:") {
25        task_id.replace(':', "-")
26    } else {
27        format!("#{task_id}")
28    }
29}
30
31/// Errors that can occur during PR creation.
32#[derive(Debug, thiserror::Error)]
33pub enum PrCreateError {
34    #[error("GitHub API error: {0}")]
35    ApiError(#[from] anyhow::Error),
36}
37
38/// Result type for PR creation operations.
39pub type PrCreateResult<T> = Result<T, PrCreateError>;
40
41/// Check if the current branch has any commits ahead of the default branch.
42pub async fn has_commits_ahead(dir: &Path, default_branch: &str) -> bool {
43    let output = Command::new("git")
44        .args([
45            "rev-list",
46            "--count",
47            &format!("origin/{default_branch}..HEAD"),
48        ])
49        .current_dir(dir)
50        .output()
51        .await;
52    match output {
53        Ok(o) if o.status.success() => {
54            let count: u32 = String::from_utf8_lossy(&o.stdout)
55                .trim()
56                .parse()
57                .unwrap_or(0);
58            count > 0
59        }
60        _ => {
61            tracing::debug!(
62                default_branch,
63                "git rev-list failed for compare ref, assuming commits ahead"
64            );
65            // If we can't determine, assume there ARE commits ahead so we
66            // attempt a push (better to try and fail loudly than silently
67            // skip push and lose the PR creation path).
68            true
69        }
70    }
71}
72
73/// Check if there are uncommitted changes in the working directory.
74pub async fn has_changes(dir: &Path) -> bool {
75    // Check for staged, unstaged, and untracked files
76    let diff = Command::new("git")
77        .args(["diff", "--quiet"])
78        .current_dir(dir)
79        .status()
80        .await;
81
82    let cached = Command::new("git")
83        .args(["diff", "--cached", "--quiet"])
84        .current_dir(dir)
85        .status()
86        .await;
87
88    let untracked = Command::new("git")
89        .args(["ls-files", "--others", "--exclude-standard"])
90        .current_dir(dir)
91        .output_with_context()
92        .await;
93
94    let has_diff = diff.map(|s| !s.success()).unwrap_or(false);
95    let has_cached = cached.map(|s| !s.success()).unwrap_or(false);
96    let has_untracked = untracked
97        .map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
98        .unwrap_or(false);
99
100    has_diff || has_cached || has_untracked
101}
102
103pub(crate) fn find_stash_ref_by_hash(stash_list: &str, stash_hash: &str) -> Option<String> {
104    stash_list.lines().find_map(|line| {
105        let mut parts = line.split_whitespace();
106        match (parts.next(), parts.next()) {
107            (Some(hash), Some(reference)) if hash == stash_hash => Some(reference.to_string()),
108            _ => None,
109        }
110    })
111}
112
113/// Options for [`stash_rebase_restore`].
114pub(crate) struct StashRebaseOpts {
115    /// Disable GPG commit signing during the rebase (passes `-c commit.gpgsign=false`).
116    pub disable_gpg_signing: bool,
117    /// Abort the rebase (via `git rebase --abort`) and restore the stash when the
118    /// rebase fails.  Set to `false` when the caller intentionally leaves the working
119    /// tree in the in-progress-rebase state so an agent can resolve conflicts.
120    pub abort_on_failure: bool,
121}
122
123impl Default for StashRebaseOpts {
124    fn default() -> Self {
125        Self {
126            disable_gpg_signing: false,
127            abort_on_failure: true,
128        }
129    }
130}
131
132/// Outcome returned by [`stash_rebase_restore`].
133#[derive(Debug)]
134pub(crate) enum RebaseOutcome {
135    /// Rebase completed successfully.
136    Succeeded,
137    /// Rebase failed.  When `abort_on_failure` is true the rebase was aborted and any
138    /// stash was restored; when false the working tree is left in the in-progress-rebase
139    /// state.
140    Failed(String),
141    /// A stash operation failed (push failed, or OID could not be captured); the
142    /// rebase was skipped to preserve the dirty working tree.
143    Skipped(String),
144}
145
146/// Stash uncommitted changes, rebase onto `target_ref`, then restore the stash.
147///
148/// This is the single canonical implementation of the stash → rebase → restore
149/// pattern used in multiple call sites.  Callers are responsible for any
150/// `git fetch` that should precede the rebase.
151///
152/// # Concurrency safety
153/// Git stashes are repo-global; multiple worktrees share the same stash stack.
154/// This function captures the stash OID immediately after pushing and applies by
155/// that exact ref — never via `stash pop` — to avoid restoring a stash that
156/// belongs to a different concurrently-running worktree.
157///
158/// # Stash failure handling
159/// If the stash push fails, or the OID cannot be captured after a successful push,
160/// the rebase is **skipped** and [`RebaseOutcome::Skipped`] is returned.  This
161/// preserves the dirty working tree intact rather than risking data loss.
162pub(crate) async fn stash_rebase_restore(
163    dir: &Path,
164    target_ref: &str,
165    opts: StashRebaseOpts,
166) -> anyhow::Result<RebaseOutcome> {
167    let stash_ref: Option<String> = if has_changes(dir).await {
168        let stash_out = Command::new("git")
169            .args(["stash", "push", "--include-untracked", "-m", "orch-rebase"])
170            .current_dir(dir)
171            .output_with_context()
172            .await;
173
174        match stash_out {
175            Ok(o) if o.status.success() => {
176                let stdout_preview = String::from_utf8_lossy(&o.stdout)
177                    .lines()
178                    .next()
179                    .unwrap_or("")
180                    .to_string();
181                tracing::debug!(
182                    dir = %dir.display(),
183                    stdout = %stdout_preview,
184                    "git stash push succeeded before rebase"
185                );
186
187                // Capture the OID of the stash object just created so we can
188                // apply it back by that exact ref, regardless of any stashes
189                // that other worktrees may push between now and then.
190                let ref_out = Command::new("git")
191                    .args(["rev-parse", "refs/stash@{0}"])
192                    .current_dir(dir)
193                    .output_with_context()
194                    .await;
195                let stash_hash = ref_out
196                    .ok()
197                    .filter(|o| o.status.success())
198                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
199                    .filter(|s| !s.is_empty());
200
201                if stash_hash.is_none() {
202                    // Stash was created but we can't track its ref.
203                    // Pop immediately to avoid orphaning the changes, then skip.
204                    tracing::warn!(
205                        dir = %dir.display(),
206                        "git stash succeeded but rev-parse failed — popping stash and skipping rebase"
207                    );
208                    let _ = Command::new("git")
209                        .args(["stash", "pop"])
210                        .current_dir(dir)
211                        .output_with_context()
212                        .await;
213                    return Ok(RebaseOutcome::Skipped(
214                        "stash OID capture failed".to_string(),
215                    ));
216                }
217                stash_hash
218            }
219            Ok(o) => {
220                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
221                tracing::warn!(
222                    dir = %dir.display(),
223                    code = ?o.status.code(),
224                    stderr = %stderr,
225                    "git stash push failed — skipping rebase to preserve dirty state"
226                );
227                return Ok(RebaseOutcome::Skipped(format!(
228                    "stash push failed: {stderr}"
229                )));
230            }
231            Err(e) => {
232                tracing::warn!(
233                    dir = %dir.display(),
234                    error = %e,
235                    "failed to run git stash push — skipping rebase to preserve dirty state"
236                );
237                return Ok(RebaseOutcome::Skipped(format!("stash push error: {e}")));
238            }
239        }
240    } else {
241        None
242    };
243
244    let mut rebase_args: Vec<&str> = Vec::new();
245    if opts.disable_gpg_signing {
246        rebase_args.extend(["-c", "commit.gpgsign=false"]);
247    }
248    rebase_args.extend(["rebase", target_ref]);
249
250    let output = Command::new("git")
251        .args(&rebase_args)
252        .current_dir(dir)
253        .output_with_context()
254        .await;
255
256    match output {
257        Ok(o) if o.status.success() => {
258            tracing::debug!(dir = %dir.display(), target_ref, "rebase succeeded");
259            restore_stash_by_hash(dir, stash_ref.as_deref()).await;
260            Ok(RebaseOutcome::Succeeded)
261        }
262        Ok(o) => {
263            let stderr = String::from_utf8_lossy(&o.stderr).to_string();
264            tracing::warn!(dir = %dir.display(), target_ref, err = %stderr, "rebase failed");
265            if opts.abort_on_failure {
266                let abort = Command::new("git")
267                    .args(["rebase", "--abort"])
268                    .current_dir(dir)
269                    .output_with_context()
270                    .await;
271                match abort {
272                    Err(e) => {
273                        tracing::warn!(dir = %dir.display(), error = %e, "git rebase --abort failed — worktree may be in inconsistent state")
274                    }
275                    Ok(o) if !o.status.success() => {
276                        let stderr = String::from_utf8_lossy(&o.stderr);
277                        tracing::warn!(dir = %dir.display(), stderr = %stderr, "git rebase --abort returned non-zero — worktree may be in inconsistent state");
278                    }
279                    _ => {}
280                }
281            }
282            restore_stash_by_hash(dir, stash_ref.as_deref()).await;
283            Ok(RebaseOutcome::Failed(stderr))
284        }
285        Err(e) => {
286            if opts.abort_on_failure {
287                let abort = Command::new("git")
288                    .args(["rebase", "--abort"])
289                    .current_dir(dir)
290                    .output_with_context()
291                    .await;
292                match abort {
293                    Err(e) => {
294                        tracing::warn!(dir = %dir.display(), error = %e, "git rebase --abort failed — worktree may be in inconsistent state")
295                    }
296                    Ok(o) if !o.status.success() => {
297                        let stderr = String::from_utf8_lossy(&o.stderr);
298                        tracing::warn!(dir = %dir.display(), stderr = %stderr, "git rebase --abort returned non-zero — worktree may be in inconsistent state");
299                    }
300                    _ => {}
301                }
302            }
303            restore_stash_by_hash(dir, stash_ref.as_deref()).await;
304            Err(e)
305        }
306    }
307}
308
309/// Apply a stash entry by its OID and drop it from the stack on success.
310///
311/// On failure the entry is preserved so it can be recovered manually.
312async fn restore_stash_by_hash(dir: &Path, stash_hash: Option<&str>) {
313    let Some(stash_hash) = stash_hash else {
314        return;
315    };
316
317    let apply = Command::new("git")
318        .args(["stash", "apply", stash_hash])
319        .current_dir(dir)
320        .output_with_context()
321        .await;
322    if apply.map(|o| o.status.success()).unwrap_or(false) {
323        let list = Command::new("git")
324            .args(["stash", "list", "--format=%H %gd"])
325            .current_dir(dir)
326            .output_with_context()
327            .await;
328        if let Ok(list_out) = list {
329            if list_out.status.success() {
330                let list_str = String::from_utf8_lossy(&list_out.stdout);
331                if let Some(stash_ref) = find_stash_ref_by_hash(&list_str, stash_hash) {
332                    match Command::new("git")
333                        .args(["stash", "drop", &stash_ref])
334                        .current_dir(dir)
335                        .output_with_context()
336                        .await
337                    {
338                        Ok(drop_out) if !drop_out.status.success() => {
339                            let stderr = String::from_utf8_lossy(&drop_out.stderr);
340                            tracing::warn!(
341                                dir = %dir.display(),
342                                stash_ref = %stash_ref,
343                                err = %stderr,
344                                "stash drop failed after apply — entry may remain on stack"
345                            );
346                        }
347                        Err(e) => {
348                            tracing::warn!(
349                                dir = %dir.display(),
350                                stash_ref = %stash_ref,
351                                error = %e,
352                                "stash drop command failed after apply — entry may remain on stack"
353                            );
354                        }
355                        _ => {}
356                    }
357                } else {
358                    tracing::warn!(
359                        dir = %dir.display(),
360                        stash = %stash_hash,
361                        "stash applied but could not find ref by hash — stash entry may be orphaned on stack"
362                    );
363                }
364            }
365        }
366    } else {
367        tracing::warn!(
368            dir = %dir.display(),
369            stash = %stash_hash,
370            "stash apply failed after rebase — stash preserved for manual recovery"
371        );
372    }
373}
374
375/// Resolve the git author identity from config, falling back to `{agent}[bot]`.
376fn git_identity(agent: &str) -> (String, String) {
377    let name = crate::config::get("git.name").unwrap_or_else(|_| format!("{agent}[bot]"));
378    let email = crate::config::get("git.email")
379        .unwrap_or_else(|_| format!("{agent}[bot]@users.noreply.github.com"));
380    (name, email)
381}
382
383/// Auto-commit any uncommitted changes.
384pub async fn auto_commit(
385    dir: &Path,
386    task_id: &str,
387    title: &str,
388    agent: &str,
389    attempt: u32,
390) -> anyhow::Result<bool> {
391    // Check for changes first (before creating span to avoid Send issues)
392    if !has_changes(dir).await {
393        return Ok(false);
394    }
395
396    tracing::info!(task_id, "auto-committing uncommitted changes");
397
398    let task_ref = format_task_ref(task_id);
399    let commit_msg = format!("{title}\n\nTask {task_ref}\nAgent: {agent}\nAttempt: {attempt}");
400
401    // git add -A
402    let add = Command::new("git")
403        .args(["add", "-A"])
404        .current_dir(dir)
405        .output_with_context()
406        .await?;
407
408    if !add.status.success() {
409        tracing::warn!(task_id, "git add -A failed");
410        return Ok(false);
411    }
412
413    // git commit — override author/committer to match the agent identity
414    let (git_name, git_email) = git_identity(agent);
415    let commit = Command::new("git")
416        // Same policy as the rebase path: never depend on the operator's signing agent
417        .args(["-c", "commit.gpgsign=false", "commit", "-m", &commit_msg])
418        .env("GIT_AUTHOR_NAME", &git_name)
419        .env("GIT_COMMITTER_NAME", &git_name)
420        .env("GIT_AUTHOR_EMAIL", &git_email)
421        .env("GIT_COMMITTER_EMAIL", &git_email)
422        .current_dir(dir)
423        .output_with_context()
424        .await?;
425
426    if !commit.status.success() {
427        let stderr = String::from_utf8_lossy(&commit.stderr);
428        tracing::warn!(task_id, err = %stderr, "git commit failed");
429        // Unstage files so has_changes() sees them as unstaged on the next
430        // check and the workflow can retry the commit cleanly.
431        let restore = Command::new("git")
432            .args(["restore", "--staged", "."])
433            .current_dir(dir)
434            .output_with_context()
435            .await;
436        match restore {
437            Ok(o) if o.status.success() => {}
438            Ok(o) => {
439                let restore_stderr = String::from_utf8_lossy(&o.stderr).to_string();
440                tracing::warn!(
441                    task_id,
442                    stderr = %restore_stderr,
443                    "git restore --staged failed after commit failure; falling back to git reset HEAD -- ."
444                );
445                let reset = Command::new("git")
446                    .args(["reset", "HEAD", "--", "."])
447                    .current_dir(dir)
448                    .output_with_context()
449                    .await?;
450                if !reset.status.success() {
451                    anyhow::bail!(
452                        "git commit failed and cleanup failed (git restore --staged + git reset HEAD -- .): commit stderr: {}; restore stderr: {}; reset stderr: {}",
453                        stderr.trim(),
454                        restore_stderr.trim(),
455                        String::from_utf8_lossy(&reset.stderr).trim()
456                    );
457                }
458            }
459            Err(e) => {
460                tracing::warn!(
461                    task_id,
462                    error = %e,
463                    "git restore --staged command failed after commit failure; falling back to git reset HEAD -- ."
464                );
465                let reset = Command::new("git")
466                    .args(["reset", "HEAD", "--", "."])
467                    .current_dir(dir)
468                    .output_with_context()
469                    .await?;
470                if !reset.status.success() {
471                    anyhow::bail!(
472                        "git commit failed and cleanup failed (git restore --staged command error + git reset HEAD -- .): commit stderr: {}; restore error: {}; reset stderr: {}",
473                        stderr.trim(),
474                        e,
475                        String::from_utf8_lossy(&reset.stderr).trim()
476                    );
477                }
478            }
479        }
480        return Ok(false);
481    }
482
483    tracing::info!(task_id, "auto-commit succeeded");
484    Ok(true)
485}
486
487/// Maximum number of commits to replay during a rebase.
488///
489/// If a branch has diverged so far from the default branch that it would
490/// require replaying more than this many commits, we skip the rebase entirely.
491/// This prevents degenerate cases (e.g. a branch forked from the very first
492/// commit that now sits 300+ commits behind main) from producing hundreds of
493/// add/add conflicts that block the agent and leave the worktree unusable.
494const MAX_REBASE_COMMITS: usize = 50;
495
496/// Rebase the current branch on top of `origin/{branch}`.
497///
498/// Used for push-failure recovery: when another agent has pushed to the same
499/// branch, we fetch the latest remote state and rebase local commits on top.
500/// Returns `Ok(true)` if rebase succeeded, `Ok(false)` if there was nothing to
501/// rebase, and `Err` if rebase failed (conflicts).
502///
503/// Similar to `rebase_on_default` but targets the feature branch instead of
504/// the default branch, and is used after push failures rather than at startup.
505pub async fn rebase_on_branch(dir: &Path, branch: &str) -> anyhow::Result<bool> {
506    let origin_branch = format!("origin/{branch}");
507
508    // Fetch the remote branch to get latest state.
509    // 2-minute timeout mirrors the push timeout to prevent indefinite stalls.
510    let fetch = tokio::time::timeout(
511        std::time::Duration::from_secs(120),
512        Command::new("git")
513            .args(["fetch", "origin", branch])
514            .kill_on_drop(true)
515            .current_dir(dir)
516            .output(),
517    )
518    .await
519    .map_err(|_| anyhow::anyhow!("git fetch timed out after 120s"))
520    .and_then(|r| r.map_err(Into::into));
521
522    match fetch {
523        Ok(o) if o.status.success() => {}
524        Ok(o) => {
525            let stderr = String::from_utf8_lossy(&o.stderr);
526            anyhow::bail!("failed to fetch origin/{branch}: {stderr}");
527        }
528        Err(e) => anyhow::bail!("failed to fetch origin/{branch}: {e}"),
529    }
530
531    // Check if there are local commits to rebase.
532    let count_output = Command::new("git")
533        .args(["rev-list", "--count", &format!("{origin_branch}..HEAD")])
534        .current_dir(dir)
535        .output_with_context()
536        .await;
537
538    let commit_count: usize = match count_output {
539        Ok(o) if o.status.success() => {
540            let raw = String::from_utf8_lossy(&o.stdout);
541            raw.trim().parse().map_err(|e| {
542                anyhow::anyhow!(
543                    "failed to parse commit count from git rev-list output {:?}: {e}",
544                    raw.trim()
545                )
546            })?
547        }
548        Ok(o) => {
549            let stderr = String::from_utf8_lossy(&o.stderr);
550            anyhow::bail!("git rev-list --count failed: {stderr}");
551        }
552        Err(e) => anyhow::bail!("failed to run git rev-list --count: {e}"),
553    };
554
555    if commit_count == 0 {
556        // No local commits to rebase — nothing to do.
557        return Ok(false);
558    }
559
560    if commit_count > MAX_REBASE_COMMITS {
561        anyhow::bail!(
562            "refusing to rebase {commit_count} commits on origin/{branch} (max {})",
563            MAX_REBASE_COMMITS
564        );
565    }
566
567    tracing::info!(
568        branch = branch,
569        commit_count,
570        "rebasing local commits on origin/{branch}"
571    );
572
573    match stash_rebase_restore(dir, &origin_branch, StashRebaseOpts::default()).await? {
574        RebaseOutcome::Succeeded => Ok(true),
575        RebaseOutcome::Failed(e) => anyhow::bail!("rebase failed: {e}"),
576        RebaseOutcome::Skipped(reason) => anyhow::bail!("rebase skipped: {reason}"),
577    }
578}
579
580/// Rebase the current branch on the default branch.
581///
582/// Run before the agent starts to ensure the worktree has the latest code.
583/// Fetches both the default branch and the current branch so agents never
584/// need to run `git fetch` themselves (important for sandboxed agents like
585/// codex whose workspace-write sandbox blocks writes outside the worktree).
586/// Non-fatal: if rebase fails (conflicts), the agent may still be able to work.
587pub async fn rebase_on_default(dir: &Path, default_branch: &str) {
588    // auto_merge leaves a conflicted rebase in place for the agent, aborting it here would undo that
589    if rebase_in_progress(dir).await {
590        tracing::info!("rebase already in progress, skipping pre-dispatch rebase");
591        return;
592    }
593
594    // Fetch current branch (for retries with existing remote commits)
595    // and default branch (for rebase) in one call.
596    let _ = Command::new("git")
597        .args(["fetch", "origin"])
598        .current_dir(dir)
599        .output_with_context()
600        .await;
601
602    // Count commits that would be replayed.  If the branch diverged too far
603    // back we skip the rebase: replaying hundreds of historical commits produces
604    // massive add/add conflicts (the branch adds files that main already has)
605    // and leaves the worktree stuck.
606    let commit_count = Command::new("git")
607        .args([
608            "rev-list",
609            "--count",
610            &format!("origin/{default_branch}..HEAD"),
611        ])
612        .current_dir(dir)
613        .output_with_context()
614        .await
615        .ok()
616        .and_then(|o| {
617            String::from_utf8_lossy(&o.stdout)
618                .trim()
619                .parse::<usize>()
620                .ok()
621        })
622        .unwrap_or(0);
623
624    if commit_count > MAX_REBASE_COMMITS {
625        tracing::warn!(
626            default_branch,
627            commit_count,
628            max = MAX_REBASE_COMMITS,
629            "skipping rebase: branch has too many commits to replay safely"
630        );
631        return;
632    }
633
634    match stash_rebase_restore(
635        dir,
636        &format!("origin/{default_branch}"),
637        StashRebaseOpts::default(),
638    )
639    .await
640    {
641        Ok(RebaseOutcome::Succeeded) => {
642            tracing::debug!(default_branch, "rebased worktree on default branch")
643        }
644        Ok(RebaseOutcome::Failed(e)) => {
645            tracing::warn!(default_branch, err = %e, "rebase failed, continuing with current state")
646        }
647        Ok(RebaseOutcome::Skipped(reason)) => {
648            tracing::warn!(default_branch, %reason, "stash failed before rebase, continuing with current state")
649        }
650        Err(e) => tracing::warn!(err = %e, "rebase error"),
651    }
652}
653
654async fn rebase_in_progress(dir: &Path) -> bool {
655    for name in ["rebase-merge", "rebase-apply"] {
656        let out = Command::new("git")
657            .args(["rev-parse", "--git-path", name])
658            .current_dir(dir)
659            .output_with_context()
660            .await;
661        if let Ok(o) = out {
662            let p = String::from_utf8_lossy(&o.stdout).trim().to_string();
663            if !p.is_empty() && dir.join(p).exists() {
664                return true;
665            }
666        }
667    }
668    false
669}
670
671/// Build git config environment variables that inject GitHub token credentials.
672///
673/// This handles two scenarios:
674/// - **SSH remotes** (`git@github.com:user/repo.git`): rewritten to
675///   `https://x-access-token:TOKEN@github.com/…` via `insteadOf`.
676/// - **HTTPS remotes** (`https://github.com/user/repo.git`): rewritten to
677///   `https://x-access-token:TOKEN@github.com/…` via a second `insteadOf`.
678///
679/// When no token is available, falls back to the legacy SSH→HTTPS conversion
680/// without credentials (works for repos that use credential helpers or SSH keys).
681///
682/// Returns a `Vec<(String, String)>` of environment variable key-value pairs.
683/// These are NOT visible in the process list (`ps`), unlike `-c` args.
684///
685/// Git environment variables:
686/// - `GIT_CONFIG_COUNT` — number of config entries
687/// - `GIT_CONFIG_KEY_N` — config key for entry N
688/// - `GIT_CONFIG_VALUE_N` — config value for entry N
689pub(crate) fn build_git_auth_env() -> Vec<(String, String)> {
690    let token = crate::github::token::TokenResolver::default_env()
691        .get_token_sync()
692        .ok()
693        .flatten();
694    build_git_auth_env_for_token(token)
695}
696
697fn build_git_auth_env_for_token(token: Option<String>) -> Vec<(String, String)> {
698    match token {
699        Some(t) if !t.is_empty() => {
700            let authed = format!("url.https://x-access-token:{t}@github.com/.insteadOf");
701            vec![
702                ("GIT_CONFIG_COUNT".into(), "2".into()),
703                ("GIT_CONFIG_KEY_0".into(), authed.clone()),
704                ("GIT_CONFIG_VALUE_0".into(), "https://github.com/".into()),
705                ("GIT_CONFIG_KEY_1".into(), authed),
706                ("GIT_CONFIG_VALUE_1".into(), "git@github.com:".into()),
707            ]
708        }
709        _ => {
710            vec![
711                ("GIT_CONFIG_COUNT".into(), "1".into()),
712                (
713                    "GIT_CONFIG_KEY_0".into(),
714                    "url.https://github.com/.insteadOf".into(),
715                ),
716                ("GIT_CONFIG_VALUE_0".into(), "git@github.com:".into()),
717            ]
718        }
719    }
720}
721
722/// Returns `true` if the push error indicates that the GitHub token lacks the
723/// `workflow` OAuth scope, which is required to push changes to
724/// `.github/workflows/` files.
725///
726/// GitHub rejects pushes with: "refusing to allow an OAuth App to create or
727/// update workflow `…` without `workflow` scope"
728pub(crate) fn is_workflow_scope_error(stderr: &str) -> bool {
729    stderr.contains("without `workflow` scope")
730        || stderr.contains("without 'workflow' scope")
731        || (stderr.contains("refusing to allow")
732            && stderr.contains("workflow")
733            && stderr.contains("scope"))
734}
735
736/// Remove `.github/workflows/` files from committed changes and amend the
737/// commits so the branch can be pushed without the `workflow` OAuth scope.
738///
739/// Strategy: find all commits ahead of `origin/{default_branch}`, identify
740/// which ones touch `.github/workflows/`, and rewrite them to exclude those
741/// files. Uses `git filter-branch`-style approach via interactive rebase
742/// with `exec` steps.
743///
744/// Returns `Ok(true)` if workflow files were removed and commits rewritten,
745/// `Ok(false)` if no workflow files were found in the commits.
746async fn strip_workflow_files(dir: &Path, default_branch: &str) -> anyhow::Result<bool> {
747    // Find workflow files in commits ahead of the default branch
748    let output = Command::new("git")
749        .args([
750            "diff",
751            "--name-only",
752            &format!("origin/{default_branch}...HEAD"),
753            "--",
754            ".github/workflows/",
755        ])
756        .current_dir(dir)
757        .output_with_context()
758        .await?;
759
760    let workflow_files: Vec<String> = String::from_utf8_lossy(&output.stdout)
761        .lines()
762        .filter(|l| !l.is_empty())
763        .map(|s| s.to_string())
764        .collect();
765
766    if workflow_files.is_empty() {
767        return Ok(false);
768    }
769
770    tracing::info!(
771        files = ?workflow_files,
772        "stripping workflow files from commits to bypass missing workflow scope"
773    );
774
775    // Remove workflow files from the index and amend each commit.
776    // We use a soft reset → remove files → re-commit approach for simplicity.
777    //
778    // Count commits ahead of default branch
779    let count_output = Command::new("git")
780        .args([
781            "rev-list",
782            "--count",
783            &format!("origin/{default_branch}..HEAD"),
784        ])
785        .current_dir(dir)
786        .output_with_context()
787        .await?;
788
789    let commit_count: usize = String::from_utf8_lossy(&count_output.stdout)
790        .trim()
791        .parse()
792        .unwrap_or(0);
793
794    if commit_count == 0 {
795        return Ok(false);
796    }
797
798    // Save the commit messages before resetting.
799    // Use a null byte as the record prefix — null bytes cannot appear in git commit
800    // messages, so this separator is unambiguous (unlike a string like ---COMMIT_SEP---
801    // which an agent could write into a commit body, corrupting the parse).
802    let log_output = Command::new("git")
803        .args([
804            "log",
805            "--format=%x00%H%n%B",
806            &format!("origin/{default_branch}..HEAD"),
807        ])
808        .current_dir(dir)
809        .output_with_context()
810        .await?;
811    let log_text = String::from_utf8_lossy(&log_output.stdout).to_string();
812
813    // Parse commits in reverse order (oldest first).
814    // Each record starts with \0 (from %x00 in the format), so split on \0 and
815    // discard any leading empty fragment before the first record.
816    let commits: Vec<(String, String)> = log_text
817        .split('\0')
818        .filter(|s| !s.trim().is_empty())
819        .map(|block| {
820            let block = block.trim();
821            let first_nl = block.find('\n').unwrap_or(block.len());
822            let hash = block[..first_nl].to_string();
823            let msg = block[first_nl..].trim().to_string();
824            (hash, msg)
825        })
826        .collect::<Vec<_>>()
827        .into_iter()
828        .rev()
829        .collect();
830
831    if commits.is_empty() {
832        return Ok(false);
833    }
834
835    // Soft reset to the base (origin/default_branch)
836    let reset = Command::new("git")
837        .args(["reset", "--soft", &format!("origin/{default_branch}")])
838        .current_dir(dir)
839        .output_with_context()
840        .await?;
841
842    if !reset.status.success() {
843        let stderr = String::from_utf8_lossy(&reset.stderr);
844        anyhow::bail!("git reset --soft failed during workflow file stripping: {stderr}");
845    }
846
847    // Remove workflow files from the index
848    let mut rm_args = vec!["rm", "--cached", "--ignore-unmatch", "--"];
849    let file_refs: Vec<&str> = workflow_files.iter().map(|s| s.as_str()).collect();
850    rm_args.extend(file_refs);
851
852    let rm = Command::new("git")
853        .args(&rm_args)
854        .current_dir(dir)
855        .output_with_context()
856        .await?;
857
858    if !rm.status.success() {
859        let stderr = String::from_utf8_lossy(&rm.stderr);
860        tracing::warn!("git rm --cached for workflow files failed (non-fatal): {stderr}");
861    }
862
863    // Re-commit all remaining changes as a single commit preserving the first message.
864    // This squashes multiple commits but preserves the work minus workflow files.
865    let has_staged = Command::new("git")
866        .args(["diff", "--cached", "--quiet"])
867        .current_dir(dir)
868        .status()
869        .await
870        .map(|s| !s.success())
871        .unwrap_or(false);
872
873    if !has_staged {
874        tracing::warn!(
875            "after removing workflow files, no staged changes remain — \
876             the only changes were workflow files"
877        );
878        // Reset back to where we were — nothing to push
879        // Use the original HEAD from the first (newest) commit
880        if let Some((hash, _)) = commits.last() {
881            let _ = Command::new("git")
882                .args(["reset", "--soft", hash])
883                .current_dir(dir)
884                .output_with_context()
885                .await;
886        }
887        return Ok(false);
888    }
889
890    // Use the first commit's message (oldest commit)
891    let commit_msg = if commits.len() == 1 {
892        commits[0].1.clone()
893    } else {
894        // Combine messages when squashing
895        let mut combined = String::new();
896        for (i, (_, msg)) in commits.iter().enumerate() {
897            if i > 0 {
898                combined.push_str("\n\n");
899            }
900            combined.push_str(msg);
901        }
902        combined.push_str("\n\n[workflow files stripped: token lacks `workflow` scope]");
903        combined
904    };
905
906    let (git_name, git_email) = git_identity("orchestrator");
907    let commit = Command::new("git")
908        // Same policy as the rebase path: never depend on the operator's signing agent
909        .args(["-c", "commit.gpgsign=false", "commit", "-m", &commit_msg])
910        .env("GIT_AUTHOR_NAME", &git_name)
911        .env("GIT_COMMITTER_NAME", &git_name)
912        .env("GIT_AUTHOR_EMAIL", &git_email)
913        .env("GIT_COMMITTER_EMAIL", &git_email)
914        .current_dir(dir)
915        .output_with_context()
916        .await?;
917
918    if !commit.status.success() {
919        let stderr = String::from_utf8_lossy(&commit.stderr);
920        // Recovery: restore the original commits so the worktree isn't stuck
921        if let Some((original_head, _)) = commits.last() {
922            let _ = Command::new("git")
923                .args(["reset", "--hard", original_head])
924                .current_dir(dir)
925                .output_with_context()
926                .await;
927        }
928        anyhow::bail!("git commit failed during workflow file stripping: {stderr}");
929    }
930
931    tracing::info!(
932        stripped_files = ?workflow_files,
933        original_commits = commit_count,
934        "successfully stripped workflow files from commits"
935    );
936
937    Ok(true)
938}
939
940/// Push the branch to origin.
941pub async fn push_branch(dir: &Path, branch: &str, default_branch: &str) -> anyhow::Result<bool> {
942    let current = get_current_branch(dir).await;
943    let branch_to_push = if !current.is_empty() {
944        &current
945    } else {
946        branch
947    };
948
949    // Guard against empty branch names (prevents git config corruption)
950    if branch_to_push.is_empty() {
951        anyhow::bail!("cannot push: branch name is empty");
952    }
953
954    // Skip push for main/master
955    if branch_to_push == "main" || branch_to_push == "master" {
956        return Ok(false);
957    }
958
959    // Check if there are commits to push and whether the remote branch exists
960    let (remote_exists, has_unpushed) =
961        check_push_needed(dir, branch_to_push, default_branch).await;
962    if remote_exists && !has_unpushed {
963        // Remote already has this branch and it's up to date — nothing to push.
964        // Return true so callers know the branch IS on the remote and PR creation
965        // is safe to attempt (e.g. a PR may already exist or can be created).
966        tracing::debug!(
967            branch = branch_to_push,
968            "remote branch exists and is up to date, skipping push"
969        );
970        return Ok(true);
971    }
972    if !remote_exists && !has_unpushed {
973        // Branch doesn't exist on the remote and has no commits ahead of the
974        // default branch — push it anyway so the branch is created on the remote.
975        // PR creation will then fail with "No commits between …" (422) which is
976        // handled downstream, rather than with "head invalid" (422) which is
977        // harder to distinguish from a real error.
978        tracing::debug!(
979            branch = branch_to_push,
980            "remote branch missing with no new commits — pushing to create it on remote"
981        );
982    }
983
984    tracing::info!(branch = branch_to_push, "pushing branch");
985
986    let auth_env = build_git_auth_env();
987
988    // First attempt: normal push (no force).
989    // lfs.locksverify=false skips the auxiliary LFS locks/verify roundtrip in
990    // the pre-push hook; agents never hold LFS locks, and a transient timeout
991    // on that endpoint must not fail the whole push.
992    // A 2-minute timeout prevents an indefinitely-stalled push from blocking
993    // post-processing and triggering a stuck-task re-dispatch race.
994    let output = tokio::time::timeout(
995        std::time::Duration::from_secs(120),
996        Command::new("git")
997            .args([
998                "-c",
999                "lfs.locksverify=false",
1000                "push",
1001                "-u",
1002                "origin",
1003                branch_to_push,
1004            ])
1005            .kill_on_drop(true)
1006            .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1007            .current_dir(dir)
1008            .output(),
1009    )
1010    .await
1011    .map_err(|_| anyhow::anyhow!("git push timed out after 120s"))??;
1012
1013    if output.status.success() {
1014        tracing::info!(branch = branch_to_push, "push succeeded");
1015        return Ok(true);
1016    }
1017
1018    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1019
1020    // Transient network blip (timeout, reset) rather than a ref rejection —
1021    // retry the identical push once before classifying the run as failed.
1022    if is_transient_network_error(&stderr) {
1023        tracing::warn!(
1024            branch = branch_to_push,
1025            stderr = %sanitize_push_error(&stderr),
1026            "push failed with transient network error — retrying once"
1027        );
1028        let retry_output = tokio::time::timeout(
1029            std::time::Duration::from_secs(120),
1030            Command::new("git")
1031                .args([
1032                    "-c",
1033                    "lfs.locksverify=false",
1034                    "push",
1035                    "-u",
1036                    "origin",
1037                    branch_to_push,
1038                ])
1039                .kill_on_drop(true)
1040                .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1041                .current_dir(dir)
1042                .output(),
1043        )
1044        .await
1045        .map_err(|_| anyhow::anyhow!("git push (network retry) timed out after 120s"))??;
1046
1047        if retry_output.status.success() {
1048            tracing::info!(branch = branch_to_push, "push succeeded on network retry");
1049            return Ok(true);
1050        }
1051
1052        let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
1053            .trim()
1054            .to_string();
1055        tracing::warn!(
1056            branch = branch_to_push,
1057            stderr = %sanitize_push_error(&retry_stderr),
1058            "push retry also failed"
1059        );
1060        anyhow::bail!("push failed: {}", sanitize_push_error(&retry_stderr));
1061    }
1062
1063    // Handle workflow scope error: the token lacks the `workflow` OAuth scope
1064    // required to push changes to `.github/workflows/`. Strip workflow files
1065    // from commits and retry the push.
1066    if is_workflow_scope_error(&stderr) {
1067        tracing::warn!(
1068            branch = branch_to_push,
1069            "push rejected: token lacks `workflow` scope — stripping workflow files and retrying"
1070        );
1071
1072        match strip_workflow_files(dir, default_branch).await {
1073            Ok(true) => {
1074                // Workflow files stripped, retry push
1075                let retry_output = tokio::time::timeout(
1076                    std::time::Duration::from_secs(120),
1077                    Command::new("git")
1078                        .args([
1079                            "-c",
1080                            "lfs.locksverify=false",
1081                            "push",
1082                            "-u",
1083                            "origin",
1084                            branch_to_push,
1085                        ])
1086                        .kill_on_drop(true)
1087                        .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1088                        .current_dir(dir)
1089                        .output(),
1090                )
1091                .await
1092                .map_err(|_| anyhow::anyhow!("git push (retry) timed out after 120s"))??;
1093
1094                if retry_output.status.success() {
1095                    tracing::info!(
1096                        branch = branch_to_push,
1097                        "push succeeded after stripping workflow files"
1098                    );
1099                    return Ok(true);
1100                }
1101
1102                let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
1103                    .trim()
1104                    .to_string();
1105
1106                // If still failing with workflow scope, bail with clear message
1107                if is_workflow_scope_error(&retry_stderr) {
1108                    anyhow::bail!(
1109                        "push failed: token lacks `workflow` OAuth scope and workflow files \
1110                         could not be fully stripped — add `workflow` scope to your GitHub \
1111                         token or use a GitHub App for authentication"
1112                    );
1113                }
1114
1115                anyhow::bail!("push failed after stripping workflow files: {retry_stderr}");
1116            }
1117            Ok(false) => {
1118                // No workflow files found to strip (shouldn't happen if error was detected)
1119                anyhow::bail!(
1120                    "push failed: token lacks `workflow` OAuth scope — add `workflow` scope \
1121                     to your GitHub token or use a GitHub App for authentication. \
1122                     Error: {stderr}"
1123                );
1124            }
1125            Err(e) => {
1126                tracing::error!(error = %e, "failed to strip workflow files from commits");
1127                anyhow::bail!(
1128                    "push failed: token lacks `workflow` OAuth scope and automatic \
1129                     recovery failed ({e}) — add `workflow` scope to your GitHub token \
1130                     or use a GitHub App for authentication"
1131                );
1132            }
1133        }
1134    }
1135
1136    // If non-fast-forward, the branch was likely rebased on the default branch
1137    // by `rebase_on_default()` before the agent started — the local history is
1138    // correct but diverges from the remote. Force-push with lease to update it
1139    // without pulling (which would duplicate commits from main).
1140    if push_needs_rebase(&stderr) && remote_exists {
1141        tracing::info!(
1142            branch = branch_to_push,
1143            "push rejected (non-fast-forward), force-pushing with lease"
1144        );
1145        let output = Command::new("git")
1146            .args([
1147                "-c",
1148                "lfs.locksverify=false",
1149                "push",
1150                "--force-with-lease",
1151                "-u",
1152                "origin",
1153                branch_to_push,
1154            ])
1155            .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1156            .current_dir(dir)
1157            .output_with_context()
1158            .await?;
1159
1160        if output.status.success() {
1161            tracing::info!(branch = branch_to_push, "force push succeeded");
1162            return Ok(true);
1163        }
1164
1165        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1166
1167        // Check if force push also hit the workflow scope error
1168        if is_workflow_scope_error(&stderr) {
1169            tracing::warn!(
1170                branch = branch_to_push,
1171                "force push also rejected due to workflow scope — stripping and retrying"
1172            );
1173            match strip_workflow_files(dir, default_branch).await {
1174                Ok(true) => {
1175                    let retry_output = Command::new("git")
1176                        .args([
1177                            "-c",
1178                            "lfs.locksverify=false",
1179                            "push",
1180                            "--force-with-lease",
1181                            "-u",
1182                            "origin",
1183                            branch_to_push,
1184                        ])
1185                        .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1186                        .current_dir(dir)
1187                        .output_with_context()
1188                        .await?;
1189
1190                    if retry_output.status.success() {
1191                        tracing::info!(
1192                            branch = branch_to_push,
1193                            "force push succeeded after stripping workflow files"
1194                        );
1195                        return Ok(true);
1196                    }
1197
1198                    let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
1199                        .trim()
1200                        .to_string();
1201                    anyhow::bail!(
1202                        "force push failed after stripping workflow files: {retry_stderr}"
1203                    );
1204                }
1205                Ok(false) => {
1206                    anyhow::bail!(
1207                        "force push failed: token lacks `workflow` OAuth scope — \
1208                         add `workflow` scope to your GitHub token or use a GitHub App. \
1209                         Error: {stderr}"
1210                    );
1211                }
1212                Err(e) => {
1213                    anyhow::bail!(
1214                        "force push failed: token lacks `workflow` scope and recovery \
1215                         failed ({e})"
1216                    );
1217                }
1218            }
1219        }
1220
1221        // The remote branch moved since the last fetch (GitHub's "Update branch", another
1222        // clone), so the lease no longer matches. Replay local commits on the new tip once
1223        if is_stale_lease_error(&stderr) && rebase_on_branch(dir, branch_to_push).await? {
1224            let output = Command::new("git")
1225                .args([
1226                    "-c",
1227                    "lfs.locksverify=false",
1228                    "push",
1229                    "-u",
1230                    "origin",
1231                    branch_to_push,
1232                ])
1233                .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1234                .current_dir(dir)
1235                .output_with_context()
1236                .await?;
1237            if output.status.success() {
1238                tracing::info!(
1239                    branch = branch_to_push,
1240                    "push succeeded after rebasing on the moved remote branch"
1241                );
1242                return Ok(true);
1243            }
1244
1245            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1246            anyhow::bail!("push failed after rebasing on the moved remote branch: {stderr}");
1247        }
1248
1249        if is_stale_lease_error(&stderr) {
1250            tracing::info!(
1251                branch = branch_to_push,
1252                "remote branch already holds every local commit, nothing to push"
1253            );
1254            return Ok(true);
1255        }
1256
1257        anyhow::bail!("force push failed: {stderr}");
1258    }
1259
1260    anyhow::bail!("push failed: {stderr}")
1261}
1262
1263fn push_needs_rebase(stderr: &str) -> bool {
1264    let lower = stderr.to_ascii_lowercase();
1265    lower.contains("non-fast-forward")
1266        || lower.contains("rejected")
1267        || lower.contains("fetch first")
1268        || lower.contains("behind")
1269}
1270
1271/// True when `--force-with-lease` refused because the remote ref is not the one last fetched
1272fn is_stale_lease_error(stderr: &str) -> bool {
1273    let lower = stderr.to_ascii_lowercase();
1274    lower.contains("stale info") || lower.contains("remote ref updated since checkout")
1275}
1276
1277/// True when a push failure looks like a transient network error (dial
1278/// timeout, i/o timeout, connection reset) rather than a ref rejection.
1279/// A refused ref must not be retried, but a blipped endpoint deserves one
1280/// immediate retry before the run is classified as `push_failed`.
1281fn is_transient_network_error(stderr: &str) -> bool {
1282    let lower = stderr.to_ascii_lowercase();
1283    lower.contains("i/o timeout")
1284        || lower.contains("dial tcp")
1285        || lower.contains("connection reset")
1286        || lower.contains("connection refused")
1287        || lower.contains("no route to host")
1288        || lower.contains("temporary failure in name resolution")
1289        || lower.contains("eof")
1290}
1291
1292/// Strip credentials from push stderr before it is logged or returned:
1293/// URL userinfo (`https://x-access-token:TOKEN@…`) plus any secrets caught
1294/// by the leak patterns, so a failing push can never write a token to logs.
1295fn sanitize_push_error(stderr: &str) -> String {
1296    static URL_USERINFO: LazyLock<Regex> =
1297        LazyLock::new(|| Regex::new(r"://[^/@\s]+@").expect("valid url userinfo regex"));
1298    let no_userinfo = URL_USERINFO.replace_all(stderr, "://***@");
1299    crate::security::redact(&no_userinfo)
1300}
1301
1302/// Drop sentences and list items about pushing or opening the PR
1303/// Agents report they could not push, orch already pushed, so those lines read as false on GitHub
1304fn strip_push_and_pr_creation(text: &str) -> String {
1305    let mut kept_lines: Vec<String> = Vec::new();
1306    for line in text.lines() {
1307        if line.trim().is_empty() {
1308            if kept_lines.last().is_some_and(|l| !l.is_empty()) {
1309                kept_lines.push(String::new());
1310            }
1311            continue;
1312        }
1313
1314        let indent_len = line.len() - line.trim_start().len();
1315        let indent = &line[..indent_len];
1316        let trimmed = line.trim();
1317        let (marker, rest) = match list_marker(trimmed) {
1318            Some((m, r)) => (m, r),
1319            None => ("", trimmed),
1320        };
1321
1322        let kept: Vec<&str> = split_statements(rest)
1323            .into_iter()
1324            .filter(|s| !is_push_or_pr_creation_statement(s))
1325            .collect();
1326        if kept.is_empty() {
1327            continue;
1328        }
1329        kept_lines.push(format!("{indent}{marker}{}", kept.join(" ")));
1330    }
1331    while kept_lines.last().is_some_and(String::is_empty) {
1332        kept_lines.pop();
1333    }
1334    kept_lines.join("\n")
1335}
1336
1337fn split_statements(text: &str) -> Vec<&str> {
1338    static SEP: LazyLock<Regex> =
1339        LazyLock::new(|| Regex::new(r"(\.{2,}|(?:[.!?]))\s+").expect("valid statement separator"));
1340    let mut statements = Vec::new();
1341    let mut last = 0;
1342    for cap in SEP.captures_iter(text) {
1343        let full = cap.get(0).expect("full separator match");
1344        let punct = cap.get(1).expect("punctuation capture");
1345        let ellipsis = punct.as_str().len() >= 2 && punct.as_str().bytes().all(|b| b == b'.');
1346        let end = if ellipsis { punct.start() } else { punct.end() };
1347        let stmt = text[last..end].trim();
1348        if !stmt.is_empty() {
1349            statements.push(stmt);
1350        }
1351        last = full.end();
1352    }
1353    let rest = text[last..].trim();
1354    if !rest.is_empty() {
1355        statements.push(rest);
1356    }
1357    statements
1358}
1359
1360fn list_marker(line: &str) -> Option<(&str, &str)> {
1361    for prefix in ["- ", "* ", "+ "] {
1362        if let Some(rest) = line.strip_prefix(prefix) {
1363            return Some((prefix, rest));
1364        }
1365    }
1366    let dot = line.find(". ")?;
1367    if !line[..dot].is_empty() && line[..dot].bytes().all(|b| b.is_ascii_digit()) {
1368        Some((&line[..dot + 2], &line[dot + 2..]))
1369    } else {
1370        None
1371    }
1372}
1373
1374fn is_push_or_pr_creation_statement(text: &str) -> bool {
1375    static PAT: LazyLock<Regex> = LazyLock::new(|| {
1376        Regex::new(concat!(
1377            r"(?i)",
1378            r"\bgit\s+push\b",
1379            r"|\bgh\s+pr\s+create\b",
1380            r"|\bgh\s+pr\s+open\b",
1381            r"|\bpush\s+(was\s+)?(denied|blocked|rejected)\b",
1382            r"|\bpush\s+permission\b",
1383            r"|\bcouldn['’]?t\s+push\b",
1384            r"|\bcould\s+not\s+push\b",
1385            r"|\bcan['’]?t\s+push\b",
1386            r"|\bcannot\s+push\b",
1387            r"|\bdidn['’]?t\s+push\b",
1388            r"|\bdid\s+not\s+push\b",
1389            r"|\bnothing\s+is\s+on\s+the\s+remote\b",
1390            r"|\bnot\s+on\s+the\s+remote\b",
1391            r"|\bopen(?:ing)?\s+(?:the|a)\s+pr\b",
1392            r"|\bcreat(?:e|ing)\s+(?:the|a)\s+pr\b",
1393            r"|\bopen(?:ing)?\s+(?:the|a)\s+pull\s+request\b",
1394            r"|\bcreat(?:e|ing)\s+(?:the|a)\s+pull\s+request\b",
1395        ))
1396        .expect("valid push/pr-creation regex")
1397    });
1398    PAT.is_match(text)
1399}
1400
1401fn cleaned_pr_items(items: &[String]) -> Vec<String> {
1402    items
1403        .iter()
1404        .filter_map(|item| {
1405            let cleaned = strip_push_and_pr_creation(item);
1406            (!cleaned.is_empty()).then_some(cleaned)
1407        })
1408        .collect()
1409}
1410
1411/// Format the GitHub PR description from the agent summary, stripping push and PR-creation statements
1412#[allow(clippy::too_many_arguments)]
1413fn build_pr_body(
1414    title: &str,
1415    summary: &str,
1416    accomplished: &[String],
1417    remaining: &[String],
1418    files: &[String],
1419    task_id: &str,
1420    agent: &str,
1421    model: Option<&str>,
1422) -> String {
1423    let cleaned_summary = strip_push_and_pr_creation(summary);
1424    let summary_text = if cleaned_summary.is_empty() {
1425        title
1426    } else {
1427        cleaned_summary.as_str()
1428    };
1429    let accomplished = cleaned_pr_items(accomplished);
1430    let remaining = cleaned_pr_items(remaining);
1431
1432    let mut body = format!("## Summary\n\n{summary_text}");
1433
1434    if !accomplished.is_empty() {
1435        body.push_str("\n\n### What was done\n\n");
1436        for item in &accomplished {
1437            body.push_str(&format!("- {item}\n"));
1438        }
1439    }
1440
1441    if !remaining.is_empty() {
1442        body.push_str("\n\n### Remaining\n\n");
1443        for item in &remaining {
1444            body.push_str(&format!("- {item}\n"));
1445        }
1446    }
1447
1448    if !files.is_empty() {
1449        body.push_str("\n\n### Files changed\n\n");
1450        for file in files {
1451            body.push_str(&format!("- `{file}`\n"));
1452        }
1453    }
1454
1455    if !task_id.starts_with("internal:") {
1456        if let Ok(issue_num) = task_id.parse::<u64>() {
1457            body.push_str(&format!("\n\nCloses #{issue_num}"));
1458        }
1459    }
1460
1461    let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1462    let task_ref = format_task_ref(task_id);
1463    body.push_str(&format!(
1464        "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1465    ));
1466    body
1467}
1468
1469/// Create a PR if one doesn't already exist.
1470///
1471/// Uses [`GhHttp`] exclusively for GitHub API operations. No `gh` CLI
1472/// fallback is used, ensuring consistent behavior across environments.
1473///
1474/// # Returns
1475/// - `Ok(url)` - PR URL (newly created or already existed)
1476/// - `Err(PrCreateError)` - API error or other failure
1477#[allow(clippy::too_many_arguments)]
1478pub async fn create_pr_if_needed(
1479    dir: &Path,
1480    branch: &str,
1481    title: &str,
1482    summary: &str,
1483    accomplished: &[String],
1484    remaining: &[String],
1485    files: &[String],
1486    task_id: &str,
1487    agent: &str,
1488    model: Option<&str>,
1489    repo: &str,
1490    base_branch: &str,
1491) -> PrCreateResult<String> {
1492    let gh = GhHttp::new()?;
1493
1494    // Check if PR already exists using GhHttp API
1495    match gh.get_pr_number(repo, branch).await {
1496        Ok(Some(pr_number)) => {
1497            tracing::info!(task_id, pr = pr_number, "PR already exists");
1498            // Append attribution footer if the agent created the PR without one.
1499            append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model).await;
1500            // Fetch the existing PR to get its URL so the caller can store pr_number.
1501            let pr = match gh.get_pr(repo, pr_number).await {
1502                Ok(pr) => pr,
1503                Err(e) => {
1504                    tracing::warn!(task_id, error = %e, "get_pr API call failed after detecting existing PR");
1505                    return Err(PrCreateError::ApiError(e));
1506                }
1507            };
1508            return Ok(pr.html_url);
1509        }
1510        Ok(None) => {
1511            // No PR exists, proceed to create one
1512        }
1513        Err(e) => {
1514            tracing::warn!(task_id, error = %e, "get_pr_number API call failed");
1515            return Err(PrCreateError::ApiError(e));
1516        }
1517    }
1518
1519    // When the agent didn't self-report files, derive them from git.
1520    let git_files: Vec<String>;
1521    let effective_files: &[String] = if files.is_empty() {
1522        match tokio::process::Command::new("git")
1523            .args([
1524                "diff",
1525                &format!("origin/{base_branch}...HEAD"),
1526                "--name-only",
1527            ])
1528            .current_dir(dir)
1529            .output()
1530            .await
1531        {
1532            Ok(output) if output.status.success() => {
1533                git_files = String::from_utf8_lossy(&output.stdout)
1534                    .lines()
1535                    .filter(|l| !l.is_empty())
1536                    .map(|l| l.to_string())
1537                    .collect();
1538                &git_files
1539            }
1540            _ => files,
1541        }
1542    } else {
1543        files
1544    };
1545
1546    let body = build_pr_body(
1547        title,
1548        summary,
1549        accomplished,
1550        remaining,
1551        effective_files,
1552        task_id,
1553        agent,
1554        model,
1555    );
1556
1557    // Always use the short task title for the PR title (summary goes in body)
1558    let pr_title = title;
1559
1560    // Create PR using GhHttp API with exponential backoff retry for transient 5xx errors.
1561    const MAX_RETRIES: u32 = 3;
1562    let mut last_error: Option<anyhow::Error> = None;
1563    let mut created_url: Option<String> = None;
1564    let mut had_transient_error = false;
1565    let mut effective_base = base_branch.to_string();
1566    let mut tried_api_fallback = false;
1567
1568    for attempt in 1..=MAX_RETRIES {
1569        match gh
1570            .create_pr(repo, pr_title, &body, branch, &effective_base)
1571            .await
1572        {
1573            Ok(u) => {
1574                created_url = Some(u);
1575                break;
1576            }
1577            Err(e) => {
1578                let err_str = format!("{e}");
1579                if is_transient_github_error(&err_str) {
1580                    had_transient_error = true;
1581                    if attempt < MAX_RETRIES {
1582                        let delay = std::time::Duration::from_secs(2u64.pow(attempt));
1583                        tracing::warn!(
1584                            task_id,
1585                            attempt,
1586                            delay_secs = delay.as_secs(),
1587                            error = %e,
1588                            "transient GitHub API error during PR creation — retrying"
1589                        );
1590                        tokio::time::sleep(delay).await;
1591                    }
1592                } else if is_invalid_base_error(&err_str) && !tried_api_fallback {
1593                    // Local base branch detection was wrong (e.g. "remotes/origin/main").
1594                    // Query GitHub API for the canonical default branch and retry once.
1595                    tried_api_fallback = true;
1596                    tracing::warn!(
1597                        task_id,
1598                        base_branch = %effective_base,
1599                        error = %e,
1600                        "PR creation failed with invalid base — fetching default branch from GitHub API"
1601                    );
1602                    match gh.get_default_branch(repo).await {
1603                        Ok(api_default) if api_default != effective_base => {
1604                            tracing::info!(
1605                                task_id,
1606                                old_base = %effective_base,
1607                                new_base = %api_default,
1608                                "retrying PR creation with GitHub API default branch"
1609                            );
1610                            effective_base = api_default;
1611                            continue;
1612                        }
1613                        Ok(api_default) => {
1614                            tracing::warn!(
1615                                task_id,
1616                                base_branch = %api_default,
1617                                "GitHub API default branch matches local — no fallback possible"
1618                            );
1619                        }
1620                        Err(api_err) => {
1621                            tracing::warn!(
1622                                task_id,
1623                                error = %api_err,
1624                                "failed to fetch default branch from GitHub API"
1625                            );
1626                        }
1627                    }
1628                }
1629                last_error = Some(e);
1630            }
1631        }
1632    }
1633
1634    let url = match created_url {
1635        Some(u) => u,
1636        None => {
1637            let e = last_error.unwrap_or_else(|| {
1638                anyhow::anyhow!("PR creation failed after retries (no error captured)")
1639            });
1640            let err_str = format!("{e}");
1641            // For transient 5xx errors, GitHub may have created the PR despite returning
1642            // an error (e.g. 502 after the write succeeded). Re-check for an existing PR
1643            // before propagating the failure so we don't orphan the PR from the task.
1644            // Also check when the final error is non-transient but a prior attempt had a
1645            // transient error — the PR may have been created on that earlier attempt (e.g.
1646            // attempt 1 returns 502, attempt 2 returns 422 "already exists").
1647            if is_transient_github_error(&err_str) || had_transient_error {
1648                tracing::warn!(
1649                    task_id,
1650                    error = %e,
1651                    "transient GitHub API error after all retries — checking if PR was actually created"
1652                );
1653                match gh.get_pr_number(repo, branch).await {
1654                    Ok(Some(pr_number)) => {
1655                        tracing::info!(
1656                            task_id,
1657                            pr = pr_number,
1658                            "PR was created despite transient errors — recovering"
1659                        );
1660                        append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model)
1661                            .await;
1662                        match gh.get_pr(repo, pr_number).await {
1663                            Ok(pr) => pr.html_url,
1664                            Err(get_err) => {
1665                                tracing::warn!(
1666                                    task_id,
1667                                    error = %get_err,
1668                                    "failed to fetch PR URL after transient-error recovery"
1669                                );
1670                                return Err(PrCreateError::ApiError(e));
1671                            }
1672                        }
1673                    }
1674                    Ok(None) => {
1675                        tracing::warn!(
1676                            task_id,
1677                            "PR was not created after all retries — propagating original error"
1678                        );
1679                        return Err(PrCreateError::ApiError(e));
1680                    }
1681                    Err(check_err) => {
1682                        tracing::warn!(
1683                            task_id,
1684                            error = %check_err,
1685                            "failed to verify PR existence after transient error retries — propagating original error"
1686                        );
1687                        return Err(PrCreateError::ApiError(e));
1688                    }
1689                }
1690            } else {
1691                return Err(PrCreateError::ApiError(e));
1692            }
1693        }
1694    };
1695
1696    tracing::info!(task_id, pr_url = %url, "created PR via GhHttp API");
1697
1698    Ok(url)
1699}
1700
1701/// Returns true if the error string indicates a transient GitHub API failure
1702/// (HTTP 5xx, network errors, DNS failures, TLS issues, rate limits), where
1703/// the operation may succeed on retry.
1704pub(crate) fn is_transient_github_error(err_str: &str) -> bool {
1705    // Treat transport/send failures and explicit GhHttp server-error messages
1706    // as transient. Older callers relied on parsing the "failed (NNN)" pattern
1707    // to detect 5xx — keep that, but also recognize the newer wrappers.
1708    if err_str.contains("HTTP send failed") {
1709        return true;
1710    }
1711    if err_str.contains("GitHub API server error") {
1712        return true;
1713    }
1714
1715    // Check for HTTP 5xx status codes
1716    if extract_github_http_status(err_str)
1717        .map(|s| (500..600).contains(&s))
1718        .unwrap_or(false)
1719    {
1720        return true;
1721    }
1722
1723    // Check for common network-level transient errors
1724    let lower = err_str.to_lowercase();
1725
1726    // Connection and transport errors
1727    if lower.contains("connection reset")
1728        || lower.contains("broken pipe")
1729        || lower.contains("connection refused")
1730        || lower.contains("connection closed")
1731        || lower.contains("transport error")
1732        || lower.contains("failed to connect")
1733        || lower.contains("failed to connect to github.com port")
1734        || lower.contains("curl: (7) failed to connect")
1735        || lower.contains("couldn't connect to server")
1736        || lower.contains("could not connect to server")
1737    {
1738        return true;
1739    }
1740
1741    // Timeout errors
1742    if lower.contains("timeout") {
1743        return true;
1744    }
1745
1746    // DNS resolution errors
1747    if lower.contains("dns error")
1748        || lower.contains("resolve")
1749        || lower.contains("name resolution")
1750        || lower.contains("no such host")
1751    {
1752        return true;
1753    }
1754
1755    // TLS/SSL errors
1756    if lower.contains("tls handshake")
1757        || lower.contains("certificate")
1758        || lower.contains("ssl error")
1759    {
1760        return true;
1761    }
1762
1763    // Network unreachable errors
1764    if lower.contains("network is unreachable")
1765        || lower.contains("host unreachable")
1766        || lower.contains("temporary failure")
1767    {
1768        return true;
1769    }
1770
1771    // EOF errors (connection dropped mid-request)
1772    if lower.contains("unexpected eof") || lower.contains("unexpected end of file") {
1773        return true;
1774    }
1775
1776    // Internal circuit-breaker state (orch's own GitHub 5xx protection)
1777    if lower.contains("circuit-breaker") || lower.contains("circuit breaker") {
1778        return true;
1779    }
1780
1781    // Rate limit errors (transient with cooldown/retry)
1782    if lower.contains("rate limit") || lower.contains("too many requests") {
1783        return true;
1784    }
1785
1786    false
1787}
1788
1789/// Returns true when the error indicates the PR base branch is invalid.
1790///
1791/// GitHub returns HTTP 422 with `PullRequest.base invalid` when the base
1792/// branch name is not a valid ref in the repository. This is a terminal
1793/// configuration error — retrying with the same base will never succeed.
1794pub(crate) fn is_invalid_base_error(err_str: &str) -> bool {
1795    let lower = err_str.to_lowercase();
1796    lower.contains("422") && lower.contains("base") && lower.contains("invalid")
1797}
1798
1799/// Returns true when an error looks like a transient *GitHub API/CLI* failure.
1800///
1801/// This is stricter than [`is_transient_github_error`]: in addition to matching
1802/// a transient transport pattern, it requires GitHub-specific context in the
1803/// message. This prevents generic agent/runtime transport failures (for
1804/// example "broken pipe" from a model stream disconnect) from being treated as
1805/// GitHub retry signals in review reroute logic.
1806pub(crate) fn is_transient_github_api_error(err_str: &str) -> bool {
1807    if !is_transient_github_error(err_str) {
1808        return false;
1809    }
1810
1811    if extract_github_http_status(err_str).is_some() {
1812        return true;
1813    }
1814
1815    let lower = err_str.to_lowercase();
1816    lower.contains("github")
1817        || lower.contains("api.github.com")
1818        || lower.contains("gh api")
1819        || lower.contains("gh pr")
1820        || lower.contains("gh error")
1821        // `circuit-breaker` is orch's internal GitHub 5xx protection, set
1822        // exclusively by GhHttp.  It can never originate from agent/runtime
1823        // failures, so treating it as a GitHub-context marker is safe even
1824        // when upstream wrapping strips the "GitHub API" prefix.
1825        || lower.contains("circuit-breaker")
1826        || lower.contains("circuit breaker")
1827}
1828
1829/// Extract the HTTP status code from a GhHttp error string.
1830///
1831/// GhHttp formats errors as: `"GitHub API POST https://... failed (NNN): body"`
1832/// This parses the numeric status code from that format.
1833fn extract_github_http_status(err_str: &str) -> Option<u16> {
1834    let marker = "failed (";
1835    let start = err_str.find(marker)? + marker.len();
1836    let rest = &err_str[start..];
1837    let end = rest.find(')')?;
1838    rest[..end].parse().ok()
1839}
1840
1841/// Append the Orch attribution footer to an existing PR body if not already present.
1842///
1843/// Called when the agent created the PR directly (bypassing orch's
1844/// `create_pr_if_needed`), so the footer was never added by `build_pr_body`.
1845async fn append_pr_footer_if_missing(
1846    gh: &GhHttp,
1847    repo: &str,
1848    pr_number: u64,
1849    task_id: &str,
1850    agent: &str,
1851    model: Option<&str>,
1852) {
1853    let pr = match gh.get_pr(repo, pr_number).await {
1854        Ok(pr) => pr,
1855        Err(e) => {
1856            tracing::debug!(task_id, error = %e, "failed to fetch PR for footer check");
1857            return;
1858        }
1859    };
1860
1861    let body = pr.body.unwrap_or_default();
1862    if body.contains("via [Orch]") {
1863        return; // Footer already present
1864    }
1865
1866    let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1867    let task_ref = format_task_ref(task_id);
1868    let footer = format!(
1869        "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1870    );
1871    let new_body = format!("{body}{footer}");
1872
1873    if let Err(e) = gh.update_pr_body(repo, pr_number, &new_body).await {
1874        tracing::warn!(task_id, error = %e, "failed to append footer to PR (non-fatal)");
1875    } else {
1876        tracing::info!(
1877            task_id,
1878            pr = pr_number,
1879            "appended attribution footer to agent-created PR"
1880        );
1881    }
1882}
1883
1884/// Get the current branch name.
1885async fn get_current_branch(dir: &Path) -> String {
1886    let output = Command::new("git")
1887        .args(["branch", "--show-current"])
1888        .current_dir(dir)
1889        .output_with_context()
1890        .await;
1891
1892    match output {
1893        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
1894        _ => String::new(),
1895    }
1896}
1897
1898/// Count the number of changed files in the working directory.
1899pub async fn count_changed_files(dir: &Path) -> anyhow::Result<usize> {
1900    // Count modified and new files (excluding deleted)
1901    let output = Command::new("git")
1902        .args(["status", "--porcelain"])
1903        .current_dir(dir)
1904        .output_with_context()
1905        .await?;
1906
1907    let status_output = String::from_utf8_lossy(&output.stdout);
1908    let count = status_output
1909        .lines()
1910        .filter(|line| {
1911            let prefix = line.get(0..2).unwrap_or("");
1912            // Count modified (M), added (A), renamed (R), copied (C), untracked (??)
1913            prefix.starts_with('M')
1914                || prefix.starts_with('A')
1915                || prefix.starts_with('R')
1916                || prefix.starts_with('C')
1917                || prefix.starts_with('?')
1918        })
1919        .count();
1920
1921    Ok(count)
1922}
1923
1924/// Check if there are unpushed commits.
1925/// Returns `(remote_exists, has_unpushed_commits)`.
1926///
1927/// - `remote_exists` — whether `origin/<branch>` is known to the local repo.
1928/// - `has_unpushed_commits` — whether there are local commits not yet on the
1929///   remote.  When the remote branch doesn't exist the comparison is against
1930///   `origin/<default_branch>` so we can still detect new work.
1931async fn check_push_needed(dir: &Path, branch: &str, default_branch: &str) -> (bool, bool) {
1932    // Check if remote tracking branch exists
1933    let remote_exists = Command::new("git")
1934        .args(["rev-parse", &format!("origin/{branch}")])
1935        .current_dir(dir)
1936        .output_with_context()
1937        .await
1938        .map(|o| o.status.success())
1939        .unwrap_or(false);
1940
1941    let compare_ref = if remote_exists {
1942        format!("origin/{branch}..HEAD")
1943    } else {
1944        // Compare against default branch (passed in, NOT detected from
1945        // current HEAD — in a worktree HEAD is the feature branch itself)
1946        format!("origin/{default_branch}..HEAD")
1947    };
1948
1949    tracing::debug!(
1950        branch,
1951        compare_ref,
1952        remote_exists,
1953        "checking unpushed commits"
1954    );
1955
1956    let output = Command::new("git")
1957        .args(["log", &compare_ref, "--oneline"])
1958        .current_dir(dir)
1959        .output_with_context()
1960        .await;
1961
1962    let has_unpushed = match output {
1963        Ok(o) if o.status.success() => {
1964            let out = String::from_utf8_lossy(&o.stdout);
1965            let has = !out.trim().is_empty();
1966            tracing::debug!(branch, has_unpushed = has, "unpushed commits check result");
1967            has
1968        }
1969        _ => {
1970            tracing::debug!(branch, "git log failed for compare ref, assuming unpushed");
1971            // If we can't determine, assume there ARE commits to push
1972            // (better to attempt push and let it fail than silently skip)
1973            true
1974        }
1975    };
1976
1977    (remote_exists, has_unpushed)
1978}
1979
1980#[cfg(test)]
1981mod tests {
1982    use super::*;
1983
1984    #[test]
1985    fn pr_create_error_display() {
1986        let err = PrCreateError::ApiError(anyhow::anyhow!("test error"));
1987        assert_eq!(format!("{}", err), "GitHub API error: test error");
1988    }
1989
1990    #[test]
1991    fn pr_create_error_from_anyhow() {
1992        let anyhow_err = anyhow::anyhow!("source error");
1993        let pr_err: PrCreateError = anyhow_err.into();
1994        assert!(matches!(pr_err, PrCreateError::ApiError(_)));
1995    }
1996
1997    #[test]
1998    fn build_git_auth_env_without_token_falls_back_to_ssh_https_conversion() {
1999        // Pass None directly to test the no-token path without relying on env vars
2000        // or the gh CLI, keeping the test deterministic regardless of local auth state.
2001        let env = build_git_auth_env_for_token(None);
2002
2003        // When no token is available the fallback must include the SSH insteadOf rule.
2004        // The SSH→HTTPS conversion sets the target URL (the rewrite destination) to git@github.com:.
2005        let has_ssh_rule = env
2006            .iter()
2007            .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "git@github.com:");
2008        assert!(
2009            has_ssh_rule,
2010            "expected SSH insteadOf fallback (value is git@github.com:), got: {env:?}"
2011        );
2012    }
2013
2014    #[test]
2015    fn build_git_auth_env_with_token_covers_both_ssh_and_https() {
2016        // Pass a pre-built token directly to test the token path without touching env vars.
2017        let env = build_git_auth_env_for_token(Some("ghp_testtoken1234".into()));
2018
2019        // Must contain the token in the auth URL (the token is in the value of GIT_CONFIG_KEY_N).
2020        let has_token = env
2021            .iter()
2022            .any(|(_, v)| v.contains("x-access-token:ghp_testtoken1234@github.com"));
2023        assert!(has_token, "expected token in auth URL, got: {env:?}");
2024        // Must cover HTTPS origins (GIT_CONFIG_VALUE_0 is the HTTPS source URL).
2025        let has_https_rule = env
2026            .iter()
2027            .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "https://github.com/");
2028        assert!(
2029            has_https_rule,
2030            "expected HTTPS insteadOf rule, got: {env:?}"
2031        );
2032        // Must cover SSH origins (GIT_CONFIG_VALUE_1 is the SSH source URL).
2033        let has_ssh_rule = env
2034            .iter()
2035            .any(|(k, v)| k == "GIT_CONFIG_VALUE_1" && v == "git@github.com:");
2036        assert!(has_ssh_rule, "expected SSH insteadOf rule, got: {env:?}");
2037    }
2038
2039    #[test]
2040    fn find_stash_ref_by_hash_returns_matching_reference() {
2041        let stash_list = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{1}\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb stash@{0}";
2042
2043        assert_eq!(
2044            find_stash_ref_by_hash(stash_list, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
2045            Some("stash@{1}".to_string())
2046        );
2047        assert_eq!(
2048            find_stash_ref_by_hash(stash_list, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
2049            Some("stash@{0}".to_string())
2050        );
2051    }
2052
2053    #[test]
2054    fn find_stash_ref_by_hash_returns_none_when_missing_or_malformed() {
2055        let stash_list = "malformed-line\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{2}";
2056
2057        assert_eq!(
2058            find_stash_ref_by_hash(stash_list, "cccccccccccccccccccccccccccccccccccccccc"),
2059            None
2060        );
2061        assert_eq!(find_stash_ref_by_hash("", "anything"), None);
2062    }
2063
2064    #[test]
2065    fn push_needs_rebase_detects_common_rejections() {
2066        assert!(push_needs_rebase(
2067            "! [rejected] branch -> branch (fetch first)"
2068        ));
2069        assert!(push_needs_rebase("non-fast-forward update was rejected"));
2070        assert!(push_needs_rebase(
2071            "push rejected because the remote contains work"
2072        ));
2073    }
2074
2075    #[test]
2076    fn is_transient_network_error_detects_lfs_locks_timeout() {
2077        let lfs_err = "Post \"https://x-access-token:***@github.com/o/r.git/info/lfs/locks/verify\": dial tcp 140.82.112.4:443: i/o timeout";
2078        assert!(is_transient_network_error(lfs_err));
2079        assert!(is_transient_network_error(
2080            "dial tcp 140.82.112.4:443: connect: connection refused"
2081        ));
2082        assert!(is_transient_network_error(
2083            "read tcp 10.0.0.1:51234->140.82.112.4:443: read: connection reset by peer"
2084        ));
2085    }
2086
2087    #[test]
2088    fn is_transient_network_error_rejects_ref_rejections() {
2089        assert!(!is_transient_network_error(
2090            "! [rejected] branch -> branch (fetch first)"
2091        ));
2092        assert!(!is_transient_network_error(
2093            "remote: error: GH006: Protected branch update failed"
2094        ));
2095    }
2096
2097    #[test]
2098    fn sanitize_push_error_strips_url_credentials() {
2099        // Shape must not look like a real GitHub PAT so gitleaks stays clean
2100        let raw = "Post \"https://x-access-token:test-secret-1234567890abcdef@github.com/o/r.git/info/lfs/locks/verify\": dial tcp 140.82.112.4:443: i/o timeout";
2101        let sanitized = sanitize_push_error(raw);
2102        assert!(!sanitized.contains("test-secret-1234567890abcdef"));
2103        assert!(sanitized.contains("://***@github.com"));
2104        assert!(sanitized.contains("i/o timeout"));
2105    }
2106
2107    #[test]
2108    fn extract_github_http_status_parses_ghhttp_error_format() {
2109        let err =
2110            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
2111        assert_eq!(extract_github_http_status(err), Some(502));
2112
2113        let err500 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (500): Internal Server Error";
2114        assert_eq!(extract_github_http_status(err500), Some(500));
2115
2116        let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
2117        assert_eq!(extract_github_http_status(err422), Some(422));
2118
2119        let not_github = "some other error";
2120        assert_eq!(extract_github_http_status(not_github), None);
2121    }
2122
2123    #[test]
2124    fn is_transient_github_error_matches_5xx_only() {
2125        let err502 =
2126            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
2127        assert!(is_transient_github_error(err502));
2128
2129        let err503 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (503): Service Unavailable";
2130        assert!(is_transient_github_error(err503));
2131
2132        let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
2133        assert!(!is_transient_github_error(err422));
2134
2135        let err404 =
2136            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (404): Not Found";
2137        assert!(!is_transient_github_error(err404));
2138    }
2139
2140    #[test]
2141    fn is_transient_github_error_matches_connection_errors() {
2142        // Connection-related transient errors
2143        assert!(is_transient_github_error("connection refused"));
2144        assert!(is_transient_github_error("Connection reset by peer"));
2145        assert!(is_transient_github_error("broken pipe"));
2146        assert!(is_transient_github_error("connection closed unexpectedly"));
2147        assert!(is_transient_github_error(
2148            "transport error: connection lost"
2149        ));
2150
2151        // Git/Curl connect-failure patterns (review push failures)
2152        assert!(is_transient_github_error(
2153            "fatal: unable to access 'https://github.com/owner/repo.git/': Failed to connect to github.com port 443 after 20990 ms: Couldn't connect to server"
2154        ));
2155        assert!(is_transient_github_error(
2156            "Failed to connect to github.com port 443: Connection timed out"
2157        ));
2158        assert!(is_transient_github_error(
2159            "Failed to connect to github.com port 443: Connection refused"
2160        ));
2161        assert!(is_transient_github_error(
2162            "curl: (7) Failed to connect to github.com port 443: Couldn't connect to server"
2163        ));
2164        assert!(is_transient_github_error(
2165            "fatal: unable to access 'https://github.com/owner/repo.git/': Couldn't connect to server"
2166        ));
2167    }
2168
2169    #[test]
2170    fn is_transient_github_error_matches_dns_errors() {
2171        // DNS resolution failures
2172        assert!(is_transient_github_error("dns error: failed to lookup"));
2173        assert!(is_transient_github_error("failed to resolve hostname"));
2174        assert!(is_transient_github_error("name resolution failed"));
2175        assert!(is_transient_github_error("no such host: api.github.com"));
2176    }
2177
2178    #[test]
2179    fn is_transient_github_error_matches_tls_errors() {
2180        // TLS/SSL handshake failures
2181        assert!(is_transient_github_error("tls handshake failed"));
2182        assert!(is_transient_github_error("certificate verification failed"));
2183        assert!(is_transient_github_error("ssl error: unknown ca"));
2184    }
2185
2186    #[test]
2187    fn is_transient_github_error_matches_network_unreachable() {
2188        // Network unreachable errors
2189        assert!(is_transient_github_error("network is unreachable"));
2190        assert!(is_transient_github_error("host unreachable"));
2191        assert!(is_transient_github_error(
2192            "temporary failure in name resolution"
2193        ));
2194    }
2195
2196    #[test]
2197    fn is_transient_github_error_matches_eof_errors() {
2198        // EOF/connection dropped errors
2199        assert!(is_transient_github_error("unexpected eof while reading"));
2200        assert!(is_transient_github_error("unexpected end of file"));
2201    }
2202
2203    #[test]
2204    fn is_transient_github_error_matches_rate_limit() {
2205        // Rate limit errors (transient with cooldown)
2206        assert!(is_transient_github_error("rate limit exceeded"));
2207        assert!(is_transient_github_error("429 Too Many Requests"));
2208        assert!(is_transient_github_error("too many requests, retry later"));
2209    }
2210
2211    #[test]
2212    fn is_transient_github_error_matches_timeout() {
2213        // Timeout errors
2214        assert!(is_transient_github_error("operation timeout"));
2215        assert!(is_transient_github_error("request timeout"));
2216    }
2217
2218    #[test]
2219    fn is_transient_github_error_rejects_non_transient() {
2220        // Non-transient errors should not match
2221        assert!(!is_transient_github_error("permission denied"));
2222        assert!(!is_transient_github_error("file not found"));
2223        assert!(!is_transient_github_error("invalid argument"));
2224        assert!(!is_transient_github_error("authentication failed"));
2225        assert!(!is_transient_github_error("bad credentials"));
2226    }
2227
2228    #[test]
2229    fn is_transient_github_api_error_requires_github_context() {
2230        assert!(is_transient_github_api_error(
2231            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway"
2232        ));
2233        assert!(is_transient_github_api_error(
2234            "gh error: transport error: connection reset by peer"
2235        ));
2236        assert!(is_transient_github_api_error(
2237            "push failed: fatal: unable to access 'https://github.com/owner/repo.git/': Failed to connect to github.com port 443 after 20990 ms: Couldn't connect to server"
2238        ));
2239        assert!(!is_transient_github_api_error(
2240            "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)"
2241        ));
2242        assert!(!is_transient_github_api_error(
2243            "agent stream failed: failed to connect to local relay"
2244        ));
2245    }
2246
2247    #[test]
2248    fn is_transient_github_api_error_matches_circuit_breaker() {
2249        // Full message from GhHttp
2250        assert!(is_transient_github_api_error(
2251            "GitHub API transient 5xx circuit-breaker active for 240s"
2252        ));
2253        // Wrapped through PrCreateError + response_handler
2254        assert!(is_transient_github_api_error(
2255            "create PR failed: GitHub API error: GitHub API transient 5xx circuit-breaker active"
2256        ));
2257        // Shortened form where upstream wrapping stripped the "GitHub API" prefix —
2258        // circuit-breaker alone must still be recognized as a GitHub signal.
2259        assert!(is_transient_github_api_error(
2260            "create PR failed: circuit-breaker"
2261        ));
2262        // Spaced variant
2263        assert!(is_transient_github_api_error(
2264            "create PR failed: circuit breaker tripped"
2265        ));
2266    }
2267
2268    #[tokio::test]
2269    async fn auto_commit_unstages_files_on_commit_failure() {
2270        // Create a temp git repo with one initial commit
2271        let dir =
2272            std::env::temp_dir().join(format!("orch_test_auto_commit_{}", std::process::id()));
2273        let _ = std::fs::remove_dir_all(&dir);
2274        std::fs::create_dir_all(&dir).unwrap();
2275
2276        // Init repo
2277        let _ = Command::new("git")
2278            .args(["init"])
2279            .current_dir(&dir)
2280            .output()
2281            .await;
2282        let _ = Command::new("git")
2283            .args(["config", "user.email", "test@test.com"])
2284            .current_dir(&dir)
2285            .output()
2286            .await;
2287        let _ = Command::new("git")
2288            .args(["config", "user.name", "Test"])
2289            .current_dir(&dir)
2290            .output()
2291            .await;
2292        // Create initial file and commit
2293        std::fs::write(dir.join("init.txt"), "init").unwrap();
2294        let _ = Command::new("git")
2295            .args(["add", "init.txt"])
2296            .current_dir(&dir)
2297            .output()
2298            .await;
2299        let _ = Command::new("git")
2300            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2301            .current_dir(&dir)
2302            .output()
2303            .await;
2304
2305        // Create a new file (unstaged)
2306        std::fs::write(dir.join("new_file.txt"), "content").unwrap();
2307
2308        // Verify has_changes sees it
2309        assert!(has_changes(&dir).await);
2310
2311        // auto_commit should return false (no git user.email configured for this
2312        // test repo would cause commit to fail if we hadn't set it, but we did —
2313        // so this test verifies the happy path too).
2314        // For this test, the commit should succeed since we set user.email/name.
2315        let result = auto_commit(&dir, "1", "Test title", "test", 1).await;
2316        assert!(result.unwrap());
2317
2318        // Verify no changes remain
2319        assert!(!has_changes(&dir).await);
2320
2321        // Cleanup
2322        let _ = std::fs::remove_dir_all(&dir);
2323    }
2324
2325    #[tokio::test]
2326    async fn auto_commit_ignores_repo_signing_config() {
2327        // Repo demands signing via a signer that always fails — auto_commit
2328        // must still commit (it passes -c commit.gpgsign=false)
2329        let dir =
2330            std::env::temp_dir().join(format!("orch_test_auto_commit_gpg_{}", std::process::id()));
2331        let _ = std::fs::remove_dir_all(&dir);
2332        std::fs::create_dir_all(&dir).unwrap();
2333
2334        for args in [
2335            vec!["init"],
2336            vec!["config", "user.email", "test@test.com"],
2337            vec!["config", "user.name", "Test"],
2338            vec!["config", "commit.gpgsign", "true"],
2339            vec!["config", "gpg.program", "/usr/bin/false"],
2340        ] {
2341            let _ = Command::new("git")
2342                .args(&args)
2343                .current_dir(&dir)
2344                .output()
2345                .await;
2346        }
2347        std::fs::write(dir.join("init.txt"), "init").unwrap();
2348        let _ = Command::new("git")
2349            .args(["add", "init.txt"])
2350            .current_dir(&dir)
2351            .output()
2352            .await;
2353        let _ = Command::new("git")
2354            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2355            .current_dir(&dir)
2356            .output()
2357            .await;
2358
2359        std::fs::write(dir.join("signed_off.txt"), "content").unwrap();
2360        assert!(has_changes(&dir).await);
2361
2362        let result = auto_commit(&dir, "3", "Test title", "test", 1).await;
2363        assert!(result.unwrap());
2364        assert!(!has_changes(&dir).await);
2365
2366        let _ = std::fs::remove_dir_all(&dir);
2367    }
2368
2369    #[tokio::test]
2370    async fn auto_commit_unstages_on_pre_commit_hook_failure() {
2371        // Create a temp git repo where commit always fails (via pre-commit hook)
2372        let dir =
2373            std::env::temp_dir().join(format!("orch_test_auto_commit_hook_{}", std::process::id()));
2374        let _ = std::fs::remove_dir_all(&dir);
2375        std::fs::create_dir_all(&dir).unwrap();
2376
2377        // Init repo
2378        let _ = Command::new("git")
2379            .args(["init"])
2380            .current_dir(&dir)
2381            .output()
2382            .await;
2383        let _ = Command::new("git")
2384            .args(["config", "user.email", "test@test.com"])
2385            .current_dir(&dir)
2386            .output()
2387            .await;
2388        let _ = Command::new("git")
2389            .args(["config", "user.name", "Test"])
2390            .current_dir(&dir)
2391            .output()
2392            .await;
2393        // Create initial commit
2394        std::fs::write(dir.join("init.txt"), "init").unwrap();
2395        let _ = Command::new("git")
2396            .args(["add", "init.txt"])
2397            .current_dir(&dir)
2398            .output()
2399            .await;
2400        let _ = Command::new("git")
2401            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2402            .current_dir(&dir)
2403            .output()
2404            .await;
2405
2406        // Install a pre-commit hook that always fails
2407        let hooks_dir = dir.join(".git/hooks");
2408        std::fs::create_dir_all(&hooks_dir).unwrap();
2409        std::fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\nexit 1\n").unwrap();
2410        #[cfg(unix)]
2411        {
2412            use std::os::unix::fs::PermissionsExt;
2413            let mut perms = std::fs::metadata(hooks_dir.join("pre-commit"))
2414                .unwrap()
2415                .permissions();
2416            perms.set_mode(0o755);
2417            std::fs::set_permissions(hooks_dir.join("pre-commit"), perms).unwrap();
2418        }
2419
2420        // Create a new file
2421        std::fs::write(dir.join("staged_file.txt"), "content").unwrap();
2422
2423        // Verify has_changes sees it
2424        assert!(has_changes(&dir).await);
2425
2426        // auto_commit should return false (pre-commit hook blocks it)
2427        let result = auto_commit(&dir, "2", "Test title", "test", 1).await;
2428        assert!(!result.unwrap());
2429
2430        // After commit failure, files should be unstaged — has_changes should
2431        // still return true because the unstaged file is still there.
2432        assert!(has_changes(&dir).await);
2433
2434        // Verify the file is NOT staged (git diff --cached --quiet should succeed)
2435        let cached = Command::new("git")
2436            .args(["diff", "--cached", "--quiet"])
2437            .current_dir(&dir)
2438            .status()
2439            .await;
2440        assert!(
2441            cached.map(|s| s.success()).unwrap_or(false),
2442            "files should be unstaged after commit failure"
2443        );
2444
2445        // Cleanup
2446        let _ = std::fs::remove_dir_all(&dir);
2447    }
2448
2449    #[test]
2450    fn is_workflow_scope_error_detects_backtick_format() {
2451        let err = "! [remote rejected] branch -> branch (refusing to allow an OAuth App to create or update workflow `.github/workflows/ci.yml` without `workflow` scope)";
2452        assert!(is_workflow_scope_error(err));
2453    }
2454
2455    #[test]
2456    fn is_workflow_scope_error_detects_single_quote_format() {
2457        let err = "refusing to allow an OAuth App to create or update workflow '.github/workflows/ci.yml' without 'workflow' scope";
2458        assert!(is_workflow_scope_error(err));
2459    }
2460
2461    #[test]
2462    fn is_workflow_scope_error_detects_refusing_to_allow_variant() {
2463        let err = "remote: refusing to allow a Personal Access Token to create or update workflow `.github/workflows/scan.yml` without `workflow` scope";
2464        assert!(is_workflow_scope_error(err));
2465    }
2466
2467    #[test]
2468    fn is_workflow_scope_error_rejects_unrelated_errors() {
2469        assert!(!is_workflow_scope_error("non-fast-forward"));
2470        assert!(!is_workflow_scope_error("permission denied"));
2471        assert!(!is_workflow_scope_error("authentication failed"));
2472        assert!(!is_workflow_scope_error("rejected"));
2473    }
2474
2475    #[tokio::test]
2476    async fn strip_workflow_files_removes_workflow_from_commits() {
2477        // Create a temp git repo simulating the scenario
2478        let dir = std::env::temp_dir().join(format!("orch_test_strip_wf_{}", std::process::id()));
2479        let _ = std::fs::remove_dir_all(&dir);
2480        std::fs::create_dir_all(&dir).unwrap();
2481
2482        // Init repo with initial commit
2483        let _ = Command::new("git")
2484            .args(["init"])
2485            .current_dir(&dir)
2486            .output()
2487            .await;
2488        let _ = Command::new("git")
2489            .args(["config", "user.email", "test@test.com"])
2490            .current_dir(&dir)
2491            .output()
2492            .await;
2493        let _ = Command::new("git")
2494            .args(["config", "user.name", "Test"])
2495            .current_dir(&dir)
2496            .output()
2497            .await;
2498        std::fs::write(dir.join("init.txt"), "init").unwrap();
2499        let _ = Command::new("git")
2500            .args(["add", "."])
2501            .current_dir(&dir)
2502            .output()
2503            .await;
2504        let _ = Command::new("git")
2505            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2506            .current_dir(&dir)
2507            .output()
2508            .await;
2509
2510        // Create a "remote" by adding the repo as its own origin (for rev-list)
2511        let _ = Command::new("git")
2512            .args(["remote", "add", "origin", dir.to_str().unwrap()])
2513            .current_dir(&dir)
2514            .output()
2515            .await;
2516        let _ = Command::new("git")
2517            .args(["fetch", "origin"])
2518            .current_dir(&dir)
2519            .output()
2520            .await;
2521
2522        // Create a branch off main
2523        let _ = Command::new("git")
2524            .args(["checkout", "-b", "feature"])
2525            .current_dir(&dir)
2526            .output()
2527            .await;
2528
2529        // Add both a normal file and a workflow file
2530        std::fs::write(dir.join("feature.txt"), "feature work").unwrap();
2531        let wf_dir = dir.join(".github/workflows");
2532        std::fs::create_dir_all(&wf_dir).unwrap();
2533        std::fs::write(wf_dir.join("ci.yml"), "name: CI\non: push").unwrap();
2534        let _ = Command::new("git")
2535            .args(["add", "."])
2536            .current_dir(&dir)
2537            .output()
2538            .await;
2539        let _ = Command::new("git")
2540            .args([
2541                "-c",
2542                "commit.gpgsign=false",
2543                "commit",
2544                "-m",
2545                "add feature and workflow",
2546            ])
2547            .current_dir(&dir)
2548            .output()
2549            .await;
2550
2551        // Get the default branch name
2552        let branch_out = Command::new("git")
2553            .args(["rev-parse", "--abbrev-ref", "origin/HEAD"])
2554            .current_dir(&dir)
2555            .output()
2556            .await;
2557        // Fallback to "main" if origin/HEAD is not set
2558        let default_branch = branch_out
2559            .ok()
2560            .filter(|o| o.status.success())
2561            .map(|o| {
2562                String::from_utf8_lossy(&o.stdout)
2563                    .trim()
2564                    .strip_prefix("origin/")
2565                    .unwrap_or("main")
2566                    .to_string()
2567            })
2568            .unwrap_or_else(|| "main".to_string());
2569
2570        // Strip workflow files
2571        let stripped = strip_workflow_files(&dir, &default_branch).await.unwrap();
2572        assert!(stripped, "should have stripped workflow files");
2573
2574        // Verify workflow file is no longer in the committed tree
2575        let ls_output = Command::new("git")
2576            .args(["ls-tree", "-r", "--name-only", "HEAD"])
2577            .current_dir(&dir)
2578            .output()
2579            .await
2580            .unwrap();
2581        let files = String::from_utf8_lossy(&ls_output.stdout);
2582        assert!(
2583            !files.contains(".github/workflows/ci.yml"),
2584            "workflow file should be removed from commits"
2585        );
2586        assert!(
2587            files.contains("feature.txt"),
2588            "non-workflow files should be preserved"
2589        );
2590
2591        // Cleanup
2592        let _ = std::fs::remove_dir_all(&dir);
2593    }
2594
2595    #[tokio::test]
2596    async fn strip_workflow_files_returns_false_when_no_workflows() {
2597        let dir =
2598            std::env::temp_dir().join(format!("orch_test_strip_wf_none_{}", std::process::id()));
2599        let _ = std::fs::remove_dir_all(&dir);
2600        std::fs::create_dir_all(&dir).unwrap();
2601
2602        // Init repo
2603        let _ = Command::new("git")
2604            .args(["init"])
2605            .current_dir(&dir)
2606            .output()
2607            .await;
2608        let _ = Command::new("git")
2609            .args(["config", "user.email", "test@test.com"])
2610            .current_dir(&dir)
2611            .output()
2612            .await;
2613        let _ = Command::new("git")
2614            .args(["config", "user.name", "Test"])
2615            .current_dir(&dir)
2616            .output()
2617            .await;
2618        std::fs::write(dir.join("init.txt"), "init").unwrap();
2619        let _ = Command::new("git")
2620            .args(["add", "."])
2621            .current_dir(&dir)
2622            .output()
2623            .await;
2624        let _ = Command::new("git")
2625            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2626            .current_dir(&dir)
2627            .output()
2628            .await;
2629
2630        let _ = Command::new("git")
2631            .args(["remote", "add", "origin", dir.to_str().unwrap()])
2632            .current_dir(&dir)
2633            .output()
2634            .await;
2635        let _ = Command::new("git")
2636            .args(["fetch", "origin"])
2637            .current_dir(&dir)
2638            .output()
2639            .await;
2640
2641        let _ = Command::new("git")
2642            .args(["checkout", "-b", "feature2"])
2643            .current_dir(&dir)
2644            .output()
2645            .await;
2646
2647        // Only non-workflow files
2648        std::fs::write(dir.join("feature.txt"), "feature").unwrap();
2649        let _ = Command::new("git")
2650            .args(["add", "."])
2651            .current_dir(&dir)
2652            .output()
2653            .await;
2654        let _ = Command::new("git")
2655            .args([
2656                "-c",
2657                "commit.gpgsign=false",
2658                "commit",
2659                "-m",
2660                "add feature only",
2661            ])
2662            .current_dir(&dir)
2663            .output()
2664            .await;
2665
2666        let stripped = strip_workflow_files(&dir, "main").await.unwrap();
2667        assert!(!stripped, "should not strip when no workflow files exist");
2668
2669        // Cleanup
2670        let _ = std::fs::remove_dir_all(&dir);
2671    }
2672
2673    #[test]
2674    fn is_invalid_base_error_detects_base_invalid() {
2675        assert!(is_invalid_base_error(
2676            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): {\"errors\":[{\"resource\":\"PullRequest\",\"field\":\"base\",\"code\":\"invalid\"}]}"
2677        ));
2678        assert!(is_invalid_base_error(
2679            "GitHub API POST .../pulls failed (422): Base field is invalid"
2680        ));
2681    }
2682
2683    #[test]
2684    fn is_invalid_base_error_rejects_non_base_errors() {
2685        // No-commits error should NOT match
2686        assert!(!is_invalid_base_error(
2687            "GitHub API POST .../pulls failed (422): No commits between main and branch"
2688        ));
2689        // Head invalid should NOT match
2690        assert!(!is_invalid_base_error(
2691            "GitHub API POST .../pulls failed (422): head is not a valid ref"
2692        ));
2693        // Transient 5xx should NOT match
2694        assert!(!is_invalid_base_error(
2695            "GitHub API POST .../pulls failed (502): Bad Gateway"
2696        ));
2697        // 404 should NOT match
2698        assert!(!is_invalid_base_error(
2699            "GitHub API POST .../pulls failed (404): Not Found"
2700        ));
2701    }
2702
2703    fn pr_summary(body: &str) -> &str {
2704        let rest = body
2705            .strip_prefix("## Summary\n\n")
2706            .expect("PR body starts with ## Summary");
2707        rest.split("\n\n").next().unwrap()
2708    }
2709
2710    fn assert_no_push_or_pr_creation(body: &str) {
2711        let lower = body.to_ascii_lowercase();
2712        for needle in [
2713            "git push",
2714            "gh pr create",
2715            "did not push",
2716            "couldn't push",
2717            "could not push",
2718            "push was denied",
2719            "push was blocked",
2720            "push permission",
2721            "nothing is on the remote",
2722            "open the pr",
2723        ] {
2724            assert!(
2725                !lower.contains(needle),
2726                "PR body still contains {needle:?}:\n{body}"
2727            );
2728        }
2729    }
2730
2731    #[test]
2732    fn build_pr_body_strips_push_and_pr_creation_statements() {
2733        let examples = [
2734            (
2735                concat!(
2736                    "Added the lesson list page. ",
2737                    "I did not push or open the PR, because the push was blocked on a permission prompt. ",
2738                    "Run `git push -u origin HEAD`, then `gh pr create`.",
2739                ),
2740                "Added the lesson list page.",
2741            ),
2742            (
2743                "It is committed locally ... The push was denied, so nothing is on the remote yet.",
2744                "It is committed locally",
2745            ),
2746            (
2747                concat!(
2748                    "Fixed the quiz scoring. ",
2749                    "I couldn't push it because the push permission was denied.",
2750                ),
2751                "Fixed the quiz scoring.",
2752            ),
2753        ];
2754        for (summary, work) in examples {
2755            let body = build_pr_body(
2756                "Fix the widget",
2757                summary,
2758                &[
2759                    "Wrote the lesson list".into(),
2760                    "Could not push because the push permission was denied".into(),
2761                ],
2762                &["Run `git push -u origin HEAD`".into()],
2763                &[],
2764                "16",
2765                "claude",
2766                None,
2767            );
2768            assert_eq!(pr_summary(&body), work);
2769            assert!(body.contains("Wrote the lesson list"));
2770            assert!(!body.contains("### Remaining"));
2771            assert_no_push_or_pr_creation(&body);
2772        }
2773    }
2774
2775    #[test]
2776    fn build_pr_body_keeps_summary_without_push_text() {
2777        let summary = "Added retry logic for GitHub 5xx errors.";
2778        let accomplished = vec!["Extracted the PR body builder".into()];
2779        let remaining = vec!["Watch CI after merge".into()];
2780        let body = build_pr_body(
2781            "Fix the widget",
2782            summary,
2783            &accomplished,
2784            &remaining,
2785            &["src/engine/runner/git_ops.rs".into()],
2786            "16",
2787            "claude",
2788            Some("sonnet"),
2789        );
2790        assert_eq!(pr_summary(&body), summary);
2791        assert!(body.contains("### What was done\n\n- Extracted the PR body builder\n"));
2792        assert!(body.contains("### Remaining\n\n- Watch CI after merge\n"));
2793        assert!(body.contains("- `src/engine/runner/git_ops.rs`"));
2794        assert!(body.contains(" using `sonnet`"));
2795    }
2796
2797    fn sh_git(dir: &std::path::Path, args: &[&str]) {
2798        let out = std::process::Command::new("git")
2799            .args([
2800                "-c",
2801                "commit.gpgsign=false",
2802                "-c",
2803                "user.name=T",
2804                "-c",
2805                "user.email=t@t",
2806            ])
2807            .args(args)
2808            .current_dir(dir)
2809            .output()
2810            .unwrap();
2811        assert!(
2812            out.status.success(),
2813            "git {args:?}: {}",
2814            String::from_utf8_lossy(&out.stderr)
2815        );
2816    }
2817
2818    fn commit_file(dir: &std::path::Path, name: &str) {
2819        std::fs::write(dir.join(name), name).unwrap();
2820        sh_git(dir, &["add", name]);
2821        sh_git(dir, &["commit", "-m", name]);
2822    }
2823
2824    /**
2825     * The review rebases its branch on main and force-pushes with lease. When GitHub's
2826     * "Update branch" moved the remote branch after the last fetch, the lease is stale and
2827     * the push was rejected, which blocked approved work (#3783)
2828     **/
2829    #[tokio::test]
2830    async fn push_branch_recovers_from_stale_lease_after_remote_moved() {
2831        let temp = tempfile::TempDir::new().unwrap();
2832        let bare = temp.path().join("origin.git");
2833        let a = temp.path().join("a");
2834        let b = temp.path().join("b");
2835        std::fs::create_dir_all(&bare).unwrap();
2836        sh_git(&bare, &["init", "--bare", "-b", "main"]);
2837        sh_git(temp.path(), &["clone", bare.to_str().unwrap(), "a"]);
2838        // push_branch's own rebase commits, and CI runners have no global identity
2839        sh_git(&a, &["config", "user.name", "T"]);
2840        sh_git(&a, &["config", "user.email", "t@t"]);
2841        commit_file(&a, "base.txt");
2842        sh_git(&a, &["push", "origin", "main"]);
2843        sh_git(&a, &["checkout", "-b", "feat"]);
2844        commit_file(&a, "feature.txt");
2845        sh_git(&a, &["push", "-u", "origin", "feat"]);
2846
2847        sh_git(temp.path(), &["clone", bare.to_str().unwrap(), "b"]);
2848        commit_file(&b, "main-change.txt");
2849        sh_git(&b, &["push", "origin", "main"]);
2850        sh_git(&b, &["checkout", "feat"]);
2851        commit_file(&b, "update-branch.txt");
2852        sh_git(&b, &["push", "origin", "feat"]);
2853
2854        // A only fetches main, so its view of origin/feat is stale when it rewrites feat
2855        sh_git(&a, &["fetch", "origin", "main"]);
2856        sh_git(&a, &["rebase", "origin/main"]);
2857
2858        assert!(push_branch(&a, "feat", "main").await.unwrap());
2859
2860        sh_git(&b, &["fetch", "origin", "feat"]);
2861        sh_git(&b, &["checkout", "origin/feat"]);
2862        for name in [
2863            "base.txt",
2864            "feature.txt",
2865            "main-change.txt",
2866            "update-branch.txt",
2867        ] {
2868            assert!(b.join(name).exists(), "remote feat lost {name}");
2869        }
2870    }
2871
2872    #[test]
2873    fn stale_lease_error_matches_git_messages() {
2874        assert!(is_stale_lease_error(
2875            "! [rejected] feat -> feat (stale info)"
2876        ));
2877        assert!(is_stale_lease_error(
2878            "! [rejected] feat -> feat (remote ref updated since checkout)"
2879        ));
2880        assert!(!is_stale_lease_error(
2881            "! [rejected] feat -> feat (fetch first)"
2882        ));
2883    }
2884}