//! 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 and : // 1 // 2 NUL // u

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, 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 { 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::>(); let had_files = !listed.is_empty(); let files = listed.into_iter().filter(|file| !is_internal_path(&file.path)).collect::>(); // 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 { 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 { 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::>() .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::>(); 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) ); } } }