repos
/ repos-rust master

repos-rust

mirror archived upstream

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

17.7 KB · 535 lines · Rust Raw History
  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}