at 2f4eb91
//! Generates a static git repository web UI and a clonable bare clone. //! //! Produces: //! - `<output>/repository/` — one HTML log page per branch, refs page, //! per-commit detail pages //! - `<output>/repository.git/` — bare clone suitable for dumb HTTP serving //! //! The branch named by `git_ui.default_branch` is rendered to `index.html`; //! every other branch gets `<sanitized-name>.html`. //! //! It also generates `<output>/repository/browse/<hash>/` — a full recursive //! static file tree browser with server-side syntax highlighting (via syntect), //! generated for every branch tip and every tagged commit. //! //! This is a site-level step called once from `main.rs`, not a per-version Builder. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::Duration; use chrono::{DateTime, Utc}; use gix::bstr::ByteSlice; use indicatif::{ProgressBar, ProgressStyle}; use miette::{IntoDiagnostic, Result}; use crate::cli::{CYAN, GREEN, RED, RESET}; use serde::Serialize; use tera::{Context, Tera}; use tracing::warn; use crate::config::{AbbayeConfig, GitUiConfig}; // ── Template sources ────────────────────────────────────────────────────────── pub const TEMPLATE_GIT_LOG: &str = include_str!("templates/git_log.html.j2"); pub const TEMPLATE_GIT_COMMIT: &str = include_str!("templates/git_commit.html.j2"); pub const TEMPLATE_GIT_REFS: &str = include_str!("templates/git_refs.html.j2"); pub const TEMPLATE_GIT_TREE: &str = include_str!("templates/git_tree.html.j2"); pub const TEMPLATE_GIT_BLOB: &str = include_str!("templates/git_blob.html.j2"); // ── Template-facing data structures ────────────────────────────────────────── #[derive(Clone, Serialize)] struct CommitParent { hash: String, hash_short: String, } /// A single commit's metadata, passed to Tera templates. /// `Clone` is required so commits can be deduplicated across branches. #[derive(Clone, Serialize)] struct CommitInfo { hash: String, hash_short: String, author_name: String, author_email: String, /// ISO-8601 timestamp for `<time datetime="">`. date_iso: String, /// Short date for the log table (YYYY-MM-DD). date: String, /// Date + time for the commit detail page. datetime_display: String, /// First line of the commit message. subject: String, /// Everything after the blank line separator, if present. body: Option<String>, parents: Vec<CommitParent>, /// Tags and branches whose tip is exactly this commit. ref_badges: Vec<RefBadge>, } #[derive(Serialize)] struct RefInfo { name: String, short_name: String, hash: String, hash_short: String, } /// The visual kind of a single unified-diff line. /// Serialises as lowercase for use as a CSS modifier class. #[derive(Serialize)] #[serde(rename_all = "lowercase")] enum DiffLineKind { Header, // diff --git, index, ---, +++, mode lines Hunk, // @@ -a,b +c,d @@ Added, // lines beginning with + Removed, // lines beginning with - Context, // unchanged surrounding lines } #[derive(Serialize)] struct DiffLine { kind: DiffLineKind, content: String, } #[derive(Serialize)] struct ChangedFile { path: String, /// One of: added, deleted, modified, renamed, copied, changed. status: String, diff_lines: Vec<DiffLine>, } /// Discriminates the two kinds of ref badge shown on log pages. /// Serialises as lowercase (`"tag"` / `"branch"`) for use as a CSS modifier class. #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] #[serde(rename_all = "lowercase")] enum RefBadgeKind { /// Derived `Ord`: `Tag < Branch`, so tags sort before branches. Tag, Branch, } /// A ref badge shown next to a commit hash on log pages. #[derive(Clone, Serialize)] struct RefBadge { label: String, kind: RefBadgeKind, } /// One level in the breadcrumb navigation on tree and blob pages. #[derive(Serialize)] struct Crumb { name: String, /// Relative link to this directory's `index.html`. `None` for the last /// (current) segment — rendered as plain text, not a link. url: Option<String>, } /// Kind of an entry in a directory listing. Serialises as lowercase. #[derive(Serialize)] #[serde(rename_all = "lowercase")] enum TreeEntryKind { Tree, Blob, } /// One row in a directory listing. #[derive(Serialize)] struct TreeEntry { name: String, kind: TreeEntryKind, /// Relative URL from the current tree page to this entry's page. url: String, } /// One entry in the branch-switcher nav rendered on every log page. #[derive(Serialize)] struct BranchNav { short_name: String, /// HTML filename for this branch ("index.html" or "<name>.html"). filename: String, is_current: bool, } // ── Internal branch descriptor (never serialized) ──────────────────────────── #[derive(Clone)] struct BranchEntry { short_name: String, /// Output filename: "index.html" for the default branch, else a sanitized name. filename: String, tip: gix::ObjectId, } // ── Public entry point ──────────────────────────────────────────────────────── /// Generate the repository web UI and bare clone into `config.site.output_dir`. /// Shared spinner style — matches the builder spinners in `site.rs`. fn make_spinner(label: &str) -> ProgressBar { let pb = ProgressBar::new_spinner(); pb.set_style( ProgressStyle::with_template("{spinner:.bold} {prefix} {msg}") .expect("valid template") .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "), ); pb.set_prefix(format!("{CYAN}[{label}]{RESET}")); pb.enable_steady_tick(Duration::from_millis(100)); pb } pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Result<()> { let output_dir = &config.site.output_dir; let ui_dir = output_dir.join("repository"); let bare_dir = output_dir.join("repository.git"); tokio::fs::create_dir_all(&ui_dir).await.into_diagnostic()?; tokio::fs::create_dir_all(ui_dir.join("commit")) .await .into_diagnostic()?; let repo_path: PathBuf = git_cfg .repo_path .clone() .unwrap_or_else(|| PathBuf::from(".")); let max_commits = git_cfg.max_commits; let default_branch = git_cfg.default_branch.clone(); let repo_path_clone = repo_path.clone(); // Compute clone URL before the blocking task so we can pass it into the // browse page generator without re-deriving it. let clone_url = generate_clone_command(config, git_cfg); // ── All gix work happens inside one blocking task (Repository is !Send) ─── // // Returns: // branch_pages — (short_name, filename, commits) per branch // unique_commits — all commits across all branches, deduplicated // tags / ref_branches — for refs.html // browse_revisions — (hex_hash, ObjectId) for every branch tip + tag tip let (branch_pages, unique_commits, tags, ref_branches, browse_revisions) = tokio::task::spawn_blocking(move || -> Result<_> { let repo = match gix::discover(&repo_path_clone) { Ok(r) => r, Err(e) => { warn!( "git_ui: could not open repository at {}: {e}", repo_path_clone.display() ); return Ok((vec![], vec![], vec![], vec![], vec![])); } }; let branches = collect_branch_entries(&repo, &default_branch)?; let (tags, ref_branches) = collect_refs(&repo)?; let ref_labels = build_ref_labels(&repo)?; // Walk commits per branch; collect unique commits for detail pages. let mut unique_map: HashMap<String, CommitInfo> = HashMap::new(); let mut branch_pages: Vec<(String, String, Vec<CommitInfo>)> = Vec::new(); for branch in &branches { let commits = collect_commits(&repo, branch.tip, max_commits, &ref_labels)?; for c in &commits { unique_map .entry(c.hash.clone()) .or_insert_with(|| c.clone()); } branch_pages.push((branch.short_name.clone(), branch.filename.clone(), commits)); } let unique_commits: Vec<CommitInfo> = unique_map.into_values().collect(); // Collect revisions for the tree browser: branch tips + tag tips. let mut seen: HashMap<String, gix::ObjectId> = HashMap::new(); for branch in &branches { seen.insert(branch.tip.to_string(), branch.tip); } let refs_platform = repo.references().into_diagnostic()?; for reference in refs_platform.all().into_diagnostic()? { let mut reference = reference.map_err(|e| miette::miette!("{e}"))?; let name = reference.name().as_bstr().to_str_lossy().into_owned(); if !name.starts_with("refs/tags/") { continue; } if let Ok(id) = reference.peel_to_id() { let hash = id.to_string(); seen.entry(hash).or_insert_with(|| id.detach()); } } let browse_revisions: Vec<(String, gix::ObjectId)> = seen.into_iter().collect(); Ok(( branch_pages, unique_commits, tags, ref_branches, browse_revisions, )) }) .await .into_diagnostic()??; if branch_pages.is_empty() { // Nothing to render (empty repo or no branches). return Ok(()); } let pb = make_spinner("git ui"); // ── Bare clone + dumb HTTP setup ────────────────────────────────────────── pb.set_message("cloning bare repository…"); if let Err(e) = export_bare_clone(&repo_path, &bare_dir).await { pb.finish_with_message(format!("{RED}\u{2717} failed:{RESET} {e}")); return Err(e); } // ── Tera setup ──────────────────────────────────────────────────────────── let mut tera = Tera::default(); let theme_path = PathBuf::from(".abbaye").join("theme"); for (name, builtin) in [ ("git_log.html", TEMPLATE_GIT_LOG), ("git_commit.html", TEMPLATE_GIT_COMMIT), ("git_refs.html", TEMPLATE_GIT_REFS), ] { let override_path = theme_path.join(format!("{name}.j2")); if override_path.is_file() { tera.add_template_file(&override_path, Some(name)) .into_diagnostic()?; } else { tera.add_raw_template(name, builtin).into_diagnostic()?; } } crate::site::load_extra_theme_templates( &mut tera, &theme_path, &["git_log.html", "git_commit.html", "git_refs.html"], )?; // ── Branch switcher nav (shared across all log pages) ───────────────────── // // Order: the default branch (index.html) first, then alphabetical. let mut nav_entries: Vec<(String, String)> = branch_pages .iter() .map(|(name, file, _)| (name.clone(), file.clone())) .collect(); nav_entries.sort_by(|(na, fa), (nb, fb)| { let a_default = fa == "index.html"; let b_default = fb == "index.html"; b_default.cmp(&a_default).then(na.cmp(nb)) }); // ── Render one log page per branch ──────────────────────────────────────── pb.set_message("rendering log pages…"); for (short_name, filename, commits) in &branch_pages { let truncated = commits.len() >= max_commits; let branch_nav: Vec<BranchNav> = nav_entries .iter() .map(|(bn, bf)| BranchNav { short_name: bn.clone(), filename: bf.clone(), is_current: bn == short_name, }) .collect(); let mut ctx = Context::new(); ctx.insert("project_name", &config.site.name); ctx.insert("lang", &config.site.lang); ctx.insert("clone_url", &clone_url); ctx.insert("current_branch", short_name); ctx.insert("branch_nav", &branch_nav); ctx.insert("commits", commits); ctx.insert("truncated", &truncated); ctx.insert("root_path", "../"); let html = tera.render("git_log.html", &ctx).into_diagnostic()?; tokio::fs::write(ui_dir.join(filename), html) .await .into_diagnostic()?; } // ── Render refs page ────────────────────────────────────────────────────── { let mut ctx = Context::new(); ctx.insert("project_name", &config.site.name); ctx.insert("lang", &config.site.lang); ctx.insert("clone_url", &clone_url); ctx.insert("tags", &tags); ctx.insert("branches", &ref_branches); ctx.insert("root_path", "../"); let html = tera.render("git_refs.html", &ctx).into_diagnostic()?; tokio::fs::write(ui_dir.join("refs.html"), html) .await .into_diagnostic()?; } // ── Render per-commit detail pages ──────────────────────────────────────── pb.set_message(format!("rendering {} commit pages…", unique_commits.len())); for commit_info in &unique_commits { let changed_files = get_changed_files(&commit_info.hash).await?; let has_browse = !commit_info.ref_badges.is_empty(); let mut ctx = Context::new(); ctx.insert("project_name", &config.site.name); ctx.insert("lang", &config.site.lang); ctx.insert("clone_url", &clone_url); ctx.insert("commit", commit_info); ctx.insert("changed_files", &changed_files); ctx.insert("has_browse", &has_browse); ctx.insert("root_path", "../../"); let html = tera.render("git_commit.html", &ctx).into_diagnostic()?; tokio::fs::write( ui_dir .join("commit") .join(format!("{}.html", commit_info.hash)), html, ) .await .into_diagnostic()?; } // ── Tree browser (browse/<hash>/) ───────────────────────────────────────── if !browse_revisions.is_empty() { pb.set_message(format!( "building browse pages for {} revision(s)…", browse_revisions.len() )); let browse_dir = ui_dir.join("browse"); tokio::fs::create_dir_all(&browse_dir) .await .into_diagnostic()?; let project_name = config.site.name.clone(); let lang = config.site.lang.clone(); let clone_url_browse = clone_url.clone(); let theme_path = PathBuf::from(".abbaye").join("theme"); let repo_path_browse = repo_path.clone(); tokio::task::spawn_blocking(move || { build_browse_pages( &browse_revisions, &browse_dir, &repo_path_browse, &theme_path, &project_name, &lang, &clone_url_browse, ) }) .await .into_diagnostic()?? } pb.finish_with_message(format!("{GREEN}\u{2713} done{RESET}")); Ok(()) } pub fn generate_clone_command(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Option<String> { let clone_url: Option<String> = git_cfg.clone_url.clone().or_else(|| { config.site.base_url.as_ref().map(|base| { format!( "{}/repository.git {}", base.trim_end_matches('/'), if config.site.name.contains(" ") { format!("'{}'", config.site.name) } else { config.site.name.clone() } ) }) }); clone_url } // ── Git data collection ─────────────────────────────────────────────────────── /// Collect all local branches and assign output filenames. /// /// The branch whose short name matches `default_branch` gets `"index.html"`. /// Every other branch gets `"<sanitized-short-name>.html"` where `/` is /// replaced by `-`. /// /// If no branch matches `default_branch`, the first branch (alphabetically) /// receives `"index.html"` and a warning is emitted. fn collect_branch_entries( repo: &gix::Repository, default_branch: &str, ) -> Result<Vec<BranchEntry>> { let mut entries: Vec<BranchEntry> = Vec::new(); let refs_platform = repo.references().into_diagnostic()?; for reference in refs_platform.all().into_diagnostic()? { let mut reference = reference.map_err(|e| miette::miette!("{e}"))?; let name = reference.name().as_bstr().to_str_lossy().into_owned(); if !name.starts_with("refs/heads/") { continue; } let short_name = name.trim_start_matches("refs/heads/").to_string(); let tip = match reference.peel_to_id() { Ok(id) => id.detach(), Err(_) => continue, }; // Tentative filename; replaced for the default branch below. let filename = format!("{}.html", short_name.replace('/', "-")); entries.push(BranchEntry { short_name, filename, tip, }); } // Stable, predictable page order. entries.sort_by(|a, b| a.short_name.cmp(&b.short_name)); // Assign index.html to the configured default branch. if let Some(e) = entries.iter_mut().find(|e| e.short_name == default_branch) { e.filename = "index.html".to_string(); } else if let Some(first) = entries.first_mut() { warn!( "git_ui: default branch '{}' not found; using '{}' as index.html", default_branch, first.short_name ); first.filename = "index.html".to_string(); } Ok(entries) } /// Build a map from commit hash (hex string) to the ref badges pointing at it. /// Tags come before branches within each entry; both are sorted alphabetically. fn build_ref_labels(repo: &gix::Repository) -> Result<HashMap<String, Vec<RefBadge>>> { let mut map: HashMap<String, Vec<RefBadge>> = HashMap::new(); let refs_platform = repo.references().into_diagnostic()?; for reference in refs_platform.all().into_diagnostic()? { let mut reference = reference.map_err(|e| miette::miette!("{e}"))?; let name = reference.name().as_bstr().to_str_lossy().into_owned(); if name == "HEAD" || name.starts_with("refs/remotes/") { continue; } let hash = match reference.peel_to_id() { Ok(id) => id.to_string(), Err(_) => continue, }; let badge = if let Some(label) = name.strip_prefix("refs/tags/") { RefBadge { label: label.to_string(), kind: RefBadgeKind::Tag, } } else if let Some(label) = name.strip_prefix("refs/heads/") { RefBadge { label: label.to_string(), kind: RefBadgeKind::Branch, } } else { continue; }; map.entry(hash).or_default().push(badge); } // Within each entry: tags first (Tag < Branch via Ord), then alphabetical. for badges in map.values_mut() { badges.sort_by(|a, b| a.kind.cmp(&b.kind).then(a.label.cmp(&b.label))); } Ok(map) } /// Walk at most `max` commits reachable from `tip`, newest first. fn collect_commits( repo: &gix::Repository, tip: gix::ObjectId, max: usize, ref_labels: &HashMap<String, Vec<RefBadge>>, ) -> Result<Vec<CommitInfo>> { let walk = repo.rev_walk([tip]).all().into_diagnostic()?; let mut commits = Vec::new(); for info in walk.take(max) { let info = info.into_diagnostic()?; let id = info.id; let object = repo.find_object(id).into_diagnostic()?; let commit = object.into_commit(); let decoded = commit.decode().into_diagnostic()?; let author = decoded.author().into_diagnostic()?; let author_name = author.name.to_str_lossy().into_owned(); let author_email = author.email.to_str_lossy().into_owned(); let unix_secs: i64 = author .time .split_whitespace() .next() .and_then(|s| s.parse().ok()) .unwrap_or(0); let dt: DateTime<Utc> = DateTime::from_timestamp(unix_secs, 0).unwrap_or_default(); let date = dt.format("%Y-%m-%d").to_string(); let date_iso = dt.to_rfc3339(); let datetime_display = dt.format("%Y-%m-%d %H:%M UTC").to_string(); let raw_msg = decoded.message.to_str_lossy(); let (subject, body) = parse_message(&raw_msg); let hash = id.to_string(); let hash_short = hash[..7].to_string(); let parents = info .parent_ids .iter() .map(|p| { let h = p.to_string(); let hs = h[..7].to_string(); CommitParent { hash: h, hash_short: hs, } }) .collect(); let ref_badges = ref_labels.get(&hash).cloned().unwrap_or_default(); commits.push(CommitInfo { hash, hash_short, author_name, author_email, date, date_iso, datetime_display, subject, body, parents, ref_badges, }); } Ok(commits) } /// Collect tags and branches for the refs overview page. fn collect_refs(repo: &gix::Repository) -> Result<(Vec<RefInfo>, Vec<RefInfo>)> { let mut tags: Vec<RefInfo> = Vec::new(); let mut branches: Vec<RefInfo> = Vec::new(); let refs_platform = repo.references().into_diagnostic()?; for reference in refs_platform.all().into_diagnostic()? { let mut reference = reference.map_err(|e| miette::miette!("{e}"))?; let name = reference.name().as_bstr().to_str_lossy().into_owned(); if name.starts_with("refs/remotes/") || name == "HEAD" { continue; } let hash = match reference.peel_to_id() { Ok(id) => id.to_string(), Err(_) => continue, }; let hash_short = hash[..7.min(hash.len())].to_string(); if name.starts_with("refs/tags/") { let short_name = name.trim_start_matches("refs/tags/").to_string(); tags.push(RefInfo { name, short_name, hash, hash_short, }); } else if name.starts_with("refs/heads/") { let short_name = name.trim_start_matches("refs/heads/").to_string(); branches.push(RefInfo { name, short_name, hash, hash_short, }); } } // Tags: newest first (reverse-lexicographic ≈ version order for semver tags). tags.sort_by(|a, b| b.name.cmp(&a.name)); branches.sort_by(|a, b| a.name.cmp(&b.name)); Ok((tags, branches)) } // ── Changed files via git CLI ───────────────────────────────────────────────── async fn get_changed_files(commit_hash: &str) -> Result<Vec<ChangedFile>> { let output = tokio::process::Command::new("git") .args(["diff-tree", "-p", "--no-commit-id", "-r", commit_hash]) .output() .await .into_diagnostic()?; if !output.status.success() { return Ok(vec![]); } Ok(parse_diff_output(&String::from_utf8_lossy(&output.stdout))) } /// Parse the output of `git diff-tree -p` into per-file [`ChangedFile`] entries. /// /// Each file section starts with a `diff --git a/<path> b/<path>` line. /// Status is inferred from subsequent mode/rename headers. /// Diff line kinds are determined by their leading character, with header /// lines (`--- `, `+++ `) distinguished from content lines (`-`, `+`) by /// the mandatory space that follows the three-character marker. fn parse_diff_output(text: &str) -> Vec<ChangedFile> { let mut files: Vec<ChangedFile> = Vec::new(); let mut cur_lines: Vec<DiffLine> = Vec::new(); let mut cur_path = String::new(); let mut cur_status = "modified"; let push_line = |lines: &mut Vec<DiffLine>, kind, content: &str| { lines.push(DiffLine { kind, content: content.to_string(), }); }; for line in text.lines() { if line.starts_with("diff --git ") { if !cur_path.is_empty() { files.push(ChangedFile { path: cur_path.clone(), status: cur_status.to_string(), diff_lines: std::mem::take(&mut cur_lines), }); } // Extract the destination path from the trailing " b/<path>" token. // rsplit_once handles paths that contain spaces. cur_path = line .rsplit_once(" b/") .map(|(_, p)| p.to_string()) .unwrap_or_default(); cur_status = "modified"; push_line(&mut cur_lines, DiffLineKind::Header, line); } else if line.starts_with("new file mode") { cur_status = "added"; push_line(&mut cur_lines, DiffLineKind::Header, line); } else if line.starts_with("deleted file mode") { cur_status = "deleted"; push_line(&mut cur_lines, DiffLineKind::Header, line); } else if line.starts_with("rename from") || line.starts_with("rename to") { cur_status = "renamed"; push_line(&mut cur_lines, DiffLineKind::Header, line); } else if line.starts_with("similarity index") || line.starts_with("copy from") || line.starts_with("copy to") || line.starts_with("index ") || line.starts_with("--- ") // file header, not a removed line || line.starts_with("+++ ") // file header, not an added line || line.starts_with("Binary files") || line.starts_with('\\') { push_line(&mut cur_lines, DiffLineKind::Header, line); } else if line.starts_with("@@") { push_line(&mut cur_lines, DiffLineKind::Hunk, line); } else if line.starts_with('+') { push_line(&mut cur_lines, DiffLineKind::Added, line); } else if line.starts_with('-') { push_line(&mut cur_lines, DiffLineKind::Removed, line); } else { push_line(&mut cur_lines, DiffLineKind::Context, line); } } if !cur_path.is_empty() { files.push(ChangedFile { path: cur_path, status: cur_status.to_string(), diff_lines: cur_lines, }); } files } // ── Bare clone export ───────────────────────────────────────────────────────── async fn export_bare_clone(source: &Path, dest: &Path) -> Result<()> { use tokio::process::Command; if dest.exists() { tokio::fs::remove_dir_all(dest).await.into_diagnostic()?; } let status = Command::new("git") .arg("clone") .arg("--bare") .arg(source) .arg(dest) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .await .into_diagnostic()?; if !status.success() { return Err(miette::miette!( "git clone --bare failed with exit status {status}" )); } // Enable dumb HTTP transport so the bare repo is clonable over plain HTTPS. let status = Command::new("git") .arg("-C") .arg(dest) .arg("update-server-info") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .await .into_diagnostic()?; if !status.success() { warn!("git update-server-info failed; dumb HTTP cloning may not work"); } Ok(()) } // ── Tree browser ────────────────────────────────────────────────────────────────── /// Build the full static tree browser for every revision in `revisions`. /// /// Everything here is synchronous (gix + std::fs + syntect), intended to run /// inside `tokio::task::spawn_blocking`. fn build_browse_pages( revisions: &[(String, gix::ObjectId)], browse_dir: &Path, // public/repository/browse/ repo_path: &Path, // for `git cat-file blob` theme_path: &Path, // .abbaye/theme (theme overrides) project_name: &str, lang: &Option<String>, clone_url: &Option<String>, ) -> Result<()> { use syntect::highlighting::ThemeSet; use syntect::parsing::SyntaxSet; let ss = SyntaxSet::load_defaults_newlines(); let ts = ThemeSet::load_defaults(); let theme = &ts.themes["InspiredGitHub"]; // Build a separate Tera instance for browse templates. let mut tera = Tera::default(); for (name, builtin) in [ ("git_tree.html", TEMPLATE_GIT_TREE), ("git_blob.html", TEMPLATE_GIT_BLOB), ] { let override_path = theme_path.join(format!("{name}.j2")); if override_path.is_file() { tera.add_template_file(&override_path, Some(name)) .map_err(|e| miette::miette!("{e}"))?; } else { tera.add_raw_template(name, builtin) .map_err(|e| miette::miette!("{e}"))?; } } crate::site::load_extra_theme_templates( &mut tera, theme_path, &["git_tree.html", "git_blob.html"], )?; for (hash, oid) in revisions { let rev_dir = browse_dir.join(hash); std::fs::create_dir_all(&rev_dir).into_diagnostic()?; // Resolve the commit's root tree. let repo = gix::open(repo_path).into_diagnostic()?; let commit_obj = repo.find_object(*oid).into_diagnostic()?.into_commit(); let decoded = commit_obj.decode().into_diagnostic()?; let tree_id = decoded.tree(); walk_tree_dir( repo_path, &repo, tree_id, "", hash, &rev_dir, &tera, project_name, lang, clone_url, &ss, theme, )?; } Ok(()) } /// Recursively generate one `index.html` (directory listing) per tree and one /// `<name>.html` per blob, rooted at `rev_dir`. /// TODO: Fix clippy warning about too many arguments #[allow(clippy::too_many_arguments)] fn walk_tree_dir( repo_path: &Path, repo: &gix::Repository, tree_id: gix::ObjectId, dir_path: &str, // "" = root, "src", "src/utils" commit_hash: &str, rev_dir: &Path, // public/repository/browse/<hash>/ tera: &Tera, project_name: &str, lang: &Option<String>, clone_url: &Option<String>, ss: &syntect::parsing::SyntaxSet, theme: &syntect::highlighting::Theme, ) -> Result<()> { let tree_obj = repo.find_object(tree_id).into_diagnostic()?.into_tree(); let decoded = tree_obj.decode().into_diagnostic()?; // Depth = number of path components in dir_path. let depth: usize = dir_path.split('/').filter(|s| !s.is_empty()).count(); // Public output directory for this tree's index.html. let page_dir = if dir_path.is_empty() { rev_dir.to_path_buf() } else { dir_path .split('/') .filter(|s| !s.is_empty()) .fold(rev_dir.to_path_buf(), |p, c| p.join(c)) }; std::fs::create_dir_all(&page_dir).into_diagnostic()?; let mut entries: Vec<TreeEntry> = Vec::new(); // Defer recursion until after the listing page is written. let mut subdirs: Vec<(String, gix::ObjectId)> = Vec::new(); let mut blobs: Vec<(String, gix::ObjectId)> = Vec::new(); for entry in decoded.entries.iter() { let name = entry.filename.to_str_lossy().into_owned(); let oid: gix::ObjectId = entry.oid.to_owned(); if entry.mode.is_tree() { entries.push(TreeEntry { url: format!("{name}/index.html"), name: name.clone(), kind: TreeEntryKind::Tree, }); subdirs.push((name, oid)); } else { // blob, executable blob, symlink, or submodule commit entries.push(TreeEntry { url: format!("{name}.html"), name: name.clone(), kind: TreeEntryKind::Blob, }); if !entry.mode.is_commit() { // Skip submodule gitlinks (they have no blob content). blobs.push((name, oid)); } } } // Directories first, then files; both alphabetical. entries.sort_by(|a, b| { let a_tree = matches!(a.kind, TreeEntryKind::Tree); let b_tree = matches!(b.kind, TreeEntryKind::Tree); b_tree.cmp(&a_tree).then(a.name.cmp(&b.name)) }); // root_path: how many levels up to reach the site root (public/). // browse/<hash>/[subpath/] => 3 + depth levels up. let root_path = "../".repeat(3 + depth); // commit_url: link back to the commit detail page. let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2)); let breadcrumbs = make_crumbs(dir_path, false, None); let mut ctx = Context::new(); ctx.insert("project_name", project_name); ctx.insert("lang", lang); ctx.insert("clone_url", clone_url); ctx.insert("commit_hash", commit_hash); ctx.insert("commit_hash_short", &commit_hash[..7]); ctx.insert("commit_url", &commit_url); ctx.insert("dir_path", dir_path); ctx.insert("entries", &entries); ctx.insert("breadcrumbs", &breadcrumbs); ctx.insert("root_path", &root_path); let html = tera .render("git_tree.html", &ctx) .map_err(|e| miette::miette!("{e}"))?; std::fs::write(page_dir.join("index.html"), html).into_diagnostic()?; // Recurse into subdirectories. for (name, oid) in subdirs { let child_path = if dir_path.is_empty() { name } else { format!("{dir_path}/{name}") }; walk_tree_dir( repo_path, repo, oid, &child_path, commit_hash, rev_dir, tera, project_name, lang, clone_url, ss, theme, )?; } // Render blob pages. for (name, oid) in blobs { let file_path = if dir_path.is_empty() { name.clone() } else { format!("{dir_path}/{name}") }; render_blob_page( repo_path, &name, &file_path, oid, commit_hash, depth, &page_dir, tera, project_name, lang, clone_url, ss, theme, )?; } Ok(()) } /// Write one syntax-highlighted blob page to `page_dir/<name>.html`. #[allow(clippy::too_many_arguments)] fn render_blob_page( repo_path: &Path, filename: &str, file_path: &str, // full path from repo root, e.g. "src/main.rs" oid: gix::ObjectId, commit_hash: &str, depth: usize, // number of directory components containing the file page_dir: &Path, // output directory (same as the parent tree's page_dir) tera: &Tera, project_name: &str, lang: &Option<String>, clone_url: &Option<String>, ss: &syntect::parsing::SyntaxSet, theme: &syntect::highlighting::Theme, ) -> Result<()> { const MAX_BLOB_BYTES: usize = 1024 * 1024; // 1 MiB // Read blob via `git cat-file blob <oid>` to avoid gix private-field access. let data: Vec<u8> = std::process::Command::new("git") .current_dir(repo_path) .args(["cat-file", "blob", &oid.to_string()]) .output() .map(|o| o.stdout) .unwrap_or_default(); let is_binary = data[..data.len().min(8192)].contains(&0u8); let too_large = data.len() > MAX_BLOB_BYTES; let content_html: Option<String> = if is_binary || too_large || data.is_empty() { None } else { let text = String::from_utf8_lossy(&data); let ext = std::path::Path::new(filename) .extension() .and_then(|s| s.to_str()) .unwrap_or(""); let syntax = ss .find_syntax_by_extension(ext) .or_else(|| { text.lines() .next() .and_then(|l| ss.find_syntax_by_first_line(l)) }) .unwrap_or_else(|| ss.find_syntax_plain_text()); Some( syntect::html::highlighted_html_for_string(&text, ss, syntax, theme) .unwrap_or_else(|_| format!("<pre>{}</pre>", escape_html(&text))), ) }; let root_path = "../".repeat(3 + depth); let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2)); let breadcrumbs = make_crumbs( std::path::Path::new(file_path) .parent() .and_then(|p| p.to_str()) .unwrap_or(""), true, Some(filename), ); let mut ctx = Context::new(); ctx.insert("project_name", project_name); ctx.insert("lang", lang); ctx.insert("clone_url", clone_url); ctx.insert("commit_hash", commit_hash); ctx.insert("commit_hash_short", &commit_hash[..7]); ctx.insert("commit_url", &commit_url); ctx.insert("file_path", file_path); ctx.insert("filename", filename); ctx.insert("breadcrumbs", &breadcrumbs); ctx.insert("content_html", &content_html); ctx.insert("is_binary", &is_binary); ctx.insert("too_large", &too_large); ctx.insert("size", &data.len()); ctx.insert("root_path", &root_path); let html = tera .render("git_blob.html", &ctx) .map_err(|e| miette::miette!("{e}"))?; std::fs::write(page_dir.join(format!("{filename}.html")), html).into_diagnostic()?; Ok(()) } /// Build breadcrumb entries for a tree or blob page. /// /// `dir_path` is the path to the containing directory (e.g. `"src"` for /// `src/main.rs`). `depth` is derived from `dir_path` internally. fn make_crumbs(dir_path: &str, is_blob: bool, filename: Option<&str>) -> Vec<Crumb> { let parts: Vec<&str> = dir_path.split('/').filter(|s| !s.is_empty()).collect(); let depth = parts.len(); let mut crumbs = Vec::new(); // Root crumb (“~”). let root_url = if depth == 0 && !is_blob { None // we ARE the root dir listing } else { Some(format!("{}index.html", "../".repeat(depth))) }; crumbs.push(Crumb { name: "~".to_string(), url: root_url, }); // Intermediate directory crumbs. for (i, &part) in parts.iter().enumerate() { let is_last_and_tree = i == depth - 1 && !is_blob; let url = if is_last_and_tree { None // current directory } else { // levels_up = how many "../" to navigate from current location to this dir let levels_up = depth - i - 1; Some(format!("{}index.html", "../".repeat(levels_up))) }; crumbs.push(Crumb { name: part.to_string(), url, }); } // Filename crumb for blobs. if is_blob { if let Some(name) = filename { crumbs.push(Crumb { name: name.to_string(), url: None, }); } } crumbs } fn escape_html(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") } // ── Helpers ──────────────────────────────────────────────────────────────────── /// Split a raw commit message into (subject, optional body). fn parse_message(raw: &str) -> (String, Option<String>) { if let Some(idx) = raw.find("\n\n") { let subject = raw[..idx].trim().to_string(); let body = raw[idx + 2..].trim().to_string(); let body = if body.is_empty() { None } else { Some(body) }; (subject, body) } else { (raw.trim().to_string(), None) } }