Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +57 −1
| @@ -198,6 +198,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 198 | checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" | 198 | checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" |
| 199 | dependencies = [ | 199 | dependencies = [ |
| 200 | "find-msvc-tools", | 200 | "find-msvc-tools", |
| 201 | "jobserver", | ||
| 202 | "libc", | ||
| 201 | "shlex", | 203 | "shlex", |
| 202 | ] | 204 | ] |
| 203 | 205 | ||
| @@ -648,6 +650,26 @@ dependencies = [ | |||
| 648 | [[package]] | 650 | [[package]] |
| 649 | name = "git" | 651 | name = "git" |
| 650 | version = "0.1.0" | 652 | 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 | ] | ||
| 651 | 673 | ||
| 652 | [[package]] | 674 | [[package]] |
| 653 | name = "hashbrown" | 675 | name = "hashbrown" |
| @@ -858,6 +880,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 858 | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | 880 | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" |
| 859 | 881 | ||
| 860 | [[package]] | 882 | [[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]] | ||
| 861 | name = "js-sys" | 893 | name = "js-sys" |
| 862 | version = "0.3.103" | 894 | version = "0.3.103" |
| 863 | source = "registry+https://github.com/rust-lang/crates.io-index" | 895 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -875,6 +907,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 875 | checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" | 907 | checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" |
| 876 | 908 | ||
| 877 | [[package]] | 909 | [[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]] | ||
| 878 | name = "libsqlite3-sys" | 922 | name = "libsqlite3-sys" |
| 879 | version = "0.37.0" | 923 | version = "0.37.0" |
| 880 | source = "registry+https://github.com/rust-lang/crates.io-index" | 924 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -886,6 +930,18 @@ dependencies = [ | |||
| 886 | ] | 930 | ] |
| 887 | 931 | ||
| 888 | [[package]] | 932 | [[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]] | ||
| 889 | name = "linux-raw-sys" | 945 | name = "linux-raw-sys" |
| 890 | version = "0.12.1" | 946 | version = "0.12.1" |
| 891 | source = "registry+https://github.com/rust-lang/crates.io-index" | 947 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1603,7 +1659,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1603 | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" | 1659 | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" |
| 1604 | dependencies = [ | 1660 | dependencies = [ |
| 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 = [ | |||
| 72 | ulid = "1" | 72 | ulid = "1" |
| 73 | tokio = { version = "1", features = ["macros", "rt-multi-thread"] } | 73 | tokio = { version = "1", features = ["macros", "rt-multi-thread"] } |
| 74 | tempfile = "3" | 74 | 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 } | ||
| 75 | # Authentication primitives. Argon2id password hashing; SHA-256 for session and | 79 | # 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 | |||
| 10 | publish.workspace = true | 10 | publish.workspace = true |
| 11 | 11 | ||
| 12 | [dependencies] | 12 | [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 } | ||
| 13 | 25 | ||
| 14 | [lints] | 26 | [lints] |
| 15 | workspace = true | 27 | workspace = 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 this | 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/. | 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. |
| 4 | 4 | ||
| 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 | |||
| 242 | lands, this path grows the invite-and-email behaviour; the `passwd` fallback stays | 242 | lands, this path grows the invite-and-email behaviour; the `passwd` fallback stays |
| 243 | for SMTP-disabled instances (`mail.backend = "none"`), which is exactly the case | 243 | for SMTP-disabled instances (`mail.backend = "none"`), which is exactly the case |
| 244 | that motivated it. | 244 | 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 @@ | |||
| 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 | }; |
| 162 | 167 | ||
| 163 | formatter = pkgs.nixpkgs-fmt; | 168 | formatter = pkgs.nixpkgs-fmt; |