use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::{PullStrategy, WorktreeConfig};
use crate::error::WorktreeError;
use super::{GitWorktreeStatus, RemoteUrlParts, WorktreeInfo, WorktreeManager, parse_remote_url};
fn expand_tilde(path: &str) -> PathBuf {
if let Some(stripped) = path.strip_prefix("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(stripped);
}
PathBuf::from(path)
}
#[derive(Debug)]
pub struct NativeWorktreeManager {
repo_root: PathBuf,
repo_name: String,
base_path: PathBuf,
config: WorktreeConfig,
remote_parts: Option<RemoteUrlParts>,
}
impl NativeWorktreeManager {
pub fn new(repo_root: PathBuf, config: WorktreeConfig) -> Result<Self, WorktreeError> {
let output = Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(&repo_root)
.output()?;
if !output.status.success() {
return Err(WorktreeError::NotGitRepo(repo_root));
}
let remote_parts = Self::fetch_remote_url(&repo_root).ok().flatten();
let repo_name = remote_parts.as_ref().map_or_else(
|| {
repo_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
},
|p| p.repo.clone(),
);
let base_path = expand_tilde(&config.base_path);
Ok(Self {
repo_root,
repo_name,
base_path,
config,
remote_parts,
})
}
fn fetch_remote_url(repo_root: &Path) -> Result<Option<RemoteUrlParts>, std::io::Error> {
let output = Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(repo_root)
.output()?;
if !output.status.success() {
return Ok(None);
}
let url = String::from_utf8_lossy(&output.stdout);
Ok(parse_remote_url(url.trim()))
}
pub fn discover(config: WorktreeConfig) -> Result<Self, WorktreeError> {
let output = Command::new("git")
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
.output()?;
if !output.status.success() {
let cwd = std::env::current_dir()?;
return Err(WorktreeError::NotGitRepo(cwd));
}
let git_common_dir = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
let main_repo_root = git_common_dir
.parent()
.ok_or_else(|| WorktreeError::InvalidPath(git_common_dir.clone()))?
.to_path_buf();
Self::new(main_repo_root, config)
}
#[must_use]
pub fn repo_name(&self) -> &str {
&self.repo_name
}
#[must_use]
pub fn base_path(&self) -> &PathBuf {
&self.base_path
}
}
fn parse_git_status_dirty(output: &str) -> bool {
!output.trim().is_empty()
}
fn parse_git_detached(output: &str) -> bool {
output.trim() == "HEAD"
}
fn parse_rev_list_count(output: &str) -> u32 {
output.trim().parse().unwrap_or(0)
}
impl WorktreeManager for NativeWorktreeManager {
fn create_worktree(
&self,
branch_name: Option<&str>,
) -> Result<(PathBuf, String), WorktreeError> {
let dir_id = uuid::Uuid::new_v4().to_string();
let branch = branch_name.map_or_else(
|| format!("{}{}", self.config.branch_prefix, dir_id),
String::from,
);
let worktree_path = if let Some(ref parts) = self.remote_parts {
self.base_path
.join(&parts.domain)
.join(&parts.user)
.join(&parts.repo)
.join(&branch)
} else {
self.base_path.join(&self.repo_name).join(&branch)
};
let parent = worktree_path
.parent()
.ok_or_else(|| WorktreeError::InvalidPath(worktree_path.clone()))?;
fs::create_dir_all(parent)?;
if worktree_path.exists() {
return Err(WorktreeError::WorktreeExists {
path: worktree_path,
});
}
let output = Command::new("git")
.current_dir(&self.repo_root)
.args(["worktree", "add", "-b", &branch])
.arg(&worktree_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("already exists") {
return Err(WorktreeError::BranchExists(branch));
}
return Err(WorktreeError::GitFailed(stderr.trim().to_string()));
}
Ok((worktree_path, branch))
}
fn remove_worktree(&self, path: &Path) -> Result<(), WorktreeError> {
if !path.exists() {
return Err(WorktreeError::WorktreeNotFound {
path: path.to_path_buf(),
});
}
let output = Command::new("git")
.current_dir(&self.repo_root)
.args(["worktree", "remove", "--force"])
.arg(path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(WorktreeError::GitFailed(stderr.trim().to_string()));
}
Ok(())
}
fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>, WorktreeError> {
let output = Command::new("git")
.current_dir(&self.repo_root)
.args(["worktree", "list", "--porcelain"])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(WorktreeError::GitFailed(stderr.trim().to_string()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut worktrees = Vec::new();
let mut current_path: Option<PathBuf> = None;
let mut current_branch: Option<String> = None;
let push_worktree =
|worktrees: &mut Vec<WorktreeInfo>, path: PathBuf, branch: Option<String>| {
let detached = branch.is_none();
worktrees.push(WorktreeInfo {
path,
branch: branch.unwrap_or_else(|| "HEAD".to_string()),
status: GitWorktreeStatus {
detached,
..Default::default()
},
});
};
for line in stdout.lines() {
if let Some(path_str) = line.strip_prefix("worktree ") {
if let Some(path) = current_path.take() {
push_worktree(&mut worktrees, path, current_branch.take());
}
current_path = Some(PathBuf::from(path_str));
current_branch = None;
} else if let Some(branch_ref) = line.strip_prefix("branch ") {
current_branch = Some(
branch_ref
.strip_prefix("refs/heads/")
.unwrap_or(branch_ref)
.to_string(),
);
}
}
if let Some(path) = current_path {
push_worktree(&mut worktrees, path, current_branch);
}
Ok(worktrees)
}
fn is_git_repo(&self) -> bool {
Command::new("git")
.current_dir(&self.repo_root)
.args(["rev-parse", "--git-dir"])
.output()
.is_ok_and(|output| output.status.success())
}
fn git_pull(&self, path: &Path, strategy: PullStrategy) -> Result<(), WorktreeError> {
if !path.exists() {
return Err(WorktreeError::WorktreeNotFound {
path: path.to_path_buf(),
});
}
let mut args = vec!["pull"];
if matches!(strategy, PullStrategy::Rebase) {
args.push("--rebase");
}
let output = Command::new("git").current_dir(path).args(&args).output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(WorktreeError::GitFailed(stderr.trim().to_string()));
}
Ok(())
}
fn get_status(&self, path: &Path) -> Result<GitWorktreeStatus, WorktreeError> {
if !path.exists() {
return Err(WorktreeError::WorktreeNotFound {
path: path.to_path_buf(),
});
}
let status_output = Command::new("git")
.current_dir(path)
.args(["status", "--porcelain"])
.output()?;
let dirty = if status_output.status.success() {
parse_git_status_dirty(&String::from_utf8_lossy(&status_output.stdout))
} else {
false
};
let head_output = Command::new("git")
.current_dir(path)
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()?;
let detached = if head_output.status.success() {
parse_git_detached(&String::from_utf8_lossy(&head_output.stdout))
} else {
false
};
let behind_output = Command::new("git")
.current_dir(path)
.args(["rev-list", "--count", "HEAD..@{u}"])
.output()?;
let ahead_output = Command::new("git")
.current_dir(path)
.args(["rev-list", "--count", "@{u}..HEAD"])
.output()?;
let (ahead, behind, no_upstream) =
if behind_output.status.success() && ahead_output.status.success() {
(
parse_rev_list_count(&String::from_utf8_lossy(&ahead_output.stdout)),
parse_rev_list_count(&String::from_utf8_lossy(&behind_output.stdout)),
false,
)
} else {
(0, 0, true)
};
Ok(GitWorktreeStatus {
dirty,
ahead,
behind,
detached,
no_upstream,
})
}
fn git_fetch(&self, path: &Path) -> Result<(), WorktreeError> {
if !path.exists() {
return Err(WorktreeError::WorktreeNotFound {
path: path.to_path_buf(),
});
}
let output = Command::new("git")
.current_dir(path)
.args(["fetch", "--quiet"])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(WorktreeError::GitFailed(stderr.trim().to_string()));
}
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rstest::rstest;
use serial_test::serial;
use std::process::Command;
use tempfile::TempDir;
fn setup_git_repo() -> TempDir {
let temp_dir = TempDir::new().expect("failed to create temp dir");
Command::new("git")
.args(["init"])
.current_dir(temp_dir.path())
.output()
.expect("failed to git init");
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(temp_dir.path())
.output()
.expect("failed to set git email");
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.expect("failed to set git name");
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(temp_dir.path())
.output()
.expect("failed to create initial commit");
temp_dir
}
#[test]
fn new_not_git_repo() {
let temp_dir = TempDir::new().unwrap();
let config = WorktreeConfig::default();
let result = NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config);
assert!(matches!(result, Err(WorktreeError::NotGitRepo(_))));
}
#[rstest]
#[case("/tmp/tazuna-test-worktrees", "/tmp/tazuna-test-worktrees")]
#[case("relative/path", "relative/path")]
fn expand_tilde_path_passthrough(#[case] input: &str, #[case] expected: &str) {
let temp_dir = setup_git_repo();
let config = WorktreeConfig {
base_path: input.to_string(),
..Default::default()
};
let manager = NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config).unwrap();
assert_eq!(manager.base_path(), &PathBuf::from(expected));
}
#[test]
#[serial]
fn discover_not_git_repo() {
let temp_dir = TempDir::new().unwrap();
let config = WorktreeConfig::default();
let original_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(temp_dir.path()).unwrap();
let result = NativeWorktreeManager::discover(config);
std::env::set_current_dir(&original_cwd).unwrap();
assert!(matches!(result, Err(WorktreeError::NotGitRepo(_))));
}
#[test]
fn new_valid_repo() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig::default();
let manager = NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config);
assert!(manager.is_ok());
}
#[test]
fn create_worktree_success() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig::default();
let manager = NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config).unwrap();
let (path, branch) = manager.create_worktree(None).unwrap();
assert!(path.exists());
assert!(path.join(".git").exists()); assert!(path.starts_with(manager.base_path().join(manager.repo_name())));
assert!(branch.starts_with("tazuna/"));
}
#[test]
fn create_worktree_custom_branch() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig::default();
let manager = NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config).unwrap();
let (_, branch) = manager.create_worktree(Some("feature/custom")).unwrap();
assert_eq!(branch, "feature/custom");
let worktrees = manager.list_worktrees().unwrap();
assert_eq!(worktrees.len(), 2);
let wt = worktrees.iter().find(|w| w.branch == "feature/custom");
assert!(
wt.is_some(),
"Expected worktree with branch 'feature/custom'"
);
}
#[test]
fn create_worktree_branch_exists() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig::default();
let manager = NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config).unwrap();
let _ = manager.create_worktree(Some("feature/test")).unwrap();
let result = manager.create_worktree(Some("feature/test"));
assert!(matches!(
result,
Err(WorktreeError::BranchExists(_) | WorktreeError::WorktreeExists { .. })
));
}
#[test]
fn remove_worktree_success() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig::default();
let manager = NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config).unwrap();
let (path, _) = manager.create_worktree(None).unwrap();
assert!(path.exists());
manager.remove_worktree(&path).unwrap();
assert!(!path.exists());
}
#[test]
fn operations_on_nonexistent_path_return_not_found() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig::default();
let manager = NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config).unwrap();
let bad_path = Path::new("/nonexistent/path");
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 { .. })
));
}
#[test]
fn list_worktrees_includes_main() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig::default();
let manager = NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config).unwrap();
let worktrees = manager.list_worktrees().unwrap();
assert_eq!(worktrees.len(), 1);
assert_eq!(worktrees[0].path, temp_dir.path());
}
#[test]
fn list_worktrees_multiple() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig::default();
let manager = NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config).unwrap();
let _ = manager.create_worktree(None).unwrap();
let _ = manager.create_worktree(None).unwrap();
let worktrees = manager.list_worktrees().unwrap();
assert_eq!(worktrees.len(), 3);
}
#[test]
fn is_git_repo_true() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig::default();
let manager = NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config).unwrap();
assert!(manager.is_git_repo());
}
#[test]
fn branch_naming_with_config_prefix() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig {
auto_cleanup: false,
branch_prefix: "wt/".to_string(),
..Default::default()
};
let manager = NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config).unwrap();
let (_, branch) = manager.create_worktree(None).unwrap();
assert!(branch.starts_with("wt/"));
let worktrees = manager.list_worktrees().unwrap();
let wt = worktrees.iter().find(|w| w.branch.starts_with("wt/"));
assert!(wt.is_some(), "Expected worktree with branch prefix 'wt/'");
}
#[test]
fn git_pull_no_remote() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig::default();
let manager = NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config).unwrap();
let (path, _) = manager.create_worktree(None).unwrap();
let result = manager.git_pull(&path, PullStrategy::Merge);
assert!(matches!(result, Err(WorktreeError::GitFailed(_))));
let result = manager.git_pull(&path, PullStrategy::Rebase);
assert!(matches!(result, Err(WorktreeError::GitFailed(_))));
}
#[test]
#[serial]
fn discover_from_worktree_finds_main_repo() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig::default();
let main_manager =
NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config.clone()).unwrap();
let (worktree_path, _) = main_manager.create_worktree(None).unwrap();
let original_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&worktree_path).unwrap();
let discovered = NativeWorktreeManager::discover(config).unwrap();
std::env::set_current_dir(&original_cwd).unwrap();
let expected_name = temp_dir.path().file_name().unwrap().to_str().unwrap();
assert_eq!(discovered.repo_name(), expected_name);
}
#[test]
#[serial]
fn discover_from_main_repo_works() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig::default();
let original_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(temp_dir.path()).unwrap();
let manager = NativeWorktreeManager::discover(config).unwrap();
std::env::set_current_dir(&original_cwd).unwrap();
let expected_name = temp_dir.path().file_name().unwrap().to_str().unwrap();
assert_eq!(manager.repo_name(), expected_name);
}
#[test]
#[serial]
fn list_worktrees_from_worktree_sees_all() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig::default();
let main_manager =
NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config.clone()).unwrap();
let (wt1_path, _) = main_manager.create_worktree(None).unwrap();
let (wt2_path, _) = main_manager.create_worktree(None).unwrap();
let original_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&wt2_path).unwrap();
let discovered = NativeWorktreeManager::discover(config).unwrap();
std::env::set_current_dir(&original_cwd).unwrap();
let worktrees = discovered.list_worktrees().unwrap();
assert_eq!(worktrees.len(), 3);
let paths: Vec<_> = worktrees.iter().map(|w| &w.path).collect();
assert!(paths.contains(&&wt1_path));
assert!(paths.contains(&&wt2_path));
}
#[rstest]
#[case("", false)]
#[case(" ", false)]
#[case("\n", false)]
#[case(" M file.rs", true)]
#[case("M file.rs\n", true)]
#[case("?? newfile.txt", true)]
#[case("A staged.rs\n M modified.rs", true)]
fn parse_git_status_dirty_cases(#[case] input: &str, #[case] expected: bool) {
assert_eq!(parse_git_status_dirty(input), expected);
}
#[rstest]
#[case("HEAD", true)]
#[case("HEAD\n", true)]
#[case(" HEAD ", true)]
#[case("main", false)]
#[case("feature/branch", false)]
#[case("tazuna/session-123", false)]
fn parse_git_detached_cases(#[case] input: &str, #[case] expected: bool) {
assert_eq!(parse_git_detached(input), expected);
}
#[rstest]
#[case("0", 0)]
#[case("5", 5)]
#[case("123\n", 123)]
#[case(" 42 ", 42)]
#[case("", 0)]
#[case("not a number", 0)]
#[case("-1", 0)]
fn parse_rev_list_count_cases(#[case] input: &str, #[case] expected: u32) {
assert_eq!(parse_rev_list_count(input), expected);
}
#[test]
fn get_status_clean_worktree() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig::default();
let manager = NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config).unwrap();
let (path, _) = manager.create_worktree(None).unwrap();
let status = manager.get_status(&path).unwrap();
assert!(!status.dirty);
assert!(!status.detached);
assert!(status.no_upstream);
assert_eq!(status.ahead, 0);
assert_eq!(status.behind, 0);
}
#[test]
fn get_status_dirty_worktree() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig::default();
let manager = NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config).unwrap();
let (path, _) = manager.create_worktree(None).unwrap();
std::fs::write(path.join("dirty.txt"), "content").unwrap();
let status = manager.get_status(&path).unwrap();
assert!(status.dirty);
}
#[test]
fn get_status_main_repo() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig::default();
let manager = NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config).unwrap();
let status = manager.get_status(temp_dir.path()).unwrap();
assert!(!status.dirty);
assert!(!status.detached);
}
#[test]
fn git_fetch_no_remote() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig::default();
let manager = NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config).unwrap();
let (path, _) = manager.create_worktree(None).unwrap();
let result = manager.git_fetch(&path);
assert!(result.is_ok());
}
#[test]
fn list_worktrees_stable() {
let temp_dir = setup_git_repo();
let config = WorktreeConfig::default();
let manager = NativeWorktreeManager::new(temp_dir.path().to_path_buf(), config).unwrap();
let (created_path, created_branch) = manager.create_worktree(None).unwrap();
let list1 = manager.list_worktrees().unwrap();
let list2 = manager.list_worktrees().unwrap();
let wt1 = list1.iter().find(|w| w.path == created_path).unwrap();
let wt2 = list2.iter().find(|w| w.path == created_path).unwrap();
assert_eq!(wt1.path, wt2.path);
assert_eq!(wt1.branch, wt2.branch);
assert_eq!(wt1.branch, created_branch);
}
}