mod native;
mod remote_url;
pub use remote_url::{RemoteUrlParts, parse_remote_url};
pub use native::NativeWorktreeManager;
use std::path::{Path, PathBuf};
use crate::config::PullStrategy;
use crate::error::WorktreeError;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GitWorktreeStatus {
pub dirty: bool,
pub ahead: u32,
pub behind: u32,
pub detached: bool,
pub no_upstream: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeInfo {
pub path: PathBuf,
pub branch: String,
pub status: GitWorktreeStatus,
}
pub trait WorktreeManager: Send + Sync {
fn create_worktree(
&self,
branch_name: Option<&str>,
) -> Result<(PathBuf, String), WorktreeError>;
fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>, WorktreeError>;
fn is_git_repo(&self) -> bool;
fn remove_worktree(&self, path: &Path) -> Result<(), WorktreeError>;
fn git_pull(&self, path: &Path, strategy: PullStrategy) -> Result<(), WorktreeError>;
fn get_status(&self, path: &Path) -> Result<GitWorktreeStatus, WorktreeError>;
fn git_fetch(&self, path: &Path) -> Result<(), WorktreeError>;
}
#[cfg(test)]
pub mod mock {
use super::*;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
#[derive(Debug)]
pub struct MockWorktreeManager {
worktrees: Mutex<HashMap<PathBuf, WorktreeInfo>>,
is_repo: bool,
repo_name: String,
base_path: PathBuf,
branch_prefix: String,
}
impl MockWorktreeManager {
#[must_use]
pub fn new(repo_root: impl AsRef<Path>, is_repo: bool) -> Self {
let repo_name = repo_root
.as_ref()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
Self {
worktrees: Mutex::new(HashMap::new()),
is_repo,
repo_name,
base_path: PathBuf::from("/tmp/tazuna/worktrees"),
branch_prefix: "tazuna/".to_string(),
}
}
#[must_use]
pub fn with_branch_prefix(mut self, prefix: &str) -> Self {
self.branch_prefix = prefix.to_string();
self
}
}
impl WorktreeManager for MockWorktreeManager {
fn create_worktree(
&self,
branch_name: Option<&str>,
) -> Result<(PathBuf, String), WorktreeError> {
if !self.is_repo {
return Err(WorktreeError::NotGitRepo(PathBuf::from("/mock/repo")));
}
let mut worktrees = self
.worktrees
.lock()
.map_err(|_| WorktreeError::GitFailed("lock poisoned".into()))?;
let dir_id = uuid::Uuid::new_v4().to_string();
let branch = branch_name
.map_or_else(|| format!("{}{}", self.branch_prefix, dir_id), String::from);
let path = self.base_path.join(&self.repo_name).join(&branch);
if worktrees.contains_key(&path) {
return Err(WorktreeError::WorktreeExists { path });
}
worktrees.insert(
path.clone(),
WorktreeInfo {
path: path.clone(),
branch: branch.clone(),
status: GitWorktreeStatus::default(),
},
);
Ok((path, branch))
}
fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>, WorktreeError> {
let worktrees = self
.worktrees
.lock()
.map_err(|_| WorktreeError::GitFailed("lock poisoned".into()))?;
Ok(worktrees.values().cloned().collect())
}
fn is_git_repo(&self) -> bool {
self.is_repo
}
fn remove_worktree(&self, path: &Path) -> Result<(), WorktreeError> {
let mut worktrees = self
.worktrees
.lock()
.map_err(|_| WorktreeError::GitFailed("lock poisoned".into()))?;
worktrees
.remove(path)
.ok_or_else(|| WorktreeError::WorktreeNotFound {
path: path.to_path_buf(),
})?;
Ok(())
}
fn git_pull(&self, path: &Path, _strategy: PullStrategy) -> Result<(), WorktreeError> {
let worktrees = self
.worktrees
.lock()
.map_err(|_| WorktreeError::GitFailed("lock poisoned".into()))?;
if !worktrees.contains_key(path) {
return Err(WorktreeError::WorktreeNotFound {
path: path.to_path_buf(),
});
}
Ok(())
}
fn get_status(&self, path: &Path) -> Result<GitWorktreeStatus, WorktreeError> {
let worktrees = self
.worktrees
.lock()
.map_err(|_| WorktreeError::GitFailed("lock poisoned".into()))?;
worktrees
.get(path)
.map(|info| info.status.clone())
.ok_or_else(|| WorktreeError::WorktreeNotFound {
path: path.to_path_buf(),
})
}
fn git_fetch(&self, path: &Path) -> Result<(), WorktreeError> {
let worktrees = self
.worktrees
.lock()
.map_err(|_| WorktreeError::GitFailed("lock poisoned".into()))?;
if worktrees.contains_key(path) {
Ok(())
} else {
Err(WorktreeError::WorktreeNotFound {
path: path.to_path_buf(),
})
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::mock::MockWorktreeManager;
use super::*;
use rstest::rstest;
#[test]
fn git_worktree_status_default_and_eq() {
let status = GitWorktreeStatus::default();
assert!(!status.dirty && status.ahead == 0 && status.behind == 0);
assert!(!status.detached && !status.no_upstream);
let custom = GitWorktreeStatus {
dirty: true,
ahead: 3,
behind: 2,
..Default::default()
};
assert_eq!(custom, custom.clone());
}
#[test]
fn worktree_info_fields() {
let info = WorktreeInfo {
path: PathBuf::from("/repo/.tazuna/worktrees/test-123"),
branch: "tazuna/test-123".to_string(),
status: GitWorktreeStatus::default(),
};
assert_eq!(info.path, PathBuf::from("/repo/.tazuna/worktrees/test-123"));
assert_eq!(info.branch, "tazuna/test-123");
}
#[test]
fn mock_create_worktree_success() {
let manager = MockWorktreeManager::new(PathBuf::from("/repo"), true);
let (path, branch) = manager.create_worktree(None).unwrap();
assert!(path.starts_with("/tmp/tazuna/worktrees/repo/"));
assert!(branch.starts_with("tazuna/"));
}
#[test]
fn mock_create_worktree_with_custom_branch() {
let manager = MockWorktreeManager::new(PathBuf::from("/repo"), true);
let (path, branch) = manager.create_worktree(Some("feature/custom")).unwrap();
assert!(path.starts_with("/tmp/tazuna/worktrees/repo/"));
assert_eq!(branch, "feature/custom");
let worktrees = manager.list_worktrees().unwrap();
assert_eq!(worktrees.len(), 1);
assert_eq!(worktrees[0].branch, "feature/custom");
}
#[test]
fn mock_create_worktree_not_git_repo() {
let manager = MockWorktreeManager::new(PathBuf::from("/not-repo"), false);
let result = manager.create_worktree(None);
assert!(matches!(result, Err(WorktreeError::NotGitRepo(_))));
}
#[test]
fn mock_remove_worktree_success() {
let manager = MockWorktreeManager::new(PathBuf::from("/repo"), true);
let (path, _) = manager.create_worktree(None).unwrap();
assert!(manager.remove_worktree(&path).is_ok());
assert!(manager.list_worktrees().unwrap().is_empty());
}
#[rstest]
#[case(0, 0)]
#[case(3, 3)]
fn mock_list_worktrees_count(#[case] create_count: usize, #[case] expected: usize) {
let manager = MockWorktreeManager::new(PathBuf::from("/repo"), true);
for _ in 0..create_count {
manager.create_worktree(None).unwrap();
}
assert_eq!(manager.list_worktrees().unwrap().len(), expected);
}
#[test]
fn mock_is_git_repo() {
let manager_repo = MockWorktreeManager::new(PathBuf::from("/repo"), true);
let manager_not_repo = MockWorktreeManager::new(PathBuf::from("/not-repo"), false);
assert!(manager_repo.is_git_repo());
assert!(!manager_not_repo.is_git_repo());
}
#[test]
fn mock_with_branch_prefix() {
let manager =
MockWorktreeManager::new(PathBuf::from("/repo"), true).with_branch_prefix("feature/");
let (_, branch) = manager.create_worktree(None).unwrap();
assert!(branch.starts_with("feature/"));
let worktrees = manager.list_worktrees().unwrap();
assert!(worktrees[0].branch.starts_with("feature/"));
}
#[test]
fn mock_operations_on_valid_worktree() {
use crate::config::PullStrategy;
let manager = MockWorktreeManager::new(PathBuf::from("/repo"), true);
let (path, _) = manager.create_worktree(None).unwrap();
assert!(manager.git_pull(&path, PullStrategy::Merge).is_ok());
assert!(manager.git_pull(&path, PullStrategy::Rebase).is_ok());
assert!(manager.git_fetch(&path).is_ok());
let status = manager.get_status(&path).unwrap();
assert!(!status.dirty && status.ahead == 0 && status.behind == 0);
}
#[test]
fn mock_operations_on_nonexistent_path() {
use crate::config::PullStrategy;
let manager = MockWorktreeManager::new(PathBuf::from("/repo"), true);
let bad_path = Path::new("/nonexistent");
assert!(matches!(
manager.remove_worktree(bad_path),
Err(WorktreeError::WorktreeNotFound { .. })
));
assert!(matches!(
manager.git_pull(bad_path, PullStrategy::Merge),
Err(WorktreeError::WorktreeNotFound { .. })
));
assert!(matches!(
manager.get_status(bad_path),
Err(WorktreeError::WorktreeNotFound { .. })
));
assert!(matches!(
manager.git_fetch(bad_path),
Err(WorktreeError::WorktreeNotFound { .. })
));
}
}