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;
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/// Is this directory called something only a tool would call a checkout?
138///
139/// See [`constants::EPHEMERAL_REPO_PREFIXES`] for why the match is a narrow prefix.
140fn has_ephemeral_name(path: &Path) -> bool {
141    path.file_name().is_some_and(|name| {
142        let name = name.to_string_lossy();
143        constants::EPHEMERAL_REPO_PREFIXES
144            .iter()
145            .any(|prefix| name.starts_with(prefix))
146    })
147}
148
149/// Is `path` somewhere a tool keeps disposable checkouts?
150///
151/// `path` is expected to be canonical already; the temp directory is canonicalised here
152/// because macOS reports it as `/var/folders/…`, a symlink to `/private/var/folders/…`.
153/// A temp directory that cannot be resolved is treated as no match: declining to register
154/// a real workspace would be the worse error of the two.
155fn is_ephemeral_location(path: &Path) -> bool {
156    if has_ephemeral_name(path) {
157        return true;
158    }
159    let under_temp = std::env::temp_dir()
160        .canonicalize()
161        .is_ok_and(|tmp| path.starts_with(tmp));
162    if under_temp {
163        return true;
164    }
165    is_under_ephemeral_ancestor(path, None)
166}
167
168/// The ancestor half of [`is_ephemeral_location`], stopping at `root`.
169///
170/// `devp init <dir>` names a directory outright, and second-guessing the path somebody
171/// typed is not this function's job — `devp init ~/.cache/things` must still find the
172/// repositories in it. So when a scan root is given, only the directories *below* it are
173/// examined. Without a root, every ancestor is.
174fn is_under_ephemeral_ancestor(path: &Path, root: Option<&Path>) -> bool {
175    let Some(parent) = path.parent() else {
176        return false;
177    };
178    parent
179        .ancestors()
180        .take_while(|ancestor| root != Some(*ancestor))
181        .any(|ancestor| {
182            ancestor.file_name().is_some_and(|name| {
183                constants::EPHEMERAL_ANCESTORS.contains(&&*name.to_string_lossy())
184            })
185        })
186}
187
188/// Would registering `repo`, found by scanning `root`, be registering a throwaway?
189///
190/// The check `devp init` applies. One `devp init` in a home directory added twenty-eight
191/// plugin-manager checkouts to a real registry, every one of which was deleted within the
192/// week — leaving twenty-eight `Path missing` rows on the dashboard and no way to tell
193/// them apart from a workspace that was genuinely lost.
194pub(crate) fn is_throwaway_checkout(root: &Path, repo: &Path) -> bool {
195    has_ephemeral_name(repo) || is_under_ephemeral_ancestor(repo, Some(root))
196}
197
198/// Run `unlink --missing`: drop every registered path that no longer exists.
199///
200/// Nothing on disk is touched — the directories are already gone. Registries accumulate
201/// these from clones that were deleted, drives that were reformatted, and workspaces that
202/// were moved; `devp doctor` counts them and sends the user here rather than printing one
203/// `devp unlink` line per entry.
204pub fn run_unlink_missing() -> Result<()> {
205    let mut registry = Registry::load()?;
206
207    let gone: Vec<_> = registry
208        .repositories
209        .keys()
210        .filter(|p| !p.exists())
211        .cloned()
212        .collect();
213
214    if gone.is_empty() {
215        output::print_success("Every registered repository still exists — nothing to remove.");
216        return Ok(());
217    }
218
219    for path in &gone {
220        registry.remove_repo(path);
221        output::print_info(&format!("Unlinked: {}", output::clean_path(path)));
222    }
223    // `undo` reverts the last `init`/`link` by unregistering what it added. A path in that
224    // list that no longer exists can never be reverted into anything, so leaving it there
225    // only sets `undo` up to report that it removed nothing.
226    registry.last_added_repos.retain(|p| p.exists());
227    registry.save()?;
228
229    output::print_success(&format!(
230        "Removed {} registry {} pointing at directories that no longer exist.",
231        gone.len(),
232        output::plural(gone.len(), "entry", "entries")
233    ));
234    Ok(())
235}
236
237/// Run the `unlink` command — unregister a repository.
238pub fn run_unlink(path_str: &str) -> Result<()> {
239    // A deleted directory still has to be removable from the registry, so an
240    // uncanonicalisable path falls back to what the user typed rather than failing.
241    let path = Path::new(path_str)
242        .canonicalize()
243        .unwrap_or_else(|_| Path::new(path_str).to_path_buf());
244
245    let mut registry = Registry::load()?;
246
247    if registry.remove_repo(&path) {
248        registry.save()?;
249        output::print_success(&format!("Unlinked: {}", output::clean_path(&path)));
250    } else {
251        output::print_warning(&format!("Not in registry: {}", output::clean_path(&path)));
252    }
253
254    Ok(())
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use tempfile::TempDir;
261
262    #[test]
263    fn a_repository_under_the_temp_directory_is_recognised_as_scratch() {
264        let tmp = TempDir::new().unwrap();
265        let repo = tmp.path().canonicalize().unwrap().join("repo");
266        std::fs::create_dir_all(&repo).unwrap();
267
268        assert!(
269            is_ephemeral_location(&repo),
270            "{} should be seen as scratch — TempDir builds under std::env::temp_dir()",
271            repo.display()
272        );
273    }
274
275    #[test]
276    fn a_repository_outside_the_temp_directory_is_not() {
277        // The crate's own source tree: a real workspace by any definition.
278        let here = Path::new(env!("CARGO_MANIFEST_DIR"))
279            .canonicalize()
280            .unwrap();
281        assert!(!is_ephemeral_location(&here));
282    }
283
284    #[test]
285    fn a_plugin_managers_checkout_is_recognised_as_scratch() {
286        // The shape that filled a real registry: an agent plugin manager clones into
287        // `~/.claude/plugins/cache/temp_git_<id>`, nowhere near the OS temp directory.
288        let home = Path::new(env!("CARGO_MANIFEST_DIR"));
289        let clone = home
290            .join(".claude")
291            .join("plugins")
292            .join("cache")
293            .join("temp_git_1787245534782_8o55r2");
294        assert!(is_ephemeral_location(&clone));
295    }
296
297    #[test]
298    fn a_project_of_that_name_is_still_a_project() {
299        // Only ancestors are matched. A repository *called* `cache` is somebody's work.
300        let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("cache");
301        assert!(!is_ephemeral_location(&repo));
302    }
303
304    #[test]
305    fn a_throwaway_clone_is_recognised_by_its_name_alone() {
306        // The registry that motivated this held twenty-eight of these. The prefix has to
307        // be enough on its own: not every tool is polite enough to put its scratch
308        // checkouts under a directory called `cache`.
309        let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("temp_git_1787320293656");
310        assert!(is_ephemeral_location(&repo));
311        assert!(is_throwaway_checkout(
312            Path::new(env!("CARGO_MANIFEST_DIR")),
313            &repo
314        ));
315    }
316
317    #[test]
318    fn a_repository_merely_named_after_temporary_work_is_not() {
319        // A prefix, not a substring, and the underscore-and-git shape is required: these
320        // are all somebody's actual work.
321        for name in [
322            "temporary-fixes",
323            "template-git",
324            "my-temp-git-notes",
325            "tempo",
326        ] {
327            let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join(name);
328            assert!(!is_ephemeral_location(&repo), "{name} is a real repository");
329        }
330    }
331
332    #[test]
333    fn init_does_not_second_guess_the_directory_it_was_pointed_at() {
334        // `devp init ~/.cache/things` names a directory outright. Refusing to scan it
335        // because of its own name would make the command silently do nothing — but a
336        // cache directory *below* the root is still a tool's doing.
337        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("cache");
338        let inside = root.join("project");
339        assert!(!is_throwaway_checkout(&root, &inside));
340
341        let deeper = root.join("nested").join("cache").join("project");
342        assert!(is_throwaway_checkout(&root, &deeper));
343    }
344}