fabrica

hanna/fabrica

feat(git): bare-repo creation and the in-process read API

d52473f · hanna committed on 2026-07-25

Begin phase 5. `crates/git` wraps libgit2 (`git2`) for the in-process object
model — transport stays a spawned-`git` concern for later.

- Layout + creation: repo_path shards by ULID (`{root}/{id[0..2]}/{id}.git`);
  create_bare does init_bare with the initial HEAD, the server config
  (logAllRefUpdates, denyNonFastForwards=false, gc.auto=0), and the
  post-receive/pre-receive hooks, cleaning up the directory on any failure.
- Read API in the plain `types` vocabulary (no `git2` in public signatures, so
  `web` never links libgit2 semantics): resolve_ref, tree_entries, blob (with
  binary sniff), branches (ahead/behind vs default), tags (annotated vs
  lightweight), commits (newest-first, optional path filter, paged), commit.
- Oids are hex strings, times epoch-ms; methods are blocking and documented to
  require spawn_blocking off the async runtime.

Integration tests build a real bare repo via git2 and exercise every read.
git2 is pinned to a libgit2-sys matching nixpkgs' libgit2 1.9.4, and the dev
shell exports LD_LIBRARY_PATH for the dynamic link; both are in decisions.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
8 files changed · +1272 −2UnifiedSplit
Cargo.lock +57 −1
@@ -198,6 +198,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
198checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"198checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
199dependencies = [199dependencies = [
200 "find-msvc-tools",200 "find-msvc-tools",
201 "jobserver",
202 "libc",
201 "shlex",203 "shlex",
202]204]
203205
@@ -648,6 +650,26 @@ dependencies = [
648[[package]]650[[package]]
649name = "git"651name = "git"
650version = "0.1.0"652version = "0.1.0"
653dependencies = [
654 "git2",
655 "libgit2-sys",
656 "model",
657 "tempfile",
658 "thiserror",
659]
660
661[[package]]
662name = "git2"
663version = "0.20.4"
664source = "registry+https://github.com/rust-lang/crates.io-index"
665checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b"
666dependencies = [
667 "bitflags",
668 "libc",
669 "libgit2-sys",
670 "log",
671 "url",
672]
651673
652[[package]]674[[package]]
653name = "hashbrown"675name = "hashbrown"
@@ -858,6 +880,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
858checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"880checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
859881
860[[package]]882[[package]]
883name = "jobserver"
884version = "0.1.35"
885source = "registry+https://github.com/rust-lang/crates.io-index"
886checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
887dependencies = [
888 "getrandom 0.4.3",
889 "libc",
890]
891
892[[package]]
861name = "js-sys"893name = "js-sys"
862version = "0.3.103"894version = "0.3.103"
863source = "registry+https://github.com/rust-lang/crates.io-index"895source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -875,6 +907,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
875checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"907checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
876908
877[[package]]909[[package]]
910name = "libgit2-sys"
911version = "0.18.3+1.9.2"
912source = "registry+https://github.com/rust-lang/crates.io-index"
913checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487"
914dependencies = [
915 "cc",
916 "libc",
917 "libz-sys",
918 "pkg-config",
919]
920
921[[package]]
878name = "libsqlite3-sys"922name = "libsqlite3-sys"
879version = "0.37.0"923version = "0.37.0"
880source = "registry+https://github.com/rust-lang/crates.io-index"924source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -886,6 +930,18 @@ dependencies = [
886]930]
887931
888[[package]]932[[package]]
933name = "libz-sys"
934version = "1.1.29"
935source = "registry+https://github.com/rust-lang/crates.io-index"
936checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9"
937dependencies = [
938 "cc",
939 "libc",
940 "pkg-config",
941 "vcpkg",
942]
943
944[[package]]
889name = "linux-raw-sys"945name = "linux-raw-sys"
890version = "0.12.1"946version = "0.12.1"
891source = "registry+https://github.com/rust-lang/crates.io-index"947source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1603,7 +1659,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1603checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"1659checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
1604dependencies = [1660dependencies = [
1605 "fastrand",1661 "fastrand",
1606 "getrandom 0.4.3",1662 "getrandom 0.3.4",
1607 "once_cell",1663 "once_cell",
1608 "rustix",1664 "rustix",
1609 "windows-sys 0.61.2",1665 "windows-sys 0.61.2",
Cargo.toml +4 −0
@@ -72,6 +72,10 @@ sqlx = { version = "0.9", default-features = false, features = [
72ulid = "1"72ulid = "1"
73tokio = { version = "1", features = ["macros", "rt-multi-thread"] }73tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
74tempfile = "3"74tempfile = "3"
75# Git object model (refs, trees, blobs, commits, diffs, signatures). Linked
76# against the system libgit2 (1.9) via pkg-config; the Nix build sets
77# LIBGIT2_NO_VENDOR=1. Client-only for transport — pack transport spawns `git`.
78git2 = { version = "0.20", default-features = false }
75# Authentication primitives. Argon2id password hashing; SHA-256 for session and79# Authentication primitives. Argon2id password hashing; SHA-256 for session and
76# invite token digests; HMAC-SHA256 for hand-rolled HS256 JWTs (avoiding a `ring`80# invite token digests; HMAC-SHA256 for hand-rolled HS256 JWTs (avoiding a `ring`
77# dependency, whose license set is not in our allow-list — see docs/decisions.md);81# dependency, whose license set is not in our allow-list — see docs/decisions.md);
crates/git/Cargo.toml +12 −0
@@ -10,6 +10,18 @@ repository.workspace = true
10publish.workspace = true10publish.workspace = true
1111
12[dependencies]12[dependencies]
13model = { workspace = true }
14git2 = { workspace = true }
15thiserror = { workspace = true }
16# Pin the libgit2 bindings to a build that requires libgit2 <= the version
17# nixpkgs ships (1.9.4). git2 0.20's default libgit2-sys (0.18.7, bundling 1.9.6)
18# demands >= 1.9.6 and fails against the system lib under LIBGIT2_NO_VENDOR. This
19# is a direct pin, not used in code — bump it when nixpkgs bumps libgit2. See
20# docs/decisions.md.
21libgit2-sys = "=0.18.3"
22
23[dev-dependencies]
24tempfile = { workspace = true }
1325
14[lints]26[lints]
15workspace = true27workspace = true
crates/git/src/lib.rs +239 −1
@@ -2,4 +2,242 @@
2// License, v. 2.0. If a copy of the MPL was not distributed with this2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
44
5//! The git layer: in-process object reads via libgit2 and spawned pack transport.5//! The git layer: object-model reads over `git2`, on-disk repository layout, and
6//! (in later phases) attribute resolution, signature verification, and
7//! pack-transport spawning.
8//!
9//! # Division of responsibility
10//!
11//! `git2` (libgit2) is the object-model library — refs, trees, blobs, commits,
12//! revwalks, diffs, signatures. It is **client-only for transport**: there is no
13//! server-side `upload-pack`/`receive-pack`, so clone/fetch/push are handled by
14//! spawning the `git` binary (a later phase). This crate owns the in-process
15//! reads and the bare-repository creation that both paths share.
16//!
17//! # Threading
18//!
19//! libgit2 calls are blocking and `git2::Repository` is not `Sync`. Callers on an
20//! async runtime **MUST** run these methods on `tokio::task::spawn_blocking` (or a
21//! bounded blocking pool); [`Repo`] is opened per operation, which is acceptable
22//! for the MVP. Nothing here spawns threads or touches the network.
23//!
24//! # Layout
25//!
26//! Repositories live at `{repo_dir}/{id[0..2]}/{id}.git` keyed by ULID, never by
27//! name, so renames and group moves are metadata-only. The database is the sole
28//! name→path authority; [`repo_path`] is the one place that mapping is computed.
29
30mod repo;
31mod types;
32
33use std::fs;
34use std::io;
35use std::os::unix::fs::PermissionsExt;
36use std::path::{Path, PathBuf};
37
38use git2::{Repository, RepositoryInitOptions};
39
40pub use crate::repo::Repo;
41pub use crate::types::{
42 Blob, BranchInfo, CommitDetail, CommitSummary, EntryKind, Oid, Page, Person, RefKind, Resolved,
43 TagInfo, TreeEntry,
44};
45
46/// An error from the git layer.
47#[derive(Debug, thiserror::Error)]
48pub enum GitError {
49 /// An error surfaced by libgit2.
50 #[error(transparent)]
51 Libgit2(#[from] git2::Error),
52
53 /// A filesystem operation failed, with the path that was being touched.
54 #[error("git io error at {path}: {source}")]
55 Io {
56 /// The path involved.
57 path: PathBuf,
58 /// The underlying I/O error.
59 source: io::Error,
60 },
61
62 /// A repository id was too short to shard (ids are 26-char ULIDs).
63 #[error("repository id {0:?} is too short")]
64 BadId(String),
65
66 /// A revision string did not resolve to any ref or object.
67 #[error("revision {0:?} not found")]
68 RevNotFound(String),
69
70 /// A path was not present in the given tree.
71 #[error("path {0:?} not found")]
72 PathNotFound(String),
73
74 /// A path resolved to something other than a file where a file was required.
75 #[error("path {0:?} is not a file")]
76 NotAFile(String),
77}
78
79/// Compute the on-disk path of a repository from its id: `{root}/{id[0..2]}/{id}.git`.
80///
81/// # Errors
82///
83/// Returns [`GitError::BadId`] if `id` is shorter than the two-character shard
84/// prefix.
85pub fn repo_path(root: &Path, id: &str) -> Result<PathBuf, GitError> {
86 if id.len() < 2 {
87 return Err(GitError::BadId(id.to_string()));
88 }
89 Ok(root.join(&id[0..2]).join(format!("{id}.git")))
90}
91
92/// Create a bare repository for `id` under `root` and return an open handle.
93///
94/// Performs the filesystem half of repo creation (the caller inserts the database
95/// row): `init_bare` with `HEAD` pointing at `default_branch`; the config the
96/// server relies on (`core.logAllRefUpdates=true`, `receive.denyNonFastForwards=
97/// false`, `gc.auto=0` — maintenance is fabrica-driven, not push-driven); and the
98/// `post-receive`/`pre-receive` hooks that shell out to `hook_binary`.
99///
100/// On any failure after the directory is created, the partially built directory
101/// is removed so a retry starts clean.
102///
103/// # Errors
104///
105/// Returns [`GitError`] if the id is invalid, the directory already exists, or any
106/// libgit2/filesystem step fails.
107pub fn create_bare(
108 root: &Path,
109 id: &str,
110 default_branch: &str,
111 hook_binary: &Path,
112) -> Result<Repo, GitError> {
113 let path = repo_path(root, id)?;
114 if path.exists() {
115 return Err(GitError::Io {
116 path: path.clone(),
117 source: io::Error::new(io::ErrorKind::AlreadyExists, "repository directory exists"),
118 });
119 }
120 if let Some(parent) = path.parent() {
121 fs::create_dir_all(parent).map_err(|source| GitError::Io {
122 path: parent.to_path_buf(),
123 source,
124 })?;
125 }
126
127 // Any error past this point should not leave a half-built repo behind.
128 match init_inner(&path, default_branch, hook_binary) {
129 Ok(()) => Repo::open_path(&path),
130 Err(err) => {
131 let _ = fs::remove_dir_all(&path);
132 Err(err)
133 }
134 }
135}
136
137/// The fallible body of [`create_bare`], separated so the caller can clean up on
138/// any error.
139fn init_inner(path: &Path, default_branch: &str, hook_binary: &Path) -> Result<(), GitError> {
140 let mut opts = RepositoryInitOptions::new();
141 opts.bare(true).initial_head(default_branch).mkpath(true);
142 let repo = Repository::init_opts(path, &opts)?;
143
144 let mut config = repo.config()?;
145 config.set_bool("core.logallrefupdates", true)?;
146 config.set_bool("receive.denyNonFastForwards", false)?;
147 config.set_i32("gc.auto", 0)?;
148
149 install_hooks(path, hook_binary)?;
150 Ok(())
151}
152
153/// Write the `post-receive` and `pre-receive` hooks. `post-receive` dispatches to
154/// `fabrica hook post-receive`; `pre-receive` is a reserved no-op. Both are made
155/// executable.
156fn install_hooks(repo_path: &Path, hook_binary: &Path) -> Result<(), GitError> {
157 let hooks_dir = repo_path.join("hooks");
158 fs::create_dir_all(&hooks_dir).map_err(|source| GitError::Io {
159 path: hooks_dir.clone(),
160 source,
161 })?;
162
163 let binary = hook_binary.display();
164 write_hook(
165 &hooks_dir.join("post-receive"),
166 &format!("#!/bin/sh\nexec \"{binary}\" hook post-receive\n"),
167 )?;
168 // Reserved for future branch protection; accepts every push for now.
169 write_hook(&hooks_dir.join("pre-receive"), "#!/bin/sh\nexit 0\n")?;
170 Ok(())
171}
172
173/// Write a single hook file and mark it executable (`0o755`).
174fn write_hook(path: &Path, contents: &str) -> Result<(), GitError> {
175 fs::write(path, contents).map_err(|source| GitError::Io {
176 path: path.to_path_buf(),
177 source,
178 })?;
179 fs::set_permissions(path, fs::Permissions::from_mode(0o755)).map_err(|source| {
180 GitError::Io {
181 path: path.to_path_buf(),
182 source,
183 }
184 })?;
185 Ok(())
186}
187
188#[cfg(test)]
189mod tests {
190 #![allow(clippy::unwrap_used)]
191
192 use super::*;
193
194 #[test]
195 fn repo_path_shards_by_id_prefix() {
196 let root = Path::new("/srv/repos");
197 let path = repo_path(root, "01hzxk9m2n8p7q6r5s4t3v2w1x").unwrap();
198 assert_eq!(
199 path,
200 Path::new("/srv/repos/01/01hzxk9m2n8p7q6r5s4t3v2w1x.git")
201 );
202 }
203
204 #[test]
205 fn repo_path_rejects_short_ids() {
206 assert!(matches!(
207 repo_path(Path::new("/srv"), "a"),
208 Err(GitError::BadId(_))
209 ));
210 }
211
212 #[test]
213 fn create_bare_sets_head_config_and_hooks() {
214 let dir = tempfile::tempdir().unwrap();
215 let repo = create_bare(
216 dir.path(),
217 "01hzxk9m2n8p7q6r5s4t3v2w1x",
218 "trunk",
219 Path::new("/usr/bin/fabrica"),
220 )
221 .unwrap();
222
223 // HEAD points at the requested initial branch.
224 assert_eq!(repo.head_branch().unwrap().as_deref(), Some("trunk"));
225
226 let path = repo_path(dir.path(), "01hzxk9m2n8p7q6r5s4t3v2w1x").unwrap();
227 // The hook exists, is executable, and dispatches to `fabrica hook`.
228 let hook = path.join("hooks").join("post-receive");
229 let body = std::fs::read_to_string(&hook).unwrap();
230 assert!(body.contains("hook post-receive"), "body: {body}");
231 let mode = std::fs::metadata(&hook).unwrap().permissions().mode();
232 assert_eq!(mode & 0o111, 0o111, "hook must be executable");
233 }
234
235 #[test]
236 fn create_bare_is_not_clobbering() {
237 let dir = tempfile::tempdir().unwrap();
238 let id = "01hzxk9m2n8p7q6r5s4t3v2w1x";
239 create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).unwrap();
240 // A second create at the same id fails (the directory already exists).
241 assert!(create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).is_err());
242 }
243}
crates/git/src/repo.rs +707 −0
@@ -0,0 +1,707 @@
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! An open repository and its read operations.
6//!
7//! Every method here is synchronous and blocking; see the crate docs for the
8//! `spawn_blocking` requirement. The public surface speaks only in the plain
9//! [`crate::types`] vocabulary — `git2` handles never escape this module.
10
11use std::path::Path;
12
13use git2::{BranchType, Commit, Repository, Signature, Sort, TreeEntry as GitTreeEntry};
14
15use crate::GitError;
16use crate::types::{
17 Blob, BranchInfo, CommitDetail, CommitSummary, EntryKind, Oid, Page, Person, RefKind, Resolved,
18 TagInfo, TreeEntry,
19};
20
21/// Filemode bits git uses to distinguish tree-entry kinds.
22const MODE_DIR: i32 = 0o040_000;
23const MODE_SYMLINK: i32 = 0o120_000;
24const MODE_SUBMODULE: i32 = 0o160_000;
25
26/// Bytes sniffed from the head of a blob when deciding whether it is binary.
27const BINARY_SNIFF_BYTES: usize = 8_000;
28
29/// An open bare repository.
30pub struct Repo {
31 inner: Repository,
32}
33
34impl Repo {
35 /// Open the repository at `path` (expected to be a bare `.git` directory).
36 ///
37 /// # Errors
38 ///
39 /// Returns [`GitError::Libgit2`] if the path is not a repository.
40 pub fn open_path(path: &Path) -> Result<Self, GitError> {
41 Ok(Self {
42 inner: Repository::open_bare(path)?,
43 })
44 }
45
46 /// The branch name `HEAD` points at, even when that branch is unborn (a
47 /// freshly created repo with no commits). Returns `None` if `HEAD` is
48 /// detached.
49 ///
50 /// # Errors
51 ///
52 /// Returns [`GitError::Libgit2`] if `HEAD` cannot be read.
53 pub fn head_branch(&self) -> Result<Option<String>, GitError> {
54 let head = self.inner.find_reference("HEAD")?;
55 Ok(head
56 .symbolic_target()
57 .and_then(|t| t.strip_prefix("refs/heads/"))
58 .map(str::to_string))
59 }
60
61 /// Resolve a revision string — a branch or tag name, `HEAD`, or a raw commit
62 /// id — to the commit it names.
63 ///
64 /// Resolution order is branch, then tag, then a general parse (which covers
65 /// `HEAD`, full and abbreviated oids, and revspecs). A branch and a tag of the
66 /// same name resolve to the branch, matching git's own precedence for a bare
67 /// short name in this context.
68 ///
69 /// # Errors
70 ///
71 /// Returns [`GitError::RevNotFound`] if nothing matches.
72 pub fn resolve_ref(&self, rev: &str) -> Result<Resolved, GitError> {
73 if let Ok(branch) = self.inner.find_branch(rev, BranchType::Local) {
74 let commit = branch.get().peel_to_commit()?;
75 return Ok(Resolved {
76 oid: oid_of(commit.id()),
77 kind: RefKind::Branch,
78 name: rev.to_string(),
79 });
80 }
81 if let Ok(reference) = self.inner.find_reference(&format!("refs/tags/{rev}")) {
82 let commit = reference.peel_to_commit()?;
83 return Ok(Resolved {
84 oid: oid_of(commit.id()),
85 kind: RefKind::Tag,
86 name: rev.to_string(),
87 });
88 }
89 let object = self
90 .inner
91 .revparse_single(rev)
92 .map_err(|_| GitError::RevNotFound(rev.to_string()))?;
93 let commit = object
94 .peel_to_commit()
95 .map_err(|_| GitError::RevNotFound(rev.to_string()))?;
96 Ok(Resolved {
97 oid: oid_of(commit.id()),
98 kind: if rev == "HEAD" {
99 RefKind::Head
100 } else {
101 RefKind::Commit
102 },
103 name: rev.to_string(),
104 })
105 }
106
107 /// List the entries of the tree at `path` within revision `rev`. An empty
108 /// `path` lists the root tree. Directories sort before files, each
109 /// alphabetically.
110 ///
111 /// # Errors
112 ///
113 /// Returns [`GitError::PathNotFound`] if `path` is absent or names a file.
114 pub fn tree_entries(&self, rev: &Resolved, path: &Path) -> Result<Vec<TreeEntry>, GitError> {
115 let commit = self.commit_at(&rev.oid)?;
116 let root = commit.tree()?;
117
118 let tree = if path.components().next().is_none() {
119 root
120 } else {
121 let entry = root
122 .get_path(path)
123 .map_err(|_| GitError::PathNotFound(path.display().to_string()))?;
124 entry
125 .to_object(&self.inner)?
126 .into_tree()
127 .map_err(|_| GitError::PathNotFound(path.display().to_string()))?
128 };
129
130 let mut entries: Vec<TreeEntry> = tree
131 .iter()
132 .map(|entry| self.map_tree_entry(&entry))
133 .collect();
134 entries.sort_by(|a, b| {
135 let a_dir = a.kind == EntryKind::Directory;
136 let b_dir = b.kind == EntryKind::Directory;
137 b_dir.cmp(&a_dir).then_with(|| a.name.cmp(&b.name))
138 });
139 Ok(entries)
140 }
141
142 /// Read the file at `path` within revision `rev`.
143 ///
144 /// # Errors
145 ///
146 /// Returns [`GitError::PathNotFound`] if `path` is absent, or
147 /// [`GitError::NotAFile`] if it names a directory or submodule.
148 pub fn blob(&self, rev: &Resolved, path: &Path) -> Result<Blob, GitError> {
149 let commit = self.commit_at(&rev.oid)?;
150 let entry = commit
151 .tree()?
152 .get_path(path)
153 .map_err(|_| GitError::PathNotFound(path.display().to_string()))?;
154 let object = entry.to_object(&self.inner)?;
155 let blob = object
156 .into_blob()
157 .map_err(|_| GitError::NotAFile(path.display().to_string()))?;
158 let content = blob.content().to_vec();
159 let size = content.len() as u64;
160 Ok(Blob {
161 is_binary: looks_binary(&content),
162 size,
163 content,
164 })
165 }
166
167 /// The local branches, each with its position (ahead/behind) relative to the
168 /// default branch and its tip commit.
169 ///
170 /// # Errors
171 ///
172 /// Returns [`GitError::Libgit2`] on a libgit2 failure.
173 pub fn branches(&self) -> Result<Vec<BranchInfo>, GitError> {
174 let default = self.head_branch()?;
175 let default_oid = default
176 .as_deref()
177 .and_then(|name| self.inner.find_branch(name, BranchType::Local).ok())
178 .and_then(|branch| branch.get().peel_to_commit().ok())
179 .map(|commit| commit.id());
180
181 let mut out = Vec::new();
182 for branch in self.inner.branches(Some(BranchType::Local))? {
183 let (branch, _) = branch?;
184 let Some(name) = branch.name()?.map(str::to_string) else {
185 continue;
186 };
187 let tip = branch.get().peel_to_commit()?;
188 let (ahead, behind) = match default_oid {
189 Some(base) if base != tip.id() => self.inner.graph_ahead_behind(tip.id(), base)?,
190 _ => (0, 0),
191 };
192 out.push(BranchInfo {
193 is_default: default.as_deref() == Some(name.as_str()),
194 name,
195 oid: oid_of(tip.id()),
196 ahead,
197 behind,
198 tip: commit_summary(&tip),
199 });
200 }
201 out.sort_by(|a, b| {
202 b.is_default
203 .cmp(&a.is_default)
204 .then_with(|| a.name.cmp(&b.name))
205 });
206 Ok(out)
207 }
208
209 /// The tags, peeled to the commit each ultimately points at, annotated tags
210 /// carrying their message and tagger.
211 ///
212 /// # Errors
213 ///
214 /// Returns [`GitError::Libgit2`] on a libgit2 failure.
215 pub fn tags(&self) -> Result<Vec<TagInfo>, GitError> {
216 let mut out = Vec::new();
217 for name in self.inner.tag_names(None)?.iter().flatten() {
218 let object = self.inner.revparse_single(&format!("refs/tags/{name}"))?;
219 let commit = object.peel_to_commit()?;
220 let (annotated, message, tagger) = match object.as_tag() {
221 Some(tag) => (
222 true,
223 tag.message().map(str::to_string),
224 tag.tagger().as_ref().map(person),
225 ),
226 None => (false, None, None),
227 };
228 out.push(TagInfo {
229 name: name.to_string(),
230 oid: oid_of(commit.id()),
231 annotated,
232 message,
233 tagger,
234 });
235 }
236 out.sort_by(|a, b| a.name.cmp(&b.name));
237 Ok(out)
238 }
239
240 /// Walk commits reachable from `rev`, newest first, returning one page.
241 ///
242 /// When `path` is given, only commits that changed that path are returned
243 /// (compared against the first parent — a pragmatic history simplification,
244 /// not full merge-aware path following). Paging is applied *after* filtering.
245 ///
246 /// # Errors
247 ///
248 /// Returns [`GitError::Libgit2`] on a libgit2 failure.
249 pub fn commits(
250 &self,
251 rev: &Resolved,
252 path: Option<&Path>,
253 page: Page,
254 ) -> Result<Vec<CommitSummary>, GitError> {
255 let start = git2::Oid::from_str(rev.oid.as_str())?;
256 let mut walk = self.inner.revwalk()?;
257 walk.set_sorting(Sort::TIME)?;
258 walk.push(start)?;
259
260 let mut out = Vec::with_capacity(page.limit);
261 let mut skipped = 0usize;
262 for oid in walk {
263 let commit = self.inner.find_commit(oid?)?;
264 if let Some(path) = path {
265 if !commit_touches(&commit, path)? {
266 continue;
267 }
268 }
269 if skipped < page.offset {
270 skipped += 1;
271 continue;
272 }
273 out.push(commit_summary(&commit));
274 if out.len() >= page.limit {
275 break;
276 }
277 }
278 Ok(out)
279 }
280
281 /// Fetch a single commit in full.
282 ///
283 /// # Errors
284 ///
285 /// Returns [`GitError::RevNotFound`] if no such commit exists.
286 pub fn commit(&self, oid: &Oid) -> Result<CommitDetail, GitError> {
287 let commit = self.commit_at(oid)?;
288 Ok(CommitDetail {
289 oid: oid_of(commit.id()),
290 message: commit.message().unwrap_or("").to_string(),
291 author: person(&commit.author()),
292 committer: person(&commit.committer()),
293 parents: commit.parent_ids().map(oid_of).collect(),
294 tree: oid_of(commit.tree_id()),
295 })
296 }
297
298 /// Look up a commit by our [`Oid`], mapping a miss to [`GitError::RevNotFound`].
299 fn commit_at(&self, oid: &Oid) -> Result<Commit<'_>, GitError> {
300 let parsed = git2::Oid::from_str(oid.as_str())
301 .map_err(|_| GitError::RevNotFound(oid.to_string()))?;
302 self.inner
303 .find_commit(parsed)
304 .map_err(|_| GitError::RevNotFound(oid.to_string()))
305 }
306
307 /// Map a libgit2 tree entry into our [`TreeEntry`], reading blob sizes for
308 /// files and symlinks.
309 fn map_tree_entry(&self, entry: &GitTreeEntry<'_>) -> TreeEntry {
310 let mode = entry.filemode();
311 let kind = match mode {
312 MODE_DIR => EntryKind::Directory,
313 MODE_SUBMODULE => EntryKind::Submodule,
314 MODE_SYMLINK => EntryKind::Symlink,
315 _ => EntryKind::File,
316 };
317 let size = match kind {
318 EntryKind::File | EntryKind::Symlink => self
319 .inner
320 .find_blob(entry.id())
321 .ok()
322 .map(|b| b.size() as u64),
323 EntryKind::Directory | EntryKind::Submodule => None,
324 };
325 TreeEntry {
326 name: entry.name().unwrap_or_default().to_string(),
327 kind,
328 // Filemodes are small positive octal constants; the fallback is
329 // unreachable for a well-formed tree entry.
330 mode: u32::try_from(mode).unwrap_or(0),
331 oid: oid_of(entry.id()),
332 size,
333 }
334 }
335}
336
337/// Whether `commit` changed `path` relative to its first parent (or, for a root
338/// commit, whether the path exists in it).
339fn commit_touches(commit: &Commit<'_>, path: &Path) -> Result<bool, GitError> {
340 let here = entry_oid(commit, path)?;
341 match commit.parent(0) {
342 Ok(parent) => Ok(here != entry_oid(&parent, path)?),
343 // No parent: the commit touches the path iff the path exists in it.
344 Err(_) => Ok(here.is_some()),
345 }
346}
347
348/// The object id at `path` in a commit's tree, or `None` if the path is absent.
349fn entry_oid(commit: &Commit<'_>, path: &Path) -> Result<Option<git2::Oid>, GitError> {
350 let tree = commit.tree()?;
351 Ok(tree.get_path(path).ok().map(|entry| entry.id()))
352}
353
354/// Convert a libgit2 oid to our hex [`Oid`].
355fn oid_of(oid: git2::Oid) -> Oid {
356 Oid::new(oid.to_string())
357}
358
359/// Build a [`Person`] from a libgit2 signature, converting the second-resolution
360/// time to epoch milliseconds.
361fn person(sig: &Signature<'_>) -> Person {
362 Person {
363 name: sig.name().unwrap_or_default().to_string(),
364 email: sig.email().unwrap_or_default().to_string(),
365 time_ms: sig.when().seconds().saturating_mul(1000),
366 }
367}
368
369/// Build a [`CommitSummary`] for a log row.
370fn commit_summary(commit: &Commit<'_>) -> CommitSummary {
371 CommitSummary {
372 oid: oid_of(commit.id()),
373 summary: commit.summary().unwrap_or("").to_string(),
374 author: person(&commit.author()),
375 parents: commit.parent_count(),
376 }
377}
378
379/// Heuristic binary sniff: a NUL byte in the head of the content marks it binary,
380/// matching git's own `core.check-blob-content` behaviour closely enough for a
381/// "don't try to render this" decision.
382fn looks_binary(bytes: &[u8]) -> bool {
383 let window = &bytes[..bytes.len().min(BINARY_SNIFF_BYTES)];
384 window.contains(&0)
385}
386
387#[cfg(test)]
388mod tests {
389 #![allow(clippy::unwrap_used, clippy::expect_used)]
390
391 use std::path::{Path, PathBuf};
392
393 use git2::{Repository, Signature, Time};
394 use tempfile::TempDir;
395
396 use super::*;
397 use crate::{create_bare, repo_path};
398
399 const ID: &str = "01hzxk9m2n8p7q6r5s4t3v2w1x";
400
401 /// A populated bare repository plus the temp dir keeping it alive.
402 struct Fixture {
403 _dir: TempDir,
404 path: PathBuf,
405 /// Oids of the commits made, in creation order.
406 commits: Vec<git2::Oid>,
407 }
408
409 /// Build a bare repo with:
410 /// - `main`: commit1 (README + src/main.rs), commit2 (README changed only);
411 /// - `feature`: commit3 on top of commit2 (adds src/extra.rs);
412 /// - an annotated tag `v1.0` on commit1 and a lightweight tag `light` on commit2.
413 fn fixture() -> Fixture {
414 let dir = tempfile::tempdir().unwrap();
415 create_bare(dir.path(), ID, "main", Path::new("/usr/bin/fabrica")).unwrap();
416 let path = repo_path(dir.path(), ID).unwrap();
417 let raw = Repository::open_bare(&path).unwrap();
418
419 let c1 = commit(
420 &raw,
421 "main",
422 &[],
423 &[("README.md", "hello\n"), ("src/main.rs", "fn main() {}\n")],
424 "initial import",
425 1,
426 );
427 let c2 = commit(
428 &raw,
429 "main",
430 &[c1],
431 &[
432 ("README.md", "hello world\n"),
433 ("src/main.rs", "fn main() {}\n"),
434 ],
435 "expand readme",
436 2,
437 );
438 let c3 = commit(
439 &raw,
440 "feature",
441 &[c2],
442 &[
443 ("README.md", "hello world\n"),
444 ("src/main.rs", "fn main() {}\n"),
445 ("src/extra.rs", "// extra\n"),
446 ],
447 "add extra module",
448 3,
449 );
450
451 // Tags: annotated on c1, lightweight on c2.
452 let obj = raw.find_object(c1, None).unwrap();
453 let tagger = Signature::new("Ada", "ada@example.com", &Time::new(10_000, 0)).unwrap();
454 raw.tag("v1.0", &obj, &tagger, "the first release", false)
455 .unwrap();
456 raw.reference("refs/tags/light", c2, true, "lightweight")
457 .unwrap();
458
459 Fixture {
460 _dir: dir,
461 path,
462 commits: vec![c1, c2, c3],
463 }
464 }
465
466 /// Write `files` (paths may contain one level of nesting) as a tree and commit
467 /// it onto `branch`, returning the new commit oid. `t` seeds the commit time so
468 /// ordering is deterministic.
469 fn commit(
470 raw: &Repository,
471 branch: &str,
472 parents: &[git2::Oid],
473 files: &[(&str, &str)],
474 message: &str,
475 t: i64,
476 ) -> git2::Oid {
477 use std::collections::BTreeMap;
478
479 // Group files by their (single) directory prefix.
480 let mut by_dir: BTreeMap<Option<String>, Vec<(String, git2::Oid)>> = BTreeMap::new();
481 for (path, content) in files {
482 let blob = raw.blob(content.as_bytes()).unwrap();
483 let (dir, name) = match path.split_once('/') {
484 Some((dir, name)) => (Some(dir.to_string()), name.to_string()),
485 None => (None, (*path).to_string()),
486 };
487 by_dir.entry(dir).or_default().push((name, blob));
488 }
489
490 let mut root = raw.treebuilder(None).unwrap();
491 for (dir, entries) in by_dir {
492 match dir {
493 None => {
494 for (name, blob) in entries {
495 root.insert(&name, blob, 0o100_644).unwrap();
496 }
497 }
498 Some(dir) => {
499 let mut sub = raw.treebuilder(None).unwrap();
500 for (name, blob) in entries {
501 sub.insert(&name, blob, 0o100_644).unwrap();
502 }
503 let sub_oid = sub.write().unwrap();
504 root.insert(&dir, sub_oid, 0o040_000).unwrap();
505 }
506 }
507 }
508 let tree_oid = root.write().unwrap();
509 let tree = raw.find_tree(tree_oid).unwrap();
510
511 let sig = Signature::new("Ada Lovelace", "ada@example.com", &Time::new(t, 0)).unwrap();
512 let parent_commits: Vec<_> = parents
513 .iter()
514 .map(|p| raw.find_commit(*p).unwrap())
515 .collect();
516 let parent_refs: Vec<&git2::Commit> = parent_commits.iter().collect();
517 raw.commit(
518 Some(&format!("refs/heads/{branch}")),
519 &sig,
520 &sig,
521 message,
522 &tree,
523 &parent_refs,
524 )
525 .unwrap()
526 }
527
528 fn open(fx: &Fixture) -> Repo {
529 Repo::open_path(&fx.path).unwrap()
530 }
531
532 #[test]
533 fn resolve_ref_covers_branch_tag_head_oid_and_miss() {
534 let fx = fixture();
535 let repo = open(&fx);
536 let c2 = fx.commits[1].to_string();
537
538 let main = repo.resolve_ref("main").unwrap();
539 assert_eq!(main.kind, RefKind::Branch);
540 assert_eq!(main.oid.as_str(), c2);
541
542 let head = repo.resolve_ref("HEAD").unwrap();
543 assert_eq!(head.kind, RefKind::Head);
544 assert_eq!(head.oid.as_str(), c2, "HEAD tracks the default branch");
545
546 let tag = repo.resolve_ref("v1.0").unwrap();
547 assert_eq!(tag.kind, RefKind::Tag);
548 assert_eq!(tag.oid.as_str(), fx.commits[0].to_string());
549
550 let raw = repo.resolve_ref(&c2).unwrap();
551 assert_eq!(raw.kind, RefKind::Commit);
552 assert_eq!(raw.oid.as_str(), c2);
553
554 assert!(matches!(
555 repo.resolve_ref("nope"),
556 Err(GitError::RevNotFound(_))
557 ));
558 }
559
560 #[test]
561 fn tree_entries_lists_root_and_subdir_dirs_first() {
562 let fx = fixture();
563 let repo = open(&fx);
564 let main = repo.resolve_ref("main").unwrap();
565
566 let root = repo.tree_entries(&main, Path::new("")).unwrap();
567 let names: Vec<_> = root.iter().map(|e| e.name.as_str()).collect();
568 assert_eq!(names, ["src", "README.md"], "directories sort first");
569 assert_eq!(root[0].kind, EntryKind::Directory);
570 assert_eq!(root[1].kind, EntryKind::File);
571 assert_eq!(root[1].size, Some("hello world\n".len() as u64));
572
573 let src = repo.tree_entries(&main, Path::new("src")).unwrap();
574 assert_eq!(
575 src.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
576 ["main.rs"]
577 );
578
579 assert!(matches!(
580 repo.tree_entries(&main, Path::new("does/not/exist")),
581 Err(GitError::PathNotFound(_))
582 ));
583 // A file path is not a directory.
584 assert!(matches!(
585 repo.tree_entries(&main, Path::new("README.md")),
586 Err(GitError::PathNotFound(_))
587 ));
588 }
589
590 #[test]
591 fn blob_reads_content_and_flags() {
592 let fx = fixture();
593 let repo = open(&fx);
594 let main = repo.resolve_ref("main").unwrap();
595
596 let readme = repo.blob(&main, Path::new("README.md")).unwrap();
597 assert_eq!(readme.content, b"hello world\n");
598 assert_eq!(readme.size, 12);
599 assert!(!readme.is_binary);
600
601 // A directory is not a file.
602 assert!(matches!(
603 repo.blob(&main, Path::new("src")),
604 Err(GitError::NotAFile(_))
605 ));
606 assert!(matches!(
607 repo.blob(&main, Path::new("missing")),
608 Err(GitError::PathNotFound(_))
609 ));
610 }
611
612 #[test]
613 fn branches_report_default_and_ahead_behind() {
614 let fx = fixture();
615 let repo = open(&fx);
616 let branches = repo.branches().unwrap();
617
618 let names: Vec<_> = branches.iter().map(|b| b.name.as_str()).collect();
619 assert_eq!(names, ["main", "feature"], "default sorts first");
620
621 let main = &branches[0];
622 assert!(main.is_default);
623 assert_eq!((main.ahead, main.behind), (0, 0));
624
625 let feature = &branches[1];
626 assert!(!feature.is_default);
627 // feature is one commit ahead of main and behind by none.
628 assert_eq!((feature.ahead, feature.behind), (1, 0));
629 assert_eq!(feature.tip.summary, "add extra module");
630 }
631
632 #[test]
633 fn tags_distinguish_annotated_from_lightweight() {
634 let fx = fixture();
635 let repo = open(&fx);
636 let tags = repo.tags().unwrap();
637
638 let names: Vec<_> = tags.iter().map(|t| t.name.as_str()).collect();
639 assert_eq!(names, ["light", "v1.0"]);
640
641 let light = tags.iter().find(|t| t.name == "light").unwrap();
642 assert!(!light.annotated);
643 assert_eq!(light.oid.as_str(), fx.commits[1].to_string());
644 assert!(light.message.is_none());
645
646 let v1 = tags.iter().find(|t| t.name == "v1.0").unwrap();
647 assert!(v1.annotated);
648 assert_eq!(v1.oid.as_str(), fx.commits[0].to_string());
649 assert_eq!(v1.message.as_deref(), Some("the first release"));
650 assert_eq!(v1.tagger.as_ref().unwrap().name, "Ada");
651 }
652
653 #[test]
654 fn commits_walk_newest_first_and_filter_by_path() {
655 let fx = fixture();
656 let repo = open(&fx);
657 let main = repo.resolve_ref("main").unwrap();
658
659 let all = repo.commits(&main, None, Page::first(10)).unwrap();
660 let summaries: Vec<_> = all.iter().map(|c| c.summary.as_str()).collect();
661 assert_eq!(summaries, ["expand readme", "initial import"]);
662
663 // Paging: skip the newest, take one.
664 let page = repo
665 .commits(
666 &main,
667 None,
668 Page {
669 offset: 1,
670 limit: 1,
671 },
672 )
673 .unwrap();
674 assert_eq!(page.len(), 1);
675 assert_eq!(page[0].summary, "initial import");
676
677 // src/main.rs was only introduced in commit1 and unchanged since.
678 let touched = repo
679 .commits(&main, Some(Path::new("src/main.rs")), Page::first(10))
680 .unwrap();
681 assert_eq!(
682 touched
683 .iter()
684 .map(|c| c.summary.as_str())
685 .collect::<Vec<_>>(),
686 ["initial import"]
687 );
688 }
689
690 #[test]
691 fn commit_returns_full_detail() {
692 let fx = fixture();
693 let repo = open(&fx);
694 let c2 = Oid::new(fx.commits[1].to_string());
695
696 let detail = repo.commit(&c2).unwrap();
697 assert_eq!(detail.message, "expand readme");
698 assert_eq!(detail.author.name, "Ada Lovelace");
699 assert_eq!(detail.author.email, "ada@example.com");
700 assert_eq!(detail.parents, vec![Oid::new(fx.commits[0].to_string())]);
701
702 assert!(matches!(
703 repo.commit(&Oid::new("0".repeat(40))),
704 Err(GitError::RevNotFound(_))
705 ));
706 }
707}
crates/git/src/types.rs +218 −0
@@ -0,0 +1,218 @@
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! The public vocabulary of the git layer.
6//!
7//! None of these types expose `git2`: the object ids are plain hex strings, times
8//! are epoch-millisecond integers, and the shapes carry exactly what the `web`
9//! and `api` crates render — so those crates never link against libgit2 semantics
10//! and the read API stays swappable.
11
12/// A git object id, stored as its lowercase 40-char hex string.
13///
14/// A newtype rather than a bare `String` so it cannot be confused with a ref
15/// name or a path, and so the `git2` conversion stays inside this crate.
16#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
17pub struct Oid(String);
18
19impl Oid {
20 /// Wrap a hex string as an [`Oid`]. The value is lowercased; no validation is
21 /// performed here — resolution against a repository is where an unknown id is
22 /// rejected.
23 #[must_use]
24 pub fn new(hex: impl Into<String>) -> Self {
25 Self(hex.into().to_lowercase())
26 }
27
28 /// The hex string.
29 #[must_use]
30 pub fn as_str(&self) -> &str {
31 &self.0
32 }
33
34 /// The conventional 7-character short form for display.
35 #[must_use]
36 pub fn short(&self) -> &str {
37 let end = self.0.len().min(7);
38 &self.0[..end]
39 }
40}
41
42impl std::fmt::Display for Oid {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 f.write_str(&self.0)
45 }
46}
47
48/// What a ref string resolved to.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum RefKind {
51 /// A branch (`refs/heads/…`).
52 Branch,
53 /// A tag (`refs/tags/…`), annotated or lightweight.
54 Tag,
55 /// A raw commit id, given directly rather than through a ref.
56 Commit,
57 /// The repository `HEAD`.
58 Head,
59}
60
61/// A resolved revision: the commit it points at, how it was named, and the name
62/// itself (a branch/tag name, or the oid string for a raw commit).
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Resolved {
65 /// The commit the revision resolves to.
66 pub oid: Oid,
67 /// How the revision was named.
68 pub kind: RefKind,
69 /// The human name: branch or tag name, `HEAD`, or the oid for a raw commit.
70 pub name: String,
71}
72
73/// The kind of a tree entry.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum EntryKind {
76 /// A subdirectory (another tree).
77 Directory,
78 /// A regular or executable file (a blob).
79 File,
80 /// A symbolic link (a blob whose content is the link target).
81 Symlink,
82 /// A submodule (a gitlink commit id).
83 Submodule,
84}
85
86/// One entry in a tree listing.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct TreeEntry {
89 /// The entry's name (the final path segment).
90 pub name: String,
91 /// Its kind.
92 pub kind: EntryKind,
93 /// The unix file mode (e.g. `0o100644`).
94 pub mode: u32,
95 /// The object id the entry points at.
96 pub oid: Oid,
97 /// The blob size in bytes, for files; `None` for directories and submodules.
98 pub size: Option<u64>,
99}
100
101/// A blob's bytes plus the facts a view needs before deciding how to render it.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct Blob {
104 /// The raw content.
105 pub content: Vec<u8>,
106 /// Its size in bytes (`content.len()`, surfaced for convenience).
107 pub size: u64,
108 /// Whether the content looks binary (contains a NUL in the sniff window).
109 pub is_binary: bool,
110}
111
112/// A person and the moment they acted, as recorded on a commit or tag.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct Person {
115 /// Display name.
116 pub name: String,
117 /// Email address.
118 pub email: String,
119 /// The action time, epoch milliseconds UTC.
120 pub time_ms: i64,
121}
122
123/// A commit as shown in a log list — enough for one row.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct CommitSummary {
126 /// The commit id.
127 pub oid: Oid,
128 /// The first line of the message.
129 pub summary: String,
130 /// Who wrote the change.
131 pub author: Person,
132 /// Number of parents (0 for a root commit, ≥2 for a merge).
133 pub parents: usize,
134}
135
136/// A commit in full, for the commit page.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct CommitDetail {
139 /// The commit id.
140 pub oid: Oid,
141 /// The complete message.
142 pub message: String,
143 /// Who wrote the change.
144 pub author: Person,
145 /// Who committed it (may differ from the author).
146 pub committer: Person,
147 /// The parent commit ids.
148 pub parents: Vec<Oid>,
149 /// The root tree id.
150 pub tree: Oid,
151}
152
153/// A branch and its position relative to the default branch.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct BranchInfo {
156 /// The branch name (without the `refs/heads/` prefix).
157 pub name: String,
158 /// The commit it points at.
159 pub oid: Oid,
160 /// Whether this is the repository's default branch.
161 pub is_default: bool,
162 /// Commits ahead of the default branch (0 for the default branch itself).
163 pub ahead: usize,
164 /// Commits behind the default branch.
165 pub behind: usize,
166 /// The tip commit, for the "last updated" column.
167 pub tip: CommitSummary,
168}
169
170/// A tag and what it points at.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct TagInfo {
173 /// The tag name (without the `refs/tags/` prefix).
174 pub name: String,
175 /// The commit the tag ultimately points at (peeled).
176 pub oid: Oid,
177 /// Whether the tag is annotated (has its own object) vs. lightweight.
178 pub annotated: bool,
179 /// The annotation message, for annotated tags.
180 pub message: Option<String>,
181 /// The tagger, for annotated tags.
182 pub tagger: Option<Person>,
183}
184
185/// A half-open slice of a listing: skip `offset`, take `limit`.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub struct Page {
188 /// How many items to skip from the start.
189 pub offset: usize,
190 /// How many items to return.
191 pub limit: usize,
192}
193
194impl Page {
195 /// The first page of `limit` items.
196 #[must_use]
197 pub fn first(limit: usize) -> Self {
198 Self { offset: 0, limit }
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn oid_lowercases_and_shortens() {
208 let oid = Oid::new("ABCDEF1234567890ABCDEF1234567890ABCDEF12");
209 assert_eq!(oid.as_str(), "abcdef1234567890abcdef1234567890abcdef12");
210 assert_eq!(oid.short(), "abcdef1");
211 assert_eq!(oid.to_string(), oid.as_str());
212 }
213
214 #[test]
215 fn short_handles_undersized_ids() {
216 assert_eq!(Oid::new("abc").short(), "abc");
217 }
218}
docs/decisions.md +30 −0
@@ -242,3 +242,33 @@ state without a credential, and the operator has an obvious next step. When mail
242lands, this path grows the invite-and-email behaviour; the `passwd` fallback stays242lands, this path grows the invite-and-email behaviour; the `passwd` fallback stays
243for SMTP-disabled instances (`mail.backend = "none"`), which is exactly the case243for SMTP-disabled instances (`mail.backend = "none"`), which is exactly the case
244that motivated it.244that motivated it.
245
246## 2026-07-24 — git2 pinned to the libgit2 nixpkgs ships (1.9.4)
247
248**Decision:** `crates/git` depends on `git2` 0.20 with a direct
249`libgit2-sys = "=0.18.3"` pin, and the build uses the **system** libgit2
250(`LIBGIT2_NO_VENDOR=1`, already set in the flake) rather than the vendored copy.
251
252**Alternatives:** Let `libgit2-sys` build its bundled libgit2 from source (drop
253`LIBGIT2_NO_VENDOR`, add `cmake`); track `git2`'s latest `libgit2-sys`.
254
255**Rationale:** `git2` 0.20's default `libgit2-sys` (0.18.7, bundling 1.9.6)
256requires libgit2 ≥ 1.9.6 and aborts against nixpkgs' 1.9.4 under
257`LIBGIT2_NO_VENDOR`. `libgit2-sys` 0.18.3 (bundling 1.9.2) requires ≥ 1.9.2,
258which 1.9.4 satisfies. The exact pin makes the coupling explicit and stops a
259stray `cargo update` from reintroducing the mismatch; bump it in lockstep when
260nixpkgs bumps libgit2. Using the system lib keeps builds fast and avoids a `cmake`
261build-dep and a second copy of libgit2 in the graph.
262
263## 2026-07-24 — Dev shell sets LD_LIBRARY_PATH for dynamic libgit2
264
265**Decision:** The `devShells.default` exports
266`LD_LIBRARY_PATH = makeLibraryPath buildInputs`.
267
268**Alternatives:** Rely on rpath; require developers to set it themselves.
269
270**Rationale:** Test and bin artifacts built interactively (`cargo test`,
271`cargo run` in the dev shell) link libgit2 dynamically but do not receive the Nix
272build sandbox's rpath, so the loader cannot find `libgit2.so.1.9` and the binary
273exits 127. The Nix build's own checks (`nix flake check`) embed rpath and are
274unaffected; this only fixes the interactive `just check` path.
flake.nix +5 −0
@@ -158,6 +158,11 @@
158 ];158 ];
159 buildInputs = commonArgs.buildInputs;159 buildInputs = commonArgs.buildInputs;
160 LIBGIT2_NO_VENDOR = "1";160 LIBGIT2_NO_VENDOR = "1";
161 # Test/bin artifacts built interactively link libgit2 dynamically but
162 # do not get the build sandbox's rpath, so the loader needs the lib
163 # dirs on the search path for `cargo test`/`cargo run` to work in the
164 # shell. (The Nix build embeds rpath itself, so its checks don't.)
165 LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath commonArgs.buildInputs;
161 };166 };
162167
163 formatter = pkgs.nixpkgs-fmt;168 formatter = pkgs.nixpkgs-fmt;