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
156pub(crate) fn parse_porcelain_v2(output: &str) -> BranchStatus {
164 let mut status = BranchStatus::default();
165 let mut records = output.split('\0');
166 while let Some(rec) = records.next() {
167 if let Some(rest) = rec.strip_prefix("# branch.oid ") {
168 status.head = (rest != "(initial)").then(|| rest.to_string());
170 } else if let Some(rest) = rec.strip_prefix("# branch.head ") {
171 status.branch = (rest != "(detached)").then(|| rest.to_string());
172 } else if let Some(rest) = rec.strip_prefix("# branch.upstream ") {
173 status.upstream = Some(rest.to_string());
174 } else if let Some(rest) = rec.strip_prefix("# branch.ab ") {
175 let mut parts = rest.split(' ');
177 status.ahead = parts
178 .next()
179 .and_then(|t| t.strip_prefix('+'))
180 .and_then(|n| n.parse().ok());
181 status.behind = parts
182 .next()
183 .and_then(|t| t.strip_prefix('-'))
184 .and_then(|n| n.parse().ok());
185 } else if rec.starts_with("1 ") {
186 status.tracked_changes += 1;
187 } else if rec.starts_with("2 ") {
188 status.tracked_changes += 1;
189 records.next();
192 } else if rec.starts_with("u ") {
193 status.tracked_changes += 1;
194 status.conflicts += 1;
195 } else if rec.starts_with("? ") {
196 status.untracked += 1;
197 }
198 }
200 status
201}
202
203pub(crate) fn parse_git_version(raw: &str) -> Option<vcs_diff::Version> {
207 vcs_diff::parse_dotted_version(raw)
208}
209
210pub(crate) fn parse_nul_paths(output: &[u8]) -> Vec<PathBuf> {
217 output
218 .split(|&b| b == 0)
219 .filter(|path| !path.is_empty())
220 .map(vcs_diff::path_from_bytes)
221 .collect()
222}
223
224pub(crate) fn parse_log(output: &str) -> Vec<Commit> {
228 output
229 .split('\0')
230 .filter(|rec| !rec.is_empty())
231 .filter_map(|rec| {
232 let mut fields = rec.split('\u{1f}');
233 Some(Commit {
234 hash: fields.next()?.to_string(),
235 short_hash: fields.next()?.to_string(),
236 author: fields.next()?.to_string(),
237 date: fields.next()?.to_string(),
238 subject: fields.next().unwrap_or("").to_string(),
239 })
240 })
241 .collect()
242}
243
244pub(crate) fn parse_branches(output: &str) -> Vec<Branch> {
246 output
247 .lines()
248 .filter(|line| !line.trim().is_empty())
249 .filter_map(|line| {
250 let current = line.starts_with('*');
251 let name = line.get(1..).unwrap_or("").trim();
252 if name.is_empty() || name.starts_with('(') {
254 return None;
255 }
256 Some(Branch {
257 name: name.to_string(),
258 current,
259 })
260 })
261 .collect()
262}
263
264pub(crate) fn parse_worktree_porcelain(output: &[u8]) -> Vec<Worktree> {
284 let mut worktrees = Vec::new();
285 let mut current: Option<Worktree> = None;
286 let flush = |current: &mut Option<Worktree>, out: &mut Vec<Worktree>| {
287 if let Some(wt) = current.take() {
288 out.push(wt);
289 }
290 };
291 for line in output.split(|&b| b == b'\n') {
292 if line.is_empty() {
293 flush(&mut current, &mut worktrees);
294 continue;
295 }
296 let (label, value) = match line.iter().position(|&b| b == b' ') {
299 Some(i) => (&line[..i], Some(&line[i + 1..])),
300 None => (line, None),
301 };
302 match label {
303 b"worktree" => {
305 flush(&mut current, &mut worktrees);
306 current = Some(Worktree {
307 path: value.map(vcs_diff::path_from_bytes).unwrap_or_default(),
309 branch: None,
310 head: None,
311 bare: false,
312 detached: false,
313 locked: false,
314 });
315 }
316 b"HEAD" => {
317 if let Some(wt) = current.as_mut() {
318 wt.head = value.map(|v| String::from_utf8_lossy(v).into_owned());
319 }
320 }
321 b"branch" => {
322 if let Some(wt) = current.as_mut() {
323 wt.branch = value.map(|v| {
325 let full = String::from_utf8_lossy(v);
326 full.strip_prefix("refs/heads/")
327 .unwrap_or(&full)
328 .to_string()
329 });
330 }
331 }
332 b"bare" => {
333 if let Some(wt) = current.as_mut() {
334 wt.bare = true;
335 }
336 }
337 b"detached" => {
338 if let Some(wt) = current.as_mut() {
339 wt.detached = true;
340 }
341 }
342 b"locked" => {
343 if let Some(wt) = current.as_mut() {
344 wt.locked = true;
345 }
346 }
347 _ => {}
348 }
349 }
350 flush(&mut current, &mut worktrees);
351 worktrees
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
357#[non_exhaustive]
358pub struct BlameLine {
359 pub commit: String,
361 pub orig_line: u32,
363 pub final_line: u32,
365 pub author: String,
367 pub author_time: i64,
369 pub author_tz: String,
371 pub content: String,
373}
374
375pub(crate) fn parse_blame_porcelain(output: &str) -> Vec<BlameLine> {
381 let mut lines = Vec::new();
382 let mut current: Option<BlameLine> = None;
383 for line in output.lines() {
384 if let Some(content) = line.strip_prefix('\t') {
386 if let Some(mut entry) = current.take() {
387 entry.content = content.to_string();
388 lines.push(entry);
389 }
390 continue;
391 }
392 let (label, value) = match line.split_once(' ') {
393 Some((l, v)) => (l, v),
394 None => (line, ""),
395 };
396 if (label.len() == 40 || label.len() == 64) && label.bytes().all(|b| b.is_ascii_hexdigit())
401 {
402 let mut nums = value.split(' ');
403 let orig = nums.next().and_then(|n| n.parse().ok()).unwrap_or(0);
404 let fin = nums.next().and_then(|n| n.parse().ok()).unwrap_or(0);
405 current = Some(BlameLine {
406 commit: label.to_string(),
407 orig_line: orig,
408 final_line: fin,
409 author: String::new(),
410 author_time: 0,
411 author_tz: String::new(),
412 content: String::new(),
413 });
414 continue;
415 }
416 let Some(entry) = current.as_mut() else {
417 continue;
418 };
419 match label {
420 "author" => entry.author = value.to_string(),
421 "author-time" => entry.author_time = value.parse().unwrap_or(0),
422 "author-tz" => entry.author_tz = value.to_string(),
423 _ => {}
426 }
427 }
428 lines
429}
430
431pub(crate) fn parse_shortstat(output: &str) -> DiffStat {
435 let mut stat = DiffStat::default();
436 for part in output.split(',') {
437 let part = part.trim();
438 let n = part
439 .split_whitespace()
440 .next()
441 .and_then(|tok| tok.parse().ok())
442 .unwrap_or(0);
443 if part.contains("file") {
444 stat.files_changed = n;
445 } else if part.contains("insertion") {
446 stat.insertions = n;
447 } else if part.contains("deletion") {
448 stat.deletions = n;
449 }
450 }
451 stat
452}
453
454pub(crate) fn parse_ls_remote_heads(output: &str) -> Vec<String> {
457 output
458 .lines()
459 .filter_map(|line| {
460 let (_sha, refname) = line.split_once('\t')?;
461 refname
462 .trim()
463 .strip_prefix("refs/heads/")
464 .map(str::to_string)
465 })
466 .collect()
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472
473 #[test]
474 fn porcelain_parses_codes_and_paths() {
475 let got = parse_porcelain(b" M src/lib.rs\0?? new file.txt\0A added.rs\0");
477 assert_eq!(
478 got,
479 vec![
480 StatusEntry {
481 code: " M".into(),
482 path: "src/lib.rs".into(),
483 old_path: None,
484 },
485 StatusEntry {
486 code: "??".into(),
487 path: "new file.txt".into(),
488 old_path: None,
489 },
490 StatusEntry {
491 code: "A ".into(),
492 path: "added.rs".into(),
493 old_path: None,
494 },
495 ]
496 );
497 }
498
499 #[cfg(unix)]
504 #[test]
505 fn porcelain_preserves_non_utf8_path_bytes() {
506 use std::os::unix::ffi::OsStrExt;
507 let got = parse_porcelain(b" M caf\xff.txt\0");
508 assert_eq!(got.len(), 1);
509 assert_eq!(got[0].path.as_os_str().as_bytes(), b"caf\xff.txt");
510 }
511
512 #[test]
513 fn porcelain_parses_rename_with_old_path() {
514 let got = parse_porcelain(b"R new.rs\0old.rs\0 M other.rs\0");
516 assert_eq!(
517 got,
518 vec![
519 StatusEntry {
520 code: "R ".into(),
521 path: "new.rs".into(),
522 old_path: Some("old.rs".into()),
523 },
524 StatusEntry {
525 code: " M".into(),
526 path: "other.rs".into(),
527 old_path: None,
528 },
529 ]
530 );
531 }
532
533 #[test]
537 fn porcelain_parses_worktree_rename_in_the_y_column() {
538 let got = parse_porcelain(b" R new.rs\0old.rs\0 M other.rs\0");
540 assert_eq!(
541 got,
542 vec![
543 StatusEntry {
544 code: " R".into(),
545 path: "new.rs".into(),
546 old_path: Some("old.rs".into()),
547 },
548 StatusEntry {
549 code: " M".into(),
550 path: "other.rs".into(),
551 old_path: None,
552 },
553 ],
554 "the source record must be consumed, not left as a phantom entry"
555 );
556 }
557
558 #[test]
559 fn porcelain_ignores_blank_and_short_records() {
560 assert!(parse_porcelain(b"\0 \0X\0").is_empty());
561 }
562
563 #[test]
567 fn porcelain_skips_non_ascii_status_records() {
568 assert!(parse_porcelain("𝓁abc\0".as_bytes()).is_empty());
569 let entries = parse_porcelain("𝓁abc\0 M a.rs\0".as_bytes());
571 assert_eq!(entries.len(), 1);
572 assert_eq!(entries[0].path, std::path::Path::new("a.rs"));
573 }
574
575 #[test]
576 fn porcelain_v2_parses_branch_and_change_counts() {
577 let out = concat!(
580 "# branch.oid abcdef1234567890\0",
581 "# branch.head main\0",
582 "# branch.upstream origin/main\0",
583 "# branch.ab +2 -1\0",
584 "1 .M N... 100644 100644 100644 1111 2222 a.rs\0",
585 "2 R. N... 100644 100644 100644 3333 4444 R100 new.rs\0",
586 "1 trap.rs\0",
587 "u UU N... 100644 100644 100644 100644 5 6 7 conflict.rs\0",
588 "? untracked.txt\0",
589 "! ignored.txt\0",
590 );
591 let s = parse_porcelain_v2(out);
592 assert_eq!(s.head.as_deref(), Some("abcdef1234567890"));
593 assert_eq!(s.branch.as_deref(), Some("main"));
594 assert_eq!(s.upstream.as_deref(), Some("origin/main"));
595 assert_eq!((s.ahead, s.behind), (Some(2), Some(1)));
596 assert_eq!(
597 s.tracked_changes, 3,
598 "1 + 2(rename) + u; the trap is consumed"
599 );
600 assert_eq!(s.untracked, 1);
601 assert_eq!(s.conflicts, 1);
602 assert!(s.is_dirty());
603 }
604
605 #[test]
606 fn porcelain_v2_handles_unborn_detached_and_no_upstream() {
607 let s = parse_porcelain_v2("# branch.oid (initial)\0# branch.head main\0");
609 assert_eq!(s.head, None);
610 assert_eq!(s.branch.as_deref(), Some("main"));
611 assert_eq!(s.upstream, None);
612 assert_eq!((s.ahead, s.behind), (None, None));
613 assert!(!s.is_dirty());
614
615 let s = parse_porcelain_v2("# branch.oid deadbeef\0# branch.head (detached)\0");
617 assert_eq!(s.head.as_deref(), Some("deadbeef"));
618 assert_eq!(s.branch, None);
619 assert_eq!(s.upstream, None);
620 }
621
622 #[test]
626 fn blame_line_porcelain_parses_headers_and_metadata() {
627 let sha_a = "a".repeat(40);
628 let sha_b = "b".repeat(40);
629 let out = format!(
630 "{sha_a} 1 1 2\nauthor Alice\nauthor-mail <a@x>\nauthor-time 1717500000\n\
631 author-tz +0200\ncommitter Alice\nsummary first\nboundary\nfilename f.txt\n\
632 \tline one\n\
633 {sha_a} 2 2\nauthor Alice\nauthor-mail <a@x>\nauthor-time 1717500000\n\
634 author-tz +0200\ncommitter Alice\nsummary first\nfilename f.txt\n\
635 \tline two\n\
636 {sha_b} 1 3 1\nauthor Bob\nauthor-mail <b@x>\nauthor-time 1717600000\n\
637 author-tz -0500\ncommitter Bob\nsummary second\nfilename f.txt\n\
638 \t\n"
639 );
640 let lines = parse_blame_porcelain(&out);
641 assert_eq!(lines.len(), 3);
642 assert_eq!(lines[0].commit, sha_a);
643 assert_eq!(lines[0].orig_line, 1);
644 assert_eq!(lines[0].final_line, 1);
645 assert_eq!(lines[0].author, "Alice");
646 assert_eq!(lines[0].author_time, 1717500000);
647 assert_eq!(lines[0].author_tz, "+0200");
648 assert_eq!(lines[0].content, "line one");
649 assert_eq!(lines[1].final_line, 2);
651 assert_eq!(lines[1].content, "line two");
652 assert_eq!(lines[2].commit, sha_b);
654 assert_eq!(lines[2].author, "Bob");
655 assert_eq!(lines[2].content, "");
656 }
657
658 #[test]
659 fn blame_ignores_garbage_and_empty_input() {
660 assert!(parse_blame_porcelain("").is_empty());
661 assert!(parse_blame_porcelain("not a header\n\torphan content\n").is_empty());
662 }
663
664 #[test]
667 fn blame_recognises_sha256_object_ids() {
668 let sha = "c".repeat(64);
669 let out = format!(
670 "{sha} 1 1 1\nauthor Carol\nauthor-mail <c@x>\nauthor-time 1717700000\n\
671 author-tz +0000\ncommitter Carol\nsummary s\nfilename f.txt\n\
672 \tline\n"
673 );
674 let lines = parse_blame_porcelain(&out);
675 assert_eq!(
676 lines.len(),
677 1,
678 "a SHA-256 blame must parse, not drop to empty"
679 );
680 assert_eq!(lines[0].commit, sha);
681 assert_eq!(lines[0].author, "Carol");
682 assert_eq!(lines[0].content, "line");
683 }
684
685 #[test]
686 fn git_version_parses_real_world_shapes() {
687 let v = parse_git_version("git version 2.54.0.windows.1").unwrap();
690 assert_eq!((v.major, v.minor, v.patch), (2, 54, 0));
691 let v = parse_git_version("git version 2.41.0-rc1").unwrap();
692 assert_eq!((v.major, v.minor, v.patch), (2, 41, 0));
693 let v = parse_git_version("git version 2.54").unwrap();
694 assert_eq!(v.patch, 0, "missing patch defaults to 0");
695 assert!(parse_git_version("no digits here").is_none());
696 assert!(parse_git_version("git version unknowable").is_none());
697 }
698
699 #[test]
700 fn nul_paths_split_and_keep_special_characters() {
701 assert_eq!(
702 parse_nul_paths(b"a.rs\0sub/with space.rs\0"),
703 [PathBuf::from("a.rs"), PathBuf::from("sub/with space.rs")]
704 );
705 assert!(parse_nul_paths(b"").is_empty());
706 }
707
708 #[test]
709 fn log_splits_unit_separated_fields() {
710 let input = "abc123\u{1f}abc\u{1f}Ada\u{1f}2026-05-31T10:00:00+00:00\u{1f}Add feature\0\
711 def456\u{1f}def\u{1f}Linus\u{1f}2026-05-30T09:00:00+00:00\u{1f}Fix bug\0";
712 let got = parse_log(input);
713 assert_eq!(got.len(), 2);
714 assert_eq!(
715 got[0],
716 Commit {
717 hash: "abc123".into(),
718 short_hash: "abc".into(),
719 author: "Ada".into(),
720 date: "2026-05-31T10:00:00+00:00".into(),
721 subject: "Add feature".into(),
722 }
723 );
724 assert_eq!(got[1].subject, "Fix bug");
725 }
726
727 #[test]
728 fn log_tolerates_empty_subject() {
729 let got = parse_log("h\u{1f}h\u{1f}A\u{1f}2026-05-31T10:00:00+00:00\u{1f}\0");
730 assert_eq!(got[0].subject, "");
731 }
732
733 #[test]
734 fn branches_marks_current_and_skips_detached() {
735 let got = parse_branches("* main\n feature\n (HEAD detached at abc123)\n");
736 assert_eq!(
737 got,
738 vec![
739 Branch {
740 name: "main".into(),
741 current: true
742 },
743 Branch {
744 name: "feature".into(),
745 current: false
746 },
747 ]
748 );
749 }
750
751 #[test]
752 fn worktrees_parse_branch_detached_and_bare() {
753 let input = "worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\
754 \nworktree /repo/wt\nHEAD def456\ndetached\n\
755 \nworktree /repo/bare\nbare\n";
756 let got = parse_worktree_porcelain(input.as_bytes());
757 assert_eq!(got.len(), 3);
758 assert_eq!(got[0].path, PathBuf::from("/repo"));
759 assert_eq!(got[0].branch.as_deref(), Some("main"));
760 assert_eq!(got[0].head.as_deref(), Some("abc123"));
761 assert!(got[1].detached && got[1].branch.is_none());
762 assert!(got[2].bare && got[2].head.is_none());
763 }
764
765 #[cfg(unix)]
770 #[test]
771 fn worktrees_preserve_non_utf8_path_bytes() {
772 use std::os::unix::ffi::OsStrExt;
773 let got = parse_worktree_porcelain(b"worktree /repo/wt-caf\xff\nHEAD abc123\n");
774 assert_eq!(got.len(), 1);
775 assert_eq!(got[0].path.as_os_str().as_bytes(), b"/repo/wt-caf\xff");
776 assert_eq!(got[0].head.as_deref(), Some("abc123"));
777 }
778
779 #[test]
780 fn worktrees_parse_last_record_without_trailing_blank() {
781 let got = parse_worktree_porcelain(b"worktree /only\nHEAD aaa\nbranch refs/heads/x\n");
783 assert_eq!(got.len(), 1);
784 assert_eq!(got[0].branch.as_deref(), Some("x"));
785 }
786
787 #[test]
788 fn shortstat_parses_all_clauses() {
789 let got = parse_shortstat(" 3 files changed, 12 insertions(+), 4 deletions(-)\n");
790 assert_eq!(got, DiffStat::new(3, 12, 4));
791 }
792
793 #[test]
794 fn shortstat_tolerates_missing_clauses_and_empty() {
795 let only_ins = parse_shortstat(" 1 file changed, 2 insertions(+)\n");
797 assert_eq!(only_ins.insertions, 2);
798 assert_eq!(only_ins.deletions, 0);
799 assert_eq!(parse_shortstat(""), DiffStat::default());
800 }
801}
802
803#[cfg(test)]
810mod proptests {
811 use super::*;
812 use proptest::prelude::*;
813
814 fn structured_line() -> impl Strategy<Value = String> {
817 prop_oneof![
818 Just("diff --git a/f b/f\n".to_string()),
819 Just("--- a/f\n".to_string()),
820 Just("+++ b/f\n".to_string()),
821 Just("@@ -1,2 +3,4 @@ ctx\n".to_string()),
822 Just("@@ -1 +1 @@\n".to_string()),
823 Just("rename from {old => new}.rs\n".to_string()),
824 Just("R100\told\tnew\n".to_string()),
825 Just(format!("{}\n", "a".repeat(40))), "[-+ ]?[a-zé\t]{0,12}\n", "[ MARD?]{0,2} [a-zé/]{0,8}\0", ]
829 }
830
831 fn structured_doc() -> impl Strategy<Value = String> {
832 prop::collection::vec(structured_line(), 0..40).prop_map(|lines| lines.concat())
833 }
834
835 proptest! {
836 #[test]
838 fn parsers_never_panic_on_arbitrary_text(s in any::<String>()) {
839 let _ = parse_porcelain(s.as_bytes());
840 let _ = parse_porcelain_v2(&s);
841 let _ = parse_log(&s);
842 let _ = parse_branches(&s);
843 let _ = parse_worktree_porcelain(s.as_bytes());
844 let _ = parse_blame_porcelain(&s);
845 let _ = parse_shortstat(&s);
846 let _ = parse_ls_remote_heads(&s);
847 let _ = parse_nul_paths(s.as_bytes());
848 let _ = parse_git_version(&s);
849 }
850
851 #[test]
855 fn byte_parsers_never_panic_on_arbitrary_bytes(b in any::<Vec<u8>>()) {
856 let _ = parse_porcelain(&b);
857 let _ = parse_nul_paths(&b);
858 let _ = parse_worktree_porcelain(&b);
859 }
860
861 #[test]
863 fn parsers_never_panic_on_structured_text(s in structured_doc()) {
864 let _ = parse_porcelain(s.as_bytes());
865 let _ = parse_porcelain_v2(&s);
866 let _ = parse_log(&s);
867 let _ = parse_blame_porcelain(&s);
868 }
869
870 #[test]
873 fn porcelain_v2_never_panics(records in prop::collection::vec(
874 prop_oneof![
875 Just("# branch.oid (initial)".to_string()),
876 Just("# branch.head main".to_string()),
877 Just("# branch.ab +1 -2".to_string()),
878 "1 [.MADRCU]{2} [a-zé /]{0,10}".prop_map(|s| s),
879 "2 R\\. .* R100 [a-zé /]{0,8}".prop_map(|s| s),
880 "u UU [a-zé /]{0,8}".prop_map(|s| s),
881 "\\? [a-zé /]{0,8}".prop_map(|s| s),
882 "[a-zé0-9# ]{0,12}".prop_map(|s| s),
883 ],
884 0..20,
885 ).prop_map(|r| r.join("\0"))) {
886 let _ = parse_porcelain_v2(&records);
887 }
888 }
889}