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 | 198 | checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" | |
| 199 | 199 | dependencies = [ | |
| 200 | 200 | "find-msvc-tools", | |
| 201 | + | "jobserver", | |
| 202 | + | "libc", | |
| 201 | 203 | "shlex", | |
| 202 | 204 | ] | |
| 203 | 205 | ||
| @@ -648,6 +650,26 @@ dependencies = [ | |||
| 648 | 650 | [[package]] | |
| 649 | 651 | name = "git" | |
| 650 | 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 | 674 | [[package]] | |
| 653 | 675 | name = "hashbrown" | |
| @@ -858,6 +880,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 858 | 880 | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | |
| 859 | 881 | ||
| 860 | 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 | 893 | name = "js-sys" | |
| 862 | 894 | version = "0.3.103" | |
| 863 | 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 | 907 | checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" | |
| 876 | 908 | ||
| 877 | 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 | 922 | name = "libsqlite3-sys" | |
| 879 | 923 | version = "0.37.0" | |
| 880 | 924 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -886,6 +930,18 @@ dependencies = [ | |||
| 886 | 930 | ] | |
| 887 | 931 | ||
| 888 | 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 | 945 | name = "linux-raw-sys" | |
| 890 | 946 | version = "0.12.1" | |
| 891 | 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 | 1659 | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" | |
| 1604 | 1660 | dependencies = [ | |
| 1605 | 1661 | "fastrand", | |
| 1606 | - | "getrandom 0.4.3", | |
| 1662 | + | "getrandom 0.3.4", | |
| 1607 | 1663 | "once_cell", | |
| 1608 | 1664 | "rustix", | |
| 1609 | 1665 | "windows-sys 0.61.2", | |
Cargo.toml +4 −0
| @@ -72,6 +72,10 @@ sqlx = { version = "0.9", default-features = false, features = [ | |||
| 72 | 72 | ulid = "1" | |
| 73 | 73 | tokio = { version = "1", features = ["macros", "rt-multi-thread"] } | |
| 74 | 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 | 79 | # Authentication primitives. Argon2id password hashing; SHA-256 for session and | |
| 76 | 80 | # invite token digests; HMAC-SHA256 for hand-rolled HS256 JWTs (avoiding a `ring` | |
| 77 | 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 | 10 | publish.workspace = true | |
| 11 | 11 | ||
| 12 | 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 | 26 | [lints] | |
| 15 | 27 | workspace = true | |
crates/git/src/lib.rs +239 −1
| @@ -2,4 +2,242 @@ | |||
| 2 | 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | 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 | 242 | lands, this path grows the invite-and-email behaviour; the `passwd` fallback stays | |
| 243 | 243 | for SMTP-disabled instances (`mail.backend = "none"`), which is exactly the case | |
| 244 | 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 | 159 | buildInputs = commonArgs.buildInputs; | |
| 160 | 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 | 168 | formatter = pkgs.nixpkgs-fmt; | |