//! Parsing what git prints for the status panel and the version timeline. use super::is_internal_path;use crate::git::{GitFileStatus, GitLogEntry, GitLogFile, GitStatus}; /// Parse `git status --porcelain=v2 -z` output into the status of a readable/// repository, remotes left for the caller (so `""` parses to one with nothing/// to report). Records are NUL-terminated; a rename/copy (`2`) record's/// *original* path follows as its own NUL-terminated token, and paths are/// never quoted in `-z` mode.////// Branch and upstream tracking stay empty: nothing in the app has read them/// since the commit/push panel went.pub(super) fn parse_porcelain_v2(raw: &str) -> GitStatus { let mut result = GitStatus { available: true, repository: true, branch: None, remote: None, remote_url: None, upstream: None, ahead: 0, behind: 0, files: Vec::new(), }; let mut tokens = raw.split('\0'); while let Some(token) = tokens.next() { let Some((kind, rest)) = token.split_once(' ') else { continue; }; // Fields between <XY> and <path>: // 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path> // 2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path> NUL <origPath> // u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path> let skipped = match kind { "1" => 6, "2" => 7, "u" => 8, "?" => { push_v2_entry(&mut result.files, "??", rest); continue; } _ => continue, }; let mut fields = rest.splitn(skipped + 2, ' '); let xy = fields.next().unwrap_or(""); let path = fields.nth(skipped).unwrap_or(""); if kind == "2" { let _original = tokens.next(); } push_v2_entry(&mut result.files, xy, path); } result} fn push_v2_entry(files: &mut Vec<GitFileStatus>, xy: &str, path: &str) { if path.is_empty() { return; } let mut states = xy.chars(); // v2 marks "unchanged" with '.'; classify_status and the staged/unstaged // flags below speak the v1 dialect where that position is a space. let mut next_state = || match states.next() { Some('.') | None => ' ', Some(state) => state, }; let (index, worktree) = (next_state(), next_state()); files.push(GitFileStatus { path: path.replace('\\', "/"), status: classify_status(index, worktree), staged: index != ' ' && index != '?', unstaged: worktree != ' ' || index == '?', });} fn classify_status(index: char, worktree: char) -> String { let code = if worktree == 'U' || index == 'U' || (index == 'A' && worktree == 'A') { 'U' } else if worktree == '?' { '?' } else if worktree != ' ' { worktree } else { index }; match code { 'A' => "added", 'D' => "deleted", 'R' => "renamed", 'C' => "copied", 'U' => "conflict", '?' => "untracked", _ => "modified", } .to_string()} /// Parse `git log --name-status` with `%x1e`-separated records whose header/// fields (hash, short hash, author, ISO date, subject) are `%x1f`-separated.pub(super) fn parse_log(raw: &str) -> Vec<GitLogEntry> { let entry = |record: &str| { let mut lines = record.trim_matches('\n').lines(); let mut header = lines.next()?.splitn(5, '\u{1f}'); let mut field = || header.next().unwrap_or("").to_string(); let (hash, short_hash, author_name, timestamp, message) = (field(), field(), field(), field(), field()); if hash.is_empty() { return None; } let listed = lines.filter_map(parse_name_status_line).collect::<Vec<_>>(); let had_files = !listed.is_empty(); let files = listed.into_iter().filter(|file| !is_internal_path(&file.path)).collect::<Vec<_>>(); // A version whose every change was Lattice's own state is not a // version of the user's work. Projects from before `.research/` was // ignored have a long run of these, one per Overleaf sync. if had_files && files.is_empty() { return None; } Some(GitLogEntry { hash, short_hash, author_name, timestamp, message, files }) }; raw.split('\u{1e}').filter_map(entry).collect()} fn parse_name_status_line(line: &str) -> Option<GitLogFile> { let mut parts = line.trim_end().split('\t'); let code = parts.next()?.chars().next()?; // Renames and copies list old then new; report the new path so the // timeline points at the file that exists in that commit. if matches!(code, 'R' | 'C') { parts.next()?; } let path = parts.next()?; let kind = match code { 'A' | 'C' => "added", 'D' => "deleted", 'R' => "renamed", _ => "modified", }; Some(GitLogFile { path: path.replace('\\', "/"), kind: kind.to_string(), old_path: None })} /// Parse `git diff --name-status -z -M`: a status token, then the path, NUL/// separated; a rename or copy lists its old path before the new one. Lattice's/// own state is left out, as in the log.pub(super) fn parse_name_status_z(raw: &str) -> Vec<GitLogFile> { let mut files = Vec::new(); let mut tokens = raw.split('\0').filter(|token| !token.is_empty()); while let Some(status) = tokens.next() { let Some(code) = status.chars().next() else { continue }; let old_path = if matches!(code, 'R' | 'C') { tokens.next().map(|path| path.replace('\\', "/")) } else { None }; let Some(path) = tokens.next() else { break }; let path = path.replace('\\', "/"); if is_internal_path(&path) { continue; } let kind = match code { 'A' | 'C' => "added", 'D' => "deleted", 'R' => "renamed", _ => "modified", }; files.push(GitLogFile { path, kind: kind.to_string(), old_path }); } files} #[cfg(test)]mod tests { use super::*; #[test] fn parses_name_status_between_two_versions() { let raw = ["M", "main.tex", "R087", "old.tex", "new.tex", "A", "fig.pdf", "D", "gone.tex"] .into_iter() .chain(["M", ".research/overleaf.json", ""]) .collect::<Vec<_>>() .join("\0"); let files = parse_name_status_z(&raw) .into_iter() .map(|file| { ( file.old_path .map_or(file.path.clone(), |old| format!("{old} -> {}", file.path)), file.kind, ) }) .collect::<Vec<_>>(); let expected = [ ("main.tex", "modified"), ("old.tex -> new.tex", "renamed"), ("fig.pdf", "added"), ("gone.tex", "deleted"), ] .map(|(path, kind)| (path.to_string(), kind.to_string())); assert_eq!(files, expected); } #[test] fn parses_porcelain_v2_status() { assert!(parse_porcelain_v2("").files.is_empty()); let raw = [ "1 .M N... 100644 100644 100644 abc def main.tex", "1 A. N... 000000 100644 100644 000 def sections/intro.tex", "? notes.md", "2 R. N... 100644 100644 100644 abc def R100 new.tex", "old.tex", "u UU N... 100644 100644 100644 100644 a1 b2 c3 conflicted.tex", "1 .M N... 100644 100644 100644 abc def path with spaces.tex", ] .join("\0"); // Renames report the new path; the original follows as its own token // and must not surface as a file of its own. let expected = [ ("main.tex", "modified", false, true), ("sections/intro.tex", "added", true, false), ("notes.md", "untracked", false, true), ("new.tex", "renamed", true, false), ("conflicted.tex", "conflict", true, true), ("path with spaces.tex", "modified", false, true), ]; let files = parse_porcelain_v2(&raw).files; assert_eq!(files.len(), expected.len()); for (file, (path, status, staged, unstaged)) in files.iter().zip(expected) { assert_eq!( (file.path.as_str(), file.status.as_str(), file.staged, file.unstaged), (path, status, staged, unstaged) ); } }}
//! Parsing what git prints for the status panel and the version timeline.
use super::is_internal_path;
use crate::git::{GitFileStatus, GitLogEntry, GitLogFile, GitStatus};
/// Parse `git status --porcelain=v2 -z` output into the status of a readable
/// repository, remotes left for the caller (so `""` parses to one with nothing
/// to report). Records are NUL-terminated; a rename/copy (`2`) record's
/// *original* path follows as its own NUL-terminated token, and paths are
/// never quoted in `-z` mode.
///
/// Branch and upstream tracking stay empty: nothing in the app has read them
/// since the commit/push panel went.
pub(super) fn parse_porcelain_v2(raw: &str) -> GitStatus {
let mut result = GitStatus {
available: true,
repository: true,
branch: None,
remote: None,
remote_url: None,
upstream: None,
ahead: 0,
behind: 0,
files: Vec::new(),
};
let mut tokens = raw.split('\0');
while let Some(token) = tokens.next() {
let Some((kind, rest)) = token.split_once(' ') else {
continue;
};
// Fields between <XY> and <path>:
// 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
// 2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path> NUL <origPath>
// u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>
let skipped = match kind {
"1" => 6,
"2" => 7,
"u" => 8,
"?" => {
push_v2_entry(&mut result.files, "??", rest);
continue;
}
_ => continue,
};
let mut fields = rest.splitn(skipped + 2, ' ');
let xy = fields.next().unwrap_or("");
let path = fields.nth(skipped).unwrap_or("");
if kind == "2" {
let _original = tokens.next();
}
push_v2_entry(&mut result.files, xy, path);
}
result
}
fn push_v2_entry(files: &mut Vec<GitFileStatus>, xy: &str, path: &str) {
if path.is_empty() {
return;
}
let mut states = xy.chars();
// v2 marks "unchanged" with '.'; classify_status and the staged/unstaged
// flags below speak the v1 dialect where that position is a space.
let mut next_state = || match states.next() {
Some('.') | None => ' ',
Some(state) => state,
};
let (index, worktree) = (next_state(), next_state());
files.push(GitFileStatus {
path: path.replace('\\', "/"),
status: classify_status(index, worktree),
staged: index != ' ' && index != '?',
unstaged: worktree != ' ' || index == '?',
});
}
fn classify_status(index: char, worktree: char) -> String {
let code = if worktree == 'U' || index == 'U' || (index == 'A' && worktree == 'A') {
'U'
} else if worktree == '?' {
'?'
} else if worktree != ' ' {
worktree
} else {
index
};
match code {
'A' => "added",
'D' => "deleted",
'R' => "renamed",
'C' => "copied",
'U' => "conflict",
'?' => "untracked",
_ => "modified",
}
.to_string()
}
/// Parse `git log --name-status` with `%x1e`-separated records whose header
/// fields (hash, short hash, author, ISO date, subject) are `%x1f`-separated.
pub(super) fn parse_log(raw: &str) -> Vec<GitLogEntry> {
let entry = |record: &str| {
let mut lines = record.trim_matches('\n').lines();
let mut header = lines.next()?.splitn(5, '\u{1f}');
let mut field = || header.next().unwrap_or("").to_string();
let (hash, short_hash, author_name, timestamp, message) =
(field(), field(), field(), field(), field());
if hash.is_empty() {
return None;
}
let listed = lines.filter_map(parse_name_status_line).collect::<Vec<_>>();
let had_files = !listed.is_empty();
let files =
listed.into_iter().filter(|file| !is_internal_path(&file.path)).collect::<Vec<_>>();
// A version whose every change was Lattice's own state is not a
// version of the user's work. Projects from before `.research/` was
// ignored have a long run of these, one per Overleaf sync.
if had_files && files.is_empty() {
return None;
}
Some(GitLogEntry { hash, short_hash, author_name, timestamp, message, files })
};
raw.split('\u{1e}').filter_map(entry).collect()
}
fn parse_name_status_line(line: &str) -> Option<GitLogFile> {
let mut parts = line.trim_end().split('\t');
let code = parts.next()?.chars().next()?;
// Renames and copies list old then new; report the new path so the
// timeline points at the file that exists in that commit.
if matches!(code, 'R' | 'C') {
parts.next()?;
}
let path = parts.next()?;
let kind = match code {
'A' | 'C' => "added",
'D' => "deleted",
'R' => "renamed",
_ => "modified",
};
Some(GitLogFile { path: path.replace('\\', "/"), kind: kind.to_string(), old_path: None })
}
/// Parse `git diff --name-status -z -M`: a status token, then the path, NUL
/// separated; a rename or copy lists its old path before the new one. Lattice's
/// own state is left out, as in the log.
pub(super) fn parse_name_status_z(raw: &str) -> Vec<GitLogFile> {
let mut files = Vec::new();
let mut tokens = raw.split('\0').filter(|token| !token.is_empty());
while let Some(status) = tokens.next() {
let Some(code) = status.chars().next() else { continue };
let old_path = if matches!(code, 'R' | 'C') {
tokens.next().map(|path| path.replace('\\', "/"))
} else {
None
};
let Some(path) = tokens.next() else { break };
let path = path.replace('\\', "/");
if is_internal_path(&path) {
continue;
}
let kind = match code {
'A' | 'C' => "added",
'D' => "deleted",
'R' => "renamed",
_ => "modified",
};
files.push(GitLogFile { path, kind: kind.to_string(), old_path });
}
files
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_name_status_between_two_versions() {
let raw = ["M", "main.tex", "R087", "old.tex", "new.tex", "A", "fig.pdf", "D", "gone.tex"]
.into_iter()
.chain(["M", ".research/overleaf.json", ""])
.collect::<Vec<_>>()
.join("\0");
let files = parse_name_status_z(&raw)
.into_iter()
.map(|file| {
(
file.old_path
.map_or(file.path.clone(), |old| format!("{old} -> {}", file.path)),
file.kind,
)
})
.collect::<Vec<_>>();
let expected = [
("main.tex", "modified"),
("old.tex -> new.tex", "renamed"),
("fig.pdf", "added"),
("gone.tex", "deleted"),
]
.map(|(path, kind)| (path.to_string(), kind.to_string()));
assert_eq!(files, expected);
}
#[test]
fn parses_porcelain_v2_status() {
assert!(parse_porcelain_v2("").files.is_empty());
let raw = [
"1 .M N... 100644 100644 100644 abc def main.tex",
"1 A. N... 000000 100644 100644 000 def sections/intro.tex",
"? notes.md",
"2 R. N... 100644 100644 100644 abc def R100 new.tex",
"old.tex",
"u UU N... 100644 100644 100644 100644 a1 b2 c3 conflicted.tex",
"1 .M N... 100644 100644 100644 abc def path with spaces.tex",
]
.join("\0");
// Renames report the new path; the original follows as its own token
// and must not surface as a file of its own.
let expected = [
("main.tex", "modified", false, true),
("sections/intro.tex", "added", true, false),
("notes.md", "untracked", false, true),
("new.tex", "renamed", true, false),
("conflicted.tex", "conflict", true, true),
("path with spaces.tex", "modified", false, true),
];
let files = parse_porcelain_v2(&raw).files;
assert_eq!(files.len(), expected.len());
for (file, (path, status, staged, unstaged)) in files.iter().zip(expected) {
assert_eq!(
(file.path.as_str(), file.status.as_str(), file.staged, file.unstaged),
(path, status, staged, unstaged)
);
}
}
}