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 let pipe = stdin.map(|_| child.stdin.take().expect("stdin was requested"));
86 let (out, written) = std::thread::scope(|scope| {
87 let writer = pipe.zip(stdin).map(|(mut pipe, data)| {
88 scope.spawn(move || {
89 use std::io::Write;
90 pipe.write_all(data)
91 })
93 });
94 let out = child.wait_with_output();
95 let written = writer.map_or(Ok(()), |w| w.join().expect("stdin writer panicked"));
96 (out, written)
97 });
98 let out = out.map_err(|e| EngineError::GitSpawn { source: e })?;
99 if out.status.success() {
102 written.map_err(|e| EngineError::GitSpawn { source: e })?;
103 }
104
105 if !out.status.success() {
106 return Err(EngineError::GitCommand {
107 command: describe(&cmd),
108 code: out.status.code(),
109 stderr: String::from_utf8_lossy(&out.stderr[..out.stderr.len().min(800)])
110 .into_owned(),
111 });
112 }
113 Ok(out.stdout)
114 }
115
116 fn run_status<I, S>(&self, args: I) -> Result<std::process::ExitStatus, EngineError>
122 where
123 I: IntoIterator<Item = S>,
124 S: AsRef<OsStr>,
125 {
126 Command::new("git")
127 .arg("-c")
128 .arg("core.quotepath=false")
129 .args(args)
130 .current_dir(&self.root)
131 .stdin(Stdio::null())
132 .stdout(Stdio::null())
133 .stderr(Stdio::null())
134 .status()
135 .map_err(|e| EngineError::GitSpawn { source: e })
136 }
137
138 fn blob(&self, rev: &str, path: &[u8]) -> Result<Option<Vec<u8>>, EngineError> {
141 Ok(self.blobs(&[(rev, path)])?.pop().flatten())
142 }
143
144 fn blobs(&self, specs: &[(&str, &[u8])]) -> Result<Vec<Option<Vec<u8>>>, EngineError> {
158 if specs.is_empty() {
159 return Ok(Vec::new());
160 }
161 let wire: Vec<Vec<u8>> = specs
162 .iter()
163 .map(|(rev, path)| {
164 let mut s = rev.as_bytes().to_vec();
165 s.push(b':');
166 s.extend_from_slice(path);
167 s
168 })
169 .collect();
170 let mut stdin = Vec::new();
171 for s in &wire {
172 stdin.extend_from_slice(s);
173 stdin.push(0);
174 }
175 let out = self.run(["cat-file", "--batch", "-z"], Some(&stdin))?;
176 parse_batch_blobs(&out, &wire).map_err(|msg| EngineError::GitCommand {
177 command: format!("cat-file --batch ({} specs)", specs.len()),
178 code: None,
179 stderr: msg,
180 })
181 }
182
183 fn rev_parse(&self, rev: &str) -> Result<String, EngineError> {
185 let out = self.run(
186 ["rev-parse", "--verify", &format!("{rev}^{{commit}}")],
187 None,
188 )?;
189 Ok(String::from_utf8_lossy(trim_newline(&out)).into_owned())
190 }
191
192 fn rev_parse_commit_or_tree(&self, rev: &str) -> Result<String, EngineError> {
196 self.rev_parse(rev)
197 .or_else(|_| self.rev_parse_raw(&format!("{rev}^{{tree}}")))
198 }
199
200 fn rev_parse_raw(&self, expr: &str) -> Result<String, EngineError> {
202 let out = self.run(["rev-parse", "--verify", expr], None)?;
203 Ok(String::from_utf8_lossy(trim_newline(&out)).into_owned())
204 }
205
206 fn merge_base(&self, a: &str, b: &str) -> Result<String, EngineError> {
207 let out = self.run(["merge-base", a, b], None)?;
208 Ok(String::from_utf8_lossy(trim_newline(&out)).into_owned())
209 }
210
211 fn common_dir(&self) -> Result<PathBuf, EngineError> {
214 let out = self.run(["rev-parse", "--git-common-dir"], None)?;
215 let p = PathBuf::from(String::from_utf8_lossy(trim_newline(&out)).into_owned());
216 Ok(if p.is_absolute() {
217 p
218 } else {
219 self.root.join(p)
220 })
221 }
222}
223
224impl ports::ObjectReader for Repo {
231 fn blob(&self, rev: &str, path: &[u8]) -> Result<Option<Vec<u8>>, EngineError> {
232 Repo::blob(self, rev, path)
233 }
234
235 fn blobs(&self, specs: &[(&str, &[u8])]) -> Result<Vec<Option<Vec<u8>>>, EngineError> {
236 Repo::blobs(self, specs)
237 }
238
239 fn require_object(&self, oid: &str) -> Result<(), EngineError> {
240 self.run(["cat-file", "-e", oid], None).map(|_| ())
241 }
242}
243
244impl ports::ObjectWriter for Repo {
245 fn write_blob(&self, content: &[u8]) -> Result<String, EngineError> {
246 let out = self.run(["hash-object", "-w", "--stdin"], Some(content))?;
247 Ok(String::from_utf8_lossy(trim_newline(&out)).into_owned())
248 }
249}
250
251impl ports::RangeResolver for Repo {
252 fn merge_base(&self, a: &str, b: &str) -> Result<String, EngineError> {
253 Repo::merge_base(self, a, b)
254 }
255
256 fn resolve_endpoint(&self, rev: &str) -> Result<String, EngineError> {
257 self.rev_parse_commit_or_tree(rev)
258 }
259}
260
261impl ports::Ancestry for Repo {
262 fn commit_of(&self, spec: &str) -> Result<Option<String>, EngineError> {
263 Ok(self.rev_parse(spec).ok())
266 }
267
268 fn is_ancestor(&self, older: &str, newer: &str) -> Result<bool, EngineError> {
269 let status = self.run_status(["merge-base", "--is-ancestor", older, newer])?;
274 match status.code() {
275 Some(0) => Ok(true),
276 Some(1) => Ok(false),
277 other => Err(EngineError::GitCommand {
278 command: format!("merge-base --is-ancestor {older} {newer}"),
279 code: other,
280 stderr: String::new(),
281 }),
282 }
283 }
284}
285
286impl ports::Fetcher for Repo {
287 fn fetch(&self, remote: &str, refspecs: &[&str]) -> Result<(), EngineError> {
288 let mut args = vec!["fetch", "--quiet", remote];
289 args.extend(refspecs);
290 self.run(args, None).map(|_| ())
291 }
292}
293
294impl ports::TreeResolver for Repo {
295 fn tree_of(&self, rev: &str) -> Result<String, EngineError> {
296 self.rev_parse_raw(&format!("{rev}^{{tree}}"))
297 }
298}
299
300impl ports::DiffSource for Repo {
301 fn raw_records(&self, base: &str, head: &str) -> Result<Vec<u8>, EngineError> {
303 self.run(
304 [
305 "diff-tree",
306 "-r",
307 "-z",
308 "--raw",
309 "--full-index",
310 "--no-renames",
311 base,
312 head,
313 ],
314 None,
315 )
316 }
317
318 fn canonical_patch(&self, base: &str, head: &str) -> Result<Vec<u8>, EngineError> {
319 self.run(
320 [
321 "diff-tree",
322 "-r",
323 "-U0",
324 "--no-renames",
325 "--no-color",
326 "--no-ext-diff",
327 base,
328 head,
329 ],
330 None,
331 )
332 }
333
334 fn rename_records(&self, base: &str, head: &str) -> Result<Vec<u8>, EngineError> {
335 self.run(
336 ["diff-tree", "-r", "-M", "-z", "--name-status", base, head],
337 None,
338 )
339 }
340}
341
342impl ports::RecountSource for Repo {
343 fn recount_patch(&self, from: &str, to: &str) -> Result<Vec<u8>, EngineError> {
349 self.run(["diff-tree", "-r", "-U0", "--no-renames", from, to], None)
350 }
351}
352
353impl ports::AttributeSource for Repo {
354 fn check_attr(
355 &self,
356 attr: &str,
357 paths: &[&[u8]],
358 ) -> Result<Vec<ports::AttrValue>, EngineError> {
359 if paths.is_empty() {
360 return Ok(Vec::new());
361 }
362 let mut stdin: Vec<u8> = Vec::new();
363 for p in paths {
364 stdin.extend_from_slice(p);
365 stdin.push(0);
366 }
367 let out = self.run(["check-attr", "-z", "--stdin", attr], Some(&stdin))?;
368 let fields: Vec<&[u8]> = out.split(|&b| b == 0).collect();
370 Ok(fields
371 .chunks_exact(3)
372 .map(|triple| ports::AttrValue {
373 path: triple[0].to_vec(),
374 value: triple[2].to_vec(),
375 })
376 .collect())
377 }
378}
379
380pub struct ScratchIndex {
386 repo: Repo,
387 _dir: tempfile::TempDir,
388 index: std::ffi::OsString,
389}
390
391impl ScratchIndex {
392 fn open(repo: &Repo) -> Result<Self, EngineError> {
396 let dir = tempfile::TempDir::new().map_err(|e| EngineError::GitSpawn { source: e })?;
397 let index = dir.path().join("index").into_os_string();
398 Ok(ScratchIndex {
399 repo: repo.clone(),
400 _dir: dir,
401 index,
402 })
403 }
404
405 fn env(&self) -> [(&str, &OsStr); 1] {
406 [("GIT_INDEX_FILE", self.index.as_os_str())]
407 }
408}
409
410impl ports::TreeBuilder for Repo {
411 type Session = ScratchIndex;
412
413 fn begin_from_tree(&self, tree_ish: &str) -> Result<ScratchIndex, EngineError> {
414 let idx = ScratchIndex::open(self)?;
415 self.run_env(["read-tree", tree_ish], None, &idx.env())?;
416 Ok(idx)
417 }
418
419 fn begin_from_current_index(&self) -> Result<ScratchIndex, EngineError> {
420 let entries = self.run(["ls-files", "-s", "-z"], None)?;
424 for record in entries.split(|&b| b == 0) {
425 let meta = record.split(|&b| b == b'\t').next().unwrap_or(record);
426 if meta.ends_with(b" 1") || meta.ends_with(b" 2") || meta.ends_with(b" 3") {
427 return Err(EngineError::Range(
428 "index has unmerged entries — resolve conflicts before reviewing \
429 uncommitted changes"
430 .into(),
431 ));
432 }
433 }
434 let idx = ScratchIndex::open(self)?;
435 if !entries.is_empty() {
436 self.run_env(
437 ["update-index", "-z", "--index-info"],
438 Some(&entries),
439 &idx.env(),
440 )?;
441 }
442 Ok(idx)
443 }
444}
445
446impl ports::IndexSession for ScratchIndex {
447 fn stage(&mut self, entries: &[ports::IndexEntry]) -> Result<(), EngineError> {
448 if entries.is_empty() {
449 return Ok(());
450 }
451 let mut feed: Vec<u8> = Vec::new();
452 for e in entries {
453 feed.extend_from_slice(&index_record(e));
454 feed.push(0);
455 }
456 self.repo
457 .run_env(
458 ["update-index", "-z", "--index-info"],
459 Some(&feed),
460 &self.env(),
461 )
462 .map(|_| ())
463 }
464
465 fn stage_from_worktree(&mut self, nul_paths: &[u8]) -> Result<(), EngineError> {
466 if nul_paths.is_empty() {
467 return Ok(());
468 }
469 self.repo
470 .run_env(
471 ["update-index", "--add", "--remove", "-z", "--stdin"],
472 Some(nul_paths),
473 &self.env(),
474 )
475 .map(|_| ())
476 }
477
478 fn write_tree(&self) -> Result<String, EngineError> {
479 let out = self.repo.run_env(["write-tree"], None, &self.env())?;
480 Ok(String::from_utf8_lossy(&out).trim().to_string())
481 }
482}
483
484const ZERO_OID: &str = "0000000000000000000000000000000000000000";
485
486fn index_record(e: &ports::IndexEntry) -> Vec<u8> {
489 let (mode, oid, path) = match e {
490 ports::IndexEntry::Set { mode, oid, path } => (mode.as_str(), oid.as_str(), path),
491 ports::IndexEntry::Remove { path } => ("0", ZERO_OID, path),
492 };
493 let mut line = format!("{mode} {oid}\t").into_bytes();
494 line.extend_from_slice(path);
495 line
496}
497
498impl ports::WorkingCopy for Repo {
499 fn tracked_paths(&self) -> Result<Vec<u8>, EngineError> {
500 self.run(["ls-files", "-z"], None)
501 }
502
503 fn has_tracked_changes(&self) -> Result<bool, EngineError> {
506 let status = self.run_status(["diff-index", "--quiet", "HEAD", "--"])?;
507 Ok(!status.success())
508 }
509
510 fn untracked_paths(&self) -> Result<Vec<u8>, EngineError> {
511 self.run(["ls-files", "--others", "--exclude-standard", "-z"], None)
512 }
513}
514
515impl ports::CommitWriter for Repo {
516 fn commit_tree(
517 &self,
518 tree: &str,
519 parent: &str,
520 message: &[u8],
521 identity: ports::CommitIdentity<'_>,
522 ) -> Result<String, EngineError> {
523 let env: [(&str, &OsStr); 4] = [
524 ("GIT_AUTHOR_NAME", OsStr::new(identity.name)),
525 ("GIT_AUTHOR_EMAIL", OsStr::new(identity.email)),
526 ("GIT_COMMITTER_NAME", OsStr::new(identity.name)),
527 ("GIT_COMMITTER_EMAIL", OsStr::new(identity.email)),
528 ];
529 let out = self.run_env(
530 ["commit-tree", tree, "-p", parent, "-F", "-"],
531 Some(message),
532 &env,
533 )?;
534 Ok(String::from_utf8_lossy(trim_newline(&out)).into_owned())
535 }
536}
537
538impl ports::RefWriter for Repo {
539 fn update_ref(&self, name: &str, target: &str) -> Result<(), EngineError> {
540 self.run(["update-ref", name, target], None).map(|_| ())
541 }
542}
543
544impl ports::CommitHistory for Repo {
545 fn has_commits(&self) -> bool {
546 self.rev_parse("HEAD").is_ok()
547 }
548
549 fn recent_commits(
550 &self,
551 from: &str,
552 max: usize,
553 ) -> Result<Vec<ports::CommitSummary>, EngineError> {
554 let raw = self.run(
555 [
556 "rev-list",
557 &format!("--max-count={max}"),
558 "--no-commit-header",
559 "--format=%H%x00%h%x00%s%x00%an",
560 from,
561 ],
562 None,
563 )?;
564 Ok(parse_rev_list(&raw))
565 }
566
567 fn refs_by_commit(&self) -> HashMap<String, Vec<String>> {
568 self.run(
569 [
570 "for-each-ref",
571 "--format=%(objectname)%00%(*objectname)%00%(refname:short)",
572 "refs/heads",
573 "refs/tags",
574 "refs/remotes",
575 ],
576 None,
577 )
578 .map(|out| parse_refs(&out))
579 .unwrap_or_default()
580 }
581}
582
583impl ports::RepoLayout for Repo {
584 fn common_dir(&self) -> Result<PathBuf, EngineError> {
585 Repo::common_dir(self)
586 }
587}
588
589fn nul_fields(line: &[u8]) -> Vec<String> {
596 line.split(|&b| b == 0)
597 .map(|f| String::from_utf8_lossy(f).into_owned())
598 .collect()
599}
600
601fn parse_rev_list(bytes: &[u8]) -> Vec<ports::CommitSummary> {
605 bytes
606 .split(|&b| b == b'\n')
607 .filter(|l| !l.is_empty())
608 .filter_map(|line| {
609 let fields = nul_fields(line);
610 match fields.as_slice() {
611 [sha, short, subject, author] => Some(ports::CommitSummary {
612 sha: sha.clone(),
613 short: short.clone(),
614 subject: subject.clone(),
615 author: author.clone(),
616 }),
617 _ => None,
618 }
619 })
620 .collect()
621}
622
623fn parse_refs(bytes: &[u8]) -> HashMap<String, Vec<String>> {
631 let mut out: HashMap<String, Vec<String>> = HashMap::new();
632 for line in bytes.split(|&b| b == b'\n').filter(|l| !l.is_empty()) {
633 let fields = nul_fields(line);
634 let [oid, peeled, name] = fields.as_slice() else {
635 continue;
636 };
637 if name.is_empty() {
638 continue;
639 }
640 let target = if peeled.is_empty() { oid } else { peeled };
643 out.entry(target.clone()).or_default().push(name.clone());
644 }
645 out
646}
647
648fn parse_batch_blobs(out: &[u8], specs: &[Vec<u8>]) -> Result<Vec<Option<Vec<u8>>>, String> {
666 const MISSING: &[u8] = b" missing\n";
667 let mut at = 0usize;
668 let mut got = Vec::with_capacity(specs.len());
669
670 for spec in specs {
671 let rest = out
672 .get(at..)
673 .ok_or_else(|| "output ended early".to_string())?;
674 if rest.starts_with(spec) && rest[spec.len()..].starts_with(MISSING) {
675 got.push(None);
676 at += spec.len() + MISSING.len();
677 continue;
678 }
679 let end = rest
680 .iter()
681 .position(|&b| b == b'\n')
682 .ok_or_else(|| "no header line in cat-file output".to_string())?;
683 let header = String::from_utf8_lossy(&rest[..end]).into_owned();
684 let fields: Vec<&str> = header.split(' ').collect();
685 if fields.len() != 3
686 || fields[0].is_empty()
687 || !fields[0].bytes().all(|b| b.is_ascii_hexdigit())
688 {
689 return Err(format!("unrecognised cat-file response: {header}"));
690 }
691 if fields[1] != "blob" {
692 return Err(format!("{header}: not a blob"));
693 }
694 let size: usize = fields[2]
695 .parse()
696 .map_err(|_| format!("{header}: unparsable size"))?;
697 let body = &rest[end + 1..];
698 if body.len() < size {
699 return Err(format!(
700 "{header}: body is {} bytes, header said {size}",
701 body.len()
702 ));
703 }
704 if body.get(size) != Some(&b'\n') {
709 return Err(format!("{header}: body is not LF-terminated"));
710 }
711 got.push(Some(body[..size].to_vec()));
712 at += end + 1 + size + 1;
714 }
715 Ok(got)
716}
717
718fn trim_newline(b: &[u8]) -> &[u8] {
719 let mut end = b.len();
720 while end > 0 && (b[end - 1] == b'\n' || b[end - 1] == b'\r') {
721 end -= 1;
722 }
723 &b[..end]
724}
725
726fn describe(cmd: &Command) -> String {
727 let mut s = String::from("git");
728 for a in cmd.get_args() {
729 s.push(' ');
730 s.push_str(&a.to_string_lossy());
731 if s.len() > 200 {
732 s.push_str(" …");
733 break;
734 }
735 }
736 s
737}
738
739#[cfg(test)]
740mod tests {
741 use super::{parse_batch_blobs, parse_refs, parse_rev_list};
742
743 #[test]
747 fn batch_blob_separates_absent_from_broken() {
748 let oid = "e".repeat(40);
749 let one = |spec: &str| vec![spec.as_bytes().to_vec()];
750
751 let found = format!("{oid} blob 6\nhello\n\n");
752 assert_eq!(
753 parse_batch_blobs(found.as_bytes(), &one("HEAD:a"))
754 .unwrap()
755 .remove(0)
756 .as_deref(),
757 Some(&b"hello\n"[..]),
758 "the body is exactly the declared size, trailing LF excluded"
759 );
760
761 assert_eq!(
763 parse_batch_blobs(b"HEAD:nope missing\n", &one("HEAD:nope")).unwrap(),
764 vec![None]
765 );
766 let ends_missing = format!("{oid} blob 9\nx missing\n");
769 assert_eq!(
770 parse_batch_blobs(ends_missing.as_bytes(), &one("HEAD:a"))
771 .unwrap()
772 .remove(0)
773 .as_deref(),
774 Some(&b"x missing"[..]),
775 "a blob ending in \" missing\" must not be read as absent"
776 );
777 assert_eq!(
779 parse_batch_blobs(b"HEAD:a b.txt missing\n", &one("HEAD:a b.txt")).unwrap(),
780 vec![None]
781 );
782 assert_eq!(
786 parse_batch_blobs(b"HEAD:we\nird.txt missing\n", &one("HEAD:we\nird.txt")).unwrap(),
787 vec![None]
788 );
789
790 assert!(parse_batch_blobs(format!("{oid} tree 42\n").as_bytes(), &one("HEAD:a")).is_err());
792 assert!(
794 parse_batch_blobs(format!("{oid} blob 99\nshort\n").as_bytes(), &one("HEAD:a"))
795 .is_err()
796 );
797 assert!(parse_batch_blobs(b"no newline at all", &one("HEAD:a")).is_err());
798 }
799
800 #[test]
803 fn batch_blobs_walks_a_stream_of_responses() {
804 let oid = "a".repeat(40);
805 let specs = vec![
806 b"HEAD:one".to_vec(),
807 b"HEAD:gone".to_vec(),
808 b"HEAD:two".to_vec(),
809 ];
810 let mut out = format!("{oid} blob 4\nabcd\n").into_bytes();
811 out.extend_from_slice(b"HEAD:gone missing\n");
812 out.extend_from_slice(format!("{oid} blob 2\nxy\n").as_bytes());
813
814 assert_eq!(
815 parse_batch_blobs(&out, &specs).unwrap(),
816 vec![Some(b"abcd".to_vec()), None, Some(b"xy".to_vec()),],
817 "answers come back in the order the specs were given"
818 );
819
820 assert!(parse_batch_blobs(&out[..10], &specs).is_err());
822
823 let mut bad = format!("{oid} blob 4\nabcd").into_bytes();
827 bad.extend_from_slice(b"XHEAD:gone missing\n");
828 assert!(parse_batch_blobs(&bad, &specs[..2]).is_err());
829 }
830
831 #[test]
833 fn batch_blob_is_byte_faithful() {
834 let oid = "f".repeat(40);
835 let mut raw = format!("{oid} blob 4\n").into_bytes();
836 raw.extend_from_slice(&[0x00, 0xff, 0xfe, 0x0a, 0x0a]);
837 assert_eq!(
838 parse_batch_blobs(&raw, &[b"HEAD:a".to_vec()])
839 .unwrap()
840 .remove(0)
841 .unwrap(),
842 vec![0x00, 0xff, 0xfe, 0x0a]
843 );
844 }
845
846 #[test]
847 fn parses_nul_separated_records() {
848 let raw = b"aaaa\0a1\0fix the thing\0Alice\nbbbb\0b2\0subject with \xe2\x9c\x93 unicode\0B\xc3\xb6b\n";
849 let entries = parse_rev_list(raw);
850 assert_eq!(entries.len(), 2);
851 assert_eq!(entries[0].sha, "aaaa");
852 assert_eq!(entries[0].short, "a1");
853 assert_eq!(entries[0].subject, "fix the thing");
854 assert_eq!(entries[0].author, "Alice");
855 assert_eq!(entries[1].subject, "subject with ✓ unicode");
856 assert_eq!(entries[1].author, "Böb");
857 }
858
859 #[test]
860 fn tolerates_empty_and_malformed_lines() {
861 assert!(parse_rev_list(b"").is_empty());
862 assert!(parse_rev_list(b"\n\n").is_empty());
863 assert!(parse_rev_list(b"only-two\0fields\n").is_empty());
864 }
865
866 #[test]
867 fn refs_group_by_commit_and_peel_annotated_tags() {
868 let raw = b"aaaa\0\0main\naaaa\0\0origin/main\ntagobj\0aaaa\0v1.0\nbbbb\0\0feature\n";
871 let refs = parse_refs(raw);
872 assert_eq!(
873 refs.get("aaaa").unwrap(),
874 &vec![
875 "main".to_string(),
876 "origin/main".to_string(),
877 "v1.0".to_string()
878 ]
879 );
880 assert_eq!(refs.get("bbbb").unwrap(), &vec!["feature".to_string()]);
881 assert!(!refs.contains_key("tagobj"));
883 }
884
885 #[test]
886 fn refs_tolerate_junk() {
887 assert!(parse_refs(b"").is_empty());
888 assert!(parse_refs(b"\n\n").is_empty());
889 assert!(parse_refs(b"two\0fields\n").is_empty());
890 assert!(parse_refs(b"aaaa\0\0\n").is_empty());
892 }
893}