#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Change {
pub change_id: String,
pub commit_id: String,
pub empty: bool,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Bookmark {
pub name: String,
pub target: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct BookmarkRef {
pub name: String,
pub remote: Option<String>,
pub target: String,
pub tracked: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Workspace {
pub name: String,
pub commit: String,
pub bookmarks: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ChangedPath {
pub status: char,
pub path: String,
pub old_path: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct DiffStat {
pub files_changed: usize,
pub insertions: usize,
pub deletions: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ChangeKind {
Added,
Modified,
Deleted,
Renamed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum DiffLine {
Context(String),
Added(String),
Removed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Hunk {
pub old_start: usize,
pub old_lines: usize,
pub new_start: usize,
pub new_lines: usize,
pub section: String,
pub lines: Vec<DiffLine>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct FileDiff {
pub change: ChangeKind,
pub path: String,
pub old_path: Option<String>,
pub hunks: Vec<Hunk>,
pub raw: String,
}
pub(crate) const CHANGE_TEMPLATE: &str = "change_id.short() ++ \"\\t\" ++ commit_id.short() ++ \"\\t\" ++ if(empty, \"true\", \"false\") ++ \"\\t\" ++ description.first_line() ++ \"\\n\"";
pub(crate) const WORKSPACE_TEMPLATE: &str = "name ++ \"\\t\" ++ target.commit_id().short() ++ \"\\t\" ++ target.local_bookmarks().map(|b| b.name()).join(\",\") ++ \"\\n\"";
pub(crate) const BOOKMARKS_TEMPLATE: &str = "local_bookmarks.map(|b| b.name()).join(\",\")";
pub(crate) const BOOKMARK_ALL_TEMPLATE: &str = "name ++ \"\\t\" ++ remote ++ \"\\t\" ++ if(tracked, \"1\", \"0\") ++ \"\\t\" ++ if(normal_target, normal_target.commit_id().short(), \"\") ++ \"\\n\"";
pub(crate) const CONFLICT_TEMPLATE: &str = "if(conflict, \"1\", \"0\")";
pub(crate) const COUNT_TEMPLATE: &str = "commit_id.short() ++ \"\\n\"";
pub(crate) const REACHABLE_BOOKMARKS_TEMPLATE: &str =
"local_bookmarks.map(|b| b.name()).join(\" \") ++ \"\\t\" ++ commit_id.short() ++ \"\\n\"";
pub(crate) fn parse_changes(output: &str) -> Vec<Change> {
output
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
let mut fields = line.splitn(4, '\t');
let change_id = fields.next()?.to_string();
let commit_id = fields.next()?.to_string();
let empty = fields.next()? == "true";
let description = fields.next().unwrap_or("").to_string();
Some(Change {
change_id,
commit_id,
empty,
description,
})
})
.collect()
}
pub(crate) fn parse_bookmarks(output: &str) -> Vec<Bookmark> {
output
.lines()
.filter(|line| !line.is_empty() && !line.starts_with(char::is_whitespace))
.filter_map(|line| {
let (name, rest) = line.split_once(':')?;
let mut tokens = rest.split_whitespace();
let target = tokens
.nth(1)
.or_else(|| rest.split_whitespace().next())
.unwrap_or("")
.to_string();
Some(Bookmark {
name: name.trim().to_string(),
target,
})
})
.collect()
}
pub(crate) fn parse_bookmarks_all(output: &str) -> Vec<BookmarkRef> {
output
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
let mut fields = line.split('\t');
let name = fields.next()?.to_string();
let remote = fields.next().unwrap_or("");
let tracked = fields.next() == Some("1");
let target = fields.next().unwrap_or("").to_string();
Some(BookmarkRef {
name,
remote: (!remote.is_empty()).then(|| remote.to_string()),
target,
tracked,
})
})
.collect()
}
pub(crate) fn parse_reachable_bookmarks(output: &str) -> Vec<Bookmark> {
let mut out = Vec::new();
for line in output.lines().filter(|l| !l.is_empty()) {
let mut fields = line.splitn(2, '\t');
let names = fields.next().unwrap_or("");
let target = fields.next().unwrap_or("");
for name in names.split_whitespace() {
out.push(Bookmark {
name: name.to_string(),
target: target.to_string(),
});
}
}
out
}
pub(crate) fn parse_resolve_list(output: &str) -> Vec<String> {
output
.lines()
.filter_map(|line| {
let path = line.split(" ").next().unwrap_or(line).trim();
(!path.is_empty()).then(|| path.replace('\\', "/"))
})
.collect()
}
pub(crate) fn parse_workspaces(output: &str) -> Vec<Workspace> {
output
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
let mut fields = line.split('\t');
let name = fields.next()?.to_string();
let commit = fields.next().unwrap_or("").to_string();
let bookmarks = fields
.next()
.unwrap_or("")
.split(',')
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
Some(Workspace {
name,
commit,
bookmarks,
})
})
.collect()
}
pub(crate) fn parse_diff_summary(output: &str) -> Vec<ChangedPath> {
let normalize = |p: String| p.replace('\\', "/");
output
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
let mut chars = line.chars();
let status = chars.next()?;
let raw = chars.as_str().strip_prefix(' ')?;
if raw.is_empty() {
return None;
}
let (old_path, path) = if matches!(status, 'R' | 'C') {
let (old, new) = expand_rename(raw);
(Some(normalize(old)), normalize(new))
} else {
(None, normalize(raw.to_string()))
};
Some(ChangedPath {
status,
path,
old_path,
})
})
.collect()
}
fn expand_rename(raw: &str) -> (String, String) {
let plain = || (raw.to_string(), raw.to_string());
let (Some(open), Some(close)) = (raw.find('{'), raw.find('}')) else {
return plain();
};
if open >= close {
return plain();
}
let Some(rel) = raw[open..close].find(" => ") else {
return plain();
};
let arrow = open + rel;
let prefix = &raw[..open];
let left = &raw[open + 1..arrow];
let right = &raw[arrow + 4..close];
let suffix = &raw[close + 1..];
(
format!("{prefix}{left}{suffix}"),
format!("{prefix}{right}{suffix}"),
)
}
pub(crate) fn parse_diff_stat(output: &str) -> DiffStat {
let summary = output
.lines()
.rev()
.find(|line| line.contains("changed"))
.unwrap_or("");
let mut stat = DiffStat::default();
for part in summary.split(',') {
let part = part.trim();
let n = part
.split_whitespace()
.next()
.and_then(|tok| tok.parse().ok())
.unwrap_or(0);
if part.contains("file") {
stat.files_changed = n;
} else if part.contains("insertion") {
stat.insertions = n;
} else if part.contains("deletion") {
stat.deletions = n;
}
}
stat
}
pub fn parse_diff(diff: &str) -> Vec<FileDiff> {
diff_sections(diff).filter_map(parse_section).collect()
}
fn diff_sections(full: &str) -> impl Iterator<Item = &str> {
let mut bounds = Vec::new();
let mut idx = 0;
for line in full.split_inclusive('\n') {
if line.starts_with("diff --git ") {
bounds.push(idx);
}
idx += line.len();
}
let ends = bounds
.iter()
.skip(1)
.copied()
.chain(std::iter::once(full.len()));
bounds
.clone()
.into_iter()
.zip(ends)
.map(move |(s, e)| &full[s..e])
.collect::<Vec<_>>()
.into_iter()
}
fn parse_section(section: &str) -> Option<FileDiff> {
let mut kind = ChangeKind::Modified;
let mut new_path = None;
let mut minus_path = None;
let mut rename_to = None;
let mut rename_from = None;
let mut hunks: Vec<Hunk> = Vec::new();
let mut current: Option<Hunk> = None;
for line in section.lines() {
if let Some(hunk) = parse_hunk_header(line) {
if let Some(done) = current.replace(hunk) {
hunks.push(done);
}
continue;
}
if let Some(hunk) = current.as_mut() {
match line.as_bytes().first() {
Some(b' ') => hunk.lines.push(DiffLine::Context(line[1..].to_string())),
Some(b'+') => hunk.lines.push(DiffLine::Added(line[1..].to_string())),
Some(b'-') => hunk.lines.push(DiffLine::Removed(line[1..].to_string())),
_ => {}
}
continue;
}
if line.starts_with("new file") {
kind = ChangeKind::Added;
} else if line.starts_with("deleted file") {
kind = ChangeKind::Deleted;
} else if let Some(p) = line.strip_prefix("rename to ") {
rename_to = Some(p.trim_end().to_string());
} else if let Some(p) = line.strip_prefix("rename from ") {
rename_from = Some(p.trim_end().to_string());
} else if let Some(p) = line.strip_prefix("+++ b/") {
new_path = Some(p.trim_end().to_string());
} else if let Some(p) = line.strip_prefix("--- a/") {
minus_path = Some(p.trim_end().to_string());
}
}
if let Some(done) = current.take() {
hunks.push(done);
}
let normalize = |p: String| p.replace('\\', "/");
let old_path = if rename_to.is_some() {
kind = ChangeKind::Renamed;
rename_from.map(normalize)
} else {
None
};
let path = rename_to
.or(new_path)
.or(minus_path)
.or_else(|| header_b_path(section))?;
Some(FileDiff {
change: kind,
path: normalize(path),
old_path,
hunks,
raw: section.to_string(),
})
}
fn parse_hunk_header(line: &str) -> Option<Hunk> {
let rest = line.strip_prefix("@@ ")?;
let (ranges, section) = rest.split_once(" @@")?;
let mut parts = ranges.split_whitespace();
let (old_start, old_lines) = parse_hunk_range(parts.next()?.strip_prefix('-')?);
let (new_start, new_lines) = parse_hunk_range(parts.next()?.strip_prefix('+')?);
Some(Hunk {
old_start,
old_lines,
new_start,
new_lines,
section: section.strip_prefix(' ').unwrap_or(section).to_string(),
lines: Vec::new(),
})
}
fn parse_hunk_range(range: &str) -> (usize, usize) {
match range.split_once(',') {
Some((start, count)) => (start.parse().unwrap_or(0), count.parse().unwrap_or(0)),
None => (range.parse().unwrap_or(0), 1),
}
}
fn header_b_path(section: &str) -> Option<String> {
let first = section.lines().next()?;
let s = first.strip_prefix("diff --git ")?;
let idx = s.find(" b/")?;
Some(s[idx + 1..].strip_prefix("b/").unwrap_or("").to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn changes_split_tab_fields() {
let input = "kztuxlro\t38e00654\tfalse\tfeat: stuff\nqpvuntsm\t6ecf997f\ttrue\t\n";
let got = parse_changes(input);
assert_eq!(got.len(), 2);
assert_eq!(
got[0],
Change {
change_id: "kztuxlro".into(),
commit_id: "38e00654".into(),
empty: false,
description: "feat: stuff".into(),
}
);
assert!(got[1].empty);
assert_eq!(got[1].description, "");
}
#[test]
fn changes_keep_tab_in_description() {
let got = parse_changes("kztuxlro\t38e00654\tfalse\tcol1\tcol2\n");
assert_eq!(got.len(), 1);
assert_eq!(got[0].description, "col1\tcol2");
}
#[test]
fn reachable_bookmarks_fan_out_per_name() {
let got = parse_reachable_bookmarks("main feat\tabc123\n\tdef456\n");
assert_eq!(
got,
vec![
Bookmark {
name: "main".into(),
target: "abc123".into()
},
Bookmark {
name: "feat".into(),
target: "abc123".into()
},
]
);
}
#[test]
fn resolve_list_extracts_paths_before_description() {
let got = parse_resolve_list(
"src/a.rs 2-sided conflict\nb.txt 2-sided conflict including 1 deletion\n",
);
assert_eq!(got, vec!["src/a.rs".to_string(), "b.txt".to_string()]);
assert!(parse_resolve_list("").is_empty());
assert_eq!(
parse_resolve_list("sub\\c.txt 2-sided conflict\n"),
vec!["sub/c.txt".to_string()]
);
}
#[test]
fn bookmarks_parse_name_and_commit_and_skip_remotes() {
let input = "main: pzlznprr f5d07685 feat(process): job-backed spawn\n @origin: pzlznprr f5d07685 feat(process)\nfeature: abcd1234 deadbeef wip\n";
let got = parse_bookmarks(input);
assert_eq!(
got,
vec![
Bookmark {
name: "main".into(),
target: "f5d07685".into()
},
Bookmark {
name: "feature".into(),
target: "deadbeef".into()
},
]
);
}
#[test]
fn workspaces_split_tab_fields_and_bookmarks() {
let input = "default\te2aa3420\tmain,feature\nws1\t12345678\t\n";
let got = parse_workspaces(input);
assert_eq!(got.len(), 2);
assert_eq!(
got[0],
Workspace {
name: "default".into(),
commit: "e2aa3420".into(),
bookmarks: vec!["main".into(), "feature".into()],
}
);
assert!(got[1].bookmarks.is_empty());
}
#[test]
fn diff_summary_splits_status_and_path() {
let got = parse_diff_summary("M src/lib.rs\nA new file.txt\nD gone.rs\n");
assert_eq!(got.len(), 3);
assert_eq!(got[0].status, 'M');
assert_eq!(got[1].path, "new file.txt");
assert!(got[1].old_path.is_none());
assert_eq!(got[2].status, 'D');
}
#[test]
fn diff_summary_expands_rename_and_copy() {
let got =
parse_diff_summary("R {old.rs => new.rs}\nC sub/{a.rs => b.rs}\nM lit{eral}.rs\n");
assert_eq!(got[0].status, 'R');
assert_eq!(got[0].path, "new.rs");
assert_eq!(got[0].old_path.as_deref(), Some("old.rs"));
assert_eq!(got[1].path, "sub/b.rs");
assert_eq!(got[1].old_path.as_deref(), Some("sub/a.rs"));
assert_eq!(got[2].path, "lit{eral}.rs");
assert!(got[2].old_path.is_none());
}
#[test]
fn diff_summary_normalises_backslash_separators() {
let got = parse_diff_summary("M deep\\nested\\f.rs\nR win\\{a.rs => b.rs}\n");
assert_eq!(got[0].path, "deep/nested/f.rs");
assert_eq!(got[1].path, "win/b.rs");
assert_eq!(got[1].old_path.as_deref(), Some("win/a.rs"));
}
#[test]
fn diff_stat_parses_footer_among_per_file_lines() {
let input = "README.md | 10 +++---\n\
src/lib.rs | 4 +-\n\
4 files changed, 157 insertions(+), 137 deletions(-)\n";
assert_eq!(
parse_diff_stat(input),
DiffStat {
files_changed: 4,
insertions: 157,
deletions: 137
}
);
assert_eq!(parse_diff_stat(""), DiffStat::default());
}
#[test]
fn diff_covers_add_modify_delete_rename() {
let full = concat!(
"diff --git a/new b/new\n",
"new file mode 100644\n--- /dev/null\n+++ b/new\n@@ -0,0 +1 @@\n+n\n",
"diff --git a/mod b/mod\n",
"--- a/mod\n+++ b/mod\n@@ -1 +1 @@\n-a\n+b\n",
"diff --git a/gone b/gone\n",
"deleted file mode 100644\n--- a/gone\n+++ /dev/null\n@@ -1 +0,0 @@\n-x\n",
"diff --git a/old/f.txt b/new/f.txt\n",
"similarity index 100%\nrename from old/f.txt\nrename to new/f.txt\n",
);
let files = parse_diff(full);
let kinds: Vec<_> = files.iter().map(|f| (f.path.as_str(), f.change)).collect();
assert_eq!(
kinds,
vec![
("new", ChangeKind::Added),
("mod", ChangeKind::Modified),
("gone", ChangeKind::Deleted),
("new/f.txt", ChangeKind::Renamed),
]
);
let rename = files
.iter()
.find(|f| f.change == ChangeKind::Renamed)
.unwrap();
assert_eq!(rename.old_path.as_deref(), Some("old/f.txt"));
}
#[test]
fn diff_parses_hunk_ranges_and_body() {
let full = "diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1,2 +1,3 @@ fn main()\n ctx\n-old\n+new\n+added\n";
let files = parse_diff(full);
assert_eq!(files.len(), 1);
assert_eq!(files[0].raw, full);
let hunk = &files[0].hunks[0];
assert_eq!(
(
hunk.old_start,
hunk.old_lines,
hunk.new_start,
hunk.new_lines
),
(1, 2, 1, 3)
);
assert_eq!(hunk.section, "fn main()");
assert_eq!(
hunk.lines,
vec![
DiffLine::Context("ctx".into()),
DiffLine::Removed("old".into()),
DiffLine::Added("new".into()),
DiffLine::Added("added".into()),
]
);
}
}