Skip to main content

lonca_core/
store.rs

1//! Git-compatible object store (gitoxide).
2//!
3//! Content is plain Git: blobs, trees and commits that `git` and GitHub
4//! understand. Lonca metadata lives in Git objects too, under `refs/lonca/*`.
5
6use 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    /// The id Git gives `bytes` as a blob (SHA-1 repositories), without
27    /// writing anything.
28    #[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    /// Author time in milliseconds (Git stores seconds).
65    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    /// Opens the store without reading global or system Git configuration, so
94    /// behaviour does not depend on who runs the daemon.
95    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    /// The size of a blob without reading it (the object header).
118    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    /// Compare-and-swap: `expected == None` means the ref must not exist yet.
210    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                // gix accepts "must not exist" when the ref already holds the
216                // new value; Lonca wants a strict create.
217                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    /// Unconditional ref update. Only used to restore refs that someone
229    /// changed behind the daemon's back.
230    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    /// Deletes `name` if it still names `expected`. Only used to remove
238    /// refs someone wrote behind the daemon's back.
239    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
277/// An objects directory with no repository around it. Nothing is read from
278/// disk: opening a repository would follow its config and `commondir`, which
279/// is no good for a directory somebody else can write to.
280pub 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
310/// A Git index holding `files`, as the bytes of `.git/index`. Every stat is
311/// zero, so Git compares each file by content the first time it looks: a
312/// stat taken here could call a locally edited file clean. Files with
313/// another mode than regular or executable are refused: an index of this
314/// kind describes a checkout, where neither can be.
315pub 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
378/// `files` as a tree of directories.
379fn 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    /// The parser without gix accepts and gives what gix's does.
483    #[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        // Same content, same id: trees are content addressed.
551        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    /// `git` reading a bare repository around the objects.
697    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}