use crate::error::PjiError;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct GitWorktree {
pub path: PathBuf,
pub branch: Option<String>,
pub commit: String,
pub is_main: bool,
pub locked: bool,
pub prunable: bool,
}
impl GitWorktree {
pub fn display_name(&self) -> String {
if self.is_main {
format!("{} (main)", self.branch.as_deref().unwrap_or("detached"))
} else {
self.branch
.as_deref()
.unwrap_or(&self.commit[..8.min(self.commit.len())])
.to_string()
}
}
}
#[derive(Debug, Clone)]
pub struct WorktreeList {
pub main: GitWorktree,
pub linked: Vec<GitWorktree>,
}
impl WorktreeList {
pub fn all(&self) -> Vec<&GitWorktree> {
let mut result = vec![&self.main];
result.extend(self.linked.iter());
result
}
pub fn has_linked(&self) -> bool {
!self.linked.is_empty()
}
pub fn count(&self) -> usize {
1 + self.linked.len()
}
}
fn parse_worktree_porcelain(output: &str) -> Vec<GitWorktree> {
let mut worktrees = Vec::new();
let mut current_path: Option<PathBuf> = None;
let mut current_commit: Option<String> = None;
let mut current_branch: Option<String> = None;
let mut is_bare = false;
let mut is_locked = false;
let mut is_prunable = false;
for line in output.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
if let (Some(path), Some(commit)) = (current_path.take(), current_commit.take()) {
if !is_bare {
worktrees.push(GitWorktree {
path,
branch: current_branch.take(),
commit,
is_main: worktrees.is_empty(),
locked: is_locked,
prunable: is_prunable,
});
}
}
current_path = Some(PathBuf::from(path));
current_branch = None;
is_bare = false;
is_locked = false;
is_prunable = false;
} else if let Some(commit) = line.strip_prefix("HEAD ") {
current_commit = Some(commit.to_string());
} else if let Some(branch_ref) = line.strip_prefix("branch ") {
if let Some(branch) = branch_ref.strip_prefix("refs/heads/") {
current_branch = Some(branch.to_string());
} else {
current_branch = Some(branch_ref.to_string());
}
} else if line == "bare" {
is_bare = true;
} else if line == "locked" || line.starts_with("locked ") {
is_locked = true;
} else if line == "prunable" || line.starts_with("prunable ") {
is_prunable = true;
} else if line == "detached" {
current_branch = None;
}
}
if let (Some(path), Some(commit)) = (current_path, current_commit) {
if !is_bare {
worktrees.push(GitWorktree {
path,
branch: current_branch,
commit,
is_main: worktrees.is_empty(),
locked: is_locked,
prunable: is_prunable,
});
}
}
worktrees
}
pub(crate) fn list_worktrees(repo_dir: &PathBuf) -> Result<Option<WorktreeList>, PjiError> {
let command = format!("git -C {} worktree list --porcelain", repo_dir.display());
let output = Command::new("git")
.arg("-C")
.arg(repo_dir)
.arg("worktree")
.arg("list")
.arg("--porcelain")
.output()?;
if !output.status.success() {
return Err(PjiError::GitCommand {
command,
stderr: command_error_output(&output),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let worktrees = parse_worktree_porcelain(&stdout);
if worktrees.is_empty() {
return Ok(None);
}
let mut iter = worktrees.into_iter();
let main = match iter.next() {
Some(main) => main,
None => return Ok(None),
};
let linked: Vec<GitWorktree> = iter.collect();
Ok(Some(WorktreeList { main, linked }))
}
pub(crate) fn is_linked_worktree(dir: &Path) -> bool {
let git_path = dir.join(".git");
git_path.is_file()
}
pub(crate) fn get_main_repo_from_worktree(worktree_dir: &Path) -> Option<PathBuf> {
let git_path = worktree_dir.join(".git");
if git_path.is_dir() {
return Some(worktree_dir.to_path_buf());
}
if git_path.is_file() {
let content = std::fs::read_to_string(&git_path).ok()?;
let gitdir = content.trim().strip_prefix("gitdir: ")?;
let gitdir_path = PathBuf::from(gitdir);
let main_git_dir = gitdir_path.parent()?.parent()?.parent()?;
return Some(main_git_dir.to_path_buf());
}
None
}
pub(crate) fn add_worktree(
repo_dir: &PathBuf,
branch: &str,
path: Option<PathBuf>,
create_branch: bool,
base_branch: Option<&str>,
) -> Result<PathBuf, String> {
let worktree_path = match path {
Some(p) => p,
None => {
let repo_name = repo_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("repo");
let worktrees_dir = repo_dir
.parent()
.ok_or("Cannot determine parent directory")?
.join(format!("{}.worktrees", repo_name));
let safe_branch = branch.replace('/', "-");
worktrees_dir.join(&safe_branch)
}
};
let mut cmd = Command::new("git");
cmd.arg("-C").arg(repo_dir).arg("worktree").arg("add");
if create_branch {
cmd.arg("-b").arg(branch);
}
cmd.arg(&worktree_path);
if create_branch {
if let Some(base) = base_branch {
cmd.arg(base);
}
} else {
cmd.arg(branch);
}
let output = cmd.output().map_err(|e| e.to_string())?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(stderr.to_string());
}
Ok(worktree_path)
}
pub(crate) fn get_default_worktree_path(repo_dir: &Path, branch: &str) -> PathBuf {
let repo_name = repo_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("repo");
let worktrees_dir = repo_dir
.parent()
.map(|p| p.join(format!("{}.worktrees", repo_name)))
.unwrap_or_else(|| PathBuf::from(format!("{}.worktrees", repo_name)));
let safe_branch = branch.replace('/', "-");
worktrees_dir.join(&safe_branch)
}
pub(crate) fn remove_worktree(
repo_dir: &PathBuf,
worktree_path: &PathBuf,
force: bool,
) -> Result<(), String> {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(repo_dir).arg("worktree").arg("remove");
if force {
cmd.arg("--force");
}
cmd.arg(worktree_path);
let output = cmd.output().map_err(|e| e.to_string())?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(stderr.to_string());
}
Ok(())
}
pub(crate) fn list_local_branches(repo_dir: &PathBuf) -> Vec<String> {
let output = Command::new("git")
.arg("-C")
.arg(repo_dir)
.arg("branch")
.arg("--list")
.arg("--format=%(refname:short)")
.output();
match output {
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.lines()
.filter(|line| !line.is_empty())
.map(|line| line.to_string())
.collect()
}
_ => Vec::new(),
}
}
pub(crate) fn list_remote_branches(repo_dir: &PathBuf) -> Vec<String> {
let output = Command::new("git")
.arg("-C")
.arg(repo_dir)
.arg("branch")
.arg("-r")
.arg("--format=%(refname:short)")
.output();
match output {
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.lines()
.filter(|line| !line.is_empty() && !line.contains("HEAD"))
.map(|line| line.to_string())
.collect()
}
_ => Vec::new(),
}
}
pub(crate) fn prune_worktrees(repo_dir: &PathBuf) -> Result<String, String> {
let output = Command::new("git")
.arg("-C")
.arg(repo_dir)
.arg("worktree")
.arg("prune")
.arg("-v")
.output()
.map_err(|e| e.to_string())?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(stderr.to_string());
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.to_string())
}
fn command_error_output(output: &std::process::Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
format!("exit status {}", output.status)
} else {
stderr
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_worktree_porcelain_single() {
let output = r#"worktree /home/user/repo
HEAD abc123def456
branch refs/heads/main
"#;
let worktrees = parse_worktree_porcelain(output);
assert_eq!(worktrees.len(), 1);
assert_eq!(worktrees[0].path, PathBuf::from("/home/user/repo"));
assert_eq!(worktrees[0].branch, Some("main".to_string()));
assert_eq!(worktrees[0].commit, "abc123def456");
assert!(worktrees[0].is_main);
}
#[test]
fn test_parse_worktree_porcelain_multiple() {
let output = r#"worktree /home/user/repo
HEAD abc123
branch refs/heads/main
worktree /home/user/repo.worktrees/feature
HEAD def456
branch refs/heads/feature
"#;
let worktrees = parse_worktree_porcelain(output);
assert_eq!(worktrees.len(), 2);
assert!(worktrees[0].is_main);
assert!(!worktrees[1].is_main);
assert_eq!(worktrees[1].branch, Some("feature".to_string()));
}
#[test]
fn test_parse_worktree_porcelain_detached() {
let output = r#"worktree /home/user/repo
HEAD abc123
branch refs/heads/main
worktree /home/user/repo.worktrees/detached
HEAD def456
detached
"#;
let worktrees = parse_worktree_porcelain(output);
assert_eq!(worktrees.len(), 2);
assert!(worktrees[1].branch.is_none());
}
#[test]
fn test_parse_worktree_porcelain_locked() {
let output = r#"worktree /home/user/repo
HEAD abc123
branch refs/heads/main
worktree /home/user/repo.worktrees/locked-wt
HEAD def456
branch refs/heads/locked-branch
locked
"#;
let worktrees = parse_worktree_porcelain(output);
assert_eq!(worktrees.len(), 2);
assert!(worktrees[1].locked);
}
#[test]
fn test_worktree_display_name() {
let main_wt = GitWorktree {
path: PathBuf::from("/repo"),
branch: Some("main".to_string()),
commit: "abc123".to_string(),
is_main: true,
locked: false,
prunable: false,
};
assert_eq!(main_wt.display_name(), "main (main)");
let linked_wt = GitWorktree {
path: PathBuf::from("/repo.worktrees/feature"),
branch: Some("feature".to_string()),
commit: "def456".to_string(),
is_main: false,
locked: false,
prunable: false,
};
assert_eq!(linked_wt.display_name(), "feature");
let detached_wt = GitWorktree {
path: PathBuf::from("/repo.worktrees/detached"),
branch: None,
commit: "abc123def456".to_string(),
is_main: false,
locked: false,
prunable: false,
};
assert_eq!(detached_wt.display_name(), "abc123de");
}
}