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            if registry.settings.auto_config {
70                ensure_default_repo_config(&path);
71            }
72        }
73    } else if !quiet {
74        output::print_info(&format!("Already linked: {}", output::clean_path(&path)));
75    }
76
77    Ok(())
78}
79
80/// Write a default `.devprune.json` into a newly registered repository, when
81/// `auto_config` asks for it.
82///
83/// Never over an existing file — broken or not, it is the user's — and a write failure
84/// is a note rather than a failed registration: the repository is tracked either way,
85/// the config was only ever a convenience.
86pub(crate) fn ensure_default_repo_config(path: &Path) {
87    if path.join(crate::constants::PER_REPO_CONFIG_FILE).exists() {
88        return;
89    }
90    match PerRepoConfig::default().save_to_repo(path) {
91        Ok(()) => output::print_info(&format!(
92            "auto_config: wrote a default `.devprune.json` in {}",
93            output::clean_path(path)
94        )),
95        Err(e) => output::print_warning(&format!(
96            "auto_config: could not write `.devprune.json` in {}: {e}",
97            output::clean_path(path)
98        )),
99    }
100}
101
102/// Is `path` inside the OS temporary directory?
103///
104/// `path` is expected to be canonical already; the temp directory is canonicalised here
105/// because macOS reports it as `/var/folders/…`, a symlink to `/private/var/folders/…`.
106/// A temp directory that cannot be resolved is treated as no match: declining to register
107/// a real workspace would be the worse error of the two.
108fn is_under_temp_dir(path: &Path) -> bool {
109    std::env::temp_dir()
110        .canonicalize()
111        .is_ok_and(|tmp| path.starts_with(tmp))
112}
113
114/// Run `unlink --missing`: drop every registered path that no longer exists.
115///
116/// Nothing on disk is touched — the directories are already gone. Registries accumulate
117/// these from clones that were deleted, drives that were reformatted, and workspaces that
118/// were moved; `devp doctor` counts them and sends the user here rather than printing one
119/// `devp unlink` line per entry.
120pub fn run_unlink_missing() -> Result<()> {
121    let mut registry = Registry::load()?;
122
123    let gone: Vec<_> = registry
124        .repositories
125        .keys()
126        .filter(|p| !p.exists())
127        .cloned()
128        .collect();
129
130    if gone.is_empty() {
131        output::print_success("Every registered repository still exists — nothing to remove.");
132        return Ok(());
133    }
134
135    for path in &gone {
136        registry.remove_repo(path);
137        output::print_info(&format!("Unlinked: {}", output::clean_path(path)));
138    }
139    // `undo` reverts the last `init`/`link` by unregistering what it added. A path in that
140    // list that no longer exists can never be reverted into anything, so leaving it there
141    // only sets `undo` up to report that it removed nothing.
142    registry.last_added_repos.retain(|p| p.exists());
143    registry.save()?;
144
145    output::print_success(&format!(
146        "Removed {} registry {} pointing at directories that no longer exist.",
147        gone.len(),
148        output::plural(gone.len(), "entry", "entries")
149    ));
150    Ok(())
151}
152
153/// Run the `unlink` command — unregister a repository.
154pub fn run_unlink(path_str: &str) -> Result<()> {
155    // A deleted directory still has to be removable from the registry, so an
156    // uncanonicalisable path falls back to what the user typed rather than failing.
157    let path = Path::new(path_str)
158        .canonicalize()
159        .unwrap_or_else(|_| Path::new(path_str).to_path_buf());
160
161    let mut registry = Registry::load()?;
162
163    if registry.remove_repo(&path) {
164        registry.save()?;
165        output::print_success(&format!("Unlinked: {}", output::clean_path(&path)));
166    } else {
167        output::print_warning(&format!("Not in registry: {}", output::clean_path(&path)));
168    }
169
170    Ok(())
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use tempfile::TempDir;
177
178    #[test]
179    fn a_repository_under_the_temp_directory_is_recognised_as_scratch() {
180        let tmp = TempDir::new().unwrap();
181        let repo = tmp.path().canonicalize().unwrap().join("repo");
182        std::fs::create_dir_all(&repo).unwrap();
183
184        assert!(
185            is_under_temp_dir(&repo),
186            "{} should be seen as scratch — TempDir builds under std::env::temp_dir()",
187            repo.display()
188        );
189    }
190
191    #[test]
192    fn a_repository_outside_the_temp_directory_is_not() {
193        // The crate's own source tree: a real workspace by any definition.
194        let here = Path::new(env!("CARGO_MANIFEST_DIR"))
195            .canonicalize()
196            .unwrap();
197        assert!(!is_under_temp_dir(&here));
198    }
199}