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