1use std::path::PathBuf;
9
10use vcs_diff::DiffStat;
11
12use crate::{BINARY, BisectStep, Error, Result, RevSpec};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16#[non_exhaustive]
17pub struct StatusEntry {
18 pub code: String,
20 pub path: PathBuf,
27 pub old_path: Option<PathBuf>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Default)]
37#[non_exhaustive]
38pub struct BranchStatus {
39 pub head: Option<String>,
42 pub branch: Option<String>,
44 pub upstream: Option<String>,
46 pub ahead: Option<usize>,
48 pub behind: Option<usize>,
50 pub tracked_changes: usize,
53 pub untracked: usize,
55 pub conflicts: usize,
58}
59
60impl BranchStatus {
61 pub fn is_dirty(&self) -> bool {
63 self.tracked_changes > 0 || self.untracked > 0
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69#[non_exhaustive]
70pub struct Commit {
71 pub hash: String,
73 pub short_hash: String,
75 pub author: String,
77 pub date: String,
79 pub subject: String,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85#[non_exhaustive]
86pub struct Branch {
87 pub name: String,
89 pub current: bool,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
98#[non_exhaustive]
99pub struct StashEntry {
100 pub index: usize,
104 pub hash: String,
106 pub branch: Option<String>,
110 pub message: String,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118#[non_exhaustive]
119pub struct Worktree {
120 pub path: PathBuf,
127 pub branch: Option<String>,
129 pub head: Option<String>,
131 pub bare: bool,
133 pub detached: bool,
135 pub locked: bool,
137}
138
139pub(crate) fn parse_porcelain(output: &[u8]) -> Vec<StatusEntry> {
149 let mut entries = Vec::new();
150 let mut records = output.split(|&b| b == 0).filter(|rec| !rec.is_empty());
151 while let Some(rec) = records.next() {
152 let (Some(code), Some(&b' ')) = (rec.get(..2), rec.get(2)) else {
158 continue;
159 };
160 let path = &rec[3..];
161 let old_path = if matches!(code, [b'R' | b'C', _] | [_, b'R' | b'C']) {
167 records.next().map(vcs_diff::path_from_bytes)
168 } else {
169 None
170 };
171 entries.push(StatusEntry {
172 code: String::from_utf8_lossy(code).into_owned(),
174 path: vcs_diff::path_from_bytes(path),
175 old_path,
176 });
177 }
178 entries
179}
180
181#[doc(hidden)]
189pub fn parse_porcelain_v2(output: &str) -> BranchStatus {
190 let mut status = BranchStatus::default();
191 let mut records = output.split('\0');
192 while let Some(rec) = records.next() {
193 if let Some(rest) = rec.strip_prefix("# branch.oid ") {
194 status.head = (rest != "(initial)").then(|| rest.to_string());
196 } else if let Some(rest) = rec.strip_prefix("# branch.head ") {
197 status.branch = (rest != "(detached)").then(|| rest.to_string());
198 } else if let Some(rest) = rec.strip_prefix("# branch.upstream ") {
199 status.upstream = Some(rest.to_string());
200 } else if let Some(rest) = rec.strip_prefix("# branch.ab ") {
201 let mut parts = rest.split(' ');
203 status.ahead = parts
204 .next()
205 .and_then(|t| t.strip_prefix('+'))
206 .and_then(|n| n.parse().ok());
207 status.behind = parts
208 .next()
209 .and_then(|t| t.strip_prefix('-'))
210 .and_then(|n| n.parse().ok());
211 } else if rec.starts_with("1 ") {
212 status.tracked_changes += 1;
213 } else if rec.starts_with("2 ") {
214 status.tracked_changes += 1;
215 records.next();
218 } else if rec.starts_with("u ") {
219 status.tracked_changes += 1;
220 status.conflicts += 1;
221 } else if rec.starts_with("? ") {
222 status.untracked += 1;
223 }
224 }
226 status
227}
228
229pub(crate) fn parse_git_version(raw: &str) -> Option<vcs_diff::Version> {
233 vcs_diff::parse_dotted_version(raw)
234}
235
236pub(crate) fn parse_bisect_step(output: &str) -> Result<BisectStep> {
246 let mut result = None;
247
248 for raw_line in output.lines() {
249 let line = raw_line.trim();
250 let candidate = line
251 .strip_suffix(" is the first 'bad' commit")
252 .or_else(|| line.strip_suffix(" is the first bad commit"));
253
254 if let Some(oid) = candidate {
255 let revision = parse_bisect_oid(oid)?;
256 set_bisect_result(&mut result, BisectStep::FirstBad { revision })?;
257 continue;
258 }
259
260 if let Some(rest) = line.strip_prefix('[') {
261 let Some((oid, subject)) = rest.split_once("] ") else {
262 return Err(bisect_parse_error(format!(
263 "malformed next-candidate line: {line:?}"
264 )));
265 };
266 if subject.trim().is_empty() {
267 return Err(bisect_parse_error(format!(
268 "next-candidate line has no subject: {line:?}"
269 )));
270 }
271 let revision = parse_bisect_oid(oid)?;
272 set_bisect_result(&mut result, BisectStep::NextCandidate { revision })?;
273 }
274 }
275
276 result.ok_or_else(|| bisect_parse_error(format!("unrecognised bisect output: {output:?}")))
277}
278
279fn parse_bisect_oid(raw: &str) -> Result<RevSpec> {
280 let oid = raw.trim();
281 let valid = (4..=64).contains(&oid.len()) && oid.bytes().all(|byte| byte.is_ascii_hexdigit());
282 if !valid {
283 return Err(bisect_parse_error(format!(
284 "invalid bisect object id: {raw:?}"
285 )));
286 }
287 RevSpec::new(oid)
288}
289
290fn set_bisect_result(result: &mut Option<BisectStep>, next: BisectStep) -> Result<()> {
291 if result.is_some() {
292 return Err(bisect_parse_error(
293 "bisect output contains more than one possible result".to_string(),
294 ));
295 }
296 *result = Some(next);
297 Ok(())
298}
299
300fn bisect_parse_error(message: String) -> Error {
301 Error::parse(BINARY, message)
302}
303
304pub(crate) fn parse_nul_paths(output: &[u8]) -> Vec<PathBuf> {
311 output
312 .split(|&b| b == 0)
313 .filter(|path| !path.is_empty())
314 .map(vcs_diff::path_from_bytes)
315 .collect()
316}
317
318pub(crate) fn parse_log(output: &str) -> Vec<Commit> {
322 output
323 .split('\0')
324 .filter(|rec| !rec.is_empty())
325 .filter_map(|rec| {
326 let mut fields = rec.split('\u{1f}');
327 Some(Commit {
328 hash: fields.next()?.to_string(),
329 short_hash: fields.next()?.to_string(),
330 author: fields.next()?.to_string(),
331 date: fields.next()?.to_string(),
332 subject: fields.next().unwrap_or("").to_string(),
333 })
334 })
335 .collect()
336}
337
338pub(crate) fn parse_stash_list(output: &str) -> Vec<StashEntry> {
344 output
345 .split('\0')
346 .filter(|rec| !rec.is_empty())
347 .filter_map(|rec| {
348 let mut fields = rec.split('\u{1f}');
349 let selector = fields.next()?;
350 let hash = fields.next()?.to_string();
351 let subject = fields.next().unwrap_or("");
352 let index: usize = selector
353 .strip_prefix("stash@{")?
354 .strip_suffix('}')?
355 .parse()
356 .ok()?;
357 let (branch, message) = parse_stash_subject(subject);
358 Some(StashEntry {
359 index,
360 hash,
361 branch,
362 message,
363 })
364 })
365 .collect()
366}
367
368fn parse_stash_subject(subject: &str) -> (Option<String>, String) {
376 let Some(rest) = subject
377 .strip_prefix("WIP on ")
378 .or_else(|| subject.strip_prefix("On "))
379 else {
380 return (None, subject.to_string());
381 };
382 match rest.split_once(": ") {
383 Some((branch, message)) => {
384 let branch = (branch != "(no branch)").then(|| branch.to_string());
385 (branch, message.to_string())
386 }
387 None => (None, rest.to_string()),
388 }
389}
390
391pub(crate) fn parse_branches(output: &str) -> Vec<Branch> {
393 output
394 .lines()
395 .filter(|line| !line.trim().is_empty())
396 .filter_map(|line| {
397 let current = line.starts_with('*');
398 let name = line.get(1..).unwrap_or("").trim();
399 if name.is_empty() || name.starts_with('(') {
401 return None;
402 }
403 Some(Branch {
404 name: name.to_string(),
405 current,
406 })
407 })
408 .collect()
409}
410
411pub(crate) fn parse_worktree_porcelain(output: &[u8]) -> Vec<Worktree> {
432 let mut worktrees = Vec::new();
433 let mut current: Option<Worktree> = None;
434 let flush = |current: &mut Option<Worktree>, out: &mut Vec<Worktree>| {
435 if let Some(wt) = current.take() {
436 out.push(wt);
437 }
438 };
439 for line in output.split(|&b| b == b'\n') {
440 let line = line.strip_suffix(b"\r").unwrap_or(line);
442 if line.is_empty() {
443 flush(&mut current, &mut worktrees);
444 continue;
445 }
446 let (label, value) = match line.iter().position(|&b| b == b' ') {
449 Some(i) => (&line[..i], Some(&line[i + 1..])),
450 None => (line, None),
451 };
452 match label {
453 b"worktree" => {
455 flush(&mut current, &mut worktrees);
456 current = Some(Worktree {
457 path: value.map(vcs_diff::path_from_bytes).unwrap_or_default(),
459 branch: None,
460 head: None,
461 bare: false,
462 detached: false,
463 locked: false,
464 });
465 }
466 b"HEAD" => {
467 if let Some(wt) = current.as_mut() {
468 wt.head = value.map(|v| String::from_utf8_lossy(v).into_owned());
469 }
470 }
471 b"branch" => {
472 if let Some(wt) = current.as_mut() {
473 wt.branch = value.map(|v| {
475 let full = String::from_utf8_lossy(v);
476 full.strip_prefix("refs/heads/")
477 .unwrap_or(&full)
478 .to_string()
479 });
480 }
481 }
482 b"bare" => {
483 if let Some(wt) = current.as_mut() {
484 wt.bare = true;
485 }
486 }
487 b"detached" => {
488 if let Some(wt) = current.as_mut() {
489 wt.detached = true;
490 }
491 }
492 b"locked" => {
493 if let Some(wt) = current.as_mut() {
494 wt.locked = true;
495 }
496 }
497 _ => {}
498 }
499 }
500 flush(&mut current, &mut worktrees);
501 worktrees
502}
503
504#[derive(Debug, Clone, PartialEq, Eq)]
507#[non_exhaustive]
508pub struct CleanEntry {
509 pub path: PathBuf,
513 pub is_dir: bool,
516}
517
518pub(crate) fn parse_clean_output(output: &str) -> Vec<CleanEntry> {
530 output
531 .lines()
532 .filter_map(|line| {
533 let rest = line
534 .strip_prefix("Would remove ")
535 .or_else(|| line.strip_prefix("Removing "))?;
536 let mut decoded = vcs_diff::unquote_c_style_path(rest);
537 let is_dir = decoded.last() == Some(&b'/');
538 if is_dir {
539 decoded.pop();
540 }
541 Some(CleanEntry {
542 path: vcs_diff::path_from_bytes(&decoded),
543 is_dir,
544 })
545 })
546 .collect()
547}
548
549#[derive(Debug, Clone, PartialEq, Eq)]
552#[non_exhaustive]
553pub struct BlameLine {
554 pub commit: String,
556 pub orig_line: u32,
558 pub final_line: u32,
560 pub author: String,
562 pub author_time: i64,
564 pub author_tz: String,
566 pub content: String,
568}
569
570pub(crate) fn parse_blame_porcelain(output: &str) -> Vec<BlameLine> {
576 let mut lines = Vec::new();
577 let mut current: Option<BlameLine> = None;
578 for line in output.lines() {
579 if let Some(content) = line.strip_prefix('\t') {
581 if let Some(mut entry) = current.take() {
582 entry.content = content.to_string();
583 lines.push(entry);
584 }
585 continue;
586 }
587 let (label, value) = match line.split_once(' ') {
588 Some((l, v)) => (l, v),
589 None => (line, ""),
590 };
591 if (label.len() == 40 || label.len() == 64) && label.bytes().all(|b| b.is_ascii_hexdigit())
596 {
597 let mut nums = value.split(' ');
598 let orig = nums.next().and_then(|n| n.parse().ok()).unwrap_or(0);
599 let fin = nums.next().and_then(|n| n.parse().ok()).unwrap_or(0);
600 current = Some(BlameLine {
601 commit: label.to_string(),
602 orig_line: orig,
603 final_line: fin,
604 author: String::new(),
605 author_time: 0,
606 author_tz: String::new(),
607 content: String::new(),
608 });
609 continue;
610 }
611 let Some(entry) = current.as_mut() else {
612 continue;
613 };
614 match label {
615 "author" => entry.author = value.to_string(),
616 "author-time" => entry.author_time = value.parse().unwrap_or(0),
617 "author-tz" => entry.author_tz = value.to_string(),
618 _ => {}
621 }
622 }
623 lines
624}
625
626pub(crate) fn parse_shortstat(output: &str) -> DiffStat {
633 DiffStat::parse(output)
634}
635
636pub(crate) fn parse_ls_remote_heads(output: &str) -> Vec<String> {
639 output
640 .lines()
641 .filter_map(|line| {
642 let (_sha, refname) = line.split_once('\t')?;
643 refname
644 .trim()
645 .strip_prefix("refs/heads/")
646 .map(str::to_string)
647 })
648 .collect()
649}
650
651#[derive(Debug, Clone, PartialEq, Eq)]
656#[non_exhaustive]
657pub struct Remote {
658 pub name: String,
660 pub url: String,
662}
663
664pub(crate) fn parse_remotes(output: &str) -> Vec<Remote> {
675 let mut remotes: Vec<(Remote, bool)> = Vec::new();
676
677 for line in output.lines() {
678 let line = line.trim();
679 let Some((name, rest)) = line.split_once(char::is_whitespace) else {
680 continue;
681 };
682 let rest = rest.trim_start();
683 let (url, is_fetch) = if let Some(url) = rest.strip_suffix(" (fetch)") {
684 (url, true)
685 } else if let Some(url) = rest.strip_suffix(" (push)") {
686 (url, false)
687 } else {
688 (rest, false)
689 };
690 if name.is_empty() || url.is_empty() {
691 continue;
692 }
693
694 if let Some((remote, has_fetch)) =
695 remotes.iter_mut().find(|(remote, _)| remote.name == name)
696 {
697 if is_fetch && !*has_fetch {
698 remote.url = url.to_string();
699 *has_fetch = true;
700 }
701 } else {
702 remotes.push((
703 Remote {
704 name: name.to_string(),
705 url: url.to_string(),
706 },
707 is_fetch,
708 ));
709 }
710 }
711
712 remotes.into_iter().map(|(remote, _)| remote).collect()
713}
714
715#[derive(Debug, Clone, PartialEq, Eq)]
719#[non_exhaustive]
720pub struct Submodule {
721 pub name: String,
726 pub path: PathBuf,
731 pub url: String,
734 pub branch: Option<String>,
737}
738
739#[derive(Debug, Clone, Copy, PartialEq, Eq)]
742#[non_exhaustive]
743pub enum SubmoduleState {
744 Current,
748 Uninitialized,
751 RevisionMismatch,
755 Conflict,
757}
758
759#[derive(Debug, Clone, PartialEq, Eq)]
763#[non_exhaustive]
764pub struct SubmoduleStatus {
765 pub path: PathBuf,
768 pub sha: String,
773 pub state: SubmoduleState,
775 pub describe: Option<String>,
779}
780
781pub(crate) fn parse_gitmodules_config(output: &[u8]) -> Vec<Submodule> {
792 let mut subs: Vec<Submodule> = Vec::new();
793 for record in output.split(|&b| b == 0).filter(|r| !r.is_empty()) {
794 let (key_bytes, value_bytes) = match record.iter().position(|&b| b == b'\n') {
798 Some(i) => (&record[..i], &record[i + 1..]),
799 None => (record, &b""[..]),
800 };
801 let key = String::from_utf8_lossy(key_bytes);
803 let Some(rest) = key.strip_prefix("submodule.") else {
804 continue;
805 };
806 let Some((name, attr)) = rest.rsplit_once('.') else {
809 continue;
810 };
811 let sub = match subs.iter_mut().find(|s| s.name == name) {
813 Some(existing) => existing,
814 None => {
815 subs.push(Submodule {
816 name: name.to_string(),
817 path: PathBuf::new(),
818 url: String::new(),
819 branch: None,
820 });
821 subs.last_mut().expect("just pushed")
822 }
823 };
824 match attr {
825 "path" => sub.path = vcs_diff::path_from_bytes(value_bytes),
826 "url" => sub.url = String::from_utf8_lossy(value_bytes).into_owned(),
827 "branch" => sub.branch = Some(String::from_utf8_lossy(value_bytes).into_owned()),
828 _ => {}
831 }
832 }
833 subs
834}
835
836pub(crate) fn parse_submodule_status(output: &[u8]) -> Vec<SubmoduleStatus> {
847 let mut entries = Vec::new();
848 for line in output.split(|&b| b == b'\n') {
849 let line = line.strip_suffix(b"\r").unwrap_or(line);
851 if line.is_empty() {
852 continue;
853 }
854 let state = match line[0] {
855 b' ' => SubmoduleState::Current,
856 b'-' => SubmoduleState::Uninitialized,
857 b'+' => SubmoduleState::RevisionMismatch,
858 b'U' => SubmoduleState::Conflict,
859 _ => continue,
862 };
863 let rest = &line[1..];
864 let Some(sp) = rest.iter().position(|&b| b == b' ') else {
866 continue;
867 };
868 let sha = String::from_utf8_lossy(&rest[..sp]).into_owned();
869 let tail = &rest[sp + 1..];
870 let (path_bytes, describe) = match tail.last() {
872 Some(b')') => match tail
873 .windows(2)
874 .rposition(|w| w == b" (")
875 .filter(|&i| i + 2 < tail.len())
876 {
877 Some(i) => (
878 &tail[..i],
879 Some(String::from_utf8_lossy(&tail[i + 2..tail.len() - 1]).into_owned()),
880 ),
881 None => (tail, None),
882 },
883 _ => (tail, None),
884 };
885 entries.push(SubmoduleStatus {
886 path: vcs_diff::path_from_bytes(path_bytes),
887 sha,
888 state,
889 describe,
890 });
891 }
892 entries
893}
894
895#[cfg(test)]
896mod tests {
897 use super::*;
898
899 #[test]
900 fn porcelain_parses_codes_and_paths() {
901 let got = parse_porcelain(b" M src/lib.rs\0?? new file.txt\0A added.rs\0");
903 assert_eq!(
904 got,
905 vec![
906 StatusEntry {
907 code: " M".into(),
908 path: "src/lib.rs".into(),
909 old_path: None,
910 },
911 StatusEntry {
912 code: "??".into(),
913 path: "new file.txt".into(),
914 old_path: None,
915 },
916 StatusEntry {
917 code: "A ".into(),
918 path: "added.rs".into(),
919 old_path: None,
920 },
921 ]
922 );
923 }
924
925 #[cfg(unix)]
930 #[test]
931 fn porcelain_preserves_non_utf8_path_bytes() {
932 use std::os::unix::ffi::OsStrExt;
933 let got = parse_porcelain(b" M caf\xff.txt\0");
934 assert_eq!(got.len(), 1);
935 assert_eq!(got[0].path.as_os_str().as_bytes(), b"caf\xff.txt");
936 }
937
938 #[test]
939 fn porcelain_parses_rename_with_old_path() {
940 let got = parse_porcelain(b"R new.rs\0old.rs\0 M other.rs\0");
942 assert_eq!(
943 got,
944 vec![
945 StatusEntry {
946 code: "R ".into(),
947 path: "new.rs".into(),
948 old_path: Some("old.rs".into()),
949 },
950 StatusEntry {
951 code: " M".into(),
952 path: "other.rs".into(),
953 old_path: None,
954 },
955 ]
956 );
957 }
958
959 #[test]
963 fn porcelain_parses_worktree_rename_in_the_y_column() {
964 let got = parse_porcelain(b" R new.rs\0old.rs\0 M other.rs\0");
966 assert_eq!(
967 got,
968 vec![
969 StatusEntry {
970 code: " R".into(),
971 path: "new.rs".into(),
972 old_path: Some("old.rs".into()),
973 },
974 StatusEntry {
975 code: " M".into(),
976 path: "other.rs".into(),
977 old_path: None,
978 },
979 ],
980 "the source record must be consumed, not left as a phantom entry"
981 );
982 }
983
984 #[test]
985 fn porcelain_ignores_blank_and_short_records() {
986 assert!(parse_porcelain(b"\0 \0X\0").is_empty());
987 }
988
989 #[test]
993 fn porcelain_skips_non_ascii_status_records() {
994 assert!(parse_porcelain("𝓁abc\0".as_bytes()).is_empty());
995 let entries = parse_porcelain("𝓁abc\0 M a.rs\0".as_bytes());
997 assert_eq!(entries.len(), 1);
998 assert_eq!(entries[0].path, std::path::Path::new("a.rs"));
999 }
1000
1001 #[test]
1002 fn porcelain_v2_parses_branch_and_change_counts() {
1003 let out = concat!(
1006 "# branch.oid abcdef1234567890\0",
1007 "# branch.head main\0",
1008 "# branch.upstream origin/main\0",
1009 "# branch.ab +2 -1\0",
1010 "1 .M N... 100644 100644 100644 1111 2222 a.rs\0",
1011 "2 R. N... 100644 100644 100644 3333 4444 R100 new.rs\0",
1012 "1 trap.rs\0",
1013 "u UU N... 100644 100644 100644 100644 5 6 7 conflict.rs\0",
1014 "? untracked.txt\0",
1015 "! ignored.txt\0",
1016 );
1017 let s = parse_porcelain_v2(out);
1018 assert_eq!(s.head.as_deref(), Some("abcdef1234567890"));
1019 assert_eq!(s.branch.as_deref(), Some("main"));
1020 assert_eq!(s.upstream.as_deref(), Some("origin/main"));
1021 assert_eq!((s.ahead, s.behind), (Some(2), Some(1)));
1022 assert_eq!(
1023 s.tracked_changes, 3,
1024 "1 + 2(rename) + u; the trap is consumed"
1025 );
1026 assert_eq!(s.untracked, 1);
1027 assert_eq!(s.conflicts, 1);
1028 assert!(s.is_dirty());
1029 }
1030
1031 #[test]
1032 fn porcelain_v2_handles_unborn_detached_and_no_upstream() {
1033 let s = parse_porcelain_v2("# branch.oid (initial)\0# branch.head main\0");
1035 assert_eq!(s.head, None);
1036 assert_eq!(s.branch.as_deref(), Some("main"));
1037 assert_eq!(s.upstream, None);
1038 assert_eq!((s.ahead, s.behind), (None, None));
1039 assert!(!s.is_dirty());
1040
1041 let s = parse_porcelain_v2("# branch.oid deadbeef\0# branch.head (detached)\0");
1043 assert_eq!(s.head.as_deref(), Some("deadbeef"));
1044 assert_eq!(s.branch, None);
1045 assert_eq!(s.upstream, None);
1046 }
1047
1048 #[test]
1052 fn blame_line_porcelain_parses_headers_and_metadata() {
1053 let sha_a = "a".repeat(40);
1054 let sha_b = "b".repeat(40);
1055 let out = format!(
1056 "{sha_a} 1 1 2\nauthor Alice\nauthor-mail <a@x>\nauthor-time 1717500000\n\
1057 author-tz +0200\ncommitter Alice\nsummary first\nboundary\nfilename f.txt\n\
1058 \tline one\n\
1059 {sha_a} 2 2\nauthor Alice\nauthor-mail <a@x>\nauthor-time 1717500000\n\
1060 author-tz +0200\ncommitter Alice\nsummary first\nfilename f.txt\n\
1061 \tline two\n\
1062 {sha_b} 1 3 1\nauthor Bob\nauthor-mail <b@x>\nauthor-time 1717600000\n\
1063 author-tz -0500\ncommitter Bob\nsummary second\nfilename f.txt\n\
1064 \t\n"
1065 );
1066 let lines = parse_blame_porcelain(&out);
1067 assert_eq!(lines.len(), 3);
1068 assert_eq!(lines[0].commit, sha_a);
1069 assert_eq!(lines[0].orig_line, 1);
1070 assert_eq!(lines[0].final_line, 1);
1071 assert_eq!(lines[0].author, "Alice");
1072 assert_eq!(lines[0].author_time, 1717500000);
1073 assert_eq!(lines[0].author_tz, "+0200");
1074 assert_eq!(lines[0].content, "line one");
1075 assert_eq!(lines[1].final_line, 2);
1077 assert_eq!(lines[1].content, "line two");
1078 assert_eq!(lines[2].commit, sha_b);
1080 assert_eq!(lines[2].author, "Bob");
1081 assert_eq!(lines[2].content, "");
1082 }
1083
1084 #[test]
1085 fn blame_ignores_garbage_and_empty_input() {
1086 assert!(parse_blame_porcelain("").is_empty());
1087 assert!(parse_blame_porcelain("not a header\n\torphan content\n").is_empty());
1088 }
1089
1090 #[test]
1093 fn blame_recognises_sha256_object_ids() {
1094 let sha = "c".repeat(64);
1095 let out = format!(
1096 "{sha} 1 1 1\nauthor Carol\nauthor-mail <c@x>\nauthor-time 1717700000\n\
1097 author-tz +0000\ncommitter Carol\nsummary s\nfilename f.txt\n\
1098 \tline\n"
1099 );
1100 let lines = parse_blame_porcelain(&out);
1101 assert_eq!(
1102 lines.len(),
1103 1,
1104 "a SHA-256 blame must parse, not drop to empty"
1105 );
1106 assert_eq!(lines[0].commit, sha);
1107 assert_eq!(lines[0].author, "Carol");
1108 assert_eq!(lines[0].content, "line");
1109 }
1110
1111 #[test]
1112 fn git_version_parses_real_world_shapes() {
1113 let v = parse_git_version("git version 2.54.0.windows.1").unwrap();
1116 assert_eq!((v.major, v.minor, v.patch), (2, 54, 0));
1117 let v = parse_git_version("git version 2.41.0-rc1").unwrap();
1118 assert_eq!((v.major, v.minor, v.patch), (2, 41, 0));
1119 let v = parse_git_version("git version 2.54").unwrap();
1120 assert_eq!(v.patch, 0, "missing patch defaults to 0");
1121 assert!(parse_git_version("no digits here").is_none());
1122 assert!(parse_git_version("git version unknowable").is_none());
1123 }
1124
1125 #[test]
1126 fn nul_paths_split_and_keep_special_characters() {
1127 assert_eq!(
1128 parse_nul_paths(b"a.rs\0sub/with space.rs\0"),
1129 [PathBuf::from("a.rs"), PathBuf::from("sub/with space.rs")]
1130 );
1131 assert!(parse_nul_paths(b"").is_empty());
1132 }
1133
1134 #[test]
1135 fn log_splits_unit_separated_fields() {
1136 let input = "abc123\u{1f}abc\u{1f}Ada\u{1f}2026-05-31T10:00:00+00:00\u{1f}Add feature\0\
1137 def456\u{1f}def\u{1f}Linus\u{1f}2026-05-30T09:00:00+00:00\u{1f}Fix bug\0";
1138 let got = parse_log(input);
1139 assert_eq!(got.len(), 2);
1140 assert_eq!(
1141 got[0],
1142 Commit {
1143 hash: "abc123".into(),
1144 short_hash: "abc".into(),
1145 author: "Ada".into(),
1146 date: "2026-05-31T10:00:00+00:00".into(),
1147 subject: "Add feature".into(),
1148 }
1149 );
1150 assert_eq!(got[1].subject, "Fix bug");
1151 }
1152
1153 #[test]
1154 fn log_tolerates_empty_subject() {
1155 let got = parse_log("h\u{1f}h\u{1f}A\u{1f}2026-05-31T10:00:00+00:00\u{1f}\0");
1156 assert_eq!(got[0].subject, "");
1157 }
1158
1159 #[test]
1160 fn branches_marks_current_and_skips_detached() {
1161 let got = parse_branches("* main\n feature\n (HEAD detached at abc123)\n");
1162 assert_eq!(
1163 got,
1164 vec![
1165 Branch {
1166 name: "main".into(),
1167 current: true
1168 },
1169 Branch {
1170 name: "feature".into(),
1171 current: false
1172 },
1173 ]
1174 );
1175 }
1176
1177 #[test]
1178 fn worktrees_parse_branch_detached_and_bare() {
1179 let input = "worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\
1180 \nworktree /repo/wt\nHEAD def456\ndetached\n\
1181 \nworktree /repo/bare\nbare\n";
1182 let got = parse_worktree_porcelain(input.as_bytes());
1183 assert_eq!(got.len(), 3);
1184 assert_eq!(got[0].path, PathBuf::from("/repo"));
1185 assert_eq!(got[0].branch.as_deref(), Some("main"));
1186 assert_eq!(got[0].head.as_deref(), Some("abc123"));
1187 assert!(got[1].detached && got[1].branch.is_none());
1188 assert!(got[2].bare && got[2].head.is_none());
1189 }
1190
1191 #[test]
1192 fn worktrees_parse_crlf_without_trailing_carriage_returns() {
1193 let got = parse_worktree_porcelain(
1194 b"worktree /repo/wt\r\nHEAD abc123\r\nbranch refs/heads/main\r\nlocked\r\n\r\n\
1195 worktree /repo/bare\r\nbare\r\n\r\n\
1196 worktree /repo/detached\r\nHEAD def456\r\ndetached\r\n",
1197 );
1198 assert_eq!(got.len(), 3);
1199 assert_eq!(got[0].path, PathBuf::from("/repo/wt"));
1200 assert_eq!(got[0].head.as_deref(), Some("abc123"));
1201 assert_eq!(got[0].branch.as_deref(), Some("main"));
1202 assert!(got[0].locked);
1203 assert!(got[1].bare && got[1].head.is_none());
1204 assert!(got[2].detached && got[2].branch.is_none());
1205 assert_eq!(got[2].head.as_deref(), Some("def456"));
1206 }
1207
1208 #[cfg(unix)]
1213 #[test]
1214 fn worktrees_preserve_non_utf8_path_bytes() {
1215 use std::os::unix::ffi::OsStrExt;
1216 let got = parse_worktree_porcelain(b"worktree /repo/wt-caf\xff\nHEAD abc123\n");
1217 assert_eq!(got.len(), 1);
1218 assert_eq!(got[0].path.as_os_str().as_bytes(), b"/repo/wt-caf\xff");
1219 assert_eq!(got[0].head.as_deref(), Some("abc123"));
1220 }
1221
1222 #[test]
1223 fn worktrees_parse_last_record_without_trailing_blank() {
1224 let got = parse_worktree_porcelain(b"worktree /only\nHEAD aaa\nbranch refs/heads/x\n");
1226 assert_eq!(got.len(), 1);
1227 assert_eq!(got[0].branch.as_deref(), Some("x"));
1228 }
1229
1230 #[test]
1231 fn shortstat_parses_all_clauses() {
1232 let got = parse_shortstat(" 3 files changed, 12 insertions(+), 4 deletions(-)\n");
1233 assert_eq!(got, DiffStat::new(3, 12, 4));
1234 }
1235
1236 #[test]
1237 fn shortstat_tolerates_missing_clauses_and_empty() {
1238 let only_ins = parse_shortstat(" 1 file changed, 2 insertions(+)\n");
1240 assert_eq!(only_ins.insertions, 2);
1241 assert_eq!(only_ins.deletions, 0);
1242 assert_eq!(parse_shortstat(""), DiffStat::default());
1243 }
1244
1245 #[test]
1246 fn gitmodules_config_parses_z_framed_records() {
1247 let out = b"submodule.libs/sub.path\nlibs/sub\0\
1250 submodule.libs/sub.url\n../sub\0\
1251 submodule.libs/sub.branch\nmain\0\
1252 submodule.second.path\nsecond\0\
1253 submodule.second.url\n../sub\0";
1254 let got = parse_gitmodules_config(out);
1255 assert_eq!(
1256 got,
1257 vec![
1258 Submodule {
1259 name: "libs/sub".into(),
1260 path: "libs/sub".into(),
1261 url: "../sub".into(),
1262 branch: Some("main".into()),
1263 },
1264 Submodule {
1265 name: "second".into(),
1266 path: "second".into(),
1267 url: "../sub".into(),
1268 branch: None,
1269 },
1270 ]
1271 );
1272 }
1273
1274 #[test]
1275 fn gitmodules_config_keeps_value_with_equals_and_ignores_non_submodule_keys() {
1276 let out = b"submodule.x.url\nhttps://h/r?a=b\0\
1279 core.autocrlf\nfalse\0\
1280 submodule.x.path\nx\0";
1281 let got = parse_gitmodules_config(out);
1282 assert_eq!(got.len(), 1);
1283 assert_eq!(got[0].url, "https://h/r?a=b");
1284 assert_eq!(got[0].path, PathBuf::from("x"));
1285 }
1286
1287 #[test]
1288 fn gitmodules_config_empty_is_no_submodules() {
1289 assert!(parse_gitmodules_config(b"").is_empty());
1290 }
1291
1292 #[test]
1293 fn remotes_empty_output_is_empty() {
1294 assert!(parse_remotes("\n \t\r\n").is_empty());
1295 }
1296
1297 #[test]
1298 fn remotes_one_remote_prefers_fetch_url() {
1299 assert_eq!(
1300 parse_remotes(
1301 "origin\thttps://example.test/fetch.git (fetch)\norigin\thttps://example.test/push.git (push)\n"
1302 ),
1303 vec![Remote {
1304 name: "origin".into(),
1305 url: "https://example.test/fetch.git".into(),
1306 }]
1307 );
1308 }
1309
1310 #[test]
1311 fn remotes_preserve_spaces_and_prefer_the_fetch_url() {
1312 assert_eq!(
1313 parse_remotes(
1314 "origin C:/Users/John Doe/repo (push)\n\
1315 origin C:/Users/John Doe/fetch repo (fetch)\n"
1316 ),
1317 vec![Remote {
1318 name: "origin".into(),
1319 url: "C:/Users/John Doe/fetch repo".into(),
1320 }]
1321 );
1322 }
1323
1324 #[test]
1325 fn remotes_multiple_rows_dedupe_and_tolerate_malformed_output() {
1326 assert_eq!(
1327 parse_remotes(
1328 "origin ssh://example.test/push.git (push)\n\
1329 upstream https://example.test/upstream.git (fetch)\r\n\
1330 malformed-only-name\n\
1331 origin https://example.test/fetch.git (fetch)\n\
1332 upstream https://example.test/upstream-push.git (push)\n",
1333 ),
1334 vec![
1335 Remote {
1336 name: "origin".into(),
1337 url: "https://example.test/fetch.git".into(),
1338 },
1339 Remote {
1340 name: "upstream".into(),
1341 url: "https://example.test/upstream.git".into(),
1342 },
1343 ]
1344 );
1345 }
1346
1347 #[cfg(unix)]
1348 #[test]
1349 fn gitmodules_config_preserves_non_utf8_path_bytes() {
1350 use std::os::unix::ffi::OsStrExt;
1351 let out = b"submodule.s.path\ncaf\xff/sub\0submodule.s.url\n../sub\0";
1352 let got = parse_gitmodules_config(out);
1353 assert_eq!(got.len(), 1);
1354 assert_eq!(got[0].path.as_os_str().as_bytes(), b"caf\xff/sub");
1355 }
1356
1357 #[test]
1358 fn submodule_status_parses_all_prefix_states() {
1359 let out = b" 833caa0 libs/sub (heads/main)\n\
1362 +530fd06 plus/mod (530fd06)\n\
1363 U000aaaa conf/mod (heads/topic)\n\
1364 -deadbee minus/mod\n";
1365 let got = parse_submodule_status(out);
1366 assert_eq!(got.len(), 4);
1367
1368 assert_eq!(got[0].state, SubmoduleState::Current);
1369 assert_eq!(got[0].sha, "833caa0");
1370 assert_eq!(got[0].path, PathBuf::from("libs/sub"));
1371 assert_eq!(got[0].describe.as_deref(), Some("heads/main"));
1372
1373 assert_eq!(got[1].state, SubmoduleState::RevisionMismatch);
1374 assert_eq!(got[1].path, PathBuf::from("plus/mod"));
1375 assert_eq!(got[1].describe.as_deref(), Some("530fd06"));
1376
1377 assert_eq!(got[2].state, SubmoduleState::Conflict);
1378 assert_eq!(got[2].path, PathBuf::from("conf/mod"));
1379
1380 assert_eq!(got[3].state, SubmoduleState::Uninitialized);
1381 assert_eq!(got[3].sha, "deadbee");
1382 assert_eq!(got[3].path, PathBuf::from("minus/mod"));
1383 assert_eq!(got[3].describe, None);
1384 }
1385
1386 #[test]
1387 fn submodule_status_handles_spaced_path_and_crlf() {
1388 let out = b" abc123 dir with space/sub (v1.0)\r\n";
1391 let got = parse_submodule_status(out);
1392 assert_eq!(got.len(), 1);
1393 assert_eq!(got[0].path, PathBuf::from("dir with space/sub"));
1394 assert_eq!(got[0].describe.as_deref(), Some("v1.0"));
1395 }
1396
1397 #[test]
1398 fn submodule_status_without_describe_keeps_full_path() {
1399 let out = b" abc123 libs/no-describe\n";
1401 let got = parse_submodule_status(out);
1402 assert_eq!(got.len(), 1);
1403 assert_eq!(got[0].path, PathBuf::from("libs/no-describe"));
1404 assert_eq!(got[0].describe, None);
1405 }
1406
1407 #[test]
1408 fn submodule_status_empty_is_no_entries() {
1409 assert!(parse_submodule_status(b"").is_empty());
1410 }
1411
1412 #[test]
1413 fn stash_list_parses_default_and_custom_labels() {
1414 let out = concat!(
1418 "stash@{0}\u{1f}aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u{1f}",
1419 "On feature: my label\0",
1420 "stash@{1}\u{1f}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\u{1f}",
1421 "WIP on feature: f1c02c2 init\0",
1422 );
1423 let got = parse_stash_list(out);
1424 assert_eq!(got.len(), 2);
1425 assert_eq!(got[0].index, 0);
1426 assert_eq!(got[0].hash, "a".repeat(40));
1427 assert_eq!(got[0].branch.as_deref(), Some("feature"));
1428 assert_eq!(got[0].message, "my label");
1429 assert_eq!(got[1].index, 1);
1430 assert_eq!(got[1].branch.as_deref(), Some("feature"));
1431 assert_eq!(got[1].message, "f1c02c2 init");
1432 }
1433
1434 #[test]
1435 fn stash_list_detached_head_has_no_branch() {
1436 let out = "stash@{0}\u{1f}cccccccccccccccccccccccccccccccccccccccc\u{1f}\
1437 On (no branch): detached label\0";
1438 let got = parse_stash_list(out);
1439 assert_eq!(got.len(), 1);
1440 assert_eq!(got[0].branch, None);
1441 assert_eq!(got[0].message, "detached label");
1442 }
1443
1444 #[test]
1445 fn stash_list_empty_is_no_entries() {
1446 assert!(parse_stash_list("").is_empty());
1447 }
1448
1449 #[test]
1450 fn stash_list_skips_a_record_with_an_unrecognized_selector() {
1451 let out = "not-a-selector\u{1f}deadbeef\u{1f}subject\0";
1454 assert!(parse_stash_list(out).is_empty());
1455 }
1456
1457 #[test]
1458 fn clean_output_parses_dry_run_files_and_directories() {
1459 let out = "Would remove junk.txt\nWould remove sub/\n";
1460 let got = parse_clean_output(out);
1461 assert_eq!(
1462 got,
1463 vec![
1464 CleanEntry {
1465 path: PathBuf::from("junk.txt"),
1466 is_dir: false,
1467 },
1468 CleanEntry {
1469 path: PathBuf::from("sub"),
1470 is_dir: true,
1471 },
1472 ]
1473 );
1474 }
1475
1476 #[test]
1477 fn clean_output_parses_forced_removals() {
1478 let out = "Removing junk.txt\nRemoving sub/\n";
1479 let got = parse_clean_output(out);
1480 assert_eq!(got.len(), 2);
1481 assert_eq!(got[0].path, PathBuf::from("junk.txt"));
1482 assert!(!got[0].is_dir);
1483 assert_eq!(got[1].path, PathBuf::from("sub"));
1484 assert!(got[1].is_dir);
1485 }
1486
1487 #[test]
1488 fn clean_output_unquotes_c_quoted_paths() {
1489 let out = "Would remove \"caf\\303\\251.txt\"\nWould remove \"w\\303\\251ird dir/\"\n";
1492 let got = parse_clean_output(out);
1493 assert_eq!(got.len(), 2);
1494 assert_eq!(got[0].path, PathBuf::from("café.txt"));
1495 assert!(!got[0].is_dir);
1496 assert_eq!(got[1].path, PathBuf::from("wéird dir"));
1497 assert!(got[1].is_dir);
1498 }
1499
1500 #[test]
1501 fn clean_output_ignores_unrecognized_lines() {
1502 let out = "Skipping repository sub/nested\nWould remove real.txt\n";
1505 let got = parse_clean_output(out);
1506 assert_eq!(got.len(), 1);
1507 assert_eq!(got[0].path, PathBuf::from("real.txt"));
1508 }
1509
1510 #[test]
1511 fn clean_output_empty_is_no_entries() {
1512 assert!(parse_clean_output("").is_empty());
1513 }
1514}
1515
1516#[cfg(test)]
1523mod proptests {
1524 use super::*;
1525 use proptest::prelude::*;
1526
1527 fn structured_line() -> impl Strategy<Value = String> {
1530 prop_oneof![
1531 Just("diff --git a/f b/f\n".to_string()),
1532 Just("--- a/f\n".to_string()),
1533 Just("+++ b/f\n".to_string()),
1534 Just("@@ -1,2 +3,4 @@ ctx\n".to_string()),
1535 Just("@@ -1 +1 @@\n".to_string()),
1536 Just("rename from {old => new}.rs\n".to_string()),
1537 Just("R100\told\tnew\n".to_string()),
1538 Just(format!("{}\n", "a".repeat(40))), "[-+ ]?[a-zé\t]{0,12}\n", "[ MARD?]{0,2} [a-zé/]{0,8}\0", ]
1542 }
1543
1544 fn structured_doc() -> impl Strategy<Value = String> {
1545 prop::collection::vec(structured_line(), 0..40).prop_map(|lines| lines.concat())
1546 }
1547
1548 proptest! {
1549 #[test]
1551 fn parsers_never_panic_on_arbitrary_text(s in any::<String>()) {
1552 let _ = parse_porcelain(s.as_bytes());
1553 let _ = parse_porcelain_v2(&s);
1554 let _ = parse_log(&s);
1555 let _ = parse_branches(&s);
1556 let _ = parse_worktree_porcelain(s.as_bytes());
1557 let _ = parse_blame_porcelain(&s);
1558 let _ = parse_shortstat(&s);
1559 let _ = parse_ls_remote_heads(&s);
1560 let _ = parse_remotes(&s);
1561 let _ = parse_nul_paths(s.as_bytes());
1562 let _ = parse_git_version(&s);
1563 let _ = parse_stash_list(&s);
1564 let _ = parse_clean_output(&s);
1565 }
1566
1567 #[test]
1571 fn byte_parsers_never_panic_on_arbitrary_bytes(b in any::<Vec<u8>>()) {
1572 let _ = parse_porcelain(&b);
1573 let _ = parse_nul_paths(&b);
1574 let _ = parse_worktree_porcelain(&b);
1575 }
1576
1577 #[test]
1579 fn parsers_never_panic_on_structured_text(s in structured_doc()) {
1580 let _ = parse_porcelain(s.as_bytes());
1581 let _ = parse_porcelain_v2(&s);
1582 let _ = parse_log(&s);
1583 let _ = parse_blame_porcelain(&s);
1584 let _ = parse_gitmodules_config(s.as_bytes());
1585 let _ = parse_submodule_status(s.as_bytes());
1586 let _ = parse_stash_list(&s);
1587 let _ = parse_clean_output(&s);
1588 }
1589
1590 #[test]
1593 fn porcelain_v2_never_panics(records in prop::collection::vec(
1594 prop_oneof![
1595 Just("# branch.oid (initial)".to_string()),
1596 Just("# branch.head main".to_string()),
1597 Just("# branch.ab +1 -2".to_string()),
1598 "1 [.MADRCU]{2} [a-zé /]{0,10}".prop_map(|s| s),
1599 "2 R\\. .* R100 [a-zé /]{0,8}".prop_map(|s| s),
1600 "u UU [a-zé /]{0,8}".prop_map(|s| s),
1601 "\\? [a-zé /]{0,8}".prop_map(|s| s),
1602 "[a-zé0-9# ]{0,12}".prop_map(|s| s),
1603 ],
1604 0..20,
1605 ).prop_map(|r| r.join("\0"))) {
1606 let _ = parse_porcelain_v2(&records);
1607 }
1608 }
1609}