use super::git_output;
use super::types::{CommitSummary, Worktree};
use anyhow::{anyhow, Result};
use std::path::{Component, Path, PathBuf};
pub fn list_worktrees(root: &Path) -> Result<Vec<Worktree>> {
let out = git_output(root, &["worktree", "list", "--porcelain"])?;
let mut list = parse_porcelain(&String::from_utf8_lossy(&out));
finish(&mut list, root);
Ok(list)
}
pub fn finish(list: &mut [Worktree], current: &Path) {
let main = list.first().map(|w| w.path.clone());
let current_canon = canon(current);
for wt in list.iter_mut() {
wt.display_path = match &main {
Some(main) => display_path(main, &wt.path),
None => wt.path.to_string_lossy().into_owned(),
};
wt.is_current = canon(&wt.path) == current_canon;
}
}
fn canon(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
pub fn parse_porcelain(text: &str) -> Vec<Worktree> {
let mut list = Vec::new();
let mut cur: Option<Worktree> = None;
for line in text.lines() {
let line = line.trim_end_matches('\r');
if line.is_empty() {
if let Some(wt) = cur.take() {
list.push(wt);
}
continue;
}
let (key, value) = match line.split_once(' ') {
Some((k, v)) => (k, Some(v)),
None => (line, None),
};
match key {
"worktree" => {
if let Some(wt) = cur.take() {
list.push(wt);
}
let path = unquote(value.unwrap_or_default());
cur = Some(Worktree {
path: PathBuf::from(path),
display_path: String::new(),
head: None,
branch: None,
detached: false,
bare: false,
locked: None,
prunable: None,
is_main: list.is_empty(),
is_current: false,
});
}
_ => {
let Some(wt) = cur.as_mut() else { continue };
match key {
"HEAD" => wt.head = value.map(str::to_string),
"branch" => {
wt.branch =
value.map(|v| v.strip_prefix("refs/heads/").unwrap_or(v).to_string())
}
"detached" => wt.detached = true,
"bare" => wt.bare = true,
"locked" => wt.locked = Some(unquote(value.unwrap_or_default())),
"prunable" => wt.prunable = Some(unquote(value.unwrap_or_default())),
_ => {}
}
}
}
}
if let Some(wt) = cur.take() {
list.push(wt);
}
list
}
fn unquote(s: &str) -> String {
let Some(inner) = s.strip_prefix('"').and_then(|s| s.strip_suffix('"')) else {
return s.to_string();
};
let mut out: Vec<u8> = Vec::new();
let mut chars = inner.chars().peekable();
while let Some(c) = chars.next() {
if c != '\\' {
let mut buf = [0u8; 4];
out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
continue;
}
match chars.next() {
Some('n') => out.push(b'\n'),
Some('t') => out.push(b'\t'),
Some('"') => out.push(b'"'),
Some('\\') => out.push(b'\\'),
Some(d) if d.is_digit(8) => {
let mut v = d.to_digit(8).unwrap_or(0);
for _ in 0..2 {
match chars.peek().and_then(|c| c.to_digit(8)) {
Some(n) => {
v = v * 8 + n;
chars.next();
}
None => break,
}
}
out.push(v as u8);
}
Some(other) => {
let mut buf = [0u8; 4];
out.extend_from_slice(other.encode_utf8(&mut buf).as_bytes());
}
None => {}
}
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn display_path(main: &Path, path: &Path) -> String {
if path == main {
return main
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| main.to_string_lossy().into_owned());
}
let base: Vec<Component> = main.components().collect();
let target: Vec<Component> = path.components().collect();
let common = base
.iter()
.zip(target.iter())
.take_while(|(a, b)| a == b)
.count();
if common == 0 {
return path.to_string_lossy().into_owned();
}
let mut rel = PathBuf::new();
for _ in common..base.len() {
rel.push("..");
}
for c in &target[common..] {
rel.push(c.as_os_str());
}
rel.to_string_lossy().into_owned()
}
pub fn head_summary(path: &Path) -> Result<CommitSummary> {
let out = git_output(
path,
&[
"show",
"--no-color",
"--no-ext-diff",
"--first-parent",
"--stat=72",
"--date=format:%Y-%m-%d %H:%M",
"--format=%H%x1f%an%x1f%ae%x1f%ad%x1f%ar%x1f%s%x1e",
"HEAD",
],
)?;
parse_show(&String::from_utf8_lossy(&out))
}
pub fn parse_show(text: &str) -> Result<CommitSummary> {
let (header, rest) = text
.split_once('\x1e')
.ok_or_else(|| anyhow!("unexpected git show output"))?;
let mut fields = header.split('\x1f');
let mut next = || fields.next().unwrap_or("").trim().to_string();
let hash = next();
let author_name = next();
let author_email = next();
let date = next();
let relative_date = next();
let subject = next();
if hash.is_empty() {
return Err(anyhow!("unexpected git show output"));
}
let stat = rest
.lines()
.map(|l| l.trim_end().to_string())
.filter(|l| !l.is_empty())
.collect();
Ok(CommitSummary {
hash,
author_name,
author_email,
date,
relative_date,
subject,
stat,
})
}
#[cfg(test)]
mod tests {
use super::*;
const PORCELAIN: &str = "\
worktree /repo
HEAD 06ee70fc4b03fe3701895bd34e4605ba5d3c579b
branch refs/heads/main
worktree /repo/.claude/worktrees/agent-1
HEAD 06ee70fc4b03fe3701895bd34e4605ba5d3c579b
branch refs/heads/feat/procs-view
locked claude agent (pid 40747)
worktree /elsewhere/wt-detached
HEAD 1234567890abcdef1234567890abcdef12345678
detached
prunable gitdir file points to non-existent location
worktree /srv/bare.git
bare
";
#[test]
fn parses_porcelain_entries() {
let list = parse_porcelain(PORCELAIN);
assert_eq!(list.len(), 4);
let main = &list[0];
assert_eq!(main.path, PathBuf::from("/repo"));
assert_eq!(main.branch.as_deref(), Some("main"));
assert!(main.is_main);
assert!(!main.detached);
assert_eq!(main.short_head(), "06ee70f");
let locked = &list[1];
assert!(!locked.is_main);
assert_eq!(locked.branch.as_deref(), Some("feat/procs-view"));
assert_eq!(locked.locked.as_deref(), Some("claude agent (pid 40747)"));
assert_eq!(locked.flags(), vec!["locked"]);
let detached = &list[2];
assert!(detached.detached);
assert_eq!(detached.branch, None);
assert_eq!(detached.ref_label(), "(detached 1234567)");
assert!(detached.prunable.is_some());
assert_eq!(detached.flags(), vec!["prunable"]);
let bare = &list[3];
assert!(bare.bare);
assert_eq!(bare.head, None);
assert_eq!(bare.ref_label(), "(bare)");
assert_eq!(bare.flags(), vec!["bare"]);
}
#[test]
fn display_paths_are_relative_to_main() {
let mut list = parse_porcelain(PORCELAIN);
finish(&mut list, Path::new("/repo/.claude/worktrees/agent-1"));
assert_eq!(list[0].display_path, "repo");
assert_eq!(list[1].display_path, ".claude/worktrees/agent-1");
assert_eq!(list[2].display_path, "../elsewhere/wt-detached");
assert_eq!(list[3].display_path, "../srv/bare.git");
assert_eq!(list[0].flags(), vec!["main"]);
}
#[test]
fn current_worktree_is_detected() {
let mut list = parse_porcelain(PORCELAIN);
finish(&mut list, Path::new("/repo/.claude/worktrees/agent-1"));
let current: Vec<bool> = list.iter().map(|w| w.is_current).collect();
assert_eq!(current, vec![false, true, false, false]);
finish(&mut list, Path::new("/repo"));
assert!(list[0].is_current);
assert!(!list[1].is_current);
}
#[test]
fn current_worktree_survives_symlinked_root() {
let dir = std::env::temp_dir().join(format!("vig-wt-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let canonical = std::fs::canonicalize(&dir).unwrap();
let mut list = parse_porcelain(&format!("worktree {}\nHEAD abc\n", canonical.display()));
finish(&mut list, &dir);
assert!(list[0].is_current);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn display_path_sibling_and_unrelated() {
assert_eq!(
display_path(Path::new("/tmp/x/repo"), Path::new("/tmp/x/wt-feature")),
"../wt-feature"
);
assert_eq!(
display_path(Path::new("/tmp/x/repo"), Path::new("/tmp/x/repo/sub")),
"sub"
);
assert_eq!(
display_path(Path::new("/tmp/x/repo"), Path::new("/tmp/x/repo")),
"repo"
);
assert_eq!(
display_path(Path::new("rel/repo"), Path::new("/abs/wt")),
"/abs/wt"
);
}
#[test]
fn unquotes_git_style_paths() {
assert_eq!(unquote("plain"), "plain");
assert_eq!(unquote("\"with space\""), "with space");
assert_eq!(unquote("\"tab\\there\""), "tab\there");
assert_eq!(unquote("\"q\\\"uote\""), "q\"uote");
assert_eq!(unquote("\"\\303\\251\""), "\u{e9}");
let list = parse_porcelain("worktree \"/p/with space\"\nHEAD abc\nbranch refs/heads/x\n");
assert_eq!(list[0].path, PathBuf::from("/p/with space"));
}
#[test]
fn parses_show_output() {
let text = "06ee70fc4b03fe3701895bd34e4605ba5d3c579b\x1fKohei\x1fk@example.com\x1f2026-08-28 16:20\x1f6 minutes ago\x1fMerge pull request #108\x1e\n\n src/core/mod.rs | 1 +\n src/core/tree.rs | 162 ++++++\n 2 files changed, 163 insertions(+)\n";
let s = parse_show(text).unwrap();
assert_eq!(s.short_hash(), "06ee70f");
assert_eq!(s.author_name, "Kohei");
assert_eq!(s.author_email, "k@example.com");
assert_eq!(s.date, "2026-08-28 16:20");
assert_eq!(s.relative_date, "6 minutes ago");
assert_eq!(s.subject, "Merge pull request #108");
assert_eq!(s.stat.len(), 3);
assert_eq!(s.stat[0], " src/core/mod.rs | 1 +");
assert!(parse_show("garbage").is_err());
}
}