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