use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum ChangeArea {
Conflict,
Staged,
Unstaged,
}
impl ChangeArea {
pub const fn label(self) -> &'static str {
match self {
Self::Conflict => "Merge Changes",
Self::Staged => "Staged Changes",
Self::Unstaged => "Changes",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ChangeStatus {
Added,
Modified,
Deleted,
Renamed,
Copied,
TypeChanged,
Untracked,
Conflicted,
}
impl ChangeStatus {
pub const fn code(self) -> &'static str {
match self {
Self::Added => "A",
Self::Modified => "M",
Self::Deleted => "D",
Self::Renamed => "R",
Self::Copied => "C",
Self::TypeChanged => "T",
Self::Untracked => "U",
Self::Conflicted => "!",
}
}
pub const fn label(self) -> &'static str {
match self {
Self::Added => "Added",
Self::Modified => "Modified",
Self::Deleted => "Deleted",
Self::Renamed => "Renamed",
Self::Copied => "Copied",
Self::TypeChanged => "Type changed",
Self::Untracked => "Untracked",
Self::Conflicted => "Conflict",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Change {
pub path: PathBuf,
pub original_path: Option<PathBuf>,
pub area: ChangeArea,
pub status: ChangeStatus,
}
impl Change {
pub fn display_path(&self) -> String {
self.path.to_string_lossy().into_owned()
}
pub fn file_name(&self) -> String {
self.path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| self.display_path())
}
pub fn parent_path(&self) -> String {
self.path
.parent()
.filter(|path| !path.as_os_str().is_empty())
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_default()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BranchState {
pub head: String,
pub oid: Option<String>,
pub upstream: Option<String>,
pub ahead: usize,
pub behind: usize,
pub detached: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RepoStatus {
pub branch: BranchState,
pub changes: Vec<Change>,
}
impl RepoStatus {
pub fn staged_count(&self) -> usize {
self.changes
.iter()
.filter(|change| change.area == ChangeArea::Staged)
.count()
}
}
pub fn parse_porcelain_v2(output: &[u8]) -> RepoStatus {
let mut status = RepoStatus::default();
let records: Vec<&[u8]> = output.split(|byte| *byte == 0).collect();
let mut index = 0;
while index < records.len() {
let record = records[index];
index += 1;
if record.is_empty() {
continue;
}
if record.starts_with(b"# ") {
parse_branch_header(record, &mut status.branch);
continue;
}
match record[0] {
b'1' => parse_ordinary(record, &mut status.changes),
b'2' => {
let original_path = records.get(index).copied().unwrap_or_default();
index += usize::from(index < records.len());
parse_renamed(record, original_path, &mut status.changes);
}
b'u' => parse_unmerged(record, &mut status.changes),
b'?' => {
if let Some(path) = record.get(2..) {
status.changes.push(Change {
path: bytes_to_path(path),
original_path: None,
area: ChangeArea::Unstaged,
status: ChangeStatus::Untracked,
});
}
}
_ => {}
}
}
status.changes.sort_by(|left, right| {
left.area
.cmp(&right.area)
.then_with(|| left.display_path().cmp(&right.display_path()))
});
status
}
fn parse_branch_header(record: &[u8], branch: &mut BranchState) {
let line = String::from_utf8_lossy(record);
if let Some(value) = line.strip_prefix("# branch.oid ") {
if value != "(initial)" {
branch.oid = Some(value.to_owned());
}
} else if let Some(value) = line.strip_prefix("# branch.head ") {
branch.detached = value == "(detached)";
branch.head = if branch.detached {
branch
.oid
.as_deref()
.map(|oid| oid.chars().take(8).collect())
.unwrap_or_else(|| "detached".to_owned())
} else {
value.to_owned()
};
} else if let Some(value) = line.strip_prefix("# branch.upstream ") {
branch.upstream = Some(value.to_owned());
} else if let Some(value) = line.strip_prefix("# branch.ab ") {
for part in value.split_ascii_whitespace() {
if let Some(ahead) = part.strip_prefix('+') {
branch.ahead = ahead.parse().unwrap_or_default();
} else if let Some(behind) = part.strip_prefix('-') {
branch.behind = behind.parse().unwrap_or_default();
}
}
}
}
fn parse_ordinary(record: &[u8], changes: &mut Vec<Change>) {
let fields = splitn_bytes(record, b' ', 9);
if fields.len() != 9 || fields[1].len() < 2 {
return;
}
push_xy_changes(fields[1], fields[8], None, changes);
}
fn parse_renamed(record: &[u8], original_path: &[u8], changes: &mut Vec<Change>) {
let fields = splitn_bytes(record, b' ', 10);
if fields.len() != 10 || fields[1].len() < 2 {
return;
}
push_xy_changes(fields[1], fields[9], Some(original_path), changes);
}
fn parse_unmerged(record: &[u8], changes: &mut Vec<Change>) {
let fields = splitn_bytes(record, b' ', 11);
if fields.len() != 11 {
return;
}
changes.push(Change {
path: bytes_to_path(fields[10]),
original_path: None,
area: ChangeArea::Conflict,
status: ChangeStatus::Conflicted,
});
}
fn push_xy_changes(
xy: &[u8],
path: &[u8],
original_path: Option<&[u8]>,
changes: &mut Vec<Change>,
) {
let x = xy[0];
let y = xy[1];
let path = bytes_to_path(path);
let original_path = original_path
.filter(|value| !value.is_empty())
.map(bytes_to_path);
if x != b'.' {
changes.push(Change {
path: path.clone(),
original_path: original_path.clone(),
area: ChangeArea::Staged,
status: status_from_code(x),
});
}
if y != b'.' {
changes.push(Change {
path,
original_path,
area: ChangeArea::Unstaged,
status: status_from_code(y),
});
}
}
fn status_from_code(code: u8) -> ChangeStatus {
match code {
b'A' => ChangeStatus::Added,
b'D' => ChangeStatus::Deleted,
b'R' => ChangeStatus::Renamed,
b'C' => ChangeStatus::Copied,
b'T' => ChangeStatus::TypeChanged,
b'U' => ChangeStatus::Conflicted,
_ => ChangeStatus::Modified,
}
}
fn splitn_bytes(input: &[u8], separator: u8, count: usize) -> Vec<&[u8]> {
input.splitn(count, |byte| *byte == separator).collect()
}
fn bytes_to_path(bytes: &[u8]) -> PathBuf {
PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
#[test]
fn parses_branch_and_all_change_groups() {
let input = b"# branch.oid 0123456789abcdef\x00# branch.head feature/live\x00# branch.upstream origin/feature/live\x00# branch.ab +2 -3\x001 M. N... 100644 100644 100644 aaaaaaa bbbbbbb src/staged.rs\x001 .M N... 100644 100644 100644 aaaaaaa aaaaaaa src/live.rs\x001 MM N... 100644 100644 100644 aaaaaaa bbbbbbb src/both.rs\x00? notes with spaces.txt\x00u UU N... 100644 100644 100644 100644 aaaaaaa bbbbbbb ccccccc conflict.rs\x00";
let status = parse_porcelain_v2(input);
assert_eq!(status.branch.head, "feature/live");
assert_eq!(
status.branch.upstream.as_deref(),
Some("origin/feature/live")
);
assert_eq!((status.branch.ahead, status.branch.behind), (2, 3));
assert_eq!(status.changes.len(), 6);
assert_eq!(status.staged_count(), 2);
assert_eq!(
status
.changes
.iter()
.filter(|change| change.area == ChangeArea::Conflict)
.count(),
1
);
assert!(status.changes.iter().any(|change| {
change.path == Path::new("notes with spaces.txt")
&& change.status == ChangeStatus::Untracked
}));
}
#[test]
fn parses_rename_record_and_original_path() {
let input =
b"2 R. N... 100644 100644 100644 aaaaaaa bbbbbbb R100 new name.rs\0old name.rs\0";
let status = parse_porcelain_v2(input);
assert_eq!(status.changes.len(), 1);
assert_eq!(status.changes[0].path, PathBuf::from("new name.rs"));
assert_eq!(
status.changes[0].original_path,
Some(PathBuf::from("old name.rs"))
);
assert_eq!(status.changes[0].status, ChangeStatus::Renamed);
}
}