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