| 1 | //! All repository access goes through this module; nothing else touches gix. |
| 2 | |
| 3 | pub mod refs; |
| 4 | pub mod tree; |
| 5 | |
| 6 | use std::hash::{Hash, Hasher}; |
| 7 | use std::path::Path; |
| 8 | |
| 9 | use anyhow::Context; |
| 10 | use gix::bstr::ByteSlice; |
| 11 | |
| 12 | pub use gix::ObjectId; |
| 13 | pub use gix::Repository as Handle; |
| 14 | pub use gix::ThreadSafeRepository; |
| 15 | |
| 16 | /// A handle for use on the current thread. |
| 17 | pub 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. |
| 25 | pub 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)] |
| 46 | pub 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 | |
| 55 | pub 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). |
| 83 | pub 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)] |
| 92 | pub 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 | |
| 101 | pub 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. |
| 132 | pub 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). |
| 138 | const 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. |
| 150 | pub 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)] |
| 181 | pub 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 | |
| 191 | impl CommitInfo { |
| 192 | pub fn weight(&self) -> usize { |
| 193 | 64 + self.subject.len() + self.author_name.len() + self.author_email.len() |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | pub 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). |
| 219 | pub 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 | } |