use crate::repo::WORKTREE_CONFLICT_MESSAGE;
use crate::{CodeLoreError, Result};
pub(super) type ParsedStatus = Vec<StatusEntry>;
#[derive(Debug, PartialEq, Eq)]
pub(super) struct StatusEntry {
pub(super) path: String,
pub(super) rename_from: Option<String>,
}
pub(super) fn parse_porcelain_v2(bytes: &[u8]) -> Result<ParsedStatus> {
let mut entries = Vec::new();
let mut tokens = bytes.split(|b| *b == 0);
while let Some(token) = tokens.next() {
if token.is_empty() {
continue;
}
let record = String::from_utf8_lossy(token);
if record.starts_with("u ") {
return Err(CodeLoreError::Analysis(WORKTREE_CONFLICT_MESSAGE.into()));
}
if let Some(rest) = record.strip_prefix("1 ") {
entries.extend(parse_v2_ordinary(rest));
} else if let Some(rest) = record.strip_prefix("2 ") {
let original = tokens.next().ok_or_else(|| {
CodeLoreError::Repo(
"git status --porcelain=v2: rename record missing its source-path field".into(),
)
})?;
let original = String::from_utf8_lossy(original);
if let Some(parsed) = parse_v2_rename(rest, &original) {
entries.extend(parsed);
}
}
}
Ok(entries)
}
fn is_symlink_or_gitlink_mode(mode: u32) -> bool {
matches!(mode & 0o170_000, 0o120_000 | 0o160_000)
}
fn parse_v2_ordinary(rest: &str) -> Option<StatusEntry> {
let mut fields = rest.splitn(8, ' ');
let mut xy = fields.next()?.chars();
let staged = xy.next()?;
let unstaged = xy.next()?;
let _sub = fields.next()?;
let mode_head = u32::from_str_radix(fields.next()?, 8).ok()?;
let mode_index = u32::from_str_radix(fields.next()?, 8).ok()?;
let _mode_worktree = fields.next()?;
let _hash_head = fields.next()?;
let _hash_index = fields.next()?;
let path = fields.next()?;
let staged_mode = if staged == 'D' { mode_head } else { mode_index };
let staged_contributes = staged != '.' && !is_symlink_or_gitlink_mode(staged_mode);
let unstaged_contributes = unstaged != '.' && !is_symlink_or_gitlink_mode(mode_index);
(staged_contributes || unstaged_contributes).then(|| StatusEntry {
path: path.to_string(),
rename_from: None,
})
}
fn parse_v2_rename(rest: &str, original_path: &str) -> Option<Vec<StatusEntry>> {
let mut fields = rest.splitn(9, ' ');
let _xy = fields.next()?;
let _sub = fields.next()?;
let _mode_head = fields.next()?;
let mode_index = u32::from_str_radix(fields.next()?, 8).ok()?;
let _mode_worktree = fields.next()?;
let _hash_head = fields.next()?;
let _hash_index = fields.next()?;
let score = fields.next()?;
let path = fields.next()?;
if is_symlink_or_gitlink_mode(mode_index) {
return Some(Vec::new());
}
Some(if score.starts_with('R') {
vec![
StatusEntry {
path: path.to_string(),
rename_from: Some(original_path.to_string()),
},
StatusEntry {
path: original_path.to_string(),
rename_from: None,
},
]
} else {
vec![StatusEntry {
path: path.to_string(),
rename_from: None,
}]
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn porcelain_v2_ordinary_records_yield_one_entry_each() {
let raw = "1 .M N... 100644 100644 100644 aaaa bbbb src/unstaged.rs\0\
1 M. N... 100644 100644 100644 aaaa bbbb src/staged.rs\0\
1 MM N... 100644 100644 100644 aaaa bbbb src/both.rs\0";
let entries = parse_porcelain_v2(raw.as_bytes()).expect("parse");
let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
assert_eq!(paths, ["src/unstaged.rs", "src/staged.rs", "src/both.rs"]);
assert!(entries.iter().all(|e| e.rename_from.is_none()));
}
#[test]
fn porcelain_v2_staged_delete_judged_by_head_mode() {
let raw = "1 D. N... 100644 000000 000000 aaaa 0000 gone.rs\0\
1 D. N... 120000 000000 000000 aaaa 0000 gone-link\0";
let entries = parse_porcelain_v2(raw.as_bytes()).expect("parse");
assert_eq!(
entries,
vec![StatusEntry {
path: "gone.rs".to_string(),
rename_from: None,
}],
);
}
#[test]
fn porcelain_v2_filters_symlinks_and_gitlinks() {
let raw = "1 .M N... 120000 120000 120000 aaaa bbbb link\0\
1 .M S.M. 160000 160000 160000 aaaa bbbb vendor/dep\0";
let entries = parse_porcelain_v2(raw.as_bytes()).expect("parse");
assert_eq!(entries, Vec::new());
}
#[test]
fn porcelain_v2_intent_to_add_is_kept() {
let raw = "1 .A N... 000000 000000 100644 0000 0000 ita.rs\0";
let entries = parse_porcelain_v2(raw.as_bytes()).expect("parse");
assert_eq!(
entries,
vec![StatusEntry {
path: "ita.rs".to_string(),
rename_from: None,
}],
);
}
#[test]
fn porcelain_v2_rename_record_reads_two_fields() {
let raw = "2 R. N... 100644 100644 100644 aaaa bbbb R100 new.rs\0old.rs\0\
1 .M N... 100644 100644 100644 aaaa bbbb after.rs\0";
let entries = parse_porcelain_v2(raw.as_bytes()).expect("parse");
assert_eq!(
entries,
vec![
StatusEntry {
path: "new.rs".to_string(),
rename_from: Some("old.rs".to_string()),
},
StatusEntry {
path: "old.rs".to_string(),
rename_from: None,
},
StatusEntry {
path: "after.rs".to_string(),
rename_from: None,
},
],
);
}
#[test]
fn porcelain_v2_unmerged_record_errors() {
let raw = "u UU N... 100644 100644 100644 100644 aaaa bbbb cccc conflicted.rs\0";
let err = parse_porcelain_v2(raw.as_bytes()).expect_err("must error");
match err {
CodeLoreError::Analysis(msg) => {
assert!(
msg.contains("unmerged paths"),
"error must name the unmerged state: {msg}"
);
}
other => panic!("expected CodeLoreError::Analysis, got {other:?}"),
}
}
#[test]
fn porcelain_v2_empty_output_is_no_entries() {
assert_eq!(parse_porcelain_v2(b"").expect("parse"), Vec::new());
}
}