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}