use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeEntry {
pub path: PathBuf,
pub head: Option<String>,
pub branch: Option<String>,
pub bare: bool,
pub detached: bool,
pub locked: bool,
pub prunable: bool,
}
impl WorktreeEntry {
fn new(path: PathBuf) -> Self {
Self {
path,
head: None,
branch: None,
bare: false,
detached: false,
locked: false,
prunable: false,
}
}
pub fn branch_short(&self) -> Option<&str> {
self.branch
.as_deref()
.map(|b| b.strip_prefix("refs/heads/").unwrap_or(b))
}
}
pub fn parse_worktree_porcelain(output: &str) -> Vec<WorktreeEntry> {
let mut out: Vec<WorktreeEntry> = Vec::new();
let mut cur: Option<WorktreeEntry> = None;
for raw in output.lines() {
let line = raw.trim_end_matches(['\r', '\n']);
if let Some(path) = line.strip_prefix("worktree ") {
if let Some(done) = cur.take() {
out.push(done);
}
cur = Some(WorktreeEntry::new(PathBuf::from(path)));
continue;
}
if line.is_empty() {
continue;
}
let Some(e) = cur.as_mut() else {
continue;
};
if let Some(oid) = line.strip_prefix("HEAD ") {
e.head = Some(oid.to_string());
} else if let Some(b) = line.strip_prefix("branch ") {
e.branch = Some(b.to_string());
} else if line == "bare" {
e.bare = true;
} else if line == "detached" {
e.detached = true;
} else if line == "locked" || line.starts_with("locked ") {
e.locked = true;
} else if line == "prunable" || line.starts_with("prunable ") {
e.prunable = true;
}
}
if let Some(done) = cur.take() {
out.push(done);
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WtClass {
Main,
Nested,
Sibling,
Other,
}
pub fn classify(repo_root: &Path, wt_path: &Path) -> WtClass {
if wt_path == repo_root {
return WtClass::Main;
}
let nested_anchor = repo_root.join(".claude").join("worktrees");
if wt_path.starts_with(&nested_anchor) {
return WtClass::Nested;
}
if let (Some(rp), Some(wp)) = (repo_root.parent(), wt_path.parent()) {
if rp == wp {
if let (Some(rn), Some(wn)) = (
repo_root.file_name().and_then(|s| s.to_str()),
wt_path.file_name().and_then(|s| s.to_str()),
) {
let pfx = format!("{rn}-");
if wn != rn && wn.starts_with(&pfx) {
return WtClass::Sibling;
}
}
}
}
WtClass::Other
}
pub fn list_worktrees(repo: &Path) -> std::io::Result<Vec<WorktreeEntry>> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["worktree", "list", "--porcelain"])
.output()?;
if !out.status.success() {
return Err(std::io::Error::other(format!(
"`git -C {} worktree list --porcelain` exited {}",
repo.display(),
out.status
)));
}
Ok(parse_worktree_porcelain(&String::from_utf8_lossy(
&out.stdout,
)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_canonical_multi_record_output() {
let out = "\
worktree /Users/iggy/Documents/GitHub/tf-multiverse
HEAD aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
branch refs/heads/dev
worktree /Users/iggy/Documents/GitHub/tf-multiverse/.claude/worktrees/agent-x
HEAD bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
branch refs/heads/agent/x
worktree /Users/iggy/Documents/GitHub/tf-multiverse-flat
HEAD cccccccccccccccccccccccccccccccccccccccc
detached
worktree /Users/iggy/Documents/GitHub/bare-repo.git
bare
";
let v = parse_worktree_porcelain(out);
assert_eq!(v.len(), 4);
assert_eq!(
v[0].path,
PathBuf::from("/Users/iggy/Documents/GitHub/tf-multiverse")
);
assert_eq!(v[0].branch_short(), Some("dev"));
assert!(!v[0].detached && !v[0].bare);
assert_eq!(v[1].branch.as_deref(), Some("refs/heads/agent/x"));
assert_eq!(v[1].branch_short(), Some("agent/x"));
assert!(v[2].detached);
assert_eq!(v[2].branch, None);
assert_eq!(v[2].branch_short(), None);
assert!(v[3].bare);
assert_eq!(v[3].head, None);
assert_eq!(v[3].branch_short(), None);
}
#[test]
fn path_with_spaces_is_verbatim_to_eol() {
let out = "worktree /tmp/My Repo/wt one\nHEAD dddddddddddddddddddddddddddddddddddddddd\nbranch refs/heads/x\n";
let v = parse_worktree_porcelain(out);
assert_eq!(v.len(), 1);
assert_eq!(v[0].path, PathBuf::from("/tmp/My Repo/wt one"));
assert_eq!(v[0].branch_short(), Some("x"));
}
#[test]
fn last_record_without_trailing_blank_is_flushed() {
let out =
"worktree /a\nHEAD eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\nbranch refs/heads/main\n";
let v = parse_worktree_porcelain(out);
assert_eq!(v.len(), 1);
assert_eq!(v[0].path, PathBuf::from("/a"));
}
#[test]
fn locked_and_prunable_with_and_without_reason() {
let out = "\
worktree /a
HEAD ffffffffffffffffffffffffffffffffffffffff
branch refs/heads/a
locked
worktree /b
HEAD 1111111111111111111111111111111111111111
detached
prunable gitdir file points to non-existent location
worktree /c
HEAD 2222222222222222222222222222222222222222
branch refs/heads/c
locked machine relocated
";
let v = parse_worktree_porcelain(out);
assert_eq!(v.len(), 3);
assert!(v[0].locked && !v[0].prunable);
assert!(v[1].prunable && !v[1].locked);
assert!(v[2].locked, "locked WITH a reason still sets the flag");
}
#[test]
fn unknown_attribute_lines_are_ignored_forward_compatible() {
let out = "worktree /a\nHEAD 3333333333333333333333333333333333333333\nbranch refs/heads/a\nfuture-attr something\n";
let v = parse_worktree_porcelain(out);
assert_eq!(v.len(), 1);
assert_eq!(v[0].branch_short(), Some("a"));
}
#[test]
fn degrades_on_malformed_input_never_panics() {
assert_eq!(parse_worktree_porcelain(""), Vec::new());
assert_eq!(parse_worktree_porcelain("\n\n\n"), Vec::new());
assert_eq!(
parse_worktree_porcelain("HEAD abc\nbranch refs/heads/x\n"),
Vec::new()
);
let v = parse_worktree_porcelain("worktree \nbare\n");
assert_eq!(v.len(), 1);
assert!(v[0].bare);
assert_eq!(v[0].path, PathBuf::from(""));
}
#[test]
fn crlf_line_endings_tolerated() {
let out = "worktree /a\r\nHEAD 4444444444444444444444444444444444444444\r\nbranch refs/heads/a\r\n";
let v = parse_worktree_porcelain(out);
assert_eq!(v.len(), 1);
assert_eq!(v[0].path, PathBuf::from("/a"));
assert_eq!(v[0].branch_short(), Some("a"));
}
const REPO: &str = "/Users/iggy/Documents/GitHub/tf-multiverse";
#[test]
fn classify_main_is_the_repo_root_itself() {
assert_eq!(classify(Path::new(REPO), Path::new(REPO)), WtClass::Main);
}
#[test]
fn classify_nested_under_claude_worktrees() {
assert_eq!(
classify(
Path::new(REPO),
Path::new(&format!("{REPO}/.claude/worktrees/agent-flat")),
),
WtClass::Nested
);
assert_eq!(
classify(
Path::new(REPO),
Path::new(&format!("{REPO}/.claude/worktrees/a/b/c")),
),
WtClass::Nested
);
}
#[test]
fn classify_sibling_same_parent_with_repo_name_prefix() {
assert_eq!(
classify(
Path::new(REPO),
Path::new("/Users/iggy/Documents/GitHub/tf-multiverse-flat"),
),
WtClass::Sibling
);
assert_eq!(
classify(
Path::new(REPO),
Path::new("/Users/iggy/Documents/GitHub/tf-multiverse-check-queue"),
),
WtClass::Sibling
);
}
#[test]
fn classify_sibling_requires_the_hyphen_boundary() {
assert_eq!(
classify(
Path::new(REPO),
Path::new("/Users/iggy/Documents/GitHub/tf-multiversed"),
),
WtClass::Other
);
}
#[test]
fn classify_other_for_unrelated_and_far_locations() {
assert_eq!(
classify(Path::new(REPO), Path::new("/tmp/some/checkout")),
WtClass::Other
);
assert_eq!(
classify(
Path::new(REPO),
Path::new("/Users/iggy/Documents/GitHub/other-project"),
),
WtClass::Other
);
}
#[test]
fn classify_precedence_main_beats_nested_anchor_collision() {
assert_eq!(classify(Path::new(REPO), Path::new(REPO)), WtClass::Main);
}
#[test]
fn end_to_end_parse_then_classify_the_three_classes() {
let out = format!(
"worktree {REPO}\nHEAD a\nbranch refs/heads/dev\n\n\
worktree {REPO}/.claude/worktrees/agent-x\nHEAD b\nbranch refs/heads/agent/x\n\n\
worktree /Users/iggy/Documents/GitHub/tf-multiverse-flat\nHEAD c\ndetached\n\n\
worktree /var/tmp/special\nHEAD d\nbranch refs/heads/s\n"
);
let v = parse_worktree_porcelain(&out);
assert_eq!(v.len(), 4);
let classes: Vec<WtClass> = v
.iter()
.map(|e| classify(Path::new(REPO), &e.path))
.collect();
assert_eq!(
classes,
vec![
WtClass::Main,
WtClass::Nested,
WtClass::Sibling,
WtClass::Other,
]
);
}
}