| 1 | //! All repository access goes through this module; nothing else touches gix. |
| 2 | |
| 3 | pub mod archive; |
| 4 | pub mod blame; |
| 5 | pub mod diff; |
| 6 | pub mod log; |
| 7 | pub mod refs; |
| 8 | pub mod tree; |
| 9 | |
| 10 | use std::hash::{Hash, Hasher}; |
| 11 | use std::path::Path; |
| 12 | |
| 13 | use anyhow::Context; |
| 14 | use gix::bstr::ByteSlice; |
| 15 | |
| 16 | pub use gix::ObjectId; |
| 17 | pub use gix::Repository as Handle; |
| 18 | pub use gix::ThreadSafeRepository; |
| 19 | |
| 20 | /// A handle for use on the current thread. |
| 21 | pub fn handle(repo: &ThreadSafeRepository) -> Handle { |
| 22 | repo.to_thread_local() |
| 23 | } |
| 24 | |
| 25 | /// Open a repository by its git dir (the bare repo, or a worktree's `.git`). |
| 26 | /// |
| 27 | /// Only the repository's own config is read: no system/global config and no |
| 28 | /// `GIT_*` environment, so behavior doesn't depend on who runs the server. |
| 29 | pub fn open(git_dir: &Path) -> anyhow::Result<ThreadSafeRepository> { |
| 30 | if git_dir.join("reftable").is_dir() { |
| 31 | anyhow::bail!("uses the reftable ref format, which is not supported"); |
| 32 | } |
| 33 | let options = gix::open::Options::isolated().open_path_as_is(true); |
| 34 | let repo = ThreadSafeRepository::open_opts(git_dir, options) |
| 35 | .with_context(|| format!("cannot open {}", git_dir.display()))?; |
| 36 | let repo_local = repo.to_thread_local(); |
| 37 | if let Some(storage) = repo_local.config_snapshot().string("extensions.refStorage") |
| 38 | && storage != "files" |
| 39 | { |
| 40 | anyhow::bail!("uses the {storage} ref format, which is not supported"); |
| 41 | } |
| 42 | Ok(repo) |
| 43 | } |
| 44 | |
| 45 | /// Repository metadata stored in its git config. |
| 46 | /// |
| 47 | /// `ferrenium.*` keys win; `gitweb.*` and `cgit.*` are read for painless |
| 48 | /// migration from those tools. |
| 49 | #[derive(Debug, Default, Clone, PartialEq, Eq)] |
| 50 | pub struct ConfigMeta { |
| 51 | pub description: Option<String>, |
| 52 | pub owner: Option<String>, |
| 53 | pub section: Option<String>, |
| 54 | pub hidden: Option<bool>, |
| 55 | pub readme: Option<String>, |
| 56 | pub default_branch: Option<String>, |
| 57 | } |
| 58 | |
| 59 | pub fn config_meta(repo: &gix::Repository) -> ConfigMeta { |
| 60 | let config = repo.config_snapshot(); |
| 61 | let string = |keys: &[&str]| { |
| 62 | keys.iter().find_map(|key| { |
| 63 | let value = config.string(*key)?; |
| 64 | let value = value.to_str_lossy().trim().to_string(); |
| 65 | (!value.is_empty()).then_some(value) |
| 66 | }) |
| 67 | }; |
| 68 | ConfigMeta { |
| 69 | description: string(&["ferrenium.description", "gitweb.description", "cgit.desc"]), |
| 70 | owner: string(&["ferrenium.owner", "gitweb.owner", "cgit.owner"]), |
| 71 | section: string(&["ferrenium.section", "gitweb.category", "cgit.section"]), |
| 72 | hidden: ["ferrenium.hidden", "cgit.hide"] |
| 73 | .iter() |
| 74 | .find_map(|key| config.boolean(*key)), |
| 75 | // cgit allows `branch:path`; we always read from the default branch. |
| 76 | readme: string(&["ferrenium.readme", "cgit.readme"]) |
| 77 | .map(|r| { |
| 78 | r.rsplit_once(':') |
| 79 | .map_or(r.clone(), |(_, path)| path.to_string()) |
| 80 | }) |
| 81 | .filter(|r| !r.is_empty()), |
| 82 | default_branch: string(&["ferrenium.defaultBranch", "cgit.defbranch"]), |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | /// Short branch name HEAD points to (also when that branch is unborn). |
| 87 | pub fn head_branch(repo: &gix::Repository) -> Option<String> { |
| 88 | let name = repo.head_name().ok()??; |
| 89 | name.as_bstr() |
| 90 | .strip_prefix(b"refs/heads/") |
| 91 | .map(|b| b.to_str_lossy().into_owned()) |
| 92 | } |
| 93 | |
| 94 | /// Counts and a fingerprint over all refs. |
| 95 | #[derive(Debug, Default, Clone, PartialEq, Eq)] |
| 96 | pub struct RefSummary { |
| 97 | pub branches: usize, |
| 98 | pub tags: usize, |
| 99 | /// Newest committer time across branch tips (unix seconds). |
| 100 | pub last_activity: Option<i64>, |
| 101 | /// Changes whenever any ref (or HEAD) changes. |
| 102 | pub fingerprint: u64, |
| 103 | } |
| 104 | |
| 105 | pub fn ref_summary(repo: &gix::Repository) -> anyhow::Result<RefSummary> { |
| 106 | let mut summary = RefSummary::default(); |
| 107 | let mut hasher = std::hash::DefaultHasher::new(); |
| 108 | if let Ok(head) = repo.find_reference("HEAD") { |
| 109 | format!("{:?}", head.target()).hash(&mut hasher); |
| 110 | } |
| 111 | let platform = repo.references().context("cannot read refs")?; |
| 112 | for reference in platform.all().context("cannot read refs")? { |
| 113 | let Ok(mut reference) = reference else { |
| 114 | continue; |
| 115 | }; |
| 116 | let name = reference.name().as_bstr().to_owned(); |
| 117 | name.hash(&mut hasher); |
| 118 | format!("{:?}", reference.target()).hash(&mut hasher); |
| 119 | |
| 120 | if name.starts_with(b"refs/heads/") { |
| 121 | summary.branches += 1; |
| 122 | if let Ok(commit) = reference.peel_to_commit() |
| 123 | && let Ok(time) = commit.time() |
| 124 | { |
| 125 | summary.last_activity = summary.last_activity.max(Some(time.seconds)); |
| 126 | } |
| 127 | } else if name.starts_with(b"refs/tags/") { |
| 128 | summary.tags += 1; |
| 129 | } |
| 130 | } |
| 131 | summary.fingerprint = hasher.finish(); |
| 132 | Ok(summary) |
| 133 | } |
| 134 | |
| 135 | /// Commit id a branch points to, if the branch exists. |
| 136 | pub fn branch_commit(repo: &gix::Repository, branch: &str) -> Option<ObjectId> { |
| 137 | let mut reference = repo.find_reference(&format!("refs/heads/{branch}")).ok()?; |
| 138 | Some(reference.peel_to_commit().ok()?.id) |
| 139 | } |
| 140 | |
| 141 | /// Names tried for the README, in order of preference (case-insensitive). |
| 142 | const README_NAMES: &[&str] = &[ |
| 143 | "readme.md", |
| 144 | "readme.markdown", |
| 145 | "readme", |
| 146 | "readme.txt", |
| 147 | "readme.rst", |
| 148 | "readme.adoc", |
| 149 | "readme.org", |
| 150 | ]; |
| 151 | |
| 152 | /// Locate the README blob in `commit`'s tree: `preferred` (a path) if given, |
| 153 | /// otherwise the best match among root entries. |
| 154 | pub fn find_readme( |
| 155 | repo: &gix::Repository, |
| 156 | commit: ObjectId, |
| 157 | preferred: Option<&str>, |
| 158 | ) -> Option<(String, ObjectId)> { |
| 159 | let tree = repo.find_commit(commit).ok()?.tree().ok()?; |
| 160 | if let Some(path) = preferred { |
| 161 | let entry = tree.lookup_entry_by_path(path).ok()??; |
| 162 | return entry |
| 163 | .mode() |
| 164 | .is_blob() |
| 165 | .then(|| (path.to_string(), entry.object_id())); |
| 166 | } |
| 167 | let mut best: Option<(usize, String, ObjectId)> = None; |
| 168 | for entry in tree.iter().flatten() { |
| 169 | if !entry.mode().is_blob() { |
| 170 | continue; |
| 171 | } |
| 172 | let name = entry.filename().to_str_lossy(); |
| 173 | let lower = name.to_ascii_lowercase(); |
| 174 | if let Some(rank) = README_NAMES.iter().position(|n| *n == lower) |
| 175 | && best.as_ref().is_none_or(|(r, _, _)| rank < *r) |
| 176 | { |
| 177 | best = Some((rank, name.into_owned(), entry.oid().to_owned())); |
| 178 | } |
| 179 | } |
| 180 | best.map(|(_, name, oid)| (name, oid)) |
| 181 | } |
| 182 | |
| 183 | /// The parts of a commit shown in listings. |
| 184 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 185 | pub struct CommitInfo { |
| 186 | pub id: ObjectId, |
| 187 | pub subject: String, |
| 188 | pub author_name: String, |
| 189 | pub author_email: String, |
| 190 | /// Unix seconds. |
| 191 | pub author_time: i64, |
| 192 | pub committer_time: i64, |
| 193 | } |
| 194 | |
| 195 | impl CommitInfo { |
| 196 | pub fn weight(&self) -> usize { |
| 197 | 64 + self.subject.len() + self.author_name.len() + self.author_email.len() |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | pub fn commit_info(repo: &gix::Repository, id: ObjectId) -> anyhow::Result<CommitInfo> { |
| 202 | let commit = repo.find_commit(id).context("commit not found")?; |
| 203 | let decoded = commit.decode().context("malformed commit")?; |
| 204 | let author = decoded.author().context("malformed author")?; |
| 205 | let committer = decoded.committer().context("malformed committer")?; |
| 206 | Ok(CommitInfo { |
| 207 | id, |
| 208 | subject: decoded |
| 209 | .message() |
| 210 | .summary() |
| 211 | .to_str_lossy() |
| 212 | .trim() |
| 213 | .to_string(), |
| 214 | author_name: author.name.to_str_lossy().trim().to_string(), |
| 215 | author_email: author.email.to_str_lossy().trim().to_string(), |
| 216 | author_time: author.seconds(), |
| 217 | committer_time: committer.seconds(), |
| 218 | }) |
| 219 | } |
| 220 | |
| 221 | /// Number of commits reachable from `tip` (uses the commit-graph when the |
| 222 | /// repository has one). |
| 223 | pub fn commit_count(repo: &gix::Repository, tip: ObjectId) -> anyhow::Result<u64> { |
| 224 | let mut count = 0; |
| 225 | for info in repo.rev_walk([tip]).all()? { |
| 226 | info?; |
| 227 | count += 1; |
| 228 | } |
| 229 | Ok(count) |
| 230 | } |
| 231 | |
| 232 | /// Everything the commit page shows about a commit. |
| 233 | #[derive(Debug, Clone)] |
| 234 | pub struct CommitDetail { |
| 235 | pub info: CommitInfo, |
| 236 | /// The message after the subject line, trimmed. |
| 237 | pub body: String, |
| 238 | /// The full message as stored, for patches. |
| 239 | pub message: String, |
| 240 | pub author: Person, |
| 241 | pub committer: Person, |
| 242 | pub parents: Vec<ObjectId>, |
| 243 | pub tree: ObjectId, |
| 244 | } |
| 245 | |
| 246 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 247 | pub struct Person { |
| 248 | pub name: String, |
| 249 | pub email: String, |
| 250 | /// Unix seconds and the UTC offset in seconds, as recorded. |
| 251 | pub time: i64, |
| 252 | pub offset: i32, |
| 253 | } |
| 254 | |
| 255 | pub fn commit_detail(repo: &gix::Repository, id: ObjectId) -> anyhow::Result<CommitDetail> { |
| 256 | let commit = repo.find_commit(id).context("commit not found")?; |
| 257 | let decoded = commit.decode().context("malformed commit")?; |
| 258 | let person = |sig: gix::actor::SignatureRef<'_>| { |
| 259 | let time = sig.time().unwrap_or_default(); |
| 260 | Person { |
| 261 | name: sig.name.to_str_lossy().trim().to_string(), |
| 262 | email: sig.email.to_str_lossy().trim().to_string(), |
| 263 | time: time.seconds, |
| 264 | offset: time.offset, |
| 265 | } |
| 266 | }; |
| 267 | let message = decoded.message(); |
| 268 | Ok(CommitDetail { |
| 269 | info: commit_info(repo, id)?, |
| 270 | body: message |
| 271 | .body() |
| 272 | .map(|b| b.to_str_lossy().trim().to_string()) |
| 273 | .unwrap_or_default(), |
| 274 | message: decoded.message.to_str_lossy().into_owned(), |
| 275 | author: person(decoded.author().context("malformed author")?), |
| 276 | committer: person(decoded.committer().context("malformed committer")?), |
| 277 | parents: decoded.parents().collect(), |
| 278 | tree: decoded.tree(), |
| 279 | }) |
| 280 | } |
| 281 | |
| 282 | /// A `.mailmap`: canonical names and emails for authors. |
| 283 | #[derive(Default)] |
| 284 | pub struct Mailmap(gix::mailmap::Snapshot); |
| 285 | |
| 286 | impl Mailmap { |
| 287 | pub fn parse(data: &[u8]) -> Self { |
| 288 | Mailmap(gix::mailmap::Snapshot::from_bytes(data)) |
| 289 | } |
| 290 | |
| 291 | /// Canonical `(name, email)` for an identity. |
| 292 | pub fn resolve(&self, name: &str, email: &str) -> (String, String) { |
| 293 | let signature = gix::actor::SignatureRef { |
| 294 | name: name.as_bytes().as_bstr(), |
| 295 | email: email.as_bytes().as_bstr(), |
| 296 | time: "", |
| 297 | }; |
| 298 | match self.0.try_resolve_ref(signature) { |
| 299 | Some(found) => ( |
| 300 | found |
| 301 | .name |
| 302 | .map_or_else(|| name.to_string(), |n| n.to_str_lossy().into_owned()), |
| 303 | found |
| 304 | .email |
| 305 | .map_or_else(|| email.to_string(), |e| e.to_str_lossy().into_owned()), |
| 306 | ), |
| 307 | None => (name.to_string(), email.to_string()), |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | pub fn weight(&self) -> usize { |
| 312 | self.0 |
| 313 | .iter() |
| 314 | .map(|e| { |
| 315 | 64 + e.old_email().len() |
| 316 | + e.old_name().map_or(0, |n| n.len()) |
| 317 | + e.new_email().map_or(0, |n| n.len()) |
| 318 | + e.new_name().map_or(0, |n| n.len()) |
| 319 | }) |
| 320 | .sum() |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | /// The `.mailmap` blob at the root of `commit`, if there is one. |
| 325 | pub fn mailmap_blob(repo: &gix::Repository, commit: ObjectId) -> Option<ObjectId> { |
| 326 | let tree = repo.find_commit(commit).ok()?.tree().ok()?; |
| 327 | let entry = tree.find_entry(".mailmap")?; |
| 328 | entry.mode().is_blob().then(|| entry.object_id()) |
| 329 | } |