use crate::error::{Autom8Error, Result};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeInfo {
pub path: PathBuf,
pub branch: Option<String>,
pub commit: String,
pub is_main: bool,
pub is_bare: bool,
pub is_locked: bool,
pub is_prunable: bool,
}
impl WorktreeInfo {
fn from_porcelain_lines(lines: &[&str]) -> Option<Self> {
let mut path: Option<PathBuf> = None;
let mut branch: Option<String> = None;
let mut commit: Option<String> = None;
let mut is_bare = false;
let mut is_locked = false;
let mut is_prunable = false;
for line in lines {
if let Some(rest) = line.strip_prefix("worktree ") {
path = Some(PathBuf::from(rest));
} else if let Some(rest) = line.strip_prefix("HEAD ") {
commit = Some(rest.to_string());
} else if let Some(rest) = line.strip_prefix("branch ") {
let branch_name = rest.strip_prefix("refs/heads/").unwrap_or(rest).to_string();
branch = Some(branch_name);
} else if *line == "bare" {
is_bare = true;
} else if *line == "detached" {
} else if line.starts_with("locked") {
is_locked = true;
} else if line.starts_with("prunable") {
is_prunable = true;
}
}
let path = path?;
let commit = commit?;
Some(WorktreeInfo {
path,
branch,
commit,
is_main: false,
is_bare,
is_locked,
is_prunable,
})
}
}
pub fn list_worktrees() -> Result<Vec<WorktreeInfo>> {
let output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Autom8Error::WorktreeError(format!(
"Failed to list worktrees: {}",
stderr.trim()
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let worktrees = parse_worktree_list_porcelain(&stdout)?;
Ok(worktrees)
}
fn parse_worktree_list_porcelain(output: &str) -> Result<Vec<WorktreeInfo>> {
let mut worktrees = Vec::new();
let mut current_lines: Vec<&str> = Vec::new();
let mut is_first = true;
for line in output.lines() {
if line.is_empty() {
if !current_lines.is_empty() {
if let Some(mut wt) = WorktreeInfo::from_porcelain_lines(¤t_lines) {
wt.is_main = is_first;
is_first = false;
worktrees.push(wt);
}
current_lines.clear();
}
} else {
current_lines.push(line);
}
}
if !current_lines.is_empty() {
if let Some(mut wt) = WorktreeInfo::from_porcelain_lines(¤t_lines) {
wt.is_main = is_first;
worktrees.push(wt);
}
}
Ok(worktrees)
}
pub fn create_worktree<P: AsRef<Path>>(path: P, branch: &str) -> Result<()> {
let path = path.as_ref();
let branch_exists = Command::new("git")
.args([
"show-ref",
"--verify",
"--quiet",
&format!("refs/heads/{}", branch),
])
.output()?
.status
.success();
let output = if branch_exists {
Command::new("git")
.args(["worktree", "add", path.to_string_lossy().as_ref(), branch])
.output()?
} else {
Command::new("git")
.args([
"worktree",
"add",
"-b",
branch,
path.to_string_lossy().as_ref(),
])
.output()?
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Autom8Error::WorktreeError(format!(
"Failed to create worktree at '{}' for branch '{}': {}",
path.display(),
branch,
stderr.trim()
)));
}
Ok(())
}
pub fn remove_worktree<P: AsRef<Path>>(path: P, force: bool) -> Result<()> {
let path = path.as_ref();
let path_str = path.to_string_lossy();
let mut args = vec!["worktree", "remove"];
if force {
args.push("--force");
}
args.push(path_str.as_ref());
let output = Command::new("git").args(&args).output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Autom8Error::WorktreeError(format!(
"Failed to remove worktree at '{}': {}",
path.display(),
stderr.trim()
)));
}
Ok(())
}
pub fn get_worktree_root() -> Result<Option<PathBuf>> {
let git_dir_output = Command::new("git")
.args(["rev-parse", "--git-dir"])
.output()?;
if !git_dir_output.status.success() {
let stderr = String::from_utf8_lossy(&git_dir_output.stderr);
return Err(Autom8Error::WorktreeError(format!(
"Failed to get git directory: {}",
stderr.trim()
)));
}
let git_dir = String::from_utf8_lossy(&git_dir_output.stdout)
.trim()
.to_string();
if git_dir.contains("/worktrees/") || git_dir.contains("\\worktrees\\") {
let toplevel_output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()?;
if !toplevel_output.status.success() {
let stderr = String::from_utf8_lossy(&toplevel_output.stderr);
return Err(Autom8Error::WorktreeError(format!(
"Failed to get worktree root: {}",
stderr.trim()
)));
}
let toplevel = String::from_utf8_lossy(&toplevel_output.stdout)
.trim()
.to_string();
return Ok(Some(PathBuf::from(toplevel)));
}
Ok(None)
}
pub fn get_main_repo_root() -> Result<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--git-common-dir"])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Autom8Error::WorktreeError(format!(
"Failed to get main repo root: {}",
stderr.trim()
)));
}
let git_common_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
let git_path = PathBuf::from(&git_common_dir);
let main_repo_path = if git_path.is_absolute() {
git_path.parent().map(|p| p.to_path_buf())
} else {
let current_dir = std::env::current_dir()?;
let absolute_git = current_dir.join(&git_path);
absolute_git
.canonicalize()
.ok()
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
};
main_repo_path.ok_or_else(|| {
Autom8Error::WorktreeError("Failed to determine main repository root".to_string())
})
}
pub fn is_in_worktree() -> Result<bool> {
Ok(get_worktree_root()?.is_some())
}
pub fn get_git_repo_name() -> Result<Option<String>> {
let output = Command::new("git")
.args(["rev-parse", "--git-common-dir"])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not a git repository") {
return Ok(None);
}
return Err(Autom8Error::WorktreeError(format!(
"Failed to check git repository: {}",
stderr.trim()
)));
}
let main_root = get_main_repo_root()?;
main_root
.file_name()
.and_then(|n| n.to_str())
.map(|s| Some(s.to_string()))
.ok_or_else(|| {
Autom8Error::WorktreeError("Could not determine repository name from path".to_string())
})
}
pub const MAIN_SESSION_ID: &str = "main";
pub fn generate_session_id(worktree_path: &Path) -> String {
let path_str = worktree_path.to_string_lossy();
let mut hasher = Sha256::new();
hasher.update(path_str.as_bytes());
let result = hasher.finalize();
hex::encode(&result[..4])
}
pub fn get_current_session_id() -> Result<String> {
if let Some(worktree_root) = get_worktree_root()? {
Ok(generate_session_id(&worktree_root))
} else {
Ok(MAIN_SESSION_ID.to_string())
}
}
pub fn get_main_session_id() -> String {
MAIN_SESSION_ID.to_string()
}
pub fn get_session_id_for_path(path: &Path) -> Result<String> {
let abs_path = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
};
let main_root = get_main_repo_root()?;
let abs_canonical = abs_path.canonicalize().unwrap_or(abs_path);
let main_canonical = main_root.canonicalize().unwrap_or(main_root);
if abs_canonical == main_canonical {
Ok(MAIN_SESSION_ID.to_string())
} else {
Ok(generate_session_id(&abs_canonical))
}
}
pub fn slugify_branch_name(branch_name: &str) -> String {
branch_name
.chars()
.map(|c| {
if c == '/' || c == '\\' {
'-'
} else if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
pub fn generate_worktree_name(pattern: &str, repo_name: &str, branch_name: &str) -> String {
let slugified_branch = slugify_branch_name(branch_name);
pattern
.replace("{repo}", repo_name)
.replace("{branch}", &slugified_branch)
}
pub fn generate_worktree_path(pattern: &str, branch_name: &str) -> Result<PathBuf> {
let main_repo = get_main_repo_root()?;
let repo_name = main_repo
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| {
Autom8Error::WorktreeError("Could not determine repository name".to_string())
})?;
let worktree_name = generate_worktree_name(pattern, repo_name, branch_name);
let parent = main_repo.parent().ok_or_else(|| {
Autom8Error::WorktreeError("Could not determine repository parent directory".to_string())
})?;
Ok(parent.join(worktree_name))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorktreeResult {
Created(PathBuf),
Reused(PathBuf),
}
impl WorktreeResult {
pub fn path(&self) -> &Path {
match self {
WorktreeResult::Created(p) | WorktreeResult::Reused(p) => p,
}
}
pub fn was_created(&self) -> bool {
matches!(self, WorktreeResult::Created(_))
}
}
pub fn ensure_worktree(pattern: &str, branch_name: &str) -> Result<WorktreeResult> {
let target_path = generate_worktree_path(pattern, branch_name)?;
let worktrees = list_worktrees()?;
for wt in &worktrees {
if wt.path == target_path {
if let Some(ref wt_branch) = wt.branch {
if wt_branch == branch_name {
return Ok(WorktreeResult::Reused(target_path));
}
}
return Err(Autom8Error::WorktreeError(format!(
"Worktree at '{}' exists but uses branch '{}', not '{}'",
target_path.display(),
wt.branch.as_deref().unwrap_or("(detached)"),
branch_name
)));
}
if let Some(ref wt_branch) = wt.branch {
if wt_branch == branch_name && !wt.is_main {
return Ok(WorktreeResult::Reused(wt.path.clone()));
}
}
}
create_worktree(&target_path, branch_name)?;
Ok(WorktreeResult::Created(target_path))
}
pub fn format_worktree_error(error: &str, branch_name: &str, worktree_path: &Path) -> String {
let mut message = format!(
"Failed to create worktree for branch '{}' at '{}'.\n\n",
branch_name,
worktree_path.display()
);
if error.contains("already checked out") {
message.push_str("Reason: Branch is already checked out in another worktree.\n\n");
message.push_str("To resolve this, try one of the following:\n");
message.push_str(" 1. Use a different branch name in your spec\n");
message.push_str(" 2. Run `git worktree list` to see existing worktrees\n");
message
.push_str(" 3. Remove the conflicting worktree with `git worktree remove <path>`\n");
message.push_str("\nManual worktree creation steps:\n");
message.push_str(&format!(
" git worktree add -b {} '{}'\n",
branch_name,
worktree_path.display()
));
} else if error.contains("already exists") {
message.push_str("Reason: A directory or worktree already exists at this path.\n\n");
message.push_str("To resolve this, try one of the following:\n");
message.push_str(&format!(
" 1. Remove the existing directory: rm -rf '{}'\n",
worktree_path.display()
));
message.push_str(" 2. Use a different branch name in your spec\n");
message.push_str(" 3. Configure a different worktree_path_pattern in config\n");
message.push_str("\nManual worktree creation steps (after removing existing):\n");
message.push_str(&format!(
" git worktree add '{}' {}\n",
worktree_path.display(),
branch_name
));
} else if error.contains("permission denied") || error.contains("Permission denied") {
message.push_str("Reason: Insufficient permissions to create the worktree directory.\n\n");
message.push_str("To resolve this, try one of the following:\n");
message.push_str(&format!(
" 1. Check write permissions on: {}\n",
worktree_path
.parent()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "parent directory".to_string())
));
message.push_str(" 2. Run with appropriate permissions (e.g., sudo if needed)\n");
message
.push_str(" 3. Choose a different location in your config's worktree_path_pattern\n");
} else {
message.push_str(&format!("Error: {}\n\n", error));
message.push_str("To resolve this, try one of the following:\n");
message.push_str(" 1. Ensure you're in a git repository\n");
message.push_str(" 2. Run `git worktree list` to check current worktrees\n");
message.push_str(" 3. Check git configuration and permissions\n");
message.push_str("\nManual worktree creation steps:\n");
message.push_str(&format!(
" git worktree add '{}' {}\n",
worktree_path.display(),
branch_name
));
}
message
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_porcelain_single_worktree() {
let output = "worktree /home/user/project\nHEAD abc1234567890abcdef1234567890abcdef12345678\nbranch refs/heads/main\n\n";
let worktrees = parse_worktree_list_porcelain(output).unwrap();
assert_eq!(worktrees.len(), 1);
let wt = &worktrees[0];
assert_eq!(wt.path, PathBuf::from("/home/user/project"));
assert_eq!(wt.branch, Some("main".to_string()));
assert_eq!(wt.commit, "abc1234567890abcdef1234567890abcdef12345678");
assert!(wt.is_main);
assert!(!wt.is_bare);
}
#[test]
fn test_parse_porcelain_multiple_worktrees() {
let output = concat!(
"worktree /home/user/project\n",
"HEAD abc1234567890abcdef1234567890abcdef12345678\n",
"branch refs/heads/main\n",
"\n",
"worktree /home/user/project-feature\n",
"HEAD def5678901234abcdef5678901234abcdef56789012\n",
"branch refs/heads/feature/test\n",
"\n"
);
let worktrees = parse_worktree_list_porcelain(output).unwrap();
assert_eq!(worktrees.len(), 2);
assert!(worktrees[0].is_main);
assert_eq!(worktrees[0].branch, Some("main".to_string()));
assert!(!worktrees[1].is_main);
assert_eq!(worktrees[1].branch, Some("feature/test".to_string()));
}
#[test]
fn test_parse_porcelain_special_states() {
let output = "worktree /path\nHEAD abc123\ndetached\n\n";
let wt = &parse_worktree_list_porcelain(output).unwrap()[0];
assert!(wt.branch.is_none());
let output = "worktree /path.git\nHEAD abc123\nbare\n\n";
let wt = &parse_worktree_list_porcelain(output).unwrap()[0];
assert!(wt.is_bare);
let output = "worktree /path\nHEAD abc123\nbranch refs/heads/main\nlocked\n\n";
let wt = &parse_worktree_list_porcelain(output).unwrap()[0];
assert!(wt.is_locked);
let output = "worktree /path\nHEAD abc123\nbranch refs/heads/main\nprunable\n\n";
let wt = &parse_worktree_list_porcelain(output).unwrap()[0];
assert!(wt.is_prunable);
}
#[test]
fn test_parse_porcelain_edge_cases() {
let output = "worktree /path\nHEAD abc123\nbranch refs/heads/main";
assert_eq!(parse_worktree_list_porcelain(output).unwrap().len(), 1);
assert!(parse_worktree_list_porcelain("").unwrap().is_empty());
let output = "worktree /home/user/my project/repo\nHEAD abc123\nbranch refs/heads/main\n\n";
assert_eq!(
parse_worktree_list_porcelain(output).unwrap()[0].path,
PathBuf::from("/home/user/my project/repo")
);
}
#[test]
fn test_from_porcelain_lines_missing_required_fields() {
assert!(
WorktreeInfo::from_porcelain_lines(&["HEAD abc123", "branch refs/heads/main"])
.is_none()
);
assert!(
WorktreeInfo::from_porcelain_lines(&["worktree /path", "branch refs/heads/main"])
.is_none()
);
}
#[test]
fn test_generate_session_id_properties() {
let path = Path::new("/home/user/project-feature");
let id = generate_session_id(path);
assert_eq!(id.len(), 8);
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(id, generate_session_id(path));
let id2 = generate_session_id(Path::new("/home/user/other-project"));
assert_ne!(id, id2);
}
#[test]
fn test_generate_session_id_uniqueness() {
let paths = [
"/home/user/project1",
"/home/user/project2",
"/tmp/worktree-a",
"/tmp/worktree-b",
];
let ids: Vec<String> = paths
.iter()
.map(|p| generate_session_id(Path::new(p)))
.collect();
let unique_ids: std::collections::HashSet<_> = ids.iter().collect();
assert_eq!(ids.len(), unique_ids.len());
}
#[test]
fn test_main_session_id() {
assert_eq!(MAIN_SESSION_ID, "main");
assert_eq!(get_main_session_id(), "main");
}
#[test]
fn test_slugify_branch_name() {
assert_eq!(slugify_branch_name("feature/login"), "feature-login");
assert_eq!(
slugify_branch_name("feature/user/auth"),
"feature-user-auth"
);
assert_eq!(slugify_branch_name("main"), "main");
assert_eq!(slugify_branch_name("v1.0.0"), "v1.0.0");
assert_eq!(slugify_branch_name("feature//login"), "feature-login"); assert_eq!(slugify_branch_name("feature@login"), "feature-login"); }
#[test]
fn test_generate_worktree_name() {
assert_eq!(
generate_worktree_name("{repo}-wt-{branch}", "myproject", "feature/login"),
"myproject-wt-feature-login"
);
assert_eq!(
generate_worktree_name("{repo}_worktree_{branch}", "myproject", "main"),
"myproject_worktree_main"
);
}
#[test]
fn test_worktree_result() {
let path = PathBuf::from("/test/path");
let created = WorktreeResult::Created(path.clone());
let reused = WorktreeResult::Reused(path.clone());
assert_eq!(created.path(), &path);
assert_eq!(reused.path(), &path);
assert!(created.was_created());
assert!(!reused.was_created());
}
#[test]
fn test_format_worktree_error_messages() {
let msg = format_worktree_error(
"fatal: branch 'main' is already checked out",
"main",
Path::new("/new/worktree"),
);
assert!(msg.contains("already checked out"));
assert!(msg.contains("To resolve"));
assert!(msg.contains("git worktree"));
let msg = format_worktree_error(
"fatal: already exists",
"feature",
Path::new("/new/worktree"),
);
assert!(msg.contains("already exists"));
assert!(msg.contains("after removing existing"));
let msg = format_worktree_error(
"error: permission denied",
"feature",
Path::new("/restricted"),
);
assert!(msg.contains("permissions"));
let msg = format_worktree_error("unknown error", "feature/login", Path::new("/path/to/wt"));
assert!(msg.contains("Manual worktree creation"));
assert!(msg.contains("feature/login"));
}
}