A minimal self-hosted git browser on Rust axum: bare repos rendered as a website with commits, diffs, syntax-highlighted blobs, atom feeds, and clone over HTTPS.
axumdockergitgit-browsergitoxidegixrustself-hosted
1//! Thin wrappers over `gix` that pre-shape data into the structs templates
2//! want. Every function here is synchronous and blocking; routes call them
3//! inside `tokio::task::spawn_blocking` if they're touching anything heavier
4//! than HEAD metadata.
5
6use anyhow::{anyhow, Context, Result};
7use serde::Serialize;
8use std::path::{Path, PathBuf};
9
10/// Suffix used by the bare repos we expose. `/srv/git/foo.git/` is the
11/// canonical layout; anything without `.git` is ignored on discovery so a
12/// stray directory doesn't surface as a repo.
13pub const BARE_SUFFIX: &str = ".git";
14
15/// Maximum blob size we'll load into memory for /blob and /raw views.
16/// Anything larger is almost certainly a binary asset best fetched via
17/// `git clone`; serving it inline would risk OOMing the container under
18/// concurrent requests.
19pub const MAX_BLOB_SIZE: u64 = 25 * 1024 * 1024;
20
21#[derive(Debug, Clone, Serialize)]
22pub struct RepoSummary {
23 pub name: String,
24 pub description: String,
25 pub default_branch: String,
26 pub head_summary: Option<String>,
27 pub head_time: Option<i64>,
28 pub head_id: Option<String>,
29 pub clone_url: String,
30}
31
32#[derive(Debug, Clone, Serialize)]
33pub struct CommitInfo {
34 pub id: String,
35 pub short_id: String,
36 pub summary: String,
37 pub message: String,
38 pub author: String,
39 pub author_email: String,
40 pub time: i64,
41 pub parents: Vec<String>,
42}
43
44#[derive(Debug, Clone, Serialize)]
45pub struct TreeEntry {
46 pub name: String,
47 pub kind: &'static str, // "tree" | "blob" | "commit" (submodule) | "link"
48 pub mode: String,
49 pub size: Option<u64>,
50 pub id: String,
51}
52
53#[derive(Debug, Clone, Serialize)]
54pub struct BlobInfo {
55 pub data: Vec<u8>,
56 pub size: u64,
57 pub is_binary: bool,
58}
59
60#[derive(Debug, Clone, Serialize)]
61pub struct FileDiff {
62 pub path: String,
63 pub old_path: Option<String>,
64 pub status: &'static str, // "added" | "deleted" | "modified" | "renamed"
65 pub hunks: Vec<DiffHunk>,
66 pub is_binary: bool,
67}
68
69#[derive(Debug, Clone, Serialize)]
70pub struct DiffHunk {
71 pub header: String,
72 pub lines: Vec<DiffLine>,
73}
74
75#[derive(Debug, Clone, Serialize)]
76pub struct DiffLine {
77 pub kind: &'static str, // "add" | "del" | "ctx" | "hdr"
78 pub text: String,
79}
80
81/// Walk `root` once and return one entry per `*.git` directory that contains
82/// a HEAD ref. Sorted by most-recently-touched HEAD first so the landing page
83/// reads "what's been active" without further sorting in the template.
84pub fn discover(root: &Path, clone_base: &str) -> Result<Vec<RepoSummary>> {
85 let mut out = Vec::new();
86 let entries = match std::fs::read_dir(root) {
87 Ok(e) => e,
88 Err(e) => {
89 tracing::warn!("repo root {}: {}", root.display(), e);
90 return Ok(out);
91 }
92 };
93 for entry in entries.flatten() {
94 let path = entry.path();
95 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
96 continue;
97 };
98 if !name.ends_with(BARE_SUFFIX) {
99 continue;
100 }
101 if !path.is_dir() {
102 continue;
103 }
104 match repo_summary(&path, clone_base) {
105 Ok(s) => out.push(s),
106 Err(e) => tracing::warn!("skipping {}: {:#}", path.display(), e),
107 }
108 }
109 out.sort_by(|a, b| b.head_time.cmp(&a.head_time).then(a.name.cmp(&b.name)));
110 Ok(out)
111}
112
113fn short_name(path: &Path) -> String {
114 let name = path
115 .file_name()
116 .and_then(|s| s.to_str())
117 .unwrap_or_default();
118 name.strip_suffix(BARE_SUFFIX).unwrap_or(name).to_string()
119}
120
121pub fn open(repo_root: &Path, name: &str) -> Result<gix::Repository> {
122 let path = resolve_path(repo_root, name)?;
123 Ok(gix::open(&path).with_context(|| format!("open {}", path.display()))?)
124}
125
126/// Resolve `name` (the URL slug, e.g. `analytics` or `blog.bythewood.me`)
127/// to the on-disk bare repo path. Rejects any name containing a path
128/// separator or starting with a dot so a request for `../etc/passwd` can't
129/// escape the repo root.
130pub fn resolve_path(repo_root: &Path, name: &str) -> Result<PathBuf> {
131 if name.is_empty()
132 || name.contains('/')
133 || name.contains('\\')
134 || name.starts_with('.')
135 || name.contains("..")
136 {
137 return Err(anyhow!("invalid repo name: {name}"));
138 }
139 // Append `.git` to the FULL name, not via Path::set_extension — that
140 // would clobber the existing extension on names like `blog.bythewood.me`
141 // (giving `blog.bythewood.git`, which doesn't exist).
142 let p = repo_root.join(format!("{name}.git"));
143 if !p.exists() {
144 return Err(anyhow!("repo not found: {name}"));
145 }
146 Ok(p)
147}
148
149pub fn repo_summary(path: &Path, clone_base: &str) -> Result<RepoSummary> {
150 let name = short_name(path);
151 let repo = gix::open(path).with_context(|| format!("open {}", path.display()))?;
152 let description = read_description(path);
153 let default_branch = read_default_branch(&repo);
154 let clone_url = format!(
155 "{}/{}.git",
156 clone_base.trim_end_matches('/'),
157 name
158 );
159
160 let head = match repo.head_commit() {
161 Ok(c) => Some(c),
162 Err(_) => None,
163 };
164 let (head_summary, head_time, head_id) = if let Some(c) = head {
165 let id = c.id().to_string();
166 let summary = c
167 .message()
168 .ok()
169 .and_then(|m| Some(m.summary().to_string()))
170 .unwrap_or_default();
171 let time = c.time().ok().map(|t| t.seconds);
172 (Some(summary), time, Some(id))
173 } else {
174 (None, None, None)
175 };
176
177 Ok(RepoSummary {
178 name,
179 description,
180 default_branch,
181 head_summary,
182 head_time,
183 head_id,
184 clone_url,
185 })
186}
187
188/// Read git's per-repo `description` file. The default text shipped by git
189/// (`Unnamed repository; edit this file 'description' to name the repository.`)
190/// is treated as empty so the landing page doesn't show that boilerplate.
191fn read_description(path: &Path) -> String {
192 let raw = std::fs::read_to_string(path.join("description")).unwrap_or_default();
193 let trimmed = raw.trim();
194 if trimmed.starts_with("Unnamed repository") {
195 String::new()
196 } else {
197 trimmed.to_string()
198 }
199}
200
201fn read_default_branch(repo: &gix::Repository) -> String {
202 // HEAD is a symbolic ref like `refs/heads/master`; strip the prefix.
203 if let Ok(head) = repo.head() {
204 if let Some(name) = head.referent_name() {
205 let s = name.as_bstr().to_string();
206 if let Some(b) = s.strip_prefix("refs/heads/") {
207 return b.to_string();
208 }
209 return s;
210 }
211 }
212 "master".to_string()
213}
214
215/// Resolve a revspec (branch name, tag, full or short oid) into a commit oid.
216pub fn resolve_rev(repo: &gix::Repository, rev: &str) -> Result<gix::ObjectId> {
217 let id = repo
218 .rev_parse_single(rev)
219 .map_err(|e| anyhow!("revspec {rev}: {e}"))?
220 .detach();
221 let obj = repo.find_object(id)?;
222 let commit = obj.peel_to_kind(gix::object::Kind::Commit)?;
223 Ok(commit.id)
224}
225
226pub fn commit_info(repo: &gix::Repository, oid: gix::ObjectId) -> Result<CommitInfo> {
227 let commit = repo.find_commit(oid)?;
228 let id = commit.id().to_string();
229 let short_id = id.chars().take(8).collect();
230 let msg = commit.message()?;
231 let summary = msg.summary().to_string();
232 let message = String::from_utf8_lossy(commit.message_raw()?.as_ref()).to_string();
233 let sig = commit.author()?;
234 let author = sig.name.to_string();
235 let author_email = sig.email.to_string();
236 let time = sig.time.seconds;
237 let parents = commit.parent_ids().map(|p| p.to_string()).collect();
238 Ok(CommitInfo {
239 id,
240 short_id,
241 summary,
242 message,
243 author,
244 author_email,
245 time,
246 parents,
247 })
248}
249
250pub fn recent_commits(
251 repo: &gix::Repository,
252 start: gix::ObjectId,
253 limit: usize,
254) -> Result<Vec<CommitInfo>> {
255 let mut out = Vec::with_capacity(limit);
256 let walk = repo.rev_walk([start]).all()?;
257 for info in walk.take(limit) {
258 let info = info?;
259 out.push(commit_info(repo, info.id)?);
260 }
261 Ok(out)
262}
263
264/// Walk a tree at `rev` / `path`. `path` is a `/`-joined string ("", "src",
265/// "src/routes"), not pre-split.
266pub fn list_tree(
267 repo: &gix::Repository,
268 rev: gix::ObjectId,
269 path: &str,
270) -> Result<(Vec<TreeEntry>, Vec<String>)> {
271 let commit = repo.find_commit(rev)?;
272 let mut tree = commit.tree()?;
273
274 let breadcrumb: Vec<String> = path
275 .split('/')
276 .filter(|p| !p.is_empty())
277 .map(|s| s.to_string())
278 .collect();
279 if !breadcrumb.is_empty() {
280 let entry = tree
281 .peel_to_entry_by_path(std::path::PathBuf::from(path))?
282 .ok_or_else(|| anyhow!("path not found: {path}"))?;
283 if !entry.mode().is_tree() {
284 return Err(anyhow!("not a tree: {path}"));
285 }
286 tree = entry.object()?.try_into_tree().map_err(|_| anyhow!("not a tree: {path}"))?;
287 }
288
289 let mut entries = Vec::new();
290 for entry_ref in tree.iter() {
291 let entry_ref = entry_ref?;
292 let name = entry_ref.filename().to_string();
293 let mode = entry_ref.mode();
294 let kind = if mode.is_tree() {
295 "tree"
296 } else if mode.is_link() {
297 "link"
298 } else if mode.is_commit() {
299 "commit"
300 } else {
301 "blob"
302 };
303 let size = if kind == "blob" {
304 // Header lookup only: find_object would load every blob's full
305 // contents just to report its size in the listing.
306 repo.find_header(entry_ref.oid()).ok().map(|h| h.size())
307 } else {
308 None
309 };
310 entries.push(TreeEntry {
311 name,
312 kind,
313 mode: format!("{:o}", *mode),
314 size,
315 id: entry_ref.oid().to_string(),
316 });
317 }
318 // Trees first, then alphabetical within each group.
319 entries.sort_by(|a, b| {
320 let group = |k| if k == "tree" { 0 } else { 1 };
321 group(a.kind)
322 .cmp(&group(b.kind))
323 .then(a.name.cmp(&b.name))
324 });
325 Ok((entries, breadcrumb))
326}
327
328pub fn read_blob(repo: &gix::Repository, rev: gix::ObjectId, path: &str) -> Result<BlobInfo> {
329 let commit = repo.find_commit(rev)?;
330 let mut tree = commit.tree()?;
331 if path.is_empty() {
332 return Err(anyhow!("empty path"));
333 }
334 let entry = tree
335 .peel_to_entry_by_path(std::path::PathBuf::from(path))?
336 .ok_or_else(|| anyhow!("path not found: {path}"))?;
337 // Peek the object header before loading: try_into_blob() below would
338 // otherwise pull the entire blob into memory, so a 500MB asset in a
339 // repo could OOM the container under concurrent requests.
340 let header = repo.find_header(entry.oid())?;
341 if header.size() > MAX_BLOB_SIZE {
342 return Err(anyhow!(
343 "blob too large to display ({} bytes; cap is {} bytes)",
344 header.size(),
345 MAX_BLOB_SIZE
346 ));
347 }
348 let blob = entry
349 .object()?
350 .try_into_blob()
351 .map_err(|_| anyhow!("not a blob: {path}"))?;
352 let data = blob.data.clone();
353 let size = data.len() as u64;
354 let is_binary = looks_binary(&data);
355 Ok(BlobInfo { data, size, is_binary })
356}
357
358/// "Binary" detection mirrors what git itself does: a NUL byte in the first
359/// 8KB. Good enough for the file-view branching (text → syntect, binary →
360/// download link).
361fn looks_binary(data: &[u8]) -> bool {
362 data.iter().take(8192).any(|&b| b == 0)
363}
364
365/// Find the README at the repo root (case-insensitive, .md / .markdown / .rst
366/// / no extension). Returns the bytes if found.
367pub fn read_readme(repo: &gix::Repository, rev: gix::ObjectId) -> Option<(String, Vec<u8>)> {
368 let commit = repo.find_commit(rev).ok()?;
369 let tree = commit.tree().ok()?;
370 let mut candidates: Vec<(String, gix::ObjectId)> = Vec::new();
371 for e in tree.iter() {
372 let Ok(e) = e else { continue };
373 if !e.mode().is_blob() {
374 continue;
375 }
376 let name = e.filename().to_string();
377 let lower = name.to_ascii_lowercase();
378 if lower == "readme"
379 || lower == "readme.md"
380 || lower == "readme.markdown"
381 || lower == "readme.txt"
382 || lower == "readme.rst"
383 {
384 candidates.push((name, e.oid().into()));
385 }
386 }
387 // Prefer .md, then no-extension, then anything else.
388 candidates.sort_by_key(|(n, _)| {
389 let l = n.to_ascii_lowercase();
390 if l.ends_with(".md") || l.ends_with(".markdown") {
391 0
392 } else if !l.contains('.') {
393 1
394 } else {
395 2
396 }
397 });
398 let (name, oid) = candidates.into_iter().next()?;
399 // A README bigger than this is not a README; skip it rather than feed
400 // megabytes through markdown + sanitize on every repo page view.
401 const README_CAP: u64 = 1024 * 1024;
402 if repo.find_header(oid).ok().map(|h| h.size()).unwrap_or(u64::MAX) > README_CAP {
403 return None;
404 }
405 let blob = repo.find_object(oid).ok()?.try_into_blob().ok()?;
406 Some((name, blob.data.clone()))
407}
408
409/// Render `git show --format= --patch <oid>` for a single commit and parse
410/// the unified-diff output into per-file hunks. We shell out instead of
411/// driving `gix-diff` directly: git is the canonical implementation of
412/// unified diff and it's already on the runtime image (we need it for
413/// `git http-backend`), so the marginal cost is one extra fork per commit
414/// view, not a new dependency.
415pub fn diff_commit(repo_path: &Path, oid: gix::ObjectId) -> Result<Vec<FileDiff>> {
416 let output = std::process::Command::new("git")
417 .arg("-c")
418 // Don't octal-escape non-ASCII filenames; we parse the unified-diff
419 // header by string-matching `diff --git a/... b/...` and quoted paths
420 // would break that (and the resulting `path` would render as gibberish).
421 .arg("core.quotePath=false")
422 .arg("-C")
423 .arg(repo_path)
424 .arg("show")
425 .arg("--format=")
426 .arg("--patch")
427 .arg("--no-color")
428 .arg("-M")
429 .arg(oid.to_string())
430 .output()
431 .context("spawn git show")?;
432 if !output.status.success() {
433 return Err(anyhow!(
434 "git show exited {:?}: {}",
435 output.status.code(),
436 String::from_utf8_lossy(&output.stderr)
437 ));
438 }
439 // Cap what we parse and render: a generated-file or vendored-tree commit
440 // can produce a diff of hundreds of MB, and every byte of it would be
441 // parsed, allocated, and templated. Cut at a line boundary.
442 const DIFF_OUTPUT_CAP: usize = 2 * 1024 * 1024;
443 let text = String::from_utf8_lossy(&output.stdout);
444 if text.len() > DIFF_OUTPUT_CAP {
445 tracing::warn!("diff for {oid} truncated at {DIFF_OUTPUT_CAP} bytes");
446 let mut end = DIFF_OUTPUT_CAP;
447 while !text.is_char_boundary(end) {
448 end -= 1;
449 }
450 let cut = text[..end].rfind('\n').unwrap_or(end);
451 return Ok(parse_unified_diff(&text[..cut]));
452 }
453 Ok(parse_unified_diff(&text))
454}
455
456fn parse_unified_diff(text: &str) -> Vec<FileDiff> {
457 let mut files: Vec<FileDiff> = Vec::new();
458 let mut current_file: Option<FileDiff> = None;
459 let mut current_hunk: Option<DiffHunk> = None;
460
461 let flush_hunk = |file: &mut FileDiff, hunk: &mut Option<DiffHunk>| {
462 if let Some(h) = hunk.take() {
463 file.hunks.push(h);
464 }
465 };
466
467 for line in text.lines() {
468 if let Some(rest) = line.strip_prefix("diff --git ") {
469 if let Some(mut f) = current_file.take() {
470 flush_hunk(&mut f, &mut current_hunk);
471 files.push(f);
472 }
473 // `diff --git a/foo b/foo`: take the b-path (post-rename side).
474 let parts: Vec<&str> = rest.split(' ').collect();
475 let new_path = parts
476 .last()
477 .map(|s| s.trim_start_matches("b/").to_string())
478 .unwrap_or_default();
479 current_file = Some(FileDiff {
480 path: new_path,
481 old_path: None,
482 status: "modified",
483 hunks: Vec::new(),
484 is_binary: false,
485 });
486 continue;
487 }
488 let Some(file) = current_file.as_mut() else { continue };
489
490 if line.starts_with("new file mode") {
491 file.status = "added";
492 } else if line.starts_with("deleted file mode") {
493 file.status = "deleted";
494 } else if line.starts_with("rename from ") {
495 file.status = "renamed";
496 file.old_path = Some(line.trim_start_matches("rename from ").to_string());
497 } else if line.starts_with("Binary files ") {
498 file.is_binary = true;
499 } else if line.starts_with("@@") {
500 flush_hunk(file, &mut current_hunk);
501 current_hunk = Some(DiffHunk {
502 header: line.to_string(),
503 lines: Vec::new(),
504 });
505 } else if let Some(hunk) = current_hunk.as_mut() {
506 let (kind, body): (&'static str, &str) = if let Some(rest) = line.strip_prefix('+') {
507 if line.starts_with("+++") {
508 continue;
509 }
510 ("add", rest)
511 } else if let Some(rest) = line.strip_prefix('-') {
512 if line.starts_with("---") {
513 continue;
514 }
515 ("del", rest)
516 } else if let Some(rest) = line.strip_prefix(' ') {
517 ("ctx", rest)
518 } else {
519 continue;
520 };
521 hunk.lines.push(DiffLine {
522 kind,
523 text: body.to_string(),
524 });
525 }
526 }
527 if let Some(mut f) = current_file.take() {
528 if let Some(h) = current_hunk.take() {
529 f.hunks.push(h);
530 }
531 files.push(f);
532 }
533 files
534}