Skip to main content

omni_dev/git/
worktree_rebase.rs

1//! Batch-rebase a repository's worktrees onto its remote default branch, fetching
2//! the remote **exactly once per repository** (issue #1400).
3//!
4//! Keeping a fan-out of feature worktrees current with `main` otherwise means
5//! `cd`-ing into each one and running `git fetch` + `git rebase origin/main` by
6//! hand. Doing that naively re-fetches the remote for every worktree, which is both
7//! wasteful (N network round-trips) and subtly wrong: if `origin/main` advances
8//! between fetches, different worktrees rebase onto different tips and stop sharing
9//! a base. Linked worktrees share one object database, so a single
10//! `git fetch <remote> <branch>` updates the `refs/remotes/<remote>/<branch>`
11//! tracking ref every worktree already sees — which is exactly why "fetch once per
12//! repo, then rebase each onto that pinned ref" is the natural design.
13//!
14//! **Split of concerns (see ADR-0003 and ADR-0055).** Every git *read* — enumerate
15//! the worktrees, resolve the onto ref, classify divergence / dirty / repo state —
16//! goes through `git2`. The two *mutations*, the fetch and the rebase, shell out to
17//! the user's `git`: libgit2's vendored build here has no reliable SSH transport
18//! (issue #903) and the shell inherits the user's `ssh-agent` / `~/.ssh/config` /
19//! credential-helper configuration for free, and `git rebase` brings full conflict
20//! handling, hooks, and `--autostash`.
21//!
22//! **Where it runs (ADR-0059, superseding ADR-0055 in part).** This engine is host
23//! agnostic: it drives both the local CLI (`omni-dev worktrees rebase`) and the
24//! daemon's two-phase `rebase` op. ADR-0055 originally confined it to the CLI on
25//! the premise that the daemon's minimal environment lacked `SSH_AUTH_SOCK` and so
26//! could not authenticate a fetch. That premise was wrong — launchd exports
27//! `SSH_AUTH_SOCK` into the per-user session, so a LaunchAgent inherits the user's
28//! `ssh-agent`. What the daemon genuinely lacks is a useful `PATH`, which is why
29//! the `git` binary is resolved through
30//! [`crate::git::resolve_git_binary`] rather than by name.
31
32use std::collections::BTreeMap;
33use std::path::{Path, PathBuf};
34
35use anyhow::{bail, Result};
36use git2::{Oid, Repository, RepositoryState, StatusOptions};
37use serde::Serialize;
38
39use crate::git::remote::RemoteInfo;
40use crate::git::resolve_git_binary;
41use crate::git::worktree_batch::{
42    head_branch, is_false, main_root, resolve_selection, run_git_in, trimmed_stderr,
43};
44
45pub use crate::git::worktree_batch::Selection;
46
47/// Knobs for a batch rebase.
48#[derive(Debug, Clone, Default)]
49pub struct RebaseOptions {
50    /// Override the rebase target ref (default: the repository's remote default
51    /// branch, e.g. `origin/main`). A `<remote>/<branch>` value is still fetched
52    /// once up front; a local ref or raw commit is used as-is (no fetch).
53    pub onto: Option<String>,
54    /// Stash uncommitted changes before each rebase and restore them after, rather
55    /// than skipping a dirty worktree.
56    pub autostash: bool,
57    /// Fetch and classify, but perform no rebase.
58    pub dry_run: bool,
59    /// Leave a conflicting worktree **mid-rebase** instead of `git rebase
60    /// --abort`-ing it (#1415).
61    ///
62    /// The default (abort) keeps the worktree exactly as it was, which is the
63    /// right conservative choice for a batch the user is watching scroll past. But
64    /// it also throws away every conflict already resolved by `git rerere` and
65    /// every hunk git applied cleanly before the collision — work the user then has
66    /// to reproduce by hand. With this set, the worktree stays in its conflicted
67    /// state so the conflict can be resolved in place and finished with
68    /// `git rebase --continue`, and the batch moves on to the next worktree
69    /// regardless.
70    pub keep_conflicts: bool,
71    /// The `git` executable to shell out to. `None` resolves it via
72    /// [`resolve_git_binary`], which is what a caller with a minimal `PATH` (the
73    /// daemon) needs; the field exists so a caller can resolve **once** and reuse,
74    /// and so a test can point the engine at a stub.
75    pub git_bin: Option<PathBuf>,
76}
77
78impl RebaseOptions {
79    /// The `git` executable these options select, resolving the default lazily.
80    /// Called once per [`plan`] / [`execute`] rather than per subprocess.
81    fn git_bin(&self) -> PathBuf {
82        self.git_bin.clone().unwrap_or_else(resolve_git_binary)
83    }
84}
85
86/// The result of planning and (optionally) executing a batch rebase.
87#[derive(Debug, Clone, Serialize)]
88pub struct Plan {
89    /// One entry per repository: the fetch that was performed (or, for a local
90    /// onto ref, skipped). The length is the fetch-once-per-repo count.
91    pub fetches: Vec<FetchOutcome>,
92    /// One entry per selected worktree, in selection order.
93    pub worktrees: Vec<WorktreeOutcome>,
94}
95
96impl Plan {
97    /// Whether any worktree still needs a rebase (a [`RebaseResult::WouldRebase`]).
98    /// The CLI uses this to decide whether to confirm and execute.
99    #[must_use]
100    pub fn has_pending_rebases(&self) -> bool {
101        self.worktrees
102            .iter()
103            .any(|w| matches!(w.result, RebaseResult::WouldRebase { .. }))
104    }
105}
106
107/// The one-shot fetch performed for a single repository.
108#[derive(Debug, Clone, Serialize)]
109pub struct FetchOutcome {
110    /// The main working-tree root of the repository (the fetch is run here).
111    pub repo_root: PathBuf,
112    /// The resolved onto ref this repository's worktrees rebase onto.
113    pub onto: String,
114    /// Whether a fetch was actually run (false for a local onto ref).
115    pub fetched: bool,
116    /// Whether the fetch succeeded (always true when `fetched` is false).
117    pub ok: bool,
118    /// The `git fetch` error, when it failed.
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub detail: Option<String>,
121}
122
123/// What happened (or, in a dry run, would happen) to one worktree.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
125pub struct WorktreeOutcome {
126    /// The worktree folder.
127    pub path: PathBuf,
128    /// The checked-out branch, when on one (absent for a detached/unborn HEAD).
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub branch: Option<String>,
131    /// The ref this worktree rebases onto (empty when it could not be resolved).
132    pub onto: String,
133    /// The classification / outcome.
134    #[serde(flatten)]
135    pub result: RebaseResult,
136}
137
138/// The per-worktree classification and outcome.
139///
140/// [`plan`] only ever produces `WouldRebase` / `UpToDate` / `Skipped` /
141/// `FetchFailed`; [`execute`] turns each `WouldRebase` into `Rebased`
142/// or `Conflict`.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
144#[serde(tag = "status", rename_all = "kebab-case")]
145pub enum RebaseResult {
146    /// Rebased onto the fetched ref; it was `behind` commits behind beforehand.
147    Rebased {
148        /// Commits the worktree was behind the onto ref before the rebase.
149        behind: usize,
150    },
151    /// Would be rebased (dry run); it is `behind` commits behind.
152    WouldRebase {
153        /// Commits the worktree is behind the onto ref.
154        behind: usize,
155    },
156    /// Already on top of the onto ref — nothing to do.
157    UpToDate,
158    /// Skipped without touching the worktree, for a structural reason.
159    Skipped {
160        /// Why it was skipped.
161        reason: SkipReason,
162    },
163    /// The rebase hit conflicts. By default it was aborted and the worktree left
164    /// untouched; with [`RebaseOptions::keep_conflicts`] the worktree is instead
165    /// left mid-rebase, which `left_in_place` records.
166    Conflict {
167        /// The `git rebase` error output (trimmed).
168        detail: String,
169        /// Whether the worktree was **left mid-rebase** for the user to resolve
170        /// (`true`) rather than aborted back to its previous state (`false`).
171        /// Omitted on the wire when false, so a pre-#1415 client sees the exact
172        /// bytes it saw before.
173        #[serde(skip_serializing_if = "is_false")]
174        left_in_place: bool,
175    },
176    /// The repository's one-shot fetch failed, so no worktree of it was attempted.
177    FetchFailed {
178        /// The fetch error (trimmed).
179        detail: String,
180    },
181}
182
183/// Why a worktree was skipped rather than rebased.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
185#[serde(rename_all = "kebab-case")]
186pub enum SkipReason {
187    /// A detached or unborn HEAD — there is no branch to rebase.
188    DetachedHead,
189    /// Uncommitted changes to tracked files (pass `--autostash` to rebase anyway).
190    Dirty,
191    /// A rebase/merge/cherry-pick is already in progress.
192    OperationInProgress,
193    /// The path is not a git worktree.
194    NotAWorktree,
195    /// The onto ref could not be resolved (e.g. no remote default branch).
196    NoOntoRef,
197}
198
199/// Plans a batch rebase, fetching each repository's onto ref **exactly once**.
200///
201/// Enumerates the selected worktrees, resolves the onto ref and fetches it once per
202/// repository, then classifies each worktree against the freshly fetched ref. The
203/// returned [`Plan`] is exactly what a dry run reports; a real run passes it to
204/// [`execute`].
205///
206/// The fetch runs even for a dry run: it is non-destructive (it only advances the
207/// shared remote-tracking ref) and is what pins the single snapshot every worktree
208/// is measured — and would be rebased — against.
209pub fn plan(selection: &Selection, opts: &RebaseOptions) -> Result<Plan> {
210    let paths = resolve_selection(selection)?;
211    // Resolved once for the whole plan, not per subprocess: the probe stats the
212    // candidate paths, and the answer is process-stable.
213    let git = opts.git_bin();
214
215    // Phase A — inspect each path with git2 (no network): structural facts + HEAD.
216    let inspected: Vec<Inspected> = paths.iter().map(|p| Inspected::read(p)).collect();
217
218    // Phase B — resolve the onto ref once per distinct repository.
219    let onto_by_repo = resolve_onto_by_repo(&inspected, opts.onto.as_deref());
220
221    // Phase C — fetch once per repository (the fetch-once-per-repo invariant).
222    let (fetches, fetch_ok) = fetch_all(&git, &onto_by_repo);
223
224    // Phase D — classify each worktree against the now-fresh refs.
225    let worktrees = inspected
226        .iter()
227        .map(|i| i.classify(&onto_by_repo, &fetch_ok, opts.autostash))
228        .collect();
229
230    Ok(Plan { fetches, worktrees })
231}
232
233/// Executes a [`Plan`], rebasing every [`RebaseResult::WouldRebase`] worktree.
234///
235/// The rest pass through unchanged. Rebases run sequentially (deterministic output;
236/// no contention on the shared object database). A conflicting rebase is aborted so
237/// the worktree is left exactly as it was — unless
238/// [`RebaseOptions::keep_conflicts`] is set, in which case it is left mid-rebase.
239/// Either way the batch continues with the remaining worktrees.
240#[must_use]
241pub fn execute(plan: Plan, opts: &RebaseOptions) -> Vec<WorktreeOutcome> {
242    let git = opts.git_bin();
243    plan.worktrees
244        .into_iter()
245        .map(|mut outcome| {
246            if let RebaseResult::WouldRebase { behind } = outcome.result {
247                outcome.result = match rebase_worktree(&git, &outcome.path, &outcome.onto, opts) {
248                    Ok(()) => RebaseResult::Rebased { behind },
249                    Err(detail) => RebaseResult::Conflict {
250                        detail,
251                        left_in_place: opts.keep_conflicts,
252                    },
253                };
254            }
255            outcome
256        })
257        .collect()
258}
259
260// ── inspection (git2 reads) ──────────────────────────────────────────────────
261
262/// A single selected path after its structural git2 inspection.
263enum Inspected {
264    /// The path resolved to a git worktree.
265    Ok(Inspection),
266    /// The path is not a git worktree (or does not exist).
267    Unresolvable {
268        /// The path, as given (best-effort canonicalized).
269        path: PathBuf,
270    },
271}
272
273/// The structural facts read from one worktree, independent of any fetch. Whether
274/// this is the main working tree is deliberately not among them (ADR-0060) — the
275/// rebase engine no longer distinguishes it from a linked worktree.
276struct Inspection {
277    path: PathBuf,
278    repo_root: PathBuf,
279    branch: Option<String>,
280    head_oid: Option<Oid>,
281    state_clean: bool,
282    dirty: bool,
283}
284
285impl Inspected {
286    /// Inspects `path` with git2, degrading a non-worktree path to
287    /// [`Inspected::Unresolvable`] rather than an error so one bad path never fails
288    /// the whole batch.
289    fn read(path: &Path) -> Self {
290        let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
291        let Ok(repo) = Repository::discover(&canon) else {
292            return Self::Unresolvable { path: canon };
293        };
294        let repo_root = main_root(&repo);
295        let (branch, head_oid) = head_branch(&repo);
296        let state_clean = repo.state() == RepositoryState::Clean;
297        let dirty = is_dirty(&repo);
298        Self::Ok(Inspection {
299            path: canon,
300            repo_root,
301            branch,
302            head_oid,
303            state_clean,
304            dirty,
305        })
306    }
307
308    /// The repository root, for the resolvable case (used to group fetches).
309    fn repo_root(&self) -> Option<&Path> {
310        match self {
311            Self::Ok(i) => Some(&i.repo_root),
312            Self::Unresolvable { .. } => None,
313        }
314    }
315
316    /// Classifies this worktree against the resolved onto refs and fetch outcomes.
317    fn classify(
318        &self,
319        onto_by_repo: &BTreeMap<PathBuf, OntoSpec>,
320        fetch_ok: &BTreeMap<PathBuf, bool>,
321        autostash: bool,
322    ) -> WorktreeOutcome {
323        let i = match self {
324            Self::Unresolvable { path } => {
325                return WorktreeOutcome::skipped(
326                    path.clone(),
327                    None,
328                    String::new(),
329                    SkipReason::NotAWorktree,
330                );
331            }
332            Self::Ok(i) => i,
333        };
334
335        let onto = onto_by_repo.get(&i.repo_root);
336        let onto_display = onto.map_or_else(String::new, |s| s.display.clone());
337        let branch = i.branch.clone();
338        let skip = |reason| {
339            WorktreeOutcome::skipped(i.path.clone(), branch.clone(), onto_display.clone(), reason)
340        };
341
342        // Structural skips (independent of the fetch), safe-before-destructive order.
343        // The main working tree is not exempted here (ADR-0060) — it is classified
344        // exactly like any other worktree.
345        let (Some(head), Some(_)) = (i.head_oid, i.branch.as_ref()) else {
346            return skip(SkipReason::DetachedHead);
347        };
348        if !i.state_clean {
349            return skip(SkipReason::OperationInProgress);
350        }
351        if i.dirty && !autostash {
352            return skip(SkipReason::Dirty);
353        }
354        let Some(onto) = onto else {
355            return skip(SkipReason::NoOntoRef);
356        };
357
358        // The repository's single fetch must have succeeded.
359        if fetch_ok.get(&i.repo_root) == Some(&false) {
360            let detail = "the repository's fetch failed".to_string();
361            return WorktreeOutcome {
362                path: i.path.clone(),
363                branch,
364                onto: onto_display,
365                result: RebaseResult::FetchFailed { detail },
366            };
367        }
368
369        // Divergence against the freshly fetched ref.
370        match behind_count(&i.repo_root, head, &onto.display) {
371            None => skip(SkipReason::NoOntoRef),
372            Some(0) => WorktreeOutcome {
373                path: i.path.clone(),
374                branch,
375                onto: onto_display,
376                result: RebaseResult::UpToDate,
377            },
378            Some(behind) => WorktreeOutcome {
379                path: i.path.clone(),
380                branch,
381                onto: onto_display,
382                result: RebaseResult::WouldRebase { behind },
383            },
384        }
385    }
386}
387
388impl WorktreeOutcome {
389    /// A `Skipped` outcome.
390    fn skipped(path: PathBuf, branch: Option<String>, onto: String, reason: SkipReason) -> Self {
391        Self {
392            path,
393            branch,
394            onto,
395            result: RebaseResult::Skipped { reason },
396        }
397    }
398}
399
400/// Whether the worktree has uncommitted changes to **tracked** files (staged or
401/// unstaged). Untracked and ignored files do not block a rebase, so they are
402/// excluded.
403fn is_dirty(repo: &Repository) -> bool {
404    let mut opts = StatusOptions::new();
405    opts.include_untracked(false)
406        .include_ignored(false)
407        .exclude_submodules(true);
408    repo.statuses(Some(&mut opts))
409        .is_ok_and(|statuses| !statuses.is_empty())
410}
411
412/// Commits `onto` is ahead of `head` (i.e. how far `head` is behind `onto`), or
413/// `None` when `onto` does not resolve to a commit in the repository at `repo_root`.
414fn behind_count(repo_root: &Path, head: Oid, onto: &str) -> Option<usize> {
415    let repo = Repository::open(repo_root).ok()?;
416    let onto_oid = repo.revparse_single(onto).ok()?.peel_to_commit().ok()?.id();
417    let (_ahead, behind) = repo.graph_ahead_behind(head, onto_oid).ok()?;
418    Some(behind)
419}
420
421// ── onto resolution ──────────────────────────────────────────────────────────
422
423/// The rebase target for one repository.
424#[derive(Debug, Clone, PartialEq, Eq)]
425struct OntoSpec {
426    /// The git revspec every worktree of this repo rebases onto (e.g. `origin/main`).
427    display: String,
428    /// `Some((remote, branch))` when `display` is a remote-tracking ref to fetch
429    /// once up front; `None` for a local ref / raw commit (no fetch).
430    fetch: Option<(String, String)>,
431}
432
433/// Resolves the onto ref for every distinct repository among the resolvable
434/// inspections. The map's one-entry-per-root shape **is** the fetch-once-per-repo
435/// grouping: N worktrees of one repo collapse to a single entry.
436fn resolve_onto_by_repo(
437    inspected: &[Inspected],
438    override_ref: Option<&str>,
439) -> BTreeMap<PathBuf, OntoSpec> {
440    let mut map: BTreeMap<PathBuf, OntoSpec> = BTreeMap::new();
441    for root in inspected.iter().filter_map(Inspected::repo_root) {
442        if map.contains_key(root) {
443            continue;
444        }
445        if let Ok(repo) = Repository::open(root) {
446            map.insert(root.to_path_buf(), resolve_onto(&repo, override_ref));
447        }
448    }
449    map
450}
451
452/// The onto spec for a single repository: the `--onto` override if given, else the
453/// remote (`origin`) default branch resolved from local refs.
454fn resolve_onto(repo: &Repository, override_ref: Option<&str>) -> OntoSpec {
455    if let Some(reference) = override_ref {
456        return onto_from_override(repo, reference);
457    }
458    let remote = "origin";
459    let branch =
460        RemoteInfo::detect_main_branch_local(repo, remote).unwrap_or_else(|| "main".to_string());
461    OntoSpec {
462        display: format!("{remote}/{branch}"),
463        fetch: Some((remote.to_string(), branch)),
464    }
465}
466
467/// Interprets an explicit `--onto` value: a `<remote>/<branch>` whose first segment
468/// is a configured remote is fetched once; anything else (a local branch, a raw
469/// commit) is used verbatim with no fetch.
470fn onto_from_override(repo: &Repository, reference: &str) -> OntoSpec {
471    if let Some((remote, branch)) = reference.split_once('/') {
472        if repo.find_remote(remote).is_ok() {
473            return OntoSpec {
474                display: reference.to_string(),
475                fetch: Some((remote.to_string(), branch.to_string())),
476            };
477        }
478    }
479    OntoSpec {
480        display: reference.to_string(),
481        fetch: None,
482    }
483}
484
485// ── fetch (shell-out, once per repo) ─────────────────────────────────────────
486
487/// Fetches each repository's onto ref once, returning the per-repo outcomes and a
488/// `root -> ok` map the classifier consults. A repo with a local onto ref records a
489/// `fetched: false, ok: true` entry.
490fn fetch_all(
491    git: &Path,
492    onto_by_repo: &BTreeMap<PathBuf, OntoSpec>,
493) -> (Vec<FetchOutcome>, BTreeMap<PathBuf, bool>) {
494    let mut fetches = Vec::new();
495    let mut fetch_ok = BTreeMap::new();
496    for (root, spec) in onto_by_repo {
497        let outcome = match &spec.fetch {
498            Some((remote, branch)) => {
499                let result = fetch_once(git, root, remote, branch);
500                let ok = result.is_ok();
501                FetchOutcome {
502                    repo_root: root.clone(),
503                    onto: spec.display.clone(),
504                    fetched: true,
505                    ok,
506                    detail: result.err().map(|e| e.to_string()),
507                }
508            }
509            None => FetchOutcome {
510                repo_root: root.clone(),
511                onto: spec.display.clone(),
512                fetched: false,
513                ok: true,
514                detail: None,
515            },
516        };
517        fetch_ok.insert(root.clone(), outcome.ok);
518        fetches.push(outcome);
519    }
520    (fetches, fetch_ok)
521}
522
523/// Runs `git fetch <remote> <branch>` once in `repo_root`. The shared object
524/// database means this single fetch updates the tracking ref every worktree sees.
525fn fetch_once(git: &Path, repo_root: &Path, remote: &str, branch: &str) -> Result<()> {
526    let output = run_git_in(git, repo_root, &["fetch", remote, branch])?;
527    if output.status.success() {
528        return Ok(());
529    }
530    bail!(
531        "git fetch {remote} {branch} failed: {}",
532        trimmed_stderr(&output)
533    )
534}
535
536// ── rebase (shell-out, per worktree) ─────────────────────────────────────────
537
538/// Rebases the branch checked out in `path` onto `onto`, returning the trimmed
539/// error on failure (a conflict, or anything else).
540///
541/// By default the rebase is aborted on failure so the worktree is left exactly as
542/// it was; with `autostash`, `git rebase --abort` also restores the stashed
543/// changes. With [`RebaseOptions::keep_conflicts`] the abort is **skipped** and the
544/// worktree stays mid-rebase for in-place resolution — including any autostash
545/// entry, which git re-applies when the rebase eventually concludes (via
546/// `--continue` or a later `--abort`), exactly as for a hand-run rebase.
547fn rebase_worktree(
548    git: &Path,
549    path: &Path,
550    onto: &str,
551    opts: &RebaseOptions,
552) -> std::result::Result<(), String> {
553    let args = rebase_args(onto, opts.autostash);
554    let argv: Vec<&str> = args.iter().map(String::as_str).collect();
555    match run_git_in(git, path, &argv) {
556        Ok(output) if output.status.success() => Ok(()),
557        Ok(output) => {
558            let detail = trimmed_stderr(&output);
559            if !opts.keep_conflicts {
560                // Best-effort abort; harmlessly errors if no rebase is in progress.
561                let _ = run_git_in(git, path, &["rebase", "--abort"]);
562            }
563            Err(detail)
564        }
565        Err(err) => Err(err.to_string()),
566    }
567}
568
569/// The `git rebase` argument vector, with `--autostash` inserted when requested.
570/// Pure, so the argument shape is unit-testable.
571fn rebase_args(onto: &str, autostash: bool) -> Vec<String> {
572    let mut args = vec!["rebase".to_string()];
573    if autostash {
574        args.push("--autostash".to_string());
575    }
576    args.push(onto.to_string());
577    args
578}
579
580#[cfg(test)]
581#[allow(clippy::unwrap_used, clippy::expect_used)]
582mod tests {
583    use super::*;
584
585    use crate::git::worktree_batch::all_worktree_paths;
586
587    /// The shared git-load serialization guard (see
588    /// [`crate::git::worktree_batch::test_serial_lock`]).
589    fn serial() -> std::sync::MutexGuard<'static, ()> {
590        crate::git::worktree_batch::test_serial_lock()
591    }
592
593    // ── pure helpers ──────────────────────────────────────────────────────
594
595    #[test]
596    fn rebase_args_omits_autostash_by_default() {
597        assert_eq!(
598            rebase_args("origin/main", false),
599            vec!["rebase", "origin/main"]
600        );
601    }
602
603    #[test]
604    fn rebase_args_inserts_autostash_before_the_ref() {
605        assert_eq!(
606            rebase_args("origin/main", true),
607            vec!["rebase", "--autostash", "origin/main"]
608        );
609    }
610
611    #[test]
612    fn onto_from_override_fetches_a_remote_tracking_ref() {
613        let (_dir, repo) = repo_with_origin();
614        let spec = onto_from_override(&repo, "origin/release");
615        assert_eq!(spec.display, "origin/release");
616        assert_eq!(
617            spec.fetch,
618            Some(("origin".to_string(), "release".to_string()))
619        );
620    }
621
622    #[test]
623    fn onto_from_override_keeps_a_multi_segment_branch_whole() {
624        let (_dir, repo) = repo_with_origin();
625        let spec = onto_from_override(&repo, "origin/feature/foo");
626        assert_eq!(
627            spec.fetch,
628            Some(("origin".to_string(), "feature/foo".to_string()))
629        );
630    }
631
632    #[test]
633    fn onto_from_override_does_not_fetch_a_local_ref() {
634        let (_dir, repo) = repo_with_origin();
635        // `develop` has no `/`, and `upstream/x`'s first segment is not a remote.
636        assert_eq!(onto_from_override(&repo, "develop").fetch, None);
637        assert_eq!(onto_from_override(&repo, "upstream/x").fetch, None);
638        assert_eq!(onto_from_override(&repo, "HEAD~2").fetch, None);
639    }
640
641    #[test]
642    fn resolve_onto_defaults_to_origin_main() {
643        let (_dir, repo) = repo_with_origin();
644        let spec = resolve_onto(&repo, None);
645        assert_eq!(spec.display, "origin/main");
646        assert_eq!(spec.fetch, Some(("origin".to_string(), "main".to_string())));
647    }
648
649    // ── the fetch-once-per-repo invariant ─────────────────────────────────
650
651    #[test]
652    fn one_repo_with_many_worktrees_fetches_exactly_once() {
653        // The main tree plus three linked worktrees sharing one repository must
654        // yield a single fetch entry — the whole point of #1400.
655        let _guard = serial();
656        let scenario = Scenario::new();
657        scenario.add_worktree("feature-a");
658        scenario.add_worktree("feature-b");
659        scenario.add_worktree("feature-c");
660
661        let plan = plan(
662            &Selection::All {
663                base: scenario.local.clone(),
664            },
665            &RebaseOptions::default(),
666        )
667        .unwrap();
668
669        assert_eq!(
670            plan.fetches.len(),
671            1,
672            "fetch must run once per repo, not per worktree"
673        );
674        assert_eq!(
675            plan.worktrees.len(),
676            4,
677            "--all now includes the main working tree alongside its three linked \
678             worktrees (#1438)"
679        );
680        let main_canon = std::fs::canonicalize(&scenario.local).unwrap();
681        assert!(plan.worktrees.iter().any(|w| w.path == main_canon));
682        assert!(plan.fetches[0].ok);
683    }
684
685    #[test]
686    fn all_worktree_paths_includes_the_main_working_tree() {
687        let _guard = serial();
688        let scenario = Scenario::new();
689        scenario.add_worktree("feature-a");
690        let paths = all_worktree_paths(&scenario.local).unwrap();
691        assert_eq!(paths.len(), 2);
692        assert!(paths.contains(&std::fs::canonicalize(&scenario.local).unwrap()));
693    }
694
695    #[test]
696    fn resolve_onto_by_repo_collapses_worktrees_of_one_repo() {
697        let _guard = serial();
698        let scenario = Scenario::new();
699        scenario.add_worktree("feature-a");
700        scenario.add_worktree("feature-b");
701        let paths = all_worktree_paths(&scenario.local).unwrap();
702        let inspected: Vec<Inspected> = paths.iter().map(|p| Inspected::read(p)).collect();
703        let map = resolve_onto_by_repo(&inspected, None);
704        assert_eq!(
705            map.len(),
706            1,
707            "the main tree and two linked worktrees of one repo resolve to one onto \
708             entry"
709        );
710    }
711
712    // ── end-to-end classify + execute ─────────────────────────────────────
713
714    #[test]
715    fn behind_worktree_is_rebased_onto_the_fetched_ref() {
716        let _guard = serial();
717        let scenario = Scenario::new();
718        let wt = scenario.add_worktree("feature");
719        // Advance origin/main by one commit from a separate clone, so the local
720        // tracking ref only learns of it on fetch.
721        scenario.advance_origin_main("second\n");
722
723        let plan = plan(
724            &Selection::Paths(vec![wt.clone()]),
725            &RebaseOptions::default(),
726        )
727        .unwrap();
728        assert_eq!(plan.worktrees.len(), 1);
729        assert_eq!(
730            plan.worktrees[0].result,
731            RebaseResult::WouldRebase { behind: 1 },
732            "the feature worktree is one commit behind the fetched origin/main"
733        );
734
735        let outcomes = execute(plan, &RebaseOptions::default());
736        assert_eq!(outcomes[0].result, RebaseResult::Rebased { behind: 1 });
737        // The worktree is clean and its branch now contains origin/main's commit.
738        assert!(head_contains(&wt, &scenario.origin_main_oid()));
739    }
740
741    #[test]
742    fn up_to_date_worktree_is_not_rebased() {
743        let _guard = serial();
744        let scenario = Scenario::new();
745        let wt = scenario.add_worktree("feature");
746        // No advance: feature sits on origin/main already.
747        let plan = plan(&Selection::Paths(vec![wt]), &RebaseOptions::default()).unwrap();
748        assert_eq!(plan.worktrees[0].result, RebaseResult::UpToDate);
749        assert!(!plan.has_pending_rebases());
750    }
751
752    #[test]
753    fn dirty_worktree_is_skipped_but_autostash_rebases_it() {
754        let _guard = serial();
755        let scenario = Scenario::new();
756        let wt = scenario.add_worktree("feature");
757        scenario.advance_origin_main("second\n");
758        // Dirty a file `origin/main` does not touch, so the autostash pop is clean.
759        std::fs::write(wt.join("keep.txt"), "dirty change\n").unwrap();
760
761        let skipped = plan(
762            &Selection::Paths(vec![wt.clone()]),
763            &RebaseOptions::default(),
764        )
765        .unwrap();
766        assert_eq!(
767            skipped.worktrees[0].result,
768            RebaseResult::Skipped {
769                reason: SkipReason::Dirty
770            }
771        );
772
773        let opts = RebaseOptions {
774            autostash: true,
775            ..RebaseOptions::default()
776        };
777        let planned = plan(&Selection::Paths(vec![wt.clone()]), &opts).unwrap();
778        assert_eq!(
779            planned.worktrees[0].result,
780            RebaseResult::WouldRebase { behind: 1 }
781        );
782        let outcomes = execute(planned, &opts);
783        assert_eq!(outcomes[0].result, RebaseResult::Rebased { behind: 1 });
784        // Autostash restored the local edit on top of the rebased branch.
785        assert_eq!(
786            std::fs::read_to_string(wt.join("keep.txt")).unwrap(),
787            "dirty change\n"
788        );
789    }
790
791    #[test]
792    fn main_working_tree_is_rebased_like_any_worktree() {
793        let _guard = serial();
794        let scenario = Scenario::new();
795        // Advance origin/main by one commit, so the main tree is behind on fetch.
796        scenario.advance_origin_main("second\n");
797
798        let plan = plan(
799            &Selection::Paths(vec![scenario.local.clone()]),
800            &RebaseOptions::default(),
801        )
802        .unwrap();
803        assert_eq!(
804            plan.worktrees[0].result,
805            RebaseResult::WouldRebase { behind: 1 },
806            "the main working tree is a valid rebase target like any other (#1438)"
807        );
808
809        let outcomes = execute(plan, &RebaseOptions::default());
810        assert_eq!(outcomes[0].result, RebaseResult::Rebased { behind: 1 });
811        assert!(head_contains(&scenario.local, &scenario.origin_main_oid()));
812    }
813
814    #[test]
815    fn non_worktree_path_is_skipped_not_fatal() {
816        let dir = tempfile::tempdir().unwrap();
817        let plan = plan(
818            &Selection::Paths(vec![dir.path().to_path_buf()]),
819            &RebaseOptions::default(),
820        )
821        .unwrap();
822        assert_eq!(
823            plan.worktrees[0].result,
824            RebaseResult::Skipped {
825                reason: SkipReason::NotAWorktree
826            }
827        );
828    }
829
830    #[test]
831    fn conflicting_rebase_aborts_and_leaves_the_worktree_untouched() {
832        let _guard = serial();
833        let scenario = Scenario::new();
834        let wt = scenario.add_worktree("feature");
835        // Feature edits file.txt; origin/main edits the same line differently.
836        scenario.commit_in_worktree(&wt, "file.txt", "feature side\n", "feature edit");
837        scenario.advance_origin_main("main side\n");
838        let head_before = head_oid(&wt);
839
840        let plan = plan(
841            &Selection::Paths(vec![wt.clone()]),
842            &RebaseOptions::default(),
843        )
844        .unwrap();
845        assert!(matches!(
846            plan.worktrees[0].result,
847            RebaseResult::WouldRebase { .. }
848        ));
849        let outcomes = execute(plan, &RebaseOptions::default());
850        assert!(
851            matches!(
852                outcomes[0].result,
853                RebaseResult::Conflict {
854                    left_in_place: false,
855                    ..
856                }
857            ),
858            "a conflicting rebase is reported, not silently half-applied"
859        );
860        // Aborted: HEAD unchanged and no rebase left in progress.
861        assert_eq!(head_oid(&wt), head_before);
862        let repo = Repository::open(&wt).unwrap();
863        assert_eq!(repo.state(), RepositoryState::Clean);
864    }
865
866    #[test]
867    fn keep_conflicts_leaves_the_worktree_mid_rebase() {
868        // The inverse of the test above (#1415): the conflicted worktree must be
869        // left in its conflicted state so the user can resolve it in place, rather
870        // than aborted back to where it started.
871        let _guard = serial();
872        let scenario = Scenario::new();
873        let wt = scenario.add_worktree("feature");
874        scenario.commit_in_worktree(&wt, "file.txt", "feature side\n", "feature edit");
875        scenario.advance_origin_main("main side\n");
876
877        let opts = RebaseOptions {
878            keep_conflicts: true,
879            ..RebaseOptions::default()
880        };
881        let plan = plan(&Selection::Paths(vec![wt.clone()]), &opts).unwrap();
882        let outcomes = execute(plan, &opts);
883        assert!(
884            matches!(
885                outcomes[0].result,
886                RebaseResult::Conflict {
887                    left_in_place: true,
888                    ..
889                }
890            ),
891            "the outcome records that the worktree was left mid-rebase"
892        );
893        // The load-bearing assertion: a rebase really is still in progress, which
894        // is what makes `git rebase --continue` (and the tree's cue) meaningful.
895        let repo = Repository::open(&wt).unwrap();
896        assert_ne!(
897            repo.state(),
898            RepositoryState::Clean,
899            "the worktree must still be mid-rebase, not aborted back to clean"
900        );
901        // And the conflict markers are on disk for the user to resolve.
902        let conflicted = std::fs::read_to_string(wt.join("file.txt")).unwrap();
903        assert!(
904            conflicted.contains("<<<<<<<"),
905            "expected conflict markers, got: {conflicted}"
906        );
907    }
908
909    #[test]
910    fn a_kept_conflict_does_not_stop_the_rest_of_the_batch() {
911        // A conflicting worktree left in place must not sink its siblings: the
912        // batch continues, and the next worktree still rebases.
913        let _guard = serial();
914        let scenario = Scenario::new();
915        let clashing = scenario.add_worktree("clashing");
916        let clean = scenario.add_worktree("clean");
917        scenario.commit_in_worktree(&clashing, "file.txt", "feature side\n", "feature edit");
918        scenario.advance_origin_main("main side\n");
919
920        let opts = RebaseOptions {
921            keep_conflicts: true,
922            ..RebaseOptions::default()
923        };
924        let plan = plan(&Selection::Paths(vec![clashing, clean.clone()]), &opts).unwrap();
925        let outcomes = execute(plan, &opts);
926        assert!(matches!(
927            outcomes[0].result,
928            RebaseResult::Conflict {
929                left_in_place: true,
930                ..
931            }
932        ));
933        assert_eq!(
934            outcomes[1].result,
935            RebaseResult::Rebased { behind: 1 },
936            "the second worktree rebases despite the first being left conflicted"
937        );
938        assert!(head_contains(&clean, &scenario.origin_main_oid()));
939    }
940
941    #[test]
942    fn left_in_place_is_omitted_from_json_when_false() {
943        // Forward-compatibility: an aborted conflict must serialize byte-identically
944        // to the pre-#1415 shape, so an older client is unaffected.
945        let aborted = serde_json::to_value(RebaseResult::Conflict {
946            detail: "boom".to_string(),
947            left_in_place: false,
948        })
949        .unwrap();
950        assert_eq!(aborted["status"], "conflict");
951        assert!(aborted.get("left_in_place").is_none());
952
953        let kept = serde_json::to_value(RebaseResult::Conflict {
954            detail: "boom".to_string(),
955            left_in_place: true,
956        })
957        .unwrap();
958        assert_eq!(kept["left_in_place"], true);
959    }
960
961    #[test]
962    fn git_bin_defaults_to_the_resolver_and_honours_an_override() {
963        assert_eq!(
964            RebaseOptions::default().git_bin(),
965            crate::git::resolve_git_binary(),
966            "an unset git_bin falls back to the shared resolver"
967        );
968        let opts = RebaseOptions {
969            git_bin: Some(PathBuf::from("/custom/git")),
970            ..RebaseOptions::default()
971        };
972        assert_eq!(opts.git_bin(), PathBuf::from("/custom/git"));
973    }
974
975    #[test]
976    fn dry_run_fetches_but_rebases_nothing() {
977        let _guard = serial();
978        let scenario = Scenario::new();
979        let wt = scenario.add_worktree("feature");
980        scenario.advance_origin_main("second\n");
981        let head_before = head_oid(&wt);
982
983        let opts = RebaseOptions {
984            dry_run: true,
985            ..RebaseOptions::default()
986        };
987        let plan = plan(&Selection::Paths(vec![wt.clone()]), &opts).unwrap();
988        // Planned as would-rebase, and the fetch did happen (tracking ref advanced),
989        // but we do not call execute in a dry run.
990        assert_eq!(
991            plan.worktrees[0].result,
992            RebaseResult::WouldRebase { behind: 1 }
993        );
994        assert_eq!(plan.fetches.len(), 1);
995        assert!(plan.fetches[0].fetched && plan.fetches[0].ok);
996        assert_eq!(
997            head_oid(&wt),
998            head_before,
999            "dry run must not move the branch"
1000        );
1001    }
1002
1003    #[test]
1004    fn json_shape_is_kebab_tagged() {
1005        let outcome = WorktreeOutcome {
1006            path: PathBuf::from("/wt"),
1007            branch: Some("feature".to_string()),
1008            onto: "origin/main".to_string(),
1009            result: RebaseResult::Skipped {
1010                reason: SkipReason::Dirty,
1011            },
1012        };
1013        let value = serde_json::to_value(&outcome).unwrap();
1014        assert_eq!(value["status"], "skipped");
1015        assert_eq!(value["reason"], "dirty");
1016        assert_eq!(value["onto"], "origin/main");
1017    }
1018
1019    // ── classify branches (pure — no git, no subprocess) ──────────────────
1020
1021    /// An `Inspected::Ok` with a fake (never-opened) repo root, for exercising the
1022    /// classification branches that return before any repo is opened.
1023    fn inspected(
1024        branch: Option<&str>,
1025        head: Option<Oid>,
1026        state_clean: bool,
1027        dirty: bool,
1028    ) -> Inspected {
1029        Inspected::Ok(Inspection {
1030            path: PathBuf::from("/wt"),
1031            repo_root: PathBuf::from("/repo"),
1032            branch: branch.map(str::to_string),
1033            head_oid: head,
1034            state_clean,
1035            dirty,
1036        })
1037    }
1038
1039    fn onto_map() -> BTreeMap<PathBuf, OntoSpec> {
1040        let mut map = BTreeMap::new();
1041        map.insert(
1042            PathBuf::from("/repo"),
1043            OntoSpec {
1044                display: "origin/main".to_string(),
1045                fetch: Some(("origin".to_string(), "main".to_string())),
1046            },
1047        );
1048        map
1049    }
1050
1051    fn ok_map(ok: bool) -> BTreeMap<PathBuf, bool> {
1052        let mut map = BTreeMap::new();
1053        map.insert(PathBuf::from("/repo"), ok);
1054        map
1055    }
1056
1057    fn classify_reason(
1058        inspected: &Inspected,
1059        onto: &BTreeMap<PathBuf, OntoSpec>,
1060        autostash: bool,
1061    ) -> RebaseResult {
1062        inspected.classify(onto, &ok_map(true), autostash).result
1063    }
1064
1065    #[test]
1066    fn classify_skips_a_detached_head() {
1067        let out = classify_reason(
1068            &inspected(None, Some(Oid::ZERO_SHA1), true, false),
1069            &onto_map(),
1070            false,
1071        );
1072        assert_eq!(
1073            out,
1074            RebaseResult::Skipped {
1075                reason: SkipReason::DetachedHead
1076            }
1077        );
1078    }
1079
1080    #[test]
1081    fn classify_skips_an_in_progress_operation() {
1082        let out = classify_reason(
1083            &inspected(Some("f"), Some(Oid::ZERO_SHA1), false, false),
1084            &onto_map(),
1085            false,
1086        );
1087        assert_eq!(
1088            out,
1089            RebaseResult::Skipped {
1090                reason: SkipReason::OperationInProgress
1091            }
1092        );
1093    }
1094
1095    #[test]
1096    fn classify_skips_dirty_only_without_autostash() {
1097        let dirty = inspected(Some("f"), Some(Oid::ZERO_SHA1), true, true);
1098        assert_eq!(
1099            classify_reason(&dirty, &onto_map(), false),
1100            RebaseResult::Skipped {
1101                reason: SkipReason::Dirty
1102            }
1103        );
1104        // With autostash the dirty gate is passed; the fake repo root then yields no
1105        // onto commit, so it lands on the later `NoOntoRef` rather than `Dirty`.
1106        assert_eq!(
1107            classify_reason(&dirty, &onto_map(), true),
1108            RebaseResult::Skipped {
1109                reason: SkipReason::NoOntoRef
1110            }
1111        );
1112    }
1113
1114    #[test]
1115    fn classify_reports_no_onto_ref_when_the_repo_is_unresolved() {
1116        let out = classify_reason(
1117            &inspected(Some("f"), Some(Oid::ZERO_SHA1), true, false),
1118            &BTreeMap::new(),
1119            false,
1120        );
1121        assert_eq!(
1122            out,
1123            RebaseResult::Skipped {
1124                reason: SkipReason::NoOntoRef
1125            }
1126        );
1127    }
1128
1129    #[test]
1130    fn classify_reports_fetch_failed_when_the_repos_fetch_failed() {
1131        let out = inspected(Some("f"), Some(Oid::ZERO_SHA1), true, false)
1132            .classify(&onto_map(), &ok_map(false), false)
1133            .result;
1134        assert!(matches!(out, RebaseResult::FetchFailed { .. }));
1135    }
1136
1137    #[test]
1138    fn classify_reports_not_a_worktree_for_an_unresolvable_path() {
1139        let out = Inspected::Unresolvable {
1140            path: PathBuf::from("/x"),
1141        }
1142        .classify(&onto_map(), &ok_map(true), false)
1143        .result;
1144        assert_eq!(
1145            out,
1146            RebaseResult::Skipped {
1147                reason: SkipReason::NotAWorktree
1148            }
1149        );
1150    }
1151
1152    #[test]
1153    fn head_branch_reports_branch_detached_and_unborn() {
1154        let dir = tempfile::tempdir().unwrap();
1155        let repo = Repository::init(dir.path()).unwrap();
1156        config_identity(&repo);
1157        // Unborn: HEAD points at an unborn branch, no commit yet.
1158        assert_eq!(head_branch(&repo), (None, None));
1159        // On a branch.
1160        let oid = empty_commit(&repo, "refs/heads/main", &[]);
1161        repo.set_head("refs/heads/main").unwrap();
1162        let (branch, head) = head_branch(&repo);
1163        assert_eq!(branch.as_deref(), Some("main"));
1164        assert_eq!(head, Some(oid));
1165        // Detached.
1166        repo.set_head_detached(oid).unwrap();
1167        assert_eq!(head_branch(&repo), (None, Some(oid)));
1168    }
1169
1170    #[test]
1171    fn resolve_onto_honours_an_override() {
1172        let (_dir, repo) = repo_with_origin();
1173        assert_eq!(
1174            resolve_onto(&repo, Some("origin/main")).fetch,
1175            Some(("origin".to_string(), "main".to_string()))
1176        );
1177        assert_eq!(resolve_onto(&repo, Some("develop")).fetch, None);
1178    }
1179
1180    #[test]
1181    fn fetch_all_skips_the_fetch_for_a_local_onto() {
1182        let mut map = BTreeMap::new();
1183        map.insert(
1184            PathBuf::from("/repo"),
1185            OntoSpec {
1186                display: "HEAD~1".to_string(),
1187                fetch: None,
1188            },
1189        );
1190        let (fetches, ok) = fetch_all(Path::new("git"), &map);
1191        assert_eq!(fetches.len(), 1);
1192        assert!(!fetches[0].fetched && fetches[0].ok);
1193        assert_eq!(ok.get(Path::new("/repo")), Some(&true));
1194    }
1195
1196    #[test]
1197    fn fetch_once_errors_when_the_remote_is_missing() {
1198        let _guard = serial();
1199        let dir = tempfile::tempdir().unwrap();
1200        let repo = Repository::init(dir.path()).unwrap();
1201        config_identity(&repo);
1202        let err = fetch_once(&resolve_git_binary(), dir.path(), "origin", "main")
1203            .unwrap_err()
1204            .to_string();
1205        assert!(err.contains("git fetch"), "got: {err}");
1206    }
1207
1208    // ── test scaffolding ──────────────────────────────────────────────────
1209
1210    /// A repo with a bare `origin` (so `resolve_onto` sees a real remote) and one
1211    /// commit on `main`; no worktrees yet.
1212    fn repo_with_origin() -> (tempfile::TempDir, Repository) {
1213        let dir = tempfile::tempdir().unwrap();
1214        let repo = Repository::init(dir.path()).unwrap();
1215        config_identity(&repo);
1216        repo.remote("origin", "https://example.invalid/x.git")
1217            .unwrap();
1218        let oid = empty_commit(&repo, "refs/heads/main", &[]);
1219        repo.reference("refs/remotes/origin/main", oid, true, "seed")
1220            .unwrap();
1221        (dir, repo)
1222    }
1223
1224    /// An `origin` bare repo, a `local` clone with `main` pushed, and helpers to add
1225    /// worktrees and advance `origin/main` out-of-band (as a second clone would).
1226    struct Scenario {
1227        root: tempfile::TempDir,
1228        origin: PathBuf,
1229        local: PathBuf,
1230    }
1231
1232    impl Scenario {
1233        fn new() -> Self {
1234            let root = tempfile::tempdir().unwrap();
1235            let origin = root.path().join("origin.git");
1236            let local = root.path().join("local");
1237            std::fs::create_dir_all(&origin).unwrap();
1238            std::fs::create_dir_all(&local).unwrap();
1239            git(&origin, &["init", "--bare", "-b", "main"]);
1240            git(&local, &["init", "-b", "main"]);
1241            config_repo(&local, "Test", "test@example.com");
1242            std::fs::write(local.join("file.txt"), "first\n").unwrap();
1243            // A second tracked file that `origin/main` never touches, so a dirty
1244            // edit to it stashes/pops cleanly across a rebase (no false conflict).
1245            std::fs::write(local.join("keep.txt"), "keep\n").unwrap();
1246            git(&local, &["add", "file.txt", "keep.txt"]);
1247            git(&local, &["commit", "-m", "first"]);
1248            git(
1249                &local,
1250                &["remote", "add", "origin", origin.to_str().unwrap()],
1251            );
1252            git(&local, &["push", "-u", "origin", "main"]);
1253            Self {
1254                root,
1255                origin,
1256                local,
1257            }
1258        }
1259
1260        /// Adds a linked worktree branched off the current `main` and returns its path.
1261        fn add_worktree(&self, name: &str) -> PathBuf {
1262            let path = self.root.path().join(name);
1263            git(
1264                &self.local,
1265                &[
1266                    "worktree",
1267                    "add",
1268                    "-b",
1269                    name,
1270                    path.to_str().unwrap(),
1271                    "main",
1272                ],
1273            );
1274            path
1275        }
1276
1277        /// Advances `origin/main` by one commit that changes `file.txt`, writing
1278        /// directly into the bare origin's object database with `git2` — the
1279        /// `local` repo only learns of it on fetch.
1280        ///
1281        /// Done in-process (no `git clone`/subprocess) so the git-heavy test suite
1282        /// stays light enough not to starve unrelated timing-sensitive tests.
1283        fn advance_origin_main(&self, content: &str) {
1284            let repo = Repository::open_bare(&self.origin).unwrap();
1285            let parent = repo
1286                .find_commit(repo.refname_to_id("refs/heads/main").unwrap())
1287                .unwrap();
1288            // Seed the tree from the parent so `keep.txt` survives; only `file.txt`
1289            // changes (so a worktree that also edited `file.txt` conflicts).
1290            let mut builder = repo.treebuilder(Some(&parent.tree().unwrap())).unwrap();
1291            let blob = repo.blob(content.as_bytes()).unwrap();
1292            builder.insert("file.txt", blob, 0o100_644).unwrap();
1293            let tree = repo.find_tree(builder.write().unwrap()).unwrap();
1294            let sig = git2::Signature::now("Other", "other@example.com").unwrap();
1295            repo.commit(
1296                Some("refs/heads/main"),
1297                &sig,
1298                &sig,
1299                "advance",
1300                &tree,
1301                &[&parent],
1302            )
1303            .unwrap();
1304        }
1305
1306        /// Commits a change inside a worktree (to set up a conflict).
1307        fn commit_in_worktree(&self, wt: &Path, file: &str, content: &str, msg: &str) {
1308            std::fs::write(wt.join(file), content).unwrap();
1309            git(wt, &["add", file]);
1310            git(wt, &["commit", "-m", msg]);
1311        }
1312
1313        /// The current tip oid of `origin/main` on the server.
1314        fn origin_main_oid(&self) -> Oid {
1315            let repo = Repository::open_bare(&self.origin).unwrap();
1316            repo.refname_to_id("refs/heads/main").unwrap()
1317        }
1318    }
1319
1320    /// Pins a test repo's identity and, crucially, **disables commit signing**.
1321    ///
1322    /// Test repos otherwise inherit the developer's global git config; a global
1323    /// `commit.gpgsign = true` makes every commit shell out to gpg, which fails
1324    /// under the parallel test suite ("gpg: signing failed: Cannot allocate
1325    /// memory"). Repo-local config wins over global, and worktrees share the main
1326    /// repo's config file — so this also covers the commits the production
1327    /// `git rebase` creates.
1328    fn config_repo(dir: &Path, name: &str, email: &str) {
1329        git(dir, &["config", "user.name", name]);
1330        git(dir, &["config", "user.email", email]);
1331        git(dir, &["config", "commit.gpgsign", "false"]);
1332    }
1333
1334    fn git(dir: &Path, args: &[&str]) {
1335        let output = run_git_in(&resolve_git_binary(), dir, args).unwrap();
1336        assert!(
1337            output.status.success(),
1338            "git {args:?} failed: {}",
1339            String::from_utf8_lossy(&output.stderr)
1340        );
1341    }
1342
1343    fn config_identity(repo: &Repository) {
1344        let mut cfg = repo.config().unwrap();
1345        cfg.set_str("user.name", "Test").unwrap();
1346        cfg.set_str("user.email", "test@example.com").unwrap();
1347    }
1348
1349    fn empty_commit(repo: &Repository, refname: &str, parents: &[&git2::Commit<'_>]) -> Oid {
1350        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
1351        let tree = repo
1352            .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
1353            .unwrap();
1354        repo.commit(Some(refname), &sig, &sig, "seed", &tree, parents)
1355            .unwrap()
1356    }
1357
1358    fn head_oid(wt: &Path) -> Oid {
1359        let repo = Repository::open(wt).unwrap();
1360        let head = repo.head().unwrap();
1361        head.target().unwrap()
1362    }
1363
1364    fn head_contains(wt: &Path, oid: &Oid) -> bool {
1365        let repo = Repository::open(wt).unwrap();
1366        let head = repo.head().unwrap().target().unwrap();
1367        repo.graph_descendant_of(head, *oid).unwrap_or(false) || head == *oid
1368    }
1369}