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 config that does not parse also keeps the hook out. It may well be the file that
49    // says `disable_hooks`, and a broken one is not licence to register the repo anyway.
50    if quiet
51        && !matches!(
52            PerRepoConfig::load_with_diagnostics(&path),
53            Ok(None)
54                | Ok(Some(PerRepoConfig {
55                    disable_hooks: false,
56                    ..
57                }))
58        )
59    {
60        return Ok(());
61    }
62
63    let mut registry = Registry::load()?;
64
65    if registry.add_repo(path.clone()) {
66        let adoption = registry.adopt_moved_entry(&path, scanner::git::repo_identity(&path));
67        registry.last_added_repos = vec![path.clone()];
68        registry.save()?;
69        if !quiet {
70            output::print_success(&format!("Linked: {}", output::clean_path(&path)));
71            report_adoption(&adoption);
72            if registry.settings.auto_config {
73                ensure_default_repo_config(&path);
74            }
75        }
76    } else {
77        // Backfill only when it is missing. The global Git hook runs this on every
78        // commit, and shelling out to git plus rewriting the registry each time would
79        // be a real cost for a value that never changes once written.
80        if registry.needs_identity(&path) {
81            let adoption = registry.adopt_moved_entry(&path, scanner::git::repo_identity(&path));
82            registry.save()?;
83            if !quiet {
84                report_adoption(&adoption);
85            }
86        }
87        if !quiet {
88            output::print_info(&format!("Already linked: {}", output::clean_path(&path)));
89        }
90    }
91
92    Ok(())
93}
94
95/// Say when a registration recognised a repository that had moved.
96///
97/// Silence here would be worse than noise: the entry the user was staring at in
98/// `devp status` as `Path missing` has just disappeared, and its lifetime total has
99/// turned up on a different row. That is the right outcome, but only if it is stated.
100pub(crate) fn report_adoption(adoption: &Adoption) {
101    match adoption {
102        Adoption::Nothing => {}
103        Adoption::Moved(old) => output::print_info(&format!(
104            "  Recognised as the repository registered at {} — that path is gone, so its \
105             prune history came with it.",
106            output::clean_path(old)
107        )),
108        Adoption::Ambiguous => output::print_warning(
109            "  More than one missing repository shares this root commit, so none was \
110             adopted — they are clones, not a move. Clear them with `devp unlink --missing`.",
111        ),
112    }
113}
114
115/// Write a default `.devprune.json` into a newly registered repository, when
116/// `auto_config` asks for it.
117///
118/// Never over an existing file — broken or not, it is the user's — and a write failure
119/// is a note rather than a failed registration: the repository is tracked either way,
120/// the config was only ever a convenience.
121pub(crate) fn ensure_default_repo_config(path: &Path) {
122    if path.join(crate::constants::PER_REPO_CONFIG_FILE).exists() {
123        return;
124    }
125    match PerRepoConfig::default().save_to_repo(path) {
126        Ok(()) => output::print_info(&format!(
127            "auto_config: wrote a default `.devprune.json` in {}",
128            output::clean_path(path)
129        )),
130        Err(e) => output::print_warning(&format!(
131            "auto_config: could not write `.devprune.json` in {}: {e}",
132            output::clean_path(path)
133        )),
134    }
135}
136
137/// Register the repository the caller is standing in, when nothing has registered it yet.
138///
139/// Git has no `post-init` hook. The three hooks dev-prune installs — `post-commit`,
140/// `post-checkout` and `post-merge` — between them cover every way a repository arrives
141/// from somewhere else, and none of them covers one created here: `git init` runs no hook
142/// at all, and the first hook a new repository ever sees belongs to its first commit.
143/// Until then it is invisible to the mechanism whose whole job was to find it — which is
144/// precisely when somebody runs `devp status` to check whether that worked, sees nothing,
145/// and concludes the hooks are broken. They are not; there was never a hook to fire.
146///
147/// So the commands that read the registry check the working directory first. The guards
148/// are the hook's guards, deliberately: a throwaway checkout, a repository whose config
149/// sets `disable_hooks`, a config that does not parse — every case
150/// `devp link . --quiet` declines to register is declined here for the same reason, so
151/// this can never track something the hook would have left alone.
152///
153/// Returns the path when one was added, so the caller can say so. Persisting is the
154/// caller's: it holds the registry and already saves for its own reasons.
155pub fn adopt_enclosing_repo(registry: &mut Registry) -> Option<PathBuf> {
156    let cwd = std::env::current_dir().ok()?;
157    adopt_repo_at(registry, &cwd)
158}
159
160/// [`adopt_enclosing_repo`], against a named directory rather than the process's own.
161///
162/// Split out so the guards can be tested without a test changing the working directory,
163/// which is process-global and would race every other test in the binary.
164pub(crate) fn adopt_repo_at(registry: &mut Registry, start: &Path) -> Option<PathBuf> {
165    let path = enclosing_repo(start)?;
166
167    if is_ephemeral_location(&path) {
168        return None;
169    }
170
171    if !matches!(
172        PerRepoConfig::load_with_diagnostics(&path),
173        Ok(None)
174            | Ok(Some(PerRepoConfig {
175                disable_hooks: false,
176                ..
177            }))
178    ) {
179        return None;
180    }
181
182    if !registry.add_repo(path.clone()) {
183        return None;
184    }
185
186    registry.adopt_moved_entry(&path, scanner::git::repo_identity(&path));
187    Some(path)
188}
189
190/// Say that the working directory was just registered, and why nothing had done it.
191///
192/// The "why" is not padding. Somebody who ran `git init` and then `devp status` has
193/// already formed the theory that the hooks are broken, and a bare "Registered ..." line
194/// leaves that theory standing. One sentence replaces it with the truth.
195pub(crate) fn report_cwd_adoption(path: &Path) {
196    output::print_success(&format!("Registered {}", output::clean_path(path)));
197    output::print_info(
198        "  You are standing in it and nothing had tracked it yet. `git init` runs no Git \
199         hook, so a repository created since the last pass stays unseen until its first \
200         commit — found here instead.",
201    );
202}
203
204/// The Git repository `start` is inside, if any.
205///
206/// Walks up rather than testing `start` alone: `devp status` from `src/` in a new
207/// repository is the same question as running it from the root, and answering it only at
208/// the root would leave the gap open for everyone who does not happen to be standing
209/// there.
210fn enclosing_repo(start: &Path) -> Option<PathBuf> {
211    start
212        .canonicalize()
213        .ok()?
214        .ancestors()
215        .find(|dir| scanner::is_git_repo(dir))
216        .map(Path::to_path_buf)
217}
218
219/// Is this directory called something only a tool would call a checkout?
220///
221/// See [`constants::EPHEMERAL_REPO_PREFIXES`] for why the match is a narrow prefix.
222fn has_ephemeral_name(path: &Path) -> bool {
223    path.file_name().is_some_and(|name| {
224        let name = name.to_string_lossy();
225        constants::EPHEMERAL_REPO_PREFIXES
226            .iter()
227            .any(|prefix| name.starts_with(prefix))
228    })
229}
230
231/// Is `path` somewhere a tool keeps disposable checkouts?
232///
233/// `path` is expected to be canonical already; the temp directory is canonicalised here
234/// because macOS reports it as `/var/folders/…`, a symlink to `/private/var/folders/…`.
235/// A temp directory that cannot be resolved is treated as no match: declining to register
236/// a real workspace would be the worse error of the two.
237fn is_ephemeral_location(path: &Path) -> bool {
238    if has_ephemeral_name(path) {
239        return true;
240    }
241    let under_temp = std::env::temp_dir()
242        .canonicalize()
243        .is_ok_and(|tmp| path.starts_with(tmp));
244    if under_temp {
245        return true;
246    }
247    is_under_ephemeral_ancestor(path, None)
248}
249
250/// The ancestor half of [`is_ephemeral_location`], stopping at `root`.
251///
252/// `devp init <dir>` names a directory outright, and second-guessing the path somebody
253/// typed is not this function's job — `devp init ~/.cache/things` must still find the
254/// repositories in it. So when a scan root is given, only the directories *below* it are
255/// examined. Without a root, every ancestor is.
256fn is_under_ephemeral_ancestor(path: &Path, root: Option<&Path>) -> bool {
257    let Some(parent) = path.parent() else {
258        return false;
259    };
260    parent
261        .ancestors()
262        .take_while(|ancestor| root != Some(*ancestor))
263        .any(|ancestor| {
264            ancestor.file_name().is_some_and(|name| {
265                constants::EPHEMERAL_ANCESTORS.contains(&&*name.to_string_lossy())
266            })
267        })
268}
269
270/// Would registering `repo`, found by scanning `root`, be registering a throwaway?
271///
272/// The check `devp init` applies. One `devp init` in a home directory added twenty-eight
273/// plugin-manager checkouts to a real registry, every one of which was deleted within the
274/// week — leaving twenty-eight `Path missing` rows on the dashboard and no way to tell
275/// them apart from a workspace that was genuinely lost.
276pub(crate) fn is_throwaway_checkout(root: &Path, repo: &Path) -> bool {
277    has_ephemeral_name(repo) || is_under_ephemeral_ancestor(repo, Some(root))
278}
279
280/// Run `unlink --missing`: drop every registered path that no longer exists.
281///
282/// Nothing on disk is touched — the directories are already gone. Registries accumulate
283/// these from clones that were deleted, drives that were reformatted, and workspaces that
284/// were moved; `devp doctor` counts them and sends the user here rather than printing one
285/// `devp unlink` line per entry.
286pub fn run_unlink_missing() -> Result<()> {
287    let mut registry = Registry::load()?;
288
289    let gone: Vec<_> = registry
290        .repositories
291        .keys()
292        .filter(|p| !p.exists())
293        .cloned()
294        .collect();
295
296    if gone.is_empty() {
297        output::print_success("Every registered repository still exists — nothing to remove.");
298        return Ok(());
299    }
300
301    for path in &gone {
302        registry.remove_repo(path);
303        output::print_info(&format!("Unlinked: {}", output::clean_path(path)));
304    }
305    // `undo` reverts the last `init`/`link` by unregistering what it added. A path in that
306    // list that no longer exists can never be reverted into anything, so leaving it there
307    // only sets `undo` up to report that it removed nothing.
308    registry.last_added_repos.retain(|p| p.exists());
309    registry.save()?;
310
311    output::print_success(&format!(
312        "Removed {} registry {} pointing at directories that no longer exist.",
313        gone.len(),
314        output::plural(gone.len(), "entry", "entries")
315    ));
316    Ok(())
317}
318
319/// Run the `unlink` command — unregister a repository.
320pub fn run_unlink(path_str: &str) -> Result<()> {
321    // A deleted directory still has to be removable from the registry, so an
322    // uncanonicalisable path falls back to what the user typed rather than failing.
323    let path = Path::new(path_str)
324        .canonicalize()
325        .unwrap_or_else(|_| Path::new(path_str).to_path_buf());
326
327    let mut registry = Registry::load()?;
328
329    if registry.remove_repo(&path) {
330        registry.save()?;
331        output::print_success(&format!("Unlinked: {}", output::clean_path(&path)));
332    } else {
333        output::print_warning(&format!("Not in registry: {}", output::clean_path(&path)));
334    }
335
336    Ok(())
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use tempfile::TempDir;
343
344    #[test]
345    fn a_repository_under_the_temp_directory_is_recognised_as_scratch() {
346        let tmp = TempDir::new().unwrap();
347        let repo = tmp.path().canonicalize().unwrap().join("repo");
348        std::fs::create_dir_all(&repo).unwrap();
349
350        assert!(
351            is_ephemeral_location(&repo),
352            "{} should be seen as scratch — TempDir builds under std::env::temp_dir()",
353            repo.display()
354        );
355    }
356
357    #[test]
358    fn a_repository_outside_the_temp_directory_is_not() {
359        // The crate's own source tree: a real workspace by any definition.
360        let here = Path::new(env!("CARGO_MANIFEST_DIR"))
361            .canonicalize()
362            .unwrap();
363        assert!(!is_ephemeral_location(&here));
364    }
365
366    #[test]
367    fn a_plugin_managers_checkout_is_recognised_as_scratch() {
368        // The shape that filled a real registry: an agent plugin manager clones into
369        // `~/.claude/plugins/cache/temp_git_<id>`, nowhere near the OS temp directory.
370        let home = Path::new(env!("CARGO_MANIFEST_DIR"));
371        let clone = home
372            .join(".claude")
373            .join("plugins")
374            .join("cache")
375            .join("temp_git_1787245534782_8o55r2");
376        assert!(is_ephemeral_location(&clone));
377    }
378
379    #[test]
380    fn a_project_of_that_name_is_still_a_project() {
381        // Only ancestors are matched. A repository *called* `cache` is somebody's work.
382        let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("cache");
383        assert!(!is_ephemeral_location(&repo));
384    }
385
386    #[test]
387    fn a_throwaway_clone_is_recognised_by_its_name_alone() {
388        // The registry that motivated this held twenty-eight of these. The prefix has to
389        // be enough on its own: not every tool is polite enough to put its scratch
390        // checkouts under a directory called `cache`.
391        let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("temp_git_1787320293656");
392        assert!(is_ephemeral_location(&repo));
393        assert!(is_throwaway_checkout(
394            Path::new(env!("CARGO_MANIFEST_DIR")),
395            &repo
396        ));
397    }
398
399    #[test]
400    fn a_repository_merely_named_after_temporary_work_is_not() {
401        // A prefix, not a substring, and the underscore-and-git shape is required: these
402        // are all somebody's actual work.
403        for name in [
404            "temporary-fixes",
405            "template-git",
406            "my-temp-git-notes",
407            "tempo",
408        ] {
409            let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join(name);
410            assert!(!is_ephemeral_location(&repo), "{name} is a real repository");
411        }
412    }
413
414    #[test]
415    fn the_repository_you_are_standing_in_is_adopted_when_nothing_tracks_it() {
416        // The `git init` gap, from the inside: a real repository (this crate's own),
417        // a registry that has never heard of it, and a starting directory well below
418        // the root — which is where people actually are when they run `devp status`.
419        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
420            .canonicalize()
421            .unwrap();
422        let mut registry = Registry::default();
423
424        let adopted = adopt_repo_at(&mut registry, &root.join("src").join("commands"));
425        assert_eq!(adopted.as_deref(), Some(root.as_path()));
426        assert_eq!(registry.repo_count(), 1);
427    }
428
429    #[test]
430    fn a_repository_already_registered_is_not_adopted_twice() {
431        // Every `devp status` would otherwise report registering the same repository,
432        // and the caller would save the registry once per invocation for no change.
433        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
434            .canonicalize()
435            .unwrap();
436        let mut registry = Registry::default();
437
438        assert!(adopt_repo_at(&mut registry, &root).is_some());
439        assert!(adopt_repo_at(&mut registry, &root).is_none());
440        assert_eq!(registry.repo_count(), 1);
441    }
442
443    #[test]
444    fn adoption_declines_everything_the_hook_declines() {
445        // Symmetry is the whole safety argument: this path must never register something
446        // `devp link . --quiet` would have left alone. A temp directory is the case that
447        // is cheap to build — and the one every test fixture on the machine lives in.
448        let tmp = TempDir::new().unwrap();
449        let repo = tmp.path().canonicalize().unwrap();
450        std::fs::create_dir_all(repo.join(".git")).unwrap();
451
452        let mut registry = Registry::default();
453        assert!(adopt_repo_at(&mut registry, &repo).is_none());
454        assert_eq!(registry.repo_count(), 0);
455    }
456
457    #[test]
458    fn a_directory_in_no_repository_at_all_is_left_alone() {
459        // `devp status` from a home directory must not invent a repository, and must not
460        // walk to the filesystem root looking for one that is not there.
461        let tmp = TempDir::new().unwrap();
462        let plain = tmp.path().canonicalize().unwrap();
463
464        let mut registry = Registry::default();
465        assert!(adopt_repo_at(&mut registry, &plain).is_none());
466    }
467
468    #[test]
469    fn init_does_not_second_guess_the_directory_it_was_pointed_at() {
470        // `devp init ~/.cache/things` names a directory outright. Refusing to scan it
471        // because of its own name would make the command silently do nothing — but a
472        // cache directory *below* the root is still a tool's doing.
473        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("cache");
474        let inside = root.join("project");
475        assert!(!is_throwaway_checkout(&root, &inside));
476
477        let deeper = root.join("nested").join("cache").join("project");
478        assert!(is_throwaway_checkout(&root, &deeper));
479    }
480}