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