at 22e2605
//! 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 globset::{Glob, GlobSet, GlobSetBuilder}; use indicatif::ProgressBar; use miette::{IntoDiagnostic, Result}; use crate::cli::{CYAN, GREEN, RED, RESET}; use serde::Serialize; use tera::{Context, Tera}; use tracing::{info, warn}; use crate::config::{AbbayeConfig, GitUiConfig}; // ── Template sources ────────────────────────────────────────────────────────── pub const TEMPLATE_GIT_LOG_HTML: &str = include_str!("templates/git_log.html.j2"); pub const TEMPLATE_GIT_COMMIT_HTML: &str = include_str!("templates/git_commit.html.j2"); pub const TEMPLATE_GIT_REFS_HTML: &str = include_str!("templates/git_refs.html.j2"); pub const TEMPLATE_GIT_TREE_HTML: &str = include_str!("templates/git_tree.html.j2"); pub const TEMPLATE_GIT_BLOB_HTML: &str = include_str!("templates/git_blob.html.j2"); pub const TEMPLATE_GIT_LOG_GEMTEXT: &str = include_str!("templates/git_log.gmi.j2"); pub const TEMPLATE_GIT_COMMIT_GEMTEXT: &str = include_str!("templates/git_commit.gmi.j2"); pub const TEMPLATE_GIT_REFS_GEMTEXT: &str = include_str!("templates/git_refs.gmi.j2"); pub const TEMPLATE_GIT_TREE_GEMTEXT: &str = include_str!("templates/git_tree.gmi.j2"); pub const TEMPLATE_GIT_BLOB_GEMTEXT: &str = include_str!("templates/git_blob.gmi.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 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 ──────────────────────────────────────────────────────── /// Shared spinner style - matches the builder spinners in `site.rs`. fn make_spinner(label: &str) -> ProgressBar { let pb = ProgressBar::new_spinner(); pb.set_style(crate::site::spinner_style(false)); pb.set_prefix(format!("{CYAN}[{label}]{RESET}")); pb.enable_steady_tick(Duration::from_millis(100)); pb } /// A lightweight progress reporter that either drives an `indicatif` spinner /// (interactive terminals) or logs via `tracing::info!` (dumb / piped terminals). enum Progress { /// Interactive terminal – show a spinner. Spinner(ProgressBar), /// Non-interactive terminal – just log messages. Log, } impl Progress { fn new(label: &str) -> Self { if crate::utils::is_interactive() { let pb = make_spinner(label); pb.set_message("starting…"); Self::Spinner(pb) } else { info!("[{label}] starting …"); Self::Log } } fn set_message(&self, msg: impl Into<String>) { match self { Self::Spinner(pb) => pb.set_message(msg.into()), Self::Log => info!("[git ui] {}", msg.into()), } } fn finish_done(&self) { match self { Self::Spinner(pb) => { pb.finish_with_message(format!("{GREEN}\u{2713} done{RESET}")); } Self::Log => info!("[git ui] done"), } } fn finish_failed(&self, detail: &miette::Report) { match self { Self::Spinner(pb) => { pb.finish_with_message(format!("{RED}\u{2717} failed: {detail}{RESET}")); } Self::Log => warn!("[git ui] failed: {detail}"), } } } pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Result<()> { let output_dir = &config.site.output_dir; let prefix = &git_cfg.prefix; if prefix == "repository" { warn!( "The default git UI prefix has changed from 'repository' to '' (site root). \ Set prefix = \"repository\" in your [git_ui] config to keep the legacy layout." ); } let prefix_empty = prefix.is_empty(); let ui_dir = if prefix_empty { output_dir.clone() } else { output_dir.join(prefix) }; let bare_dir = output_dir.join("repository.git"); let root_path_for = |extra_depth: usize| -> String { if prefix_empty { "../".repeat(extra_depth) } else { "../".repeat(extra_depth + 1) } }; let git_ui_path = if prefix_empty { String::new() } else { format!("{prefix}/") }; 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 include = build_globset(&git_cfg.include)?; let exclude = build_globset(&git_cfg.exclude)?; let repo_path_clone = repo_path.clone(); // `include`/`exclude`/`default_branch` are moved into the `spawn_blocking` // closure below, so keep clones around for `export_bare_clone`, which // needs the same filters to decide what to publish in `repository.git`. let bare_default_branch = default_branch.clone(); let bare_include = include.clone(); let bare_exclude = exclude.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, &include, &exclude)?; let (tags, ref_branches) = collect_refs(&repo, &include, &exclude)?; let ref_labels = build_ref_labels(&repo, &include, &exclude)?; // 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; } let short_name = name.trim_start_matches("refs/tags/"); if !ref_is_included(short_name, &include, &exclude) { 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 progress = Progress::new("git ui"); // ── Bare clone + dumb HTTP setup ────────────────────────────────────────── progress.set_message("cloning bare repository…"); if let Err(e) = export_bare_clone( &repo_path, &bare_dir, &bare_default_branch, &bare_include, &bare_exclude, ) .await { progress.finish_failed(&e); return Err(e); } // ── Tera setup ──────────────────────────────────────────────────────────── let mut tera = Tera::default(); let theme_path = PathBuf::from(".abbaye").join("theme"); crate::site::register_format_templates( &mut tera, &theme_path, &config.site.formats, &[ ("git_log", TEMPLATE_GIT_LOG_HTML, TEMPLATE_GIT_LOG_GEMTEXT), ( "git_commit", TEMPLATE_GIT_COMMIT_HTML, TEMPLATE_GIT_COMMIT_GEMTEXT, ), ( "git_refs", TEMPLATE_GIT_REFS_HTML, TEMPLATE_GIT_REFS_GEMTEXT, ), ], )?; // ── 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 (per format) ─────────────────────────── progress.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(); for format in &config.site.formats { let suffix = format.extension(); let tmpl_name = format!("git_log.{suffix}"); let ext = format.extension(); let out_filename = filename.replace(".html", &format!(".{ext}")); 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", &root_path_for(0)); ctx.insert("git_ui_path", &git_ui_path); let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?; tokio::fs::write(ui_dir.join(&out_filename), content) .await .into_diagnostic()?; } } // ── Render refs page (per format) ───────────────────────────────────────── for format in &config.site.formats { let suffix = format.extension(); let tmpl_name = format!("git_refs.{suffix}"); let ext = format.extension(); let out_filename = format!("refs.{ext}"); 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", &root_path_for(0)); ctx.insert("git_ui_path", &git_ui_path); let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?; tokio::fs::write(ui_dir.join(&out_filename), content) .await .into_diagnostic()?; } // ── Render per-commit detail pages (per format) ─────────────────────────── progress.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 commit_dir = ui_dir.join("commit"); for format in &config.site.formats { let suffix = format.extension(); let tmpl_name = format!("git_commit.{suffix}"); let ext = format.extension(); let out_filename = format!("{}.{ext}", commit_info.hash); 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", &root_path_for(1)); ctx.insert("git_ui_path", &git_ui_path); let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?; tokio::fs::write(commit_dir.join(&out_filename), content) .await .into_diagnostic()?; } } // ── Tree browser (browse/<hash>/) ───────────────────────────────────────── if !browse_revisions.is_empty() { progress.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(); let formats = config.site.formats.clone(); let git_ui_path_browse = git_ui_path.clone(); let prefix_empty_browse = prefix_empty; tokio::task::spawn_blocking(move || { crate::git_browse::build_browse_pages( &browse_revisions, &browse_dir, &repo_path_browse, &theme_path, &project_name, &lang, &clone_url_browse, &formats, &git_ui_path_browse, prefix_empty_browse, ) }) .await .into_diagnostic()?? } progress.finish_done(); 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 ─────────────────────────────────────────────────────── /// Compile a list of glob patterns (as written in `git_ui.exclude`/`include`) /// into a [`GlobSet`]. An empty pattern list compiles to an empty `GlobSet`, /// which matches nothing. fn build_globset(patterns: &[String]) -> Result<GlobSet> { let mut builder = GlobSetBuilder::new(); for pattern in patterns { let glob = Glob::new(pattern) .map_err(|e| miette::miette!("git_ui: invalid glob pattern '{pattern}': {e}"))?; builder.add(glob); } builder.build().into_diagnostic() } /// Decide whether a ref (by its short name, e.g. `"main"` or `"v1.0.0"`) /// should appear in the generated UI, per `git_ui.exclude`/`git_ui.include`. /// /// When `include` is non-empty, it acts as an allowlist: only refs matching /// at least one `include` pattern are kept. When `include` is empty, every /// ref is a candidate. From the resulting candidates, any ref matching an /// `exclude` pattern is then dropped. fn ref_is_included(short_name: &str, include: &GlobSet, exclude: &GlobSet) -> bool { let included = include.is_empty() || include.is_match(short_name); included && !exclude.is_match(short_name) } /// 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. /// /// Branches whose short name doesn't pass `git_ui.include`/`exclude` (see /// [`ref_is_included`]) are skipped entirely. fn collect_branch_entries( repo: &gix::Repository, default_branch: &str, include: &GlobSet, exclude: &GlobSet, ) -> 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(); if !ref_is_included(&short_name, include, exclude) { continue; } 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. /// /// Refs that don't pass `git_ui.include`/`exclude` (see [`ref_is_included`]) /// are omitted. fn build_ref_labels( repo: &gix::Repository, include: &GlobSet, exclude: &GlobSet, ) -> 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/") { if !ref_is_included(label, include, exclude) { continue; } RefBadge { label: label.to_string(), kind: RefBadgeKind::Tag, } } else if let Some(label) = name.strip_prefix("refs/heads/") { if !ref_is_included(label, include, exclude) { continue; } 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. /// /// Refs that don't pass `git_ui.include`/`exclude` (see [`ref_is_included`]) /// are omitted. fn collect_refs( repo: &gix::Repository, include: &GlobSet, exclude: &GlobSet, ) -> 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(); if !ref_is_included(&short_name, include, exclude) { continue; } 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(); if !ref_is_included(&short_name, include, exclude) { continue; } 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 ───────────────────────────────────────────────────────── /// Clone `source` as a bare repository at `dest`, then prune it down to the /// branches/tags allowed by `git_ui.include`/`git_ui.exclude` before enabling /// the dumb HTTP transport. /// /// `default_branch` is used to pick a sane `HEAD` for the bare clone if the /// branch it previously pointed to got filtered out. async fn export_bare_clone( source: &Path, dest: &Path, default_branch: &str, include: &GlobSet, exclude: &GlobSet, ) -> 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}" )); } prune_excluded_refs(dest, default_branch, include, exclude).await?; // 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(()) } /// Delete every branch/tag in the bare clone at `dest` that doesn't pass /// `git_ui.include`/`git_ui.exclude` (see [`ref_is_included`]), then run /// `git gc --prune=now` so the excluded history isn't merely unlisted but /// actually removed from what dumb HTTP ends up serving from disk. /// /// A no-op (skips even the ref scan) when both `include` and `exclude` are /// empty, which is the common case. async fn prune_excluded_refs( dest: &Path, default_branch: &str, include: &GlobSet, exclude: &GlobSet, ) -> Result<()> { use tokio::process::Command; if include.is_empty() && exclude.is_empty() { return Ok(()); } let output = Command::new("git") .arg("-C") .arg(dest) .arg("for-each-ref") .arg("--format=%(refname)") .arg("refs/heads") .arg("refs/tags") .output() .await .into_diagnostic()?; if !output.status.success() { warn!("git for-each-ref failed; skipping include/exclude pruning of repository.git"); return Ok(()); } let refnames = String::from_utf8_lossy(&output.stdout); let mut removed_any = false; let mut kept_branches: Vec<String> = Vec::new(); for refname in refnames.lines() { let short_name = match refname .strip_prefix("refs/heads/") .or_else(|| refname.strip_prefix("refs/tags/")) { Some(s) => s, None => continue, }; if ref_is_included(short_name, include, exclude) { if refname.starts_with("refs/heads/") { kept_branches.push(short_name.to_string()); } continue; } let status = Command::new("git") .arg("-C") .arg(dest) .arg("update-ref") .arg("-d") .arg(refname) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .await .into_diagnostic()?; if status.success() { removed_any = true; } else { warn!("failed to delete excluded ref '{refname}' from repository.git"); } } if !removed_any { return Ok(()); } // `HEAD` may have pointed at a branch we just deleted; repoint it at the // configured default branch if it survived the filter, or otherwise at // whatever branch is left, so `git clone` of `repository.git` still // checks out something sensible. let new_head = if kept_branches.iter().any(|b| b == default_branch) { Some(default_branch.to_string()) } else if let Some(first) = kept_branches.first() { warn!( "git_ui: default branch '{}' excluded from repository.git; using '{}' for HEAD instead", default_branch, first ); Some(first.clone()) } else { warn!( "git_ui: include/exclude filtered out every branch; repository.git will have no usable HEAD" ); None }; if let Some(branch) = new_head { let status = Command::new("git") .arg("-C") .arg(dest) .arg("symbolic-ref") .arg("HEAD") .arg(format!("refs/heads/{branch}")) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .await .into_diagnostic()?; if !status.success() { warn!("failed to repoint HEAD in repository.git after pruning excluded refs"); } } // Physically remove the now-unreachable objects so excluded branches/tags // aren't just unlisted but actually absent from the published repo. let status = Command::new("git") .arg("-C") .arg(dest) .arg("gc") .arg("--prune=now") .arg("--quiet") .status() .await .into_diagnostic()?; if !status.success() { warn!( "git gc --prune=now failed on repository.git; excluded objects may still be present on disk" ); } Ok(()) } // ── 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) } }