use std::path::PathBuf;
use super::{GitError, WorktreeInfo, finalize_worktree};
impl WorktreeInfo {
pub(crate) fn parse_porcelain_list(output: &str) -> anyhow::Result<Vec<Self>> {
let mut worktrees = Vec::new();
let mut current: Option<WorktreeInfo> = None;
for line in output.lines() {
if line.is_empty() {
if let Some(wt) = current.take() {
worktrees.push(finalize_worktree(wt));
}
continue;
}
let (key, value) = match line.split_once(' ') {
Some((k, v)) => (k, Some(v)),
None => (line, None),
};
match key {
"worktree" => {
let Some(path) = value else {
return Err(GitError::ParseError {
message: "worktree line missing path".into(),
}
.into());
};
current = Some(WorktreeInfo {
path: PathBuf::from(path),
head: String::new(),
branch: None,
bare: false,
detached: false,
locked: None,
prunable: None,
});
}
key => match (key, current.as_mut()) {
("HEAD", Some(wt)) => {
let Some(sha) = value else {
return Err(GitError::ParseError {
message: "HEAD line missing SHA".into(),
}
.into());
};
wt.head = sha.to_string();
}
("branch", Some(wt)) => {
let Some(branch_ref) = value else {
return Err(GitError::ParseError {
message: "branch line missing ref".into(),
}
.into());
};
let branch = branch_ref
.strip_prefix("refs/heads/")
.unwrap_or(branch_ref)
.to_string();
wt.branch = Some(branch);
}
("bare", Some(wt)) => {
wt.bare = true;
}
("detached", Some(wt)) => {
wt.detached = true;
}
("locked", Some(wt)) => {
wt.locked = Some(value.unwrap_or_default().to_string());
}
("prunable", Some(wt)) => {
wt.prunable = Some(value.unwrap_or_default().to_string());
}
_ => {
}
},
}
}
if let Some(wt) = current {
worktrees.push(finalize_worktree(wt));
}
Ok(worktrees)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DefaultBranchName(String);
impl DefaultBranchName {
pub(crate) fn from_local(remote: &str, output: &str) -> anyhow::Result<Self> {
let trimmed = output.trim();
let prefix = format!("{}/", remote);
let branch = trimmed.strip_prefix(&prefix).unwrap_or(trimmed);
if branch.is_empty() {
return Err(GitError::ParseError {
message: format!("Empty branch name from {}/HEAD", remote),
}
.into());
}
Ok(Self(branch.to_string()))
}
pub(crate) fn from_remote(output: &str) -> anyhow::Result<Self> {
output
.lines()
.find_map(|line| {
line.strip_prefix("ref: ")
.and_then(|symref| symref.split_once('\t'))
.map(|(ref_path, _)| ref_path)
.and_then(|ref_path| ref_path.strip_prefix("refs/heads/"))
.map(|branch| branch.to_string())
})
.map(Self)
.ok_or_else(|| {
GitError::ParseError {
message: "Could not find symbolic ref in ls-remote output".into(),
}
.into()
})
}
pub(crate) fn into_string(self) -> String {
self.0
}
}
pub fn parse_porcelain_z(output: &str) -> Vec<String> {
let mut files = Vec::new();
let mut entries = output.split('\0').filter(|s| !s.is_empty());
while let Some(entry) = entries.next() {
if entry.len() < 3 {
continue;
}
let status = &entry[0..2];
let path = &entry[3..];
files.push(path.to_string());
if (status.starts_with('R') || status.starts_with('C'))
&& let Some(old_path) = entries.next()
{
files.push(old_path.to_string());
}
}
files
}
pub fn parse_untracked_files(status_output: &str) -> Vec<String> {
let mut files = Vec::new();
let mut entries = status_output.split('\0').filter(|s| !s.is_empty());
while let Some(entry) = entries.next() {
if entry.len() < 3 {
continue;
}
let status = &entry[0..2];
let path = &entry[3..];
if status == "??" {
files.push(path.to_string());
}
if status.starts_with('R') || status.starts_with('C') {
entries.next();
}
}
files
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_from_local_simple() {
let result = DefaultBranchName::from_local("origin", "main");
assert!(result.is_ok());
assert_eq!(result.unwrap().into_string(), "main");
}
#[test]
fn test_from_local_with_remote_prefix() {
let result = DefaultBranchName::from_local("origin", "origin/main");
assert!(result.is_ok());
assert_eq!(result.unwrap().into_string(), "main");
}
#[test]
fn test_from_local_with_whitespace() {
let result = DefaultBranchName::from_local("origin", " main \n");
assert!(result.is_ok());
assert_eq!(result.unwrap().into_string(), "main");
}
#[test]
fn test_from_local_empty() {
let result = DefaultBranchName::from_local("origin", "");
assert!(result.is_err());
}
#[test]
fn test_from_local_only_whitespace() {
let result = DefaultBranchName::from_local("origin", " \n ");
assert!(result.is_err());
}
#[test]
fn test_from_local_different_remote() {
let result = DefaultBranchName::from_local("upstream", "upstream/develop");
assert!(result.is_ok());
assert_eq!(result.unwrap().into_string(), "develop");
}
#[test]
fn test_from_remote_standard() {
let output = "ref: refs/heads/main\tHEAD\n";
let result = DefaultBranchName::from_remote(output);
assert!(result.is_ok());
assert_eq!(result.unwrap().into_string(), "main");
}
#[test]
fn test_from_remote_master() {
let output = "ref: refs/heads/master\tHEAD\n";
let result = DefaultBranchName::from_remote(output);
assert!(result.is_ok());
assert_eq!(result.unwrap().into_string(), "master");
}
#[test]
fn test_from_remote_with_other_lines() {
let output = "abc123\tHEAD\nref: refs/heads/develop\tHEAD\ndef456\trefs/heads/main\n";
let result = DefaultBranchName::from_remote(output);
assert!(result.is_ok());
assert_eq!(result.unwrap().into_string(), "develop");
}
#[test]
fn test_from_remote_no_ref() {
let output = "abc123\tHEAD\n";
let result = DefaultBranchName::from_remote(output);
assert!(result.is_err());
}
#[test]
fn test_from_remote_empty() {
let result = DefaultBranchName::from_remote("");
assert!(result.is_err());
}
#[test]
fn test_parse_porcelain_list_single_worktree() {
let output = "worktree /path/to/repo\nHEAD abc123\nbranch refs/heads/main\n\n";
let worktrees = WorktreeInfo::parse_porcelain_list(output).unwrap();
let [wt]: [WorktreeInfo; 1] = worktrees.try_into().unwrap();
assert_eq!(wt.path.to_str().unwrap(), "/path/to/repo");
assert_eq!(wt.head, "abc123");
assert_eq!(wt.branch, Some("main".to_string()));
}
#[test]
fn test_parse_porcelain_list_multiple_worktrees() {
let output = "worktree /path/main\nHEAD aaa\nbranch refs/heads/main\n\nworktree /path/feature\nHEAD bbb\nbranch refs/heads/feature\n\n";
let worktrees = WorktreeInfo::parse_porcelain_list(output).unwrap();
let [main_wt, feature_wt]: [WorktreeInfo; 2] = worktrees.try_into().unwrap();
assert_eq!(main_wt.branch, Some("main".to_string()));
assert_eq!(feature_wt.branch, Some("feature".to_string()));
}
#[test]
fn test_parse_porcelain_list_bare_repo() {
let output = "worktree /path/to/repo.git\nHEAD abc123\nbare\n\n";
let worktrees = WorktreeInfo::parse_porcelain_list(output).unwrap();
let [wt]: [WorktreeInfo; 1] = worktrees.try_into().unwrap();
assert!(wt.bare);
}
#[test]
fn test_parse_porcelain_list_detached() {
let output = "worktree /path/to/repo\nHEAD abc123\ndetached\n\n";
let worktrees = WorktreeInfo::parse_porcelain_list(output).unwrap();
let [wt]: [WorktreeInfo; 1] = worktrees.try_into().unwrap();
assert!(wt.detached);
assert!(wt.branch.is_none());
}
#[test]
fn test_parse_porcelain_list_locked() {
let output = "worktree /path/to/repo\nHEAD abc123\nbranch refs/heads/main\nlocked reason for lock\n\n";
let worktrees = WorktreeInfo::parse_porcelain_list(output).unwrap();
let [wt]: [WorktreeInfo; 1] = worktrees.try_into().unwrap();
assert_eq!(wt.locked, Some("reason for lock".to_string()));
}
#[test]
fn test_parse_porcelain_list_prunable() {
let output = "worktree /path/to/repo\nHEAD abc123\nbranch refs/heads/main\nprunable gitdir file missing\n\n";
let worktrees = WorktreeInfo::parse_porcelain_list(output).unwrap();
let [wt]: [WorktreeInfo; 1] = worktrees.try_into().unwrap();
assert_eq!(wt.prunable, Some("gitdir file missing".to_string()));
}
#[test]
fn test_parse_porcelain_list_empty() {
let result = WorktreeInfo::parse_porcelain_list("");
assert!(result.is_ok());
let worktrees = result.unwrap();
assert!(worktrees.is_empty());
}
#[test]
fn test_parse_porcelain_list_no_trailing_blank() {
let output = "worktree /path/to/repo\nHEAD abc123\nbranch refs/heads/main";
let result = WorktreeInfo::parse_porcelain_list(output);
assert!(result.is_ok());
let worktrees = result.unwrap();
assert_eq!(worktrees.len(), 1);
}
#[test]
fn test_parse_porcelain_list_missing_worktree_path() {
let output = "worktree\nHEAD abc123\n\n";
let result = WorktreeInfo::parse_porcelain_list(output);
assert!(result.is_err());
}
#[test]
fn test_parse_porcelain_list_missing_head_sha() {
let output = "worktree /path\nHEAD\n\n";
let result = WorktreeInfo::parse_porcelain_list(output);
assert!(result.is_err());
}
#[test]
fn test_parse_porcelain_list_branch_without_refs_prefix() {
let output = "worktree /path/to/repo\nHEAD abc123\nbranch main\n\n";
let worktrees = WorktreeInfo::parse_porcelain_list(output).unwrap();
let [wt]: [WorktreeInfo; 1] = worktrees.try_into().unwrap();
assert_eq!(wt.branch, Some("main".to_string()));
}
#[test]
fn test_parse_porcelain_z_empty() {
assert!(parse_porcelain_z("").is_empty());
}
#[test]
fn test_parse_porcelain_z_modified_file() {
let output = " M src/main.rs\0";
let files = parse_porcelain_z(output);
assert_eq!(files, vec!["src/main.rs"]);
}
#[test]
fn test_parse_porcelain_z_multiple_files() {
let output = " M src/main.rs\0?? new_file.txt\0";
let files = parse_porcelain_z(output);
assert_eq!(files, vec!["src/main.rs", "new_file.txt"]);
}
#[test]
fn test_parse_porcelain_z_rename() {
let output = "R new_name.rs\0old_name.rs\0";
let files = parse_porcelain_z(output);
assert_eq!(files, vec!["new_name.rs", "old_name.rs"]);
}
#[test]
fn test_parse_porcelain_z_copy() {
let output = "C copy.rs\0original.rs\0";
let files = parse_porcelain_z(output);
assert_eq!(files, vec!["copy.rs", "original.rs"]);
}
#[test]
fn test_parse_porcelain_z_rename_among_others() {
let output = " M keep.rs\0R new.rs\0old.rs\0?? untracked.txt\0";
let files = parse_porcelain_z(output);
assert_eq!(files, vec!["keep.rs", "new.rs", "old.rs", "untracked.txt"]);
}
#[test]
fn test_parse_porcelain_z_spaces_in_path() {
let output = " M path with spaces/file name.rs\0";
let files = parse_porcelain_z(output);
assert_eq!(files, vec!["path with spaces/file name.rs"]);
}
#[test]
fn test_parse_porcelain_z_skips_short_entries() {
let output = " M valid.rs\0ab\0";
let files = parse_porcelain_z(output);
assert_eq!(files, vec!["valid.rs"]);
}
#[test]
fn test_parse_untracked_files_empty() {
assert!(parse_untracked_files("").is_empty());
}
#[test]
fn test_parse_untracked_files_only_untracked() {
let output = "?? new_file.txt\0?? another.rs\0";
let files = parse_untracked_files(output);
assert_eq!(files, vec!["new_file.txt", "another.rs"]);
}
#[test]
fn test_parse_untracked_files_filters_tracked() {
let output = " M modified.rs\0?? untracked.txt\0A added.rs\0";
let files = parse_untracked_files(output);
assert_eq!(files, vec!["untracked.txt"]);
}
#[test]
fn test_parse_untracked_files_skips_rename_old_path() {
let output = "R new.rs\0old.rs\0?? untracked.txt\0";
let files = parse_untracked_files(output);
assert_eq!(files, vec!["untracked.txt"]);
}
#[test]
fn test_parse_untracked_files_no_untracked() {
let output = " M modified.rs\0A added.rs\0";
let files = parse_untracked_files(output);
assert!(files.is_empty());
}
#[test]
fn test_parse_untracked_files_spaces_in_path() {
let output = "?? path with spaces/new file.txt\0";
let files = parse_untracked_files(output);
assert_eq!(files, vec!["path with spaces/new file.txt"]);
}
}