use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectAliasEntry {
pub alias: String,
pub path: PathBuf,
}
#[derive(Debug, Default)]
pub struct ProjectAliasStore {
entries: Vec<ProjectAliasEntry>,
file_path: PathBuf,
}
impl ProjectAliasStore {
pub fn load(root: &Path) -> anyhow::Result<Self> {
let file_path = root.join("project-paths.json");
if !file_path.exists() {
return Ok(Self {
entries: Vec::new(),
file_path,
});
}
let raw = std::fs::read_to_string(&file_path)
.map_err(|e| anyhow::anyhow!("failed to read {}: {e}", file_path.display()))?;
let entries: Vec<ProjectAliasEntry> = serde_json::from_str(&raw)
.map_err(|e| anyhow::anyhow!("failed to parse {}: {e}", file_path.display()))?;
Ok(Self { entries, file_path })
}
pub fn save(&self) -> anyhow::Result<()> {
if let Some(parent) = self.file_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| anyhow::anyhow!("failed to create {}: {e}", parent.display()))?;
}
let json = serde_json::to_string_pretty(&self.entries)
.map_err(|e| anyhow::anyhow!("failed to serialize project-paths: {e}"))?;
let tmp_path = self.file_path.with_extension("json.tmp");
std::fs::write(&tmp_path, &json)
.map_err(|e| anyhow::anyhow!("failed to write {}: {e}", tmp_path.display()))?;
if let Err(e) = std::fs::rename(&tmp_path, &self.file_path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(anyhow::anyhow!(
"failed to rename {} → {}: {e}",
tmp_path.display(),
self.file_path.display()
));
}
Ok(())
}
pub fn try_register(&mut self, alias: &str, path: &Path) -> bool {
for entry in &self.entries {
if entry.alias == alias {
if entry.path == path {
debug!(
alias,
"project-alias: already registered (same path); no-op"
);
} else {
warn!(
alias,
existing = %entry.path.display(),
incoming = %path.display(),
"project-alias: collision — '{}' already points to a different path; use `tm register` to override",
alias
);
}
return false;
}
}
self.entries.push(ProjectAliasEntry {
alias: alias.to_string(),
path: path.to_path_buf(),
});
true
}
pub fn list(&self) -> Vec<ProjectAliasEntry> {
let mut out = self.entries.clone();
out.sort_by(|a, b| a.alias.cmp(&b.alias));
out
}
}
pub fn alias_from_path(path: &Path) -> String {
path.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "project".to_string())
}
pub fn try_register_project_alias(cwd: &Path, store_root: &Path) {
if let Err(e) = try_register_project_alias_inner(cwd, store_root) {
warn!("project-alias: auto-registration failed (non-fatal): {e}");
}
}
fn try_register_project_alias_inner(cwd: &Path, store_root: &Path) -> anyhow::Result<()> {
let Some(git_root) = find_git_root(cwd) else {
debug!("project-alias: cwd is not inside a git repo; skipping");
return Ok(());
};
let is_worktree = match git_dir_differs_from_common(&git_root) {
Some(differs) => differs,
None => is_worktree_path(&git_root),
};
if is_worktree {
debug!(path = %git_root.display(), "project-alias: skipping worktree checkout");
return Ok(());
}
let alias = alias_from_path(&git_root);
let mut store = ProjectAliasStore::load(store_root)
.map_err(|e| anyhow::anyhow!("load project-paths: {e}"))?;
if store.try_register(&alias, &git_root) {
store
.save()
.map_err(|e| anyhow::anyhow!("save project-paths: {e}"))?;
debug!(alias, path = %git_root.display(), "project-alias: registered");
}
Ok(())
}
pub(crate) fn is_worktree_path(path: &Path) -> bool {
let s = path.to_string_lossy();
s.contains("/.claude/worktrees/") || s.contains("/.worktrees/")
}
pub fn is_main_checkout(path: &Path) -> bool {
if is_worktree_path(path) {
return false;
}
let Some(root) = find_git_root(path) else {
return false;
};
if is_worktree_path(&root) {
return false;
}
root.join(".git").is_dir()
}
fn git_dir_differs_from_common(path: &Path) -> Option<bool> {
let run = |arg: &str| -> Option<String> {
std::process::Command::new("git")
.args(["-C", &path.to_string_lossy(), "rev-parse", arg])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
};
let git_dir = run("--git-dir")?;
let common_dir = run("--git-common-dir")?;
Some(git_dir != common_dir)
}
fn find_git_root(cwd: &Path) -> Option<PathBuf> {
for ancestor in cwd.ancestors() {
if ancestor.join(".git").exists() {
return Some(ancestor.to_path_buf());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
fn make_store(dir: &TempDir) -> ProjectAliasStore {
ProjectAliasStore::load(dir.path()).expect("load")
}
#[test]
fn project_alias_store_new_is_empty() {
let dir = TempDir::new().expect("tmpdir");
let store = make_store(&dir);
assert!(store.list().is_empty());
}
#[test]
fn try_register_inserts_new_entry() {
let dir = TempDir::new().expect("tmpdir");
let mut store = make_store(&dir);
let path = PathBuf::from("/home/user/Projects/my-project");
let changed = store.try_register("my-project", &path);
assert!(changed, "new entry should return true");
let list = store.list();
assert_eq!(list.len(), 1);
assert_eq!(list[0].alias, "my-project");
assert_eq!(list[0].path, path);
}
#[test]
fn try_register_idempotent() {
let dir = TempDir::new().expect("tmpdir");
let mut store = make_store(&dir);
let path = PathBuf::from("/home/user/Projects/my-project");
assert!(store.try_register("my-project", &path));
let changed = store.try_register("my-project", &path);
assert!(!changed, "re-register same alias+path must be no-op");
assert_eq!(store.list().len(), 1, "no duplicate entries");
}
#[test]
fn try_register_collision_keeps_existing() {
let dir = TempDir::new().expect("tmpdir");
let mut store = make_store(&dir);
let path_a = PathBuf::from("/home/user/Projects-A/my-project");
let path_b = PathBuf::from("/home/user/Projects-B/my-project");
assert!(store.try_register("my-project", &path_a));
let changed = store.try_register("my-project", &path_b);
assert!(!changed, "collision must not mutate the store");
let list = store.list();
assert_eq!(list.len(), 1, "collision must not add a second entry");
assert_eq!(list[0].path, path_a, "existing path must be preserved");
}
#[test]
fn project_alias_store_list_is_sorted() {
let dir = TempDir::new().expect("tmpdir");
let mut store = make_store(&dir);
store.try_register("zebra", &PathBuf::from("/z"));
store.try_register("apple", &PathBuf::from("/a"));
store.try_register("mango", &PathBuf::from("/m"));
let list = store.list();
let names: Vec<&str> = list.iter().map(|e| e.alias.as_str()).collect();
assert_eq!(names, ["apple", "mango", "zebra"]);
}
#[test]
fn save_and_reload_round_trip() {
let dir = TempDir::new().expect("tmpdir");
{
let mut store = make_store(&dir);
store.try_register("alpha", &PathBuf::from("/home/user/alpha"));
store.try_register("beta", &PathBuf::from("/home/user/beta"));
store.save().expect("save");
}
{
let store2 = make_store(&dir);
let list = store2.list();
assert_eq!(list.len(), 2);
let names: Vec<&str> = list.iter().map(|e| e.alias.as_str()).collect();
assert!(names.contains(&"alpha"), "{names:?}");
assert!(names.contains(&"beta"), "{names:?}");
}
}
#[test]
fn alias_from_path_basename() {
assert_eq!(
alias_from_path(Path::new("/home/user/Projects/trusty-tools")),
"trusty-tools"
);
assert_eq!(
alias_from_path(Path::new("/home/user/Projects/trusty-tools/")),
"trusty-tools"
);
}
#[test]
fn alias_from_path_no_component() {
assert_eq!(alias_from_path(Path::new("/")), "project");
}
#[test]
fn is_worktree_path_claude_worktrees() {
let p = Path::new("/home/user/Projects/my-repo/.claude/worktrees/agent-abc123def456/");
assert!(
is_worktree_path(p),
"expected is_worktree_path=true for .claude/worktrees path, got false"
);
}
#[test]
fn is_worktree_path_repo_worktrees() {
let p = Path::new("/home/user/Projects/my-repo/.worktrees/feature-branch-wt/");
assert!(
is_worktree_path(p),
"expected is_worktree_path=true for .worktrees path, got false"
);
}
#[test]
fn is_worktree_path_normal_project() {
let p = Path::new("/home/user/Projects/my-repo");
assert!(
!is_worktree_path(p),
"expected is_worktree_path=false for normal project path, got true"
);
}
#[test]
fn is_worktree_path_home_dir_not_a_worktree() {
let p = Path::new("/home/user");
assert!(!is_worktree_path(p));
}
#[test]
fn is_main_checkout_detects_a_plain_checkout() {
let dir = TempDir::new().expect("tmpdir");
let repo = dir.path().join("repo");
std::fs::create_dir_all(repo.join(".git")).expect("mkdir .git");
std::fs::create_dir_all(repo.join("crates/trusty-mpm/src")).expect("mkdir src");
assert!(is_main_checkout(&repo));
assert!(is_main_checkout(&repo.join("crates/trusty-mpm/src")));
}
#[test]
fn is_main_checkout_rejects_worktrees() {
let dir = TempDir::new().expect("tmpdir");
let repo = dir.path().join("repo");
std::fs::create_dir_all(repo.join(".git")).expect("mkdir .git");
let claude_wt = repo.join(".claude/worktrees/wt-x/crates");
std::fs::create_dir_all(&claude_wt).expect("mkdir worktree");
assert!(
!is_main_checkout(&claude_wt),
"a .claude/worktrees path must never classify as the main checkout"
);
let elsewhere = dir.path().join("linked-wt");
std::fs::create_dir_all(&elsewhere).expect("mkdir linked");
std::fs::write(elsewhere.join(".git"), "gitdir: /repo/.git/worktrees/x")
.expect("write .git file");
assert!(
!is_main_checkout(&elsewhere),
"a `.git` FILE marks a linked worktree, wherever it lives"
);
}
#[test]
fn is_main_checkout_rejects_a_non_repository() {
let dir = TempDir::new().expect("tmpdir");
assert!(!is_main_checkout(dir.path()));
assert!(!is_main_checkout(&dir.path().join("does/not/exist")));
}
#[test]
fn find_git_root_returns_none_outside_repo() {
let dir = TempDir::new().expect("tmpdir");
let result = find_git_root(dir.path());
assert!(
result.is_none(),
"expected None for non-git dir, got {result:?}"
);
}
#[test]
fn find_git_root_returns_some_in_this_repo() {
let cwd = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let result = find_git_root(cwd);
assert!(result.is_some(), "expected Some for cwd inside a git repo");
let root = result.unwrap();
assert!(
root.join(".git").exists(),
".git must exist at {}",
root.display()
);
}
#[test]
fn try_register_and_load_round_trip_same_root() {
let store_root = TempDir::new().expect("tmpdir");
let cwd = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let Some(git_root) = find_git_root(cwd) else {
return; };
try_register_project_alias(cwd, store_root.path());
let store = ProjectAliasStore::load(store_root.path()).expect("load");
let entries = store.list();
let is_worktree = match git_dir_differs_from_common(&git_root) {
Some(differs) => differs,
None => is_worktree_path(&git_root),
};
if is_worktree {
assert!(
entries.is_empty(),
"expected no entries when cwd is a worktree, got {entries:?}"
);
} else {
let expected_alias = alias_from_path(&git_root);
assert!(
entries
.iter()
.any(|e| e.alias == expected_alias && e.path == git_root),
"expected entry alias={expected_alias} path={} in {entries:?}",
git_root.display()
);
}
}
}