use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RawWorktree {
pub(crate) path: PathBuf,
pub(crate) head: Option<String>,
pub(crate) branch: Option<String>,
pub(crate) is_bare: bool,
pub(crate) is_detached: bool,
pub(crate) is_locked: bool,
pub(crate) is_prunable: bool,
pub(crate) is_main: bool,
pub(crate) is_missing: bool,
}
impl RawWorktree {
fn new(path: PathBuf) -> Self {
RawWorktree {
path,
head: None,
branch: None,
is_bare: false,
is_detached: false,
is_locked: false,
is_prunable: false,
is_main: false,
is_missing: false,
}
}
}
pub(crate) fn parse_worktree_list(porcelain: &str) -> Vec<RawWorktree> {
let mut result: Vec<RawWorktree> = Vec::new();
let mut current: Option<RawWorktree> = None;
for line in porcelain.lines() {
if line.is_empty() {
if let Some(wt) = current.take() {
result.push(wt);
}
continue;
}
let (key, rest) = match line.split_once(' ') {
Some((k, r)) => (k, Some(r)),
None => (line, None),
};
match key {
"worktree" => {
if let Some(wt) = current.take() {
result.push(wt);
}
current = Some(RawWorktree::new(PathBuf::from(rest.unwrap_or_default())));
}
"HEAD" => {
if let Some(wt) = current.as_mut() {
wt.head = rest.map(str::to_string);
}
}
"branch" => {
if let Some(wt) = current.as_mut() {
wt.branch = rest.map(strip_branch_ref);
}
}
"bare" => {
if let Some(wt) = current.as_mut() {
wt.is_bare = true;
}
}
"detached" => {
if let Some(wt) = current.as_mut() {
wt.is_detached = true;
}
}
"locked" => {
if let Some(wt) = current.as_mut() {
wt.is_locked = true;
}
}
"prunable" => {
if let Some(wt) = current.as_mut() {
wt.is_prunable = true;
}
}
_ => {}
}
}
if let Some(wt) = current.take() {
result.push(wt);
}
if let Some(first) = result.first_mut() {
first.is_main = true;
}
result
}
fn strip_branch_ref(reference: &str) -> String {
reference
.strip_prefix("refs/heads/")
.unwrap_or(reference)
.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SubmoduleStatus {
pub(crate) state: char,
pub(crate) path: String,
}
impl SubmoduleStatus {
pub(crate) fn is_uninitialized(&self) -> bool {
self.state == '-'
}
}
pub(crate) fn parse_submodule_status(output: &str) -> Vec<SubmoduleStatus> {
let mut result = Vec::new();
for line in output.lines() {
let mut chars = line.chars();
let Some(state) = chars.next() else {
continue;
};
let rest = chars.as_str();
let Some((_sha, after_sha)) = rest.split_once(' ') else {
continue;
};
let path = match after_sha.rfind(" (") {
Some(i) => &after_sha[..i],
None => after_sha,
}
.trim();
if path.is_empty() {
continue;
}
result.push(SubmoduleStatus {
state,
path: path.to_string(),
});
}
result
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Submodule {
pub(crate) name: String,
pub(crate) path: String,
}
pub(crate) fn parse_gitmodules(output: &str) -> Vec<Submodule> {
let mut subs: Vec<Submodule> = Vec::new();
for entry in output.split('\0') {
if entry.is_empty() {
continue;
}
let Some((key, value)) = entry.split_once('\n') else {
continue;
};
let Some(rest) = key.strip_prefix("submodule.") else {
continue;
};
let Some((name, field)) = rest.rsplit_once('.') else {
continue;
};
if field != "path" || name.is_empty() || value.is_empty() {
continue;
}
match subs.iter_mut().find(|s| s.name == name) {
Some(existing) => existing.path = value.to_string(),
None => subs.push(Submodule {
name: name.to_string(),
path: value.to_string(),
}),
}
}
subs
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_main_and_linked() {
let input = "worktree /repo\nHEAD aaa111\nbranch refs/heads/main\n\
\n\
worktree /repo.worktrees/feat\nHEAD bbb222\nbranch refs/heads/feature/x\n\n";
let wts = parse_worktree_list(input);
assert_eq!(wts.len(), 2);
assert_eq!(wts[0].path, PathBuf::from("/repo"));
assert_eq!(wts[0].branch.as_deref(), Some("main"));
assert_eq!(wts[0].head.as_deref(), Some("aaa111"));
assert!(wts[0].is_main);
assert_eq!(wts[1].path, PathBuf::from("/repo.worktrees/feat"));
assert_eq!(wts[1].branch.as_deref(), Some("feature/x"));
assert!(!wts[1].is_main);
}
#[test]
fn parses_detached_and_bare_and_locked_and_prunable() {
let input = "worktree /bare\nbare\n\
\n\
worktree /d\nHEAD ccc333\ndetached\n\
\n\
worktree /l\nHEAD ddd\nbranch refs/heads/x\nlocked being used\n\
\n\
worktree /p\nHEAD eee\nbranch refs/heads/y\nprunable gitdir gone\n\n";
let wts = parse_worktree_list(input);
assert_eq!(wts.len(), 4);
assert!(wts[0].is_bare && wts[0].is_main);
assert!(wts[0].branch.is_none() && wts[0].head.is_none());
assert!(wts[1].is_detached);
assert!(wts[1].branch.is_none());
assert!(wts[2].is_locked);
assert_eq!(wts[2].branch.as_deref(), Some("x"));
assert!(wts[3].is_prunable);
}
#[test]
fn handles_trailing_record_without_blank_line() {
let input = "worktree /only\nHEAD f00\nbranch refs/heads/main";
let wts = parse_worktree_list(input);
assert_eq!(wts.len(), 1);
assert_eq!(wts[0].branch.as_deref(), Some("main"));
}
#[test]
fn handles_paths_with_spaces() {
let input = "worktree /my repo/wt\nHEAD a1\nbranch refs/heads/main\n";
let wts = parse_worktree_list(input);
assert_eq!(wts[0].path, PathBuf::from("/my repo/wt"));
}
#[test]
fn empty_input_yields_no_worktrees() {
assert!(parse_worktree_list("").is_empty());
}
#[test]
fn parses_submodule_status_markers() {
let input = "-aaa111 libs/uninit\n cccddd libs/ok (heads/main)\n\
+bbb222 vendor/drift (v1.2-3-gabcdef)\nUeee444 vendor/conflict\n";
let subs = parse_submodule_status(input);
assert_eq!(subs.len(), 4);
assert_eq!(subs[0].state, '-');
assert_eq!(subs[0].path, "libs/uninit");
assert!(subs[0].is_uninitialized());
assert_eq!(subs[1].state, ' ');
assert_eq!(subs[1].path, "libs/ok");
assert!(!subs[1].is_uninitialized());
assert_eq!(subs[2].state, '+');
assert_eq!(subs[2].path, "vendor/drift");
assert_eq!(subs[3].state, 'U');
assert_eq!(subs[3].path, "vendor/conflict");
}
#[test]
fn submodule_status_keeps_paths_with_spaces() {
let subs = parse_submodule_status("-deadbeef my libs/sub\n");
assert_eq!(subs.len(), 1);
assert_eq!(subs[0].path, "my libs/sub");
}
#[test]
fn submodule_status_skips_unparseable_lines() {
assert!(parse_submodule_status("").is_empty());
assert!(parse_submodule_status("-\n").is_empty());
assert!(parse_submodule_status("-onlysha\n").is_empty());
}
#[test]
fn gitmodules_pairs_names_with_paths_in_declaration_order() {
let out = "submodule.vendor/A.path\nvendor/A\0submodule.vendor/A.url\n../a\0\
submodule.vendor/B.path\nvendor/B\0submodule.vendor/B.url\n../b\0";
let subs = parse_gitmodules(out);
assert_eq!(subs.len(), 2);
assert_eq!(subs[0].name, "vendor/A");
assert_eq!(subs[0].path, "vendor/A");
assert_eq!(subs[1].name, "vendor/B");
}
#[test]
fn gitmodules_keeps_dots_inside_submodule_names() {
let subs = parse_gitmodules("submodule.libs.core.v2.path\nlibs/core\0");
assert_eq!(subs.len(), 1);
assert_eq!(subs[0].name, "libs.core.v2");
assert_eq!(subs[0].path, "libs/core");
}
#[test]
fn gitmodules_skips_entries_without_a_usable_path() {
let out = "submodule.only.url\n../x\0submodule.bare.path\0core.bare\nfalse\0\
submodule.blank.path\n\0submodule\nx\0";
assert!(parse_gitmodules(out).is_empty());
}
#[test]
fn gitmodules_last_duplicate_path_wins() {
let subs = parse_gitmodules("submodule.a.path\nold\0submodule.a.path\nnew\0");
assert_eq!(subs.len(), 1);
assert_eq!(subs[0].path, "new");
}
}