Skip to main content

orch/engine/runner/
git_ops.rs

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