use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
pub struct WorktreeStorage {
root_dir: PathBuf,
}
impl WorktreeStorage {
pub fn new() -> Result<Self> {
let root_dir = if let Ok(custom_root) = std::env::var("WORKTREE_STORAGE_ROOT") {
PathBuf::from(custom_root)
} else {
dirs::home_dir()
.context("Failed to get user home directory")?
.join(".worktrees")
};
std::fs::create_dir_all(&root_dir).context("Failed to create worktrees directory")?;
Ok(Self { root_dir })
}
pub fn get_repo_name(repo_path: &Path) -> Result<String> {
if let Some(name) = repo_path.file_name() {
Ok(name.to_string_lossy().to_string())
} else {
anyhow::bail!("Could not determine repository name from path")
}
}
pub fn validate_feature_name(name: &str) -> Result<()> {
if name.trim().is_empty() {
anyhow::bail!("Feature name cannot be empty");
}
let invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
for ch in invalid_chars {
if name.contains(ch) {
anyhow::bail!(
"Feature name '{}' contains invalid character '{}'. \
Feature names cannot contain: / \\ : * ? \" < > |",
name,
ch
);
}
}
Ok(())
}
#[must_use]
pub fn get_worktree_path(&self, repo_name: &str, feature_name: &str) -> PathBuf {
self.root_dir.join(repo_name).join(feature_name)
}
pub fn list_repo_worktrees(&self, repo_name: &str) -> Result<Vec<String>> {
let repo_dir = self.root_dir.join(repo_name);
if !repo_dir.exists() {
return Ok(vec![]);
}
let mut worktrees = Vec::new();
for entry in std::fs::read_dir(&repo_dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
if let Some(name) = entry.file_name().to_str() {
if !name.starts_with('.') {
worktrees.push(name.to_string());
}
}
}
}
Ok(worktrees)
}
pub fn list_all_worktrees(&self) -> Result<Vec<(String, Vec<String>)>> {
let mut all_worktrees = Vec::new();
if !self.root_dir.exists() {
return Ok(all_worktrees);
}
for entry in std::fs::read_dir(&self.root_dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
if let Some(repo_name) = entry.file_name().to_str() {
let worktrees = self.list_repo_worktrees(repo_name)?;
all_worktrees.push((repo_name.to_string(), worktrees));
}
}
}
Ok(all_worktrees)
}
#[must_use]
pub fn get_repo_storage_dir(&self, repo_name: &str) -> PathBuf {
self.root_dir.join(repo_name)
}
#[must_use]
pub fn get_root_dir(&self) -> &PathBuf {
&self.root_dir
}
pub fn store_worktree_origin(
&self,
repo_name: &str,
feature_name: &str,
origin_path: &str,
) -> Result<()> {
let repo_dir = self.root_dir.join(repo_name);
std::fs::create_dir_all(&repo_dir)?;
let origin_mapping_file = repo_dir.join(".worktree-origins");
let mapping_entry = format!("{} -> {}\n", feature_name, origin_path);
let mut existing_content = if origin_mapping_file.exists() {
std::fs::read_to_string(&origin_mapping_file)?
} else {
String::new()
};
let search_line = format!("{} -> {}", feature_name, origin_path);
if !existing_content.lines().any(|line| line == search_line) {
existing_content.push_str(&mapping_entry);
let tmp_path = origin_mapping_file.with_extension("tmp");
std::fs::write(&tmp_path, &existing_content)?;
std::fs::rename(&tmp_path, &origin_mapping_file)?;
}
Ok(())
}
pub fn get_worktree_origin(
&self,
repo_name: &str,
feature_name: &str,
) -> Result<Option<String>> {
let origin_mapping_file = self.root_dir.join(repo_name).join(".worktree-origins");
if !origin_mapping_file.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&origin_mapping_file)?;
for line in content.lines() {
if let Some((key, origin)) = line.split_once(" -> ") {
if key == feature_name {
return Ok(Some(origin.to_string()));
}
}
}
Ok(None)
}
pub fn remove_worktree_origin(&self, repo_name: &str, feature_name: &str) -> Result<()> {
let origin_mapping_file = self.root_dir.join(repo_name).join(".worktree-origins");
if !origin_mapping_file.exists() {
return Ok(()); }
let content = std::fs::read_to_string(&origin_mapping_file)?;
let new_content: String = content
.lines()
.filter(|line| {
if let Some((key, _)) = line.split_once(" -> ") {
key != feature_name
} else {
true }
})
.collect::<Vec<_>>()
.join("\n");
let final_content = if new_content.is_empty() {
String::new()
} else {
format!("{}\n", new_content)
};
let tmp_path = origin_mapping_file.with_extension("tmp");
std::fs::write(&tmp_path, &final_content)?;
std::fs::rename(&tmp_path, &origin_mapping_file)?;
Ok(())
}
}
#[must_use]
pub fn read_worktree_head_branch(path: &Path) -> Option<String> {
let repo = git2::Repository::open(path).ok()?;
let head = repo.head().ok()?;
if head.is_branch() {
head.shorthand().map(ToString::to_string)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_storage(tmp: &TempDir) -> Result<WorktreeStorage> {
let root = tmp.path().join("worktrees");
std::fs::create_dir_all(&root)?;
Ok(WorktreeStorage { root_dir: root })
}
#[test]
fn test_validate_feature_name_valid() {
assert!(WorktreeStorage::validate_feature_name("auth").is_ok());
assert!(WorktreeStorage::validate_feature_name("my-feature").is_ok());
assert!(WorktreeStorage::validate_feature_name("feature_123").is_ok());
assert!(WorktreeStorage::validate_feature_name("auth-v2.0").is_ok());
}
#[test]
fn test_validate_feature_name_slash_rejected() {
let result = WorktreeStorage::validate_feature_name("feature/auth");
assert!(result.is_err());
if let Err(e) = result {
let msg = e.to_string();
assert!(msg.contains('/') || msg.contains("invalid character"));
}
}
#[test]
fn test_validate_feature_name_special_chars_rejected() {
for ch in &['\\', ':', '*', '?', '"', '<', '>', '|'] {
let name = format!("feature{}auth", ch);
assert!(
WorktreeStorage::validate_feature_name(&name).is_err(),
"Should reject name containing '{}'",
ch
);
}
}
#[test]
fn test_validate_feature_name_empty_rejected() {
assert!(WorktreeStorage::validate_feature_name("").is_err());
assert!(WorktreeStorage::validate_feature_name(" ").is_err());
}
#[test]
fn test_get_worktree_path_uses_feature_name_directly() -> Result<()> {
let tmp = TempDir::new()?;
let storage = make_storage(&tmp)?;
let path = storage.get_worktree_path("myrepo", "auth");
assert!(path.to_string_lossy().ends_with("myrepo/auth"));
Ok(())
}
#[test]
fn test_store_worktree_origin_no_duplicate() -> Result<()> {
let tmp = TempDir::new()?;
let storage = make_storage(&tmp)?;
storage.store_worktree_origin("myrepo", "auth", "/home/user/repo")?;
storage.store_worktree_origin("myrepo", "auth", "/home/user/repo")?;
let origin_file = storage.root_dir.join("myrepo").join(".worktree-origins");
let content = std::fs::read_to_string(&origin_file)?;
let count = content
.lines()
.filter(|l| *l == "auth -> /home/user/repo")
.count();
assert_eq!(count, 1);
Ok(())
}
#[test]
fn test_store_worktree_origin_roundtrip() -> Result<()> {
let tmp = TempDir::new()?;
let storage = make_storage(&tmp)?;
storage.store_worktree_origin("myrepo", "auth", "/home/user/repo")?;
let result = storage.get_worktree_origin("myrepo", "auth")?;
assert_eq!(result, Some("/home/user/repo".to_string()));
Ok(())
}
#[test]
fn test_store_worktree_origin_different_features_independent() -> Result<()> {
let tmp = TempDir::new()?;
let storage = make_storage(&tmp)?;
storage.store_worktree_origin("myrepo", "auth", "/repo1")?;
storage.store_worktree_origin("myrepo", "payments", "/repo2")?;
assert_eq!(
storage.get_worktree_origin("myrepo", "auth")?,
Some("/repo1".to_string())
);
assert_eq!(
storage.get_worktree_origin("myrepo", "payments")?,
Some("/repo2".to_string())
);
Ok(())
}
#[test]
fn test_list_repo_worktrees_skips_hidden_dirs() -> Result<()> {
let tmp = TempDir::new()?;
let storage = make_storage(&tmp)?;
let repo_dir = storage.root_dir.join("myrepo");
std::fs::create_dir_all(repo_dir.join("auth"))?;
std::fs::create_dir_all(repo_dir.join("payments"))?;
std::fs::create_dir_all(repo_dir.join(".hidden"))?;
std::fs::write(repo_dir.join(".worktree-origins"), "")?;
let worktrees = storage.list_repo_worktrees("myrepo")?;
assert!(worktrees.contains(&"auth".to_string()));
assert!(worktrees.contains(&"payments".to_string()));
assert!(!worktrees.contains(&".hidden".to_string()));
assert_eq!(worktrees.len(), 2);
Ok(())
}
}