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"
198198 checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
199199 dependencies = [
200200 "find-msvc-tools",
201+ "jobserver",
202+ "libc",
201203 "shlex",
202204 ]
203205
@@ -648,6 +650,26 @@ dependencies = [
648650 [[package]]
649651 name = "git"
650652 version = "0.1.0"
653+dependencies = [
654+ "git2",
655+ "libgit2-sys",
656+ "model",
657+ "tempfile",
658+ "thiserror",
659+]
660+
661+[[package]]
662+name = "git2"
663+version = "0.20.4"
664+source = "registry+https://github.com/rust-lang/crates.io-index"
665+checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b"
666+dependencies = [
667+ "bitflags",
668+ "libc",
669+ "libgit2-sys",
670+ "log",
671+ "url",
672+]
651673
652674 [[package]]
653675 name = "hashbrown"
@@ -858,6 +880,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
858880 checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
859881
860882 [[package]]
883+name = "jobserver"
884+version = "0.1.35"
885+source = "registry+https://github.com/rust-lang/crates.io-index"
886+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
887+dependencies = [
888+ "getrandom 0.4.3",
889+ "libc",
890+]
891+
892+[[package]]
861893 name = "js-sys"
862894 version = "0.3.103"
863895 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -875,6 +907,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
875907 checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
876908
877909 [[package]]
910+name = "libgit2-sys"
911+version = "0.18.3+1.9.2"
912+source = "registry+https://github.com/rust-lang/crates.io-index"
913+checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487"
914+dependencies = [
915+ "cc",
916+ "libc",
917+ "libz-sys",
918+ "pkg-config",
919+]
920+
921+[[package]]
878922 name = "libsqlite3-sys"
879923 version = "0.37.0"
880924 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -886,6 +930,18 @@ dependencies = [
886930 ]
887931
888932 [[package]]
933+name = "libz-sys"
934+version = "1.1.29"
935+source = "registry+https://github.com/rust-lang/crates.io-index"
936+checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9"
937+dependencies = [
938+ "cc",
939+ "libc",
940+ "pkg-config",
941+ "vcpkg",
942+]
943+
944+[[package]]
889945 name = "linux-raw-sys"
890946 version = "0.12.1"
891947 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1603,7 +1659,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
16031659 checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
16041660 dependencies = [
16051661 "fastrand",
1606- "getrandom 0.4.3",
1662+ "getrandom 0.3.4",
16071663 "once_cell",
16081664 "rustix",
16091665 "windows-sys 0.61.2",
Cargo.toml +4 −0
@@ -72,6 +72,10 @@ sqlx = { version = "0.9", default-features = false, features = [
7272 ulid = "1"
7373 tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
7474 tempfile = "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`.
78+git2 = { version = "0.20", default-features = false }
7579 # Authentication primitives. Argon2id password hashing; SHA-256 for session and
7680 # invite token digests; HMAC-SHA256 for hand-rolled HS256 JWTs (avoiding a `ring`
7781 # 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
1010 publish.workspace = true
1111
1212 [dependencies]
13+model = { workspace = true }
14+git2 = { workspace = true }
15+thiserror = { 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.
21+libgit2-sys = "=0.18.3"
22+
23+[dev-dependencies]
24+tempfile = { workspace = true }
1325
1426 [lints]
1527 workspace = true
crates/git/src/lib.rs +239 −1
@@ -2,4 +2,242 @@
22 // License, v. 2.0. If a copy of the MPL was not distributed with this
33 // 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+
30+mod repo;
31+mod types;
32+
33+use std::fs;
34+use std::io;
35+use std::os::unix::fs::PermissionsExt;
36+use std::path::{Path, PathBuf};
37+
38+use git2::{Repository, RepositoryInitOptions};
39+
40+pub use crate::repo::Repo;
41+pub 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)]
48+pub 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.
85+pub 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.
107+pub 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.
139+fn 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.
156+fn 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`).
174+fn 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)]
189+mod 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+
11+use std::path::Path;
12+
13+use git2::{BranchType, Commit, Repository, Signature, Sort, TreeEntry as GitTreeEntry};
14+
15+use crate::GitError;
16+use 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.
22+const MODE_DIR: i32 = 0o040_000;
23+const MODE_SYMLINK: i32 = 0o120_000;
24+const MODE_SUBMODULE: i32 = 0o160_000;
25+
26+/// Bytes sniffed from the head of a blob when deciding whether it is binary.
27+const BINARY_SNIFF_BYTES: usize = 8_000;
28+
29+/// An open bare repository.
30+pub struct Repo {
31+ inner: Repository,
32+}
33+
34+impl 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).
339+fn 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.
349+fn 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`].
355+fn 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.
361+fn 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.
370+fn 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.
382+fn looks_binary(bytes: &[u8]) -> bool {
383+ let window = &bytes[..bytes.len().min(BINARY_SNIFF_BYTES)];
384+ window.contains(&0)
385+}
386+
387+#[cfg(test)]
388+mod 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)]
17+pub struct Oid(String);
18+
19+impl 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+
42+impl 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)]
50+pub 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)]
64+pub 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)]
75+pub 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)]
88+pub 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)]
103+pub 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)]
114+pub 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)]
125+pub 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)]
138+pub 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)]
155+pub 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)]
172+pub 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)]
187+pub 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+
194+impl 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)]
203+mod 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
242242 lands, this path grows the invite-and-email behaviour; the `passwd` fallback stays
243243 for SMTP-disabled instances (`mail.backend = "none"`), which is exactly the case
244244 that 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)
256+requires 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,
258+which 1.9.4 satisfies. The exact pin makes the coupling explicit and stops a
259+stray `cargo update` from reintroducing the mismatch; bump it in lockstep when
260+nixpkgs bumps libgit2. Using the system lib keeps builds fast and avoids a `cmake`
261+build-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
272+build sandbox's rpath, so the loader cannot find `libgit2.so.1.9` and the binary
273+exits 127. The Nix build's own checks (`nix flake check`) embed rpath and are
274+unaffected; this only fixes the interactive `just check` path.
flake.nix +5 −0
@@ -158,6 +158,11 @@
158158 ];
159159 buildInputs = commonArgs.buildInputs;
160160 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;
161166 };
162167
163168 formatter = pkgs.nixpkgs-fmt;