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}