Skip to main content

omni_dev/git/
worktree_push.rs

1//! Batch-publish a repository's worktrees to their upstreams, force-pushing with a
2//! lease where history was rewritten (issue #1443).
3//!
4//! This is the other half of [`worktree_rebase`]. A rebase rewrites history, so
5//! every already-published branch it touches is now diverged from its upstream and
6//! a plain `git push` is rejected — leaving the user to open a terminal in each
7//! worktree and force-push by hand. This engine closes that loop for a whole batch.
8//!
9//! **Split of concerns (see ADR-0003 and ADR-0061).** Every git *read* — the
10//! checked-out branch, the upstream and its remote, the divergence, the repository's
11//! remote default branch — goes through `git2`. The one *mutation*, the push itself,
12//! shells out to the user's `git`: libgit2's vendored build here has no reliable SSH
13//! transport (issue #903), the shell inherits the user's `ssh-agent` /
14//! `~/.ssh/config` / credential-helper configuration for free, and only real `git`
15//! implements the lease this engine depends on. The binary is resolved through
16//! [`crate::git::resolve_git_binary`] rather than by name, because the daemon's
17//! `PATH` is minimal (ADR-0059 §3).
18//!
19//! # Two rules this engine exists to enforce
20//!
21//! **1. Always `--force-with-lease --force-if-includes`, never `--force`.**
22//! A bare `--force-with-lease` leases against the *local* remote-tracking ref, so
23//! any background fetch that refreshes `refs/remotes/<remote>/<branch>` silently
24//! renews the lease and a teammate's unseen commit becomes overwritable. Per
25//! `git-push(1)`, `--force-if-includes` additionally verifies that such implicitly
26//! updated remote-tracking refs were actually integrated locally — and it is **not**
27//! implied: it must be passed explicitly, and it is a documented no-op unless
28//! `--force-with-lease` is given in its valueless (or refname-only) form. The hazard
29//! is elevated in exactly the environment this ships into, since the built-in VS
30//! Code Git extension's `git.autofetch` is precisely such a background fetch.
31//! `--force` is never emitted, and no option exposes it: a refused lease is the
32//! feature working.
33//!
34//! **2. Never force-push the repository's remote default branch.** [ADR-0060]
35//! dropped the rebase engine's main-working-tree gate; a force-push must invert
36//! that, and the gate is on the *branch* rather than the worktree's structural
37//! role. A rebase rewrites only local history and is `git reflog`-recoverable; a
38//! force-push publishes that rewrite to everyone. A fast-forward onto the default
39//! branch stays allowed, because it is an ordinary push.
40//!
41//! # Planning does not touch the network
42//!
43//! Unlike [`worktree_rebase::plan`], [`plan`] performs no fetch — which is why it
44//! takes no `git` binary at all. Classification reads the local
45//! `refs/remotes/<remote>/<branch>`, and that is *exactly* the ref the lease is
46//! checked against, so the plan the user confirms and the lease the push enforces
47//! agree by construction. A fetch here would refresh that ref — renewing the very
48//! lease rule 1 exists to protect. The cost is that a teammate's unseen push reads
49//! as [`PushResult::WouldFastForward`] and is then refused by the remote, which is
50//! reported rather than forced.
51//!
52//! [`worktree_rebase`]: crate::git::worktree_rebase
53//! [`worktree_rebase::plan`]: crate::git::worktree_rebase::plan
54//! [ADR-0060]: https://github.com/rust-works/omni-dev/blob/main/docs/adrs/adr-0060.md
55
56use std::path::{Path, PathBuf};
57
58use anyhow::Result;
59use git2::{Oid, Repository};
60use serde::Serialize;
61
62use crate::git::remote::RemoteInfo;
63use crate::git::resolve_git_binary;
64use crate::git::worktree_batch::{
65    head_branch, is_false, resolve_selection, run_git_in, trimmed_stderr,
66};
67
68pub use crate::git::worktree_batch::Selection;
69
70/// The remote a branch with no configured upstream is published to, when the
71/// repository has one by that name. Matches git's own convention and
72/// [`crate::git::repository::GitRepository::push_branch`].
73const DEFAULT_REMOTE: &str = "origin";
74
75/// Knobs for a batch push.
76///
77/// Deliberately minimal: there is no force escape hatch (see the module docs) and
78/// no remote override — a branch publishes to its own upstream's remote, which is
79/// the only destination this engine will use.
80#[derive(Debug, Clone, Default)]
81pub struct PushOptions {
82    /// The `git` executable to shell out to. `None` resolves it via
83    /// [`resolve_git_binary`], which is what a caller with a minimal `PATH` (the
84    /// daemon) needs; the field exists so a caller can resolve **once** and reuse,
85    /// and so a test can point the engine at a stub.
86    pub git_bin: Option<PathBuf>,
87}
88
89impl PushOptions {
90    /// The `git` executable these options select, resolving the default lazily.
91    /// Called once per [`execute`] rather than per subprocess.
92    fn git_bin(&self) -> PathBuf {
93        self.git_bin.clone().unwrap_or_else(resolve_git_binary)
94    }
95}
96
97/// The result of planning and (optionally) executing a batch push.
98///
99/// Unlike [`worktree_rebase::Plan`](crate::git::worktree_rebase::Plan) there is no
100/// `fetches` field: planning contacts no remote (see the module docs).
101#[derive(Debug, Clone, Serialize)]
102pub struct Plan {
103    /// One entry per selected worktree, in selection order.
104    pub worktrees: Vec<WorktreeOutcome>,
105}
106
107impl Plan {
108    /// Whether any worktree still has something to publish. The CLI uses this to
109    /// decide whether to confirm and execute.
110    #[must_use]
111    pub fn has_pending_pushes(&self) -> bool {
112        self.worktrees.iter().any(|w| w.result.is_pending())
113    }
114}
115
116/// What happened (or, in a plan, would happen) to one worktree.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
118pub struct WorktreeOutcome {
119    /// The worktree folder, canonicalized — the key the daemon's transient
120    /// `pushing` set is joined on.
121    pub path: PathBuf,
122    /// The checked-out branch, when on one (absent for a detached/unborn HEAD).
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub branch: Option<String>,
125    /// The remote this branch publishes to (empty when it could not be resolved).
126    pub remote: String,
127    /// The branch name on the remote. Usually identical to `branch`, but
128    /// `branch.<name>.merge` may name a different ref. Empty when unresolved.
129    pub remote_branch: String,
130    /// The classification / outcome.
131    #[serde(flatten)]
132    pub result: PushResult,
133}
134
135/// The per-worktree classification and outcome.
136///
137/// [`plan`] only ever produces `UpToDate` / `WouldFastForward` / `WouldForce` /
138/// `WouldCreate` / `Skipped`; [`execute`] turns each pending variant into `Pushed`,
139/// `Created` or `Rejected`.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
141#[serde(tag = "status", rename_all = "kebab-case")]
142pub enum PushResult {
143    /// The upstream is already at the local tip — nothing to publish.
144    UpToDate,
145    /// Ahead of the upstream and not behind it: a plain push suffices. **No force
146    /// flag is used**, on the default branch included.
147    WouldFastForward {
148        /// Commits the branch is ahead of its upstream.
149        ahead: usize,
150    },
151    /// Diverged from the upstream — ahead *and* behind — so the push needs the
152    /// lease. This is what a rebase leaves behind, and the only variant the
153    /// default-branch gate refuses.
154    WouldForce {
155        /// Commits the branch is ahead of its upstream.
156        ahead: usize,
157        /// Commits the branch is behind its upstream.
158        behind: usize,
159    },
160    /// The branch has no upstream, so publishing it creates one
161    /// (`--set-upstream`). Included deliberately: a batch action that silently
162    /// refused every unpublished branch would be worse than one that publishes it,
163    /// and `--force-with-lease` is a no-op against a ref that does not exist yet.
164    WouldCreate,
165    /// Published to the existing upstream.
166    Pushed {
167        /// Whether this was a leased non-fast-forward update rather than an
168        /// ordinary fast-forward.
169        forced: bool,
170    },
171    /// Published a branch that had no upstream, and recorded the tracking ref.
172    Created,
173    /// The remote refused the update, or `git push` failed.
174    Rejected {
175        /// The refusal reason, from `git push --porcelain` where it gave one, else
176        /// the trimmed `git` error.
177        detail: String,
178        /// Whether the refusal was the **lease** — the remote moved since the tip
179        /// this worktree last saw. The signal that the fix is `git fetch` plus a
180        /// rebase, never a harder push. Omitted on the wire when false, so a client
181        /// that does not know the field sees the same bytes.
182        #[serde(skip_serializing_if = "is_false")]
183        stale: bool,
184    },
185    /// Skipped without contacting the remote, for a structural reason.
186    Skipped {
187        /// Why it was skipped.
188        reason: SkipReason,
189    },
190}
191
192impl PushResult {
193    /// Whether this outcome still has something to publish — i.e. whether
194    /// [`execute`] will act on it.
195    #[must_use]
196    pub const fn is_pending(&self) -> bool {
197        self.pending_kind().is_some()
198    }
199
200    /// The flavour of push this outcome calls for, or `None` when there is nothing
201    /// to do. The single place the classification-to-flags mapping lives.
202    const fn pending_kind(&self) -> Option<PushKind> {
203        match self {
204            Self::WouldFastForward { .. } => Some(PushKind::FastForward),
205            Self::WouldForce { .. } => Some(PushKind::Force),
206            Self::WouldCreate => Some(PushKind::Create),
207            _ => None,
208        }
209    }
210}
211
212/// Why a worktree was skipped rather than pushed.
213///
214/// A **dirty** worktree is deliberately absent: a push publishes commits, not the
215/// working tree, so uncommitted changes are no reason to refuse one.
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
217#[serde(rename_all = "kebab-case")]
218pub enum SkipReason {
219    /// A detached or unborn HEAD — there is no branch to publish. This is also
220    /// what covers a worktree sitting mid-rebase, whose HEAD *is* detached.
221    DetachedHead,
222    /// The path is not a git worktree.
223    NotAWorktree,
224    /// The branch tracks no upstream and the repository has no remote to publish
225    /// to, so there is no destination to create one on.
226    NoRemote,
227    /// The branch is the repository's remote default branch and the push would be
228    /// a non-fast-forward. Refused outright: a rebase is local and
229    /// `git reflog`-recoverable, but force-pushing the default branch publishes
230    /// that rewrite to everyone (ADR-0061).
231    DefaultBranchForcePush,
232}
233
234/// The three shapes a push can take, each with its own flags.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236enum PushKind {
237    /// Ahead only — an ordinary push, no force flags.
238    FastForward,
239    /// Diverged — `--force-with-lease --force-if-includes`.
240    Force,
241    /// No upstream yet — `--set-upstream`.
242    Create,
243}
244
245/// Plans a batch push, classifying every selected worktree against its upstream.
246///
247/// **Contacts no remote**, which is why it needs no `git` binary: the comparison is
248/// against the local `refs/remotes/<remote>/<branch>`, the same ref
249/// `--force-with-lease` leases against, so the plan and the lease agree by
250/// construction. The returned [`Plan`] is exactly what a dry run reports; a real
251/// run passes it to [`execute`].
252///
253/// # Errors
254///
255/// Returns an error only when the selection itself cannot be resolved (e.g.
256/// `--all` outside a repository). A single unusable path is reported as a
257/// [`SkipReason`], never an error, so one bad path cannot fail the whole batch.
258pub fn plan(selection: &Selection) -> Result<Plan> {
259    let paths = resolve_selection(selection)?;
260    let worktrees = paths.iter().map(|path| classify(path)).collect();
261    Ok(Plan { worktrees })
262}
263
264/// Executes a [`Plan`], publishing every worktree that still has something to push.
265///
266/// The rest pass through unchanged. Pushes run sequentially: linked worktrees share
267/// one object database and ref store, and a successful push writes
268/// `refs/remotes/<remote>/<branch>` into it. A refused push is reported and the
269/// batch continues with the remaining worktrees.
270#[must_use]
271pub fn execute(plan: Plan, opts: &PushOptions) -> Vec<WorktreeOutcome> {
272    let git = opts.git_bin();
273    plan.worktrees
274        .into_iter()
275        .map(|mut outcome| {
276            if let Some(kind) = outcome.result.pending_kind() {
277                outcome.result = push_worktree(&git, &outcome, kind);
278            }
279            outcome
280        })
281        .collect()
282}
283
284// ── classification (git2 reads only) ─────────────────────────────────────────
285
286/// Reads and classifies one selected path.
287///
288/// A single pass, unlike the rebase engine's inspect-then-classify split: that
289/// split exists only to group worktrees by repository for its fetch-once contract,
290/// and there is no fetch here to group.
291fn classify(path: &Path) -> WorktreeOutcome {
292    let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
293    let Ok(repo) = Repository::discover(&canon) else {
294        return WorktreeOutcome::skipped(canon, None, SkipReason::NotAWorktree);
295    };
296
297    // Safe-before-destructive order, exactly as the rebase classifier.
298    let (branch, head_oid) = head_branch(&repo);
299    let (Some(branch), Some(head)) = (branch, head_oid) else {
300        return WorktreeOutcome::skipped(canon, None, SkipReason::DetachedHead);
301    };
302
303    let Some(target) = resolve_target(&repo, &branch) else {
304        return WorktreeOutcome::skipped(canon, Some(branch), SkipReason::NoRemote);
305    };
306
307    let outcome = |result| WorktreeOutcome {
308        path: canon.clone(),
309        branch: Some(branch.clone()),
310        remote: target.remote.clone(),
311        remote_branch: target.remote_branch.clone(),
312        result,
313    };
314
315    // No upstream yet: publishing creates one. `--force-with-lease` cannot apply to
316    // a ref that does not exist, so the default-branch gate below is irrelevant
317    // here — creating a branch destroys nothing.
318    let Some(upstream_oid) = target.upstream_oid else {
319        return outcome(PushResult::WouldCreate);
320    };
321
322    let Some((ahead, behind)) = repo.graph_ahead_behind(head, upstream_oid).ok() else {
323        // Either tip is unreachable in this object database — treat it as nothing
324        // to publish rather than guessing at a force.
325        return outcome(PushResult::UpToDate);
326    };
327
328    match (ahead, behind) {
329        (0, _) => outcome(PushResult::UpToDate),
330        (ahead, 0) => outcome(PushResult::WouldFastForward { ahead }),
331        (ahead, behind) => {
332            // The one gate that inverts ADR-0060: refuse to *force*-push the
333            // repository's remote default branch, whichever worktree holds it. A
334            // fast-forward onto it (above) stays an ordinary push and is allowed.
335            if is_default_branch(&repo, &target) {
336                return outcome(PushResult::Skipped {
337                    reason: SkipReason::DefaultBranchForcePush,
338                });
339            }
340            outcome(PushResult::WouldForce { ahead, behind })
341        }
342    }
343}
344
345/// Where a branch publishes to, and what its upstream currently points at.
346struct Target {
347    /// The remote name (`origin`, or whatever the branch tracks).
348    remote: String,
349    /// The branch name **on the remote** — usually the local name, but
350    /// `branch.<name>.merge` may say otherwise.
351    remote_branch: String,
352    /// The commit the remote-tracking ref points at, or `None` when the branch
353    /// tracks no upstream yet.
354    upstream_oid: Option<Oid>,
355}
356
357/// Resolves a branch's push destination.
358///
359/// With a configured upstream the remote comes from `branch.<name>.remote` and the
360/// destination ref from `branch.<name>.merge` — the same two values git's own
361/// default push resolution reads — while the tip to compare against comes from the
362/// remote-tracking ref git2 resolves, which honours the remote's fetch refspec.
363/// Without an upstream, the branch publishes to [`DEFAULT_REMOTE`] if the
364/// repository has it, else to its single remote; `None` when it has neither.
365fn resolve_target(repo: &Repository, branch: &str) -> Option<Target> {
366    let refname = format!("refs/heads/{branch}");
367
368    if let Some(remote) = buf_string(repo.branch_upstream_remote(&refname)) {
369        let upstream_oid = buf_string(repo.branch_upstream_name(&refname))
370            .and_then(|name| repo.refname_to_id(&name).ok());
371        return Some(Target {
372            remote_branch: merge_ref_branch(repo, branch).unwrap_or_else(|| branch.to_string()),
373            remote,
374            upstream_oid,
375        });
376    }
377
378    Some(Target {
379        remote: fallback_remote(repo)?,
380        remote_branch: branch.to_string(),
381        upstream_oid: None,
382    })
383}
384
385/// An owned `String` from a git2 `Buf`-returning call, dropping both the git error
386/// and the (vanishingly rare) non-UTF-8 case in one step.
387fn buf_string(buf: std::result::Result<git2::Buf, git2::Error>) -> Option<String> {
388    buf.ok()?.as_str().ok().map(ToString::to_string)
389}
390
391/// The short branch name `branch.<name>.merge` points at, when it names an ordinary
392/// `refs/heads/*` ref. `None` for an unset or exotic value, leaving the caller to
393/// publish under the local name.
394fn merge_ref_branch(repo: &Repository, branch: &str) -> Option<String> {
395    let merge = repo
396        .config()
397        .ok()?
398        .get_string(&format!("branch.{branch}.merge"))
399        .ok()?;
400    merge.strip_prefix("refs/heads/").map(ToString::to_string)
401}
402
403/// The remote an upstream-less branch publishes to: [`DEFAULT_REMOTE`] when the
404/// repository has it, else its sole remote. `None` when the repository has no
405/// remotes, or several and no `origin` — in which case the destination is genuinely
406/// ambiguous and the worktree is skipped rather than guessed at.
407fn fallback_remote(repo: &Repository) -> Option<String> {
408    let remotes = repo.remotes().ok()?;
409    // `iter()` yields `Result<Option<&str>, _>`: the first `flatten` drops per-name
410    // errors, the second drops non-UTF-8 names (the same idiom the worktree
411    // enumeration in `worktree_batch` uses).
412    let names: Vec<String> = remotes
413        .iter()
414        .flatten()
415        .flatten()
416        .map(String::from)
417        .collect();
418    if names.iter().any(|name| name == DEFAULT_REMOTE) {
419        return Some(DEFAULT_REMOTE.to_string());
420    }
421    match names.as_slice() {
422        [only] => Some(only.clone()),
423        _ => None,
424    }
425}
426
427/// Whether this push targets the remote's default branch — the one branch a force
428/// push is refused on. Compared on the **remote-side** name, which is what is
429/// actually being protected.
430fn is_default_branch(repo: &Repository, target: &Target) -> bool {
431    RemoteInfo::detect_main_branch_local(repo, &target.remote)
432        .is_some_and(|default| default == target.remote_branch)
433}
434
435impl WorktreeOutcome {
436    /// A `Skipped` outcome, for the cases where no destination was resolved.
437    fn skipped(path: PathBuf, branch: Option<String>, reason: SkipReason) -> Self {
438        Self {
439            path,
440            branch,
441            remote: String::new(),
442            remote_branch: String::new(),
443            result: PushResult::Skipped { reason },
444        }
445    }
446}
447
448// ── push (shell-out, per worktree) ───────────────────────────────────────────
449
450/// Publishes one worktree's branch, mapping `git push --porcelain`'s status line
451/// onto a [`PushResult`].
452fn push_worktree(git: &Path, outcome: &WorktreeOutcome, kind: PushKind) -> PushResult {
453    let branch = outcome.branch.as_deref().unwrap_or_default();
454    let args = push_args(kind, &outcome.remote, branch, &outcome.remote_branch);
455    let argv: Vec<&str> = args.iter().map(String::as_str).collect();
456
457    let output = match run_git_in(git, &outcome.path, &argv) {
458        Ok(output) => output,
459        Err(err) => {
460            // `{err:#}` chains the anyhow context onto the underlying io::Error's
461            // message (e.g. "Text file busy") — plain `{err}` prints only the
462            // outer "failed to execute ... in ..." context and silently drops the
463            // reason, which is also what the spawn-retry's ETXTBSY sniff below
464            // depends on.
465            return PushResult::Rejected {
466                detail: format!("{err:#}"),
467                stale: false,
468            };
469        }
470    };
471
472    let stdout = String::from_utf8_lossy(&output.stdout);
473    if let Some(result) = parse_porcelain(&stdout).into_iter().find_map(result_of) {
474        return result;
475    }
476    // `--porcelain` always emits a status line, so this is the "git died before it
477    // said anything" path: trust the exit status and report stderr.
478    if output.status.success() {
479        return match kind {
480            PushKind::Create => PushResult::Created,
481            PushKind::Force => PushResult::Pushed { forced: true },
482            PushKind::FastForward => PushResult::Pushed { forced: false },
483        };
484    }
485    PushResult::Rejected {
486        detail: trimmed_stderr(&output),
487        stale: false,
488    }
489}
490
491/// The `git push` argument vector for one worktree. Pure, so the flag rules — the
492/// load-bearing part of this engine — are unit-testable without a remote.
493///
494/// `--force-with-lease` is passed **valueless**: per `git-push(1)` the
495/// `=<refname>:<expect>` form makes `--force-if-includes` a documented no-op, which
496/// would silently drop the protection against a background fetch having renewed the
497/// lease. `--force` is never emitted.
498///
499/// The refspec is fully qualified on both sides so neither end depends on
500/// `push.default` or on the remote resolving a short name.
501fn push_args(kind: PushKind, remote: &str, local: &str, remote_branch: &str) -> Vec<String> {
502    let mut args = vec!["push".to_string(), "--porcelain".to_string()];
503    match kind {
504        PushKind::FastForward => {}
505        PushKind::Force => {
506            args.push("--force-with-lease".to_string());
507            args.push("--force-if-includes".to_string());
508        }
509        PushKind::Create => args.push("--set-upstream".to_string()),
510    }
511    args.push(remote.to_string());
512    args.push(format!("refs/heads/{local}:refs/heads/{remote_branch}"));
513    args
514}
515
516/// One parsed `git push --porcelain` status line.
517#[derive(Debug, Clone, PartialEq, Eq)]
518struct PorcelainLine {
519    /// The single-character status flag (see [`result_of`]).
520    flag: char,
521    /// The human summary, e.g. `[rejected]` or `abc123..def456`.
522    summary: String,
523    /// The parenthesised explanation, when git gave one.
524    reason: Option<String>,
525}
526
527/// Parses `git push --porcelain` output (which goes to **stdout**) into its status
528/// lines, dropping the `To <url>` header, the trailing `Done`, and anything else
529/// that is not `<flag>\t<from>:<to>\t<summary> (<reason>)`.
530fn parse_porcelain(stdout: &str) -> Vec<PorcelainLine> {
531    stdout.lines().filter_map(parse_porcelain_line).collect()
532}
533
534/// Parses one status line, or `None` when the line is not one.
535fn parse_porcelain_line(line: &str) -> Option<PorcelainLine> {
536    let mut fields = line.split('\t');
537    let flag_field = fields.next()?;
538    let _refs = fields.next()?;
539    let rest = fields.next()?;
540
541    // The flag field is exactly one character — a space for a plain fast-forward,
542    // which is why this cannot be written as a `trim` + `chars().next()`.
543    let mut chars = flag_field.chars();
544    let flag = chars.next()?;
545    if chars.next().is_some() {
546        return None;
547    }
548
549    let (summary, reason) = split_reason(rest);
550    Some(PorcelainLine {
551        flag,
552        summary,
553        reason,
554    })
555}
556
557/// Splits `<summary> (<reason>)` into its two halves.
558fn split_reason(rest: &str) -> (String, Option<String>) {
559    let rest = rest.trim();
560    if let Some(stripped) = rest.strip_suffix(')') {
561        if let Some(open) = stripped.rfind('(') {
562            return (
563                stripped[..open].trim().to_string(),
564                Some(stripped[open + 1..].trim().to_string()),
565            );
566        }
567    }
568    (rest.to_string(), None)
569}
570
571/// Maps one status line onto a [`PushResult`], or `None` for a flag this engine
572/// never produces (a deletion) so the caller keeps looking.
573///
574/// The flag alphabet is `git-push(1)`'s: a space for a fast-forward, `+` for a
575/// forced update, `*` for a new ref, `=` for up-to-date, `!` for a rejection.
576fn result_of(line: PorcelainLine) -> Option<PushResult> {
577    match line.flag {
578        '=' => Some(PushResult::UpToDate),
579        ' ' => Some(PushResult::Pushed { forced: false }),
580        '+' => Some(PushResult::Pushed { forced: true }),
581        '*' => Some(PushResult::Created),
582        '!' => {
583            let reason = line.reason.unwrap_or(line.summary);
584            Some(PushResult::Rejected {
585                stale: is_lease_refusal(&reason),
586                detail: reason,
587            })
588        }
589        _ => None,
590    }
591}
592
593/// Whether a rejection reason is the **lease** refusing, rather than an ordinary
594/// non-fast-forward or a server-side hook.
595///
596/// Git words the two lease failures differently — `stale info` when the
597/// remote-tracking ref no longer matches the remote, and `remote ref updated since
598/// checkout` when `--force-if-includes` finds an unintegrated update — and both
599/// mean the same thing to the user: fetch, rebase, and try again.
600fn is_lease_refusal(reason: &str) -> bool {
601    let reason = reason.to_ascii_lowercase();
602    reason.contains("stale info") || reason.contains("remote ref updated since checkout")
603}
604
605#[cfg(test)]
606#[allow(clippy::unwrap_used, clippy::expect_used)]
607mod tests {
608    use super::*;
609
610    use crate::git::worktree_batch::test_serial_lock;
611
612    /// The shared git-load serialization guard (see
613    /// [`crate::git::worktree_batch::test_serial_lock`]).
614    fn serial() -> std::sync::MutexGuard<'static, ()> {
615        test_serial_lock()
616    }
617
618    // ── the flag rules (pure) ─────────────────────────────────────────────
619
620    #[test]
621    fn a_fast_forward_push_uses_no_force_flag_at_all() {
622        assert_eq!(
623            push_args(PushKind::FastForward, "origin", "feat", "feat"),
624            vec![
625                "push",
626                "--porcelain",
627                "origin",
628                "refs/heads/feat:refs/heads/feat"
629            ],
630        );
631    }
632
633    #[test]
634    fn a_forced_push_pairs_the_lease_with_force_if_includes() {
635        let args = push_args(PushKind::Force, "origin", "feat", "feat");
636        assert_eq!(
637            args,
638            vec![
639                "push",
640                "--porcelain",
641                "--force-with-lease",
642                "--force-if-includes",
643                "origin",
644                "refs/heads/feat:refs/heads/feat"
645            ],
646        );
647        // The load-bearing detail (ADR-0061 §2): `--force-if-includes` is a
648        // documented no-op unless `--force-with-lease` is *valueless*, so an
649        // `=<refname>:<expect>` form would silently drop the protection.
650        assert!(
651            args.iter().all(|a| !a.starts_with("--force-with-lease=")),
652            "the lease must stay valueless or --force-if-includes is a no-op"
653        );
654    }
655
656    #[test]
657    fn no_push_flavour_ever_emits_bare_force() {
658        for kind in [PushKind::FastForward, PushKind::Force, PushKind::Create] {
659            let args = push_args(kind, "origin", "feat", "feat");
660            assert!(
661                !args.iter().any(|a| a == "--force" || a == "-f"),
662                "{kind:?} must never force without a lease"
663            );
664        }
665    }
666
667    #[test]
668    fn creating_a_branch_sets_its_upstream() {
669        assert_eq!(
670            push_args(PushKind::Create, "upstream", "feat", "feat"),
671            vec![
672                "push",
673                "--porcelain",
674                "--set-upstream",
675                "upstream",
676                "refs/heads/feat:refs/heads/feat"
677            ],
678        );
679    }
680
681    #[test]
682    fn the_refspec_honours_a_differing_remote_branch_name() {
683        let args = push_args(PushKind::Force, "origin", "local-name", "remote-name");
684        assert_eq!(
685            args.last().unwrap(),
686            "refs/heads/local-name:refs/heads/remote-name",
687            "a `branch.<n>.merge` pointing elsewhere must not be pushed over the local name"
688        );
689    }
690
691    // ── porcelain parsing (pure) ──────────────────────────────────────────
692
693    #[test]
694    fn porcelain_parsing_maps_every_documented_flag() {
695        let cases = [
696            (
697                "=\trefs/heads/a:refs/heads/a\t[up to date]",
698                PushResult::UpToDate,
699            ),
700            (
701                " \trefs/heads/a:refs/heads/a\tabc123..def456",
702                PushResult::Pushed { forced: false },
703            ),
704            (
705                "+\trefs/heads/a:refs/heads/a\tabc123...def456 (forced update)",
706                PushResult::Pushed { forced: true },
707            ),
708            (
709                "*\trefs/heads/a:refs/heads/a\t[new branch]",
710                PushResult::Created,
711            ),
712        ];
713        for (line, expected) in cases {
714            let parsed = parse_porcelain(line);
715            assert_eq!(parsed.len(), 1, "failed to parse {line:?}");
716            assert_eq!(result_of(parsed[0].clone()), Some(expected), "for {line:?}");
717        }
718    }
719
720    #[test]
721    fn porcelain_parsing_skips_the_header_and_trailer() {
722        let stdout = "To git@github.com:o/r.git\n\
723                      =\trefs/heads/a:refs/heads/a\t[up to date]\n\
724                      Done\n";
725        let parsed = parse_porcelain(stdout);
726        assert_eq!(parsed.len(), 1, "only the status line is a status line");
727        assert_eq!(parsed[0].flag, '=');
728    }
729
730    #[test]
731    fn a_stale_lease_rejection_is_distinguished_from_an_ordinary_one() {
732        let stale = parse_porcelain("!\trefs/heads/a:refs/heads/a\t[rejected] (stale info)");
733        assert_eq!(
734            result_of(stale[0].clone()),
735            Some(PushResult::Rejected {
736                detail: "stale info".to_string(),
737                stale: true,
738            }),
739        );
740
741        let includes = parse_porcelain(
742            "!\trefs/heads/a:refs/heads/a\t[rejected] (remote ref updated since checkout)",
743        );
744        assert!(
745            matches!(result_of(includes[0].clone()), Some(PushResult::Rejected { stale, .. }) if stale),
746            "--force-if-includes wording is a lease refusal too"
747        );
748
749        let hook = parse_porcelain(
750            "!\trefs/heads/a:refs/heads/a\t[remote rejected] (pre-receive hook declined)",
751        );
752        assert!(
753            matches!(result_of(hook[0].clone()), Some(PushResult::Rejected { stale, .. }) if !stale),
754            "a server-side hook refusal is not a lease refusal"
755        );
756    }
757
758    #[test]
759    fn a_rejection_without_a_reason_falls_back_to_the_summary() {
760        let parsed = parse_porcelain("!\trefs/heads/a:refs/heads/a\t[rejected]");
761        assert_eq!(
762            result_of(parsed[0].clone()),
763            Some(PushResult::Rejected {
764                detail: "[rejected]".to_string(),
765                stale: false,
766            }),
767        );
768    }
769
770    #[test]
771    fn split_reason_leaves_a_parenthesis_free_summary_alone() {
772        assert_eq!(
773            split_reason("abc123..def456"),
774            ("abc123..def456".to_string(), None)
775        );
776        assert_eq!(
777            split_reason("[rejected] (non-fast-forward)"),
778            (
779                "[rejected]".to_string(),
780                Some("non-fast-forward".to_string())
781            )
782        );
783        assert_eq!(
784            split_reason("weird)"),
785            ("weird)".to_string(), None),
786            "a trailing `)` with no opening `(` is part of the summary, not a reason"
787        );
788    }
789
790    #[test]
791    fn a_multi_character_first_field_is_not_a_status_line() {
792        // The flag field is exactly one character. Anything wider is some other
793        // tab-separated output, and mis-reading it as a status would invent an
794        // outcome — so it is dropped rather than guessed at.
795        assert!(parse_porcelain("To\tgit@host:o/r.git\tsomething").is_empty());
796        assert!(parse_porcelain("\trefs/heads/a:refs/heads/a\tsummary").is_empty());
797    }
798
799    #[test]
800    fn an_unknown_status_flag_yields_no_result() {
801        // `-` is a deletion, which this engine never asks for. An unrecognised flag
802        // must not be mapped onto a success or a failure — the caller keeps looking
803        // and ultimately falls back to the exit status.
804        let parsed = parse_porcelain("-\t:refs/heads/a\t[deleted]");
805        assert_eq!(parsed.len(), 1, "the line still parses structurally");
806        assert_eq!(result_of(parsed[0].clone()), None);
807    }
808
809    // ── classification (git2 reads, real repos) ───────────────────────────
810
811    #[test]
812    fn an_unpushed_branch_would_be_created() {
813        let _guard = serial();
814        let scenario = Scenario::new();
815        let wt = scenario.add_worktree("feature-a");
816
817        let outcome = classify(&wt);
818        assert_eq!(outcome.result, PushResult::WouldCreate);
819        assert_eq!(
820            outcome.remote, "origin",
821            "an upstream-less branch falls back to origin"
822        );
823        assert_eq!(outcome.remote_branch, "feature-a");
824    }
825
826    #[test]
827    fn a_branch_ahead_of_its_upstream_would_fast_forward() {
828        let _guard = serial();
829        let scenario = Scenario::new();
830        let wt = scenario.add_worktree("feature-a");
831        scenario.publish(&wt, "feature-a");
832        scenario.commit_in(&wt, "file.txt", "local\n", "local work");
833
834        assert_eq!(
835            classify(&wt).result,
836            PushResult::WouldFastForward { ahead: 1 },
837            "ahead-only needs no lease"
838        );
839    }
840
841    #[test]
842    fn a_published_branch_with_nothing_new_is_up_to_date() {
843        let _guard = serial();
844        let scenario = Scenario::new();
845        let wt = scenario.add_worktree("feature-a");
846        scenario.publish(&wt, "feature-a");
847
848        assert_eq!(classify(&wt).result, PushResult::UpToDate);
849    }
850
851    #[test]
852    fn a_rewritten_branch_would_force() {
853        let _guard = serial();
854        let scenario = Scenario::new();
855        let wt = scenario.add_worktree("feature-a");
856        scenario.commit_in(&wt, "file.txt", "worktree first\n", "first");
857        scenario.publish(&wt, "feature-a");
858        // Rewrite the published tip, exactly as a rebase would.
859        scenario.git_in(&wt, &["commit", "--amend", "-m", "rewritten"]);
860
861        assert!(
862            matches!(
863                classify(&wt).result,
864                PushResult::WouldForce {
865                    ahead: 1,
866                    behind: 1
867                }
868            ),
869            "a rewritten tip diverges and needs the lease"
870        );
871    }
872
873    #[test]
874    fn a_dangling_upstream_ref_is_not_guessed_at_as_a_force() {
875        // The remote-tracking ref resolves, but the commit it names is absent from
876        // the object database (a truncated fetch, a pruned pack). Divergence is then
877        // unknowable — and the safe reading of "unknowable" is *nothing to publish*,
878        // never a force, which would overwrite the remote on the strength of a
879        // comparison that failed.
880        let _guard = serial();
881        let scenario = Scenario::new();
882        let wt = scenario.add_worktree("feature-a");
883        scenario.publish(&wt, "feature-a");
884        scenario.commit_in(&wt, "file.txt", "local\n", "local work");
885        assert_eq!(
886            classify(&wt).result,
887            PushResult::WouldFastForward { ahead: 1 },
888            "precondition: an intact upstream classifies normally"
889        );
890
891        // Point the tracking ref at a well-formed oid no object exists for. Written
892        // directly rather than via `git update-ref`, which validates the target.
893        let tracking = scenario.local.join(".git/refs/remotes/origin/feature-a");
894        std::fs::create_dir_all(tracking.parent().unwrap()).unwrap();
895        std::fs::write(&tracking, "0123456789abcdef0123456789abcdef01234567\n").unwrap();
896
897        assert_eq!(classify(&wt).result, PushResult::UpToDate);
898    }
899
900    #[test]
901    fn a_detached_head_is_skipped() {
902        let _guard = serial();
903        let scenario = Scenario::new();
904        let wt = scenario.add_worktree("feature-a");
905        scenario.git_in(&wt, &["checkout", "--detach"]);
906
907        assert_eq!(
908            classify(&wt).result,
909            PushResult::Skipped {
910                reason: SkipReason::DetachedHead
911            },
912        );
913    }
914
915    #[test]
916    fn a_non_worktree_path_is_skipped_rather_than_failing_the_batch() {
917        let dir = tempfile::tempdir().unwrap();
918        assert_eq!(
919            classify(dir.path()).result,
920            PushResult::Skipped {
921                reason: SkipReason::NotAWorktree
922            },
923        );
924    }
925
926    #[test]
927    fn a_dirty_worktree_is_not_a_skip() {
928        // A push publishes commits, not the working tree — so uncommitted changes
929        // are no reason to refuse one (unlike the rebase engine).
930        let _guard = serial();
931        let scenario = Scenario::new();
932        let wt = scenario.add_worktree("feature-a");
933        scenario.publish(&wt, "feature-a");
934        scenario.commit_in(&wt, "file.txt", "committed\n", "work");
935        std::fs::write(wt.join("keep.txt"), "uncommitted\n").unwrap();
936
937        assert_eq!(
938            classify(&wt).result,
939            PushResult::WouldFastForward { ahead: 1 },
940            "a dirty tree must not suppress a push"
941        );
942    }
943
944    // ── the default-branch gate (ADR-0061 §3) ─────────────────────────────
945
946    #[test]
947    fn force_pushing_the_remote_default_branch_is_refused() {
948        let _guard = serial();
949        let scenario = Scenario::new();
950        // Rewrite `main`'s published tip in the main working tree.
951        scenario.git_in(&scenario.local, &["commit", "--amend", "-m", "rewritten"]);
952
953        let outcome = classify(&scenario.local);
954        assert_eq!(
955            outcome.result,
956            PushResult::Skipped {
957                reason: SkipReason::DefaultBranchForcePush
958            },
959            "a force-push to the default branch publishes a rewrite to everyone",
960        );
961    }
962
963    #[test]
964    fn fast_forwarding_the_remote_default_branch_stays_allowed() {
965        let _guard = serial();
966        let scenario = Scenario::new();
967        scenario.commit_in(&scenario.local, "file.txt", "more\n", "more");
968
969        assert_eq!(
970            classify(&scenario.local).result,
971            PushResult::WouldFastForward { ahead: 1 },
972            "an ordinary push to the default branch destroys nothing",
973        );
974    }
975
976    // ── execution against a real bare remote ──────────────────────────────
977
978    #[test]
979    fn a_rewritten_branch_is_force_pushed_with_the_lease() {
980        let _guard = serial();
981        let scenario = Scenario::new();
982        let wt = scenario.add_worktree("feature-a");
983        scenario.commit_in(&wt, "file.txt", "worktree first\n", "first");
984        scenario.publish(&wt, "feature-a");
985        scenario.git_in(&wt, &["commit", "--amend", "-m", "rewritten"]);
986        let rewritten = scenario.head_oid(&wt);
987
988        let plan = plan(&Selection::Paths(vec![wt])).unwrap();
989        assert!(plan.has_pending_pushes());
990        let outcomes = execute(plan, &PushOptions::default());
991
992        assert_eq!(outcomes.len(), 1);
993        assert_eq!(
994            outcomes[0].result,
995            PushResult::Pushed { forced: true },
996            "unexpected outcome: {:?}",
997            outcomes[0],
998        );
999        assert_eq!(
1000            scenario.origin_oid("refs/heads/feature-a"),
1001            Some(rewritten),
1002            "the remote must carry the rewritten tip",
1003        );
1004    }
1005
1006    #[test]
1007    fn an_unpublished_branch_is_created_with_its_upstream() {
1008        let _guard = serial();
1009        let scenario = Scenario::new();
1010        let wt = scenario.add_worktree("feature-a");
1011        scenario.commit_in(&wt, "file.txt", "new\n", "new work");
1012        let tip = scenario.head_oid(&wt);
1013
1014        let plan = plan(&Selection::Paths(vec![wt.clone()])).unwrap();
1015        let outcomes = execute(plan, &PushOptions::default());
1016
1017        assert_eq!(outcomes[0].result, PushResult::Created);
1018        assert_eq!(scenario.origin_oid("refs/heads/feature-a"), Some(tip));
1019        // `--set-upstream` recorded the tracking ref, so a re-plan sees it.
1020        assert_eq!(classify(&wt).result, PushResult::UpToDate);
1021    }
1022
1023    #[test]
1024    fn a_lease_refusal_is_reported_rather_than_forced_through() {
1025        let _guard = serial();
1026        let scenario = Scenario::new();
1027        let wt = scenario.add_worktree("feature-a");
1028        scenario.commit_in(&wt, "file.txt", "worktree first\n", "first");
1029        scenario.publish(&wt, "feature-a");
1030        let before = scenario.origin_oid("refs/heads/feature-a");
1031
1032        // Someone else advances the branch on the server; we never fetch, so our
1033        // remote-tracking ref — and therefore our lease — still names the old tip.
1034        scenario.advance_origin("refs/heads/feature-a", "theirs\n");
1035        let theirs = scenario.origin_oid("refs/heads/feature-a");
1036        assert_ne!(before, theirs);
1037
1038        scenario.git_in(&wt, &["commit", "--amend", "-m", "rewritten"]);
1039        let plan = plan(&Selection::Paths(vec![wt])).unwrap();
1040        let outcomes = execute(plan, &PushOptions::default());
1041
1042        match &outcomes[0].result {
1043            PushResult::Rejected { stale, detail } => assert!(
1044                *stale,
1045                "a lease refusal must be flagged so the report can name `git fetch`; got {detail:?}"
1046            ),
1047            other => panic!("expected the lease to refuse, got {other:?}"),
1048        }
1049        assert_eq!(
1050            scenario.origin_oid("refs/heads/feature-a"),
1051            theirs,
1052            "their commit must survive",
1053        );
1054    }
1055
1056    #[test]
1057    fn a_batch_continues_past_a_skipped_worktree() {
1058        let _guard = serial();
1059        let scenario = Scenario::new();
1060        let pushable = scenario.add_worktree("feature-a");
1061        scenario.commit_in(&pushable, "file.txt", "new\n", "new work");
1062        let detached = scenario.add_worktree("feature-b");
1063        scenario.git_in(&detached, &["checkout", "--detach"]);
1064
1065        let plan = plan(&Selection::Paths(vec![detached, pushable])).unwrap();
1066        let outcomes = execute(plan, &PushOptions::default());
1067
1068        assert_eq!(outcomes.len(), 2, "selection order is preserved");
1069        assert_eq!(
1070            outcomes[0].result,
1071            PushResult::Skipped {
1072                reason: SkipReason::DetachedHead
1073            },
1074        );
1075        assert_eq!(outcomes[1].result, PushResult::Created);
1076    }
1077
1078    #[test]
1079    fn all_selects_every_worktree_of_the_repository() {
1080        let _guard = serial();
1081        let scenario = Scenario::new();
1082        scenario.add_worktree("feature-a");
1083        scenario.add_worktree("feature-b");
1084
1085        let plan = plan(&Selection::All {
1086            base: scenario.local,
1087        })
1088        .unwrap();
1089        assert_eq!(
1090            plan.worktrees.len(),
1091            3,
1092            "the main working tree is a target like any other (ADR-0060)"
1093        );
1094    }
1095
1096    // ── remote fallback ───────────────────────────────────────────────────
1097
1098    #[test]
1099    fn a_repository_with_no_remote_has_nowhere_to_publish() {
1100        let _guard = serial();
1101        let dir = tempfile::tempdir().unwrap();
1102        let local = dir.path().join("solo");
1103        std::fs::create_dir_all(&local).unwrap();
1104        git_at(&local, &["init", "-b", "main"]);
1105        config_repo(&local, "Test", "test@example.com");
1106        std::fs::write(local.join("file.txt"), "x\n").unwrap();
1107        git_at(&local, &["add", "file.txt"]);
1108        git_at(&local, &["commit", "-m", "first"]);
1109
1110        assert_eq!(
1111            classify(&local).result,
1112            PushResult::Skipped {
1113                reason: SkipReason::NoRemote
1114            },
1115        );
1116    }
1117
1118    #[test]
1119    fn a_sole_non_origin_remote_is_the_fallback_destination() {
1120        // With no `origin` but exactly one remote, the destination is unambiguous
1121        // and an unpublished branch should publish there rather than be skipped.
1122        let _guard = serial();
1123        let dir = tempfile::tempdir().unwrap();
1124        let local = dir.path().join("solo");
1125        std::fs::create_dir_all(&local).unwrap();
1126        git_at(&local, &["init", "-b", "main"]);
1127        config_repo(&local, "Test", "test@example.com");
1128        std::fs::write(local.join("file.txt"), "x\n").unwrap();
1129        git_at(&local, &["add", "file.txt"]);
1130        git_at(&local, &["commit", "-m", "first"]);
1131        git_at(&local, &["remote", "add", "upstream", "/nonexistent.git"]);
1132
1133        let outcome = classify(&local);
1134        assert_eq!(outcome.result, PushResult::WouldCreate);
1135        assert_eq!(outcome.remote, "upstream");
1136    }
1137
1138    #[test]
1139    fn several_remotes_without_origin_are_too_ambiguous_to_guess() {
1140        let _guard = serial();
1141        let dir = tempfile::tempdir().unwrap();
1142        let local = dir.path().join("solo");
1143        std::fs::create_dir_all(&local).unwrap();
1144        git_at(&local, &["init", "-b", "main"]);
1145        config_repo(&local, "Test", "test@example.com");
1146        std::fs::write(local.join("file.txt"), "x\n").unwrap();
1147        git_at(&local, &["add", "file.txt"]);
1148        git_at(&local, &["commit", "-m", "first"]);
1149        git_at(&local, &["remote", "add", "upstream", "/a.git"]);
1150        git_at(&local, &["remote", "add", "fork", "/b.git"]);
1151
1152        assert_eq!(
1153            classify(&local).result,
1154            PushResult::Skipped {
1155                reason: SkipReason::NoRemote
1156            },
1157            "publishing to an arbitrary one of several remotes would be a guess",
1158        );
1159    }
1160
1161    // ── the git-subprocess seam (stubbed `git`) ───────────────────────────
1162
1163    /// A `WorktreeOutcome` pointing at `path`, pre-classified as `result`, for
1164    /// driving [`execute`] against a stubbed `git`.
1165    fn outcome_at(path: &Path, result: PushResult) -> WorktreeOutcome {
1166        WorktreeOutcome {
1167            path: path.to_path_buf(),
1168            branch: Some("feat".to_string()),
1169            remote: "origin".to_string(),
1170            remote_branch: "feat".to_string(),
1171            result,
1172        }
1173    }
1174
1175    /// Runs [`execute`] over one pre-classified outcome against a `git` stub whose
1176    /// body is `script`, and returns the resulting [`PushResult`].
1177    ///
1178    /// This is what [`PushOptions::git_bin`] exists for: the paths below are `git`
1179    /// behaving in ways a real remote cannot be made to reproduce on demand.
1180    fn execute_against_stub(script: &str, result: PushResult) -> PushResult {
1181        use crate::test_support::shim::{retry_on_etxtbsy, shim_lock, write_exec_script};
1182        let _guard = shim_lock();
1183        let dir = tempfile::tempdir().unwrap();
1184        let stub = dir.path().join("git-stub");
1185        write_exec_script(&stub, script);
1186
1187        let opts = PushOptions {
1188            git_bin: Some(stub),
1189        };
1190        let plan = Plan {
1191            worktrees: vec![outcome_at(dir.path(), result)],
1192        };
1193        // The shim is executed here, so absorb the write-then-exec `ETXTBSY` race.
1194        retry_on_etxtbsy(|| {
1195            let outcomes = execute(plan.clone(), &opts);
1196            let result = outcomes.into_iter().next().unwrap().result;
1197            match &result {
1198                PushResult::Rejected { detail, .. } if detail.contains("Text file busy") => {
1199                    Err(anyhow::anyhow!(std::io::Error::from_raw_os_error(26)))
1200                }
1201                _ => Ok(result),
1202            }
1203        })
1204        .unwrap()
1205    }
1206
1207    #[test]
1208    fn a_git_that_cannot_be_spawned_is_reported_not_panicked() {
1209        // The engine must degrade to a per-worktree `Rejected` rather than taking
1210        // the whole batch down — one unusable path never fails the rest.
1211        let dir = tempfile::tempdir().unwrap();
1212        let opts = PushOptions {
1213            git_bin: Some(dir.path().join("no-such-git")),
1214        };
1215        let plan = Plan {
1216            worktrees: vec![outcome_at(dir.path(), PushResult::WouldCreate)],
1217        };
1218
1219        let outcomes = execute(plan, &opts);
1220        assert!(
1221            matches!(
1222                &outcomes[0].result,
1223                PushResult::Rejected { stale: false, detail } if detail.contains("failed to execute")
1224            ),
1225            "unexpected outcome: {:?}",
1226            outcomes[0].result,
1227        );
1228    }
1229
1230    #[test]
1231    fn a_silent_successful_git_is_trusted_by_its_exit_status() {
1232        // `--porcelain` always emits a status line, so this is the "git exited 0
1233        // without saying anything" path. Each push flavour must still report the
1234        // outcome it asked for rather than collapsing to one.
1235        let cases = [
1236            (PushResult::WouldCreate, PushResult::Created),
1237            (
1238                PushResult::WouldForce {
1239                    ahead: 1,
1240                    behind: 1,
1241                },
1242                PushResult::Pushed { forced: true },
1243            ),
1244            (
1245                PushResult::WouldFastForward { ahead: 1 },
1246                PushResult::Pushed { forced: false },
1247            ),
1248        ];
1249        for (planned, expected) in cases {
1250            assert_eq!(
1251                execute_against_stub("#!/bin/sh\nexit 0\n", planned.clone()),
1252                expected,
1253                "for a planned {planned:?}",
1254            );
1255        }
1256    }
1257
1258    #[test]
1259    fn a_silent_failing_git_is_reported_with_its_stderr() {
1260        let result = execute_against_stub(
1261            "#!/bin/sh\necho 'fatal: could not read from remote' >&2\nexit 128\n",
1262            PushResult::WouldFastForward { ahead: 1 },
1263        );
1264        assert_eq!(
1265            result,
1266            PushResult::Rejected {
1267                detail: "fatal: could not read from remote".to_string(),
1268                stale: false,
1269            },
1270        );
1271    }
1272
1273    #[test]
1274    fn a_porcelain_status_line_wins_over_the_exit_status() {
1275        // git exits non-zero on a rejection but still reports *why* on stdout; the
1276        // parsed reason is what the user needs, not the bare exit code.
1277        let result = execute_against_stub(
1278            "#!/bin/sh\n\
1279             echo 'To /origin.git'\n\
1280             printf '!\\trefs/heads/feat:refs/heads/feat\\t[rejected] (stale info)\\n'\n\
1281             echo 'Done'\n\
1282             exit 1\n",
1283            PushResult::WouldForce {
1284                ahead: 1,
1285                behind: 1,
1286            },
1287        );
1288        assert_eq!(
1289            result,
1290            PushResult::Rejected {
1291                detail: "stale info".to_string(),
1292                stale: true,
1293            },
1294        );
1295    }
1296
1297    // ── fixture ───────────────────────────────────────────────────────────
1298
1299    /// A bare `origin` plus a local checkout of `main`, mirroring the rebase
1300    /// engine's fixture. The bare remote is what makes a *real* push — and a real
1301    /// lease refusal — testable without a network.
1302    struct Scenario {
1303        root: tempfile::TempDir,
1304        origin: PathBuf,
1305        local: PathBuf,
1306    }
1307
1308    impl Scenario {
1309        fn new() -> Self {
1310            let root = tempfile::tempdir().unwrap();
1311            let origin = root.path().join("origin.git");
1312            let local = root.path().join("local");
1313            std::fs::create_dir_all(&origin).unwrap();
1314            std::fs::create_dir_all(&local).unwrap();
1315            git_at(&origin, &["init", "--bare", "-b", "main"]);
1316            git_at(&local, &["init", "-b", "main"]);
1317            config_repo(&local, "Test", "test@example.com");
1318            std::fs::write(local.join("file.txt"), "first\n").unwrap();
1319            std::fs::write(local.join("keep.txt"), "keep\n").unwrap();
1320            git_at(&local, &["add", "file.txt", "keep.txt"]);
1321            git_at(&local, &["commit", "-m", "first"]);
1322            git_at(
1323                &local,
1324                &["remote", "add", "origin", origin.to_str().unwrap()],
1325            );
1326            git_at(&local, &["push", "-u", "origin", "main"]);
1327            Self {
1328                root,
1329                origin,
1330                local,
1331            }
1332        }
1333
1334        /// Adds a linked worktree branched off the current `main`.
1335        fn add_worktree(&self, name: &str) -> PathBuf {
1336            let path = self.root.path().join(name);
1337            git_at(
1338                &self.local,
1339                &[
1340                    "worktree",
1341                    "add",
1342                    "-b",
1343                    name,
1344                    path.to_str().unwrap(),
1345                    "main",
1346                ],
1347            );
1348            std::fs::canonicalize(&path).unwrap()
1349        }
1350
1351        /// Publishes a worktree's branch and records its upstream.
1352        fn publish(&self, wt: &Path, branch: &str) {
1353            git_at(wt, &["push", "-u", "origin", branch]);
1354        }
1355
1356        /// Commits a change inside a worktree.
1357        fn commit_in(&self, wt: &Path, file: &str, content: &str, msg: &str) {
1358            std::fs::write(wt.join(file), content).unwrap();
1359            git_at(wt, &["add", file]);
1360            git_at(wt, &["commit", "-m", msg]);
1361        }
1362
1363        /// Runs an arbitrary git command in a worktree.
1364        fn git_in(&self, wt: &Path, args: &[&str]) {
1365            git_at(wt, args);
1366        }
1367
1368        /// Advances a ref on the **server** with `git2`, writing straight into the
1369        /// bare origin's object database — the local repo only learns of it on a
1370        /// fetch it never performs, which is exactly the lease scenario.
1371        fn advance_origin(&self, refname: &str, content: &str) {
1372            let repo = Repository::open_bare(&self.origin).unwrap();
1373            let parent = repo
1374                .find_commit(repo.refname_to_id(refname).unwrap())
1375                .unwrap();
1376            let mut builder = repo.treebuilder(Some(&parent.tree().unwrap())).unwrap();
1377            let blob = repo.blob(content.as_bytes()).unwrap();
1378            builder.insert("file.txt", blob, 0o100_644).unwrap();
1379            let tree = repo.find_tree(builder.write().unwrap()).unwrap();
1380            let sig = git2::Signature::now("Other", "other@example.com").unwrap();
1381            repo.commit(Some(refname), &sig, &sig, "theirs", &tree, &[&parent])
1382                .unwrap();
1383        }
1384
1385        /// A ref's tip on the server, or `None` when it does not exist there.
1386        fn origin_oid(&self, refname: &str) -> Option<Oid> {
1387            Repository::open_bare(&self.origin)
1388                .unwrap()
1389                .refname_to_id(refname)
1390                .ok()
1391        }
1392
1393        /// A worktree's current HEAD commit.
1394        fn head_oid(&self, wt: &Path) -> Oid {
1395            Repository::open(wt)
1396                .unwrap()
1397                .head()
1398                .unwrap()
1399                .target()
1400                .unwrap()
1401        }
1402    }
1403
1404    fn config_repo(dir: &Path, name: &str, email: &str) {
1405        git_at(dir, &["config", "user.name", name]);
1406        git_at(dir, &["config", "user.email", email]);
1407        git_at(dir, &["config", "commit.gpgsign", "false"]);
1408    }
1409
1410    fn git_at(dir: &Path, args: &[&str]) {
1411        let output = run_git_in(&resolve_git_binary(), dir, args).unwrap();
1412        assert!(
1413            output.status.success(),
1414            "git {args:?} in {} failed: {}",
1415            dir.display(),
1416            String::from_utf8_lossy(&output.stderr)
1417        );
1418    }
1419}