Skip to main content

orch/engine/runner/
git_ops.rs

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