use std::path::Path;
use anyhow::{Context, Result};
use crate::config::{Adoption, PerRepoConfig, Registry};
use crate::constants;
use crate::output;
use crate::scanner;
pub fn run_link(path_str: &str, quiet: bool) -> Result<()> {
let path = Path::new(path_str)
.canonicalize()
.with_context(|| format!("Path not found: {path_str}"))?;
if !scanner::is_git_repo(&path) {
if quiet {
return Ok(());
}
anyhow::bail!(
"`{}` is not a Git repository.\n \
Run `git init` there first, then `devp link .` again.",
output::clean_path(&path)
);
}
if quiet && is_ephemeral_location(&path) {
return Ok(());
}
if quiet
&& !matches!(
PerRepoConfig::load_with_diagnostics(&path),
Ok(None)
| Ok(Some(PerRepoConfig {
disable_hooks: false,
..
}))
)
{
return Ok(());
}
let mut registry = Registry::load()?;
if registry.add_repo(path.clone()) {
let adoption = registry.adopt_moved_entry(&path, scanner::git::repo_identity(&path));
registry.last_added_repos = vec![path.clone()];
registry.save()?;
if !quiet {
output::print_success(&format!("Linked: {}", output::clean_path(&path)));
report_adoption(&adoption);
if registry.settings.auto_config {
ensure_default_repo_config(&path);
}
}
} else {
if registry.needs_identity(&path) {
let adoption = registry.adopt_moved_entry(&path, scanner::git::repo_identity(&path));
registry.save()?;
if !quiet {
report_adoption(&adoption);
}
}
if !quiet {
output::print_info(&format!("Already linked: {}", output::clean_path(&path)));
}
}
Ok(())
}
pub(crate) fn report_adoption(adoption: &Adoption) {
match adoption {
Adoption::Nothing => {}
Adoption::Moved(old) => output::print_info(&format!(
" Recognised as the repository registered at {} — that path is gone, so its \
prune history came with it.",
output::clean_path(old)
)),
Adoption::Ambiguous => output::print_warning(
" More than one missing repository shares this root commit, so none was \
adopted — they are clones, not a move. Clear them with `devp unlink --missing`.",
),
}
}
pub(crate) fn ensure_default_repo_config(path: &Path) {
if path.join(crate::constants::PER_REPO_CONFIG_FILE).exists() {
return;
}
match PerRepoConfig::default().save_to_repo(path) {
Ok(()) => output::print_info(&format!(
"auto_config: wrote a default `.devprune.json` in {}",
output::clean_path(path)
)),
Err(e) => output::print_warning(&format!(
"auto_config: could not write `.devprune.json` in {}: {e}",
output::clean_path(path)
)),
}
}
fn has_ephemeral_name(path: &Path) -> bool {
path.file_name().is_some_and(|name| {
let name = name.to_string_lossy();
constants::EPHEMERAL_REPO_PREFIXES
.iter()
.any(|prefix| name.starts_with(prefix))
})
}
fn is_ephemeral_location(path: &Path) -> bool {
if has_ephemeral_name(path) {
return true;
}
let under_temp = std::env::temp_dir()
.canonicalize()
.is_ok_and(|tmp| path.starts_with(tmp));
if under_temp {
return true;
}
is_under_ephemeral_ancestor(path, None)
}
fn is_under_ephemeral_ancestor(path: &Path, root: Option<&Path>) -> bool {
let Some(parent) = path.parent() else {
return false;
};
parent
.ancestors()
.take_while(|ancestor| root != Some(*ancestor))
.any(|ancestor| {
ancestor.file_name().is_some_and(|name| {
constants::EPHEMERAL_ANCESTORS.contains(&&*name.to_string_lossy())
})
})
}
pub(crate) fn is_throwaway_checkout(root: &Path, repo: &Path) -> bool {
has_ephemeral_name(repo) || is_under_ephemeral_ancestor(repo, Some(root))
}
pub fn run_unlink_missing() -> Result<()> {
let mut registry = Registry::load()?;
let gone: Vec<_> = registry
.repositories
.keys()
.filter(|p| !p.exists())
.cloned()
.collect();
if gone.is_empty() {
output::print_success("Every registered repository still exists — nothing to remove.");
return Ok(());
}
for path in &gone {
registry.remove_repo(path);
output::print_info(&format!("Unlinked: {}", output::clean_path(path)));
}
registry.last_added_repos.retain(|p| p.exists());
registry.save()?;
output::print_success(&format!(
"Removed {} registry {} pointing at directories that no longer exist.",
gone.len(),
output::plural(gone.len(), "entry", "entries")
));
Ok(())
}
pub fn run_unlink(path_str: &str) -> Result<()> {
let path = Path::new(path_str)
.canonicalize()
.unwrap_or_else(|_| Path::new(path_str).to_path_buf());
let mut registry = Registry::load()?;
if registry.remove_repo(&path) {
registry.save()?;
output::print_success(&format!("Unlinked: {}", output::clean_path(&path)));
} else {
output::print_warning(&format!("Not in registry: {}", output::clean_path(&path)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn a_repository_under_the_temp_directory_is_recognised_as_scratch() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().canonicalize().unwrap().join("repo");
std::fs::create_dir_all(&repo).unwrap();
assert!(
is_ephemeral_location(&repo),
"{} should be seen as scratch — TempDir builds under std::env::temp_dir()",
repo.display()
);
}
#[test]
fn a_repository_outside_the_temp_directory_is_not() {
let here = Path::new(env!("CARGO_MANIFEST_DIR"))
.canonicalize()
.unwrap();
assert!(!is_ephemeral_location(&here));
}
#[test]
fn a_plugin_managers_checkout_is_recognised_as_scratch() {
let home = Path::new(env!("CARGO_MANIFEST_DIR"));
let clone = home
.join(".claude")
.join("plugins")
.join("cache")
.join("temp_git_1787245534782_8o55r2");
assert!(is_ephemeral_location(&clone));
}
#[test]
fn a_project_of_that_name_is_still_a_project() {
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("cache");
assert!(!is_ephemeral_location(&repo));
}
#[test]
fn a_throwaway_clone_is_recognised_by_its_name_alone() {
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("temp_git_1787320293656");
assert!(is_ephemeral_location(&repo));
assert!(is_throwaway_checkout(
Path::new(env!("CARGO_MANIFEST_DIR")),
&repo
));
}
#[test]
fn a_repository_merely_named_after_temporary_work_is_not() {
for name in [
"temporary-fixes",
"template-git",
"my-temp-git-notes",
"tempo",
] {
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join(name);
assert!(!is_ephemeral_location(&repo), "{name} is a real repository");
}
}
#[test]
fn init_does_not_second_guess_the_directory_it_was_pointed_at() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("cache");
let inside = root.join("project");
assert!(!is_throwaway_checkout(&root, &inside));
let deeper = root.join("nested").join("cache").join("project");
assert!(is_throwaway_checkout(&root, &deeper));
}
}