Krolyxon
Commit 55c1225

ferreniumsrcgitmod.rs

226 lines · 7.6 KB · Rust

1//! All repository access goes through this module; nothing else touches gix.
2
3pub mod refs;
4pub mod tree;
5
6use std::hash::{Hash, Hasher};
7use std::path::Path;
8
9use anyhow::Context;
10use gix::bstr::ByteSlice;
11
12pub use gix::ObjectId;
13pub use gix::Repository as Handle;
14pub use gix::ThreadSafeRepository;
15
16/// A handle for use on the current thread.
17pub fn handle(repo: &ThreadSafeRepository) -> Handle {
18 repo.to_thread_local()
19}
20
21/// Open a repository by its git dir (the bare repo, or a worktree's `.git`).
22///
23/// Only the repository's own config is read: no system/global config and no
24/// `GIT_*` environment, so behavior doesn't depend on who runs the server.
25pub fn open(git_dir: &Path) -> anyhow::Result<ThreadSafeRepository> {
26 if git_dir.join("reftable").is_dir() {
27 anyhow::bail!("uses the reftable ref format, which is not supported");
28 }
29 let options = gix::open::Options::isolated().open_path_as_is(true);
30 let repo = ThreadSafeRepository::open_opts(git_dir, options)
31 .with_context(|| format!("cannot open {}", git_dir.display()))?;
32 let repo_local = repo.to_thread_local();
33 if let Some(storage) = repo_local.config_snapshot().string("extensions.refStorage")
34 && storage != "files"
35 {
36 anyhow::bail!("uses the {storage} ref format, which is not supported");
37 }
38 Ok(repo)
39}
40
41/// Repository metadata stored in its git config.
42///
43/// `ferrenium.*` keys win; `gitweb.*` and `cgit.*` are read for painless
44/// migration from those tools.
45#[derive(Debug, Default, Clone, PartialEq, Eq)]
46pub struct ConfigMeta {
47 pub description: Option<String>,
48 pub owner: Option<String>,
49 pub section: Option<String>,
50 pub hidden: Option<bool>,
51 pub readme: Option<String>,
52 pub default_branch: Option<String>,
53}
54
55pub fn config_meta(repo: &gix::Repository) -> ConfigMeta {
56 let config = repo.config_snapshot();
57 let string = |keys: &[&str]| {
58 keys.iter().find_map(|key| {
59 let value = config.string(*key)?;
60 let value = value.to_str_lossy().trim().to_string();
61 (!value.is_empty()).then_some(value)
62 })
63 };
64 ConfigMeta {
65 description: string(&["ferrenium.description", "gitweb.description", "cgit.desc"]),
66 owner: string(&["ferrenium.owner", "gitweb.owner", "cgit.owner"]),
67 section: string(&["ferrenium.section", "gitweb.category", "cgit.section"]),
68 hidden: ["ferrenium.hidden", "cgit.hide"]
69 .iter()
70 .find_map(|key| config.boolean(*key)),
71 // cgit allows `branch:path`; we always read from the default branch.
72 readme: string(&["ferrenium.readme", "cgit.readme"])
73 .map(|r| {
74 r.rsplit_once(':')
75 .map_or(r.clone(), |(_, path)| path.to_string())
76 })
77 .filter(|r| !r.is_empty()),
78 default_branch: string(&["ferrenium.defaultBranch", "cgit.defbranch"]),
79 }
80}
81
82/// Short branch name HEAD points to (also when that branch is unborn).
83pub fn head_branch(repo: &gix::Repository) -> Option<String> {
84 let name = repo.head_name().ok()??;
85 name.as_bstr()
86 .strip_prefix(b"refs/heads/")
87 .map(|b| b.to_str_lossy().into_owned())
88}
89
90/// Counts and a fingerprint over all refs.
91#[derive(Debug, Default, Clone, PartialEq, Eq)]
92pub struct RefSummary {
93 pub branches: usize,
94 pub tags: usize,
95 /// Newest committer time across branch tips (unix seconds).
96 pub last_activity: Option<i64>,
97 /// Changes whenever any ref (or HEAD) changes.
98 pub fingerprint: u64,
99}
100
101pub fn ref_summary(repo: &gix::Repository) -> anyhow::Result<RefSummary> {
102 let mut summary = RefSummary::default();
103 let mut hasher = std::hash::DefaultHasher::new();
104 if let Ok(head) = repo.find_reference("HEAD") {
105 format!("{:?}", head.target()).hash(&mut hasher);
106 }
107 let platform = repo.references().context("cannot read refs")?;
108 for reference in platform.all().context("cannot read refs")? {
109 let Ok(mut reference) = reference else {
110 continue;
111 };
112 let name = reference.name().as_bstr().to_owned();
113 name.hash(&mut hasher);
114 format!("{:?}", reference.target()).hash(&mut hasher);
115
116 if name.starts_with(b"refs/heads/") {
117 summary.branches += 1;
118 if let Ok(commit) = reference.peel_to_commit()
119 && let Ok(time) = commit.time()
120 {
121 summary.last_activity = summary.last_activity.max(Some(time.seconds));
122 }
123 } else if name.starts_with(b"refs/tags/") {
124 summary.tags += 1;
125 }
126 }
127 summary.fingerprint = hasher.finish();
128 Ok(summary)
129}
130
131/// Commit id a branch points to, if the branch exists.
132pub fn branch_commit(repo: &gix::Repository, branch: &str) -> Option<ObjectId> {
133 let mut reference = repo.find_reference(&format!("refs/heads/{branch}")).ok()?;
134 Some(reference.peel_to_commit().ok()?.id)
135}
136
137/// Names tried for the README, in order of preference (case-insensitive).
138const README_NAMES: &[&str] = &[
139 "readme.md",
140 "readme.markdown",
141 "readme",
142 "readme.txt",
143 "readme.rst",
144 "readme.adoc",
145 "readme.org",
146];
147
148/// Locate the README blob in `commit`'s tree: `preferred` (a path) if given,
149/// otherwise the best match among root entries.
150pub fn find_readme(
151 repo: &gix::Repository,
152 commit: ObjectId,
153 preferred: Option<&str>,
154) -> Option<(String, ObjectId)> {
155 let tree = repo.find_commit(commit).ok()?.tree().ok()?;
156 if let Some(path) = preferred {
157 let entry = tree.lookup_entry_by_path(path).ok()??;
158 return entry
159 .mode()
160 .is_blob()
161 .then(|| (path.to_string(), entry.object_id()));
162 }
163 let mut best: Option<(usize, String, ObjectId)> = None;
164 for entry in tree.iter().flatten() {
165 if !entry.mode().is_blob() {
166 continue;
167 }
168 let name = entry.filename().to_str_lossy();
169 let lower = name.to_ascii_lowercase();
170 if let Some(rank) = README_NAMES.iter().position(|n| *n == lower)
171 && best.as_ref().is_none_or(|(r, _, _)| rank < *r)
172 {
173 best = Some((rank, name.into_owned(), entry.oid().to_owned()));
174 }
175 }
176 best.map(|(_, name, oid)| (name, oid))
177}
178
179/// The parts of a commit shown in listings.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct CommitInfo {
182 pub id: ObjectId,
183 pub subject: String,
184 pub author_name: String,
185 pub author_email: String,
186 /// Unix seconds.
187 pub author_time: i64,
188 pub committer_time: i64,
189}
190
191impl CommitInfo {
192 pub fn weight(&self) -> usize {
193 64 + self.subject.len() + self.author_name.len() + self.author_email.len()
194 }
195}
196
197pub fn commit_info(repo: &gix::Repository, id: ObjectId) -> anyhow::Result<CommitInfo> {
198 let commit = repo.find_commit(id).context("commit not found")?;
199 let decoded = commit.decode().context("malformed commit")?;
200 let author = decoded.author().context("malformed author")?;
201 let committer = decoded.committer().context("malformed committer")?;
202 Ok(CommitInfo {
203 id,
204 subject: decoded
205 .message()
206 .summary()
207 .to_str_lossy()
208 .trim()
209 .to_string(),
210 author_name: author.name.to_str_lossy().trim().to_string(),
211 author_email: author.email.to_str_lossy().trim().to_string(),
212 author_time: author.seconds(),
213 committer_time: committer.seconds(),
214 })
215}
216
217/// Number of commits reachable from `tip` (uses the commit-graph when the
218/// repository has one).
219pub fn commit_count(repo: &gix::Repository, tip: ObjectId) -> anyhow::Result<u64> {
220 let mut count = 0;
221 for info in repo.rev_walk([tip]).all()? {
222 info?;
223 count += 1;
224 }
225 Ok(count)
226}