pub mod repo;
use serde::Serialize;
use tokio::process::Command;
#[derive(Debug, Clone, Serialize)]
pub struct WorktreeInfo {
pub path: String,
pub branch: Option<String>,
pub bare: bool,
pub detached: bool,
}
pub async fn is_git_repo(path: &str) -> bool {
let Ok(output) =
Command::new("git").args(["-C", path, "rev-parse", "--is-inside-work-tree"]).output().await
else {
return false;
};
String::from_utf8_lossy(&output.stdout).trim() == "true"
}
pub async fn discover_worktrees(path: &str) -> anyhow::Result<Vec<WorktreeInfo>> {
let output =
Command::new("git").args(["-C", path, "worktree", "list", "--porcelain"]).output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git worktree list failed: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_worktree_list(&stdout))
}
pub async fn current_branch(repo_path: &str) -> anyhow::Result<String> {
let output = Command::new("git")
.args(["-C", repo_path, "rev-parse", "--abbrev-ref", "HEAD"])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git rev-parse failed: {}", stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub async fn list_branches(repo_path: &str) -> anyhow::Result<Vec<String>> {
let output = Command::new("git")
.args(["-C", repo_path, "branch", "--format=%(refname:short)"])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git branch failed: {}", stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect())
}
pub async fn remove_worktree(repo_path: &str, target_path: &str) -> anyhow::Result<()> {
let output = Command::new("git")
.args(["-C", repo_path, "worktree", "remove", "--force", target_path])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git worktree remove failed: {}", stderr.trim());
}
Ok(())
}
pub async fn add_worktree(
repo_path: &str,
branch: &str,
target_path: &str,
base: Option<&str>,
detach: bool,
) -> anyhow::Result<()> {
let mut args: Vec<&str> = vec!["-C", repo_path, "worktree", "add"];
if detach {
args.push("--detach");
}
args.push("-b");
args.push(branch);
args.push(target_path);
if let Some(base_ref) = base {
args.push(base_ref);
}
let output = Command::new("git").args(&args).output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git worktree add failed: {}", stderr.trim());
}
Ok(())
}
fn parse_worktree_list(raw: &str) -> Vec<WorktreeInfo> {
raw.trim()
.split("\n\n")
.filter(|chunk| !chunk.is_empty())
.filter_map(|chunk| {
let mut info =
WorktreeInfo { path: String::new(), branch: None, bare: false, detached: false };
for line in chunk.lines() {
let mut parts = line.splitn(2, ' ');
let key = parts.next().unwrap_or("");
let value = parts.next().unwrap_or("").trim();
match key {
"worktree" => info.path = value.to_string(),
"branch" => {
info.branch =
Some(value.strip_prefix("refs/heads/").unwrap_or(value).to_string());
}
"bare" => info.bare = true,
"detached" => info.detached = true,
_ => {}
}
}
if info.path.is_empty() { None } else { Some(info) }
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_worktree_list_single() {
let input = "worktree /home/user/repo\nHEAD abc123\nbranch refs/heads/main\n";
let result = parse_worktree_list(input);
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, "/home/user/repo");
assert_eq!(result[0].branch.as_deref(), Some("main"));
assert!(!result[0].bare);
assert!(!result[0].detached);
}
#[test]
fn test_parse_worktree_list_multiple() {
let input = "\
worktree /home/user/repo
HEAD abc123
branch refs/heads/main
worktree /home/user/repo-dev
HEAD def456
branch refs/heads/dev
worktree /home/user/repo-feature
HEAD ghi789
detached
";
let result = parse_worktree_list(input);
assert_eq!(result.len(), 3);
assert_eq!(result[0].branch.as_deref(), Some("main"));
assert_eq!(result[1].branch.as_deref(), Some("dev"));
assert!(result[2].detached);
assert_eq!(result[2].branch, None);
}
}