Krolyxon
Commit 4e844bb

ferreniumsrcgitmod.rs

327 lines · 10.9 KB · Rust

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