use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug)]
pub enum WorktreeError {
CreateFailed(String),
RemoveFailed(String),
BranchDeleteFailed(String),
StatusFailed(String),
SpawnFailed(String),
EmptyBranchName,
}
impl std::fmt::Display for WorktreeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CreateFailed(msg) => write!(f, "git worktree add failed: {msg}"),
Self::RemoveFailed(msg) => write!(f, "git worktree remove failed: {msg}"),
Self::BranchDeleteFailed(msg) => write!(f, "git branch -D failed: {msg}"),
Self::StatusFailed(msg) => write!(f, "git status --porcelain failed: {msg}"),
Self::SpawnFailed(msg) => write!(f, "failed to spawn git: {msg}"),
Self::EmptyBranchName => write!(f, "branch name must be non-empty"),
}
}
}
impl std::error::Error for WorktreeError {}
#[derive(Debug)]
pub struct WorktreeSession {
path: PathBuf,
branch: String,
repo_root: PathBuf,
}
impl WorktreeSession {
pub fn create(repo_root: &Path, branch: &str) -> Result<Self, WorktreeError> {
if branch.trim().is_empty() {
return Err(WorktreeError::EmptyBranchName);
}
let worktree_path = worktree_path_for(repo_root, branch);
let output = Command::new("git")
.arg("-C")
.arg(repo_root)
.arg("worktree")
.arg("add")
.arg("-b")
.arg(branch)
.arg(&worktree_path)
.output()
.map_err(|e| WorktreeError::SpawnFailed(e.to_string()))?;
if !output.status.success() {
return Err(WorktreeError::CreateFailed(
String::from_utf8_lossy(&output.stderr).into(),
));
}
Ok(Self {
path: worktree_path,
branch: branch.to_string(),
repo_root: repo_root.to_path_buf(),
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn branch(&self) -> &str {
&self.branch
}
pub fn repo_root(&self) -> &Path {
&self.repo_root
}
pub fn is_dirty(&self) -> Result<bool, WorktreeError> {
let output = Command::new("git")
.arg("-C")
.arg(&self.path)
.arg("status")
.arg("--porcelain")
.output()
.map_err(|e| WorktreeError::SpawnFailed(e.to_string()))?;
if !output.status.success() {
return Err(WorktreeError::StatusFailed(
String::from_utf8_lossy(&output.stderr).into(),
));
}
Ok(!output.stdout.is_empty())
}
pub fn auto_close(self) -> Result<(), WorktreeError> {
remove_worktree(&self.repo_root, &self.path)?;
delete_branch(&self.repo_root, &self.branch)?;
Ok(())
}
pub fn auto_close_if_clean(self) -> Result<Option<(PathBuf, String)>, WorktreeError> {
if self.is_dirty()? {
Ok(Some((self.path, self.branch)))
} else {
let path = self.path.clone();
let branch = self.branch.clone();
remove_worktree(&self.repo_root, &path)?;
delete_branch(&self.repo_root, &branch)?;
Ok(None)
}
}
pub fn keep(self) -> (PathBuf, String) {
(self.path, self.branch)
}
}
fn remove_worktree(repo_root: &Path, path: &Path) -> Result<(), WorktreeError> {
let output = Command::new("git")
.arg("-C")
.arg(repo_root)
.arg("worktree")
.arg("remove")
.arg("--force")
.arg(path)
.output()
.map_err(|e| WorktreeError::SpawnFailed(e.to_string()))?;
if !output.status.success() {
return Err(WorktreeError::RemoveFailed(String::from_utf8_lossy(&output.stderr).into()));
}
Ok(())
}
fn delete_branch(repo_root: &Path, branch: &str) -> Result<(), WorktreeError> {
let output = Command::new("git")
.arg("-C")
.arg(repo_root)
.arg("branch")
.arg("-D")
.arg(branch)
.output()
.map_err(|e| WorktreeError::SpawnFailed(e.to_string()))?;
if !output.status.success() {
return Err(WorktreeError::BranchDeleteFailed(
String::from_utf8_lossy(&output.stderr).into(),
));
}
Ok(())
}
fn worktree_path_for(repo_root: &Path, branch: &str) -> PathBuf {
let sanitized: String = branch
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
.collect();
repo_root.join(".git").join("apr-worktrees").join(sanitized)
}
#[cfg(test)]
mod tests;