1use std::collections::HashMap;
5use std::ffi::OsStr;
6use std::path::{Path, PathBuf};
7use std::process::{Command, Stdio};
8
9use crate::EngineError;
10use crate::ports;
11
12#[derive(Debug, Clone)]
13pub struct Repo {
14 root: PathBuf,
15}
16
17impl Repo {
18 pub fn open(dir: &Path) -> Result<Self, EngineError> {
20 let probe = Repo {
21 root: dir.to_path_buf(),
22 };
23 let out = probe.run(["rev-parse", "--show-toplevel"], None)?;
24 let root = PathBuf::from(String::from_utf8_lossy(trim_newline(&out)).into_owned());
25 Ok(Repo { root })
26 }
27
28 pub fn root(&self) -> &Path {
29 &self.root
30 }
31
32 fn run<I, S>(&self, args: I, stdin: Option<&[u8]>) -> Result<Vec<u8>, EngineError>
39 where
40 I: IntoIterator<Item = S>,
41 S: AsRef<OsStr>,
42 {
43 self.run_env(args, stdin, &[])
44 }
45
46 fn run_env<I, S>(
48 &self,
49 args: I,
50 stdin: Option<&[u8]>,
51 env: &[(&str, &OsStr)],
52 ) -> Result<Vec<u8>, EngineError>
53 where
54 I: IntoIterator<Item = S>,
55 S: AsRef<OsStr>,
56 {
57 let mut cmd = Command::new("git");
58 cmd.arg("-c")
61 .arg("core.quotepath=false")
62 .args(args)
63 .current_dir(&self.root)
64 .stdin(if stdin.is_some() {
65 Stdio::piped()
66 } else {
67 Stdio::null()
68 })
69 .stdout(Stdio::piped())
70 .stderr(Stdio::piped());
71 for (k, v) in env {
72 cmd.env(k, v);
73 }
74
75 let mut child = cmd
76 .spawn()
77 .map_err(|e| EngineError::GitSpawn { source: e })?;
78 if let Some(data) = stdin {
79 use std::io::Write;
80 let mut pipe = child.stdin.take().expect("stdin was requested");
81 pipe.write_all(data)
82 .map_err(|e| EngineError::GitSpawn { source: e })?;
83 }
85 let out = child
86 .wait_with_output()
87 .map_err(|e| EngineError::GitSpawn { source: e })?;
88
89 if !out.status.success() {
90 return Err(EngineError::GitCommand {
91 command: describe(&cmd),
92 code: out.status.code(),
93 stderr: String::from_utf8_lossy(&out.stderr[..out.stderr.len().min(800)])
94 .into_owned(),
95 });
96 }
97 Ok(out.stdout)
98 }
99
100 fn run_status<I, S>(&self, args: I) -> Result<std::process::ExitStatus, EngineError>
106 where
107 I: IntoIterator<Item = S>,
108 S: AsRef<OsStr>,
109 {
110 Command::new("git")
111 .arg("-c")
112 .arg("core.quotepath=false")
113 .args(args)
114 .current_dir(&self.root)
115 .stdin(Stdio::null())
116 .stdout(Stdio::null())
117 .stderr(Stdio::null())
118 .status()
119 .map_err(|e| EngineError::GitSpawn { source: e })
120 }
121
122 fn blob(&self, rev: &str, path: &[u8]) -> Result<Option<Vec<u8>>, EngineError> {
125 Ok(self.blobs(&[(rev, path)])?.pop().flatten())
126 }
127
128 fn blobs(&self, specs: &[(&str, &[u8])]) -> Result<Vec<Option<Vec<u8>>>, EngineError> {
142 if specs.is_empty() {
143 return Ok(Vec::new());
144 }
145 let wire: Vec<Vec<u8>> = specs
146 .iter()
147 .map(|(rev, path)| {
148 let mut s = rev.as_bytes().to_vec();
149 s.push(b':');
150 s.extend_from_slice(path);
151 s
152 })
153 .collect();
154 let mut stdin = Vec::new();
155 for s in &wire {
156 stdin.extend_from_slice(s);
157 stdin.push(0);
158 }
159 let out = self.run(["cat-file", "--batch", "-z"], Some(&stdin))?;
160 parse_batch_blobs(&out, &wire).map_err(|msg| EngineError::GitCommand {
161 command: format!("cat-file --batch ({} specs)", specs.len()),
162 code: None,
163 stderr: msg,
164 })
165 }
166
167 fn rev_parse(&self, rev: &str) -> Result<String, EngineError> {
169 let out = self.run(
170 ["rev-parse", "--verify", &format!("{rev}^{{commit}}")],
171 None,
172 )?;
173 Ok(String::from_utf8_lossy(trim_newline(&out)).into_owned())
174 }
175
176 fn rev_parse_commit_or_tree(&self, rev: &str) -> Result<String, EngineError> {
180 self.rev_parse(rev)
181 .or_else(|_| self.rev_parse_raw(&format!("{rev}^{{tree}}")))
182 }
183
184 fn rev_parse_raw(&self, expr: &str) -> Result<String, EngineError> {
186 let out = self.run(["rev-parse", "--verify", expr], None)?;
187 Ok(String::from_utf8_lossy(trim_newline(&out)).into_owned())
188 }
189
190 fn merge_base(&self, a: &str, b: &str) -> Result<String, EngineError> {
191 let out = self.run(["merge-base", a, b], None)?;
192 Ok(String::from_utf8_lossy(trim_newline(&out)).into_owned())
193 }
194
195 fn common_dir(&self) -> Result<PathBuf, EngineError> {
198 let out = self.run(["rev-parse", "--git-common-dir"], None)?;
199 let p = PathBuf::from(String::from_utf8_lossy(trim_newline(&out)).into_owned());
200 Ok(if p.is_absolute() {
201 p
202 } else {
203 self.root.join(p)
204 })
205 }
206}
207
208impl ports::ObjectReader for Repo {
215 fn blob(&self, rev: &str, path: &[u8]) -> Result<Option<Vec<u8>>, EngineError> {
216 Repo::blob(self, rev, path)
217 }
218
219 fn blobs(&self, specs: &[(&str, &[u8])]) -> Result<Vec<Option<Vec<u8>>>, EngineError> {
220 Repo::blobs(self, specs)
221 }
222
223 fn require_object(&self, oid: &str) -> Result<(), EngineError> {
224 self.run(["cat-file", "-e", oid], None).map(|_| ())
225 }
226}
227
228impl ports::ObjectWriter for Repo {
229 fn write_blob(&self, content: &[u8]) -> Result<String, EngineError> {
230 let out = self.run(["hash-object", "-w", "--stdin"], Some(content))?;
231 Ok(String::from_utf8_lossy(trim_newline(&out)).into_owned())
232 }
233}
234
235impl ports::RangeResolver for Repo {
236 fn merge_base(&self, a: &str, b: &str) -> Result<String, EngineError> {
237 Repo::merge_base(self, a, b)
238 }
239
240 fn resolve_endpoint(&self, rev: &str) -> Result<String, EngineError> {
241 self.rev_parse_commit_or_tree(rev)
242 }
243}
244
245impl ports::Ancestry for Repo {
246 fn commit_of(&self, spec: &str) -> Result<Option<String>, EngineError> {
247 Ok(self.rev_parse(spec).ok())
250 }
251
252 fn is_ancestor(&self, older: &str, newer: &str) -> Result<bool, EngineError> {
253 let status = self.run_status(["merge-base", "--is-ancestor", older, newer])?;
258 match status.code() {
259 Some(0) => Ok(true),
260 Some(1) => Ok(false),
261 other => Err(EngineError::GitCommand {
262 command: format!("merge-base --is-ancestor {older} {newer}"),
263 code: other,
264 stderr: String::new(),
265 }),
266 }
267 }
268}
269
270impl ports::Fetcher for Repo {
271 fn fetch(&self, remote: &str, refspecs: &[&str]) -> Result<(), EngineError> {
272 let mut args = vec!["fetch", "--quiet", remote];
273 args.extend(refspecs);
274 self.run(args, None).map(|_| ())
275 }
276}
277
278impl ports::TreeResolver for Repo {
279 fn tree_of(&self, rev: &str) -> Result<String, EngineError> {
280 self.rev_parse_raw(&format!("{rev}^{{tree}}"))
281 }
282}
283
284impl ports::DiffSource for Repo {
285 fn raw_records(&self, base: &str, head: &str) -> Result<Vec<u8>, EngineError> {
287 self.run(
288 [
289 "diff-tree",
290 "-r",
291 "-z",
292 "--raw",
293 "--full-index",
294 "--no-renames",
295 base,
296 head,
297 ],
298 None,
299 )
300 }
301
302 fn canonical_patch(&self, base: &str, head: &str) -> Result<Vec<u8>, EngineError> {
303 self.run(
304 [
305 "diff-tree",
306 "-r",
307 "-U0",
308 "--no-renames",
309 "--no-color",
310 "--no-ext-diff",
311 base,
312 head,
313 ],
314 None,
315 )
316 }
317
318 fn rename_records(&self, base: &str, head: &str) -> Result<Vec<u8>, EngineError> {
319 self.run(
320 ["diff-tree", "-r", "-M", "-z", "--name-status", base, head],
321 None,
322 )
323 }
324}
325
326impl ports::RecountSource for Repo {
327 fn recount_patch(&self, from: &str, to: &str) -> Result<Vec<u8>, EngineError> {
333 self.run(["diff-tree", "-r", "-U0", "--no-renames", from, to], None)
334 }
335}
336
337impl ports::AttributeSource for Repo {
338 fn check_attr(
339 &self,
340 attr: &str,
341 paths: &[&[u8]],
342 ) -> Result<Vec<ports::AttrValue>, EngineError> {
343 if paths.is_empty() {
344 return Ok(Vec::new());
345 }
346 let mut stdin: Vec<u8> = Vec::new();
347 for p in paths {
348 stdin.extend_from_slice(p);
349 stdin.push(0);
350 }
351 let out = self.run(["check-attr", "-z", "--stdin", attr], Some(&stdin))?;
352 let fields: Vec<&[u8]> = out.split(|&b| b == 0).collect();
354 Ok(fields
355 .chunks_exact(3)
356 .map(|triple| ports::AttrValue {
357 path: triple[0].to_vec(),
358 value: triple[2].to_vec(),
359 })
360 .collect())
361 }
362}
363
364pub struct ScratchIndex {
370 repo: Repo,
371 _dir: tempfile::TempDir,
372 index: std::ffi::OsString,
373}
374
375impl ScratchIndex {
376 fn open(repo: &Repo) -> Result<Self, EngineError> {
380 let dir = tempfile::TempDir::new().map_err(|e| EngineError::GitSpawn { source: e })?;
381 let index = dir.path().join("index").into_os_string();
382 Ok(ScratchIndex {
383 repo: repo.clone(),
384 _dir: dir,
385 index,
386 })
387 }
388
389 fn env(&self) -> [(&str, &OsStr); 1] {
390 [("GIT_INDEX_FILE", self.index.as_os_str())]
391 }
392}
393
394impl ports::TreeBuilder for Repo {
395 type Session = ScratchIndex;
396
397 fn begin_from_tree(&self, tree_ish: &str) -> Result<ScratchIndex, EngineError> {
398 let idx = ScratchIndex::open(self)?;
399 self.run_env(["read-tree", tree_ish], None, &idx.env())?;
400 Ok(idx)
401 }
402
403 fn begin_from_current_index(&self) -> Result<ScratchIndex, EngineError> {
404 let entries = self.run(["ls-files", "-s", "-z"], None)?;
408 for record in entries.split(|&b| b == 0) {
409 let meta = record.split(|&b| b == b'\t').next().unwrap_or(record);
410 if meta.ends_with(b" 1") || meta.ends_with(b" 2") || meta.ends_with(b" 3") {
411 return Err(EngineError::Range(
412 "index has unmerged entries — resolve conflicts before reviewing \
413 uncommitted changes"
414 .into(),
415 ));
416 }
417 }
418 let idx = ScratchIndex::open(self)?;
419 if !entries.is_empty() {
420 self.run_env(
421 ["update-index", "-z", "--index-info"],
422 Some(&entries),
423 &idx.env(),
424 )?;
425 }
426 Ok(idx)
427 }
428}
429
430impl ports::IndexSession for ScratchIndex {
431 fn stage(&mut self, entries: &[ports::IndexEntry]) -> Result<(), EngineError> {
432 if entries.is_empty() {
433 return Ok(());
434 }
435 let mut feed: Vec<u8> = Vec::new();
436 for e in entries {
437 feed.extend_from_slice(&index_record(e));
438 feed.push(0);
439 }
440 self.repo
441 .run_env(
442 ["update-index", "-z", "--index-info"],
443 Some(&feed),
444 &self.env(),
445 )
446 .map(|_| ())
447 }
448
449 fn stage_from_worktree(&mut self, nul_paths: &[u8]) -> Result<(), EngineError> {
450 if nul_paths.is_empty() {
451 return Ok(());
452 }
453 self.repo
454 .run_env(
455 ["update-index", "--add", "--remove", "-z", "--stdin"],
456 Some(nul_paths),
457 &self.env(),
458 )
459 .map(|_| ())
460 }
461
462 fn write_tree(&self) -> Result<String, EngineError> {
463 let out = self.repo.run_env(["write-tree"], None, &self.env())?;
464 Ok(String::from_utf8_lossy(&out).trim().to_string())
465 }
466}
467
468const ZERO_OID: &str = "0000000000000000000000000000000000000000";
469
470fn index_record(e: &ports::IndexEntry) -> Vec<u8> {
473 let (mode, oid, path) = match e {
474 ports::IndexEntry::Set { mode, oid, path } => (mode.as_str(), oid.as_str(), path),
475 ports::IndexEntry::Remove { path } => ("0", ZERO_OID, path),
476 };
477 let mut line = format!("{mode} {oid}\t").into_bytes();
478 line.extend_from_slice(path);
479 line
480}
481
482impl ports::WorkingCopy for Repo {
483 fn tracked_paths(&self) -> Result<Vec<u8>, EngineError> {
484 self.run(["ls-files", "-z"], None)
485 }
486
487 fn has_tracked_changes(&self) -> Result<bool, EngineError> {
490 let status = self.run_status(["diff-index", "--quiet", "HEAD", "--"])?;
491 Ok(!status.success())
492 }
493
494 fn untracked_paths(&self) -> Result<Vec<u8>, EngineError> {
495 self.run(["ls-files", "--others", "--exclude-standard", "-z"], None)
496 }
497}
498
499impl ports::CommitWriter for Repo {
500 fn commit_tree(
501 &self,
502 tree: &str,
503 parent: &str,
504 message: &[u8],
505 identity: ports::CommitIdentity<'_>,
506 ) -> Result<String, EngineError> {
507 let env: [(&str, &OsStr); 4] = [
508 ("GIT_AUTHOR_NAME", OsStr::new(identity.name)),
509 ("GIT_AUTHOR_EMAIL", OsStr::new(identity.email)),
510 ("GIT_COMMITTER_NAME", OsStr::new(identity.name)),
511 ("GIT_COMMITTER_EMAIL", OsStr::new(identity.email)),
512 ];
513 let out = self.run_env(
514 ["commit-tree", tree, "-p", parent, "-F", "-"],
515 Some(message),
516 &env,
517 )?;
518 Ok(String::from_utf8_lossy(trim_newline(&out)).into_owned())
519 }
520}
521
522impl ports::RefWriter for Repo {
523 fn update_ref(&self, name: &str, target: &str) -> Result<(), EngineError> {
524 self.run(["update-ref", name, target], None).map(|_| ())
525 }
526}
527
528impl ports::CommitHistory for Repo {
529 fn has_commits(&self) -> bool {
530 self.rev_parse("HEAD").is_ok()
531 }
532
533 fn recent_commits(
534 &self,
535 from: &str,
536 max: usize,
537 ) -> Result<Vec<ports::CommitSummary>, EngineError> {
538 let raw = self.run(
539 [
540 "rev-list",
541 &format!("--max-count={max}"),
542 "--no-commit-header",
543 "--format=%H%x00%h%x00%s%x00%an",
544 from,
545 ],
546 None,
547 )?;
548 Ok(parse_rev_list(&raw))
549 }
550
551 fn refs_by_commit(&self) -> HashMap<String, Vec<String>> {
552 self.run(
553 [
554 "for-each-ref",
555 "--format=%(objectname)%00%(*objectname)%00%(refname:short)",
556 "refs/heads",
557 "refs/tags",
558 "refs/remotes",
559 ],
560 None,
561 )
562 .map(|out| parse_refs(&out))
563 .unwrap_or_default()
564 }
565}
566
567impl ports::RepoLayout for Repo {
568 fn common_dir(&self) -> Result<PathBuf, EngineError> {
569 Repo::common_dir(self)
570 }
571}
572
573fn nul_fields(line: &[u8]) -> Vec<String> {
580 line.split(|&b| b == 0)
581 .map(|f| String::from_utf8_lossy(f).into_owned())
582 .collect()
583}
584
585fn parse_rev_list(bytes: &[u8]) -> Vec<ports::CommitSummary> {
589 bytes
590 .split(|&b| b == b'\n')
591 .filter(|l| !l.is_empty())
592 .filter_map(|line| {
593 let fields = nul_fields(line);
594 match fields.as_slice() {
595 [sha, short, subject, author] => Some(ports::CommitSummary {
596 sha: sha.clone(),
597 short: short.clone(),
598 subject: subject.clone(),
599 author: author.clone(),
600 }),
601 _ => None,
602 }
603 })
604 .collect()
605}
606
607fn parse_refs(bytes: &[u8]) -> HashMap<String, Vec<String>> {
615 let mut out: HashMap<String, Vec<String>> = HashMap::new();
616 for line in bytes.split(|&b| b == b'\n').filter(|l| !l.is_empty()) {
617 let fields = nul_fields(line);
618 let [oid, peeled, name] = fields.as_slice() else {
619 continue;
620 };
621 if name.is_empty() {
622 continue;
623 }
624 let target = if peeled.is_empty() { oid } else { peeled };
627 out.entry(target.clone()).or_default().push(name.clone());
628 }
629 out
630}
631
632fn parse_batch_blobs(out: &[u8], specs: &[Vec<u8>]) -> Result<Vec<Option<Vec<u8>>>, String> {
650 const MISSING: &[u8] = b" missing\n";
651 let mut at = 0usize;
652 let mut got = Vec::with_capacity(specs.len());
653
654 for spec in specs {
655 let rest = out
656 .get(at..)
657 .ok_or_else(|| "output ended early".to_string())?;
658 if rest.starts_with(spec) && rest[spec.len()..].starts_with(MISSING) {
659 got.push(None);
660 at += spec.len() + MISSING.len();
661 continue;
662 }
663 let end = rest
664 .iter()
665 .position(|&b| b == b'\n')
666 .ok_or_else(|| "no header line in cat-file output".to_string())?;
667 let header = String::from_utf8_lossy(&rest[..end]).into_owned();
668 let fields: Vec<&str> = header.split(' ').collect();
669 if fields.len() != 3
670 || fields[0].is_empty()
671 || !fields[0].bytes().all(|b| b.is_ascii_hexdigit())
672 {
673 return Err(format!("unrecognised cat-file response: {header}"));
674 }
675 if fields[1] != "blob" {
676 return Err(format!("{header}: not a blob"));
677 }
678 let size: usize = fields[2]
679 .parse()
680 .map_err(|_| format!("{header}: unparsable size"))?;
681 let body = &rest[end + 1..];
682 if body.len() < size {
683 return Err(format!(
684 "{header}: body is {} bytes, header said {size}",
685 body.len()
686 ));
687 }
688 if body.get(size) != Some(&b'\n') {
693 return Err(format!("{header}: body is not LF-terminated"));
694 }
695 got.push(Some(body[..size].to_vec()));
696 at += end + 1 + size + 1;
698 }
699 Ok(got)
700}
701
702fn trim_newline(b: &[u8]) -> &[u8] {
703 let mut end = b.len();
704 while end > 0 && (b[end - 1] == b'\n' || b[end - 1] == b'\r') {
705 end -= 1;
706 }
707 &b[..end]
708}
709
710fn describe(cmd: &Command) -> String {
711 let mut s = String::from("git");
712 for a in cmd.get_args() {
713 s.push(' ');
714 s.push_str(&a.to_string_lossy());
715 if s.len() > 200 {
716 s.push_str(" …");
717 break;
718 }
719 }
720 s
721}
722
723#[cfg(test)]
724mod tests {
725 use super::{parse_batch_blobs, parse_refs, parse_rev_list};
726
727 #[test]
731 fn batch_blob_separates_absent_from_broken() {
732 let oid = "e".repeat(40);
733 let one = |spec: &str| vec![spec.as_bytes().to_vec()];
734
735 let found = format!("{oid} blob 6\nhello\n\n");
736 assert_eq!(
737 parse_batch_blobs(found.as_bytes(), &one("HEAD:a"))
738 .unwrap()
739 .remove(0)
740 .as_deref(),
741 Some(&b"hello\n"[..]),
742 "the body is exactly the declared size, trailing LF excluded"
743 );
744
745 assert_eq!(
747 parse_batch_blobs(b"HEAD:nope missing\n", &one("HEAD:nope")).unwrap(),
748 vec![None]
749 );
750 let ends_missing = format!("{oid} blob 9\nx missing\n");
753 assert_eq!(
754 parse_batch_blobs(ends_missing.as_bytes(), &one("HEAD:a"))
755 .unwrap()
756 .remove(0)
757 .as_deref(),
758 Some(&b"x missing"[..]),
759 "a blob ending in \" missing\" must not be read as absent"
760 );
761 assert_eq!(
763 parse_batch_blobs(b"HEAD:a b.txt missing\n", &one("HEAD:a b.txt")).unwrap(),
764 vec![None]
765 );
766 assert_eq!(
770 parse_batch_blobs(b"HEAD:we\nird.txt missing\n", &one("HEAD:we\nird.txt")).unwrap(),
771 vec![None]
772 );
773
774 assert!(parse_batch_blobs(format!("{oid} tree 42\n").as_bytes(), &one("HEAD:a")).is_err());
776 assert!(
778 parse_batch_blobs(format!("{oid} blob 99\nshort\n").as_bytes(), &one("HEAD:a"))
779 .is_err()
780 );
781 assert!(parse_batch_blobs(b"no newline at all", &one("HEAD:a")).is_err());
782 }
783
784 #[test]
787 fn batch_blobs_walks_a_stream_of_responses() {
788 let oid = "a".repeat(40);
789 let specs = vec![
790 b"HEAD:one".to_vec(),
791 b"HEAD:gone".to_vec(),
792 b"HEAD:two".to_vec(),
793 ];
794 let mut out = format!("{oid} blob 4\nabcd\n").into_bytes();
795 out.extend_from_slice(b"HEAD:gone missing\n");
796 out.extend_from_slice(format!("{oid} blob 2\nxy\n").as_bytes());
797
798 assert_eq!(
799 parse_batch_blobs(&out, &specs).unwrap(),
800 vec![Some(b"abcd".to_vec()), None, Some(b"xy".to_vec()),],
801 "answers come back in the order the specs were given"
802 );
803
804 assert!(parse_batch_blobs(&out[..10], &specs).is_err());
806
807 let mut bad = format!("{oid} blob 4\nabcd").into_bytes();
811 bad.extend_from_slice(b"XHEAD:gone missing\n");
812 assert!(parse_batch_blobs(&bad, &specs[..2]).is_err());
813 }
814
815 #[test]
817 fn batch_blob_is_byte_faithful() {
818 let oid = "f".repeat(40);
819 let mut raw = format!("{oid} blob 4\n").into_bytes();
820 raw.extend_from_slice(&[0x00, 0xff, 0xfe, 0x0a, 0x0a]);
821 assert_eq!(
822 parse_batch_blobs(&raw, &[b"HEAD:a".to_vec()])
823 .unwrap()
824 .remove(0)
825 .unwrap(),
826 vec![0x00, 0xff, 0xfe, 0x0a]
827 );
828 }
829
830 #[test]
831 fn parses_nul_separated_records() {
832 let raw = b"aaaa\0a1\0fix the thing\0Alice\nbbbb\0b2\0subject with \xe2\x9c\x93 unicode\0B\xc3\xb6b\n";
833 let entries = parse_rev_list(raw);
834 assert_eq!(entries.len(), 2);
835 assert_eq!(entries[0].sha, "aaaa");
836 assert_eq!(entries[0].short, "a1");
837 assert_eq!(entries[0].subject, "fix the thing");
838 assert_eq!(entries[0].author, "Alice");
839 assert_eq!(entries[1].subject, "subject with ✓ unicode");
840 assert_eq!(entries[1].author, "Böb");
841 }
842
843 #[test]
844 fn tolerates_empty_and_malformed_lines() {
845 assert!(parse_rev_list(b"").is_empty());
846 assert!(parse_rev_list(b"\n\n").is_empty());
847 assert!(parse_rev_list(b"only-two\0fields\n").is_empty());
848 }
849
850 #[test]
851 fn refs_group_by_commit_and_peel_annotated_tags() {
852 let raw = b"aaaa\0\0main\naaaa\0\0origin/main\ntagobj\0aaaa\0v1.0\nbbbb\0\0feature\n";
855 let refs = parse_refs(raw);
856 assert_eq!(
857 refs.get("aaaa").unwrap(),
858 &vec![
859 "main".to_string(),
860 "origin/main".to_string(),
861 "v1.0".to_string()
862 ]
863 );
864 assert_eq!(refs.get("bbbb").unwrap(), &vec!["feature".to_string()]);
865 assert!(!refs.contains_key("tagobj"));
867 }
868
869 #[test]
870 fn refs_tolerate_junk() {
871 assert!(parse_refs(b"").is_empty());
872 assert!(parse_refs(b"\n\n").is_empty());
873 assert!(parse_refs(b"two\0fields\n").is_empty());
874 assert!(parse_refs(b"aaaa\0\0\n").is_empty());
876 }
877}