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::{PerRepoConfig, Registry};
11use crate::output;
12use crate::scanner;
13
14/// Run the `link` command — register a Git repository.
15///
16/// `quiet` is what the global Git hook passes. In that mode nothing is printed and a
17/// repository whose `.devprune.json` sets `disable_hooks` is left unregistered — that
18/// flag exists precisely to keep the hook out of a specific workspace.
19pub fn run_link(path_str: &str, quiet: bool) -> Result<()> {
20 let path = Path::new(path_str)
21 .canonicalize()
22 .with_context(|| format!("Path not found: {path_str}"))?;
23
24 if !scanner::is_git_repo(&path) {
25 if quiet {
26 return Ok(());
27 }
28 // Non-zero, because nothing was linked. The hook path above still exits 0: a
29 // commit in a directory dev-prune does not track is not a failed commit.
30 anyhow::bail!(
31 "`{}` is not a Git repository.\n \
32 Run `git init` there first, then `devp link .` again.",
33 output::clean_path(&path)
34 );
35 }
36
37 // A repository under the OS temporary directory is scratch by definition: a test
38 // fixture, a `git clone` into `mktemp -d`, a build step. The hook fires on its first
39 // commit, the directory is gone minutes later, and the registry keeps an entry that
40 // can never be pruned and never be found again. Registering those is how a registry
41 // fills with dead paths. An explicit `devp link` still works — this only declines to
42 // do it *unasked*.
43 if quiet && is_under_temp_dir(&path) {
44 return Ok(());
45 }
46
47 // A config that does not parse also keeps the hook out. It may well be the file that
48 // says `disable_hooks`, and a broken one is not licence to register the repo anyway.
49 if quiet
50 && !matches!(
51 PerRepoConfig::load_with_diagnostics(&path),
52 Ok(None)
53 | Ok(Some(PerRepoConfig {
54 disable_hooks: false,
55 ..
56 }))
57 )
58 {
59 return Ok(());
60 }
61
62 let mut registry = Registry::load()?;
63
64 if registry.add_repo(path.clone()) {
65 registry.last_added_repos = vec![path.clone()];
66 registry.save()?;
67 if !quiet {
68 output::print_success(&format!("Linked: {}", output::clean_path(&path)));
69 }
70 } else if !quiet {
71 output::print_info(&format!("Already linked: {}", output::clean_path(&path)));
72 }
73
74 Ok(())
75}
76
77/// Is `path` inside the OS temporary directory?
78///
79/// `path` is expected to be canonical already; the temp directory is canonicalised here
80/// because macOS reports it as `/var/folders/…`, a symlink to `/private/var/folders/…`.
81/// A temp directory that cannot be resolved is treated as no match: declining to register
82/// a real workspace would be the worse error of the two.
83fn is_under_temp_dir(path: &Path) -> bool {
84 std::env::temp_dir()
85 .canonicalize()
86 .is_ok_and(|tmp| path.starts_with(tmp))
87}
88
89/// Run `unlink --missing`: drop every registered path that no longer exists.
90///
91/// Nothing on disk is touched — the directories are already gone. Registries accumulate
92/// these from clones that were deleted, drives that were reformatted, and workspaces that
93/// were moved; `devp doctor` counts them and sends the user here rather than printing one
94/// `devp unlink` line per entry.
95pub fn run_unlink_missing() -> Result<()> {
96 let mut registry = Registry::load()?;
97
98 let gone: Vec<_> = registry
99 .repositories
100 .keys()
101 .filter(|p| !p.exists())
102 .cloned()
103 .collect();
104
105 if gone.is_empty() {
106 output::print_success("Every registered repository still exists — nothing to remove.");
107 return Ok(());
108 }
109
110 for path in &gone {
111 registry.remove_repo(path);
112 output::print_info(&format!("Unlinked: {}", output::clean_path(path)));
113 }
114 // `undo` reverts the last `init`/`link` by unregistering what it added. A path in that
115 // list that no longer exists can never be reverted into anything, so leaving it there
116 // only sets `undo` up to report that it removed nothing.
117 registry.last_added_repos.retain(|p| p.exists());
118 registry.save()?;
119
120 output::print_success(&format!(
121 "Removed {} registry {} pointing at directories that no longer exist.",
122 gone.len(),
123 output::plural(gone.len(), "entry", "entries")
124 ));
125 Ok(())
126}
127
128/// Run the `unlink` command — unregister a repository.
129pub fn run_unlink(path_str: &str) -> Result<()> {
130 // A deleted directory still has to be removable from the registry, so an
131 // uncanonicalisable path falls back to what the user typed rather than failing.
132 let path = Path::new(path_str)
133 .canonicalize()
134 .unwrap_or_else(|_| Path::new(path_str).to_path_buf());
135
136 let mut registry = Registry::load()?;
137
138 if registry.remove_repo(&path) {
139 // The unlink already did what `undo` would do for this path. Left in the list,
140 // the next `devp undo` would "revert" the addition by removing nothing and
141 // reporting exactly that.
142 registry.last_added_repos.retain(|p| p != &path);
143 registry.save()?;
144 output::print_success(&format!("Unlinked: {}", output::clean_path(&path)));
145 } else {
146 output::print_warning(&format!("Not in registry: {}", output::clean_path(&path)));
147 }
148
149 Ok(())
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use tempfile::TempDir;
156
157 #[test]
158 fn a_repository_under_the_temp_directory_is_recognised_as_scratch() {
159 let tmp = TempDir::new().unwrap();
160 let repo = tmp.path().canonicalize().unwrap().join("repo");
161 std::fs::create_dir_all(&repo).unwrap();
162
163 assert!(
164 is_under_temp_dir(&repo),
165 "{} should be seen as scratch — TempDir builds under std::env::temp_dir()",
166 repo.display()
167 );
168 }
169
170 #[test]
171 fn a_repository_outside_the_temp_directory_is_not() {
172 // The crate's own source tree: a real workspace by any definition.
173 let here = Path::new(env!("CARGO_MANIFEST_DIR"))
174 .canonicalize()
175 .unwrap();
176 assert!(!is_under_temp_dir(&here));
177 }
178}