Skip to main content

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}