dev_prune/commands/link.rs
1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handlers for `dev-prune link` and `dev-prune unlink` commands.
5
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result};
9
10use crate::config::{Adoption, PerRepoConfig, Registry};
11use crate::constants;
12use crate::output;
13use crate::scanner;
14
15/// Run the `link` command — register a Git repository.
16///
17/// `quiet` is what the global Git hook passes. In that mode nothing is printed and a
18/// repository whose `.devprune.json` sets `disable_hooks` is left unregistered — that
19/// flag exists precisely to keep the hook out of a specific workspace.
20pub fn run_link(path_str: &str, quiet: bool) -> Result<()> {
21 let path = Path::new(path_str)
22 .canonicalize()
23 .with_context(|| format!("Path not found: {path_str}"))?;
24
25 if !scanner::is_git_repo(&path) {
26 if quiet {
27 return Ok(());
28 }
29 // Non-zero, because nothing was linked. The hook path above still exits 0: a
30 // commit in a directory dev-prune does not track is not a failed commit.
31 anyhow::bail!(
32 "`{}` is not a Git repository.\n \
33 Run `git init` there first, then `devp link .` again.",
34 output::clean_path(&path)
35 );
36 }
37
38 // A repository in a scratch location is scratch by definition: a test fixture, a
39 // `git clone` into `mktemp -d`, a plugin manager's checkout, a build step. The hook
40 // fires on its first commit, the directory is gone minutes later, and the registry
41 // keeps an entry that can never be pruned and never be found again. Registering those
42 // is how a registry fills with dead paths. An explicit `devp link` still works — this
43 // only declines to do it *unasked*.
44 if quiet && is_ephemeral_location(&path) {
45 return Ok(());
46 }
47
48 // A linked worktree nested inside its own main repository is agent tooling's
49 // scratch space, not a workspace: the first commit inside one fired the hook, and
50 // the registry gained a row the user never asked for and will never prune —
51 // the `.git` boundary already keeps the main repository's pass out of it, and
52 // `git worktree remove` reclaims the whole thing at once. An explicit
53 // `devp link` still registers one, same as every other quiet-only decline.
54 if quiet && is_nested_linked_worktree(&path) {
55 return Ok(());
56 }
57
58 // A config that does not parse also keeps the hook out. It may well be the file that
59 // says `disable_hooks`, and a broken one is not licence to register the repo anyway.
60 if quiet
61 && !matches!(
62 PerRepoConfig::load_with_diagnostics(&path),
63 Ok(None)
64 | Ok(Some(PerRepoConfig {
65 disable_hooks: false,
66 ..
67 }))
68 )
69 {
70 return Ok(());
71 }
72
73 let mut registry = Registry::load()?;
74
75 if registry.add_repo(path.clone()) {
76 let adoption = registry.adopt_moved_entry(&path, scanner::git::repo_identity(&path));
77 registry.last_added_repos = vec![path.clone()];
78 registry.save()?;
79 if !quiet {
80 output::print_success(&format!("Linked: {}", output::clean_path(&path)));
81 report_adoption(&adoption);
82 if registry.settings.auto_config {
83 ensure_default_repo_config(&path);
84 }
85 }
86 } else {
87 // Backfill only when it is missing. The global Git hook runs this on every
88 // commit, and shelling out to git plus rewriting the registry each time would
89 // be a real cost for a value that never changes once written.
90 if registry.needs_identity(&path) {
91 let adoption = registry.adopt_moved_entry(&path, scanner::git::repo_identity(&path));
92 registry.save()?;
93 if !quiet {
94 report_adoption(&adoption);
95 }
96 }
97 if !quiet {
98 output::print_info(&format!("Already linked: {}", output::clean_path(&path)));
99 }
100 }
101
102 Ok(())
103}
104
105/// Say when a registration recognised a repository that had moved.
106///
107/// Silence here would be worse than noise: the entry the user was staring at in
108/// `devp status` as `Path missing` has just disappeared, and its lifetime total has
109/// turned up on a different row. That is the right outcome, but only if it is stated.
110pub(crate) fn report_adoption(adoption: &Adoption) {
111 match adoption {
112 Adoption::Nothing => {}
113 Adoption::Moved(old) => output::print_info(&format!(
114 " Recognised as the repository registered at {} — that path is gone, so its \
115 prune history came with it.",
116 output::clean_path(old)
117 )),
118 Adoption::Ambiguous => output::print_warning(
119 " More than one missing repository shares this root commit, so none was \
120 adopted — they are clones, not a move. Clear them with `devp unlink --missing`.",
121 ),
122 }
123}
124
125/// Write a default `.devprune.json` into a newly registered repository, when
126/// `auto_config` asks for it.
127///
128/// Never over an existing file — broken or not, it is the user's — and a write failure
129/// is a note rather than a failed registration: the repository is tracked either way,
130/// the config was only ever a convenience.
131pub(crate) fn ensure_default_repo_config(path: &Path) {
132 if path.join(crate::constants::PER_REPO_CONFIG_FILE).exists() {
133 return;
134 }
135 match PerRepoConfig::default().save_to_repo(path) {
136 Ok(()) => output::print_info(&format!(
137 "auto_config: wrote a default `.devprune.json` in {}",
138 output::clean_path(path)
139 )),
140 Err(e) => output::print_warning(&format!(
141 "auto_config: could not write `.devprune.json` in {}: {e}",
142 output::clean_path(path)
143 )),
144 }
145}
146
147/// Register the repository the caller is standing in, when nothing has registered it yet.
148///
149/// Git has no `post-init` hook. The three hooks dev-prune installs — `post-commit`,
150/// `post-checkout` and `post-merge` — between them cover every way a repository arrives
151/// from somewhere else, and none of them covers one created here: `git init` runs no hook
152/// at all, and the first hook a new repository ever sees belongs to its first commit.
153/// Until then it is invisible to the mechanism whose whole job was to find it — which is
154/// precisely when somebody runs `devp status` to check whether that worked, sees nothing,
155/// and concludes the hooks are broken. They are not; there was never a hook to fire.
156///
157/// So the commands that read the registry check the working directory first. The guards
158/// are the hook's guards, deliberately: a throwaway checkout, a repository whose config
159/// sets `disable_hooks`, a config that does not parse — every case
160/// `devp link . --quiet` declines to register is declined here for the same reason, so
161/// this can never track something the hook would have left alone.
162///
163/// Returns the path when one was added, so the caller can say so. Persisting is the
164/// caller's: it holds the registry and already saves for its own reasons.
165pub fn adopt_enclosing_repo(registry: &mut Registry) -> Option<PathBuf> {
166 let cwd = std::env::current_dir().ok()?;
167 adopt_repo_at(registry, &cwd)
168}
169
170/// [`adopt_enclosing_repo`], against a named directory rather than the process's own.
171///
172/// Split out so the guards can be tested without a test changing the working directory,
173/// which is process-global and would race every other test in the binary.
174pub(crate) fn adopt_repo_at(registry: &mut Registry, start: &Path) -> Option<PathBuf> {
175 let path = enclosing_repo(start)?;
176
177 if is_ephemeral_location(&path) {
178 return None;
179 }
180
181 if is_nested_linked_worktree(&path) {
182 return None;
183 }
184
185 if !matches!(
186 PerRepoConfig::load_with_diagnostics(&path),
187 Ok(None)
188 | Ok(Some(PerRepoConfig {
189 disable_hooks: false,
190 ..
191 }))
192 ) {
193 return None;
194 }
195
196 if !registry.add_repo(path.clone()) {
197 return None;
198 }
199
200 registry.adopt_moved_entry(&path, scanner::git::repo_identity(&path));
201 Some(path)
202}
203
204/// Say that the working directory was just registered, and why nothing had done it.
205///
206/// The "why" is not padding. Somebody who ran `git init` and then `devp status` has
207/// already formed the theory that the hooks are broken, and a bare "Registered ..." line
208/// leaves that theory standing. One sentence replaces it with the truth.
209pub(crate) fn report_cwd_adoption(path: &Path) {
210 output::print_success(&format!("Registered {}", output::clean_path(path)));
211 output::print_info(
212 " You are standing in it and nothing had tracked it yet. `git init` runs no Git \
213 hook, so a repository created since the last pass stays unseen until its first \
214 commit — found here instead.",
215 );
216}
217
218/// The Git repository `start` is inside, if any.
219///
220/// Walks up rather than testing `start` alone: `devp status` from `src/` in a new
221/// repository is the same question as running it from the root, and answering it only at
222/// the root would leave the gap open for everyone who does not happen to be standing
223/// there.
224fn enclosing_repo(start: &Path) -> Option<PathBuf> {
225 start
226 .canonicalize()
227 .ok()?
228 .ancestors()
229 .find(|dir| scanner::is_git_repo(dir))
230 .map(Path::to_path_buf)
231}
232
233/// Is this directory called something only a tool would call a checkout?
234///
235/// See [`constants::EPHEMERAL_REPO_PREFIXES`] for why the match is a narrow prefix.
236fn has_ephemeral_name(path: &Path) -> bool {
237 path.file_name().is_some_and(|name| {
238 let name = name.to_string_lossy();
239 constants::EPHEMERAL_REPO_PREFIXES
240 .iter()
241 .any(|prefix| name.starts_with(prefix))
242 })
243}
244
245/// Is `path` somewhere a tool keeps disposable checkouts?
246///
247/// `path` is expected to be canonical already; the temp directory is canonicalised here
248/// because macOS reports it as `/var/folders/…`, a symlink to `/private/var/folders/…`.
249/// A temp directory that cannot be resolved is treated as no match: declining to register
250/// a real workspace would be the worse error of the two.
251fn is_ephemeral_location(path: &Path) -> bool {
252 if has_ephemeral_name(path) {
253 return true;
254 }
255 let under_temp = std::env::temp_dir()
256 .canonicalize()
257 .is_ok_and(|tmp| path.starts_with(tmp));
258 if under_temp {
259 return true;
260 }
261 is_under_ephemeral_ancestor(path, None)
262}
263
264/// The ancestor half of [`is_ephemeral_location`], stopping at `root`.
265///
266/// `devp init <dir>` names a directory outright, and second-guessing the path somebody
267/// typed is not this function's job — `devp init ~/.cache/things` must still find the
268/// repositories in it. So when a scan root is given, only the directories *below* it are
269/// examined. Without a root, every ancestor is.
270fn is_under_ephemeral_ancestor(path: &Path, root: Option<&Path>) -> bool {
271 let Some(parent) = path.parent() else {
272 return false;
273 };
274 parent
275 .ancestors()
276 .take_while(|ancestor| root != Some(*ancestor))
277 .any(|ancestor| {
278 ancestor.file_name().is_some_and(|name| {
279 constants::EPHEMERAL_ANCESTORS.contains(&&*name.to_string_lossy())
280 })
281 })
282}
283
284/// Is `path` a linked Git worktree sitting *inside* its own main working tree?
285///
286/// A linked worktree's `.git` is a file, not a directory, and its one meaningful line
287/// points at the metadata git keeps for it under the main repository:
288/// `gitdir: <main>/.git/worktrees/<name>`. When that main working tree is also an
289/// ancestor of `path`, this is a checkout some tool carved out of the repository it
290/// belongs to — agent harnesses do exactly this, under `.claude/worktrees/` — and the
291/// hook firing on its first commit is the tool committing, not the user adopting a
292/// workspace.
293///
294/// Only the nested case is declined. A worktree checked out *beside* its main
295/// repository is somebody's parallel branch and registers like any other repository.
296/// Anything unreadable or oddly shaped is treated as no match, for the same reason
297/// [`is_ephemeral_location`] resolves doubt that way: refusing to register a real
298/// workspace is the worse error.
299fn is_nested_linked_worktree(path: &Path) -> bool {
300 let dot_git = path.join(".git");
301 if !dot_git.is_file() {
302 return false;
303 }
304 let Ok(contents) = std::fs::read_to_string(&dot_git) else {
305 return false;
306 };
307 let Some(gitdir) = contents
308 .lines()
309 .find_map(|line| line.strip_prefix(constants::GITDIR_PREFIX))
310 else {
311 return false;
312 };
313 // git writes the gitdir relative when the worktree was created with a relative
314 // path; it resolves against the worktree directory, so this must too.
315 let gitdir = path.join(gitdir.trim());
316 let Ok(gitdir) = gitdir.canonicalize() else {
317 return false;
318 };
319
320 // Walk the shape backwards: <main>/.git/worktrees/<name>.
321 let Some(worktrees) = gitdir.parent() else {
322 return false;
323 };
324 if worktrees.file_name().is_none_or(|n| n != "worktrees") {
325 return false;
326 }
327 let Some(main_git) = worktrees.parent() else {
328 return false;
329 };
330 if main_git.file_name().is_none_or(|n| n != ".git") {
331 return false;
332 }
333 let Some(main_root) = main_git.parent() else {
334 return false;
335 };
336
337 // Canonicalise both sides before comparing: on Windows one of them typically
338 // carries the `\\?\` verbatim prefix and the other does not, and `starts_with`
339 // on mixed spellings never matches.
340 let (Ok(main_root), Ok(this)) = (main_root.canonicalize(), path.canonicalize()) else {
341 return false;
342 };
343 this != main_root && this.starts_with(&main_root)
344}
345
346/// Would registering `repo`, found by scanning `root`, be registering a throwaway?
347///
348/// The check `devp init` applies. One `devp init` in a home directory added twenty-eight
349/// plugin-manager checkouts to a real registry, every one of which was deleted within the
350/// week — leaving twenty-eight `Path missing` rows on the dashboard and no way to tell
351/// them apart from a workspace that was genuinely lost.
352pub(crate) fn is_throwaway_checkout(root: &Path, repo: &Path) -> bool {
353 has_ephemeral_name(repo) || is_under_ephemeral_ancestor(repo, Some(root))
354}
355
356/// Run `unlink --missing`: drop every registered path that no longer exists.
357///
358/// Nothing on disk is touched — the directories are already gone. Registries accumulate
359/// these from clones that were deleted, drives that were reformatted, and workspaces that
360/// were moved; `devp doctor` counts them and sends the user here rather than printing one
361/// `devp unlink` line per entry.
362pub fn run_unlink_missing() -> Result<()> {
363 let mut registry = Registry::load()?;
364
365 let gone: Vec<_> = registry
366 .repositories
367 .keys()
368 .filter(|p| !p.exists())
369 .cloned()
370 .collect();
371
372 if gone.is_empty() {
373 output::print_success("Every registered repository still exists — nothing to remove.");
374 return Ok(());
375 }
376
377 for path in &gone {
378 registry.remove_repo(path);
379 output::print_info(&format!("Unlinked: {}", output::clean_path(path)));
380 }
381 // `undo` reverts the last `init`/`link` by unregistering what it added. A path in that
382 // list that no longer exists can never be reverted into anything, so leaving it there
383 // only sets `undo` up to report that it removed nothing.
384 registry.last_added_repos.retain(|p| p.exists());
385 registry.save()?;
386
387 output::print_success(&format!(
388 "Removed {} registry {} pointing at directories that no longer exist.",
389 gone.len(),
390 output::plural(gone.len(), "entry", "entries")
391 ));
392 Ok(())
393}
394
395/// Run the `unlink` command — unregister a repository.
396pub fn run_unlink(path_str: &str) -> Result<()> {
397 // A deleted directory still has to be removable from the registry, so an
398 // uncanonicalisable path falls back to what the user typed rather than failing.
399 let path = Path::new(path_str)
400 .canonicalize()
401 .unwrap_or_else(|_| Path::new(path_str).to_path_buf());
402
403 let mut registry = Registry::load()?;
404
405 if registry.remove_repo(&path) {
406 registry.save()?;
407 output::print_success(&format!("Unlinked: {}", output::clean_path(&path)));
408 } else {
409 output::print_warning(&format!("Not in registry: {}", output::clean_path(&path)));
410 }
411
412 Ok(())
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use tempfile::TempDir;
419
420 #[test]
421 fn a_repository_under_the_temp_directory_is_recognised_as_scratch() {
422 let tmp = TempDir::new().unwrap();
423 let repo = tmp.path().canonicalize().unwrap().join("repo");
424 std::fs::create_dir_all(&repo).unwrap();
425
426 assert!(
427 is_ephemeral_location(&repo),
428 "{} should be seen as scratch — TempDir builds under std::env::temp_dir()",
429 repo.display()
430 );
431 }
432
433 #[test]
434 fn a_repository_outside_the_temp_directory_is_not() {
435 // The crate's own source tree: a real workspace by any definition.
436 let here = Path::new(env!("CARGO_MANIFEST_DIR"))
437 .canonicalize()
438 .unwrap();
439 assert!(!is_ephemeral_location(&here));
440 }
441
442 #[test]
443 fn a_plugin_managers_checkout_is_recognised_as_scratch() {
444 // The shape that filled a real registry: an agent plugin manager clones into
445 // `~/.claude/plugins/cache/temp_git_<id>`, nowhere near the OS temp directory.
446 let home = Path::new(env!("CARGO_MANIFEST_DIR"));
447 let clone = home
448 .join(".claude")
449 .join("plugins")
450 .join("cache")
451 .join("temp_git_1787245534782_8o55r2");
452 assert!(is_ephemeral_location(&clone));
453 }
454
455 #[test]
456 fn a_project_of_that_name_is_still_a_project() {
457 // Only ancestors are matched. A repository *called* `cache` is somebody's work.
458 let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("cache");
459 assert!(!is_ephemeral_location(&repo));
460 }
461
462 #[test]
463 fn a_throwaway_clone_is_recognised_by_its_name_alone() {
464 // The registry that motivated this held twenty-eight of these. The prefix has to
465 // be enough on its own: not every tool is polite enough to put its scratch
466 // checkouts under a directory called `cache`.
467 let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("temp_git_1787320293656");
468 assert!(is_ephemeral_location(&repo));
469 assert!(is_throwaway_checkout(
470 Path::new(env!("CARGO_MANIFEST_DIR")),
471 &repo
472 ));
473 }
474
475 #[test]
476 fn a_repository_merely_named_after_temporary_work_is_not() {
477 // A prefix, not a substring, and the underscore-and-git shape is required: these
478 // are all somebody's actual work.
479 for name in [
480 "temporary-fixes",
481 "template-git",
482 "my-temp-git-notes",
483 "tempo",
484 ] {
485 let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join(name);
486 assert!(!is_ephemeral_location(&repo), "{name} is a real repository");
487 }
488 }
489
490 /// A repository that adoption will accept, somewhere that is not the OS temp
491 /// directory.
492 ///
493 /// Both of the tests below used to point at this crate's own checkout, which read
494 /// well and was wrong the moment the source is not a checkout: a crates.io tarball
495 /// ships no `.git`, so `cargo test` on the published crate — which is exactly what a
496 /// distro packager runs — failed twice for a reason that had nothing to do with the
497 /// code. Under `target/` for the same reason `test_cli_init_and_status` is:
498 /// `adopt_repo_at` declines anything below the temp directory by design.
499 fn fixture_repo() -> (TempDir, PathBuf) {
500 let base = Path::new(env!("CARGO_MANIFEST_DIR")).join("target/adopt-fixtures");
501 std::fs::create_dir_all(&base).unwrap();
502 let tmp = TempDir::new_in(&base).unwrap();
503 let repo = tmp.path().canonicalize().unwrap().join("my-test-repo");
504 // An empty `.git` is all `is_git_repo` looks for, and all adoption reads.
505 std::fs::create_dir_all(repo.join(".git")).unwrap();
506 (tmp, repo)
507 }
508
509 #[test]
510 fn the_repository_you_are_standing_in_is_adopted_when_nothing_tracks_it() {
511 // The `git init` gap, from the inside: a real repository, a registry that has
512 // never heard of it, and a starting directory well below the root — which is
513 // where people actually are when they run `devp status`.
514 let (_fixture, root) = fixture_repo();
515 let deep = root.join("src").join("commands");
516 std::fs::create_dir_all(&deep).unwrap();
517 let mut registry = Registry::default();
518
519 let adopted = adopt_repo_at(&mut registry, &deep);
520 assert_eq!(adopted.as_deref(), Some(root.as_path()));
521 assert_eq!(registry.repo_count(), 1);
522 }
523
524 #[test]
525 fn a_repository_already_registered_is_not_adopted_twice() {
526 // Every `devp status` would otherwise report registering the same repository,
527 // and the caller would save the registry once per invocation for no change.
528 let (_fixture, root) = fixture_repo();
529 let mut registry = Registry::default();
530
531 assert!(adopt_repo_at(&mut registry, &root).is_some());
532 assert!(adopt_repo_at(&mut registry, &root).is_none());
533 assert_eq!(registry.repo_count(), 1);
534 }
535
536 #[test]
537 fn adoption_declines_everything_the_hook_declines() {
538 // Symmetry is the whole safety argument: this path must never register something
539 // `devp link . --quiet` would have left alone. A temp directory is the case that
540 // is cheap to build — and the one every test fixture on the machine lives in.
541 let tmp = TempDir::new().unwrap();
542 let repo = tmp.path().canonicalize().unwrap();
543 std::fs::create_dir_all(repo.join(".git")).unwrap();
544
545 let mut registry = Registry::default();
546 assert!(adopt_repo_at(&mut registry, &repo).is_none());
547 assert_eq!(registry.repo_count(), 0);
548 }
549
550 #[test]
551 fn a_directory_in_no_repository_at_all_is_left_alone() {
552 // `devp status` from a home directory must not invent a repository, and must not
553 // walk to the filesystem root looking for one that is not there.
554 let tmp = TempDir::new().unwrap();
555 let plain = tmp.path().canonicalize().unwrap();
556
557 let mut registry = Registry::default();
558 assert!(adopt_repo_at(&mut registry, &plain).is_none());
559 }
560
561 /// A fabricated linked worktree: a main repository, git's metadata directory for
562 /// the worktree under `.git/worktrees/<name>`, and a worktree whose `.git` file
563 /// carries the given `gitdir:` line. No git shell-out — the guard reads the file
564 /// shape, and the file shape is all these tests build.
565 fn fabricated_worktree(base: &Path, worktree_rel: &str, gitdir_line: &str) -> PathBuf {
566 let meta = base.join("main/.git/worktrees/wt");
567 std::fs::create_dir_all(&meta).unwrap();
568 let worktree = base.join(worktree_rel);
569 std::fs::create_dir_all(&worktree).unwrap();
570 std::fs::write(worktree.join(".git"), format!("{gitdir_line}\n")).unwrap();
571 worktree
572 }
573
574 #[test]
575 fn a_worktree_nested_in_its_main_repository_is_declined_by_the_quiet_paths() {
576 // The registry row that motivated this: an agent harness ran
577 // `git worktree add .claude/worktrees/<name>`, the first commit inside it fired
578 // the hook, and the user found a repository they never made in `devp status`.
579 let tmp = TempDir::new().unwrap();
580 let base = tmp.path().canonicalize().unwrap();
581 let worktree = fabricated_worktree(
582 &base,
583 "main/.claude/worktrees/wt",
584 &format!("gitdir: {}", base.join("main/.git/worktrees/wt").display()),
585 );
586
587 assert!(is_nested_linked_worktree(&worktree));
588 }
589
590 #[test]
591 fn a_relative_gitdir_resolves_against_the_worktree_directory() {
592 // git writes the pointer relative when the worktree was added by relative
593 // path; the same nesting must still be recognised.
594 let tmp = TempDir::new().unwrap();
595 let base = tmp.path().canonicalize().unwrap();
596 let worktree = fabricated_worktree(
597 &base,
598 "main/.claude/worktrees/wt",
599 "gitdir: ../../../.git/worktrees/wt",
600 );
601
602 assert!(is_nested_linked_worktree(&worktree));
603 }
604
605 #[test]
606 fn a_worktree_beside_its_main_repository_is_a_parallel_branch_not_scratch() {
607 let tmp = TempDir::new().unwrap();
608 let base = tmp.path().canonicalize().unwrap();
609 let worktree = fabricated_worktree(
610 &base,
611 "main-wt",
612 &format!("gitdir: {}", base.join("main/.git/worktrees/wt").display()),
613 );
614
615 assert!(!is_nested_linked_worktree(&worktree));
616 }
617
618 #[test]
619 fn a_submodule_is_not_mistaken_for_a_nested_worktree() {
620 // A submodule's `.git` is also a file, but it points under `.git/modules/`,
621 // and a submodule is pruned as itself — it must keep registering.
622 let tmp = TempDir::new().unwrap();
623 let base = tmp.path().canonicalize().unwrap();
624 let modules = base.join("main/.git/modules/sub");
625 std::fs::create_dir_all(&modules).unwrap();
626 let sub = base.join("main/sub");
627 std::fs::create_dir_all(&sub).unwrap();
628 std::fs::write(sub.join(".git"), format!("gitdir: {}\n", modules.display())).unwrap();
629
630 assert!(!is_nested_linked_worktree(&sub));
631 }
632
633 #[test]
634 fn adoption_declines_a_nested_worktree_too() {
635 // The status-command adoption path mirrors the hook's guards; a worktree the
636 // hook declines must not slip in because somebody ran `devp status` inside it.
637 let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("target/adopt-fixtures");
638 std::fs::create_dir_all(&fixtures).unwrap();
639 let tmp = TempDir::new_in(&fixtures).unwrap();
640 let base = tmp.path().canonicalize().unwrap();
641 let worktree = fabricated_worktree(
642 &base,
643 "main/.claude/worktrees/wt",
644 "gitdir: ../../../.git/worktrees/wt",
645 );
646
647 let mut registry = Registry::default();
648 assert!(adopt_repo_at(&mut registry, &worktree).is_none());
649 assert_eq!(registry.repo_count(), 0);
650 }
651
652 #[test]
653 fn init_does_not_second_guess_the_directory_it_was_pointed_at() {
654 // `devp init ~/.cache/things` names a directory outright. Refusing to scan it
655 // because of its own name would make the command silently do nothing — but a
656 // cache directory *below* the root is still a tool's doing.
657 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("cache");
658 let inside = root.join("project");
659 assert!(!is_throwaway_checkout(&root, &inside));
660
661 let deeper = root.join("nested").join("cache").join("project");
662 assert!(is_throwaway_checkout(&root, &deeper));
663 }
664}