1use std::path::PathBuf;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8#[non_exhaustive]
9pub struct StatusEntry {
10 pub code: String,
12 pub path: String,
15 pub orig_path: Option<String>,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21#[non_exhaustive]
22pub struct Commit {
23 pub hash: String,
25 pub short_hash: String,
27 pub author: String,
29 pub date: String,
31 pub subject: String,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37#[non_exhaustive]
38pub struct Branch {
39 pub name: String,
41 pub current: bool,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47#[non_exhaustive]
48pub struct Worktree {
49 pub path: PathBuf,
51 pub branch: Option<String>,
53 pub head: Option<String>,
55 pub bare: bool,
57 pub detached: bool,
59 pub locked: bool,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
65#[non_exhaustive]
66pub struct DiffStat {
67 pub files_changed: usize,
69 pub insertions: usize,
71 pub deletions: usize,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum ChangeKind {
79 Added,
81 Modified,
83 Deleted,
85 Renamed,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
92#[non_exhaustive]
93pub enum DiffLine {
94 Context(String),
96 Added(String),
98 Removed(String),
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104#[non_exhaustive]
105pub struct Hunk {
106 pub old_start: usize,
108 pub old_lines: usize,
110 pub new_start: usize,
112 pub new_lines: usize,
114 pub section: String,
116 pub lines: Vec<DiffLine>,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
123#[non_exhaustive]
124pub struct FileDiff {
125 pub change: ChangeKind,
127 pub path: String,
129 pub old_path: Option<String>,
131 pub hunks: Vec<Hunk>,
133 pub raw: String,
136}
137
138pub(crate) fn parse_porcelain(output: &str) -> Vec<StatusEntry> {
142 let mut entries = Vec::new();
143 let mut records = output.split('\0').filter(|rec| !rec.is_empty());
144 while let Some(rec) = records.next() {
145 if rec.len() < 4 {
148 continue;
149 }
150 let orig_path = if matches!(rec.as_bytes()[0], b'R' | b'C') {
153 records.next().map(str::to_string)
154 } else {
155 None
156 };
157 entries.push(StatusEntry {
158 code: rec[..2].to_string(),
159 path: rec[3..].to_string(),
160 orig_path,
161 });
162 }
163 entries
164}
165
166pub(crate) fn parse_log(output: &str) -> Vec<Commit> {
170 output
171 .split('\0')
172 .filter(|rec| !rec.is_empty())
173 .filter_map(|rec| {
174 let mut fields = rec.split('\u{1f}');
175 Some(Commit {
176 hash: fields.next()?.to_string(),
177 short_hash: fields.next()?.to_string(),
178 author: fields.next()?.to_string(),
179 date: fields.next()?.to_string(),
180 subject: fields.next().unwrap_or("").to_string(),
181 })
182 })
183 .collect()
184}
185
186pub(crate) fn parse_branches(output: &str) -> Vec<Branch> {
188 output
189 .lines()
190 .filter(|line| !line.trim().is_empty())
191 .filter_map(|line| {
192 let current = line.starts_with('*');
193 let name = line.get(1..).unwrap_or("").trim();
194 if name.is_empty() || name.starts_with('(') {
196 return None;
197 }
198 Some(Branch {
199 name: name.to_string(),
200 current,
201 })
202 })
203 .collect()
204}
205
206pub(crate) fn parse_worktree_porcelain(output: &str) -> Vec<Worktree> {
211 let mut worktrees = Vec::new();
212 let mut current: Option<Worktree> = None;
213 let flush = |current: &mut Option<Worktree>, out: &mut Vec<Worktree>| {
214 if let Some(wt) = current.take() {
215 out.push(wt);
216 }
217 };
218 for line in output.lines() {
219 if line.is_empty() {
220 flush(&mut current, &mut worktrees);
221 continue;
222 }
223 let (label, value) = match line.split_once(' ') {
224 Some((l, v)) => (l, Some(v)),
225 None => (line, None),
226 };
227 match label {
228 "worktree" => {
230 flush(&mut current, &mut worktrees);
231 current = Some(Worktree {
232 path: PathBuf::from(value.unwrap_or("")),
233 branch: None,
234 head: None,
235 bare: false,
236 detached: false,
237 locked: false,
238 });
239 }
240 "HEAD" => {
241 if let Some(wt) = current.as_mut() {
242 wt.head = value.map(str::to_string);
243 }
244 }
245 "branch" => {
246 if let Some(wt) = current.as_mut() {
247 wt.branch =
249 value.map(|v| v.strip_prefix("refs/heads/").unwrap_or(v).to_string());
250 }
251 }
252 "bare" => {
253 if let Some(wt) = current.as_mut() {
254 wt.bare = true;
255 }
256 }
257 "detached" => {
258 if let Some(wt) = current.as_mut() {
259 wt.detached = true;
260 }
261 }
262 "locked" => {
263 if let Some(wt) = current.as_mut() {
264 wt.locked = true;
265 }
266 }
267 _ => {}
268 }
269 }
270 flush(&mut current, &mut worktrees);
271 worktrees
272}
273
274pub(crate) fn parse_shortstat(output: &str) -> DiffStat {
278 let mut stat = DiffStat::default();
279 for part in output.split(',') {
280 let part = part.trim();
281 let n = part
282 .split_whitespace()
283 .next()
284 .and_then(|tok| tok.parse().ok())
285 .unwrap_or(0);
286 if part.contains("file") {
287 stat.files_changed = n;
288 } else if part.contains("insertion") {
289 stat.insertions = n;
290 } else if part.contains("deletion") {
291 stat.deletions = n;
292 }
293 }
294 stat
295}
296
297pub(crate) fn parse_ls_remote_heads(output: &str) -> Vec<String> {
300 output
301 .lines()
302 .filter_map(|line| {
303 let (_sha, refname) = line.split_once('\t')?;
304 refname
305 .trim()
306 .strip_prefix("refs/heads/")
307 .map(str::to_string)
308 })
309 .collect()
310}
311
312pub fn parse_diff(diff: &str) -> Vec<FileDiff> {
320 diff_sections(diff).filter_map(parse_section).collect()
321}
322
323fn diff_sections(full: &str) -> impl Iterator<Item = &str> {
325 let mut bounds = Vec::new();
326 let mut idx = 0;
327 for line in full.split_inclusive('\n') {
328 if line.starts_with("diff --git ") {
329 bounds.push(idx);
330 }
331 idx += line.len();
332 }
333 let ends = bounds
334 .iter()
335 .skip(1)
336 .copied()
337 .chain(std::iter::once(full.len()));
338 bounds
339 .clone()
340 .into_iter()
341 .zip(ends)
342 .map(move |(s, e)| &full[s..e])
343 .collect::<Vec<_>>()
344 .into_iter()
345}
346
347fn parse_section(section: &str) -> Option<FileDiff> {
350 let mut kind = ChangeKind::Modified;
351 let mut new_path = None;
352 let mut minus_path = None;
353 let mut rename_to = None;
354 let mut rename_from = None;
355 let mut hunks: Vec<Hunk> = Vec::new();
356 let mut current: Option<Hunk> = None;
357
358 for line in section.lines() {
359 if let Some(hunk) = parse_hunk_header(line) {
360 if let Some(done) = current.replace(hunk) {
361 hunks.push(done);
362 }
363 continue;
364 }
365 if let Some(hunk) = current.as_mut() {
366 match line.as_bytes().first() {
369 Some(b' ') => hunk.lines.push(DiffLine::Context(line[1..].to_string())),
370 Some(b'+') => hunk.lines.push(DiffLine::Added(line[1..].to_string())),
371 Some(b'-') => hunk.lines.push(DiffLine::Removed(line[1..].to_string())),
372 _ => {}
373 }
374 continue;
375 }
376 if line.starts_with("new file") {
378 kind = ChangeKind::Added;
379 } else if line.starts_with("deleted file") {
380 kind = ChangeKind::Deleted;
381 } else if let Some(p) = line.strip_prefix("rename to ") {
382 rename_to = Some(p.trim_end().to_string());
383 } else if let Some(p) = line.strip_prefix("rename from ") {
384 rename_from = Some(p.trim_end().to_string());
385 } else if let Some(p) = line.strip_prefix("+++ b/") {
386 new_path = Some(p.trim_end().to_string());
387 } else if let Some(p) = line.strip_prefix("--- a/") {
388 minus_path = Some(p.trim_end().to_string());
389 }
390 }
391 if let Some(done) = current.take() {
392 hunks.push(done);
393 }
394
395 let normalize = |p: String| p.replace('\\', "/");
396 let old_path = if rename_to.is_some() {
398 kind = ChangeKind::Renamed;
399 rename_from.map(normalize)
400 } else {
401 None
402 };
403 let path = rename_to
404 .or(new_path)
405 .or(minus_path)
406 .or_else(|| header_b_path(section))?;
407 Some(FileDiff {
408 change: kind,
409 path: normalize(path),
410 old_path,
411 hunks,
412 raw: section.to_string(),
413 })
414}
415
416fn parse_hunk_header(line: &str) -> Option<Hunk> {
419 let rest = line.strip_prefix("@@ ")?;
420 let (ranges, section) = rest.split_once(" @@")?;
421 let mut parts = ranges.split_whitespace();
422 let (old_start, old_lines) = parse_hunk_range(parts.next()?.strip_prefix('-')?);
423 let (new_start, new_lines) = parse_hunk_range(parts.next()?.strip_prefix('+')?);
424 Some(Hunk {
425 old_start,
426 old_lines,
427 new_start,
428 new_lines,
429 section: section.strip_prefix(' ').unwrap_or(section).to_string(),
430 lines: Vec::new(),
431 })
432}
433
434fn parse_hunk_range(range: &str) -> (usize, usize) {
436 match range.split_once(',') {
437 Some((start, count)) => (start.parse().unwrap_or(0), count.parse().unwrap_or(0)),
438 None => (range.parse().unwrap_or(0), 1),
439 }
440}
441
442fn header_b_path(section: &str) -> Option<String> {
446 let first = section.lines().next()?;
447 let s = first.strip_prefix("diff --git ")?;
448 let idx = s.find(" b/")?;
449 Some(s[idx + 1..].strip_prefix("b/").unwrap_or("").to_string())
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn porcelain_parses_codes_and_paths() {
458 let got = parse_porcelain(" M src/lib.rs\0?? new file.txt\0A added.rs\0");
460 assert_eq!(
461 got,
462 vec![
463 StatusEntry {
464 code: " M".into(),
465 path: "src/lib.rs".into(),
466 orig_path: None,
467 },
468 StatusEntry {
469 code: "??".into(),
470 path: "new file.txt".into(),
471 orig_path: None,
472 },
473 StatusEntry {
474 code: "A ".into(),
475 path: "added.rs".into(),
476 orig_path: None,
477 },
478 ]
479 );
480 }
481
482 #[test]
483 fn porcelain_parses_rename_with_orig_path() {
484 let got = parse_porcelain("R new.rs\0old.rs\0 M other.rs\0");
486 assert_eq!(
487 got,
488 vec![
489 StatusEntry {
490 code: "R ".into(),
491 path: "new.rs".into(),
492 orig_path: Some("old.rs".into()),
493 },
494 StatusEntry {
495 code: " M".into(),
496 path: "other.rs".into(),
497 orig_path: None,
498 },
499 ]
500 );
501 }
502
503 #[test]
504 fn porcelain_ignores_blank_and_short_records() {
505 assert!(parse_porcelain("\0 \0X\0").is_empty());
506 }
507
508 #[test]
509 fn log_splits_unit_separated_fields() {
510 let input = "abc123\u{1f}abc\u{1f}Ada\u{1f}2026-05-31T10:00:00+00:00\u{1f}Add feature\0\
511 def456\u{1f}def\u{1f}Linus\u{1f}2026-05-30T09:00:00+00:00\u{1f}Fix bug\0";
512 let got = parse_log(input);
513 assert_eq!(got.len(), 2);
514 assert_eq!(
515 got[0],
516 Commit {
517 hash: "abc123".into(),
518 short_hash: "abc".into(),
519 author: "Ada".into(),
520 date: "2026-05-31T10:00:00+00:00".into(),
521 subject: "Add feature".into(),
522 }
523 );
524 assert_eq!(got[1].subject, "Fix bug");
525 }
526
527 #[test]
528 fn log_tolerates_empty_subject() {
529 let got = parse_log("h\u{1f}h\u{1f}A\u{1f}2026-05-31T10:00:00+00:00\u{1f}\0");
530 assert_eq!(got[0].subject, "");
531 }
532
533 #[test]
534 fn branches_marks_current_and_skips_detached() {
535 let got = parse_branches("* main\n feature\n (HEAD detached at abc123)\n");
536 assert_eq!(
537 got,
538 vec![
539 Branch {
540 name: "main".into(),
541 current: true
542 },
543 Branch {
544 name: "feature".into(),
545 current: false
546 },
547 ]
548 );
549 }
550
551 #[test]
552 fn worktrees_parse_branch_detached_and_bare() {
553 let input = "worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\
554 \nworktree /repo/wt\nHEAD def456\ndetached\n\
555 \nworktree /repo/bare\nbare\n";
556 let got = parse_worktree_porcelain(input);
557 assert_eq!(got.len(), 3);
558 assert_eq!(got[0].path, PathBuf::from("/repo"));
559 assert_eq!(got[0].branch.as_deref(), Some("main"));
560 assert_eq!(got[0].head.as_deref(), Some("abc123"));
561 assert!(got[1].detached && got[1].branch.is_none());
562 assert!(got[2].bare && got[2].head.is_none());
563 }
564
565 #[test]
566 fn worktrees_parse_last_record_without_trailing_blank() {
567 let got = parse_worktree_porcelain("worktree /only\nHEAD aaa\nbranch refs/heads/x\n");
569 assert_eq!(got.len(), 1);
570 assert_eq!(got[0].branch.as_deref(), Some("x"));
571 }
572
573 #[test]
574 fn shortstat_parses_all_clauses() {
575 let got = parse_shortstat(" 3 files changed, 12 insertions(+), 4 deletions(-)\n");
576 assert_eq!(
577 got,
578 DiffStat {
579 files_changed: 3,
580 insertions: 12,
581 deletions: 4
582 }
583 );
584 }
585
586 #[test]
587 fn shortstat_tolerates_missing_clauses_and_empty() {
588 let only_ins = parse_shortstat(" 1 file changed, 2 insertions(+)\n");
590 assert_eq!(only_ins.insertions, 2);
591 assert_eq!(only_ins.deletions, 0);
592 assert_eq!(parse_shortstat(""), DiffStat::default());
593 }
594
595 #[test]
596 fn diff_covers_add_modify_delete_rename() {
597 let full = concat!(
600 "diff --git a/new b/new\n",
601 "new file mode 100644\n--- /dev/null\n+++ b/new\n@@ -0,0 +1 @@\n+n\n",
602 "diff --git a/mod b/mod\n",
603 "--- a/mod\n+++ b/mod\n@@ -1 +1 @@\n-a\n+b\n",
604 "diff --git a/gone b/gone\n",
605 "deleted file mode 100644\n--- a/gone\n+++ /dev/null\n@@ -1 +0,0 @@\n-x\n",
606 "diff --git a/old/f.txt b/new/f.txt\n",
607 "similarity index 100%\nrename from old/f.txt\nrename to new/f.txt\n",
608 );
609 let files = parse_diff(full);
610 let kinds: Vec<_> = files.iter().map(|f| (f.path.as_str(), f.change)).collect();
611 assert_eq!(
612 kinds,
613 vec![
614 ("new", ChangeKind::Added),
615 ("mod", ChangeKind::Modified),
616 ("gone", ChangeKind::Deleted),
617 ("new/f.txt", ChangeKind::Renamed),
618 ]
619 );
620 let rename = files
622 .iter()
623 .find(|f| f.change == ChangeKind::Renamed)
624 .unwrap();
625 assert_eq!(rename.old_path.as_deref(), Some("old/f.txt"));
626 }
627
628 #[test]
629 fn diff_handles_space_paths() {
630 let full = "diff --git a/a b/c.txt b/a b/c.txt\n--- a/a b/c.txt\t\n+++ b/a b/c.txt\t\n@@ -1 +1 @@\n-x\n+y\n";
633 let files = parse_diff(full);
634 assert_eq!(files.len(), 1);
635 assert_eq!(files[0].path, "a b/c.txt");
636 }
637
638 #[test]
639 fn diff_parses_hunk_ranges_and_body() {
640 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";
641 let files = parse_diff(full);
642 assert_eq!(files.len(), 1);
643 assert_eq!(files[0].raw, full);
645 let hunk = &files[0].hunks[0];
646 assert_eq!(
647 (
648 hunk.old_start,
649 hunk.old_lines,
650 hunk.new_start,
651 hunk.new_lines
652 ),
653 (1, 2, 1, 3)
654 );
655 assert_eq!(hunk.section, "fn main()");
656 assert_eq!(
657 hunk.lines,
658 vec![
659 DiffLine::Context("ctx".into()),
660 DiffLine::Removed("old".into()),
661 DiffLine::Added("new".into()),
662 DiffLine::Added("added".into()),
663 ]
664 );
665 }
666
667 #[test]
668 fn diff_omitted_count_defaults_to_one() {
669 let full = "diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -3 +3 @@\n-a\n+b\n";
671 let hunk = &parse_diff(full)[0].hunks[0];
672 assert_eq!((hunk.old_start, hunk.old_lines), (3, 1));
673 assert_eq!((hunk.new_start, hunk.new_lines), (3, 1));
674 }
675}