use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct WorktreeInfo {
pub path: PathBuf,
pub branch: String,
pub is_main: bool,
}
pub struct WorktreeManager {
repo_path: PathBuf,
}
impl WorktreeManager {
pub fn new(repo_path: impl Into<PathBuf>) -> Result<Self> {
let repo_path = repo_path.into();
let output = Command::new("git")
.current_dir(&repo_path)
.args(["rev-parse", "--git-dir"])
.output()
.context("Failed to execute git command")?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Not a git repository: {}",
repo_path.display()
));
}
Ok(Self { repo_path })
}
pub fn from_current_dir() -> Result<Self> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.context("Failed to execute git command")?;
if !output.status.success() {
return Err(anyhow::anyhow!("Not in a git repository"));
}
let repo_path = String::from_utf8_lossy(&output.stdout)
.trim()
.to_string();
Self::new(PathBuf::from(repo_path))
}
pub fn create(&self, branch: &str, base_dir: &Path) -> Result<PathBuf> {
let safe_branch = sanitize_branch_name(branch);
let unique_branch = self.generate_unique_branch(&safe_branch)?;
let base_path = if base_dir.is_absolute() {
base_dir.to_path_buf()
} else {
self.repo_path.join(base_dir)
};
if !base_path.exists() {
std::fs::create_dir_all(&base_path)
.with_context(|| format!("Failed to create directory: {}", base_path.display()))?;
}
let worktree_name = unique_branch.replace('/', "-");
let worktree_path = base_path.join(&worktree_name);
let output = Command::new("git")
.current_dir(&self.repo_path)
.args([
"worktree",
"add",
"-b",
&unique_branch,
worktree_path.to_str().context("Invalid path")?,
])
.output()
.context("Failed to execute git worktree add")?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to create worktree: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(worktree_path)
}
pub fn remove(&self, path: &Path) -> Result<()> {
let output = Command::new("git")
.current_dir(&self.repo_path)
.args([
"worktree",
"remove",
"--force",
path.to_str().context("Invalid path")?,
])
.output()
.context("Failed to execute git worktree remove")?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to remove worktree: {}",
String::from_utf8_lossy(&output.stderr)
));
}
if let Some(parent) = path.parent() {
if parent.exists() {
if let Ok(mut entries) = parent.read_dir() {
if entries.next().is_none() {
let _ = std::fs::remove_dir(parent);
}
}
}
}
Ok(())
}
pub fn list(&self) -> Result<Vec<WorktreeInfo>> {
let output = Command::new("git")
.current_dir(&self.repo_path)
.args(["worktree", "list", "--porcelain"])
.output()
.context("Failed to execute git worktree list")?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to list worktrees: {}",
String::from_utf8_lossy(&output.stderr)
));
}
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;
for line in stdout.lines() {
if let Some(path_str) = line.strip_prefix("worktree ") {
current_path = Some(PathBuf::from(path_str));
} else if let Some(branch_str) = line.strip_prefix("branch refs/heads/") {
current_branch = Some(branch_str.to_string());
} else if line.is_empty() {
if let Some(path) = current_path.take() {
let branch = current_branch.take().unwrap_or_default();
let is_main = path == self.repo_path;
worktrees.push(WorktreeInfo { path, branch, is_main });
}
current_branch = None;
}
}
if let Some(path) = current_path.take() {
let branch = current_branch.take().unwrap_or_default();
let is_main = path == self.repo_path;
worktrees.push(WorktreeInfo { path, branch, is_main });
}
Ok(worktrees)
}
pub fn branch_exists(&self, branch: &str) -> Result<bool> {
let output = Command::new("git")
.current_dir(&self.repo_path)
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
.output()
.context("Failed to execute git rev-parse")?;
Ok(output.status.success())
}
pub fn generate_unique_branch(&self, base_name: &str) -> Result<String> {
if !self.branch_exists(base_name)? {
return Ok(base_name.to_string());
}
let mut counter = 2;
loop {
let candidate = format!("{base_name}-{counter}");
if !self.branch_exists(&candidate)? {
return Ok(candidate);
}
counter += 1;
if counter > 1000 {
return Err(anyhow::anyhow!(
"Failed to generate unique branch name for: {base_name}"
));
}
}
}
pub fn repo_path(&self) -> &Path {
&self.repo_path
}
}
fn sanitize_branch_name(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == '/' {
c
} else if c == ' ' {
'-'
} else {
'_'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
fn setup_test_repo() -> (TempDir, WorktreeManager) {
let temp = TempDir::new().unwrap();
Command::new("git")
.current_dir(temp.path())
.args(["init"])
.output()
.unwrap();
Command::new("git")
.current_dir(temp.path())
.args(["config", "user.email", "test@test.com"])
.output()
.unwrap();
Command::new("git")
.current_dir(temp.path())
.args(["config", "user.name", "Test User"])
.output()
.unwrap();
Command::new("git")
.current_dir(temp.path())
.args(["commit", "--allow-empty", "-m", "init", "--no-gpg-sign"])
.output()
.unwrap();
let manager = WorktreeManager::new(temp.path()).unwrap();
(temp, manager)
}
#[test]
fn test_sanitize_branch_name() {
assert_eq!(sanitize_branch_name("feature/test"), "feature/test");
assert_eq!(sanitize_branch_name("feature test"), "feature-test");
assert_eq!(sanitize_branch_name("feature@test"), "feature_test");
assert_eq!(sanitize_branch_name("my-branch_name"), "my-branch_name");
}
#[test]
fn test_new_from_git_repo() {
let (temp, manager) = setup_test_repo();
assert_eq!(manager.repo_path(), temp.path());
}
#[test]
fn test_new_from_non_git_repo() {
let temp = TempDir::new().unwrap();
let result = WorktreeManager::new(temp.path());
assert!(result.is_err());
}
#[test]
fn test_branch_exists() {
let (_temp, manager) = setup_test_repo();
let master_exists = manager.branch_exists("master").unwrap();
let main_exists = manager.branch_exists("main").unwrap();
assert!(master_exists || main_exists);
assert!(!manager.branch_exists("nonexistent-branch").unwrap());
}
#[test]
fn test_generate_unique_branch() {
let (temp, manager) = setup_test_repo();
let branch1 = manager.generate_unique_branch("feature").unwrap();
assert_eq!(branch1, "feature");
manager.create(&branch1, temp.path()).unwrap();
let branch2 = manager.generate_unique_branch("feature").unwrap();
assert_eq!(branch2, "feature-2");
}
#[test]
fn test_create_and_list_worktree() {
let (temp, manager) = setup_test_repo();
let initial_list = manager.list().unwrap();
assert_eq!(initial_list.len(), 1);
assert!(initial_list[0].is_main);
let wt_path = manager.create("test-branch", temp.path()).unwrap();
assert!(wt_path.exists());
let list = manager.list().unwrap();
assert_eq!(list.len(), 2);
let created_wt = list.iter().find(|wt| !wt.is_main).unwrap();
assert_eq!(created_wt.branch, "test-branch");
}
#[test]
fn test_create_and_remove_worktree() {
let (temp, manager) = setup_test_repo();
let wt_path = manager.create("test-branch", temp.path()).unwrap();
assert!(wt_path.exists());
let list = manager.list().unwrap();
assert_eq!(list.len(), 2);
manager.remove(&wt_path).unwrap();
assert!(!wt_path.exists());
let list_after = manager.list().unwrap();
assert_eq!(list_after.len(), 1);
}
#[test]
fn test_create_with_relative_base_dir() {
let (_temp, manager) = setup_test_repo();
let wt_path = manager
.create("feature/new", Path::new(".worktrees"))
.unwrap();
assert!(wt_path.exists());
assert!(wt_path.to_str().unwrap().contains("feature-new"));
manager.remove(&wt_path).unwrap();
}
}