dev_prune/commands/
link.rs1use 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
15pub 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 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 if quiet && is_ephemeral_location(&path) {
45 return Ok(());
46 }
47
48 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 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
95pub(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
115pub(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
137fn 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
149fn 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
168fn 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
188pub(crate) fn is_throwaway_checkout(root: &Path, repo: &Path) -> bool {
195 has_ephemeral_name(repo) || is_under_ephemeral_ancestor(repo, Some(root))
196}
197
198pub 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 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
237pub fn run_unlink(path_str: &str) -> Result<()> {
239 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 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 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 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 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 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 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}