Skip to main content

majordomus_cli/worktree/
service.rs

1//! The worktree service: every decision about where a worktree belongs, what stands where,
2//! whether a call is where it should be, and what may be created or removed.
3//!
4//! This is the only implementation. The command line renders what it answers, the
5//! capability registry projects its read-only questions to MCP, HTTP, OpenAPI and the
6//! Cockpit, the git hooks ask its guard, and the migration in [`super::migrate`] moves what
7//! it judged misplaced. None of them decides anything; a second decision somewhere else is
8//! the defect this file exists to prevent.
9//!
10//! One operation touches the network: creating a branch that does not exist here fetches
11//! the trunk, and that branch, from the trunk's remote, so that new work starts from the
12//! remote's trunk as it is now and a branch already pushed is taken up rather than started
13//! again (see [`StartPoint`]). The fetch is bounded and quiet, and a remote that cannot be
14//! read is said, with how far behind the local trunk stands. Nothing else fetches: a ref
15//! that does not resolve locally does not resolve, and every other operation works with
16//! the machine offline.
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::path::{Path, PathBuf};
20
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23
24use super::direnv::{self, EnvrcApproval, LocalOverrides};
25use super::error::{Result, WorktreeError};
26use super::git;
27use super::hooks::{self, HooksPath};
28use super::identity::{RepositoryIdentity, ResolvedPath, TrunkSource};
29use super::lock::WorktreeLock;
30use super::model::{
31    BranchState, ContainerView, DiagnosticCode, DirtyState, GuardVerdict, InspectReport,
32    RepairReport, RepositoryTopology, RepositoryView, Severity, Standing, StatusReport,
33    TopologyDiagnostic, TopologyTallies, TrunkView, WorktreeKind, WorktreeState, SCHEMA,
34};
35use super::path::{self, BranchName, CONTAINER_SUFFIX};
36use super::state::{self, BranchRef};
37use super::topology::WorktreeRecord;
38
39/// How much of each work tree's state a topology reads.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Detail {
42    /// The registrations, the branches and the filesystem: no per-worktree subprocess.
43    Fast,
44    /// Also the uncommitted work of every existing work tree, one `git status` each.
45    Full,
46}
47
48/// What to create.
49#[derive(Debug, Clone, Default)]
50pub struct CreateRequest {
51    /// The branch to check out or create.
52    pub branch: String,
53    /// The commit or ref a new branch starts from, as given and never fetched. When absent,
54    /// the remote's trunk as fetched now, then the local trunk, then HEAD ([`StartPoint`]).
55    pub base: Option<String>,
56}
57
58/// Where a new branch started, and why there.
59///
60/// The local trunk of a long-lived checkout is routinely hundreds of commits behind its
61/// remote, because nothing advances it; a branch cut from it carries none of that work and
62/// every worker had to reset it by hand. So the default is the remote's trunk, fetched at
63/// the moment of creation, and the local trunk only when the remote cannot be read — said,
64/// never silent.
65///
66/// ```
67/// use majordomus_cli::worktree::StartPoint;
68/// let p = StartPoint::RemoteTrunk { remote: "origin".into(), branch: "master".into() };
69/// assert_eq!(p.reference(), "origin/master");
70/// assert!(p.fallback().is_none());
71/// let fell = StartPoint::LocalTrunk {
72///     branch: "master".into(),
73///     unreachable: Some("origin".into()),
74///     reason: Some("no answer within 60s".into()),
75///     behind: Some(779),
76/// };
77/// assert_eq!(fell.reference(), "master");
78/// assert!(fell.fallback().unwrap().contains("779 commits behind origin/master"));
79/// ```
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
81#[serde(tag = "from", rename_all = "snake_case")]
82pub enum StartPoint {
83    /// The ref `--base` named, as given; never fetched.
84    Given {
85        /// The ref.
86        reference: String,
87    },
88    /// The trunk on its remote, fetched by this command.
89    RemoteTrunk {
90        /// The remote.
91        remote: String,
92        /// The trunk branch.
93        branch: String,
94    },
95    /// The branch already on the remote: the local branch is created at it and tracks it.
96    RemoteBranch {
97        /// The remote.
98        remote: String,
99        /// The branch, the same name on both sides.
100        branch: String,
101        /// Whether it was fetched now; `false` is the remote-tracking ref as last fetched,
102        /// because the remote could not be read.
103        fetched: bool,
104    },
105    /// The local trunk: the trunk has no remote, or its remote could not be read.
106    LocalTrunk {
107        /// The trunk branch.
108        branch: String,
109        /// The remote that could not be read; `None` when the trunk has no remote.
110        #[serde(default, skip_serializing_if = "Option::is_none")]
111        unreachable: Option<String>,
112        /// Why it could not be read.
113        #[serde(default, skip_serializing_if = "Option::is_none")]
114        reason: Option<String>,
115        /// How many commits the remote's trunk, as last fetched, has that the local trunk
116        /// lacks; `None` when it was never fetched.
117        #[serde(default, skip_serializing_if = "Option::is_none")]
118        behind: Option<u64>,
119    },
120    /// HEAD, because no trunk is known.
121    Head,
122}
123
124impl StartPoint {
125    /// The ref git is given to start the branch from.
126    ///
127    /// ```
128    /// use majordomus_cli::worktree::StartPoint;
129    ///
130    /// let remote = StartPoint::RemoteTrunk { remote: "origin".into(), branch: "master".into() };
131    /// assert_eq!(remote.reference(), "origin/master");
132    /// assert_eq!(StartPoint::Head.reference(), "HEAD");
133    /// ```
134    pub fn reference(&self) -> String {
135        match self {
136            StartPoint::Given { reference } => reference.clone(),
137            StartPoint::RemoteTrunk { remote, branch }
138            | StartPoint::RemoteBranch { remote, branch, .. } => format!("{remote}/{branch}"),
139            StartPoint::LocalTrunk { branch, .. } => branch.clone(),
140            StartPoint::Head => "HEAD".into(),
141        }
142    }
143
144    /// The sentence a person must read when the start is not the remote's state as it is
145    /// now; `None` when it is, or when nothing better could have been asked.
146    ///
147    /// ```
148    /// use majordomus_cli::worktree::StartPoint;
149    ///
150    /// let stale = StartPoint::LocalTrunk {
151    ///     branch: "master".into(),
152    ///     unreachable: Some("origin".into()),
153    ///     reason: Some("offline".into()),
154    ///     behind: Some(3),
155    /// };
156    /// let said = stale.fallback().expect("a stale start is said");
157    /// assert!(said.contains("3 commits behind origin/master"), "{said}");
158    /// let remote = StartPoint::RemoteTrunk { remote: "origin".into(), branch: "master".into() };
159    /// assert_eq!(remote.fallback(), None);
160    /// ```
161    pub fn fallback(&self) -> Option<String> {
162        match self {
163            StartPoint::LocalTrunk {
164                branch,
165                unreachable: Some(remote),
166                reason,
167                behind,
168            } => {
169                let why = reason.as_deref().unwrap_or("it did not answer");
170                let distance = match behind {
171                    Some(0) => format!("level with {remote}/{branch} as last fetched"),
172                    Some(1) => format!("1 commit behind {remote}/{branch} as last fetched"),
173                    Some(n) => format!("{n} commits behind {remote}/{branch} as last fetched"),
174                    None => format!("{remote}/{branch} was never fetched, so how far behind is unknown"),
175                };
176                Some(format!(
177                    "could not fetch {branch} from {remote} ({why}): started from the local {branch}, {distance}; \
178                     once {remote} answers, `git fetch {remote} && git reset --hard {remote}/{branch}` in the worktree before the first commit"
179                ))
180            }
181            StartPoint::RemoteBranch {
182                remote,
183                branch,
184                fetched: false,
185            } => Some(format!(
186                "could not fetch {branch} from {remote}: tracking {remote}/{branch} as last fetched, which may be behind"
187            )),
188            _ => None,
189        }
190    }
191}
192
193/// What a create did.
194#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
195pub struct CreateReport {
196    /// Where the worktree is.
197    pub path: String,
198    /// The branch checked out in it.
199    pub branch: String,
200    /// Whether that branch was created by this command.
201    pub branch_created: bool,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    /// The ref a new branch started from (`origin/master`).
204    pub base: Option<String>,
205    /// Where a branch this command created started, and why there.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub start: Option<StartPoint>,
208    /// The exact commit a branch this command created starts at, read back from the
209    /// worktree rather than assumed.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub start_commit: Option<String>,
212    /// The container it went into.
213    pub container: String,
214    /// Whether the container was created by this command.
215    pub container_created: bool,
216    /// The worktree already existed at its canonical path and nothing was created.
217    pub existed: bool,
218    /// What became of its `.envrc` under direnv: the primary checkout's approval carried to
219    /// this path, or why it was not. A worktree that starts blocked is the recurring
220    /// "direnv does not work again"; this says so before the first `cd` does.
221    pub envrc: EnvrcApproval,
222    /// What became of the primary checkout's machine-local `.envrc.local` here.
223    #[serde(default = "default_local")]
224    pub envrc_local: LocalOverrides,
225    /// Which hooks it runs: its own tree's, bound per worktree when the shared
226    /// `core.hooksPath` would have sent it to the primary checkout's (I2718).
227    #[serde(default = "default_hooks")]
228    pub hooks: HooksPath,
229}
230
231/// The remote the trunk belongs to: the one whose HEAD names it, else the one the trunk's
232/// configuration tracks. `None` when the trunk lives only here.
233fn trunk_remote(primary: &Path, trunk: &str) -> Result<Option<String>> {
234    let remotes = git::try_run(primary, &["remote"])?;
235    if remotes.status != Some(0) {
236        return Ok(None);
237    }
238    let remotes: Vec<String> = remotes
239        .text()?
240        .lines()
241        .map(str::trim)
242        .filter(|r| !r.is_empty())
243        .map(str::to_string)
244        .collect();
245    for remote in &remotes {
246        // A remote with no HEAD, or one git cannot be asked about, names no trunk.
247        let head = git::try_run(
248            primary,
249            &["symbolic-ref", "-q", &format!("refs/remotes/{remote}/HEAD")],
250        )
251        .ok()
252        .filter(|head| head.status == Some(0));
253        if let Some(head) = head {
254            if head.text()? == format!("refs/remotes/{remote}/{trunk}") {
255                return Ok(Some(remote.clone()));
256            }
257        }
258    }
259    let configured = git::try_run(
260        primary,
261        &["config", "--get", &format!("branch.{trunk}.remote")],
262    )?;
263    if configured.status == Some(0) {
264        let remote = configured.text()?;
265        if remotes.contains(&remote) {
266            return Ok(Some(remote));
267        }
268    }
269    Ok(None)
270}
271
272fn default_local() -> LocalOverrides {
273    LocalOverrides::NonePrimary
274}
275
276fn default_hooks() -> HooksPath {
277    HooksPath::NotConfigured
278}
279
280/// What a remove did.
281#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
282pub struct RemoveReport {
283    /// What was removed.
284    pub path: String,
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    /// The branch it held. It still exists: removing a worktree never deletes a branch.
287    pub branch: Option<String>,
288    /// Whether `--force` was needed.
289    pub forced: bool,
290}
291
292/// The repository's worktrees, decided once.
293///
294/// Built from a directory — any directory inside any work tree — and nothing else.
295#[derive(Debug, Clone)]
296pub struct WorktreeService {
297    identity: RepositoryIdentity,
298    container: ResolvedPath,
299    issue_ids: Vec<String>,
300    scratch_roots: Vec<ScratchRoot>,
301}
302
303/// A directory a checkout of somebody else's lives under: the tool's own temporary roots
304/// or a provider's, as `share/providers.yaml` declares them (ADR 0024).
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct ScratchRoot {
307    /// The provider that creates checkouts there; `None` for the tool's own roots.
308    pub provider: Option<String>,
309    /// The root, expanded.
310    pub path: PathBuf,
311}
312
313impl WorktreeService {
314    /// Resolve the repository from `start`. The scratch roots come from the distribution's
315    /// provider declarations, located the way every command locates the share; a checkout
316    /// with no share in reach declares none, and a temporary checkout is then what its path
317    /// says it is.
318    pub fn open(start: &Path) -> Result<Self> {
319        let identity = RepositoryIdentity::discover(start)?;
320        Self::over(identity)
321    }
322
323    /// The service over an identity already resolved.
324    pub fn over(identity: RepositoryIdentity) -> Result<Self> {
325        let primary = identity.primary_worktree().path.clone();
326        let current = identity.current_worktree().path.clone();
327        let scratch_roots = declared_scratch_roots(&current, &primary);
328        Self::over_with(identity, scratch_roots)
329    }
330
331    /// The service over an identity, with the scratch roots given rather than located.
332    pub fn over_with(
333        identity: RepositoryIdentity,
334        scratch_roots: Vec<ScratchRoot>,
335    ) -> Result<Self> {
336        let container = ResolvedPath::of(path::container_root(&identity.primary_worktree().path)?);
337        let issue_ids = state::issue_ids(&identity.primary_worktree().path);
338        Ok(WorktreeService {
339            identity,
340            container,
341            issue_ids,
342            scratch_roots,
343        })
344    }
345
346    /// The scratch roots in force, expanded.
347    pub fn scratch_roots(&self) -> &[ScratchRoot] {
348        &self.scratch_roots
349    }
350
351    /// The repository this service speaks for.
352    pub fn identity(&self) -> &RepositoryIdentity {
353        &self.identity
354    }
355
356    /// The canonical container.
357    pub fn container(&self) -> &ResolvedPath {
358        &self.container
359    }
360
361    /// Where the worktree of `branch` belongs.
362    pub fn expected_path_of(&self, branch: &str) -> Result<PathBuf> {
363        path::expected_path(&self.container.path, &BranchName::parse(branch)?)
364    }
365
366    // ------------------------------------------------------------ views
367
368    fn repository_view(&self) -> RepositoryView {
369        RepositoryView {
370            primary_worktree: display(&self.identity.primary_worktree().path),
371            git_common_dir: display(&self.identity.git_common_dir().path),
372            name: self.identity.repository_name(),
373        }
374    }
375
376    fn container_view(&self) -> ContainerView {
377        ContainerView {
378            path: display(&self.container.path),
379            suffix: CONTAINER_SUFFIX.to_string(),
380            exists: self.container.path.is_dir(),
381        }
382    }
383
384    fn trunk_view(&self) -> TrunkView {
385        let trunk = self.identity.trunk();
386        TrunkView {
387            branch: trunk.branch.clone(),
388            source: trunk.source,
389            checked_out_at: trunk
390                .branch
391                .as_deref()
392                .and_then(|b| self.identity.record_of_branch(b))
393                .map(|r| display(&r.path)),
394        }
395    }
396
397    // ------------------------------------------------------------ judging one work tree
398
399    /// Everything the topology says about one registered work tree. The one place a
400    /// standing is decided.
401    pub fn judge(
402        &self,
403        record: &WorktreeRecord,
404        detail: Detail,
405        branches: &BTreeMap<String, BranchRef>,
406    ) -> WorktreeState {
407        let dirty = Self::measures_dirty(record, detail)
408            .then(|| state::dirty_state(&record.path).ok())
409            .flatten();
410        self.judge_with(record, branches, dirty)
411    }
412
413    /// Does the topology take a `git status` of this work tree at this detail? The one
414    /// place the condition is written, because the whole topology takes these measurements
415    /// at once ([`state::dirty_states`]) and the batch must select exactly the work trees
416    /// [`Self::judge`] would have measured one at a time.
417    fn measures_dirty(record: &WorktreeRecord, detail: Detail) -> bool {
418        detail == Detail::Full && record.path.is_dir() && !record.bare
419    }
420
421    /// [`Self::judge`] with the uncommitted work already measured — or deliberately not
422    /// measured, which is what `None` means here, exactly as it does in the answer.
423    fn judge_with(
424        &self,
425        record: &WorktreeRecord,
426        branches: &BTreeMap<String, BranchRef>,
427        dirty: Option<DirtyState>,
428    ) -> WorktreeState {
429        let resolved = ResolvedPath::of(&record.path);
430        let is_primary = self.identity.is_primary(record);
431        let exists = record.path.is_dir();
432        let trunk = self.identity.trunk();
433        let mut diagnostics = Vec::new();
434        let path_text = display(&record.path);
435
436        let label = match (&record.branch, &record.head) {
437            (Some(b), _) => b.clone(),
438            (None, Some(h)) => path::detached_label(h),
439            (None, None) => "(unborn)".to_string(),
440        };
441
442        // where the branch belongs, if it has one and the name derives
443        let expected: Option<PathBuf> = match record.branch.as_deref() {
444            Some(b) => match BranchName::parse(b)
445                .and_then(|n| path::expected_path(&self.container.path, &n))
446            {
447                Ok(p) => Some(p),
448                Err(e) => {
449                    diagnostics.push(TopologyDiagnostic {
450                        code: match e {
451                            WorktreeError::PathEscape { .. } => DiagnosticCode::PathEscape,
452                            _ => DiagnosticCode::InvalidBranchName,
453                        },
454                        severity: Severity::Error,
455                        path: Some(path_text.clone()),
456                        branch: Some(b.to_string()),
457                        expected: None,
458                        message: e.to_string(),
459                        remedy: "rename the branch (git branch -m) to a name git and the filesystem both accept".into(),
460                    });
461                    None
462                }
463            },
464            None => None,
465        };
466        let expected_text = expected.as_deref().map(display);
467
468        let standing = if is_primary {
469            if let Some(b) = record.branch.as_deref() {
470                if let Some(t) = trunk.branch.as_deref() {
471                    if b != t {
472                        diagnostics.push(TopologyDiagnostic {
473                            code: DiagnosticCode::PrimaryOnNonTrunk,
474                            severity: Severity::Error,
475                            path: Some(path_text.clone()),
476                            branch: Some(b.to_string()),
477                            expected: expected_text.clone(),
478                            message: format!(
479                                "the primary checkout holds '{b}', and the primary checkout hosts the trunk '{t}'"
480                            ),
481                            remedy: format!(
482                                "when the primary checkout is clean: git switch {t}; then majordomus worktree create {b}"
483                            ),
484                        });
485                    }
486                }
487            }
488            Standing::Primary
489        } else if record.prunable.is_some() || !exists {
490            diagnostics.push(TopologyDiagnostic {
491                code: if record.prunable.is_some() {
492                    DiagnosticCode::StaleRegistration
493                } else {
494                    DiagnosticCode::Missing
495                },
496                severity: Severity::Warning,
497                path: Some(path_text.clone()),
498                branch: record.branch.clone(),
499                expected: expected_text.clone(),
500                message: match &record.prunable {
501                    Some(reason) if !reason.is_empty() => {
502                        format!("git reports it prunable: {reason}")
503                    }
504                    _ => "the directory git registered does not exist".into(),
505                },
506                remedy: "majordomus worktree repair".into(),
507            });
508            Standing::Missing
509        } else if record.branch.is_none() {
510            diagnostics.push(TopologyDiagnostic {
511                code: DiagnosticCode::Detached,
512                severity: Severity::Info,
513                path: Some(path_text.clone()),
514                branch: None,
515                expected: None,
516                message: "HEAD is detached: no branch, so no canonical path; nothing moves it"
517                    .into(),
518                remedy: "git switch -c <branch> there, then majordomus worktree migrate".into(),
519            });
520            Standing::Detached
521        } else if let Some(root) = self.scratch_root_of(&resolved) {
522            diagnostics.push(TopologyDiagnostic {
523                code: DiagnosticCode::Ephemeral,
524                severity: Severity::Warning,
525                path: Some(path_text.clone()),
526                branch: record.branch.clone(),
527                expected: expected_text.clone(),
528                message: format!(
529                    "a session's scratch checkout under {}{}, holding branch '{}' which belongs at {}; nothing moves it while the session that made it may be running",
530                    display(&root.path),
531                    root.provider
532                        .as_deref()
533                        .map(|p| format!(" (created by {p})"))
534                        .unwrap_or_default(),
535                    label,
536                    expected_text.as_deref().unwrap_or("-")
537                ),
538                remedy: format!(
539                    "detach it (git switch --detach) and continue in the canonical worktree (majordomus worktree create {}); or majordomus worktree migrate --include-ephemeral --only {}",
540                    label, label
541                ),
542            });
543            Standing::Ephemeral
544        } else if resolved.same_as(&self.container) {
545            diagnostics.push(TopologyDiagnostic {
546                code: DiagnosticCode::ContainerOccupied,
547                severity: Severity::Error,
548                path: Some(path_text.clone()),
549                branch: record.branch.clone(),
550                expected: expected_text.clone(),
551                message: format!(
552                    "it occupies the container {} itself; the container holds worktrees and cannot be one",
553                    display(&self.container.path)
554                ),
555                remedy: "majordomus worktree migrate".into(),
556            });
557            Standing::Misplaced
558        } else if expected.as_deref().is_some_and(|e| {
559            ResolvedPath::of(e).same_as(&resolved) && !symlink_below(&self.container.path, e)
560        }) {
561            Standing::Canonical
562        } else if expected.is_some() {
563            diagnostics.push(TopologyDiagnostic {
564                code: DiagnosticCode::PathMismatch,
565                severity: Severity::Error,
566                path: Some(path_text.clone()),
567                branch: record.branch.clone(),
568                expected: expected_text.clone(),
569                message: format!(
570                    "branch '{}' belongs at {}",
571                    label,
572                    expected_text.as_deref().unwrap_or("-")
573                ),
574                remedy: "majordomus worktree migrate".into(),
575            });
576            let inside_primary = resolved.is_inside(self.identity.primary_worktree());
577            let inside_other = self
578                .identity
579                .registered_worktrees()
580                .iter()
581                .filter(|r| r.path != record.path)
582                .any(|r| resolved.is_inside(&ResolvedPath::of(&r.path)));
583            if inside_primary || inside_other {
584                diagnostics.push(TopologyDiagnostic {
585                    code: DiagnosticCode::Nested,
586                    severity: Severity::Warning,
587                    path: Some(path_text.clone()),
588                    branch: record.branch.clone(),
589                    expected: expected_text.clone(),
590                    message: if inside_primary {
591                        "it sits inside the primary checkout".into()
592                    } else {
593                        "it sits inside another worktree of this repository".into()
594                    },
595                    remedy: "majordomus worktree migrate".into(),
596                });
597            }
598            Standing::Misplaced
599        } else {
600            // a branch whose name did not derive: already diagnosed above
601            Standing::Misplaced
602        };
603
604        if standing == Standing::Misplaced {
605            if let Some(reason) = &record.locked {
606                diagnostics.push(TopologyDiagnostic {
607                    code: DiagnosticCode::Locked,
608                    severity: Severity::Warning,
609                    path: Some(path_text.clone()),
610                    branch: record.branch.clone(),
611                    expected: expected_text.clone(),
612                    message: if reason.is_empty() {
613                        "it is locked, so git will not move it".into()
614                    } else {
615                        format!("it is locked ({reason}), so git will not move it")
616                    },
617                    remedy: format!("git worktree unlock {path_text}"),
618                });
619            }
620            if let Some(e) = expected.as_deref() {
621                if let Some(d) = self.destination_conflict(e, Some(record)) {
622                    diagnostics.push(d);
623                }
624            }
625        }
626
627        if !is_primary && record.branch.as_deref().is_some_and(|b| trunk.is(b)) {
628            diagnostics.push(TopologyDiagnostic {
629                code: DiagnosticCode::TrunkInLinkedWorktree,
630                severity: Severity::Warning,
631                path: Some(path_text.clone()),
632                branch: record.branch.clone(),
633                expected: Some(display(&self.identity.primary_worktree().path)),
634                message: "the trunk is checked out in a linked worktree; the primary checkout hosts it".into(),
635                remedy: "switch the primary checkout to the trunk and remove this worktree when it is clean".into(),
636            });
637        }
638
639        WorktreeState {
640            path: path_text,
641            kind: if is_primary {
642                WorktreeKind::Primary
643            } else {
644                WorktreeKind::Linked
645            },
646            standing,
647            branch: record.branch.clone(),
648            label,
649            head: record.head.clone(),
650            detached: record.detached,
651            expected_path: if is_primary && record.branch.as_deref().is_some_and(|b| trunk.is(b)) {
652                None
653            } else {
654                expected_text
655            },
656            exists,
657            current: self.identity.is_current(record),
658            locked: record.locked.clone(),
659            prunable: record.prunable.clone(),
660            dirty,
661            upstream: record
662                .branch
663                .as_deref()
664                .and_then(|b| branches.get(b))
665                .and_then(|b| b.upstream.clone()),
666            issue: record
667                .branch
668                .as_deref()
669                .and_then(|b| state::issue_of(b, &self.issue_ids)),
670            diagnostics,
671        }
672    }
673
674    /// The scratch root a path lies under, when it does. The roots are the declarations'
675    /// (`share/providers.yaml`): the tool's own temporary directories and every provider's,
676    /// such as `.claude/worktrees/` under the primary checkout, which Claude Code creates and
677    /// removes with the session that asked for it, or the thread directory an orchestrator
678    /// keeps under its data directory. A root the primary checkout itself lives under is
679    /// skipped, as a test fixture's temporary directory is: nothing beside the repository
680    /// is a scratch checkout of it.
681    pub fn scratch_root_of(&self, path: &ResolvedPath) -> Option<ScratchRoot> {
682        let primary = self.identity.primary_worktree();
683        for root in &self.scratch_roots {
684            let resolved = ResolvedPath::of(root.path.clone());
685            if primary.is_inside(&resolved) || primary.same_as(&resolved) {
686                continue;
687            }
688            if path.is_inside(&resolved) {
689                return Some(ScratchRoot {
690                    provider: root.provider.clone(),
691                    path: resolved.path,
692                });
693            }
694        }
695        None
696    }
697
698    /// [`Self::scratch_root_of`], the path alone.
699    pub fn ephemeral_root_of(&self, path: &ResolvedPath) -> Option<PathBuf> {
700        self.scratch_root_of(path).map(|r| r.path)
701    }
702
703    /// What occupies `expected`, when something other than `own` does.
704    pub(crate) fn destination_conflict(
705        &self,
706        expected: &Path,
707        own: Option<&WorktreeRecord>,
708    ) -> Option<TopologyDiagnostic> {
709        let meta = std::fs::symlink_metadata(expected).ok()?;
710        let resolved = ResolvedPath::of(expected);
711        // A link is a conflict whatever it points at — including this very worktree: the
712        // canonical path has to be the directory, not a name for it somewhere else.
713        if !meta.file_type().is_symlink() {
714            if let Some(r) = self.identity.record_at(&resolved) {
715                if own.is_some_and(|o| o.path == r.path) {
716                    return None;
717                }
718                return Some(TopologyDiagnostic {
719                code: DiagnosticCode::DestinationConflict,
720                severity: Severity::Error,
721                path: own.map(|o| display(&o.path)),
722                branch: own.and_then(|o| o.branch.clone()),
723                expected: Some(display(expected)),
724                message: format!(
725                    "the canonical path is occupied by a registered worktree of this repository holding {}",
726                    r.branch.clone().unwrap_or_else(|| "a detached HEAD".into())
727                ),
728                remedy: "move that worktree to its own canonical path first (majordomus worktree migrate)".into(),
729            });
730            }
731        }
732        let what = if meta.file_type().is_symlink() {
733            "a symbolic link".to_string()
734        } else if meta.is_dir() {
735            if expected.join(".git").exists() {
736                "a git checkout this repository has not registered".to_string()
737            } else if std::fs::read_dir(expected)
738                .map(|mut d| d.next().is_none())
739                .unwrap_or(false)
740            {
741                "an empty directory".to_string()
742            } else {
743                "an unrelated directory with content".to_string()
744            }
745        } else {
746            "a file".to_string()
747        };
748        Some(TopologyDiagnostic {
749            code: DiagnosticCode::DestinationConflict,
750            severity: Severity::Error,
751            path: own.map(|o| display(&o.path)),
752            branch: own.and_then(|o| o.branch.clone()),
753            expected: Some(display(expected)),
754            message: format!("the canonical path is occupied by {what}; nothing overwrites it"),
755            remedy: format!(
756                "inspect and move it aside by hand: ls -la {}",
757                display(expected)
758            ),
759        })
760    }
761
762    // ------------------------------------------------------------ the topology
763
764    /// The whole topology.
765    pub fn topology(&self, detail: Detail) -> Result<RepositoryTopology> {
766        let primary = &self.identity.primary_worktree().path;
767        let trunk = self.identity.trunk();
768        let branch_refs = state::branches(primary)?;
769        let branches: BTreeMap<String, BranchRef> = branch_refs
770            .iter()
771            .map(|b| (b.name.clone(), b.clone()))
772            .collect();
773        let merged: Option<BTreeSet<String>> = match trunk.branch.as_deref() {
774            Some(t) => Some(state::merged_into_trunk(primary, t)?),
775            None => None,
776        };
777
778        // Every `git status` this topology needs, taken at once rather than one work tree
779        // after the next: the measurements do not depend on one another, and on a
780        // repository with a hundred-odd registered work trees the sequence was most of
781        // what a topology cost. Judging itself runs no subprocess.
782        let records = self.identity.registered_worktrees();
783        let probes: Vec<Option<&Path>> = records
784            .iter()
785            .map(|r| Self::measures_dirty(r, detail).then_some(r.path.as_path()))
786            .collect();
787        let mut worktrees: Vec<WorktreeState> = records
788            .iter()
789            .zip(state::dirty_states(&probes))
790            .map(|(r, d)| self.judge_with(r, &branches, d))
791            .collect();
792
793        // repository-wide facts
794        let mut repository_diagnostics = Vec::new();
795        if trunk.branch.is_none() {
796            repository_diagnostics.push(TopologyDiagnostic {
797                code: DiagnosticCode::TrunkUnknown,
798                severity: Severity::Warning,
799                path: None,
800                branch: None,
801                expected: None,
802                message: "the trunk could not be determined: no remote HEAD, no init.defaultBranch, neither main nor master alone, and the primary checkout is detached".into(),
803                remedy: "git remote set-head origin --auto, or git config init.defaultBranch <branch>".into(),
804            });
805        }
806        let mut by_folded: BTreeMap<String, Vec<String>> = BTreeMap::new();
807        for w in &worktrees {
808            if let (Some(e), Some(b)) = (&w.expected_path, &w.branch) {
809                by_folded
810                    .entry(e.to_lowercase())
811                    .or_default()
812                    .push(b.clone());
813            }
814        }
815        for (_, names) in by_folded.iter().filter(|(_, n)| n.len() > 1) {
816            repository_diagnostics.push(TopologyDiagnostic {
817                code: DiagnosticCode::CaseCollision,
818                severity: Severity::Warning,
819                path: None,
820                branch: Some(names.join(", ")),
821                expected: None,
822                message: "these branch names derive paths that differ only by case, which one directory on a case-insensitive filesystem".into(),
823                remedy: "rename one of the branches (git branch -m)".into(),
824            });
825        }
826
827        let by_path: BTreeMap<String, &WorktreeState> =
828            worktrees.iter().map(|w| (w.path.clone(), w)).collect();
829        let mut branch_states: Vec<BranchState> = branch_refs
830            .iter()
831            .map(|b| {
832                let is_trunk = trunk.is(&b.name);
833                let worktree_path = b.worktree.as_deref().map(display);
834                let holder = worktree_path.as_deref().and_then(|p| by_path.get(p));
835                let merged_into_trunk = merged.as_ref().map(|m| m.contains(&b.name));
836                // A session's scratch checkout is never cleanup-eligible however merged and
837                // clean: the harness that made it removes it, and a listing that offered it
838                // would offer another session's floor.
839                let cleanup_eligible = !is_trunk
840                    && merged_into_trunk == Some(true)
841                    && match holder {
842                        None => true,
843                        Some(h) => {
844                            h.standing != Standing::Ephemeral
845                                && h.dirty.as_ref().is_some_and(|d| d.clean)
846                        }
847                    };
848                BranchState {
849                    name: b.name.clone(),
850                    head: b.head.clone(),
851                    trunk: is_trunk,
852                    expected_path: if is_trunk {
853                        None
854                    } else {
855                        self.expected_path_of(&b.name).ok().map(|p| display(&p))
856                    },
857                    worktree: worktree_path,
858                    upstream: b.upstream.clone(),
859                    merged_into_trunk,
860                    cleanup_eligible,
861                    issue: state::issue_of(&b.name, &self.issue_ids),
862                }
863            })
864            .collect();
865        crate::order::canonical(&mut branch_states);
866
867        let mut tallies = TopologyTallies {
868            worktrees: worktrees.len(),
869            branches: branch_states.len(),
870            branches_without_worktree: branch_states
871                .iter()
872                .filter(|b| b.worktree.is_none())
873                .count(),
874            cleanup_eligible: branch_states.iter().filter(|b| b.cleanup_eligible).count(),
875            ..TopologyTallies::default()
876        };
877        for w in &worktrees {
878            match w.standing {
879                Standing::Canonical => tallies.canonical += 1,
880                Standing::Misplaced => tallies.misplaced += 1,
881                Standing::Detached => tallies.detached += 1,
882                Standing::Ephemeral => tallies.ephemeral += 1,
883                Standing::Missing => tallies.missing += 1,
884                Standing::Primary => {}
885            }
886            if w.locked.is_some() {
887                tallies.locked += 1;
888            }
889            if w.dirty.as_ref().is_some_and(|d| !d.clean) {
890                tallies.dirty += 1;
891            }
892        }
893        let mut diagnostics: Vec<TopologyDiagnostic> = worktrees
894            .iter_mut()
895            .flat_map(|w| w.diagnostics.clone())
896            .collect();
897        diagnostics.extend(repository_diagnostics);
898        tallies.errors = diagnostics
899            .iter()
900            .filter(|d| d.severity == Severity::Error)
901            .count();
902        tallies.warnings = diagnostics
903            .iter()
904            .filter(|d| d.severity == Severity::Warning)
905            .count();
906
907        Ok(RepositoryTopology {
908            schema: SCHEMA.to_string(),
909            repository: self.repository_view(),
910            container: self.container_view(),
911            trunk: self.trunk_view(),
912            observed_from: display(&self.identity.current_worktree().path),
913            valid: tallies.errors == 0,
914            worktrees,
915            branches: branch_states,
916            diagnostics,
917            tallies,
918        })
919    }
920
921    /// Where this call is, and whether that is where it belongs.
922    pub fn status(&self) -> Result<StatusReport> {
923        let topology = self.topology(Detail::Fast)?;
924        let record = self.identity.current_record().cloned().ok_or_else(|| {
925            WorktreeError::NotThisRepository {
926                path: self.identity.current_worktree().path.clone(),
927                git_common_dir: self.identity.git_common_dir().path.clone(),
928            }
929        })?;
930        let branches: BTreeMap<String, BranchRef> =
931            state::branches(&self.identity.primary_worktree().path)?
932                .into_iter()
933                .map(|b| (b.name.clone(), b))
934                .collect();
935        let worktree = self.judge(&record, Detail::Full, &branches);
936        let canonical = is_in_place(&worktree, self.identity.trunk().source);
937        Ok(StatusReport {
938            schema: SCHEMA.to_string(),
939            repository: topology.repository,
940            container: topology.container,
941            trunk: topology.trunk,
942            worktree,
943            canonical,
944            repository_errors: topology.tallies.errors,
945        })
946    }
947
948    /// The guard: may a mutation proceed from the work tree this call came from?
949    pub fn guard(&self) -> Result<GuardVerdict> {
950        let status = self.status()?;
951        let w = status.worktree;
952        let exempt = matches!(w.standing, Standing::Detached)
953            || (w.standing == Standing::Primary && w.expected_path.is_none());
954        let reason = w
955            .diagnostics
956            .iter()
957            .find(|d| d.severity == Severity::Error || d.code == DiagnosticCode::Ephemeral)
958            .cloned();
959        Ok(GuardVerdict {
960            ok: status.canonical,
961            path: w.path,
962            branch: w.branch,
963            expected_path: w.expected_path,
964            standing: w.standing,
965            exempt,
966            reason: if status.canonical { None } else { reason },
967        })
968    }
969
970    /// One branch: where its worktree belongs and what is there.
971    pub fn inspect(&self, branch: &str) -> Result<InspectReport> {
972        let name = BranchName::parse(branch)?;
973        let expected = path::expected_path(&self.container.path, &name)?;
974        let primary = &self.identity.primary_worktree().path;
975        let branches: BTreeMap<String, BranchRef> = state::branches(primary)?
976            .into_iter()
977            .map(|b| (b.name.clone(), b))
978            .collect();
979        let worktree = self
980            .identity
981            .record_of_branch(branch)
982            .map(|r| self.judge(r, Detail::Full, &branches));
983        let canonical = worktree
984            .as_ref()
985            .is_some_and(|w| w.standing == Standing::Canonical);
986        let mut diagnostics: Vec<TopologyDiagnostic> = worktree
987            .as_ref()
988            .map(|w| w.diagnostics.clone())
989            .unwrap_or_default();
990        if worktree.is_none() {
991            if let Some(d) = self.destination_conflict(&expected, None) {
992                diagnostics.push(d);
993            }
994        }
995        Ok(InspectReport {
996            branch: branch.to_string(),
997            branch_exists: branches.contains_key(branch),
998            expected_path: display(&expected),
999            destination_exists: std::fs::symlink_metadata(&expected).is_ok(),
1000            worktree,
1001            canonical,
1002            diagnostics,
1003        })
1004    }
1005
1006    // ------------------------------------------------------------ selectors
1007
1008    /// Resolve a selector to exactly one registered work tree: an exact branch name, or an
1009    /// exact path. Nothing is matched by prefix, substring or similarity — these selectors
1010    /// are given to commands that delete things.
1011    pub fn resolve(&self, selector: &str) -> Result<WorktreeRecord> {
1012        if let Some(r) = self.identity.record_of_branch(selector) {
1013            return Ok(r.clone());
1014        }
1015        let wanted = ResolvedPath::of(PathBuf::from(selector));
1016        if let Some(r) = self.identity.record_at(&wanted) {
1017            return Ok(r.clone());
1018        }
1019        Err(WorktreeError::NoSuchWorktree {
1020            selector: selector.to_string(),
1021        })
1022    }
1023
1024    // ------------------------------------------------------------ create
1025
1026    /// Create the canonical worktree of a branch. The destination is derived and never
1027    /// given. Everything that could go wrong is checked before git is asked to do anything,
1028    /// so a refusal leaves the repository exactly as it was.
1029    pub fn create(&self, request: &CreateRequest) -> Result<CreateReport> {
1030        self.create_inner(request, false)
1031    }
1032
1033    /// The same, answering the existing canonical worktree rather than refusing when it is
1034    /// already there.
1035    pub fn ensure(&self, request: &CreateRequest) -> Result<CreateReport> {
1036        self.create_inner(request, true)
1037    }
1038
1039    fn create_inner(&self, request: &CreateRequest, ensure: bool) -> Result<CreateReport> {
1040        let name = BranchName::parse(&request.branch)?;
1041        // a branch with no place in the container is refused before the network is asked
1042        let target = path::expected_path(&self.container.path, &name)?;
1043        let primary = self.identity.primary_worktree().path.clone();
1044
1045        // The network is asked before the lock is taken, so a slow remote never holds every
1046        // other create in this repository behind it; the decision is re-checked under it.
1047        // A branch git cannot be asked about here is asked about again under the lock.
1048        let unknown_here = matches!(git::branch_exists(&primary, name.as_str()), Ok(false));
1049        let planned = if request.base.is_none() && unknown_here {
1050            Some(self.plan_start(&primary, name.as_str())?)
1051        } else {
1052            None
1053        };
1054        self.create_locked(request, ensure, name, target, planned)
1055    }
1056
1057    /// The create under the lock, given the branch and its place as `create_inner` checked
1058    /// them and what was planned before it: `planned` is `None` when
1059    /// the branch existed (or could not be asked about) before the lock, and the start is
1060    /// then planned here if the branch is gone by the time the lock is held.
1061    fn create_locked(
1062        &self,
1063        request: &CreateRequest,
1064        ensure: bool,
1065        name: BranchName,
1066        target: PathBuf,
1067        planned: Option<StartPoint>,
1068    ) -> Result<CreateReport> {
1069        let resolved_target = ResolvedPath::of(&target);
1070        let primary = self.identity.primary_worktree().path.clone();
1071
1072        // One lock around the whole check-then-act, and the topology re-read under it.
1073        let _lock = WorktreeLock::acquire(&self.identity.git_common_dir().path)?;
1074        let mut identity = self.identity.clone();
1075        identity.refresh()?;
1076
1077        if let Some(existing) = identity.record_of_branch(name.as_str()) {
1078            if ResolvedPath::of(&existing.path).same_as(&resolved_target) {
1079                if ensure {
1080                    // Found rather than made, and approved all the same: a worktree that
1081                    // exists and is blocked is what `ensure` is most often asked about.
1082                    let envrc = direnv::approve(&primary, &existing.path);
1083                    let envrc_local = direnv::link_local(&primary, &existing.path);
1084                    let hooks = hooks::carry_hooks_path(&primary, &existing.path);
1085                    return Ok(CreateReport {
1086                        path: display(&existing.path),
1087                        branch: name.as_str().to_string(),
1088                        branch_created: false,
1089                        base: None,
1090                        start: None,
1091                        start_commit: None,
1092                        container: display(&self.container.path),
1093                        container_created: false,
1094                        existed: true,
1095                        envrc,
1096                        envrc_local,
1097                        hooks,
1098                    });
1099                }
1100                return Err(WorktreeError::WorktreeAlreadyExists {
1101                    path: existing.path.clone(),
1102                    branch: name.as_str().to_string(),
1103                });
1104            }
1105            return Err(WorktreeError::BranchAlreadyCheckedOut {
1106                branch: name.as_str().to_string(),
1107                path: existing.path.clone(),
1108                expected: target,
1109            });
1110        }
1111        if let Some(d) = self.destination_conflict(&target, None) {
1112            return Err(WorktreeError::DestinationConflict {
1113                path: target,
1114                what: d.message,
1115            });
1116        }
1117        if !git::branch_name_is_valid(&primary, name.as_str())? {
1118            return Err(WorktreeError::InvalidBranchName {
1119                given: name.as_str().to_string(),
1120                reason: "git does not accept it as a reference name".into(),
1121            });
1122        }
1123        let branch_exists = git::branch_exists(&primary, name.as_str())?;
1124        let start = if branch_exists {
1125            None
1126        } else {
1127            let start = match (&request.base, planned) {
1128                (Some(given), _) => StartPoint::Given {
1129                    reference: given.clone(),
1130                },
1131                (None, Some(planned)) => planned,
1132                (None, None) => self.plan_start(&primary, name.as_str())?,
1133            };
1134            let base = start.reference();
1135            if !git::rev_exists(&primary, &base)? {
1136                return Err(WorktreeError::BaseDoesNotExist { base });
1137            }
1138            Some(start)
1139        };
1140        let base = start.as_ref().map(StartPoint::reference);
1141
1142        let container_created = self.ensure_container()?;
1143        if let Some(parent) = target.parent() {
1144            std::fs::create_dir_all(parent).map_err(|e| WorktreeError::io(parent, &e))?;
1145        }
1146        let target_text = display(&target);
1147        let mut args: Vec<String> = vec!["worktree".into(), "add".into()];
1148        match (&start, &base) {
1149            (None, _) | (_, None) => {
1150                args.push("--".into());
1151                args.push(target_text.clone());
1152                args.push(name.as_str().to_string());
1153            }
1154            (Some(start), Some(base)) => {
1155                match start {
1156                    // The same branch on both sides: it is the remote's, and tracks it.
1157                    StartPoint::RemoteBranch { .. } => args.push("--track".into()),
1158                    // New work cut from the remote's trunk does not track the trunk; its
1159                    // upstream is its own branch, set when it is first pushed.
1160                    StartPoint::RemoteTrunk { .. } => args.push("--no-track".into()),
1161                    _ => {}
1162                }
1163                args.push("-b".into());
1164                args.push(name.as_str().to_string());
1165                args.push("--".into());
1166                args.push(target_text.clone());
1167                args.push(base.clone());
1168            }
1169        }
1170        git::run(&primary, &args)?;
1171
1172        // Verify rather than assume: the answer is what the topology says.
1173        identity.refresh()?;
1174        let registered = identity
1175            .record_at(&resolved_target)
1176            .cloned()
1177            .ok_or_else(|| WorktreeError::MigrationUnsafe {
1178                path: target.clone(),
1179                reason: "git reported success and `git worktree list` does not show the worktree; \
1180                         nothing was cleaned up because that would risk deleting real work"
1181                    .into(),
1182            })?;
1183        // The path exists now; this is the moment direnv's approval is carried to it.
1184        let envrc = direnv::approve(&primary, &registered.path);
1185        let envrc_local = direnv::link_local(&primary, &registered.path);
1186        // And the moment it is bound to the hooks of its own tree, before its first commit.
1187        let hooks = hooks::carry_hooks_path(&primary, &registered.path);
1188        let start_commit = match start {
1189            // The worktree is made; a commit git cannot read back is not a reason to say not.
1190            Some(_) => git::head_of(&registered.path).ok().flatten(),
1191            None => None,
1192        };
1193        Ok(CreateReport {
1194            path: display(&registered.path),
1195            branch: name.as_str().to_string(),
1196            branch_created: base.is_some(),
1197            base,
1198            start,
1199            start_commit,
1200            container: display(&self.container.path),
1201            container_created,
1202            existed: false,
1203            envrc,
1204            envrc_local,
1205            hooks,
1206        })
1207    }
1208
1209    /// Decide where a branch that does not exist here starts, asking the trunk's remote.
1210    ///
1211    /// The trunk and the branch are fetched from the trunk's remote. A branch the remote
1212    /// has is taken up where it stands; any other starts at the remote's trunk. A remote
1213    /// that cannot be read leaves the remote-tracking refs as last fetched: a branch known
1214    /// there is still taken up, and a new one starts from the local trunk, with how far
1215    /// behind it is. A trunk with no remote is the local trunk, and no trunk at all is HEAD.
1216    fn plan_start(&self, primary: &Path, branch: &str) -> Result<StartPoint> {
1217        let Some(trunk) = self.identity.trunk().branch.clone() else {
1218            return Ok(StartPoint::Head);
1219        };
1220        let Some(remote) = trunk_remote(primary, &trunk)? else {
1221            return Ok(StartPoint::LocalTrunk {
1222                branch: trunk,
1223                unreachable: None,
1224                reason: None,
1225                behind: None,
1226            });
1227        };
1228        let tracking = |b: &str| format!("refs/remotes/{remote}/{b}");
1229        let refspec = |b: &str| format!("+refs/heads/{b}:{}", tracking(b));
1230        let trunk_fetch =
1231            git::fetch_bounded(primary, &remote, &[refspec(&trunk)], git::FETCH_TIMEOUT);
1232        if trunk_fetch.is_ok() {
1233            match git::fetch_bounded(primary, &remote, &[refspec(branch)], git::FETCH_TIMEOUT) {
1234                Ok(()) => {
1235                    return Ok(StartPoint::RemoteBranch {
1236                        remote,
1237                        branch: branch.to_string(),
1238                        fetched: true,
1239                    })
1240                }
1241                // A remote-tracking ref left behind by a branch since deleted there is
1242                // not taken up: the remote has just said it is gone.
1243                Err(git::FetchFailure::NoSuchRemoteRef) => {
1244                    return Ok(StartPoint::RemoteTrunk {
1245                        remote,
1246                        branch: trunk,
1247                    })
1248                }
1249                Err(git::FetchFailure::Unreachable(_)) => {}
1250            }
1251        }
1252        // Either fetch failed: a branch known here as last fetched is still taken up.
1253        if git::rev_exists(primary, &tracking(branch))? {
1254            return Ok(StartPoint::RemoteBranch {
1255                remote,
1256                branch: branch.to_string(),
1257                fetched: false,
1258            });
1259        }
1260        let reason = match trunk_fetch {
1261            // The trunk arrived and only the branch could not be asked about.
1262            Ok(()) => {
1263                return Ok(StartPoint::RemoteTrunk {
1264                    remote,
1265                    branch: trunk,
1266                })
1267            }
1268            Err(git::FetchFailure::NoSuchRemoteRef) => format!("{remote} has no branch {trunk}"),
1269            Err(git::FetchFailure::Unreachable(why)) => why,
1270        };
1271        let behind = git::commits_between(primary, &trunk, &tracking(&trunk));
1272        Ok(StartPoint::LocalTrunk {
1273            branch: trunk,
1274            unreachable: Some(remote),
1275            reason: Some(reason),
1276            behind,
1277        })
1278    }
1279
1280    /// Make sure the container exists, and answer whether this call created it. It lives
1281    /// beside the repository and is never committed, so it is created lazily, by the first
1282    /// worktree that needs it.
1283    pub(crate) fn ensure_container(&self) -> Result<bool> {
1284        let p = &self.container.path;
1285        if p.is_dir() {
1286            return Ok(false);
1287        }
1288        if std::fs::symlink_metadata(p).is_ok() {
1289            return Err(WorktreeError::ContainerNotADirectory { path: p.clone() });
1290        }
1291        std::fs::create_dir_all(p).map_err(|e| WorktreeError::io(p, &e))?;
1292        Ok(true)
1293    }
1294
1295    // ------------------------------------------------------------ remove
1296
1297    /// Remove one linked worktree. Never a branch, never the primary checkout, never
1298    /// uncommitted work without `force`.
1299    pub fn remove(&self, selector: &str, force: bool) -> Result<RemoveReport> {
1300        let _lock = WorktreeLock::acquire(&self.identity.git_common_dir().path)?;
1301        let mut identity = self.identity.clone();
1302        identity.refresh()?;
1303        let record = self.resolve(selector)?;
1304        if identity.is_primary(&record) {
1305            return Err(WorktreeError::PrimaryWorktreeProtected {
1306                path: record.path.clone(),
1307                operation: "remove".into(),
1308            });
1309        }
1310        let locked = record.locked.is_some();
1311        if locked && !force {
1312            return Err(WorktreeError::LockedWorktree {
1313                path: record.path.clone(),
1314                reason: record.locked.clone(),
1315            });
1316        }
1317        let mut forced = false;
1318        if record.path.is_dir() {
1319            let dirty = state::dirty_state(&record.path)?;
1320            if !dirty.clean {
1321                if !force {
1322                    return Err(WorktreeError::DirtyWorktree {
1323                        path: record.path.clone(),
1324                        summary: dirty.summary(),
1325                        operation: "remove".into(),
1326                    });
1327                }
1328                forced = true;
1329            }
1330        }
1331        let mut args: Vec<String> = vec!["worktree".into(), "remove".into()];
1332        if forced || locked {
1333            args.push("--force".into());
1334        }
1335        if locked {
1336            args.push("--force".into());
1337        }
1338        args.push("--".into());
1339        args.push(display(&record.path));
1340        git::run(&identity.primary_worktree().path, &args)?;
1341        Ok(RemoveReport {
1342            path: display(&record.path),
1343            branch: record.branch.clone(),
1344            forced: forced || locked,
1345        })
1346    }
1347
1348    // ------------------------------------------------------------ repair
1349
1350    /// Drop the registrations of work trees whose directories are gone, and let git repair
1351    /// the administrative links of the ones that exist. Deletes no directory.
1352    pub fn repair(&self, dry_run: bool) -> Result<RepairReport> {
1353        let _lock = WorktreeLock::acquire(&self.identity.git_common_dir().path)?;
1354        let primary = &self.identity.primary_worktree().path;
1355        let mut args: Vec<String> = vec!["worktree".into(), "prune".into(), "--verbose".into()];
1356        if dry_run {
1357            args.push("--dry-run".into());
1358        }
1359        let pruned = lines_of(&git::run(primary, &args)?.stdout);
1360        let repaired = if dry_run {
1361            Vec::new()
1362        } else {
1363            let mut args: Vec<String> = vec!["worktree".into(), "repair".into()];
1364            for r in self.identity.registered_worktrees().iter().skip(1) {
1365                if r.path.is_dir() {
1366                    args.push(display(&r.path));
1367                }
1368            }
1369            let out = git::run(primary, &args)?;
1370            let mut lines = lines_of(&out.stdout);
1371            lines.extend(lines_of(out.stderr.as_bytes()));
1372            lines
1373        };
1374        // With the links repaired, each linked worktree is bound to the hooks of its own
1375        // tree: the repair is how a worktree moved or made by hand rejoins the topology.
1376        let hooks = if dry_run {
1377            BTreeMap::new()
1378        } else {
1379            self.identity
1380                .registered_worktrees()
1381                .iter()
1382                .skip(1)
1383                .filter(|r| r.path.is_dir())
1384                .map(|r| (display(&r.path), hooks::carry_hooks_path(primary, &r.path)))
1385                .collect()
1386        };
1387        Ok(RepairReport {
1388            pruned,
1389            repaired,
1390            hooks,
1391            applied: !dry_run,
1392        })
1393    }
1394}
1395
1396/// Is any component of `path` below `container` a symbolic link? A link inside the container
1397/// pointing at a worktree elsewhere would make that worktree canonicalise to its expected
1398/// path; the expected path has to be a real directory, not a name for one somewhere else.
1399fn symlink_below(container: &Path, path: &Path) -> bool {
1400    let Ok(rest) = path.strip_prefix(container) else {
1401        return false;
1402    };
1403    let mut cursor = container.to_path_buf();
1404    for c in rest.components() {
1405        cursor.push(c);
1406        if std::fs::symlink_metadata(&cursor)
1407            .map(|m| m.file_type().is_symlink())
1408            .unwrap_or(false)
1409        {
1410            return true;
1411        }
1412    }
1413    false
1414}
1415
1416/// Whether a judged work tree is where it belongs: canonical, or the primary checkout on
1417/// the trunk (or with no trunk to be held to), or detached.
1418fn is_in_place(w: &WorktreeState, trunk_source: TrunkSource) -> bool {
1419    match w.standing {
1420        Standing::Canonical | Standing::Detached => true,
1421        Standing::Primary => {
1422            trunk_source == TrunkSource::Unknown
1423                || !w
1424                    .diagnostics
1425                    .iter()
1426                    .any(|d| d.code == DiagnosticCode::PrimaryOnNonTrunk)
1427        }
1428        Standing::Misplaced | Standing::Missing | Standing::Ephemeral => false,
1429    }
1430}
1431
1432/// The scratch roots the distribution declares, expanded against `primary`, in declaration
1433/// order: the tool's own first, then each provider's. Located the way every command locates
1434/// the share (`--share` excepted, which this path does not carry), from `current` — the
1435/// checkout the command runs in, so that a repository supervising itself reads the
1436/// declarations of the branch it is on rather than the primary checkout's; a checkout with
1437/// no share in reach declares none. The standard library's answer for the temporary
1438/// directory is added when the declarations name `$TMPDIR` and the environment does not, so
1439/// that what the tool itself creates under `std::env::temp_dir()` is recognised on every
1440/// platform.
1441pub fn declared_scratch_roots(current: &Path, primary: &Path) -> Vec<ScratchRoot> {
1442    let Ok(share) = crate::share::Share::locate(None, current) else {
1443        return Vec::new();
1444    };
1445    let Ok(decls) = share.providers() else {
1446        return Vec::new();
1447    };
1448    scratch_roots_from(&decls, primary)
1449}
1450
1451/// [`declared_scratch_roots`] over declarations already read.
1452pub fn scratch_roots_from(
1453    decls: &crate::share::ProviderDeclarations,
1454    primary: &Path,
1455) -> Vec<ScratchRoot> {
1456    let mut out: Vec<ScratchRoot> = decls
1457        .scratch_roots(primary)
1458        .into_iter()
1459        .map(|(provider, path)| ScratchRoot { provider, path })
1460        .collect();
1461    if decls.scratch_roots.iter().any(|r| r == "$TMPDIR") {
1462        let temp = std::env::temp_dir();
1463        if !out.iter().any(|r| r.provider.is_none() && r.path == temp) {
1464            out.push(ScratchRoot {
1465                provider: None,
1466                path: temp,
1467            });
1468        }
1469    }
1470    out
1471}
1472
1473fn lines_of(bytes: &[u8]) -> Vec<String> {
1474    String::from_utf8_lossy(bytes)
1475        .lines()
1476        .map(str::trim)
1477        .filter(|l| !l.is_empty())
1478        .map(str::to_string)
1479        .collect()
1480}
1481
1482/// A path as text. Absolute, as git holds it; nothing is shortened or made relative, because
1483/// these strings are copied into commands.
1484pub(crate) fn display(p: &Path) -> String {
1485    p.display().to_string()
1486}
1487
1488#[cfg(test)]
1489mod tests {
1490    use super::*;
1491
1492    /// The distribution beside this crate, as the shipped declarations.
1493    fn dist_declarations() -> crate::share::ProviderDeclarations {
1494        let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../share");
1495        crate::share::Share::locate(Some(&dir), &dir)
1496            .unwrap()
1497            .providers()
1498            .unwrap()
1499    }
1500
1501    /// A repository with a primary checkout and whatever linked worktrees a test asks for.
1502    /// Under the temporary directory on purpose: `ephemeral_root_of` disables the temporary
1503    /// roots for a repository that is itself inside one, so a worktree outside the container
1504    /// is `Misplaced` rather than `Ephemeral`.
1505    struct Repo {
1506        _home: tempfile::TempDir,
1507        primary: PathBuf,
1508    }
1509
1510    impl Repo {
1511        fn new() -> Self {
1512            let home = tempfile::tempdir().expect("tempdir");
1513            let primary = home.path().join("dev/repo");
1514            std::fs::create_dir_all(&primary).expect("mkdir");
1515            let r = Repo {
1516                _home: home,
1517                primary,
1518            };
1519            r.git(&["init", "-q", "-b", "master", "."]);
1520            r.git(&["config", "user.email", "t@example.com"]);
1521            r.git(&["config", "user.name", "t"]);
1522            r.git(&["commit", "-q", "--allow-empty", "-m", "root"]);
1523            r
1524        }
1525
1526        fn git(&self, args: &[&str]) {
1527            let ok = std::process::Command::new("git")
1528                .arg("-C")
1529                .arg(&self.primary)
1530                .args(args)
1531                .output()
1532                .expect("git runs")
1533                .status
1534                .success();
1535            assert!(ok, "git {args:?}");
1536        }
1537
1538        fn worktree(&self, branch: &str, at: &Path) {
1539            if let Some(parent) = at.parent() {
1540                std::fs::create_dir_all(parent).expect("mkdir");
1541            }
1542            self.git(&["worktree", "add", "-q", "-b", branch, &at.to_string_lossy()]);
1543        }
1544
1545        fn service(&self) -> WorktreeService {
1546            WorktreeService::open(&self.primary).expect("a service")
1547        }
1548    }
1549
1550    #[test]
1551    fn a_worktree_resolves_by_branch_or_by_path_and_says_so_when_it_is_neither() {
1552        let repo = Repo::new();
1553        let svc = repo.service();
1554        let home = svc.expected_path_of("feature/x").expect("a path");
1555        repo.worktree("feature/x", &home);
1556        let svc = repo.service();
1557
1558        // by branch
1559        let by_branch = svc.resolve("feature/x").expect("resolved by branch");
1560        assert_eq!(by_branch.branch.as_deref(), Some("feature/x"));
1561        // by path, which is the same record
1562        let by_path = svc
1563            .resolve(&home.to_string_lossy())
1564            .expect("resolved by path");
1565        assert_eq!(by_path.path, by_branch.path);
1566        // and a selector that is neither names itself in the refusal
1567        let err = svc.resolve("feature/never").expect_err("no such worktree");
1568        assert!(err.to_string().contains("feature/never"), "{err}");
1569    }
1570
1571    #[test]
1572    fn a_branch_whose_worktree_is_home_passes_the_guard_and_one_that_is_not_does_not() {
1573        let repo = Repo::new();
1574        let home = repo
1575            .service()
1576            .expected_path_of("feature/home")
1577            .expect("path");
1578        repo.worktree("feature/home", &home);
1579
1580        // the guard is asked from inside a worktree; open the service there
1581        let inside = WorktreeService::open(&home).expect("a service inside the worktree");
1582        let verdict = inside.guard().expect("a verdict");
1583        assert!(verdict.ok, "{verdict:?}");
1584        assert!(verdict.reason.is_none());
1585        assert_eq!(verdict.branch.as_deref(), Some("feature/home"));
1586
1587        // and one that is misplaced fails it, with the reason and the destination
1588        let away = repo.primary.parent().unwrap().join("elsewhere");
1589        repo.worktree("feature/away", &away);
1590        let outside = WorktreeService::open(&away).expect("a service inside the worktree");
1591        let verdict = outside.guard().expect("a verdict");
1592        assert!(!verdict.ok, "{verdict:?}");
1593        assert!(verdict.reason.is_some(), "a refusal carries its reason");
1594        assert!(verdict.expected_path.is_some(), "and where it belongs");
1595    }
1596
1597    #[test]
1598    fn the_expected_path_of_a_branch_is_under_the_container_and_a_bad_name_is_refused() {
1599        let repo = Repo::new();
1600        let svc = repo.service();
1601        let container = svc.container().path.clone();
1602
1603        let path = svc.expected_path_of("feature/nested/deep").expect("a path");
1604        assert!(path.starts_with(&container), "{path:?}");
1605        assert!(path.ends_with("feature/nested/deep"), "{path:?}");
1606
1607        // a name that would climb out of the container is refused rather than resolved
1608        for bad in ["../escape", "..", "feature/../../escape"] {
1609            assert!(
1610                svc.expected_path_of(bad).is_err(),
1611                "{bad} resolved to a path"
1612            );
1613        }
1614    }
1615
1616    #[test]
1617    fn inspecting_a_branch_reports_where_it_belongs_whether_or_not_it_is_there() {
1618        let repo = Repo::new();
1619        let svc = repo.service();
1620
1621        // a branch with no worktree still has a canonical path
1622        let absent = svc.inspect("feature/absent").expect("a report");
1623        assert!(
1624            absent.expected_path.contains("feature/absent"),
1625            "{}",
1626            absent.expected_path
1627        );
1628
1629        let home = svc.expected_path_of("feature/there").expect("a path");
1630        repo.worktree("feature/there", &home);
1631        let present = repo.service().inspect("feature/there").expect("a report");
1632        assert_eq!(present.expected_path, display(&home));
1633    }
1634
1635    #[test]
1636    fn the_declared_scratch_roots_name_the_temporary_directory_and_every_providers_own() {
1637        let primary = Path::new("/srv/repo");
1638        let roots = scratch_roots_from(&dist_declarations(), primary);
1639        let own: Vec<&PathBuf> = roots
1640            .iter()
1641            .filter(|r| r.provider.is_none())
1642            .map(|r| &r.path)
1643            .collect();
1644        assert!(
1645            own.contains(&&std::env::temp_dir()),
1646            "the tool's own roots hold the temporary directory: {own:?}"
1647        );
1648        assert!(own.contains(&&PathBuf::from("/tmp")));
1649        let claude = roots
1650            .iter()
1651            .find(|r| r.provider.as_deref() == Some("claude-code"))
1652            .expect("claude-code declares a root");
1653        assert_eq!(
1654            claude.path,
1655            PathBuf::from("/srv/repo/.claude/worktrees"),
1656            "expanded against the primary checkout"
1657        );
1658        let bb = roots
1659            .iter()
1660            .find(|r| r.provider.as_deref() == Some("bb"))
1661            .expect("bb declares a root");
1662        assert!(
1663            bb.path
1664                .ends_with("plugins/environment-git-worktree/host-data/worktrees"),
1665            "{}",
1666            bb.path.display()
1667        );
1668        let home = std::env::var("HOME").unwrap();
1669        assert!(
1670            bb.path
1671                .starts_with(std::env::var("BB_DATA_DIR").unwrap_or(format!("{home}/.bb"))),
1672            "the data directory defaults to ~/.bb: {}",
1673            bb.path.display()
1674        );
1675    }
1676
1677    #[test]
1678    fn a_checkout_under_an_orchestrators_root_is_a_scratch_checkout_named_after_it() {
1679        let home = tempfile::tempdir().unwrap();
1680        let primary = home.path().join("elsewhere/repo");
1681        std::fs::create_dir_all(&primary).unwrap();
1682        let run = |args: &[&str]| {
1683            assert!(std::process::Command::new("git")
1684                .arg("-C")
1685                .arg(&primary)
1686                .args(args)
1687                .status()
1688                .unwrap()
1689                .success());
1690        };
1691        run(&["init", "-q", "."]);
1692        run(&[
1693            "-c",
1694            "user.email=t@example.com",
1695            "-c",
1696            "user.name=t",
1697            "commit",
1698            "-q",
1699            "--allow-empty",
1700            "-m",
1701            "i",
1702        ]);
1703        let identity = RepositoryIdentity::discover(&primary).unwrap();
1704        // the declarations expanded with bb's data directory pointed into the fixture, so
1705        // that nothing about this machine's own installation is read
1706        let data = home.path().join("bb-data");
1707        let roots: Vec<ScratchRoot> = scratch_roots_from(&dist_declarations(), &primary)
1708            .into_iter()
1709            .map(|r| match r.provider.as_deref() {
1710                Some("bb") => ScratchRoot {
1711                    provider: r.provider,
1712                    path: data.join("plugins/environment-git-worktree/host-data/worktrees"),
1713                },
1714                _ => r,
1715            })
1716            .collect();
1717        let svc = WorktreeService::over_with(identity, roots).unwrap();
1718        let thread = ResolvedPath::of(
1719            data.join("plugins/environment-git-worktree/host-data/worktrees/thr_1/repo"),
1720        );
1721        let root = svc
1722            .scratch_root_of(&thread)
1723            .expect("a thread's checkout is a scratch checkout");
1724        assert_eq!(root.provider.as_deref(), Some("bb"));
1725        let sibling = ResolvedPath::of(home.path().join("sibling"));
1726        assert!(svc.scratch_root_of(&sibling).is_none());
1727    }
1728
1729    #[test]
1730    fn a_checkout_under_the_temporary_directory_is_ephemeral_unless_the_repository_lives_there() {
1731        // A real repository whose primary checkout is *outside* the temporary directory: a
1732        // worktree under it is a session's scratch checkout.
1733        let home = tempfile::tempdir().unwrap();
1734        let primary = home.path().join("elsewhere/repo");
1735        std::fs::create_dir_all(&primary).unwrap();
1736        let run = |args: &[&str]| {
1737            assert!(std::process::Command::new("git")
1738                .arg("-C")
1739                .arg(&primary)
1740                .args(args)
1741                .status()
1742                .unwrap()
1743                .success());
1744        };
1745        run(&["init", "-q", "."]);
1746        run(&[
1747            "-c",
1748            "user.email=t@example.com",
1749            "-c",
1750            "user.name=t",
1751            "commit",
1752            "-q",
1753            "--allow-empty",
1754            "-m",
1755            "i",
1756        ]);
1757        let identity = RepositoryIdentity::discover(&primary).unwrap();
1758        let svc = WorktreeService::over_with(
1759            identity,
1760            scratch_roots_from(&dist_declarations(), &primary),
1761        )
1762        .unwrap();
1763        // the fixture itself lives under the temporary directory, so the temporary roots are
1764        // disabled for it, and only the agent directory rule applies
1765        let agent = ResolvedPath::of(primary.join(".claude/worktrees/x"));
1766        assert_eq!(
1767            svc.scratch_root_of(&agent).unwrap().provider.as_deref(),
1768            Some("claude-code")
1769        );
1770        let sibling = ResolvedPath::of(home.path().join("sibling"));
1771        assert!(svc.ephemeral_root_of(&sibling).is_none(), "the repository lives in the temporary directory, so nothing beside it is a scratch checkout");
1772    }
1773
1774    /// A report written before the hooks were carried reads as hooks that were not
1775    /// configured, never as a parse failure.
1776    #[test]
1777    fn a_create_report_without_hooks_reads_as_not_configured() {
1778        assert_eq!(default_hooks(), HooksPath::NotConfigured);
1779    }
1780
1781    /// A repair binds every existing linked worktree to its hooks and says which, by path;
1782    /// a dry run changes nothing and binds nothing.
1783    #[test]
1784    fn a_repair_names_the_hooks_of_every_linked_worktree_and_a_dry_run_none() {
1785        let repo = Repo::new();
1786        let home = repo.service().expected_path_of("topic").expect("a path");
1787        repo.worktree("topic", &home);
1788        let svc = repo.service();
1789
1790        let dry = svc.repair(true).expect("a dry run");
1791        assert!(!dry.applied);
1792        assert!(dry.hooks.is_empty(), "{dry:?}");
1793
1794        let applied = svc.repair(false).expect("a repair");
1795        assert!(applied.applied);
1796        let paths: Vec<&String> = applied.hooks.keys().collect();
1797        assert_eq!(paths.len(), 1, "{applied:?}");
1798        assert!(paths[0].ends_with("topic"), "{applied:?}");
1799    }
1800
1801    /// Run git in the primary checkout with arguments that need not be UTF-8.
1802    fn git_bytes(repo: &Repo, args: &[&[u8]]) {
1803        use std::os::unix::ffi::OsStrExt;
1804        let ok = std::process::Command::new("git")
1805            .arg("-C")
1806            .arg(&repo.primary)
1807            .args(args.iter().map(|a| std::ffi::OsStr::from_bytes(a)))
1808            .output()
1809            .expect("git runs")
1810            .status
1811            .success();
1812        assert!(ok, "git {args:?}");
1813    }
1814
1815    /// A repository whose trunk is pushed to a bare `origin` beside it and tracks it there.
1816    fn with_origin() -> (Repo, PathBuf) {
1817        let repo = Repo::new();
1818        let origin = repo._home.path().join("origin.git");
1819        let url = origin.to_string_lossy().to_string();
1820        repo.git(&["init", "-q", "--bare", &url]);
1821        repo.git(&["remote", "add", "origin", &url]);
1822        repo.git(&["push", "-q", "-u", "origin", "master"]);
1823        (repo, origin)
1824    }
1825
1826    /// Put `branch` on the remote one commit ahead of the trunk without leaving a local
1827    /// branch or a remote-tracking ref for it.
1828    fn push_only(repo: &Repo, branch: &str) {
1829        repo.git(&["checkout", "-q", "-b", "side"]);
1830        repo.git(&["commit", "-q", "--allow-empty", "-m", "side"]);
1831        repo.git(&["push", "-q", "origin", &format!("side:{branch}")]);
1832        repo.git(&["checkout", "-q", "master"]);
1833        repo.git(&["branch", "-q", "-D", "side"]);
1834        repo.git(&["update-ref", "-d", &format!("refs/remotes/origin/{branch}")]);
1835    }
1836
1837    fn request(branch: &str, base: Option<&str>) -> CreateRequest {
1838        CreateRequest {
1839            branch: branch.into(),
1840            base: base.map(str::to_string),
1841        }
1842    }
1843
1844    #[test]
1845    fn a_start_point_names_its_ref_and_says_when_it_is_not_the_remotes_state() {
1846        let given = StartPoint::Given {
1847            reference: "v1.0".into(),
1848        };
1849        assert_eq!(given.reference(), "v1.0");
1850        assert_eq!(given.fallback(), None);
1851        assert_eq!(StartPoint::Head.reference(), "HEAD");
1852        assert_eq!(StartPoint::Head.fallback(), None);
1853        let local = |behind, reason: Option<&str>| StartPoint::LocalTrunk {
1854            branch: "master".into(),
1855            unreachable: Some("origin".into()),
1856            reason: reason.map(str::to_string),
1857            behind,
1858        };
1859        assert_eq!(local(Some(0), None).reference(), "master");
1860        let level = local(Some(0), None).fallback().expect("said");
1861        assert!(level.contains("(it did not answer)"), "{level}");
1862        assert!(level.contains("level with origin/master"), "{level}");
1863        let one = local(Some(1), Some("offline")).fallback().expect("said");
1864        assert!(one.contains("(offline)"), "{one}");
1865        assert!(one.contains("1 commit behind origin/master"), "{one}");
1866        let many = local(Some(4), Some("offline")).fallback().expect("said");
1867        assert!(many.contains("4 commits behind origin/master"), "{many}");
1868        let never = local(None, Some("offline")).fallback().expect("said");
1869        assert!(never.contains("was never fetched"), "{never}");
1870        let alone = StartPoint::LocalTrunk {
1871            branch: "master".into(),
1872            unreachable: None,
1873            reason: None,
1874            behind: None,
1875        };
1876        assert_eq!(alone.fallback(), None);
1877        let stale = StartPoint::RemoteBranch {
1878            remote: "origin".into(),
1879            branch: "fix/x".into(),
1880            fetched: false,
1881        };
1882        assert_eq!(stale.reference(), "origin/fix/x");
1883        let said = stale.fallback().expect("said");
1884        assert!(
1885            said.contains("tracking origin/fix/x as last fetched"),
1886            "{said}"
1887        );
1888        let fresh = StartPoint::RemoteBranch {
1889            remote: "origin".into(),
1890            branch: "fix/x".into(),
1891            fetched: true,
1892        };
1893        assert_eq!(fresh.fallback(), None);
1894    }
1895
1896    #[test]
1897    fn the_trunks_remote_is_the_one_whose_head_names_it_else_the_configured_one() {
1898        let home = tempfile::tempdir().expect("tempdir");
1899        // no repository there: `git remote` refuses, and no remote is named
1900        let absent = home.path().join("absent");
1901        assert_eq!(trunk_remote(&absent, "master").expect("an answer"), None);
1902        // a git that cannot be run is an error, not an answer
1903        assert!(trunk_remote(Path::new("no\0where"), "master").is_err());
1904
1905        let (repo, _origin) = with_origin();
1906        let p = repo.primary.clone();
1907        // pushed with -u: the configuration names origin, which has no HEAD
1908        assert_eq!(
1909            trunk_remote(&p, "master").expect("an answer"),
1910            Some("origin".into())
1911        );
1912        // a HEAD that names another branch is passed over; one that names the trunk wins
1913        let other = repo._home.path().join("other.git");
1914        repo.git(&["init", "-q", "--bare", &other.to_string_lossy()]);
1915        repo.git(&["remote", "add", "other", &other.to_string_lossy()]);
1916        repo.git(&[
1917            "symbolic-ref",
1918            "refs/remotes/origin/HEAD",
1919            "refs/remotes/origin/develop",
1920        ]);
1921        repo.git(&[
1922            "symbolic-ref",
1923            "refs/remotes/other/HEAD",
1924            "refs/remotes/other/master",
1925        ]);
1926        assert_eq!(
1927            trunk_remote(&p, "master").expect("an answer"),
1928            Some("other".into())
1929        );
1930        // a trunk configured to a remote that is not there lives only here
1931        repo.git(&["config", "branch.lonely.remote", "gone"]);
1932        assert_eq!(trunk_remote(&p, "lonely").expect("an answer"), None);
1933        // a trunk git cannot be asked about is an error
1934        assert!(trunk_remote(&p, "ma\0ster").is_err());
1935        // a configured remote that is not text is an error
1936        git_bytes(&repo, &[b"config", b"branch.odd.remote", b"o\xff"]);
1937        assert!(trunk_remote(&p, "odd").is_err());
1938        // a HEAD that is not text is an error
1939        repo.git(&["symbolic-ref", "-d", "refs/remotes/other/HEAD"]);
1940        std::fs::write(
1941            p.join(".git/refs/remotes/origin/HEAD"),
1942            b"ref: refs/remotes/origin/\xff\n",
1943        )
1944        .expect("write");
1945        assert!(trunk_remote(&p, "master").is_err());
1946    }
1947
1948    #[test]
1949    fn a_remote_whose_name_is_not_text_refuses_the_create_before_anything_is_made() {
1950        let (repo, _origin) = with_origin();
1951        let svc = repo.service();
1952        git_bytes(&repo, &[b"remote", b"add", b"o\xff", b"/nowhere"]);
1953        assert!(trunk_remote(&repo.primary, "master").is_err());
1954        assert!(svc.create(&request("feature/odd", None)).is_err());
1955        // and so is one whose start is planned only under the lock
1956        let (name, target) = placed(&svc, "feature/odd");
1957        assert!(svc
1958            .create_locked(&request("feature/odd", None), false, name, target, None)
1959            .is_err());
1960        assert!(!svc
1961            .expected_path_of("feature/odd")
1962            .expect("a path")
1963            .exists());
1964    }
1965
1966    #[test]
1967    fn a_repository_with_no_trunk_starts_from_head_and_one_with_no_remote_from_the_trunk() {
1968        let repo = Repo::new();
1969        assert_eq!(
1970            repo.service()
1971                .plan_start(&repo.primary, "feature/x")
1972                .expect("a plan"),
1973            StartPoint::LocalTrunk {
1974                branch: "master".into(),
1975                unreachable: None,
1976                reason: None,
1977                behind: None,
1978            }
1979        );
1980        repo.git(&["branch", "-m", "master", "topic"]);
1981        repo.git(&["checkout", "-q", "--detach"]);
1982        let svc = repo.service();
1983        if svc.identity.trunk().branch.is_some() {
1984            eprintln!("a trunk was found in the environment's configuration; nothing to test");
1985            return;
1986        }
1987        assert_eq!(
1988            svc.plan_start(&repo.primary, "feature/x").expect("a plan"),
1989            StartPoint::Head
1990        );
1991        let made = svc.create(&request("feature/x", None)).expect("created");
1992        assert_eq!(made.base.as_deref(), Some("HEAD"));
1993    }
1994
1995    #[test]
1996    fn the_remote_decides_where_a_branch_starts_and_its_last_fetched_refs_when_it_cannot() {
1997        let (repo, origin) = with_origin();
1998        let p = repo.primary.clone();
1999        let svc = repo.service();
2000        let plan = |b: &str| svc.plan_start(&p, b).expect("a plan");
2001        let remote_trunk = StartPoint::RemoteTrunk {
2002            remote: "origin".into(),
2003            branch: "master".into(),
2004        };
2005
2006        // the remote has it: taken up as it is there
2007        push_only(&repo, "fix/theirs");
2008        assert_eq!(
2009            plan("fix/theirs"),
2010            StartPoint::RemoteBranch {
2011                remote: "origin".into(),
2012                branch: "fix/theirs".into(),
2013                fetched: true,
2014            }
2015        );
2016        // the remote does not have it: the remote's trunk
2017        assert_eq!(plan("feature/new"), remote_trunk);
2018
2019        // the trunk arrives and the branch cannot be written here: never fetched, the
2020        // remote's trunk; fetched before, as last fetched
2021        push_only(&repo, "fix/locked");
2022        let lock = p.join(".git/refs/remotes/origin/fix/locked.lock");
2023        std::fs::create_dir_all(lock.parent().expect("a parent")).expect("mkdir");
2024        std::fs::write(&lock, "").expect("write");
2025        assert_eq!(plan("fix/locked"), remote_trunk);
2026        std::fs::remove_file(&lock).expect("unlock");
2027        repo.git(&["update-ref", "refs/remotes/origin/fix/locked", "master"]);
2028        std::fs::write(&lock, "").expect("lock again");
2029        assert_eq!(
2030            plan("fix/locked"),
2031            StartPoint::RemoteBranch {
2032                remote: "origin".into(),
2033                branch: "fix/locked".into(),
2034                fetched: false,
2035            }
2036        );
2037        std::fs::remove_file(&lock).expect("unlock");
2038
2039        // the remote cannot be read: a branch known here is taken up, a new one starts
2040        // from the local trunk, level with the trunk as last fetched
2041        let gone = origin.with_file_name("gone.git");
2042        repo.git(&["remote", "set-url", "origin", &gone.to_string_lossy()]);
2043        assert_eq!(
2044            plan("fix/locked"),
2045            StartPoint::RemoteBranch {
2046                remote: "origin".into(),
2047                branch: "fix/locked".into(),
2048                fetched: false,
2049            }
2050        );
2051        match plan("feature/offline") {
2052            StartPoint::LocalTrunk {
2053                branch,
2054                unreachable,
2055                reason,
2056                behind,
2057            } => {
2058                assert_eq!(branch, "master");
2059                assert_eq!(unreachable.as_deref(), Some("origin"));
2060                assert!(reason.is_some());
2061                assert_eq!(behind, Some(0));
2062            }
2063            other => panic!("{other:?}"),
2064        }
2065        // a branch git cannot be asked about is an error, not a guess
2066        assert!(svc.plan_start(&p, "feature/no\0pe").is_err());
2067
2068        // a remote without the trunk says so, and how far behind is unknown
2069        let empty = origin.with_file_name("empty.git");
2070        repo.git(&["init", "-q", "--bare", &empty.to_string_lossy()]);
2071        repo.git(&["remote", "set-url", "origin", &empty.to_string_lossy()]);
2072        repo.git(&["update-ref", "-d", "refs/remotes/origin/master"]);
2073        assert_eq!(
2074            plan("feature/empty"),
2075            StartPoint::LocalTrunk {
2076                branch: "master".into(),
2077                unreachable: Some("origin".into()),
2078                reason: Some("origin has no branch master".into()),
2079                behind: None,
2080            }
2081        );
2082    }
2083
2084    #[test]
2085    fn a_given_base_is_taken_as_given_and_an_existing_branch_is_checked_out_as_it_is() {
2086        let repo = Repo::new();
2087        let svc = repo.service();
2088        let made = svc
2089            .create(&request("feature/given", Some("master")))
2090            .expect("created");
2091        assert_eq!(
2092            made.start,
2093            Some(StartPoint::Given {
2094                reference: "master".into()
2095            })
2096        );
2097        assert!(made.branch_created);
2098        assert!(made.start_commit.is_some());
2099        assert!(svc
2100            .create(&request("feature/nothing", Some("no-such-ref")))
2101            .is_err());
2102
2103        repo.git(&["branch", "feature/there"]);
2104        let svc = repo.service();
2105        let made = svc
2106            .create(&request("feature/there", None))
2107            .expect("created");
2108        assert!(!made.branch_created);
2109        assert_eq!(made.start, None);
2110        assert_eq!(made.base, None);
2111        assert_eq!(made.start_commit, None);
2112    }
2113
2114    /// A branch deleted between the look taken before the lock and the one taken under it is
2115    /// planned under the lock, rather than created from nowhere.
2116    /// A branch and the place `create_inner` would have checked for it.
2117    fn placed(svc: &WorktreeService, branch: &str) -> (BranchName, PathBuf) {
2118        let name = BranchName::parse(branch).expect("a branch name");
2119        let target = path::expected_path(&svc.container.path, &name).expect("a place");
2120        (name, target)
2121    }
2122
2123    #[test]
2124    fn a_branch_deleted_while_the_create_waits_for_the_lock_is_planned_under_it() {
2125        // The branch existed when the create looked before the lock, so nothing was planned;
2126        // by the time the lock is held it is gone, and the start is planned under the lock.
2127        let repo = Repo::new();
2128        let svc = repo.service();
2129        let (name, target) = placed(&svc, "feature/raced");
2130        let made = svc
2131            .create_locked(&request("feature/raced", None), false, name, target, None)
2132            .expect("created");
2133        assert!(made.branch_created);
2134        assert_eq!(
2135            made.start,
2136            Some(StartPoint::LocalTrunk {
2137                branch: "master".into(),
2138                unreachable: None,
2139                reason: None,
2140                behind: None,
2141            })
2142        );
2143    }
2144}