1use std::path::PathBuf;
9
10use vcs_diff::DiffStat;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub struct StatusEntry {
16 pub code: String,
18 pub path: PathBuf,
25 pub old_path: Option<PathBuf>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Default)]
35#[non_exhaustive]
36pub struct BranchStatus {
37 pub head: Option<String>,
40 pub branch: Option<String>,
42 pub upstream: Option<String>,
44 pub ahead: Option<usize>,
46 pub behind: Option<usize>,
48 pub tracked_changes: usize,
51 pub untracked: usize,
53 pub conflicts: usize,
56}
57
58impl BranchStatus {
59 pub fn is_dirty(&self) -> bool {
61 self.tracked_changes > 0 || self.untracked > 0
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67#[non_exhaustive]
68pub struct Commit {
69 pub hash: String,
71 pub short_hash: String,
73 pub author: String,
75 pub date: String,
77 pub subject: String,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83#[non_exhaustive]
84pub struct Branch {
85 pub name: String,
87 pub current: bool,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
93#[non_exhaustive]
94pub struct Worktree {
95 pub path: PathBuf,
102 pub branch: Option<String>,
104 pub head: Option<String>,
106 pub bare: bool,
108 pub detached: bool,
110 pub locked: bool,
112}
113
114pub(crate) fn parse_porcelain(output: &[u8]) -> Vec<StatusEntry> {
124 let mut entries = Vec::new();
125 let mut records = output.split(|&b| b == 0).filter(|rec| !rec.is_empty());
126 while let Some(rec) = records.next() {
127 let (Some(code), Some(&b' ')) = (rec.get(..2), rec.get(2)) else {
133 continue;
134 };
135 let path = &rec[3..];
136 let old_path = if matches!(code, [b'R' | b'C', _] | [_, b'R' | b'C']) {
142 records.next().map(vcs_diff::path_from_bytes)
143 } else {
144 None
145 };
146 entries.push(StatusEntry {
147 code: String::from_utf8_lossy(code).into_owned(),
149 path: vcs_diff::path_from_bytes(path),
150 old_path,
151 });
152 }
153 entries
154}
155
156#[doc(hidden)]
164pub fn parse_porcelain_v2(output: &str) -> BranchStatus {
165 let mut status = BranchStatus::default();
166 let mut records = output.split('\0');
167 while let Some(rec) = records.next() {
168 if let Some(rest) = rec.strip_prefix("# branch.oid ") {
169 status.head = (rest != "(initial)").then(|| rest.to_string());
171 } else if let Some(rest) = rec.strip_prefix("# branch.head ") {
172 status.branch = (rest != "(detached)").then(|| rest.to_string());
173 } else if let Some(rest) = rec.strip_prefix("# branch.upstream ") {
174 status.upstream = Some(rest.to_string());
175 } else if let Some(rest) = rec.strip_prefix("# branch.ab ") {
176 let mut parts = rest.split(' ');
178 status.ahead = parts
179 .next()
180 .and_then(|t| t.strip_prefix('+'))
181 .and_then(|n| n.parse().ok());
182 status.behind = parts
183 .next()
184 .and_then(|t| t.strip_prefix('-'))
185 .and_then(|n| n.parse().ok());
186 } else if rec.starts_with("1 ") {
187 status.tracked_changes += 1;
188 } else if rec.starts_with("2 ") {
189 status.tracked_changes += 1;
190 records.next();
193 } else if rec.starts_with("u ") {
194 status.tracked_changes += 1;
195 status.conflicts += 1;
196 } else if rec.starts_with("? ") {
197 status.untracked += 1;
198 }
199 }
201 status
202}
203
204pub(crate) fn parse_git_version(raw: &str) -> Option<vcs_diff::Version> {
208 vcs_diff::parse_dotted_version(raw)
209}
210
211pub(crate) fn parse_nul_paths(output: &[u8]) -> Vec<PathBuf> {
218 output
219 .split(|&b| b == 0)
220 .filter(|path| !path.is_empty())
221 .map(vcs_diff::path_from_bytes)
222 .collect()
223}
224
225pub(crate) fn parse_log(output: &str) -> Vec<Commit> {
229 output
230 .split('\0')
231 .filter(|rec| !rec.is_empty())
232 .filter_map(|rec| {
233 let mut fields = rec.split('\u{1f}');
234 Some(Commit {
235 hash: fields.next()?.to_string(),
236 short_hash: fields.next()?.to_string(),
237 author: fields.next()?.to_string(),
238 date: fields.next()?.to_string(),
239 subject: fields.next().unwrap_or("").to_string(),
240 })
241 })
242 .collect()
243}
244
245pub(crate) fn parse_branches(output: &str) -> Vec<Branch> {
247 output
248 .lines()
249 .filter(|line| !line.trim().is_empty())
250 .filter_map(|line| {
251 let current = line.starts_with('*');
252 let name = line.get(1..).unwrap_or("").trim();
253 if name.is_empty() || name.starts_with('(') {
255 return None;
256 }
257 Some(Branch {
258 name: name.to_string(),
259 current,
260 })
261 })
262 .collect()
263}
264
265pub(crate) fn parse_worktree_porcelain(output: &[u8]) -> Vec<Worktree> {
285 let mut worktrees = Vec::new();
286 let mut current: Option<Worktree> = None;
287 let flush = |current: &mut Option<Worktree>, out: &mut Vec<Worktree>| {
288 if let Some(wt) = current.take() {
289 out.push(wt);
290 }
291 };
292 for line in output.split(|&b| b == b'\n') {
293 if line.is_empty() {
294 flush(&mut current, &mut worktrees);
295 continue;
296 }
297 let (label, value) = match line.iter().position(|&b| b == b' ') {
300 Some(i) => (&line[..i], Some(&line[i + 1..])),
301 None => (line, None),
302 };
303 match label {
304 b"worktree" => {
306 flush(&mut current, &mut worktrees);
307 current = Some(Worktree {
308 path: value.map(vcs_diff::path_from_bytes).unwrap_or_default(),
310 branch: None,
311 head: None,
312 bare: false,
313 detached: false,
314 locked: false,
315 });
316 }
317 b"HEAD" => {
318 if let Some(wt) = current.as_mut() {
319 wt.head = value.map(|v| String::from_utf8_lossy(v).into_owned());
320 }
321 }
322 b"branch" => {
323 if let Some(wt) = current.as_mut() {
324 wt.branch = value.map(|v| {
326 let full = String::from_utf8_lossy(v);
327 full.strip_prefix("refs/heads/")
328 .unwrap_or(&full)
329 .to_string()
330 });
331 }
332 }
333 b"bare" => {
334 if let Some(wt) = current.as_mut() {
335 wt.bare = true;
336 }
337 }
338 b"detached" => {
339 if let Some(wt) = current.as_mut() {
340 wt.detached = true;
341 }
342 }
343 b"locked" => {
344 if let Some(wt) = current.as_mut() {
345 wt.locked = true;
346 }
347 }
348 _ => {}
349 }
350 }
351 flush(&mut current, &mut worktrees);
352 worktrees
353}
354
355#[derive(Debug, Clone, PartialEq, Eq)]
358#[non_exhaustive]
359pub struct BlameLine {
360 pub commit: String,
362 pub orig_line: u32,
364 pub final_line: u32,
366 pub author: String,
368 pub author_time: i64,
370 pub author_tz: String,
372 pub content: String,
374}
375
376pub(crate) fn parse_blame_porcelain(output: &str) -> Vec<BlameLine> {
382 let mut lines = Vec::new();
383 let mut current: Option<BlameLine> = None;
384 for line in output.lines() {
385 if let Some(content) = line.strip_prefix('\t') {
387 if let Some(mut entry) = current.take() {
388 entry.content = content.to_string();
389 lines.push(entry);
390 }
391 continue;
392 }
393 let (label, value) = match line.split_once(' ') {
394 Some((l, v)) => (l, v),
395 None => (line, ""),
396 };
397 if (label.len() == 40 || label.len() == 64) && label.bytes().all(|b| b.is_ascii_hexdigit())
402 {
403 let mut nums = value.split(' ');
404 let orig = nums.next().and_then(|n| n.parse().ok()).unwrap_or(0);
405 let fin = nums.next().and_then(|n| n.parse().ok()).unwrap_or(0);
406 current = Some(BlameLine {
407 commit: label.to_string(),
408 orig_line: orig,
409 final_line: fin,
410 author: String::new(),
411 author_time: 0,
412 author_tz: String::new(),
413 content: String::new(),
414 });
415 continue;
416 }
417 let Some(entry) = current.as_mut() else {
418 continue;
419 };
420 match label {
421 "author" => entry.author = value.to_string(),
422 "author-time" => entry.author_time = value.parse().unwrap_or(0),
423 "author-tz" => entry.author_tz = value.to_string(),
424 _ => {}
427 }
428 }
429 lines
430}
431
432pub(crate) fn parse_shortstat(output: &str) -> DiffStat {
439 DiffStat::parse(output)
440}
441
442pub(crate) fn parse_ls_remote_heads(output: &str) -> Vec<String> {
445 output
446 .lines()
447 .filter_map(|line| {
448 let (_sha, refname) = line.split_once('\t')?;
449 refname
450 .trim()
451 .strip_prefix("refs/heads/")
452 .map(str::to_string)
453 })
454 .collect()
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 #[test]
462 fn porcelain_parses_codes_and_paths() {
463 let got = parse_porcelain(b" M src/lib.rs\0?? new file.txt\0A added.rs\0");
465 assert_eq!(
466 got,
467 vec![
468 StatusEntry {
469 code: " M".into(),
470 path: "src/lib.rs".into(),
471 old_path: None,
472 },
473 StatusEntry {
474 code: "??".into(),
475 path: "new file.txt".into(),
476 old_path: None,
477 },
478 StatusEntry {
479 code: "A ".into(),
480 path: "added.rs".into(),
481 old_path: None,
482 },
483 ]
484 );
485 }
486
487 #[cfg(unix)]
492 #[test]
493 fn porcelain_preserves_non_utf8_path_bytes() {
494 use std::os::unix::ffi::OsStrExt;
495 let got = parse_porcelain(b" M caf\xff.txt\0");
496 assert_eq!(got.len(), 1);
497 assert_eq!(got[0].path.as_os_str().as_bytes(), b"caf\xff.txt");
498 }
499
500 #[test]
501 fn porcelain_parses_rename_with_old_path() {
502 let got = parse_porcelain(b"R new.rs\0old.rs\0 M other.rs\0");
504 assert_eq!(
505 got,
506 vec![
507 StatusEntry {
508 code: "R ".into(),
509 path: "new.rs".into(),
510 old_path: Some("old.rs".into()),
511 },
512 StatusEntry {
513 code: " M".into(),
514 path: "other.rs".into(),
515 old_path: None,
516 },
517 ]
518 );
519 }
520
521 #[test]
525 fn porcelain_parses_worktree_rename_in_the_y_column() {
526 let got = parse_porcelain(b" R new.rs\0old.rs\0 M other.rs\0");
528 assert_eq!(
529 got,
530 vec![
531 StatusEntry {
532 code: " R".into(),
533 path: "new.rs".into(),
534 old_path: Some("old.rs".into()),
535 },
536 StatusEntry {
537 code: " M".into(),
538 path: "other.rs".into(),
539 old_path: None,
540 },
541 ],
542 "the source record must be consumed, not left as a phantom entry"
543 );
544 }
545
546 #[test]
547 fn porcelain_ignores_blank_and_short_records() {
548 assert!(parse_porcelain(b"\0 \0X\0").is_empty());
549 }
550
551 #[test]
555 fn porcelain_skips_non_ascii_status_records() {
556 assert!(parse_porcelain("𝓁abc\0".as_bytes()).is_empty());
557 let entries = parse_porcelain("𝓁abc\0 M a.rs\0".as_bytes());
559 assert_eq!(entries.len(), 1);
560 assert_eq!(entries[0].path, std::path::Path::new("a.rs"));
561 }
562
563 #[test]
564 fn porcelain_v2_parses_branch_and_change_counts() {
565 let out = concat!(
568 "# branch.oid abcdef1234567890\0",
569 "# branch.head main\0",
570 "# branch.upstream origin/main\0",
571 "# branch.ab +2 -1\0",
572 "1 .M N... 100644 100644 100644 1111 2222 a.rs\0",
573 "2 R. N... 100644 100644 100644 3333 4444 R100 new.rs\0",
574 "1 trap.rs\0",
575 "u UU N... 100644 100644 100644 100644 5 6 7 conflict.rs\0",
576 "? untracked.txt\0",
577 "! ignored.txt\0",
578 );
579 let s = parse_porcelain_v2(out);
580 assert_eq!(s.head.as_deref(), Some("abcdef1234567890"));
581 assert_eq!(s.branch.as_deref(), Some("main"));
582 assert_eq!(s.upstream.as_deref(), Some("origin/main"));
583 assert_eq!((s.ahead, s.behind), (Some(2), Some(1)));
584 assert_eq!(
585 s.tracked_changes, 3,
586 "1 + 2(rename) + u; the trap is consumed"
587 );
588 assert_eq!(s.untracked, 1);
589 assert_eq!(s.conflicts, 1);
590 assert!(s.is_dirty());
591 }
592
593 #[test]
594 fn porcelain_v2_handles_unborn_detached_and_no_upstream() {
595 let s = parse_porcelain_v2("# branch.oid (initial)\0# branch.head main\0");
597 assert_eq!(s.head, None);
598 assert_eq!(s.branch.as_deref(), Some("main"));
599 assert_eq!(s.upstream, None);
600 assert_eq!((s.ahead, s.behind), (None, None));
601 assert!(!s.is_dirty());
602
603 let s = parse_porcelain_v2("# branch.oid deadbeef\0# branch.head (detached)\0");
605 assert_eq!(s.head.as_deref(), Some("deadbeef"));
606 assert_eq!(s.branch, None);
607 assert_eq!(s.upstream, None);
608 }
609
610 #[test]
614 fn blame_line_porcelain_parses_headers_and_metadata() {
615 let sha_a = "a".repeat(40);
616 let sha_b = "b".repeat(40);
617 let out = format!(
618 "{sha_a} 1 1 2\nauthor Alice\nauthor-mail <a@x>\nauthor-time 1717500000\n\
619 author-tz +0200\ncommitter Alice\nsummary first\nboundary\nfilename f.txt\n\
620 \tline one\n\
621 {sha_a} 2 2\nauthor Alice\nauthor-mail <a@x>\nauthor-time 1717500000\n\
622 author-tz +0200\ncommitter Alice\nsummary first\nfilename f.txt\n\
623 \tline two\n\
624 {sha_b} 1 3 1\nauthor Bob\nauthor-mail <b@x>\nauthor-time 1717600000\n\
625 author-tz -0500\ncommitter Bob\nsummary second\nfilename f.txt\n\
626 \t\n"
627 );
628 let lines = parse_blame_porcelain(&out);
629 assert_eq!(lines.len(), 3);
630 assert_eq!(lines[0].commit, sha_a);
631 assert_eq!(lines[0].orig_line, 1);
632 assert_eq!(lines[0].final_line, 1);
633 assert_eq!(lines[0].author, "Alice");
634 assert_eq!(lines[0].author_time, 1717500000);
635 assert_eq!(lines[0].author_tz, "+0200");
636 assert_eq!(lines[0].content, "line one");
637 assert_eq!(lines[1].final_line, 2);
639 assert_eq!(lines[1].content, "line two");
640 assert_eq!(lines[2].commit, sha_b);
642 assert_eq!(lines[2].author, "Bob");
643 assert_eq!(lines[2].content, "");
644 }
645
646 #[test]
647 fn blame_ignores_garbage_and_empty_input() {
648 assert!(parse_blame_porcelain("").is_empty());
649 assert!(parse_blame_porcelain("not a header\n\torphan content\n").is_empty());
650 }
651
652 #[test]
655 fn blame_recognises_sha256_object_ids() {
656 let sha = "c".repeat(64);
657 let out = format!(
658 "{sha} 1 1 1\nauthor Carol\nauthor-mail <c@x>\nauthor-time 1717700000\n\
659 author-tz +0000\ncommitter Carol\nsummary s\nfilename f.txt\n\
660 \tline\n"
661 );
662 let lines = parse_blame_porcelain(&out);
663 assert_eq!(
664 lines.len(),
665 1,
666 "a SHA-256 blame must parse, not drop to empty"
667 );
668 assert_eq!(lines[0].commit, sha);
669 assert_eq!(lines[0].author, "Carol");
670 assert_eq!(lines[0].content, "line");
671 }
672
673 #[test]
674 fn git_version_parses_real_world_shapes() {
675 let v = parse_git_version("git version 2.54.0.windows.1").unwrap();
678 assert_eq!((v.major, v.minor, v.patch), (2, 54, 0));
679 let v = parse_git_version("git version 2.41.0-rc1").unwrap();
680 assert_eq!((v.major, v.minor, v.patch), (2, 41, 0));
681 let v = parse_git_version("git version 2.54").unwrap();
682 assert_eq!(v.patch, 0, "missing patch defaults to 0");
683 assert!(parse_git_version("no digits here").is_none());
684 assert!(parse_git_version("git version unknowable").is_none());
685 }
686
687 #[test]
688 fn nul_paths_split_and_keep_special_characters() {
689 assert_eq!(
690 parse_nul_paths(b"a.rs\0sub/with space.rs\0"),
691 [PathBuf::from("a.rs"), PathBuf::from("sub/with space.rs")]
692 );
693 assert!(parse_nul_paths(b"").is_empty());
694 }
695
696 #[test]
697 fn log_splits_unit_separated_fields() {
698 let input = "abc123\u{1f}abc\u{1f}Ada\u{1f}2026-05-31T10:00:00+00:00\u{1f}Add feature\0\
699 def456\u{1f}def\u{1f}Linus\u{1f}2026-05-30T09:00:00+00:00\u{1f}Fix bug\0";
700 let got = parse_log(input);
701 assert_eq!(got.len(), 2);
702 assert_eq!(
703 got[0],
704 Commit {
705 hash: "abc123".into(),
706 short_hash: "abc".into(),
707 author: "Ada".into(),
708 date: "2026-05-31T10:00:00+00:00".into(),
709 subject: "Add feature".into(),
710 }
711 );
712 assert_eq!(got[1].subject, "Fix bug");
713 }
714
715 #[test]
716 fn log_tolerates_empty_subject() {
717 let got = parse_log("h\u{1f}h\u{1f}A\u{1f}2026-05-31T10:00:00+00:00\u{1f}\0");
718 assert_eq!(got[0].subject, "");
719 }
720
721 #[test]
722 fn branches_marks_current_and_skips_detached() {
723 let got = parse_branches("* main\n feature\n (HEAD detached at abc123)\n");
724 assert_eq!(
725 got,
726 vec![
727 Branch {
728 name: "main".into(),
729 current: true
730 },
731 Branch {
732 name: "feature".into(),
733 current: false
734 },
735 ]
736 );
737 }
738
739 #[test]
740 fn worktrees_parse_branch_detached_and_bare() {
741 let input = "worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\
742 \nworktree /repo/wt\nHEAD def456\ndetached\n\
743 \nworktree /repo/bare\nbare\n";
744 let got = parse_worktree_porcelain(input.as_bytes());
745 assert_eq!(got.len(), 3);
746 assert_eq!(got[0].path, PathBuf::from("/repo"));
747 assert_eq!(got[0].branch.as_deref(), Some("main"));
748 assert_eq!(got[0].head.as_deref(), Some("abc123"));
749 assert!(got[1].detached && got[1].branch.is_none());
750 assert!(got[2].bare && got[2].head.is_none());
751 }
752
753 #[cfg(unix)]
758 #[test]
759 fn worktrees_preserve_non_utf8_path_bytes() {
760 use std::os::unix::ffi::OsStrExt;
761 let got = parse_worktree_porcelain(b"worktree /repo/wt-caf\xff\nHEAD abc123\n");
762 assert_eq!(got.len(), 1);
763 assert_eq!(got[0].path.as_os_str().as_bytes(), b"/repo/wt-caf\xff");
764 assert_eq!(got[0].head.as_deref(), Some("abc123"));
765 }
766
767 #[test]
768 fn worktrees_parse_last_record_without_trailing_blank() {
769 let got = parse_worktree_porcelain(b"worktree /only\nHEAD aaa\nbranch refs/heads/x\n");
771 assert_eq!(got.len(), 1);
772 assert_eq!(got[0].branch.as_deref(), Some("x"));
773 }
774
775 #[test]
776 fn shortstat_parses_all_clauses() {
777 let got = parse_shortstat(" 3 files changed, 12 insertions(+), 4 deletions(-)\n");
778 assert_eq!(got, DiffStat::new(3, 12, 4));
779 }
780
781 #[test]
782 fn shortstat_tolerates_missing_clauses_and_empty() {
783 let only_ins = parse_shortstat(" 1 file changed, 2 insertions(+)\n");
785 assert_eq!(only_ins.insertions, 2);
786 assert_eq!(only_ins.deletions, 0);
787 assert_eq!(parse_shortstat(""), DiffStat::default());
788 }
789}
790
791#[cfg(test)]
798mod proptests {
799 use super::*;
800 use proptest::prelude::*;
801
802 fn structured_line() -> impl Strategy<Value = String> {
805 prop_oneof![
806 Just("diff --git a/f b/f\n".to_string()),
807 Just("--- a/f\n".to_string()),
808 Just("+++ b/f\n".to_string()),
809 Just("@@ -1,2 +3,4 @@ ctx\n".to_string()),
810 Just("@@ -1 +1 @@\n".to_string()),
811 Just("rename from {old => new}.rs\n".to_string()),
812 Just("R100\told\tnew\n".to_string()),
813 Just(format!("{}\n", "a".repeat(40))), "[-+ ]?[a-zé\t]{0,12}\n", "[ MARD?]{0,2} [a-zé/]{0,8}\0", ]
817 }
818
819 fn structured_doc() -> impl Strategy<Value = String> {
820 prop::collection::vec(structured_line(), 0..40).prop_map(|lines| lines.concat())
821 }
822
823 proptest! {
824 #[test]
826 fn parsers_never_panic_on_arbitrary_text(s in any::<String>()) {
827 let _ = parse_porcelain(s.as_bytes());
828 let _ = parse_porcelain_v2(&s);
829 let _ = parse_log(&s);
830 let _ = parse_branches(&s);
831 let _ = parse_worktree_porcelain(s.as_bytes());
832 let _ = parse_blame_porcelain(&s);
833 let _ = parse_shortstat(&s);
834 let _ = parse_ls_remote_heads(&s);
835 let _ = parse_nul_paths(s.as_bytes());
836 let _ = parse_git_version(&s);
837 }
838
839 #[test]
843 fn byte_parsers_never_panic_on_arbitrary_bytes(b in any::<Vec<u8>>()) {
844 let _ = parse_porcelain(&b);
845 let _ = parse_nul_paths(&b);
846 let _ = parse_worktree_porcelain(&b);
847 }
848
849 #[test]
851 fn parsers_never_panic_on_structured_text(s in structured_doc()) {
852 let _ = parse_porcelain(s.as_bytes());
853 let _ = parse_porcelain_v2(&s);
854 let _ = parse_log(&s);
855 let _ = parse_blame_porcelain(&s);
856 }
857
858 #[test]
861 fn porcelain_v2_never_panics(records in prop::collection::vec(
862 prop_oneof![
863 Just("# branch.oid (initial)".to_string()),
864 Just("# branch.head main".to_string()),
865 Just("# branch.ab +1 -2".to_string()),
866 "1 [.MADRCU]{2} [a-zé /]{0,10}".prop_map(|s| s),
867 "2 R\\. .* R100 [a-zé /]{0,8}".prop_map(|s| s),
868 "u UU [a-zé /]{0,8}".prop_map(|s| s),
869 "\\? [a-zé /]{0,8}".prop_map(|s| s),
870 "[a-zé0-9# ]{0,12}".prop_map(|s| s),
871 ],
872 0..20,
873 ).prop_map(|r| r.join("\0"))) {
874 let _ = parse_porcelain_v2(&records);
875 }
876 }
877}