1use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8
9use gix::ObjectId;
10use gix::bstr::ByteSlice;
11use gix::objs::Write as _;
12use gix::objs::tree::{self, EntryKind};
13use gix::refs::transaction::PreviousValue;
14
15use crate::error::{Error, Result, git};
16use crate::paths::validate_repo_path;
17
18pub use crate::objects::{FileEntry, FileMap, FileMode, GitId};
19
20impl GitId {
21 pub fn oid(&self) -> Result<ObjectId> {
22 ObjectId::from_hex(self.0.as_bytes())
23 .map_err(|e| Error::Invalid(format!("git id {:?}: {e}", self.0)))
24 }
25
26 #[allow(
29 clippy::expect_used,
30 reason = "compute_hash fails only for a hash kind gix was built without; SHA-1 is always there"
31 )]
32 pub fn of_blob(bytes: &[u8]) -> Self {
33 let oid = gix::objs::compute_hash(gix::hash::Kind::Sha1, gix::objs::Kind::Blob, bytes)
34 .expect("hashing a buffer can not fail");
35 Self(oid.to_string())
36 }
37}
38
39impl From<ObjectId> for GitId {
40 fn from(id: ObjectId) -> Self {
41 Self(id.to_string())
42 }
43}
44
45#[derive(Clone, Debug)]
46pub struct CommitSpec {
47 pub tree: GitId,
48 pub parents: Vec<GitId>,
49 pub author_name: String,
50 pub author_email: String,
51 pub time_ms: i64,
52 pub message: String,
53 pub headers: Vec<(String, String)>,
54}
55
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub struct CommitInfo {
58 pub tree: GitId,
59 pub parents: Vec<GitId>,
60 pub headers: Vec<(String, String)>,
61 pub message: String,
62 pub author_name: String,
63 pub author_email: String,
64 pub time_ms: i64,
66}
67
68impl CommitInfo {
69 pub fn header(&self, name: &str) -> Option<&str> {
70 self.headers
71 .iter()
72 .find(|(k, _)| k == name)
73 .map(|(_, v)| v.as_str())
74 }
75}
76
77pub struct Store {
78 path: PathBuf,
79 repo: gix::ThreadSafeRepository,
80}
81
82enum Node {
83 File(FileEntry),
84 Dir(BTreeMap<String, Node>),
85}
86
87impl Store {
88 pub fn init_bare(path: &Path) -> Result<Self> {
89 gix::init_bare(path).map_err(git)?;
90 Self::open(path)
91 }
92
93 pub fn open(path: &Path) -> Result<Self> {
96 let repo = gix::open_opts(path, gix::open::Options::isolated()).map_err(git)?;
97 Ok(Self {
98 path: path.to_path_buf(),
99 repo: repo.into_sync(),
100 })
101 }
102
103 pub fn path(&self) -> &Path {
104 &self.path
105 }
106
107 fn local(&self) -> gix::Repository {
108 self.repo.to_thread_local()
109 }
110
111 pub fn write_blob(&self, bytes: &[u8]) -> Result<GitId> {
112 let repo = self.local();
113 let id = repo.write_blob(bytes).map_err(git)?;
114 Ok(id.detach().into())
115 }
116
117 pub fn blob_size(&self, id: &GitId) -> Result<u64> {
119 let repo = self.local();
120 let header = repo.find_header(id.oid()?).map_err(git)?;
121 Ok(header.size())
122 }
123
124 pub fn read_blob(&self, id: &GitId) -> Result<Vec<u8>> {
125 let repo = self.local();
126 let obj = repo.find_object(id.oid()?).map_err(git)?;
127 if obj.kind != gix::object::Kind::Blob {
128 return Err(Error::Invalid(format!("{} is not a blob", id.short())));
129 }
130 Ok(obj.data.clone())
131 }
132
133 pub fn has_object(&self, id: &GitId) -> bool {
134 let repo = self.local();
135 match id.oid() {
136 Ok(oid) => repo.has_object(oid),
137 Err(_) => false,
138 }
139 }
140
141 pub fn empty_tree(&self) -> Result<GitId> {
142 self.write_tree(&FileMap::new())
143 }
144
145 pub fn write_tree(&self, files: &FileMap) -> Result<GitId> {
146 let root = nodes(files)?;
147 let repo = self.local();
148 let write = |tree: gix::objs::Tree| Ok(repo.write_object(tree).map_err(git)?.detach());
149 Ok(write_dir(&write, &root)?.into())
150 }
151
152 pub fn read_tree(&self, tree: &GitId) -> Result<FileMap> {
153 let repo = self.local();
154 let mut out = FileMap::new();
155 walk(&repo, tree.oid()?, "", &mut out)?;
156 Ok(out)
157 }
158
159 pub fn commit_files(&self, commit: &GitId) -> Result<FileMap> {
160 let info = self.read_commit(commit)?;
161 self.read_tree(&info.tree)
162 }
163
164 pub fn read_file(&self, commit: &GitId, path: &str) -> Result<Option<Vec<u8>>> {
165 let files = self.commit_files(commit)?;
166 match files.get(path) {
167 Some(e) => Ok(Some(self.read_blob(&e.blob)?)),
168 None => Ok(None),
169 }
170 }
171
172 pub fn write_commit(&self, spec: &CommitSpec) -> Result<GitId> {
173 let repo = self.local();
174 let id = repo.write_object(commit_of(spec)?).map_err(git)?;
175 Ok(id.detach().into())
176 }
177
178 pub fn read_commit(&self, id: &GitId) -> Result<CommitInfo> {
179 let repo = self.local();
180 let obj = repo.find_object(id.oid()?).map_err(git)?;
181 if obj.kind != gix::object::Kind::Commit {
182 return Err(Error::Invalid(format!("{} is not a commit", id.short())));
183 }
184 let c = gix::objs::CommitRef::from_bytes(&obj.data, repo.object_hash()).map_err(git)?;
185 let author = c.author().map_err(git)?;
186 Ok(CommitInfo {
187 tree: c.tree().into(),
188 parents: c.parents().map(GitId::from).collect(),
189 headers: c
190 .extra_headers
191 .iter()
192 .map(|(k, v)| (k.to_str_lossy().into_owned(), v.to_str_lossy().into_owned()))
193 .collect(),
194 message: c.message.to_str_lossy().into_owned(),
195 author_name: author.name.to_str_lossy().into_owned(),
196 author_email: author.email.to_str_lossy().into_owned(),
197 time_ms: author.time().map_err(git)?.seconds * 1000,
198 })
199 }
200
201 pub fn ref_get(&self, name: &str) -> Result<Option<GitId>> {
202 let repo = self.local();
203 match repo.try_find_reference(name).map_err(git)? {
204 Some(mut r) => Ok(Some(r.peel_to_id().map_err(git)?.detach().into())),
205 None => Ok(None),
206 }
207 }
208
209 pub fn ref_cas(&self, name: &str, new: &GitId, expected: Option<&GitId>) -> Result<()> {
211 let repo = self.local();
212 let prev = match expected {
213 Some(e) => PreviousValue::MustExistAndMatch(gix::refs::Target::Object(e.oid()?)),
214 None => {
215 if repo.try_find_reference(name).map_err(git)?.is_some() {
218 return Err(Error::Conflict(format!("ref {name} already exists")));
219 }
220 PreviousValue::MustNotExist
221 }
222 };
223 repo.reference(name, new.oid()?, prev, "lonca")
224 .map_err(|e| Error::Conflict(format!("ref {name}: {e}")))?;
225 Ok(())
226 }
227
228 pub fn ref_force(&self, name: &str, new: &GitId) -> Result<()> {
231 let repo = self.local();
232 repo.reference(name, new.oid()?, PreviousValue::Any, "lonca: restore")
233 .map_err(git)?;
234 Ok(())
235 }
236
237 pub fn ref_delete(&self, name: &str, expected: &GitId) -> Result<()> {
240 use gix::refs::transaction::{Change, RefEdit, RefLog};
241 let repo = self.local();
242 let full: gix::refs::FullName = name
243 .try_into()
244 .map_err(|e| Error::Invalid(format!("ref {name}: {e}")))?;
245 repo.edit_reference(RefEdit {
246 change: Change::Delete {
247 expected: PreviousValue::MustExistAndMatch(gix::refs::Target::Object(
248 expected.oid()?,
249 )),
250 log: RefLog::AndReference,
251 },
252 name: full,
253 deref: false,
254 })
255 .map_err(|e| Error::Conflict(format!("ref {name}: {e}")))?;
256 Ok(())
257 }
258
259 pub fn refs_with_prefix(&self, prefix: &str) -> Result<Vec<(String, GitId)>> {
260 let repo = self.local();
261 let platform = repo.references().map_err(git)?;
262 let mut out = Vec::new();
263 for r in platform.all().map_err(git)? {
264 let mut r = r.map_err(git)?;
265 let name = r.name().as_bstr().to_str_lossy().into_owned();
266 if !name.starts_with(prefix) {
267 continue;
268 }
269 let id = r.peel_to_id().map_err(git)?.detach();
270 out.push((name, id.into()));
271 }
272 out.sort();
273 Ok(out)
274 }
275}
276
277pub struct LooseObjects {
281 store: gix::odb::loose::Store,
282}
283
284impl LooseObjects {
285 pub fn at(dir: &Path) -> Self {
286 Self {
287 store: gix::odb::loose::Store::at(dir, gix::hash::Kind::Sha1),
288 }
289 }
290
291 pub fn write_blob(&self, bytes: &[u8]) -> Result<GitId> {
292 let id = self
293 .store
294 .write_buf(gix::objs::Kind::Blob, bytes)
295 .map_err(git)?;
296 Ok(id.into())
297 }
298
299 pub fn write_tree(&self, files: &FileMap) -> Result<GitId> {
300 let write = |tree: gix::objs::Tree| self.store.write(&tree).map_err(git);
301 Ok(write_dir(&write, &nodes(files)?)?.into())
302 }
303
304 pub fn write_commit(&self, spec: &CommitSpec) -> Result<GitId> {
305 let id = self.store.write(&commit_of(spec)?).map_err(git)?;
306 Ok(id.into())
307 }
308}
309
310pub fn index_of(files: &FileMap) -> Result<Vec<u8>> {
316 use gix::index::entry::{Flags, Mode, Stat};
317 let mut state = gix::index::State::new(gix::hash::Kind::Sha1);
318 for (path, entry) in files {
319 let mode = match entry.mode {
320 FileMode::Regular => Mode::FILE,
321 FileMode::Executable => Mode::FILE_EXECUTABLE,
322 other => {
323 return Err(Error::Invalid(format!(
324 "{path}: no index entry for {other:?}"
325 )));
326 }
327 };
328 let id = entry.blob.oid()?;
329 state.dangerously_push_entry(
330 Stat::default(),
331 id,
332 Flags::empty(),
333 mode,
334 path.as_str().into(),
335 );
336 }
337 state.sort_entries();
338 let mut out = Vec::new();
339 let options = gix::index::write::Options {
340 extensions: gix::index::write::Extensions::None,
341 skip_hash: false,
342 };
343 gix::index::File::from_state(state, "index")
344 .write_to(&mut out, options)
345 .map_err(git)?;
346 Ok(out)
347}
348
349fn commit_of(spec: &CommitSpec) -> Result<gix::objs::Commit> {
350 let sig = gix::actor::Signature {
351 name: spec.author_name.as_str().into(),
352 email: spec.author_email.as_str().into(),
353 time: gix::date::Time {
354 seconds: spec.time_ms.div_euclid(1000),
355 offset: 0,
356 },
357 };
358 let parents = spec
359 .parents
360 .iter()
361 .map(|p| p.oid())
362 .collect::<Result<Vec<_>>>()?;
363 Ok(gix::objs::Commit {
364 tree: spec.tree.oid()?,
365 parents: parents.into_iter().collect(),
366 author: sig.clone(),
367 committer: sig,
368 encoding: None,
369 message: spec.message.as_str().into(),
370 extra_headers: spec
371 .headers
372 .iter()
373 .map(|(k, v)| (k.as_str().into(), v.as_str().into()))
374 .collect(),
375 })
376}
377
378fn nodes(files: &FileMap) -> Result<BTreeMap<String, Node>> {
380 let mut root = BTreeMap::new();
381 for (path, entry) in files {
382 validate_repo_path(path)?;
383 let comps: Vec<&str> = path.split('/').collect();
384 insert(&mut root, &comps, entry, path)?;
385 }
386 Ok(root)
387}
388
389fn insert(
390 dir: &mut BTreeMap<String, Node>,
391 comps: &[&str],
392 entry: &FileEntry,
393 full: &str,
394) -> Result<()> {
395 let clash = || Error::Invalid(format!("path {full:?} is both a file and a directory"));
396 let Some((first, rest)) = comps.split_first() else {
397 return Err(Error::Invalid(format!("empty path {full:?}")));
398 };
399 if rest.is_empty() {
400 if let Some(Node::Dir(_)) = dir.get(*first) {
401 return Err(clash());
402 }
403 dir.insert(first.to_string(), Node::File(entry.clone()));
404 return Ok(());
405 }
406 let node = dir
407 .entry(first.to_string())
408 .or_insert_with(|| Node::Dir(BTreeMap::new()));
409 match node {
410 Node::Dir(d) => insert(d, rest, entry, full),
411 Node::File(_) => Err(clash()),
412 }
413}
414
415fn write_dir(
416 write: &impl Fn(gix::objs::Tree) -> Result<ObjectId>,
417 dir: &BTreeMap<String, Node>,
418) -> Result<ObjectId> {
419 let mut entries = Vec::with_capacity(dir.len());
420 for (name, node) in dir {
421 let (mode, oid) = match node {
422 Node::File(f) => {
423 let kind = match f.mode {
424 FileMode::Regular => EntryKind::Blob,
425 FileMode::Executable => EntryKind::BlobExecutable,
426 FileMode::Symlink => EntryKind::Link,
427 FileMode::Submodule => EntryKind::Commit,
428 };
429 (kind, f.blob.oid()?)
430 }
431 Node::Dir(d) => (EntryKind::Tree, write_dir(write, d)?),
432 };
433 entries.push(tree::Entry {
434 mode: mode.into(),
435 filename: name.as_str().into(),
436 oid,
437 });
438 }
439 entries.sort();
440 write(gix::objs::Tree { entries })
441}
442
443fn walk(repo: &gix::Repository, id: ObjectId, prefix: &str, out: &mut FileMap) -> Result<()> {
444 let obj = repo.find_object(id).map_err(git)?;
445 if obj.kind != gix::object::Kind::Tree {
446 return Err(Error::Invalid(format!("{id} is not a tree")));
447 }
448 let tree = gix::objs::TreeRef::from_bytes(&obj.data, repo.object_hash()).map_err(git)?;
449 for e in tree.entries {
450 let name = e
451 .filename
452 .to_str()
453 .map_err(|_| Error::Invalid("non-UTF-8 file name in tree".into()))?;
454 let path = if prefix.is_empty() {
455 name.to_string()
456 } else {
457 format!("{prefix}/{name}")
458 };
459 let mode = match e.mode.kind() {
460 EntryKind::Tree => {
461 walk(repo, e.oid.to_owned(), &path, out)?;
462 continue;
463 }
464 EntryKind::Blob => FileMode::Regular,
465 EntryKind::BlobExecutable => FileMode::Executable,
466 EntryKind::Link => FileMode::Symlink,
467 EntryKind::Commit => FileMode::Submodule,
468 };
469 out.insert(
470 path,
471 FileEntry {
472 mode,
473 blob: e.oid.to_owned().into(),
474 },
475 );
476 }
477 Ok(())
478}
479
480#[cfg(test)]
481mod tests {
482 #[test]
484 fn a_plain_parse_is_gixs() {
485 let lower = "0123456789abcdef0123456789abcdef01234567";
486 let cases = [
487 lower.to_string(),
488 lower.to_ascii_uppercase(),
489 "AbCdEf0123456789abcdef0123456789ABCDEF01".to_string(),
490 lower[..39].to_string(),
491 format!("{lower}0"),
492 lower.replace('a', "g"),
493 "0".repeat(64),
494 String::new(),
495 format!("{}é", &lower[..38]),
496 format!(" {}", &lower[..39]),
497 ];
498 for s in cases {
499 let ours = GitId::parse(&s).ok().map(|g| g.0);
500 let gix = ObjectId::from_hex(s.as_bytes()).ok().map(|o| o.to_string());
501 assert_eq!(ours, gix, "{s:?}");
502 }
503 }
504
505 use super::*;
506
507 fn store() -> (tempfile::TempDir, Store) {
508 let dir = tempfile::tempdir().unwrap();
509 let s = Store::init_bare(&dir.path().join("repo.git")).unwrap();
510 (dir, s)
511 }
512
513 #[test]
514 fn short_and_debug_show_ten_digits() {
515 let id = GitId("0123456789abcdef0123456789abcdef01234567".into());
516 assert_eq!(id.short(), "0123456789");
517 assert_eq!(format!("{id:?}"), "GitId(0123456789)");
518 assert_eq!(GitId("abc".into()).short(), "abc");
519 }
520
521 #[test]
522 fn tree_round_trip_with_nested_dirs() {
523 let (_d, s) = store();
524 let a = s.write_blob(b"fn a() {}\n").unwrap();
525 let b = s.write_blob(b"#!/bin/sh\n").unwrap();
526 let mut files = FileMap::new();
527 files.insert(
528 "src/a.rs".into(),
529 FileEntry {
530 mode: FileMode::Regular,
531 blob: a.clone(),
532 },
533 );
534 files.insert(
535 "src/nested/deep/x.rs".into(),
536 FileEntry {
537 mode: FileMode::Regular,
538 blob: a,
539 },
540 );
541 files.insert(
542 "run.sh".into(),
543 FileEntry {
544 mode: FileMode::Executable,
545 blob: b,
546 },
547 );
548 let tree = s.write_tree(&files).unwrap();
549 assert_eq!(s.read_tree(&tree).unwrap(), files);
550 assert_eq!(s.write_tree(&files).unwrap(), tree);
552 }
553
554 #[test]
555 fn rejects_file_and_directory_clash() {
556 let (_d, s) = store();
557 let a = s.write_blob(b"x").unwrap();
558 let mut files = FileMap::new();
559 files.insert(
560 "a".into(),
561 FileEntry {
562 mode: FileMode::Regular,
563 blob: a.clone(),
564 },
565 );
566 files.insert(
567 "a/b".into(),
568 FileEntry {
569 mode: FileMode::Regular,
570 blob: a,
571 },
572 );
573 assert!(s.write_tree(&files).is_err());
574 }
575
576 #[test]
577 fn commit_headers_and_refs() {
578 let (_d, s) = store();
579 let tree = s.empty_tree().unwrap();
580 let spec = CommitSpec {
581 tree: tree.clone(),
582 parents: vec![],
583 author_name: "Ayşe".into(),
584 author_email: "ayse@example.com".into(),
585 time_ms: 1_760_000_000_000,
586 message: "first\n".into(),
587 headers: vec![(
588 "change-id".into(),
589 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz".into(),
590 )],
591 };
592 let c1 = s.write_commit(&spec).unwrap();
593 let info = s.read_commit(&c1).unwrap();
594 assert_eq!(info.tree, tree);
595 assert_eq!(
596 info.header("change-id"),
597 Some("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz")
598 );
599 assert_eq!(info.author_name, "Ayşe");
600
601 s.ref_cas("refs/heads/main", &c1, None).unwrap();
602 assert!(
603 s.ref_cas("refs/heads/main", &c1, None).is_err(),
604 "must not exist"
605 );
606 let c2 = s
607 .write_commit(&CommitSpec {
608 parents: vec![c1.clone()],
609 message: "second\n".into(),
610 ..spec
611 })
612 .unwrap();
613 assert!(
614 s.ref_cas("refs/heads/main", &c2, Some(&c2)).is_err(),
615 "stale expectation"
616 );
617 s.ref_cas("refs/heads/main", &c2, Some(&c1)).unwrap();
618 assert_eq!(s.ref_get("refs/heads/main").unwrap(), Some(c2.clone()));
619 assert_eq!(s.read_commit(&c2).unwrap().parents, vec![c1.clone()]);
620 assert_eq!(
621 s.refs_with_prefix("refs/heads/").unwrap(),
622 vec![("refs/heads/main".to_string(), c2.clone())]
623 );
624 assert!(
625 s.ref_delete("refs/heads/main", &c1).is_err(),
626 "not what it names"
627 );
628 s.ref_delete("refs/heads/main", &c2).unwrap();
629 assert_eq!(s.ref_get("refs/heads/main").unwrap(), None);
630 assert!(s.ref_delete("refs/heads/main", &c2).is_err(), "gone");
631 }
632
633 #[test]
634 fn git_cli_reads_what_we_write() {
635 let (_d, s) = store();
636 let blob = s.write_blob(b"hello\n").unwrap();
637 let mut files = FileMap::new();
638 files.insert(
639 "docs/readme.md".into(),
640 FileEntry {
641 mode: FileMode::Regular,
642 blob,
643 },
644 );
645 let tree = s.write_tree(&files).unwrap();
646 let c = s
647 .write_commit(&CommitSpec {
648 tree,
649 parents: vec![],
650 author_name: "a".into(),
651 author_email: "a@b".into(),
652 time_ms: 0,
653 message: "m\n".into(),
654 headers: vec![(
655 "change-id".into(),
656 "kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk".into(),
657 )],
658 })
659 .unwrap();
660 let out = std::process::Command::new("git")
661 .args([
662 "--git-dir",
663 s.path().to_str().unwrap(),
664 "show",
665 &format!("{c}:docs/readme.md"),
666 ])
667 .output()
668 .unwrap();
669 assert!(
670 out.status.success(),
671 "{}",
672 String::from_utf8_lossy(&out.stderr)
673 );
674 assert_eq!(out.stdout, b"hello\n");
675 let fsck = std::process::Command::new("git")
676 .args([
677 "--git-dir",
678 s.path().to_str().unwrap(),
679 "fsck",
680 "--no-dangling",
681 ])
682 .output()
683 .unwrap();
684 assert!(
685 fsck.status.success(),
686 "{}",
687 String::from_utf8_lossy(&fsck.stderr)
688 );
689 }
690}
691
692#[cfg(test)]
693mod loose_tests {
694 use super::*;
695
696 fn git(dir: &Path, args: &[&str]) -> String {
698 let out = std::process::Command::new("git")
699 .arg("--git-dir")
700 .arg(dir)
701 .args(args)
702 .output()
703 .unwrap();
704 let err = String::from_utf8_lossy(&out.stderr);
705 assert!(out.status.success(), "git {args:?}: {err}");
706 String::from_utf8_lossy(&out.stdout).trim_end().to_string()
707 }
708
709 fn file(mode: FileMode, blob: &GitId) -> FileEntry {
710 let blob = blob.clone();
711 FileEntry { mode, blob }
712 }
713
714 #[test]
715 fn objects_written_with_no_repository_are_what_git_reads() {
716 let dir = tempfile::tempdir().unwrap();
717 let bare = dir.path().join("repo.git");
718 Store::init_bare(&bare).unwrap();
719 let objects = LooseObjects::at(&bare.join("objects"));
720
721 let blob = objects.write_blob(b"hello\n").unwrap();
722 assert_eq!(blob, GitId::of_blob(b"hello\n"));
723 let files = FileMap::from([
724 ("a.txt".to_string(), file(FileMode::Regular, &blob)),
725 ("d/run.sh".to_string(), file(FileMode::Executable, &blob)),
726 ]);
727 let tree = objects.write_tree(&files).unwrap();
728 assert_eq!(
729 tree,
730 Store::open(&bare).unwrap().write_tree(&files).unwrap()
731 );
732 let spec = CommitSpec {
733 tree: tree.clone(),
734 parents: vec![],
735 author_name: "a".into(),
736 author_email: "a@b".into(),
737 time_ms: 5_000,
738 message: "m\n".into(),
739 headers: vec![],
740 };
741 let commit = objects.write_commit(&spec).unwrap();
742 assert_eq!(
743 commit,
744 Store::open(&bare).unwrap().write_commit(&spec).unwrap()
745 );
746 let shown = git(&bare, &["show", "--format=%T %an %at", "-s", &commit.0]);
747 assert_eq!(shown, format!("{} a 5", tree.0));
748 assert_eq!(
749 git(&bare, &["show", &format!("{}:d/run.sh", commit.0)]),
750 "hello"
751 );
752 }
753
754 #[test]
755 fn a_tree_of_paths_that_can_not_be_a_tree_is_refused() {
756 let dir = tempfile::tempdir().unwrap();
757 let objects = LooseObjects::at(dir.path());
758 let blob = GitId::of_blob(b"x");
759 let one = |p: &str| FileMap::from([(p.to_string(), file(FileMode::Regular, &blob))]);
760 assert!(objects.write_tree(&one(".git/config")).is_err());
761 let mut clash = one("a");
762 clash.extend(one("a/b"));
763 assert!(objects.write_tree(&clash).is_err());
764 }
765
766 #[test]
767 fn an_index_lists_the_files_in_gits_order_with_their_modes() {
768 let dir = tempfile::tempdir().unwrap();
769 let bare = dir.path().join("repo.git");
770 Store::init_bare(&bare).unwrap();
771 let blob = GitId::of_blob(b"x");
772 let files = FileMap::from([
773 ("a/b".to_string(), file(FileMode::Regular, &blob)),
774 ("a.b".to_string(), file(FileMode::Regular, &blob)),
775 ("run".to_string(), file(FileMode::Executable, &blob)),
776 ]);
777 std::fs::write(bare.join("index"), index_of(&files).unwrap()).unwrap();
778 let listed = git(&bare, &["ls-files", "--stage"]);
779 let want = [
780 format!("100644 {} 0\ta.b", blob.0),
781 format!("100644 {} 0\ta/b", blob.0),
782 format!("100755 {} 0\trun", blob.0),
783 ];
784 assert_eq!(listed, want.join("\n"));
785
786 for mode in [FileMode::Symlink, FileMode::Submodule] {
787 let files = FileMap::from([("x".to_string(), file(mode, &blob))]);
788 let e = index_of(&files).unwrap_err().to_string();
789 assert!(e.contains("x: no index entry"), "{e}");
790 }
791 }
792}
793
794#[cfg(test)]
795mod blob_id_tests {
796 use super::*;
797
798 #[test]
799 fn of_blob_matches_git() {
800 let out = std::process::Command::new("git")
801 .args(["hash-object", "--stdin"])
802 .stdin(std::process::Stdio::piped())
803 .stdout(std::process::Stdio::piped())
804 .spawn()
805 .and_then(|mut c| {
806 use std::io::Write;
807 c.stdin.take().unwrap().write_all(b"hello\n")?;
808 c.wait_with_output()
809 })
810 .unwrap();
811 assert_eq!(
812 GitId::of_blob(b"hello\n").0,
813 String::from_utf8_lossy(&out.stdout).trim()
814 );
815 }
816}