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
948async fn note_pushed_head(dir: &Path, branch: &str) {
949    if let Some(sha) = git_stdout(dir, &["rev-parse", "--verify", "HEAD"]).await {
950        write_push_lease(dir, branch, &sha).await;
951    }
952}
953
954/// True when `tip` is GitHub's "Update branch" of `lease`: a merge with first parent `lease` and
955/// second parent on the default branch, or a rebase whose commits all have a twin in `lease`
956async fn is_github_branch_update(dir: &Path, tip: &str, lease: &str, default_branch: &str) -> bool {
957    let base = format!("origin/{default_branch}");
958    let Some(line) = git_stdout(dir, &["rev-list", "--parents", "-n", "1", tip]).await else {
959        return false;
960    };
961    let parents: Vec<&str> = line.split_whitespace().skip(1).collect();
962    if let [first, second] = parents[..] {
963        return first == lease && is_ancestor(dir, second, &base).await;
964    }
965
966    // `git cherry` marks each commit in base..tip with `-` when `lease` holds the same patch
967    let Some(cherry) = git_stdout(dir, &["cherry", lease, tip, &base]).await else {
968        return false;
969    };
970    is_ancestor(dir, &base, tip).await
971        && !cherry.is_empty()
972        && cherry.lines().all(|l| l.starts_with('-'))
973}
974
975async fn expected_force_sha(
976    dir: &Path,
977    branch: &str,
978    default_branch: &str,
979) -> anyhow::Result<String> {
980    let tip = remote_tracking_sha(dir, branch).await;
981    match read_push_lease(dir, branch).await {
982        Some(lease) if lease == LEASE_ABSENT => anyhow::bail!(
983            "force push rejected: remote branch {branch} was absent when the lease was recorded"
984        ),
985        Some(lease) => match tip {
986            Some(tip)
987                if tip != lease
988                    && is_github_branch_update(dir, &tip, &lease, default_branch).await =>
989            {
990                tracing::info!(branch, %lease, %tip, "remote tip is GitHub's update of the leased branch, leasing it");
991                Ok(tip)
992            }
993            _ => Ok(lease),
994        },
995        None => {
996            let Some(tip) = tip else {
997                anyhow::bail!("force push rejected: no remote sha recorded for {branch}");
998            };
999            let fallbacks = LEASE_FALLBACKS.fetch_add(1, Ordering::Relaxed) + 1;
1000            tracing::warn!(
1001                branch,
1002                fallbacks,
1003                "no push lease recorded, using the fetched origin tip"
1004            );
1005            Ok(tip)
1006        }
1007    }
1008}
1009
1010/// Force-push `branch` only if the remote still holds the leased sha
1011pub(crate) async fn force_push_leased(
1012    dir: &Path,
1013    branch: &str,
1014    default_branch: &str,
1015) -> anyhow::Result<()> {
1016    let expected = expected_force_sha(dir, branch, default_branch).await?;
1017    let lease = format!("--force-with-lease={branch}:{expected}");
1018    tracing::info!(branch, expected_sha = %expected, "force-pushing with an expected remote sha");
1019    let output = git()
1020        .await
1021        .args([
1022            "-c",
1023            "lfs.locksverify=false",
1024            "push",
1025            &lease,
1026            "-u",
1027            "origin",
1028            branch,
1029        ])
1030        .current_dir(dir)
1031        .kill_on_drop(true)
1032        .output_with_context()
1033        .await?;
1034    if output.status.success() {
1035        note_pushed_head(dir, branch).await;
1036        return Ok(());
1037    }
1038
1039    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1040    anyhow::bail!("{}", sanitize_push_error(&stderr))
1041}
1042
1043/// Push the branch to origin.
1044pub async fn push_branch(dir: &Path, branch: &str, default_branch: &str) -> anyhow::Result<bool> {
1045    let current = get_current_branch(dir).await;
1046    let branch_to_push = if current.is_empty() { branch } else { &current };
1047
1048    // Guard against empty branch names (prevents git config corruption)
1049    if branch_to_push.is_empty() {
1050        anyhow::bail!("cannot push: branch name is empty");
1051    }
1052
1053    // Skip push for main/master
1054    if branch_to_push == "main" || branch_to_push == "master" {
1055        return Ok(false);
1056    }
1057
1058    // Check if there are commits to push and whether the remote branch exists
1059    let (remote_exists, has_unpushed) =
1060        check_push_needed(dir, branch_to_push, default_branch).await;
1061    if remote_exists && !has_unpushed {
1062        // Remote already has this branch and it's up to date — nothing to push.
1063        // Return true so callers know the branch IS on the remote and PR creation
1064        // is safe to attempt (e.g. a PR may already exist or can be created).
1065        tracing::debug!(
1066            branch = branch_to_push,
1067            "remote branch exists and is up to date, skipping push"
1068        );
1069        note_pushed_head(dir, branch_to_push).await;
1070        return Ok(true);
1071    }
1072    if !remote_exists && !has_unpushed {
1073        // Branch doesn't exist on the remote and has no commits ahead of the
1074        // default branch — push it anyway so the branch is created on the remote.
1075        // PR creation will then fail with "No commits between …" (422) which is
1076        // handled downstream, rather than with "head invalid" (422) which is
1077        // harder to distinguish from a real error.
1078        tracing::debug!(
1079            branch = branch_to_push,
1080            "remote branch missing with no new commits — pushing to create it on remote"
1081        );
1082    }
1083
1084    tracing::info!(branch = branch_to_push, "pushing branch");
1085
1086    // First attempt: normal push (no force).
1087    // lfs.locksverify=false skips the auxiliary LFS locks/verify roundtrip in
1088    // the pre-push hook; agents never hold LFS locks, and a transient timeout
1089    // on that endpoint must not fail the whole push.
1090    // A 2-minute timeout prevents an indefinitely-stalled push from blocking
1091    // post-processing and triggering a stuck-task re-dispatch race.
1092    let output = tokio::time::timeout(
1093        std::time::Duration::from_secs(120),
1094        git()
1095            .await
1096            .args([
1097                "-c",
1098                "lfs.locksverify=false",
1099                "push",
1100                "-u",
1101                "origin",
1102                branch_to_push,
1103            ])
1104            .kill_on_drop(true)
1105            .current_dir(dir)
1106            .output(),
1107    )
1108    .await
1109    .map_err(|_| anyhow::anyhow!("git push timed out after 120s"))??;
1110
1111    if output.status.success() {
1112        tracing::info!(branch = branch_to_push, "push succeeded");
1113        note_pushed_head(dir, branch_to_push).await;
1114        return Ok(true);
1115    }
1116
1117    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1118
1119    // Transient network blip (timeout, reset) rather than a ref rejection —
1120    // retry the identical push once before classifying the run as failed.
1121    if is_transient_network_error(&stderr) {
1122        tracing::warn!(
1123            branch = branch_to_push,
1124            stderr = %sanitize_push_error(&stderr),
1125            "push failed with transient network error — retrying once"
1126        );
1127        let retry_output = tokio::time::timeout(
1128            std::time::Duration::from_secs(120),
1129            git()
1130                .await
1131                .args([
1132                    "-c",
1133                    "lfs.locksverify=false",
1134                    "push",
1135                    "-u",
1136                    "origin",
1137                    branch_to_push,
1138                ])
1139                .kill_on_drop(true)
1140                .current_dir(dir)
1141                .output(),
1142        )
1143        .await
1144        .map_err(|_| anyhow::anyhow!("git push (network retry) timed out after 120s"))??;
1145
1146        if retry_output.status.success() {
1147            tracing::info!(branch = branch_to_push, "push succeeded on network retry");
1148            note_pushed_head(dir, branch_to_push).await;
1149            return Ok(true);
1150        }
1151
1152        let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
1153            .trim()
1154            .to_string();
1155        tracing::warn!(
1156            branch = branch_to_push,
1157            stderr = %sanitize_push_error(&retry_stderr),
1158            "push retry also failed"
1159        );
1160        anyhow::bail!("push failed: {}", sanitize_push_error(&retry_stderr));
1161    }
1162
1163    // Handle workflow scope error: the token lacks the `workflow` OAuth scope
1164    // required to push changes to `.github/workflows/`. Strip workflow files
1165    // from commits and retry the push.
1166    if is_workflow_scope_error(&stderr) {
1167        tracing::warn!(
1168            branch = branch_to_push,
1169            "push rejected: token lacks `workflow` scope — stripping workflow files and retrying"
1170        );
1171
1172        match strip_workflow_files(dir, default_branch).await {
1173            Ok(true) => {
1174                // Workflow files stripped, retry push
1175                let retry_output = tokio::time::timeout(
1176                    std::time::Duration::from_secs(120),
1177                    git()
1178                        .await
1179                        .args([
1180                            "-c",
1181                            "lfs.locksverify=false",
1182                            "push",
1183                            "-u",
1184                            "origin",
1185                            branch_to_push,
1186                        ])
1187                        .kill_on_drop(true)
1188                        .current_dir(dir)
1189                        .output(),
1190                )
1191                .await
1192                .map_err(|_| anyhow::anyhow!("git push (retry) timed out after 120s"))??;
1193
1194                if retry_output.status.success() {
1195                    tracing::info!(
1196                        branch = branch_to_push,
1197                        "push succeeded after stripping workflow files"
1198                    );
1199                    note_pushed_head(dir, branch_to_push).await;
1200                    return Ok(true);
1201                }
1202
1203                let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
1204                    .trim()
1205                    .to_string();
1206
1207                // If still failing with workflow scope, bail with clear message
1208                if is_workflow_scope_error(&retry_stderr) {
1209                    anyhow::bail!(
1210                        "push failed: token lacks `workflow` OAuth scope and workflow files \
1211                         could not be fully stripped — add `workflow` scope to your GitHub \
1212                         token or use a GitHub App for authentication"
1213                    );
1214                }
1215
1216                anyhow::bail!("push failed after stripping workflow files: {retry_stderr}");
1217            }
1218            Ok(false) => {
1219                // No workflow files found to strip (shouldn't happen if error was detected)
1220                anyhow::bail!(
1221                    "push failed: token lacks `workflow` OAuth scope — add `workflow` scope \
1222                     to your GitHub token or use a GitHub App for authentication. \
1223                     Error: {stderr}"
1224                );
1225            }
1226            Err(e) => {
1227                tracing::error!(error = %e, "failed to strip workflow files from commits");
1228                anyhow::bail!(
1229                    "push failed: token lacks `workflow` OAuth scope and automatic \
1230                     recovery failed ({e}) — add `workflow` scope to your GitHub token \
1231                     or use a GitHub App for authentication"
1232                );
1233            }
1234        }
1235    }
1236
1237    // Non-fast-forward after `rebase_on_default`. The lease was recorded before that fetch,
1238    // so this replaces only a tip this worktree already built on
1239    if push_needs_rebase(&stderr) && remote_exists {
1240        tracing::info!(
1241            branch = branch_to_push,
1242            "push rejected (non-fast-forward), force-pushing with lease"
1243        );
1244        let stderr = match force_push_leased(dir, branch_to_push, default_branch).await {
1245            Ok(()) => {
1246                tracing::info!(branch = branch_to_push, "force push succeeded");
1247                return Ok(true);
1248            }
1249            Err(e) => e.to_string(),
1250        };
1251
1252        // Check if force push also hit the workflow scope error
1253        if is_workflow_scope_error(&stderr) {
1254            tracing::warn!(
1255                branch = branch_to_push,
1256                "force push also rejected due to workflow scope — stripping and retrying"
1257            );
1258            match strip_workflow_files(dir, default_branch).await {
1259                Ok(true) => {
1260                    if let Err(e) = force_push_leased(dir, branch_to_push, default_branch).await {
1261                        anyhow::bail!("force push failed after stripping workflow files: {e}");
1262                    }
1263
1264                    tracing::info!(
1265                        branch = branch_to_push,
1266                        "force push succeeded after stripping workflow files"
1267                    );
1268                    return Ok(true);
1269                }
1270                Ok(false) => {
1271                    anyhow::bail!(
1272                        "force push failed: token lacks `workflow` OAuth scope — \
1273                         add `workflow` scope to your GitHub token or use a GitHub App. \
1274                         Error: {stderr}"
1275                    );
1276                }
1277                Err(e) => {
1278                    anyhow::bail!(
1279                        "force push failed: token lacks `workflow` scope and recovery \
1280                         failed ({e})"
1281                    );
1282                }
1283            }
1284        }
1285
1286        // The remote branch moved since the last fetch (GitHub's "Update branch", another
1287        // clone), so the lease no longer matches. Replay local commits on the new tip once
1288        if is_stale_lease_error(&stderr) && rebase_on_branch(dir, branch_to_push).await? {
1289            let output = git()
1290                .await
1291                .args([
1292                    "-c",
1293                    "lfs.locksverify=false",
1294                    "push",
1295                    "-u",
1296                    "origin",
1297                    branch_to_push,
1298                ])
1299                .current_dir(dir)
1300                .output_with_context()
1301                .await?;
1302            if output.status.success() {
1303                tracing::info!(
1304                    branch = branch_to_push,
1305                    "push succeeded after rebasing on the moved remote branch"
1306                );
1307                note_pushed_head(dir, branch_to_push).await;
1308                return Ok(true);
1309            }
1310
1311            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1312            anyhow::bail!("push failed after rebasing on the moved remote branch: {stderr}");
1313        }
1314
1315        if is_stale_lease_error(&stderr) {
1316            tracing::info!(
1317                branch = branch_to_push,
1318                "remote branch already holds every local commit, nothing to push"
1319            );
1320            return Ok(true);
1321        }
1322
1323        anyhow::bail!("force push failed: {stderr}");
1324    }
1325
1326    anyhow::bail!("push failed: {stderr}")
1327}
1328
1329fn push_needs_rebase(stderr: &str) -> bool {
1330    let lower = stderr.to_ascii_lowercase();
1331    lower.contains("non-fast-forward")
1332        || lower.contains("rejected")
1333        || lower.contains("fetch first")
1334        || lower.contains("behind")
1335}
1336
1337/// True when `--force-with-lease` refused because the remote ref is not the one last fetched
1338fn is_stale_lease_error(stderr: &str) -> bool {
1339    let lower = stderr.to_ascii_lowercase();
1340    lower.contains("stale info") || lower.contains("remote ref updated since checkout")
1341}
1342
1343/// True when a push failure looks like a transient network error (dial
1344/// timeout, i/o timeout, connection reset) rather than a ref rejection.
1345/// A refused ref must not be retried, but a blipped endpoint deserves one
1346/// immediate retry before the run is classified as `push_failed`.
1347fn is_transient_network_error(stderr: &str) -> bool {
1348    let lower = stderr.to_ascii_lowercase();
1349    lower.contains("i/o timeout")
1350        || lower.contains("dial tcp")
1351        || lower.contains("connection reset")
1352        || lower.contains("connection refused")
1353        || lower.contains("no route to host")
1354        || lower.contains("temporary failure in name resolution")
1355        || lower.contains("eof")
1356}
1357
1358/// Strip credentials from push stderr before it is logged or returned:
1359/// URL userinfo (`https://x-access-token:TOKEN@…`) plus any secrets caught
1360/// by the leak patterns, so a failing push can never write a token to logs.
1361fn sanitize_push_error(stderr: &str) -> String {
1362    static URL_USERINFO: LazyLock<Regex> =
1363        LazyLock::new(|| Regex::new(r"://[^/@\s]+@").expect("valid url userinfo regex"));
1364    let no_userinfo = URL_USERINFO.replace_all(stderr, "://***@");
1365    crate::security::redact(&no_userinfo)
1366}
1367
1368/// Drop sentences and list items about pushing or opening the PR
1369/// Agents report they could not push, orch already pushed, so those lines read as false on GitHub
1370fn strip_push_and_pr_creation(text: &str) -> String {
1371    let mut kept_lines: Vec<String> = Vec::new();
1372    for line in text.lines() {
1373        if line.trim().is_empty() {
1374            if kept_lines.last().is_some_and(|l| !l.is_empty()) {
1375                kept_lines.push(String::new());
1376            }
1377            continue;
1378        }
1379
1380        let indent_len = line.len() - line.trim_start().len();
1381        let indent = &line[..indent_len];
1382        let trimmed = line.trim();
1383        let (marker, rest) = match list_marker(trimmed) {
1384            Some((m, r)) => (m, r),
1385            None => ("", trimmed),
1386        };
1387
1388        let kept: Vec<&str> = split_statements(rest)
1389            .into_iter()
1390            .filter(|s| !is_push_or_pr_creation_statement(s))
1391            .collect();
1392        if kept.is_empty() {
1393            continue;
1394        }
1395        kept_lines.push(format!("{indent}{marker}{}", kept.join(" ")));
1396    }
1397    while kept_lines.last().is_some_and(String::is_empty) {
1398        kept_lines.pop();
1399    }
1400    kept_lines.join("\n")
1401}
1402
1403fn split_statements(text: &str) -> Vec<&str> {
1404    static SEP: LazyLock<Regex> =
1405        LazyLock::new(|| Regex::new(r"(\.{2,}|(?:[.!?]))\s+").expect("valid statement separator"));
1406    let mut statements = Vec::new();
1407    let mut last = 0;
1408    for cap in SEP.captures_iter(text) {
1409        let full = cap.get(0).expect("full separator match");
1410        let punct = cap.get(1).expect("punctuation capture");
1411        let ellipsis = punct.as_str().len() >= 2 && punct.as_str().bytes().all(|b| b == b'.');
1412        let end = if ellipsis { punct.start() } else { punct.end() };
1413        let stmt = text[last..end].trim();
1414        if !stmt.is_empty() {
1415            statements.push(stmt);
1416        }
1417        last = full.end();
1418    }
1419    let rest = text[last..].trim();
1420    if !rest.is_empty() {
1421        statements.push(rest);
1422    }
1423    statements
1424}
1425
1426fn list_marker(line: &str) -> Option<(&str, &str)> {
1427    for prefix in ["- ", "* ", "+ "] {
1428        if let Some(rest) = line.strip_prefix(prefix) {
1429            return Some((prefix, rest));
1430        }
1431    }
1432    let dot = line.find(". ")?;
1433    if !line[..dot].is_empty() && line[..dot].bytes().all(|b| b.is_ascii_digit()) {
1434        Some((&line[..dot + 2], &line[dot + 2..]))
1435    } else {
1436        None
1437    }
1438}
1439
1440fn is_push_or_pr_creation_statement(text: &str) -> bool {
1441    static PAT: LazyLock<Regex> = LazyLock::new(|| {
1442        Regex::new(concat!(
1443            r"(?i)",
1444            r"\bgit\s+push\b",
1445            r"|\bgh\s+pr\s+create\b",
1446            r"|\bgh\s+pr\s+open\b",
1447            r"|\bpush\s+(was\s+)?(denied|blocked|rejected)\b",
1448            r"|\bpush\s+permission\b",
1449            r"|\bcouldn['’]?t\s+push\b",
1450            r"|\bcould\s+not\s+push\b",
1451            r"|\bcan['’]?t\s+push\b",
1452            r"|\bcannot\s+push\b",
1453            r"|\bdidn['’]?t\s+push\b",
1454            r"|\bdid\s+not\s+push\b",
1455            r"|\bnothing\s+is\s+on\s+the\s+remote\b",
1456            r"|\bnot\s+on\s+the\s+remote\b",
1457            r"|\bopen(?:ing)?\s+(?:the|a)\s+pr\b",
1458            r"|\bcreat(?:e|ing)\s+(?:the|a)\s+pr\b",
1459            r"|\bopen(?:ing)?\s+(?:the|a)\s+pull\s+request\b",
1460            r"|\bcreat(?:e|ing)\s+(?:the|a)\s+pull\s+request\b",
1461        ))
1462        .expect("valid push/pr-creation regex")
1463    });
1464    PAT.is_match(text)
1465}
1466
1467fn cleaned_pr_items(items: &[String]) -> Vec<String> {
1468    items
1469        .iter()
1470        .filter_map(|item| {
1471            let cleaned = strip_push_and_pr_creation(item);
1472            (!cleaned.is_empty()).then_some(cleaned)
1473        })
1474        .collect()
1475}
1476
1477/// Format the GitHub PR description from the agent summary, stripping push and PR-creation statements
1478#[expect(
1479    clippy::too_many_arguments,
1480    reason = "each argument is one section of the PR body"
1481)]
1482fn build_pr_body(
1483    title: &str,
1484    summary: &str,
1485    accomplished: &[String],
1486    remaining: &[String],
1487    files: &[String],
1488    task_id: &str,
1489    agent: &str,
1490    model: Option<&str>,
1491) -> String {
1492    use std::fmt::Write as _;
1493    let cleaned_summary = strip_push_and_pr_creation(summary);
1494    let summary_text = if cleaned_summary.is_empty() {
1495        title
1496    } else {
1497        cleaned_summary.as_str()
1498    };
1499    let accomplished = cleaned_pr_items(accomplished);
1500    let remaining = cleaned_pr_items(remaining);
1501
1502    let mut body = format!("## Summary\n\n{summary_text}");
1503
1504    if !accomplished.is_empty() {
1505        body.push_str("\n\n### What was done\n\n");
1506        for item in &accomplished {
1507            let _ = writeln!(body, "- {item}");
1508        }
1509    }
1510
1511    if !remaining.is_empty() {
1512        body.push_str("\n\n### Remaining\n\n");
1513        for item in &remaining {
1514            let _ = writeln!(body, "- {item}");
1515        }
1516    }
1517
1518    if !files.is_empty() {
1519        body.push_str("\n\n### Files changed\n\n");
1520        for file in files {
1521            let _ = writeln!(body, "- `{file}`");
1522        }
1523    }
1524
1525    if !task_id.starts_with("internal:")
1526        && let Ok(issue_num) = task_id.parse::<u64>()
1527    {
1528        let _ = write!(body, "\n\nCloses #{issue_num}");
1529    }
1530
1531    let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1532    let task_ref = format_task_ref(task_id);
1533    let _ = write!(
1534        body,
1535        "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1536    );
1537    body
1538}
1539
1540/// Create a PR if one doesn't already exist.
1541///
1542/// Uses [`GhHttp`] exclusively for GitHub API operations. No `gh` CLI
1543/// fallback is used, ensuring consistent behavior across environments.
1544///
1545/// # Returns
1546/// - `Ok(url)` - PR URL (newly created or already existed)
1547/// - `Err(PrCreateError)` - API error or other failure
1548#[expect(
1549    clippy::too_many_arguments,
1550    reason = "the PR needs its branch, body sections, attribution and target together"
1551)]
1552pub async fn create_pr_if_needed(
1553    dir: &Path,
1554    branch: &str,
1555    title: &str,
1556    summary: &str,
1557    accomplished: &[String],
1558    remaining: &[String],
1559    files: &[String],
1560    task_id: &str,
1561    agent: &str,
1562    model: Option<&str>,
1563    repo: &str,
1564    base_branch: &str,
1565) -> PrCreateResult<String> {
1566    // Retries for transient 5xx errors on PR creation below
1567    const MAX_RETRIES: u32 = 3;
1568    let gh = GhHttp::new()?;
1569
1570    // Check if PR already exists using GhHttp API
1571    match gh.get_pr_number(repo, branch).await {
1572        Ok(Some(pr_number)) => {
1573            tracing::info!(task_id, pr = pr_number, "PR already exists");
1574            // Append attribution footer if the agent created the PR without one.
1575            append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model).await;
1576            // Fetch the existing PR to get its URL so the caller can store pr_number.
1577            let pr = match gh.get_pr(repo, pr_number).await {
1578                Ok(pr) => pr,
1579                Err(e) => {
1580                    tracing::warn!(task_id, error = %e, "get_pr API call failed after detecting existing PR");
1581                    return Err(PrCreateError::ApiError(e));
1582                }
1583            };
1584            return Ok(pr.html_url);
1585        }
1586        Ok(None) => {
1587            // No PR exists, proceed to create one
1588        }
1589        Err(e) => {
1590            tracing::warn!(task_id, error = %e, "get_pr_number API call failed");
1591            return Err(PrCreateError::ApiError(e));
1592        }
1593    }
1594
1595    // When the agent didn't self-report files, derive them from git.
1596    let git_files: Vec<String>;
1597    let effective_files: &[String] = if files.is_empty() {
1598        match tokio::process::Command::new("git")
1599            .args([
1600                "diff",
1601                &format!("origin/{base_branch}...HEAD"),
1602                "--name-only",
1603            ])
1604            .current_dir(dir)
1605            .output()
1606            .await
1607        {
1608            Ok(output) if output.status.success() => {
1609                git_files = String::from_utf8_lossy(&output.stdout)
1610                    .lines()
1611                    .filter(|l| !l.is_empty())
1612                    .map(std::string::ToString::to_string)
1613                    .collect();
1614                &git_files
1615            }
1616            _ => files,
1617        }
1618    } else {
1619        files
1620    };
1621
1622    let body = build_pr_body(
1623        title,
1624        summary,
1625        accomplished,
1626        remaining,
1627        effective_files,
1628        task_id,
1629        agent,
1630        model,
1631    );
1632
1633    // Always use the short task title for the PR title (summary goes in body)
1634    let pr_title = title;
1635
1636    // Create PR using GhHttp API with exponential backoff retry for transient 5xx errors.
1637    let mut last_error: Option<anyhow::Error> = None;
1638    let mut created_url: Option<String> = None;
1639    let mut had_transient_error = false;
1640    let mut effective_base = base_branch.to_string();
1641    let mut tried_api_fallback = false;
1642
1643    for attempt in 1..=MAX_RETRIES {
1644        match gh
1645            .create_pr(repo, pr_title, &body, branch, &effective_base)
1646            .await
1647        {
1648            Ok(u) => {
1649                created_url = Some(u);
1650                break;
1651            }
1652            Err(e) => {
1653                let err_str = format!("{e}");
1654                if is_transient_github_error(&err_str) {
1655                    had_transient_error = true;
1656                    if attempt < MAX_RETRIES {
1657                        let delay = std::time::Duration::from_secs(2u64.pow(attempt));
1658                        tracing::warn!(
1659                            task_id,
1660                            attempt,
1661                            delay_secs = delay.as_secs(),
1662                            error = %e,
1663                            "transient GitHub API error during PR creation — retrying"
1664                        );
1665                        tokio::time::sleep(delay).await;
1666                    }
1667                } else if is_invalid_base_error(&err_str) && !tried_api_fallback {
1668                    // Local base branch detection was wrong (e.g. "remotes/origin/main").
1669                    // Query GitHub API for the canonical default branch and retry once.
1670                    tried_api_fallback = true;
1671                    tracing::warn!(
1672                        task_id,
1673                        base_branch = %effective_base,
1674                        error = %e,
1675                        "PR creation failed with invalid base — fetching default branch from GitHub API"
1676                    );
1677                    match gh.get_default_branch(repo).await {
1678                        Ok(api_default) if api_default != effective_base => {
1679                            tracing::info!(
1680                                task_id,
1681                                old_base = %effective_base,
1682                                new_base = %api_default,
1683                                "retrying PR creation with GitHub API default branch"
1684                            );
1685                            effective_base = api_default;
1686                            continue;
1687                        }
1688                        Ok(api_default) => {
1689                            tracing::warn!(
1690                                task_id,
1691                                base_branch = %api_default,
1692                                "GitHub API default branch matches local — no fallback possible"
1693                            );
1694                        }
1695                        Err(api_err) => {
1696                            tracing::warn!(
1697                                task_id,
1698                                error = %api_err,
1699                                "failed to fetch default branch from GitHub API"
1700                            );
1701                        }
1702                    }
1703                }
1704                last_error = Some(e);
1705            }
1706        }
1707    }
1708
1709    let url = if let Some(u) = created_url {
1710        u
1711    } else {
1712        let e = last_error.unwrap_or_else(|| {
1713            anyhow::anyhow!("PR creation failed after retries (no error captured)")
1714        });
1715        let err_str = format!("{e}");
1716        // For transient 5xx errors, GitHub may have created the PR despite returning
1717        // an error (e.g. 502 after the write succeeded). Re-check for an existing PR
1718        // before propagating the failure so we don't orphan the PR from the task.
1719        // Also check when the final error is non-transient but a prior attempt had a
1720        // transient error — the PR may have been created on that earlier attempt (e.g.
1721        // attempt 1 returns 502, attempt 2 returns 422 "already exists").
1722        if is_transient_github_error(&err_str) || had_transient_error {
1723            tracing::warn!(
1724                task_id,
1725                error = %e,
1726                "transient GitHub API error after all retries — checking if PR was actually created"
1727            );
1728            match gh.get_pr_number(repo, branch).await {
1729                Ok(Some(pr_number)) => {
1730                    tracing::info!(
1731                        task_id,
1732                        pr = pr_number,
1733                        "PR was created despite transient errors — recovering"
1734                    );
1735                    append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model).await;
1736                    match gh.get_pr(repo, pr_number).await {
1737                        Ok(pr) => pr.html_url,
1738                        Err(get_err) => {
1739                            tracing::warn!(
1740                                task_id,
1741                                error = %get_err,
1742                                "failed to fetch PR URL after transient-error recovery"
1743                            );
1744                            return Err(PrCreateError::ApiError(e));
1745                        }
1746                    }
1747                }
1748                Ok(None) => {
1749                    tracing::warn!(
1750                        task_id,
1751                        "PR was not created after all retries — propagating original error"
1752                    );
1753                    return Err(PrCreateError::ApiError(e));
1754                }
1755                Err(check_err) => {
1756                    tracing::warn!(
1757                        task_id,
1758                        error = %check_err,
1759                        "failed to verify PR existence after transient error retries — propagating original error"
1760                    );
1761                    return Err(PrCreateError::ApiError(e));
1762                }
1763            }
1764        } else {
1765            return Err(PrCreateError::ApiError(e));
1766        }
1767    };
1768
1769    tracing::info!(task_id, pr_url = %url, "created PR via GhHttp API");
1770
1771    Ok(url)
1772}
1773
1774/// Returns true if the error string indicates a transient GitHub API failure
1775/// (HTTP 5xx, network errors, DNS failures, TLS issues, rate limits), where
1776/// the operation may succeed on retry.
1777pub(crate) fn is_transient_github_error(err_str: &str) -> bool {
1778    // Treat transport/send failures and explicit GhHttp server-error messages
1779    // as transient. Older callers relied on parsing the "failed (NNN)" pattern
1780    // to detect 5xx — keep that, but also recognize the newer wrappers.
1781    if err_str.contains("HTTP send failed") {
1782        return true;
1783    }
1784    if err_str.contains("GitHub API server error") {
1785        return true;
1786    }
1787
1788    // Check for HTTP 5xx status codes
1789    if extract_github_http_status(err_str).is_some_and(|s| (500..600).contains(&s)) {
1790        return true;
1791    }
1792
1793    // Check for common network-level transient errors
1794    let lower = err_str.to_lowercase();
1795
1796    // Connection and transport errors
1797    if lower.contains("connection reset")
1798        || lower.contains("broken pipe")
1799        || lower.contains("connection refused")
1800        || lower.contains("connection closed")
1801        || lower.contains("transport error")
1802        || lower.contains("failed to connect")
1803        || lower.contains("failed to connect to github.com port")
1804        || lower.contains("curl: (7) failed to connect")
1805        || lower.contains("couldn't connect to server")
1806        || lower.contains("could not connect to server")
1807    {
1808        return true;
1809    }
1810
1811    // Timeout errors
1812    if lower.contains("timeout") {
1813        return true;
1814    }
1815
1816    // DNS resolution errors
1817    if lower.contains("dns error")
1818        || lower.contains("resolve")
1819        || lower.contains("name resolution")
1820        || lower.contains("no such host")
1821    {
1822        return true;
1823    }
1824
1825    // TLS/SSL errors
1826    if lower.contains("tls handshake")
1827        || lower.contains("certificate")
1828        || lower.contains("ssl error")
1829    {
1830        return true;
1831    }
1832
1833    // Network unreachable errors
1834    if lower.contains("network is unreachable")
1835        || lower.contains("host unreachable")
1836        || lower.contains("temporary failure")
1837    {
1838        return true;
1839    }
1840
1841    // EOF errors (connection dropped mid-request)
1842    if lower.contains("unexpected eof") || lower.contains("unexpected end of file") {
1843        return true;
1844    }
1845
1846    // Internal circuit-breaker state (orch's own GitHub 5xx protection)
1847    if lower.contains("circuit-breaker") || lower.contains("circuit breaker") {
1848        return true;
1849    }
1850
1851    // Rate limit errors (transient with cooldown/retry)
1852    if lower.contains("rate limit") || lower.contains("too many requests") {
1853        return true;
1854    }
1855
1856    false
1857}
1858
1859/// Returns true when the error indicates the PR base branch is invalid.
1860///
1861/// GitHub returns HTTP 422 with `PullRequest.base invalid` when the base
1862/// branch name is not a valid ref in the repository. This is a terminal
1863/// configuration error — retrying with the same base will never succeed.
1864pub(crate) fn is_invalid_base_error(err_str: &str) -> bool {
1865    let lower = err_str.to_lowercase();
1866    lower.contains("422") && lower.contains("base") && lower.contains("invalid")
1867}
1868
1869/// Returns true when an error looks like a transient *GitHub API/CLI* failure.
1870///
1871/// This is stricter than [`is_transient_github_error`]: in addition to matching
1872/// a transient transport pattern, it requires GitHub-specific context in the
1873/// message. This prevents generic agent/runtime transport failures (for
1874/// example "broken pipe" from a model stream disconnect) from being treated as
1875/// GitHub retry signals in review reroute logic.
1876pub(crate) fn is_transient_github_api_error(err_str: &str) -> bool {
1877    if !is_transient_github_error(err_str) {
1878        return false;
1879    }
1880
1881    if extract_github_http_status(err_str).is_some() {
1882        return true;
1883    }
1884
1885    let lower = err_str.to_lowercase();
1886    lower.contains("github")
1887        || lower.contains("api.github.com")
1888        || lower.contains("gh api")
1889        || lower.contains("gh pr")
1890        || lower.contains("gh error")
1891        // `circuit-breaker` is orch's internal GitHub 5xx protection, set
1892        // exclusively by GhHttp.  It can never originate from agent/runtime
1893        // failures, so treating it as a GitHub-context marker is safe even
1894        // when upstream wrapping strips the "GitHub API" prefix.
1895        || lower.contains("circuit-breaker")
1896        || lower.contains("circuit breaker")
1897}
1898
1899/// Extract the HTTP status code from a GhHttp error string.
1900///
1901/// GhHttp formats errors as: `"GitHub API POST https://... failed (NNN): body"`
1902/// This parses the numeric status code from that format.
1903fn extract_github_http_status(err_str: &str) -> Option<u16> {
1904    let marker = "failed (";
1905    let start = err_str.find(marker)? + marker.len();
1906    let rest = &err_str[start..];
1907    let end = rest.find(')')?;
1908    rest[..end].parse().ok()
1909}
1910
1911/// Append the Orch attribution footer to an existing PR body if not already present.
1912///
1913/// Called when the agent created the PR directly (bypassing orch's
1914/// `create_pr_if_needed`), so the footer was never added by `build_pr_body`.
1915async fn append_pr_footer_if_missing(
1916    gh: &GhHttp,
1917    repo: &str,
1918    pr_number: u64,
1919    task_id: &str,
1920    agent: &str,
1921    model: Option<&str>,
1922) {
1923    let pr = match gh.get_pr(repo, pr_number).await {
1924        Ok(pr) => pr,
1925        Err(e) => {
1926            tracing::debug!(task_id, error = %e, "failed to fetch PR for footer check");
1927            return;
1928        }
1929    };
1930
1931    let body = pr.body.unwrap_or_default();
1932    if body.contains("via [Orch]") {
1933        return; // Footer already present
1934    }
1935
1936    let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
1937    let task_ref = format_task_ref(task_id);
1938    let footer = format!(
1939        "\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
1940    );
1941    let new_body = format!("{body}{footer}");
1942
1943    match gh.update_pr_body(repo, pr_number, &new_body).await {
1944        Err(e) => {
1945            tracing::warn!(task_id, error = %e, "failed to append footer to PR (non-fatal)");
1946        }
1947        _ => {
1948            tracing::info!(
1949                task_id,
1950                pr = pr_number,
1951                "appended attribution footer to agent-created PR"
1952            );
1953        }
1954    }
1955}
1956
1957/// Get the current branch name.
1958pub(crate) async fn get_current_branch(dir: &Path) -> String {
1959    let output = Command::new("git")
1960        .args(["branch", "--show-current"])
1961        .current_dir(dir)
1962        .output_with_context()
1963        .await;
1964
1965    match output {
1966        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
1967        _ => String::new(),
1968    }
1969}
1970
1971/// Count the number of changed files in the working directory.
1972pub async fn count_changed_files(dir: &Path) -> anyhow::Result<usize> {
1973    // Count modified and new files (excluding deleted)
1974    let output = Command::new("git")
1975        .args(["status", "--porcelain"])
1976        .current_dir(dir)
1977        .output_with_context()
1978        .await?;
1979
1980    let status_output = String::from_utf8_lossy(&output.stdout);
1981    let count = status_output
1982        .lines()
1983        .filter(|line| {
1984            let prefix = line.get(0..2).unwrap_or("");
1985            // Count modified (M), added (A), renamed (R), copied (C), untracked (??)
1986            prefix.starts_with('M')
1987                || prefix.starts_with('A')
1988                || prefix.starts_with('R')
1989                || prefix.starts_with('C')
1990                || prefix.starts_with('?')
1991        })
1992        .count();
1993
1994    Ok(count)
1995}
1996
1997/// Check if there are unpushed commits.
1998/// Returns `(remote_exists, has_unpushed_commits)`.
1999///
2000/// - `remote_exists` — whether `origin/<branch>` is known to the local repo.
2001/// - `has_unpushed_commits` — whether there are local commits not yet on the
2002///   remote.  When the remote branch doesn't exist the comparison is against
2003///   `origin/<default_branch>` so we can still detect new work.
2004async fn check_push_needed(dir: &Path, branch: &str, default_branch: &str) -> (bool, bool) {
2005    // Check if remote tracking branch exists
2006    let remote_exists = Command::new("git")
2007        .args(["rev-parse", &format!("origin/{branch}")])
2008        .current_dir(dir)
2009        .output_with_context()
2010        .await
2011        .is_ok_and(|o| o.status.success());
2012
2013    let compare_ref = if remote_exists {
2014        format!("origin/{branch}..HEAD")
2015    } else {
2016        // Compare against default branch (passed in, NOT detected from
2017        // current HEAD — in a worktree HEAD is the feature branch itself)
2018        format!("origin/{default_branch}..HEAD")
2019    };
2020
2021    tracing::debug!(
2022        branch,
2023        compare_ref,
2024        remote_exists,
2025        "checking unpushed commits"
2026    );
2027
2028    let output = Command::new("git")
2029        .args(["log", &compare_ref, "--oneline"])
2030        .current_dir(dir)
2031        .output_with_context()
2032        .await;
2033
2034    let has_unpushed = match output {
2035        Ok(o) if o.status.success() => {
2036            let out = String::from_utf8_lossy(&o.stdout);
2037            let has = !out.trim().is_empty();
2038            tracing::debug!(branch, has_unpushed = has, "unpushed commits check result");
2039            has
2040        }
2041        _ => {
2042            tracing::debug!(branch, "git log failed for compare ref, assuming unpushed");
2043            // If we can't determine, assume there ARE commits to push
2044            // (better to attempt push and let it fail than silently skip)
2045            true
2046        }
2047    };
2048
2049    (remote_exists, has_unpushed)
2050}
2051
2052#[cfg(test)]
2053mod tests {
2054    use super::*;
2055
2056    #[test]
2057    fn pr_create_error_display() {
2058        let err = PrCreateError::ApiError(anyhow::anyhow!("test error"));
2059        assert_eq!(format!("{err}"), "GitHub API error: test error");
2060    }
2061
2062    #[test]
2063    fn pr_create_error_from_anyhow() {
2064        let anyhow_err = anyhow::anyhow!("source error");
2065        let pr_err: PrCreateError = anyhow_err.into();
2066        assert!(matches!(pr_err, PrCreateError::ApiError(_)));
2067    }
2068
2069    #[test]
2070    fn git_env_never_signs_with_or_without_a_token() {
2071        for env in [
2072            build_git_auth_env_for_token(None),
2073            build_git_auth_env_for_token(Some("ghp_testtoken1234".into())),
2074        ] {
2075            let get = |key: &str| env.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone());
2076            let count: usize = get("GIT_CONFIG_COUNT").unwrap().parse().unwrap();
2077            let config: Vec<(String, String)> = (0..count)
2078                .map(|n| {
2079                    (
2080                        get(&format!("GIT_CONFIG_KEY_{n}")).unwrap(),
2081                        get(&format!("GIT_CONFIG_VALUE_{n}")).unwrap(),
2082                    )
2083                })
2084                .collect();
2085            assert!(config.contains(&("commit.gpgsign".into(), "false".into())));
2086            assert!(config.contains(&("tag.gpgsign".into(), "false".into())));
2087        }
2088    }
2089
2090    #[test]
2091    fn build_git_auth_env_without_token_falls_back_to_ssh_https_conversion() {
2092        // Pass None directly to test the no-token path without relying on env vars
2093        // or the gh CLI, keeping the test deterministic regardless of local auth state.
2094        let env = build_git_auth_env_for_token(None);
2095
2096        // When no token is available the fallback must include the SSH insteadOf rule.
2097        // The SSH→HTTPS conversion sets the target URL (the rewrite destination) to git@github.com:.
2098        let has_ssh_rule = env
2099            .iter()
2100            .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "git@github.com:");
2101        assert!(
2102            has_ssh_rule,
2103            "expected SSH insteadOf fallback (value is git@github.com:), got: {env:?}"
2104        );
2105    }
2106
2107    #[test]
2108    fn build_git_auth_env_with_token_covers_both_ssh_and_https() {
2109        // Pass a pre-built token directly to test the token path without touching env vars.
2110        let env = build_git_auth_env_for_token(Some("ghp_testtoken1234".into()));
2111
2112        // Must contain the token in the auth URL (the token is in the value of GIT_CONFIG_KEY_N).
2113        let has_token = env
2114            .iter()
2115            .any(|(_, v)| v.contains("x-access-token:ghp_testtoken1234@github.com"));
2116        assert!(has_token, "expected token in auth URL, got: {env:?}");
2117        // Must cover HTTPS origins (GIT_CONFIG_VALUE_0 is the HTTPS source URL).
2118        let has_https_rule = env
2119            .iter()
2120            .any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "https://github.com/");
2121        assert!(
2122            has_https_rule,
2123            "expected HTTPS insteadOf rule, got: {env:?}"
2124        );
2125        // Must cover SSH origins (GIT_CONFIG_VALUE_1 is the SSH source URL).
2126        let has_ssh_rule = env
2127            .iter()
2128            .any(|(k, v)| k == "GIT_CONFIG_VALUE_1" && v == "git@github.com:");
2129        assert!(has_ssh_rule, "expected SSH insteadOf rule, got: {env:?}");
2130    }
2131
2132    #[test]
2133    fn find_stash_ref_by_hash_returns_matching_reference() {
2134        let stash_list = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{1}\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb stash@{0}";
2135
2136        assert_eq!(
2137            find_stash_ref_by_hash(stash_list, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
2138            Some("stash@{1}".to_string())
2139        );
2140        assert_eq!(
2141            find_stash_ref_by_hash(stash_list, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
2142            Some("stash@{0}".to_string())
2143        );
2144    }
2145
2146    #[test]
2147    fn find_stash_ref_by_hash_returns_none_when_missing_or_malformed() {
2148        let stash_list = "malformed-line\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{2}";
2149
2150        assert_eq!(
2151            find_stash_ref_by_hash(stash_list, "cccccccccccccccccccccccccccccccccccccccc"),
2152            None
2153        );
2154        assert_eq!(find_stash_ref_by_hash("", "anything"), None);
2155    }
2156
2157    #[test]
2158    fn push_needs_rebase_detects_common_rejections() {
2159        assert!(push_needs_rebase(
2160            "! [rejected] branch -> branch (fetch first)"
2161        ));
2162        assert!(push_needs_rebase("non-fast-forward update was rejected"));
2163        assert!(push_needs_rebase(
2164            "push rejected because the remote contains work"
2165        ));
2166    }
2167
2168    #[test]
2169    fn is_transient_network_error_detects_lfs_locks_timeout() {
2170        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";
2171        assert!(is_transient_network_error(lfs_err));
2172        assert!(is_transient_network_error(
2173            "dial tcp 140.82.112.4:443: connect: connection refused"
2174        ));
2175        assert!(is_transient_network_error(
2176            "read tcp 10.0.0.1:51234->140.82.112.4:443: read: connection reset by peer"
2177        ));
2178    }
2179
2180    #[test]
2181    fn is_transient_network_error_rejects_ref_rejections() {
2182        assert!(!is_transient_network_error(
2183            "! [rejected] branch -> branch (fetch first)"
2184        ));
2185        assert!(!is_transient_network_error(
2186            "remote: error: GH006: Protected branch update failed"
2187        ));
2188    }
2189
2190    #[test]
2191    fn sanitize_push_error_strips_url_credentials() {
2192        // Shape must not look like a real GitHub PAT so gitleaks stays clean
2193        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";
2194        let sanitized = sanitize_push_error(raw);
2195        assert!(!sanitized.contains("test-secret-1234567890abcdef"));
2196        assert!(sanitized.contains("://***@github.com"));
2197        assert!(sanitized.contains("i/o timeout"));
2198    }
2199
2200    #[test]
2201    fn extract_github_http_status_parses_ghhttp_error_format() {
2202        let err =
2203            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
2204        assert_eq!(extract_github_http_status(err), Some(502));
2205
2206        let err500 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (500): Internal Server Error";
2207        assert_eq!(extract_github_http_status(err500), Some(500));
2208
2209        let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
2210        assert_eq!(extract_github_http_status(err422), Some(422));
2211
2212        let not_github = "some other error";
2213        assert_eq!(extract_github_http_status(not_github), None);
2214    }
2215
2216    #[test]
2217    fn is_transient_github_error_matches_5xx_only() {
2218        let err502 =
2219            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
2220        assert!(is_transient_github_error(err502));
2221
2222        let err503 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (503): Service Unavailable";
2223        assert!(is_transient_github_error(err503));
2224
2225        let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
2226        assert!(!is_transient_github_error(err422));
2227
2228        let err404 =
2229            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (404): Not Found";
2230        assert!(!is_transient_github_error(err404));
2231    }
2232
2233    #[test]
2234    fn is_transient_github_error_matches_connection_errors() {
2235        // Connection-related transient errors
2236        assert!(is_transient_github_error("connection refused"));
2237        assert!(is_transient_github_error("Connection reset by peer"));
2238        assert!(is_transient_github_error("broken pipe"));
2239        assert!(is_transient_github_error("connection closed unexpectedly"));
2240        assert!(is_transient_github_error(
2241            "transport error: connection lost"
2242        ));
2243
2244        // Git/Curl connect-failure patterns (review push failures)
2245        assert!(is_transient_github_error(
2246            "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"
2247        ));
2248        assert!(is_transient_github_error(
2249            "Failed to connect to github.com port 443: Connection timed out"
2250        ));
2251        assert!(is_transient_github_error(
2252            "Failed to connect to github.com port 443: Connection refused"
2253        ));
2254        assert!(is_transient_github_error(
2255            "curl: (7) Failed to connect to github.com port 443: Couldn't connect to server"
2256        ));
2257        assert!(is_transient_github_error(
2258            "fatal: unable to access 'https://github.com/owner/repo.git/': Couldn't connect to server"
2259        ));
2260    }
2261
2262    #[test]
2263    fn is_transient_github_error_matches_dns_errors() {
2264        // DNS resolution failures
2265        assert!(is_transient_github_error("dns error: failed to lookup"));
2266        assert!(is_transient_github_error("failed to resolve hostname"));
2267        assert!(is_transient_github_error("name resolution failed"));
2268        assert!(is_transient_github_error("no such host: api.github.com"));
2269    }
2270
2271    #[test]
2272    fn is_transient_github_error_matches_tls_errors() {
2273        // TLS/SSL handshake failures
2274        assert!(is_transient_github_error("tls handshake failed"));
2275        assert!(is_transient_github_error("certificate verification failed"));
2276        assert!(is_transient_github_error("ssl error: unknown ca"));
2277    }
2278
2279    #[test]
2280    fn is_transient_github_error_matches_network_unreachable() {
2281        // Network unreachable errors
2282        assert!(is_transient_github_error("network is unreachable"));
2283        assert!(is_transient_github_error("host unreachable"));
2284        assert!(is_transient_github_error(
2285            "temporary failure in name resolution"
2286        ));
2287    }
2288
2289    #[test]
2290    fn is_transient_github_error_matches_eof_errors() {
2291        // EOF/connection dropped errors
2292        assert!(is_transient_github_error("unexpected eof while reading"));
2293        assert!(is_transient_github_error("unexpected end of file"));
2294    }
2295
2296    #[test]
2297    fn is_transient_github_error_matches_rate_limit() {
2298        // Rate limit errors (transient with cooldown)
2299        assert!(is_transient_github_error("rate limit exceeded"));
2300        assert!(is_transient_github_error("429 Too Many Requests"));
2301        assert!(is_transient_github_error("too many requests, retry later"));
2302    }
2303
2304    #[test]
2305    fn is_transient_github_error_matches_timeout() {
2306        // Timeout errors
2307        assert!(is_transient_github_error("operation timeout"));
2308        assert!(is_transient_github_error("request timeout"));
2309    }
2310
2311    #[test]
2312    fn is_transient_github_error_rejects_non_transient() {
2313        // Non-transient errors should not match
2314        assert!(!is_transient_github_error("permission denied"));
2315        assert!(!is_transient_github_error("file not found"));
2316        assert!(!is_transient_github_error("invalid argument"));
2317        assert!(!is_transient_github_error("authentication failed"));
2318        assert!(!is_transient_github_error("bad credentials"));
2319    }
2320
2321    #[test]
2322    fn is_transient_github_api_error_requires_github_context() {
2323        assert!(is_transient_github_api_error(
2324            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway"
2325        ));
2326        assert!(is_transient_github_api_error(
2327            "gh error: transport error: connection reset by peer"
2328        ));
2329        assert!(is_transient_github_api_error(
2330            "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"
2331        ));
2332        assert!(!is_transient_github_api_error(
2333            "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)"
2334        ));
2335        assert!(!is_transient_github_api_error(
2336            "agent stream failed: failed to connect to local relay"
2337        ));
2338    }
2339
2340    #[test]
2341    fn is_transient_github_api_error_matches_circuit_breaker() {
2342        // Full message from GhHttp
2343        assert!(is_transient_github_api_error(
2344            "GitHub API transient 5xx circuit-breaker active for 240s"
2345        ));
2346        // Wrapped through PrCreateError + response_handler
2347        assert!(is_transient_github_api_error(
2348            "create PR failed: GitHub API error: GitHub API transient 5xx circuit-breaker active"
2349        ));
2350        // Shortened form where upstream wrapping stripped the "GitHub API" prefix —
2351        // circuit-breaker alone must still be recognized as a GitHub signal.
2352        assert!(is_transient_github_api_error(
2353            "create PR failed: circuit-breaker"
2354        ));
2355        // Spaced variant
2356        assert!(is_transient_github_api_error(
2357            "create PR failed: circuit breaker tripped"
2358        ));
2359    }
2360
2361    #[tokio::test]
2362    async fn auto_commit_unstages_files_on_commit_failure() {
2363        // Create a temp git repo with one initial commit
2364        let dir =
2365            std::env::temp_dir().join(format!("orch_test_auto_commit_{}", std::process::id()));
2366        let _ = std::fs::remove_dir_all(&dir);
2367        std::fs::create_dir_all(&dir).unwrap();
2368
2369        // Init repo
2370        let _ = Command::new("git")
2371            .args(["init"])
2372            .current_dir(&dir)
2373            .output()
2374            .await;
2375        let _ = Command::new("git")
2376            .args(["config", "user.email", "test@test.com"])
2377            .current_dir(&dir)
2378            .output()
2379            .await;
2380        let _ = Command::new("git")
2381            .args(["config", "user.name", "Test"])
2382            .current_dir(&dir)
2383            .output()
2384            .await;
2385        // Create initial file and commit
2386        std::fs::write(dir.join("init.txt"), "init").unwrap();
2387        let _ = Command::new("git")
2388            .args(["add", "init.txt"])
2389            .current_dir(&dir)
2390            .output()
2391            .await;
2392        let _ = Command::new("git")
2393            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2394            .current_dir(&dir)
2395            .output()
2396            .await;
2397
2398        // Create a new file (unstaged)
2399        std::fs::write(dir.join("new_file.txt"), "content").unwrap();
2400
2401        // Verify has_changes sees it
2402        assert!(has_changes(&dir).await);
2403
2404        // auto_commit should return false (no git user.email configured for this
2405        // test repo would cause commit to fail if we hadn't set it, but we did —
2406        // so this test verifies the happy path too).
2407        // For this test, the commit should succeed since we set user.email/name.
2408        let result = auto_commit(
2409            &dir,
2410            "1",
2411            "Test title",
2412            "test",
2413            1,
2414            super::super::commit::When::Always,
2415        )
2416        .await;
2417        assert!(result.unwrap());
2418
2419        // Verify no changes remain
2420        assert!(!has_changes(&dir).await);
2421
2422        // Cleanup
2423        let _ = std::fs::remove_dir_all(&dir);
2424    }
2425
2426    #[tokio::test]
2427    async fn auto_commit_ignores_repo_signing_config() {
2428        // Repo demands signing via a signer that always fails — auto_commit
2429        // must still commit (it passes -c commit.gpgsign=false)
2430        let dir =
2431            std::env::temp_dir().join(format!("orch_test_auto_commit_gpg_{}", std::process::id()));
2432        let _ = std::fs::remove_dir_all(&dir);
2433        std::fs::create_dir_all(&dir).unwrap();
2434
2435        for args in [
2436            vec!["init"],
2437            vec!["config", "user.email", "test@test.com"],
2438            vec!["config", "user.name", "Test"],
2439            vec!["config", "commit.gpgsign", "true"],
2440            vec!["config", "gpg.program", "/usr/bin/false"],
2441        ] {
2442            let _ = Command::new("git")
2443                .args(&args)
2444                .current_dir(&dir)
2445                .output()
2446                .await;
2447        }
2448        std::fs::write(dir.join("init.txt"), "init").unwrap();
2449        let _ = Command::new("git")
2450            .args(["add", "init.txt"])
2451            .current_dir(&dir)
2452            .output()
2453            .await;
2454        let _ = Command::new("git")
2455            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2456            .current_dir(&dir)
2457            .output()
2458            .await;
2459
2460        std::fs::write(dir.join("signed_off.txt"), "content").unwrap();
2461        assert!(has_changes(&dir).await);
2462
2463        let result = auto_commit(
2464            &dir,
2465            "3",
2466            "Test title",
2467            "test",
2468            1,
2469            super::super::commit::When::Always,
2470        )
2471        .await;
2472        assert!(result.unwrap());
2473        assert!(!has_changes(&dir).await);
2474
2475        let _ = std::fs::remove_dir_all(&dir);
2476    }
2477
2478    #[tokio::test]
2479    async fn auto_commit_unstages_on_pre_commit_hook_failure() {
2480        // Create a temp git repo where commit always fails (via pre-commit hook)
2481        let dir =
2482            std::env::temp_dir().join(format!("orch_test_auto_commit_hook_{}", std::process::id()));
2483        let _ = std::fs::remove_dir_all(&dir);
2484        std::fs::create_dir_all(&dir).unwrap();
2485
2486        // Init repo
2487        let _ = Command::new("git")
2488            .args(["init"])
2489            .current_dir(&dir)
2490            .output()
2491            .await;
2492        let _ = Command::new("git")
2493            .args(["config", "user.email", "test@test.com"])
2494            .current_dir(&dir)
2495            .output()
2496            .await;
2497        let _ = Command::new("git")
2498            .args(["config", "user.name", "Test"])
2499            .current_dir(&dir)
2500            .output()
2501            .await;
2502        // Create initial commit
2503        std::fs::write(dir.join("init.txt"), "init").unwrap();
2504        let _ = Command::new("git")
2505            .args(["add", "init.txt"])
2506            .current_dir(&dir)
2507            .output()
2508            .await;
2509        let _ = Command::new("git")
2510            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2511            .current_dir(&dir)
2512            .output()
2513            .await;
2514
2515        // Install a pre-commit hook that always fails
2516        let hooks_dir = dir.join(".git/hooks");
2517        std::fs::create_dir_all(&hooks_dir).unwrap();
2518        std::fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\nexit 1\n").unwrap();
2519        #[cfg(unix)]
2520        {
2521            use std::os::unix::fs::PermissionsExt;
2522            let mut perms = std::fs::metadata(hooks_dir.join("pre-commit"))
2523                .unwrap()
2524                .permissions();
2525            perms.set_mode(0o755);
2526            std::fs::set_permissions(hooks_dir.join("pre-commit"), perms).unwrap();
2527        }
2528
2529        // Create a new file
2530        std::fs::write(dir.join("staged_file.txt"), "content").unwrap();
2531
2532        // Verify has_changes sees it
2533        assert!(has_changes(&dir).await);
2534
2535        // auto_commit should return false (pre-commit hook blocks it)
2536        let result = auto_commit(
2537            &dir,
2538            "2",
2539            "Test title",
2540            "test",
2541            1,
2542            super::super::commit::When::Always,
2543        )
2544        .await;
2545        assert!(!result.unwrap());
2546
2547        // After commit failure, files should be unstaged — has_changes should
2548        // still return true because the unstaged file is still there.
2549        assert!(has_changes(&dir).await);
2550
2551        // Verify the file is NOT staged (git diff --cached --quiet should succeed)
2552        let cached = Command::new("git")
2553            .args(["diff", "--cached", "--quiet"])
2554            .current_dir(&dir)
2555            .status()
2556            .await;
2557        assert!(
2558            cached.is_ok_and(|s| s.success()),
2559            "files should be unstaged after commit failure"
2560        );
2561
2562        // Cleanup
2563        let _ = std::fs::remove_dir_all(&dir);
2564    }
2565
2566    #[test]
2567    fn is_workflow_scope_error_detects_backtick_format() {
2568        let err = "! [remote rejected] branch -> branch (refusing to allow an OAuth App to create or update workflow `.github/workflows/ci.yml` without `workflow` scope)";
2569        assert!(is_workflow_scope_error(err));
2570    }
2571
2572    #[test]
2573    fn is_workflow_scope_error_detects_single_quote_format() {
2574        let err = "refusing to allow an OAuth App to create or update workflow '.github/workflows/ci.yml' without 'workflow' scope";
2575        assert!(is_workflow_scope_error(err));
2576    }
2577
2578    #[test]
2579    fn is_workflow_scope_error_detects_refusing_to_allow_variant() {
2580        let err = "remote: refusing to allow a Personal Access Token to create or update workflow `.github/workflows/scan.yml` without `workflow` scope";
2581        assert!(is_workflow_scope_error(err));
2582    }
2583
2584    #[test]
2585    fn is_workflow_scope_error_rejects_unrelated_errors() {
2586        assert!(!is_workflow_scope_error("non-fast-forward"));
2587        assert!(!is_workflow_scope_error("permission denied"));
2588        assert!(!is_workflow_scope_error("authentication failed"));
2589        assert!(!is_workflow_scope_error("rejected"));
2590    }
2591
2592    #[tokio::test]
2593    async fn strip_workflow_files_removes_workflow_from_commits() {
2594        // Create a temp git repo simulating the scenario
2595        let dir = std::env::temp_dir().join(format!("orch_test_strip_wf_{}", std::process::id()));
2596        let _ = std::fs::remove_dir_all(&dir);
2597        std::fs::create_dir_all(&dir).unwrap();
2598
2599        // Init repo with initial commit
2600        let _ = Command::new("git")
2601            .args(["init"])
2602            .current_dir(&dir)
2603            .output()
2604            .await;
2605        let _ = Command::new("git")
2606            .args(["config", "user.email", "test@test.com"])
2607            .current_dir(&dir)
2608            .output()
2609            .await;
2610        let _ = Command::new("git")
2611            .args(["config", "user.name", "Test"])
2612            .current_dir(&dir)
2613            .output()
2614            .await;
2615        std::fs::write(dir.join("init.txt"), "init").unwrap();
2616        let _ = Command::new("git")
2617            .args(["add", "."])
2618            .current_dir(&dir)
2619            .output()
2620            .await;
2621        let _ = Command::new("git")
2622            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2623            .current_dir(&dir)
2624            .output()
2625            .await;
2626
2627        // Create a "remote" by adding the repo as its own origin (for rev-list)
2628        let _ = Command::new("git")
2629            .args(["remote", "add", "origin", dir.to_str().unwrap()])
2630            .current_dir(&dir)
2631            .output()
2632            .await;
2633        let _ = Command::new("git")
2634            .args(["fetch", "origin"])
2635            .current_dir(&dir)
2636            .output()
2637            .await;
2638
2639        // Create a branch off main
2640        let _ = Command::new("git")
2641            .args(["checkout", "-b", "feature"])
2642            .current_dir(&dir)
2643            .output()
2644            .await;
2645
2646        // Add both a normal file and a workflow file
2647        std::fs::write(dir.join("feature.txt"), "feature work").unwrap();
2648        let wf_dir = dir.join(".github/workflows");
2649        std::fs::create_dir_all(&wf_dir).unwrap();
2650        std::fs::write(wf_dir.join("ci.yml"), "name: CI\non: push").unwrap();
2651        let _ = Command::new("git")
2652            .args(["add", "."])
2653            .current_dir(&dir)
2654            .output()
2655            .await;
2656        let _ = Command::new("git")
2657            .args([
2658                "-c",
2659                "commit.gpgsign=false",
2660                "commit",
2661                "-m",
2662                "add feature and workflow",
2663            ])
2664            .current_dir(&dir)
2665            .output()
2666            .await;
2667
2668        // Get the default branch name
2669        let branch_out = Command::new("git")
2670            .args(["rev-parse", "--abbrev-ref", "origin/HEAD"])
2671            .current_dir(&dir)
2672            .output()
2673            .await;
2674        // Fallback to "main" if origin/HEAD is not set
2675        let default_branch = branch_out.ok().filter(|o| o.status.success()).map_or_else(
2676            || "main".to_string(),
2677            |o| {
2678                String::from_utf8_lossy(&o.stdout)
2679                    .trim()
2680                    .strip_prefix("origin/")
2681                    .unwrap_or("main")
2682                    .to_string()
2683            },
2684        );
2685
2686        // Strip workflow files
2687        let stripped = strip_workflow_files(&dir, &default_branch).await.unwrap();
2688        assert!(stripped, "should have stripped workflow files");
2689
2690        // Verify workflow file is no longer in the committed tree
2691        let ls_output = Command::new("git")
2692            .args(["ls-tree", "-r", "--name-only", "HEAD"])
2693            .current_dir(&dir)
2694            .output()
2695            .await
2696            .unwrap();
2697        let files = String::from_utf8_lossy(&ls_output.stdout);
2698        assert!(
2699            !files.contains(".github/workflows/ci.yml"),
2700            "workflow file should be removed from commits"
2701        );
2702        assert!(
2703            files.contains("feature.txt"),
2704            "non-workflow files should be preserved"
2705        );
2706
2707        // Cleanup
2708        let _ = std::fs::remove_dir_all(&dir);
2709    }
2710
2711    #[tokio::test]
2712    async fn strip_workflow_files_returns_false_when_no_workflows() {
2713        let dir =
2714            std::env::temp_dir().join(format!("orch_test_strip_wf_none_{}", std::process::id()));
2715        let _ = std::fs::remove_dir_all(&dir);
2716        std::fs::create_dir_all(&dir).unwrap();
2717
2718        // Init repo
2719        let _ = Command::new("git")
2720            .args(["init"])
2721            .current_dir(&dir)
2722            .output()
2723            .await;
2724        let _ = Command::new("git")
2725            .args(["config", "user.email", "test@test.com"])
2726            .current_dir(&dir)
2727            .output()
2728            .await;
2729        let _ = Command::new("git")
2730            .args(["config", "user.name", "Test"])
2731            .current_dir(&dir)
2732            .output()
2733            .await;
2734        std::fs::write(dir.join("init.txt"), "init").unwrap();
2735        let _ = Command::new("git")
2736            .args(["add", "."])
2737            .current_dir(&dir)
2738            .output()
2739            .await;
2740        let _ = Command::new("git")
2741            .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
2742            .current_dir(&dir)
2743            .output()
2744            .await;
2745
2746        let _ = Command::new("git")
2747            .args(["remote", "add", "origin", dir.to_str().unwrap()])
2748            .current_dir(&dir)
2749            .output()
2750            .await;
2751        let _ = Command::new("git")
2752            .args(["fetch", "origin"])
2753            .current_dir(&dir)
2754            .output()
2755            .await;
2756
2757        let _ = Command::new("git")
2758            .args(["checkout", "-b", "feature2"])
2759            .current_dir(&dir)
2760            .output()
2761            .await;
2762
2763        // Only non-workflow files
2764        std::fs::write(dir.join("feature.txt"), "feature").unwrap();
2765        let _ = Command::new("git")
2766            .args(["add", "."])
2767            .current_dir(&dir)
2768            .output()
2769            .await;
2770        let _ = Command::new("git")
2771            .args([
2772                "-c",
2773                "commit.gpgsign=false",
2774                "commit",
2775                "-m",
2776                "add feature only",
2777            ])
2778            .current_dir(&dir)
2779            .output()
2780            .await;
2781
2782        let stripped = strip_workflow_files(&dir, "main").await.unwrap();
2783        assert!(!stripped, "should not strip when no workflow files exist");
2784
2785        // Cleanup
2786        let _ = std::fs::remove_dir_all(&dir);
2787    }
2788
2789    #[test]
2790    fn is_invalid_base_error_detects_base_invalid() {
2791        assert!(is_invalid_base_error(
2792            "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): {\"errors\":[{\"resource\":\"PullRequest\",\"field\":\"base\",\"code\":\"invalid\"}]}"
2793        ));
2794        assert!(is_invalid_base_error(
2795            "GitHub API POST .../pulls failed (422): Base field is invalid"
2796        ));
2797    }
2798
2799    #[test]
2800    fn is_invalid_base_error_rejects_non_base_errors() {
2801        // No-commits error should NOT match
2802        assert!(!is_invalid_base_error(
2803            "GitHub API POST .../pulls failed (422): No commits between main and branch"
2804        ));
2805        // Head invalid should NOT match
2806        assert!(!is_invalid_base_error(
2807            "GitHub API POST .../pulls failed (422): head is not a valid ref"
2808        ));
2809        // Transient 5xx should NOT match
2810        assert!(!is_invalid_base_error(
2811            "GitHub API POST .../pulls failed (502): Bad Gateway"
2812        ));
2813        // 404 should NOT match
2814        assert!(!is_invalid_base_error(
2815            "GitHub API POST .../pulls failed (404): Not Found"
2816        ));
2817    }
2818
2819    fn pr_summary(body: &str) -> &str {
2820        let rest = body
2821            .strip_prefix("## Summary\n\n")
2822            .expect("PR body starts with ## Summary");
2823        rest.split("\n\n").next().unwrap()
2824    }
2825
2826    fn assert_no_push_or_pr_creation(body: &str) {
2827        let lower = body.to_ascii_lowercase();
2828        for needle in [
2829            "git push",
2830            "gh pr create",
2831            "did not push",
2832            "couldn't push",
2833            "could not push",
2834            "push was denied",
2835            "push was blocked",
2836            "push permission",
2837            "nothing is on the remote",
2838            "open the pr",
2839        ] {
2840            assert!(
2841                !lower.contains(needle),
2842                "PR body still contains {needle:?}:\n{body}"
2843            );
2844        }
2845    }
2846
2847    #[test]
2848    fn build_pr_body_strips_push_and_pr_creation_statements() {
2849        let examples = [
2850            (
2851                concat!(
2852                    "Added the lesson list page. ",
2853                    "I did not push or open the PR, because the push was blocked on a permission prompt. ",
2854                    "Run `git push -u origin HEAD`, then `gh pr create`.",
2855                ),
2856                "Added the lesson list page.",
2857            ),
2858            (
2859                "It is committed locally ... The push was denied, so nothing is on the remote yet.",
2860                "It is committed locally",
2861            ),
2862            (
2863                concat!(
2864                    "Fixed the quiz scoring. ",
2865                    "I couldn't push it because the push permission was denied.",
2866                ),
2867                "Fixed the quiz scoring.",
2868            ),
2869        ];
2870        for (summary, work) in examples {
2871            let body = build_pr_body(
2872                "Fix the widget",
2873                summary,
2874                &[
2875                    "Wrote the lesson list".into(),
2876                    "Could not push because the push permission was denied".into(),
2877                ],
2878                &["Run `git push -u origin HEAD`".into()],
2879                &[],
2880                "16",
2881                "claude",
2882                None,
2883            );
2884            assert_eq!(pr_summary(&body), work);
2885            assert!(body.contains("Wrote the lesson list"));
2886            assert!(!body.contains("### Remaining"));
2887            assert_no_push_or_pr_creation(&body);
2888        }
2889    }
2890
2891    #[test]
2892    fn build_pr_body_keeps_summary_without_push_text() {
2893        let summary = "Added retry logic for GitHub 5xx errors.";
2894        let accomplished = vec!["Extracted the PR body builder".into()];
2895        let remaining = vec!["Watch CI after merge".into()];
2896        let body = build_pr_body(
2897            "Fix the widget",
2898            summary,
2899            &accomplished,
2900            &remaining,
2901            &["src/engine/runner/git_ops.rs".into()],
2902            "16",
2903            "claude",
2904            Some("sonnet"),
2905        );
2906        assert_eq!(pr_summary(&body), summary);
2907        assert!(body.contains("### What was done\n\n- Extracted the PR body builder\n"));
2908        assert!(body.contains("### Remaining\n\n- Watch CI after merge\n"));
2909        assert!(body.contains("- `src/engine/runner/git_ops.rs`"));
2910        assert!(body.contains(" using `sonnet`"));
2911    }
2912
2913    fn sh_git(dir: &std::path::Path, args: &[&str]) {
2914        let out = std::process::Command::new("git")
2915            .args([
2916                "-c",
2917                "commit.gpgsign=false",
2918                "-c",
2919                "user.name=T",
2920                "-c",
2921                "user.email=t@t",
2922            ])
2923            .args(args)
2924            .current_dir(dir)
2925            .output()
2926            .unwrap();
2927        assert!(
2928            out.status.success(),
2929            "git {args:?}: {}",
2930            String::from_utf8_lossy(&out.stderr)
2931        );
2932    }
2933
2934    fn commit_file(dir: &std::path::Path, name: &str) {
2935        std::fs::write(dir.join(name), name).unwrap();
2936        sh_git(dir, &["add", name]);
2937        sh_git(dir, &["commit", "-m", name]);
2938    }
2939
2940    /**
2941     * The review rebases its branch on main and force-pushes with lease. When GitHub's
2942     * "Update branch" moved the remote branch after the last fetch, the lease is stale and
2943     * the push was rejected, which blocked approved work (#3783)
2944     **/
2945    #[tokio::test]
2946    async fn push_branch_recovers_from_stale_lease_after_remote_moved() {
2947        let temp = tempfile::TempDir::new().unwrap();
2948        let bare = temp.path().join("origin.git");
2949        let a = temp.path().join("a");
2950        let b = temp.path().join("b");
2951        std::fs::create_dir_all(&bare).unwrap();
2952        sh_git(&bare, &["init", "--bare", "-b", "main"]);
2953        sh_git(temp.path(), &["clone", bare.to_str().unwrap(), "a"]);
2954        // push_branch's own rebase commits, and CI runners have no global identity
2955        sh_git(&a, &["config", "user.name", "T"]);
2956        sh_git(&a, &["config", "user.email", "t@t"]);
2957        commit_file(&a, "base.txt");
2958        sh_git(&a, &["push", "origin", "main"]);
2959        sh_git(&a, &["checkout", "-b", "feat"]);
2960        commit_file(&a, "feature.txt");
2961        sh_git(&a, &["push", "-u", "origin", "feat"]);
2962
2963        sh_git(temp.path(), &["clone", bare.to_str().unwrap(), "b"]);
2964        commit_file(&b, "main-change.txt");
2965        sh_git(&b, &["push", "origin", "main"]);
2966        sh_git(&b, &["checkout", "feat"]);
2967        commit_file(&b, "update-branch.txt");
2968        sh_git(&b, &["push", "origin", "feat"]);
2969
2970        // A only fetches main, so its view of origin/feat is stale when it rewrites feat
2971        sh_git(&a, &["fetch", "origin", "main"]);
2972        sh_git(&a, &["rebase", "origin/main"]);
2973
2974        assert!(push_branch(&a, "feat", "main").await.unwrap());
2975
2976        sh_git(&b, &["fetch", "origin", "feat"]);
2977        sh_git(&b, &["checkout", "origin/feat"]);
2978        for name in [
2979            "base.txt",
2980            "feature.txt",
2981            "main-change.txt",
2982            "update-branch.txt",
2983        ] {
2984            assert!(b.join(name).exists(), "remote feat lost {name}");
2985        }
2986    }
2987
2988    #[test]
2989    fn stale_lease_error_matches_git_messages() {
2990        assert!(is_stale_lease_error(
2991            "! [rejected] feat -> feat (stale info)"
2992        ));
2993        assert!(is_stale_lease_error(
2994            "! [rejected] feat -> feat (remote ref updated since checkout)"
2995        ));
2996        assert!(!is_stale_lease_error(
2997            "! [rejected] feat -> feat (fetch first)"
2998        ));
2999    }
3000
3001    fn git_ok(dir: &std::path::Path, args: &[&str]) {
3002        let out = std::process::Command::new("git")
3003            .args(args)
3004            .current_dir(dir)
3005            .output()
3006            .unwrap();
3007        assert!(
3008            out.status.success(),
3009            "git {} failed: {}",
3010            args.join(" "),
3011            String::from_utf8_lossy(&out.stderr)
3012        );
3013    }
3014
3015    fn git_sha(dir: &std::path::Path, rev: &str) -> String {
3016        let out = std::process::Command::new("git")
3017            .args(["rev-parse", rev])
3018            .current_dir(dir)
3019            .output()
3020            .unwrap();
3021        assert!(out.status.success(), "git rev-parse {rev} failed");
3022        String::from_utf8_lossy(&out.stdout).trim().to_string()
3023    }
3024
3025    fn init_identity(dir: &std::path::Path) {
3026        git_ok(dir, &["config", "user.email", "test@example.com"]);
3027        git_ok(dir, &["config", "user.name", "Test"]);
3028        git_ok(dir, &["config", "commit.gpgsign", "false"]);
3029    }
3030
3031    fn write_commit(dir: &std::path::Path, file: &str, body: &str, msg: &str) {
3032        std::fs::write(dir.join(file), body).unwrap();
3033        git_ok(dir, &["add", file]);
3034        git_ok(dir, &["commit", "-m", msg]);
3035    }
3036
3037    /// A bare origin with `main` and a pushed `feature`, and a clone `a` on `feature`
3038    fn publish_feature(root: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
3039        let bare = root.join("bare.git");
3040        let a = root.join("a");
3041        git_ok(root, &["init", "-b", "main", a.to_str().unwrap()]);
3042        init_identity(&a);
3043        write_commit(&a, "README.md", "base\n", "base");
3044        git_ok(
3045            root,
3046            &["init", "--bare", "-b", "main", bare.to_str().unwrap()],
3047        );
3048        git_ok(&a, &["remote", "add", "origin", bare.to_str().unwrap()]);
3049        git_ok(&a, &["push", "-u", "origin", "main"]);
3050        git_ok(&a, &["checkout", "-b", "feature"]);
3051        write_commit(&a, "feature.txt", "feature\n", "feature");
3052        git_ok(&a, &["push", "-u", "origin", "feature"]);
3053        (bare, a)
3054    }
3055
3056    fn clone_on(
3057        root: &std::path::Path,
3058        bare: &std::path::Path,
3059        name: &str,
3060        branch: &str,
3061    ) -> std::path::PathBuf {
3062        let dir = root.join(name);
3063        git_ok(
3064            root,
3065            &["clone", bare.to_str().unwrap(), dir.to_str().unwrap()],
3066        );
3067        init_identity(&dir);
3068        if branch != "main" {
3069            git_ok(
3070                &dir,
3071                &["checkout", "-b", branch, &format!("origin/{branch}")],
3072            );
3073        }
3074
3075        dir
3076    }
3077
3078    #[tokio::test]
3079    async fn leased_force_push_does_not_replace_a_newer_remote_tip() {
3080        let root = tempfile::tempdir().unwrap();
3081        let (bare, a) = publish_feature(root.path());
3082        let b = clone_on(root.path(), &bare, "b", "feature");
3083        record_push_lease(&b, "feature", "main").await;
3084        let leased = git_sha(&b, "refs/remotes/origin/feature");
3085
3086        write_commit(&a, "feature.txt", "from a\n", "from a");
3087        git_ok(&a, &["push", "origin", "feature"]);
3088        let newer = git_sha(&a, "HEAD");
3089
3090        git_ok(&b, &["fetch", "origin"]);
3091        write_commit(&b, "feature.txt", "from b\n", "from b");
3092        let pushed = push_branch(&b, "feature", "main").await;
3093        assert!(
3094            pushed.is_err(),
3095            "force push should be rejected, got {pushed:?}"
3096        );
3097        assert_eq!(git_sha(&bare, "refs/heads/feature"), newer);
3098
3099        // A retry sees the fetched tip, which HEAD does not contain, so the lease stays
3100        record_push_lease(&b, "feature", "main").await;
3101        assert_eq!(read_push_lease(&b, "feature").await, Some(leased));
3102        let again = push_branch(&b, "feature", "main").await;
3103        assert!(
3104            again.is_err(),
3105            "second force push should stay rejected, got {again:?}"
3106        );
3107        assert_eq!(git_sha(&bare, "refs/heads/feature"), newer);
3108    }
3109
3110    #[tokio::test]
3111    async fn leased_force_push_allows_rewrite_of_the_recorded_tip() {
3112        let root = tempfile::tempdir().unwrap();
3113        let (bare, a) = publish_feature(root.path());
3114        record_push_lease(&a, "feature", "main").await;
3115        std::fs::write(a.join("feature.txt"), "rewritten\n").unwrap();
3116        git_ok(&a, &["commit", "-am", "--amend", "-m", "feature rewritten"]);
3117
3118        let pushed = push_branch(&a, "feature", "main").await;
3119        assert!(
3120            pushed.is_ok(),
3121            "rewrite of the leased tip should push, got {pushed:?}"
3122        );
3123        assert_eq!(git_sha(&bare, "refs/heads/feature"), git_sha(&a, "HEAD"));
3124        assert_eq!(
3125            read_push_lease(&a, "feature").await,
3126            Some(git_sha(&a, "HEAD"))
3127        );
3128    }
3129
3130    #[tokio::test]
3131    async fn push_after_update_branch_merge_force_pushes_the_rebased_branch() {
3132        let root = tempfile::tempdir().unwrap();
3133        let (bare, a) = publish_feature(root.path());
3134        record_push_lease(&a, "feature", "main").await;
3135
3136        // GitHub's "Update branch": main moves, then main is merged into feature on the remote
3137        let c = clone_on(root.path(), &bare, "c", "main");
3138        write_commit(&c, "main.txt", "main\n", "main moves");
3139        git_ok(&c, &["push", "origin", "main"]);
3140        git_ok(&c, &["checkout", "-b", "feature", "origin/feature"]);
3141        git_ok(
3142            &c,
3143            &[
3144                "merge",
3145                "--no-ff",
3146                "-m",
3147                "Merge main into feature",
3148                "origin/main",
3149            ],
3150        );
3151        git_ok(&c, &["push", "origin", "feature"]);
3152
3153        rebase_on_default(&a, "main").await;
3154        write_commit(&a, "feature.txt", "review fix\n", "review fix");
3155        let pushed = push_branch(&a, "feature", "main").await;
3156        assert!(
3157            pushed.is_ok(),
3158            "push after update-branch should succeed, got {pushed:?}"
3159        );
3160        assert_eq!(git_sha(&bare, "refs/heads/feature"), git_sha(&a, "HEAD"));
3161    }
3162
3163    #[tokio::test]
3164    async fn agent_push_moves_the_lease_to_a_tip_head_contains() {
3165        let root = tempfile::tempdir().unwrap();
3166        let (_bare, a) = publish_feature(root.path());
3167        record_push_lease(&a, "feature", "main").await;
3168
3169        write_commit(&a, "feature.txt", "agent step\n", "agent step");
3170        git_ok(&a, &["push", "origin", "feature"]);
3171        record_push_lease(&a, "feature", "main").await;
3172        assert_eq!(
3173            read_push_lease(&a, "feature").await,
3174            Some(git_sha(&a, "HEAD"))
3175        );
3176    }
3177
3178    #[tokio::test]
3179    async fn record_push_lease_skips_the_default_branch() {
3180        let root = tempfile::tempdir().unwrap();
3181        let (_bare, a) = publish_feature(root.path());
3182        git_ok(&a, &["checkout", "main"]);
3183        record_push_lease(&a, "main", "main").await;
3184        assert_eq!(read_push_lease(&a, "main").await, None);
3185    }
3186
3187    /// GitHub's "Update branch" by merge: main moves, then main is merged into feature
3188    fn github_update_by_merge(root: &std::path::Path, bare: &std::path::Path, name: &str) {
3189        let c = clone_on(root, bare, name, "main");
3190        write_commit(&c, "main.txt", "main\n", "main moves");
3191        git_ok(&c, &["push", "origin", "main"]);
3192        git_ok(&c, &["checkout", "-b", "feature", "origin/feature"]);
3193        git_ok(
3194            &c,
3195            &[
3196                "merge",
3197                "--no-ff",
3198                "-m",
3199                "Merge main into feature",
3200                "origin/main",
3201            ],
3202        );
3203        git_ok(&c, &["push", "origin", "feature"]);
3204    }
3205
3206    /// GitHub's "Update branch" by rebase: main moves, then feature is replayed on it
3207    fn github_update_by_rebase(
3208        root: &std::path::Path,
3209        bare: &std::path::Path,
3210        name: &str,
3211    ) -> std::path::PathBuf {
3212        let c = clone_on(root, bare, name, "main");
3213        write_commit(&c, "main.txt", "main\n", "main moves");
3214        git_ok(&c, &["push", "origin", "main"]);
3215        git_ok(&c, &["checkout", "-b", "feature", "origin/feature"]);
3216        git_ok(&c, &["rebase", "origin/main"]);
3217        git_ok(&c, &["push", "--force", "origin", "feature"]);
3218        c
3219    }
3220
3221    fn remote_has(root: &std::path::Path, bare: &std::path::Path, file: &str) -> bool {
3222        let check = root.join("check");
3223        if !check.exists() {
3224            git_ok(
3225                root,
3226                &["clone", bare.to_str().unwrap(), check.to_str().unwrap()],
3227            );
3228        }
3229
3230        git_ok(&check, &["fetch", "origin"]);
3231        git_ok(&check, &["checkout", "--detach", "origin/feature"]);
3232        check.join(file).exists()
3233    }
3234
3235    #[tokio::test]
3236    async fn push_after_update_branch_rebase_force_pushes_the_rebased_branch() {
3237        let root = tempfile::tempdir().unwrap();
3238        let (bare, a) = publish_feature(root.path());
3239        record_push_lease(&a, "feature", "main").await;
3240        github_update_by_rebase(root.path(), &bare, "c");
3241
3242        rebase_on_default(&a, "main").await;
3243        write_commit(&a, "fix.txt", "review fix\n", "review fix");
3244        let pushed = push_branch(&a, "feature", "main").await;
3245        assert!(
3246            pushed.is_ok(),
3247            "push after a rebase update should succeed, got {pushed:?}"
3248        );
3249        assert_eq!(git_sha(&bare, "refs/heads/feature"), git_sha(&a, "HEAD"));
3250    }
3251
3252    #[tokio::test]
3253    async fn rebase_update_with_a_foreign_commit_keeps_the_foreign_commit() {
3254        let root = tempfile::tempdir().unwrap();
3255        let (bare, a) = publish_feature(root.path());
3256        record_push_lease(&a, "feature", "main").await;
3257        let c = github_update_by_rebase(root.path(), &bare, "c");
3258        write_commit(&c, "other.txt", "other machine\n", "other machine");
3259        git_ok(&c, &["push", "origin", "feature"]);
3260
3261        rebase_on_default(&a, "main").await;
3262        write_commit(&a, "fix.txt", "review fix\n", "review fix");
3263        let _ = push_branch(&a, "feature", "main").await;
3264        assert!(
3265            remote_has(root.path(), &bare, "other.txt"),
3266            "the other machine's commit was dropped"
3267        );
3268    }
3269
3270    #[tokio::test]
3271    async fn merge_of_a_non_default_branch_is_not_a_github_update() {
3272        let root = tempfile::tempdir().unwrap();
3273        let (bare, a) = publish_feature(root.path());
3274        record_push_lease(&a, "feature", "main").await;
3275        let c = clone_on(root.path(), &bare, "c", "feature");
3276        git_ok(&c, &["checkout", "-b", "side", "origin/main"]);
3277        write_commit(&c, "side.txt", "side\n", "side work");
3278        git_ok(&c, &["checkout", "feature"]);
3279        git_ok(&c, &["merge", "--no-ff", "-m", "Merge side", "side"]);
3280        git_ok(&c, &["push", "origin", "feature"]);
3281
3282        rebase_on_default(&a, "main").await;
3283        write_commit(&a, "fix.txt", "review fix\n", "review fix");
3284        let _ = push_branch(&a, "feature", "main").await;
3285        assert!(
3286            remote_has(root.path(), &bare, "side.txt"),
3287            "the merged side branch was dropped"
3288        );
3289    }
3290
3291    #[tokio::test]
3292    async fn update_merge_on_top_of_a_foreign_push_keeps_the_foreign_commit() {
3293        let root = tempfile::tempdir().unwrap();
3294        let (bare, a) = publish_feature(root.path());
3295        record_push_lease(&a, "feature", "main").await;
3296        let c = clone_on(root.path(), &bare, "c", "feature");
3297        write_commit(&c, "other.txt", "other machine\n", "other machine");
3298        git_ok(&c, &["push", "origin", "feature"]);
3299        github_update_by_merge(root.path(), &bare, "d");
3300
3301        rebase_on_default(&a, "main").await;
3302        write_commit(&a, "fix.txt", "review fix\n", "review fix");
3303        let _ = push_branch(&a, "feature", "main").await;
3304        assert!(
3305            remote_has(root.path(), &bare, "other.txt"),
3306            "the other machine's commit was dropped"
3307        );
3308    }
3309
3310    #[tokio::test]
3311    async fn force_push_leased_accepts_github_updates() {
3312        for by_rebase in [false, true] {
3313            let root = tempfile::tempdir().unwrap();
3314            let (bare, a) = publish_feature(root.path());
3315            record_push_lease(&a, "feature", "main").await;
3316            if by_rebase {
3317                github_update_by_rebase(root.path(), &bare, "c");
3318            } else {
3319                github_update_by_merge(root.path(), &bare, "c");
3320            }
3321
3322            git_ok(&a, &["fetch", "origin"]);
3323            git_ok(&a, &["rebase", "origin/main"]);
3324            let pushed = force_push_leased(&a, "feature", "main").await;
3325            assert!(pushed.is_ok(), "by_rebase={by_rebase}: {pushed:?}");
3326            assert_eq!(git_sha(&bare, "refs/heads/feature"), git_sha(&a, "HEAD"));
3327        }
3328    }
3329
3330    #[tokio::test]
3331    async fn force_push_leased_refuses_a_foreign_push() {
3332        let root = tempfile::tempdir().unwrap();
3333        let (bare, a) = publish_feature(root.path());
3334        record_push_lease(&a, "feature", "main").await;
3335        let c = clone_on(root.path(), &bare, "c", "feature");
3336        write_commit(&c, "other.txt", "other machine\n", "other machine");
3337        git_ok(&c, &["push", "origin", "feature"]);
3338        let foreign = git_sha(&c, "HEAD");
3339
3340        git_ok(&a, &["fetch", "origin"]);
3341        record_push_lease(&a, "feature", "main").await;
3342        std::fs::write(a.join("feature.txt"), "rewritten\n").unwrap();
3343        git_ok(&a, &["commit", "-am", "--amend", "-m", "rewritten"]);
3344        assert!(force_push_leased(&a, "feature", "main").await.is_err());
3345        assert_eq!(git_sha(&bare, "refs/heads/feature"), foreign);
3346    }
3347
3348    #[tokio::test]
3349    async fn force_push_leased_refuses_a_branch_created_after_an_absent_lease() {
3350        let root = tempfile::tempdir().unwrap();
3351        let (bare, a) = publish_feature(root.path());
3352        git_ok(&a, &["checkout", "-b", "fresh", "main"]);
3353        record_push_lease(&a, "fresh", "main").await;
3354        assert_eq!(
3355            read_push_lease(&a, "fresh").await.as_deref(),
3356            Some(LEASE_ABSENT)
3357        );
3358
3359        let c = clone_on(root.path(), &bare, "c", "main");
3360        git_ok(&c, &["checkout", "-b", "fresh"]);
3361        write_commit(&c, "other.txt", "other machine\n", "other machine");
3362        git_ok(&c, &["push", "origin", "fresh"]);
3363        let foreign = git_sha(&c, "HEAD");
3364
3365        git_ok(&a, &["fetch", "origin"]);
3366        write_commit(&a, "mine.txt", "mine\n", "mine");
3367        assert!(force_push_leased(&a, "fresh", "main").await.is_err());
3368        assert_eq!(git_sha(&bare, "refs/heads/fresh"), foreign);
3369    }
3370
3371    #[tokio::test]
3372    async fn first_push_records_the_pushed_head_as_the_lease() {
3373        let root = tempfile::tempdir().unwrap();
3374        let (_bare, a) = publish_feature(root.path());
3375        git_ok(&a, &["checkout", "-b", "fresh", "main"]);
3376        write_commit(&a, "mine.txt", "mine\n", "mine");
3377        assert!(push_branch(&a, "fresh", "main").await.unwrap());
3378        assert_eq!(
3379            read_push_lease(&a, "fresh").await,
3380            Some(git_sha(&a, "HEAD"))
3381        );
3382    }
3383
3384    #[tokio::test]
3385    async fn missing_lease_falls_back_to_the_fetched_tip_and_counts_it() {
3386        let root = tempfile::tempdir().unwrap();
3387        let (_bare, a) = publish_feature(root.path());
3388        let before = LEASE_FALLBACKS.load(Ordering::Relaxed);
3389        let expected = expected_force_sha(&a, "feature", "main").await.unwrap();
3390        assert_eq!(expected, git_sha(&a, "refs/remotes/origin/feature"));
3391        assert!(LEASE_FALLBACKS.load(Ordering::Relaxed) > before);
3392    }
3393
3394    #[tokio::test]
3395    async fn agent_push_then_foreign_push_keeps_the_agent_tip_as_lease() {
3396        let root = tempfile::tempdir().unwrap();
3397        let (bare, a) = publish_feature(root.path());
3398        record_push_lease(&a, "feature", "main").await;
3399        write_commit(&a, "step.txt", "agent step\n", "agent step");
3400        git_ok(&a, &["push", "origin", "feature"]);
3401        let agent_tip = git_sha(&a, "HEAD");
3402
3403        let c = clone_on(root.path(), &bare, "c", "feature");
3404        write_commit(&c, "other.txt", "other machine\n", "other machine");
3405        git_ok(&c, &["push", "origin", "feature"]);
3406
3407        // Callers record before they fetch, so the lease moves to the agent's push and no further
3408        record_push_lease(&a, "feature", "main").await;
3409        git_ok(&a, &["fetch", "origin"]);
3410        record_push_lease(&a, "feature", "main").await;
3411        assert_eq!(read_push_lease(&a, "feature").await, Some(agent_tip));
3412        let foreign = git_sha(&bare, "refs/heads/feature");
3413        git_ok(&a, &["commit", "--amend", "-m", "agent step rewritten"]);
3414        assert!(force_push_leased(&a, "feature", "main").await.is_err());
3415        assert_eq!(git_sha(&bare, "refs/heads/feature"), foreign);
3416    }
3417
3418    #[tokio::test]
3419    async fn branch_without_remote_and_head_without_twins_are_not_github_updates() {
3420        let root = tempfile::tempdir().unwrap();
3421        let (_bare, a) = publish_feature(root.path());
3422        let lease = git_sha(&a, "HEAD");
3423        let main = git_sha(&a, "origin/main");
3424        // A tip equal to main has nothing in base..tip, which must not count as a rebase update
3425        assert!(!is_github_branch_update(&a, &main, &lease, "main").await);
3426        assert!(
3427            !is_github_branch_update(
3428                &a,
3429                "0000000000000000000000000000000000000000",
3430                &lease,
3431                "main"
3432            )
3433            .await
3434        );
3435    }
3436}