Krolyxon
Commit 18ca21c

ferreniumsrcgitmod.rs

329 lines · 10.9 KB · Rust

1//! All repository access goes through this module; nothing else touches gix.
2
3pub mod archive;
4pub mod blame;
5pub mod diff;
6pub mod log;
7pub mod refs;
8pub mod tree;
9
10use std::hash::{Hash, Hasher};
11use std::path::Path;
12
13use anyhow::Context;
14use gix::bstr::ByteSlice;
15
16pub use gix::ObjectId;
17pub use gix::Repository as Handle;
18pub use gix::ThreadSafeRepository;
19
20/// A handle for use on the current thread.
21pub 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.
29pub 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)]
50pub 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
59pub 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).
87pub 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)]
96pub 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
105pub 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.
136pub 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).
142const 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.
154pub 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)]
185pub 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
195impl CommitInfo {
196 pub fn weight(&self) -> usize {
197 64 + self.subject.len() + self.author_name.len() + self.author_email.len()
198 }
199}
200
201pub 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).
223pub 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)]
234pub 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)]
247pub 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
255pub 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)]
284pub struct Mailmap(gix::mailmap::Snapshot);
285
286impl 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.
325pub 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}