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::{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}