tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! Git worktree integration for session isolation.
//!
//! Provides worktree management for isolating Claude Code sessions.

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;

/// Git repository status for worktree
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GitWorktreeStatus {
    /// Modified/staged files exist
    pub dirty: bool,
    /// Commits ahead of upstream
    pub ahead: u32,
    /// Commits behind upstream
    pub behind: u32,
    /// HEAD detached (no tracking branch)
    pub detached: bool,
    /// No upstream configured
    pub no_upstream: bool,
}

/// Information about a managed worktree
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeInfo {
    /// Path to the worktree directory
    pub path: PathBuf,
    /// Branch name for this worktree
    pub branch: String,
    /// Git status (dirty, ahead/behind, etc.)
    pub status: GitWorktreeStatus,
}

/// Abstraction over git worktree operations
pub trait WorktreeManager: Send + Sync {
    /// Create a new worktree with auto-generated directory name
    ///
    /// # Arguments
    /// * `branch_name` - Optional branch name override (auto-generated if None)
    ///
    /// # Returns
    /// Tuple of (worktree path, branch name)
    fn create_worktree(
        &self,
        branch_name: Option<&str>,
    ) -> Result<(PathBuf, String), WorktreeError>;

    /// List all managed worktrees
    fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>, WorktreeError>;

    /// Check if the manager operates on a valid git repository
    fn is_git_repo(&self) -> bool;

    /// Remove a worktree by its path
    ///
    /// # Arguments
    /// * `path` - Path to the worktree directory
    fn remove_worktree(&self, path: &Path) -> Result<(), WorktreeError>;

    /// Pull latest changes from remote by path
    ///
    /// # Arguments
    /// * `path` - Path to the worktree directory
    /// * `strategy` - Merge or rebase strategy
    fn git_pull(&self, path: &Path, strategy: PullStrategy) -> Result<(), WorktreeError>;

    /// Get git status for a worktree by path
    ///
    /// # Arguments
    /// * `path` - Path to the worktree directory
    fn get_status(&self, path: &Path) -> Result<GitWorktreeStatus, WorktreeError>;

    /// Fetch from remote for a worktree by path
    ///
    /// # Arguments
    /// * `path` - Path to the worktree directory
    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;

    /// Mock worktree manager for testing
    #[derive(Debug)]
    pub struct MockWorktreeManager {
        worktrees: Mutex<HashMap<PathBuf, WorktreeInfo>>,
        is_repo: bool,
        repo_name: String,
        base_path: PathBuf,
        branch_prefix: String,
    }

    impl MockWorktreeManager {
        /// Create a new mock manager
        #[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(),
            }
        }

        /// Set branch prefix for testing
        #[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()))?;

            // Generate branch name first (with UUID for auto-generated branches)
            let dir_id = uuid::Uuid::new_v4().to_string();
            let branch = branch_name
                .map_or_else(|| format!("{}{}", self.branch_prefix, dir_id), String::from);

            // Use branch-based path structure: base_path/repo_name/branch
            let path = self.base_path.join(&self.repo_name).join(&branch);

            // Check for path collision (same branch = same path)
            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(),
                });
            }

            // Mock: always succeed for existing worktrees
            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) {
                // Mock: always succeed for existing worktrees
                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();
        // Path should be under base_path/repo_name/branch
        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();

        // All operations succeed on existing worktree
        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 { .. })
        ));
    }
}