Skip to main content

mkit_core/
layout.rs

1//! Repository path layout: the single authority for resolving on-disk
2//! state under `.mkit/` (issue #493, Phase 0).
3//!
4//! Every piece of repository state is classified into exactly one of two
5//! directories:
6//!
7//! - the **common dir** — state shared by every working tree of the
8//!   repository: the object store, refs, config, signing keys, the
9//!   history MMR, the recovery log, attestations, transport caches;
10//! - the **worktree state dir** — state private to one working tree:
11//!   `HEAD`, the staging index, in-progress-operation files
12//!   (`MERGE_HEAD`, `rebase-apply/`, …), the stash, and the worktree
13//!   lock.
14//!
15//! In the classic single-worktree layout both directories are the same
16//! `<root>/.mkit/`, so [`RepoLayout::single`] produces byte-identical
17//! paths to the historical ad-hoc joins. In a **linked** working tree
18//! (#493 Phase 1) they differ: the linked tree's per-tree state lives
19//! under the main repository's `.mkit/worktrees/<id>/`, and the linked
20//! tree's own `.mkit` is a plain FILE — the pointer file — instead of
21//! a directory. Nothing outside this module may assume the two
22//! directories coincide.
23//!
24//! # Linked-worktree on-disk model (#493 Phase 1)
25//!
26//! ```text
27//! <main>/.mkit/                       # common dir (shared state)
28//!   worktrees/<id>/                   # one per linked tree
29//!     commondir                       # path to the common dir, `../..`
30//!     mkitdir                         # abs path of the tree's pointer file
31//!     HEAD, index, ORIG_HEAD, ...     # per-tree state, as classified below
32//! <linked-tree>/.mkit                 # pointer FILE, not a directory:
33//!     `mkitdir: <path to .mkit/worktrees/<id>>\n`
34//! ```
35//!
36//! The pointer path may be absolute or relative to the linked tree
37//! root; `commondir` may be absolute or relative to the state dir.
38//! Both files are UTF-8, single-line, LF-terminated, and capped at
39//! [`MAX_POINTER_FILE_BYTES`]. Discovery ([`discover`]) fails closed on
40//! any malformed or dangling pointer; a `.mkit` DIRECTORY (every
41//! pre-Phase-1 repository) always resolves to the single-worktree
42//! layout, byte-identical to before.
43//!
44//! # Classification table
45//!
46//! | Path (relative)          | Class    | Owner module            |
47//! |--------------------------|----------|-------------------------|
48//! | `objects/`               | common   | [`crate::store`]        |
49//! | `format`                 | common   | [`crate::store`]        |
50//! | `refs/` (+`heads`,`tags`,`remotes`) | common | [`crate::refs`] |
51//! | `shallow`                | common   | [`crate::refs`]         |
52//! | `config`                 | common   | CLI config              |
53//! | `keys/`                  | common   | CLI config              |
54//! | `history/`               | common   | [`crate::history`] (feature-gated) |
55//! | `recovery-log`           | common   | [`crate::ops::recovery`] |
56//! | `attestations/`          | common   | `mkit-attest`           |
57//! | `applied-packs/`         | common   | CLI remote dispatch (redownload cache, never a gc root) |
58//! | `git/`                   | common   | `mkit-git-bridge`       |
59//! | `sparse/`                | common   | CLI sparse bitmap cache |
60//! | `pack-shards/`           | common   | CLI pack-shard output   |
61//! | `HEAD`                   | worktree | [`crate::refs`]         |
62//! | `index`                  | worktree | [`crate::index`]        |
63//! | `ORIG_HEAD`              | worktree | [`crate::ops::conflict_state`] |
64//! | `MERGE_HEAD`/`MERGE_MSG` | worktree | [`crate::ops::conflict_state`] |
65//! | `CHERRY_PICK_HEAD`/`_MSG`| worktree | [`crate::ops::conflict_state`] |
66//! | `REVERT_HEAD`/`_MSG`     | worktree | [`crate::ops::conflict_state`] |
67//! | `mkit-conflicts`         | worktree | [`crate::ops::conflict_state`] |
68//! | `MKIT_OP_RESULT`         | worktree | [`crate::ops::conflict_state`] |
69//! | `rebase-apply/`          | worktree | [`crate::ops::rebase`]  |
70//! | `bisect`                 | worktree | [`crate::ops::bisect`]  |
71//! | `stash`                  | worktree | [`crate::ops::stash`]   |
72//! | `sparse-checkout`        | worktree | [`crate::ops::restore`] |
73//! | `worktree.lock`          | worktree | CLI lock helper         |
74//!
75//! Rationale for the git-divergent entries: `shallow` is shared because
76//! it constrains the one shared object graph; the stash is per-worktree
77//! (unlike git's `refs/stash`) because mkit's stash is a worktree-state
78//! manifest, not a ref — #493 specifies stash as tree-local.
79//!
80//! # Invariants
81//!
82//! - Both directories always end in a final `.mkit` component (a linked
83//!   tree's state dir will live *under* the main `.mkit`; that still
84//!   satisfies the prefix rule below).
85//! - Every accessor resolves strictly inside `common_dir()` or
86//!   `worktree_state_dir()`; no accessor ever escapes them.
87//! - [`RepoLayout::single`] guarantees `common_dir() ==
88//!   worktree_state_dir() == worktree_root().join(".mkit")`.
89
90use std::path::{Path, PathBuf};
91
92use crate::ops::bisect::BISECT_FILE;
93use crate::ops::conflict_state::{
94    CHERRY_PICK_HEAD, CHERRY_PICK_MSG, CONFLICTS_FILE, MERGE_HEAD, MERGE_MSG, ORIG_HEAD,
95    RESULT_TREE, REVERT_HEAD, REVERT_MSG,
96};
97use crate::ops::rebase::REBASE_DIR;
98use crate::ops::recovery::RECOVERY_LOG;
99use crate::refs::{HEAD_FILE, HEADS_DIR, REFS_DIR, REMOTES_DIR, SHALLOW_FILE, TAGS_DIR};
100use crate::store::{FORMAT_FILE, MKIT_DIR, OBJECTS_DIR};
101
102/// Commit-history MMR directory name under the common dir. Canonical
103/// here (rather than in [`crate::history`]) because that module is
104/// feature-gated while the layout is not; `history::HISTORY_DIR`
105/// re-points at this constant.
106pub const HISTORY_DIR_NAME: &str = "history";
107/// Config file name under the common dir (written by the CLI).
108pub const CONFIG_FILE_NAME: &str = "config";
109/// Repository signing-key directory name under the common dir.
110pub const KEYS_DIR_NAME: &str = "keys";
111/// Staging-index file name under the worktree state dir.
112pub const INDEX_FILE_NAME: &str = "index";
113/// Stash manifest file name under the worktree state dir.
114pub const STASH_FILE_NAME: &str = "stash";
115/// Sparse-checkout filter file name under the worktree state dir.
116pub const SPARSE_CHECKOUT_FILE_NAME: &str = "sparse-checkout";
117/// Attestation store directory name under the common dir.
118pub const ATTESTATIONS_DIR_NAME: &str = "attestations";
119/// Per-remote applied-pack record directory name under the common dir.
120/// A redownload-avoidance cache — never a gc root source (#409).
121pub const APPLIED_PACKS_DIR_NAME: &str = "applied-packs";
122/// Git-bridge per-remote state directory name under the common dir.
123pub const GIT_STATE_DIR_NAME: &str = "git";
124/// Sparse bitmap-cache directory name under the common dir.
125pub const SPARSE_CACHE_DIR_NAME: &str = "sparse";
126/// Default pack-shard output directory name under the common dir.
127pub const PACK_SHARDS_DIR_NAME: &str = "pack-shards";
128/// Directory under the common dir holding one per-tree state dir per
129/// linked worktree.
130pub const WORKTREES_DIR_NAME: &str = "worktrees";
131/// Prefix of the linked-tree pointer file (`<tree>/.mkit` as a FILE):
132/// `mkitdir: <path>\n` — the analog of git's `gitdir:` file.
133pub const POINTER_PREFIX: &str = "mkitdir: ";
134/// File inside a per-tree state dir recording the path back to the
135/// common dir (relative to the state dir, or absolute). Written as
136/// `../..` by `worktree add`.
137pub const COMMONDIR_FILE_NAME: &str = "commondir";
138/// File inside a per-tree state dir recording the absolute path of the
139/// linked tree's pointer file — the back-pointer `worktree prune`
140/// checks before deleting a state dir.
141pub const BACKPOINTER_FILE_NAME: &str = "mkitdir";
142/// Hard cap on the pointer, `commondir`, and back-pointer files. Far
143/// above any real path, small enough that a corrupt or hostile file
144/// cannot balloon discovery.
145pub const MAX_POINTER_FILE_BYTES: u64 = 4096;
146
147/// Resolved repository layout: worktree root plus the two state
148/// directories (see the module docs for the classification table).
149///
150/// Cheap to clone; construction never touches the filesystem.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct RepoLayout {
153    /// Directory containing the working files (the parent of `.mkit`
154    /// in the single-worktree layout).
155    worktree_root: PathBuf,
156    /// Shared state directory (`<main root>/.mkit`).
157    common_dir: PathBuf,
158    /// Per-worktree state directory. Equal to `common_dir` in the
159    /// single-worktree layout.
160    worktree_state_dir: PathBuf,
161}
162
163impl RepoLayout {
164    /// Layout of a classic single-worktree repository rooted at
165    /// `worktree_root`: common dir and worktree state dir are both
166    /// `<worktree_root>/.mkit`.
167    #[must_use]
168    pub fn single(worktree_root: impl Into<PathBuf>) -> Self {
169        let worktree_root = worktree_root.into();
170        let mkit = worktree_root.join(MKIT_DIR);
171        Self {
172            worktree_root,
173            common_dir: mkit.clone(),
174            worktree_state_dir: mkit,
175        }
176    }
177
178    /// The working-tree root (directory whose files are under version
179    /// control).
180    #[must_use]
181    pub fn worktree_root(&self) -> &Path {
182        &self.worktree_root
183    }
184
185    /// Shared state directory. Everything in it is common to all
186    /// working trees of the repository.
187    #[must_use]
188    pub fn common_dir(&self) -> &Path {
189        &self.common_dir
190    }
191
192    /// Per-worktree state directory. Everything in it belongs to this
193    /// working tree only.
194    #[must_use]
195    pub fn worktree_state_dir(&self) -> &Path {
196        &self.worktree_state_dir
197    }
198
199    /// `true` when common dir and worktree state dir coincide (the
200    /// classic single-worktree layout).
201    #[must_use]
202    pub fn is_single(&self) -> bool {
203        self.common_dir == self.worktree_state_dir
204    }
205
206    /// Layout of a linked working tree (#493 Phase 1): working files at
207    /// `worktree_root`, per-tree state in `worktree_state_dir` (a
208    /// `worktrees/<id>` dir under the main repository's common dir),
209    /// shared state in `common_dir`.
210    ///
211    /// Pure construction — no filesystem access, no validation beyond
212    /// types. Production code obtains linked layouts via [`discover`],
213    /// which validates the on-disk pointers; this constructor is the
214    /// seam `discover` and `worktree add` build on.
215    #[must_use]
216    pub fn linked(
217        worktree_root: impl Into<PathBuf>,
218        worktree_state_dir: impl Into<PathBuf>,
219        common_dir: impl Into<PathBuf>,
220    ) -> Self {
221        Self {
222            worktree_root: worktree_root.into(),
223            common_dir: common_dir.into(),
224            worktree_state_dir: worktree_state_dir.into(),
225        }
226    }
227
228    /// `worktrees/` — the common-dir directory holding every linked
229    /// tree's per-tree state dir.
230    #[must_use]
231    pub fn worktrees_dir(&self) -> PathBuf {
232        self.common_dir.join(WORKTREES_DIR_NAME)
233    }
234
235    /// The per-tree state dir a linked worktree with `id` would use:
236    /// `worktrees/<id>` under the common dir. The caller must have
237    /// validated `id` via [`validate_worktree_id`].
238    #[must_use]
239    pub fn worktree_state_dir_for(&self, id: &str) -> PathBuf {
240        self.worktrees_dir().join(id)
241    }
242
243    // ------------------------------------------------------------------
244    // Common-dir (shared) state.
245    // ------------------------------------------------------------------
246
247    /// `objects/` — the content-addressed object store.
248    #[must_use]
249    pub fn objects_dir(&self) -> PathBuf {
250        self.common_dir.join(OBJECTS_DIR)
251    }
252
253    /// `format` — the object-addressing format marker.
254    #[must_use]
255    pub fn format_file(&self) -> PathBuf {
256        self.common_dir.join(FORMAT_FILE)
257    }
258
259    /// `refs/` — the ref tree root.
260    #[must_use]
261    pub fn refs_dir(&self) -> PathBuf {
262        self.common_dir.join(REFS_DIR)
263    }
264
265    /// `refs/heads/` — branch refs.
266    #[must_use]
267    pub fn heads_dir(&self) -> PathBuf {
268        self.common_dir.join(HEADS_DIR)
269    }
270
271    /// `refs/tags/` — tag refs.
272    #[must_use]
273    pub fn tags_dir(&self) -> PathBuf {
274        self.common_dir.join(TAGS_DIR)
275    }
276
277    /// `refs/remotes/` — remote-tracking refs.
278    #[must_use]
279    pub fn remotes_dir(&self) -> PathBuf {
280        self.common_dir.join(REMOTES_DIR)
281    }
282
283    /// `shallow` — the shallow-clone boundary. Shared: it constrains
284    /// the one object graph every worktree reads.
285    #[must_use]
286    pub fn shallow_file(&self) -> PathBuf {
287        self.common_dir.join(SHALLOW_FILE)
288    }
289
290    /// `config` — the repository config file.
291    #[must_use]
292    pub fn config_file(&self) -> PathBuf {
293        self.common_dir.join(CONFIG_FILE_NAME)
294    }
295
296    /// `keys/` — repository-local signing keys.
297    #[must_use]
298    pub fn keys_dir(&self) -> PathBuf {
299        self.common_dir.join(KEYS_DIR_NAME)
300    }
301
302    /// `history/` — the append-only commit-history MMR.
303    #[must_use]
304    pub fn history_dir(&self) -> PathBuf {
305        self.common_dir.join(HISTORY_DIR_NAME)
306    }
307
308    /// `recovery-log` — the append-only superseded-commit log.
309    #[must_use]
310    pub fn recovery_log_file(&self) -> PathBuf {
311        self.common_dir.join(RECOVERY_LOG)
312    }
313
314    /// `attestations/` — the DSSE attestation store.
315    #[must_use]
316    pub fn attestations_dir(&self) -> PathBuf {
317        self.common_dir.join(ATTESTATIONS_DIR_NAME)
318    }
319
320    /// `applied-packs/` — per-remote applied-pack records. A
321    /// redownload-avoidance cache; never a gc root source, always safe
322    /// to delete.
323    #[must_use]
324    pub fn applied_packs_dir(&self) -> PathBuf {
325        self.common_dir.join(APPLIED_PACKS_DIR_NAME)
326    }
327
328    /// `git/` — git-bridge per-remote state.
329    #[must_use]
330    pub fn git_state_dir(&self) -> PathBuf {
331        self.common_dir.join(GIT_STATE_DIR_NAME)
332    }
333
334    /// `sparse/` — the verifiable sparse-checkout bitmap cache
335    /// (keyed by tree hash, so shared).
336    #[must_use]
337    pub fn sparse_cache_dir(&self) -> PathBuf {
338        self.common_dir.join(SPARSE_CACHE_DIR_NAME)
339    }
340
341    /// `pack-shards/` — default output directory for pack shards.
342    #[must_use]
343    pub fn pack_shards_dir(&self) -> PathBuf {
344        self.common_dir.join(PACK_SHARDS_DIR_NAME)
345    }
346
347    // ------------------------------------------------------------------
348    // Per-worktree state.
349    // ------------------------------------------------------------------
350
351    /// `HEAD` — this worktree's checked-out branch or detached commit.
352    #[must_use]
353    pub fn head_file(&self) -> PathBuf {
354        self.worktree_state_dir.join(HEAD_FILE)
355    }
356
357    /// `index` — this worktree's staging index.
358    #[must_use]
359    pub fn index_file(&self) -> PathBuf {
360        self.worktree_state_dir.join(INDEX_FILE_NAME)
361    }
362
363    /// `ORIG_HEAD` — pre-operation HEAD snapshot.
364    #[must_use]
365    pub fn orig_head_file(&self) -> PathBuf {
366        self.worktree_state_dir.join(ORIG_HEAD)
367    }
368
369    /// `MERGE_HEAD` — in-progress merge counterpart commit.
370    #[must_use]
371    pub fn merge_head_file(&self) -> PathBuf {
372        self.worktree_state_dir.join(MERGE_HEAD)
373    }
374
375    /// `MERGE_MSG` — in-progress merge message.
376    #[must_use]
377    pub fn merge_msg_file(&self) -> PathBuf {
378        self.worktree_state_dir.join(MERGE_MSG)
379    }
380
381    /// `CHERRY_PICK_HEAD` — in-progress cherry-pick source commit.
382    #[must_use]
383    pub fn cherry_pick_head_file(&self) -> PathBuf {
384        self.worktree_state_dir.join(CHERRY_PICK_HEAD)
385    }
386
387    /// `CHERRY_PICK_MSG` — in-progress cherry-pick message.
388    #[must_use]
389    pub fn cherry_pick_msg_file(&self) -> PathBuf {
390        self.worktree_state_dir.join(CHERRY_PICK_MSG)
391    }
392
393    /// `REVERT_HEAD` — in-progress revert source commit.
394    #[must_use]
395    pub fn revert_head_file(&self) -> PathBuf {
396        self.worktree_state_dir.join(REVERT_HEAD)
397    }
398
399    /// `REVERT_MSG` — in-progress revert message.
400    #[must_use]
401    pub fn revert_msg_file(&self) -> PathBuf {
402        self.worktree_state_dir.join(REVERT_MSG)
403    }
404
405    /// `mkit-conflicts` — conflict sidecar for the in-progress op.
406    #[must_use]
407    pub fn conflicts_file(&self) -> PathBuf {
408        self.worktree_state_dir.join(CONFLICTS_FILE)
409    }
410
411    /// `MKIT_OP_RESULT` — full result tree of the in-progress op.
412    #[must_use]
413    pub fn result_tree_file(&self) -> PathBuf {
414        self.worktree_state_dir.join(RESULT_TREE)
415    }
416
417    /// `rebase-apply/` — in-progress rebase state.
418    #[must_use]
419    pub fn rebase_dir(&self) -> PathBuf {
420        self.worktree_state_dir.join(REBASE_DIR)
421    }
422
423    /// `bisect` — in-progress bisect state.
424    #[must_use]
425    pub fn bisect_file(&self) -> PathBuf {
426        self.worktree_state_dir.join(BISECT_FILE)
427    }
428
429    /// `stash` — this worktree's stash manifest (tree-local by #493).
430    #[must_use]
431    pub fn stash_file(&self) -> PathBuf {
432        self.worktree_state_dir.join(STASH_FILE_NAME)
433    }
434
435    /// `sparse-checkout` — this worktree's sparse filter spec.
436    #[must_use]
437    pub fn sparse_checkout_file(&self) -> PathBuf {
438        self.worktree_state_dir.join(SPARSE_CHECKOUT_FILE_NAME)
439    }
440}
441
442/// Errors surfaced by [`discover`] on a broken linked-worktree setup.
443///
444/// A repository whose `.mkit` is a directory (every single-worktree
445/// repository) can never produce one of these — discovery only engages
446/// the fail-closed path once `.mkit` is a pointer FILE.
447#[derive(Debug, thiserror::Error)]
448#[non_exhaustive]
449pub enum DiscoverError {
450    #[error("worktree pointer {0}: {1}")]
451    PointerUnreadable(PathBuf, std::io::Error),
452    #[error("worktree pointer {0} is malformed: expected a single `{POINTER_PREFIX}<path>` line")]
453    PointerMalformed(PathBuf),
454    #[error("worktree pointer {0} exceeds {MAX_POINTER_FILE_BYTES} bytes — refusing to parse")]
455    PointerTooLarge(PathBuf),
456    #[error(
457        "worktree pointer {0} is a symlink — pointer, commondir, and back-pointer files \
458         must be regular files"
459    )]
460    PointerSymlink(PathBuf),
461    #[error(
462        "worktree state dir {0} is missing or not a directory — was this worktree pruned? \
463         run `mkit worktree` maintenance from the main repository"
464    )]
465    StateDirMissing(PathBuf),
466    #[error("worktree commondir file {0}: {1}")]
467    CommonDirUnreadable(PathBuf, std::io::Error),
468    #[error("worktree common dir {0} is missing or not a directory")]
469    CommonDirMissing(PathBuf),
470}
471
472/// Validate a linked-worktree id (the `worktrees/<id>` directory name).
473///
474/// Same shape as the git-bridge remote-name rule: ASCII alphanumeric
475/// plus `.`, `_`, `-`; non-empty; at most 255 bytes; never `.` or `..`.
476/// Keeps the id a single safe path component — no separators, no
477/// traversal, no NUL.
478#[must_use]
479pub fn validate_worktree_id(id: &str) -> bool {
480    !id.is_empty()
481        && id.len() <= 255
482        && id != "."
483        && id != ".."
484        && id
485            .bytes()
486            .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
487}
488
489/// Read a single-line, LF-terminated, size-capped pointer-style file
490/// (`.mkit` pointer, `commondir`, back-pointer). Returns the line
491/// without its trailing newline. `Ok(None)` when the file is absent.
492fn read_capped_line(path: &Path) -> Result<Option<String>, DiscoverError> {
493    let meta = match std::fs::symlink_metadata(path) {
494        Ok(m) => m,
495        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
496        Err(e) => return Err(DiscoverError::PointerUnreadable(path.to_path_buf(), e)),
497    };
498    // Reject symlinks outright: `symlink_metadata` sizes the LINK
499    // itself, so a hostile `.mkit -> /dev/zero` in an untarred tree
500    // would sail past the byte cap while `fs::read` follows the link
501    // into an unbounded read. Pointer-style files are plain regular
502    // files by spec (SPEC-WORKTREE §2).
503    if meta.file_type().is_symlink() {
504        return Err(DiscoverError::PointerSymlink(path.to_path_buf()));
505    }
506    if meta.len() > MAX_POINTER_FILE_BYTES {
507        return Err(DiscoverError::PointerTooLarge(path.to_path_buf()));
508    }
509    let raw =
510        std::fs::read(path).map_err(|e| DiscoverError::PointerUnreadable(path.to_path_buf(), e))?;
511    let text = std::str::from_utf8(&raw)
512        .map_err(|_| DiscoverError::PointerMalformed(path.to_path_buf()))?;
513    let line = text
514        .strip_suffix('\n')
515        .map_or(text, |l| l.strip_suffix('\r').unwrap_or(l));
516    if line.is_empty() || line.contains('\n') {
517        return Err(DiscoverError::PointerMalformed(path.to_path_buf()));
518    }
519    Ok(Some(line.to_owned()))
520}
521
522/// Write the linked-tree pointer file: `<tree>/.mkit` containing
523/// `mkitdir: <state_dir>\n`. Used by `worktree add` (#493 Phase 2);
524/// public now so the format has exactly one writer and one reader.
525///
526/// # Errors
527/// Propagates filesystem errors from the atomic write.
528pub fn write_pointer_file(tree_root: &Path, state_dir: &Path) -> std::io::Result<()> {
529    let body = format!("{POINTER_PREFIX}{}\n", state_dir.display());
530    crate::atomic::write_atomic(&tree_root.join(MKIT_DIR), body.as_bytes(), false)
531}
532
533/// Resolve the [`RepoLayout`] for the repository whose working tree is
534/// rooted at `worktree_root` (#493 Phase 1 discovery).
535///
536/// - `.mkit` is a directory, or absent: the classic single-worktree
537///   layout ([`RepoLayout::single`]) — absence is NOT an error here so
538///   the store-open path keeps producing today's "not a repository"
539///   diagnostics unchanged.
540/// - `.mkit` is a FILE: a linked worktree. The pointer is parsed
541///   (`mkitdir: <path>`, absolute or relative to `worktree_root`), the
542///   per-tree state dir must exist, and the common dir is resolved via
543///   the state dir's `commondir` file (defaulting to `../..` when the
544///   file is absent, matching what `worktree add` writes) and must
545///   exist. Every failure along that chain is a typed, fail-closed
546///   [`DiscoverError`] — a broken linked tree must never silently
547///   degrade into "operate on some other directory".
548///
549/// # Errors
550/// See [`DiscoverError`].
551pub fn discover(worktree_root: &Path) -> Result<RepoLayout, DiscoverError> {
552    let dot_mkit = worktree_root.join(MKIT_DIR);
553    let Ok(meta) = std::fs::symlink_metadata(&dot_mkit) else {
554        return Ok(RepoLayout::single(worktree_root));
555    };
556    if meta.is_dir() {
557        return Ok(RepoLayout::single(worktree_root));
558    }
559
560    // `.mkit` exists and is not a directory: pointer file (or garbage).
561    let Some(line) = read_capped_line(&dot_mkit)? else {
562        // Raced away between the two stats; treat like absent.
563        return Ok(RepoLayout::single(worktree_root));
564    };
565    let Some(target) = line.strip_prefix(POINTER_PREFIX) else {
566        return Err(DiscoverError::PointerMalformed(dot_mkit));
567    };
568    let target = Path::new(target);
569    let state_dir = if target.is_absolute() {
570        target.to_path_buf()
571    } else {
572        worktree_root.join(target)
573    };
574    // Canonicalize so identity comparisons against registry paths hold
575    // even through symlinked tempdir prefixes (macOS `/var`).
576    let state_dir = state_dir
577        .canonicalize()
578        .map_err(|_| DiscoverError::StateDirMissing(state_dir.clone()))?;
579    if !state_dir.is_dir() {
580        return Err(DiscoverError::StateDirMissing(state_dir));
581    }
582
583    let commondir_file = state_dir.join(COMMONDIR_FILE_NAME);
584    let common_dir = match read_capped_line(&commondir_file) {
585        Ok(Some(rel)) => {
586            let p = Path::new(&rel);
587            if p.is_absolute() {
588                p.to_path_buf()
589            } else {
590                state_dir.join(p)
591            }
592        }
593        // Absent commondir: the layout `worktree add` writes puts the
594        // state dir exactly two levels under the common dir.
595        Ok(None) => state_dir.join("../.."),
596        Err(DiscoverError::PointerUnreadable(p, e)) => {
597            return Err(DiscoverError::CommonDirUnreadable(p, e));
598        }
599        Err(e) => return Err(e),
600    };
601    // Normalize the `../..` hops so every accessor yields a clean path.
602    let common_dir = common_dir
603        .canonicalize()
604        .map_err(|_| DiscoverError::CommonDirMissing(common_dir.clone()))?;
605    if !common_dir.is_dir() {
606        return Err(DiscoverError::CommonDirMissing(common_dir));
607    }
608
609    Ok(RepoLayout::linked(worktree_root, state_dir, common_dir))
610}
611
612/// One entry of the linked-worktree registry (`<common>/worktrees/*`),
613/// as reported by [`worktrees`].
614#[derive(Debug, Clone, PartialEq, Eq)]
615pub struct WorktreeEntry {
616    /// The registry id (the `worktrees/<id>` directory name).
617    pub id: String,
618    /// The per-tree state dir (`<common>/worktrees/<id>`).
619    pub state_dir: PathBuf,
620    /// The linked tree's root, derived from the back-pointer file
621    /// (its parent, since the back-pointer names `<tree>/.mkit`).
622    /// `None` when the entry is broken — see `prunable`.
623    pub tree_root: Option<PathBuf>,
624    /// `Some(reason)` when the entry no longer corresponds to a live
625    /// linked tree and `worktree prune` may delete its state dir:
626    /// missing/unreadable back-pointer, vanished tree, or a tree whose
627    /// pointer no longer points back at this state dir.
628    pub prunable: Option<String>,
629}
630
631/// Enumerate the linked-worktree registry of `layout`'s repository,
632/// sorted by id. The main worktree is NOT an entry — its state dir is
633/// the common dir itself.
634///
635/// Ids that fail [`validate_worktree_id`] and non-directory entries
636/// are reported as prunable rather than skipped, so `worktree list`
637/// and `worktree prune` see the same picture and nothing lingers
638/// invisibly.
639///
640/// # Errors
641/// [`DiscoverError::PointerUnreadable`] only for an unreadable
642/// `worktrees/` directory itself; a missing `worktrees/` dir yields an
643/// empty list.
644pub fn worktrees(layout: &RepoLayout) -> Result<Vec<WorktreeEntry>, DiscoverError> {
645    let dir = layout.worktrees_dir();
646    let entries = match std::fs::read_dir(&dir) {
647        Ok(e) => e,
648        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
649        Err(e) => return Err(DiscoverError::PointerUnreadable(dir, e)),
650    };
651    let mut out = Vec::new();
652    for entry in entries {
653        let entry = entry.map_err(|e| DiscoverError::PointerUnreadable(dir.clone(), e))?;
654        let id = entry.file_name().to_string_lossy().into_owned();
655        let state_dir = entry.path();
656        let mut wt = WorktreeEntry {
657            id: id.clone(),
658            state_dir: state_dir.clone(),
659            tree_root: None,
660            prunable: None,
661        };
662        if !validate_worktree_id(&id) || !state_dir.is_dir() {
663            wt.prunable = Some("invalid registry entry".to_owned());
664            out.push(wt);
665            continue;
666        }
667        // Follow the back-pointer to the tree and verify the tree's
668        // pointer still points back HERE — a moved/re-created tree
669        // must not be claimed by a stale registry entry.
670        match read_capped_line(&state_dir.join(BACKPOINTER_FILE_NAME)) {
671            Ok(Some(back)) => {
672                let pointer_path = PathBuf::from(back);
673                let tree_root = pointer_path.parent().map(Path::to_path_buf);
674                match discover_pointer_target(&pointer_path) {
675                    Some(target) if paths_refer_to_same(&target, &state_dir) => {
676                        wt.tree_root = tree_root;
677                    }
678                    Some(_) => {
679                        wt.tree_root = tree_root;
680                        wt.prunable =
681                            Some("tree's pointer no longer points at this state dir".to_owned());
682                    }
683                    None => {
684                        wt.tree_root = tree_root;
685                        wt.prunable = Some("linked tree is gone".to_owned());
686                    }
687                }
688            }
689            Ok(None) => wt.prunable = Some("back-pointer file missing".to_owned()),
690            Err(_) => wt.prunable = Some("back-pointer file unreadable".to_owned()),
691        }
692        out.push(wt);
693    }
694    out.sort_by(|a, b| a.id.cmp(&b.id));
695    Ok(out)
696}
697
698/// Every per-tree STATE layout of the repository, for cross-worktree
699/// root collection (#493 Phase 3): the main tree first, then one
700/// layout per `worktrees/<id>` state dir that exists on disk — in
701/// deterministic order (main, then ids ascending), so multi-lock
702/// acquisition over the result cannot deadlock against itself.
703///
704/// Deliberately INCLUDES prunable registry entries whose state dir is
705/// still present: until `worktree prune` reaps a state dir, whatever
706/// its HEAD/index/op-state pin stays pinned — gc must never treat "the
707/// tree wandered off" as "its staged objects are garbage".
708///
709/// For entries whose linked tree root is unknown (broken back-pointer)
710/// the layout's `worktree_root` falls back to the state dir itself;
711/// root collection never touches worktree files, only state.
712///
713/// # Errors
714/// Propagates registry enumeration failures — callers (gc) must abort,
715/// never prune on a partial view.
716pub fn all_state_layouts(layout: &RepoLayout) -> Result<Vec<RepoLayout>, DiscoverError> {
717    let mut out = Vec::new();
718    let main_root = layout
719        .common_dir()
720        .parent()
721        .map_or_else(|| PathBuf::from("/"), Path::to_path_buf);
722    out.push(RepoLayout::linked(
723        main_root,
724        layout.common_dir(),
725        layout.common_dir(),
726    ));
727    for wt in worktrees(layout)? {
728        if !wt.state_dir.is_dir() {
729            continue;
730        }
731        let root = wt.tree_root.clone().unwrap_or_else(|| wt.state_dir.clone());
732        out.push(RepoLayout::linked(root, wt.state_dir, layout.common_dir()));
733    }
734    Ok(out)
735}
736
737/// Best-effort read of a pointer file's target (absolute or relative
738/// to the pointer's parent). `None` when the file is missing or
739/// malformed — callers use this for registry health checks, where a
740/// broken pointer means "prunable", not "abort".
741fn discover_pointer_target(pointer_path: &Path) -> Option<PathBuf> {
742    let line = read_capped_line(pointer_path).ok().flatten()?;
743    let target = line.strip_prefix(POINTER_PREFIX)?;
744    let target = Path::new(target);
745    if target.is_absolute() {
746        Some(target.to_path_buf())
747    } else {
748        Some(pointer_path.parent()?.join(target))
749    }
750}
751
752/// Path equality up to canonicalization, tolerant of either side not
753/// existing (falls back to literal comparison).
754fn paths_refer_to_same(a: &Path, b: &Path) -> bool {
755    match (a.canonicalize(), b.canonicalize()) {
756        (Ok(ca), Ok(cb)) => ca == cb,
757        _ => a == b,
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764
765    /// Every accessor, paired with its expected `.mkit`-relative path in
766    /// the single-worktree layout and its class. The golden strings are
767    /// the exact historical joins — Phase 0 must be byte-identical.
768    fn accessor_table(l: &RepoLayout) -> Vec<(&'static str, PathBuf, &'static str, Class)> {
769        use Class::{Common, Worktree};
770        vec![
771            ("objects_dir", l.objects_dir(), "objects", Common),
772            ("format_file", l.format_file(), "format", Common),
773            ("refs_dir", l.refs_dir(), "refs", Common),
774            ("heads_dir", l.heads_dir(), "refs/heads", Common),
775            ("tags_dir", l.tags_dir(), "refs/tags", Common),
776            ("remotes_dir", l.remotes_dir(), "refs/remotes", Common),
777            ("shallow_file", l.shallow_file(), "shallow", Common),
778            ("config_file", l.config_file(), "config", Common),
779            ("keys_dir", l.keys_dir(), "keys", Common),
780            ("history_dir", l.history_dir(), "history", Common),
781            (
782                "recovery_log_file",
783                l.recovery_log_file(),
784                "recovery-log",
785                Common,
786            ),
787            (
788                "attestations_dir",
789                l.attestations_dir(),
790                "attestations",
791                Common,
792            ),
793            (
794                "applied_packs_dir",
795                l.applied_packs_dir(),
796                "applied-packs",
797                Common,
798            ),
799            ("git_state_dir", l.git_state_dir(), "git", Common),
800            ("sparse_cache_dir", l.sparse_cache_dir(), "sparse", Common),
801            (
802                "pack_shards_dir",
803                l.pack_shards_dir(),
804                "pack-shards",
805                Common,
806            ),
807            ("head_file", l.head_file(), "HEAD", Worktree),
808            ("index_file", l.index_file(), "index", Worktree),
809            ("orig_head_file", l.orig_head_file(), "ORIG_HEAD", Worktree),
810            (
811                "merge_head_file",
812                l.merge_head_file(),
813                "MERGE_HEAD",
814                Worktree,
815            ),
816            ("merge_msg_file", l.merge_msg_file(), "MERGE_MSG", Worktree),
817            (
818                "cherry_pick_head_file",
819                l.cherry_pick_head_file(),
820                "CHERRY_PICK_HEAD",
821                Worktree,
822            ),
823            (
824                "cherry_pick_msg_file",
825                l.cherry_pick_msg_file(),
826                "CHERRY_PICK_MSG",
827                Worktree,
828            ),
829            (
830                "revert_head_file",
831                l.revert_head_file(),
832                "REVERT_HEAD",
833                Worktree,
834            ),
835            (
836                "revert_msg_file",
837                l.revert_msg_file(),
838                "REVERT_MSG",
839                Worktree,
840            ),
841            (
842                "conflicts_file",
843                l.conflicts_file(),
844                "mkit-conflicts",
845                Worktree,
846            ),
847            (
848                "result_tree_file",
849                l.result_tree_file(),
850                "MKIT_OP_RESULT",
851                Worktree,
852            ),
853            ("rebase_dir", l.rebase_dir(), "rebase-apply", Worktree),
854            ("bisect_file", l.bisect_file(), "bisect", Worktree),
855            ("stash_file", l.stash_file(), "stash", Worktree),
856            (
857                "sparse_checkout_file",
858                l.sparse_checkout_file(),
859                "sparse-checkout",
860                Worktree,
861            ),
862        ]
863    }
864
865    #[derive(PartialEq, Clone, Copy, Debug)]
866    enum Class {
867        Common,
868        Worktree,
869    }
870
871    /// Phase 0 golden invariant: in the single-worktree layout every
872    /// accessor equals the historical `<root>/.mkit/<relative>` join,
873    /// byte for byte.
874    #[test]
875    fn single_layout_paths_match_legacy_joins() {
876        let root = Path::new("/repo");
877        let l = RepoLayout::single(root);
878        let legacy_mkit = root.join(MKIT_DIR);
879        for (name, got, relative, _class) in accessor_table(&l) {
880            assert_eq!(got, legacy_mkit.join(relative), "accessor {name}");
881        }
882    }
883
884    /// Single-mode structural invariant.
885    #[test]
886    fn single_layout_dirs_coincide() {
887        let l = RepoLayout::single("/repo");
888        assert!(l.is_single());
889        assert_eq!(l.common_dir(), l.worktree_state_dir());
890        assert_eq!(l.common_dir(), Path::new("/repo/.mkit"));
891        assert_eq!(l.worktree_root(), Path::new("/repo"));
892    }
893
894    /// Containment invariant: every accessor resolves strictly inside
895    /// the directory its class prescribes — nothing escapes `.mkit`.
896    #[test]
897    fn accessors_stay_inside_their_class_dir() {
898        let l = RepoLayout::single("/repo");
899        for (name, got, _relative, class) in accessor_table(&l) {
900            let class_dir = match class {
901                Class::Common => l.common_dir(),
902                Class::Worktree => l.worktree_state_dir(),
903            };
904            assert!(
905                got.starts_with(class_dir) && got != class_dir,
906                "accessor {name} must resolve strictly inside {}",
907                class_dir.display()
908            );
909            // No parent-dir or absolute components smuggled in past the
910            // class dir: re-joining the stripped suffix must round-trip.
911            let suffix = got.strip_prefix(class_dir).unwrap();
912            assert!(
913                suffix
914                    .components()
915                    .all(|c| matches!(c, std::path::Component::Normal(_))),
916                "accessor {name} suffix {} must be plain components",
917                suffix.display()
918            );
919        }
920    }
921
922    /// The layout constants that duplicate cross-crate literals must
923    /// stay in lock-step with the historical on-disk names.
924    #[test]
925    fn cross_crate_names_are_pinned() {
926        assert_eq!(CONFIG_FILE_NAME, "config");
927        assert_eq!(KEYS_DIR_NAME, "keys");
928        assert_eq!(INDEX_FILE_NAME, "index");
929        assert_eq!(STASH_FILE_NAME, "stash");
930        assert_eq!(SPARSE_CHECKOUT_FILE_NAME, "sparse-checkout");
931        assert_eq!(ATTESTATIONS_DIR_NAME, "attestations");
932        assert_eq!(APPLIED_PACKS_DIR_NAME, "applied-packs");
933        assert_eq!(GIT_STATE_DIR_NAME, "git");
934        assert_eq!(SPARSE_CACHE_DIR_NAME, "sparse");
935        assert_eq!(PACK_SHARDS_DIR_NAME, "pack-shards");
936        // Legacy prefix-embedding constants remain valid views of the
937        // same locations.
938        assert_eq!(
939            Path::new(crate::index::INDEX_FILE),
940            Path::new(MKIT_DIR).join(INDEX_FILE_NAME)
941        );
942        assert_eq!(
943            Path::new(crate::ops::stash::STASH_FILE),
944            Path::new(MKIT_DIR).join(STASH_FILE_NAME)
945        );
946    }
947
948    /// Construction is pure — no filesystem access — so a layout for a
949    /// not-yet-created repository is representable (init needs this).
950    #[test]
951    fn construction_is_pure() {
952        let l = RepoLayout::single("/definitely/not/a/real/path");
953        assert_eq!(
954            l.objects_dir(),
955            Path::new("/definitely/not/a/real/path/.mkit/objects")
956        );
957    }
958
959    /// Linked-mode classification invariant: with distinct dirs, every
960    /// accessor resolves under the dir its class prescribes — the whole
961    /// point of the seam.
962    #[test]
963    fn linked_layout_splits_accessors_by_class() {
964        let l = RepoLayout::linked(
965            "/trees/feature-x",
966            "/main/.mkit/worktrees/feature-x",
967            "/main/.mkit",
968        );
969        assert!(!l.is_single());
970        // NOTE: the state dir deliberately nests UNDER the common dir
971        // (`.mkit/worktrees/<id>`), so "under the common dir" is
972        // trivially true for everything; the leak checks that matter
973        // are (a) worktree-class accessors resolve under the state
974        // dir, and (b) common-class accessors do NOT.
975        for (name, got, _relative, class) in accessor_table(&l) {
976            match class {
977                Class::Common => {
978                    assert!(
979                        got.starts_with(l.common_dir()),
980                        "accessor {name} must live under the common dir"
981                    );
982                    assert!(
983                        !got.starts_with(l.worktree_state_dir()),
984                        "shared accessor {name} leaked into the per-tree state dir"
985                    );
986                }
987                Class::Worktree => {
988                    assert!(
989                        got.starts_with(l.worktree_state_dir()),
990                        "per-tree accessor {name} must live under the state dir"
991                    );
992                }
993            }
994        }
995        // The per-tree state dirs of OTHER worktrees live under the
996        // common dir's worktrees/, not under this tree's state dir.
997        assert_eq!(
998            l.worktree_state_dir_for("other"),
999            Path::new("/main/.mkit/worktrees/other")
1000        );
1001    }
1002
1003    #[test]
1004    fn worktree_id_grammar() {
1005        for ok in ["feature-x", "a", "wt.1", "A_B-c.d", &"x".repeat(255)] {
1006            assert!(validate_worktree_id(ok), "{ok:?} should be valid");
1007        }
1008        for bad in [
1009            "",
1010            ".",
1011            "..",
1012            "a/b",
1013            "a\\b",
1014            "a b",
1015            "a\0b",
1016            "\u{e9}clair",
1017            &"x".repeat(256),
1018        ] {
1019            assert!(!validate_worktree_id(bad), "{bad:?} should be rejected");
1020        }
1021    }
1022
1023    // ---- discover() ---------------------------------------------------
1024
1025    fn scaffold_linked(tmp: &Path) -> (PathBuf, PathBuf, PathBuf) {
1026        let main = tmp.join("main");
1027        let tree = tmp.join("tree");
1028        let state = main.join(".mkit/worktrees/tree");
1029        std::fs::create_dir_all(main.join(".mkit/objects")).unwrap();
1030        std::fs::create_dir_all(&state).unwrap();
1031        std::fs::create_dir_all(&tree).unwrap();
1032        write_pointer_file(&tree, &state).unwrap();
1033        (main, tree, state)
1034    }
1035
1036    #[test]
1037    fn discover_dir_and_absent_yield_single() {
1038        let tmp = tempfile::tempdir().unwrap();
1039        // Absent .mkit: single (store open reports not-a-repo later).
1040        let l = discover(tmp.path()).unwrap();
1041        assert!(l.is_single());
1042        // Directory .mkit: single, byte-identical to Phase 0.
1043        std::fs::create_dir_all(tmp.path().join(".mkit")).unwrap();
1044        let l = discover(tmp.path()).unwrap();
1045        assert!(l.is_single());
1046        assert_eq!(l.common_dir(), tmp.path().join(".mkit"));
1047    }
1048
1049    #[test]
1050    fn discover_follows_pointer_to_linked_layout() {
1051        let tmp = tempfile::tempdir().unwrap();
1052        let (main, tree, state) = scaffold_linked(tmp.path());
1053        let l = discover(&tree).unwrap();
1054        assert!(!l.is_single());
1055        assert_eq!(l.worktree_root(), tree.as_path());
1056        // State dir is canonicalized by discovery (symlinked tempdir
1057        // prefixes must not defeat cross-tree identity comparisons).
1058        assert_eq!(
1059            l.worktree_state_dir(),
1060            state.canonicalize().unwrap().as_path()
1061        );
1062        // commondir file absent => ../.. default, canonicalized.
1063        assert_eq!(
1064            l.common_dir(),
1065            main.join(".mkit").canonicalize().unwrap().as_path()
1066        );
1067        // The seam in action: HEAD is per-tree, refs are shared.
1068        assert_eq!(l.head_file(), state.canonicalize().unwrap().join("HEAD"));
1069        assert!(l.heads_dir().starts_with(l.common_dir()));
1070    }
1071
1072    #[test]
1073    fn discover_honors_explicit_commondir_file() {
1074        let tmp = tempfile::tempdir().unwrap();
1075        let (main, tree, state) = scaffold_linked(tmp.path());
1076        std::fs::write(state.join(COMMONDIR_FILE_NAME), "../..\n").unwrap();
1077        let l = discover(&tree).unwrap();
1078        assert_eq!(
1079            l.common_dir(),
1080            main.join(".mkit").canonicalize().unwrap().as_path()
1081        );
1082        // Absolute commondir works too.
1083        std::fs::write(
1084            state.join(COMMONDIR_FILE_NAME),
1085            format!("{}\n", main.join(".mkit").display()),
1086        )
1087        .unwrap();
1088        let l = discover(&tree).unwrap();
1089        assert_eq!(
1090            l.common_dir(),
1091            main.join(".mkit").canonicalize().unwrap().as_path()
1092        );
1093    }
1094
1095    #[test]
1096    fn discover_accepts_relative_pointer_target() {
1097        let tmp = tempfile::tempdir().unwrap();
1098        let (_main, tree, state) = scaffold_linked(tmp.path());
1099        std::fs::write(
1100            tree.join(MKIT_DIR),
1101            "mkitdir: ../main/.mkit/worktrees/tree\n",
1102        )
1103        .unwrap();
1104        let l = discover(&tree).unwrap();
1105        assert_eq!(
1106            l.worktree_state_dir(),
1107            tree.join("../main/.mkit/worktrees/tree")
1108                .canonicalize()
1109                .unwrap()
1110        );
1111        assert!(l.worktree_state_dir().is_dir());
1112        let _ = state;
1113    }
1114
1115    /// Fail-closed matrix: every malformed/dangling pointer shape is a
1116    /// typed error, never a silent fallback to some other directory.
1117    #[test]
1118    fn discover_fails_closed_on_broken_pointers() {
1119        let tmp = tempfile::tempdir().unwrap();
1120        let (_main, tree, state) = scaffold_linked(tmp.path());
1121        let pointer = tree.join(MKIT_DIR);
1122
1123        // Wrong prefix.
1124        std::fs::write(&pointer, "gitdir: /somewhere\n").unwrap();
1125        assert!(matches!(
1126            discover(&tree),
1127            Err(DiscoverError::PointerMalformed(_))
1128        ));
1129        // Empty.
1130        std::fs::write(&pointer, "").unwrap();
1131        assert!(matches!(
1132            discover(&tree),
1133            Err(DiscoverError::PointerMalformed(_))
1134        ));
1135        // Multi-line.
1136        std::fs::write(&pointer, "mkitdir: /a\nmkitdir: /b\n").unwrap();
1137        assert!(matches!(
1138            discover(&tree),
1139            Err(DiscoverError::PointerMalformed(_))
1140        ));
1141        // Non-UTF-8.
1142        std::fs::write(&pointer, [0x6d, 0x6b, 0xff, 0xfe]).unwrap();
1143        assert!(matches!(
1144            discover(&tree),
1145            Err(DiscoverError::PointerMalformed(_))
1146        ));
1147        // Oversized.
1148        std::fs::write(
1149            &pointer,
1150            format!(
1151                "mkitdir: /{}\n",
1152                "x".repeat(usize::try_from(MAX_POINTER_FILE_BYTES).unwrap())
1153            ),
1154        )
1155        .unwrap();
1156        assert!(matches!(
1157            discover(&tree),
1158            Err(DiscoverError::PointerTooLarge(_))
1159        ));
1160        // Symlinked pointer: the byte cap sizes the LINK, so a link to
1161        // a huge (or unbounded, e.g. /dev/zero) target must be
1162        // rejected outright, never followed.
1163        #[cfg(unix)]
1164        {
1165            std::fs::remove_file(&pointer).unwrap();
1166            let huge = tmp.path().join("huge");
1167            std::fs::write(&huge, format!("mkitdir: /{}\n", "x".repeat(8192))).unwrap();
1168            std::os::unix::fs::symlink(&huge, &pointer).unwrap();
1169            assert!(matches!(
1170                discover(&tree),
1171                Err(DiscoverError::PointerSymlink(_))
1172            ));
1173        }
1174
1175        // Dangling target (state dir removed — a pruned worktree).
1176        write_pointer_file(&tree, &state).unwrap();
1177        std::fs::remove_dir_all(&state).unwrap();
1178        assert!(matches!(
1179            discover(&tree),
1180            Err(DiscoverError::StateDirMissing(_))
1181        ));
1182    }
1183
1184    #[test]
1185    fn discover_fails_closed_on_missing_common_dir() {
1186        let tmp = tempfile::tempdir().unwrap();
1187        let (main, tree, state) = scaffold_linked(tmp.path());
1188        // commondir points somewhere that does not exist.
1189        std::fs::write(state.join(COMMONDIR_FILE_NAME), "../../nope\n").unwrap();
1190        assert!(matches!(
1191            discover(&tree),
1192            Err(DiscoverError::CommonDirMissing(_))
1193        ));
1194        let _ = main;
1195    }
1196
1197    /// The pointer file has exactly one writer and one reader; pin the
1198    /// bytes so the format cannot drift silently.
1199    #[test]
1200    fn pointer_file_golden_bytes() {
1201        let tmp = tempfile::tempdir().unwrap();
1202        write_pointer_file(tmp.path(), Path::new("/main/.mkit/worktrees/w1")).unwrap();
1203        let bytes = std::fs::read(tmp.path().join(MKIT_DIR)).unwrap();
1204        assert_eq!(bytes, b"mkitdir: /main/.mkit/worktrees/w1\n");
1205    }
1206}