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