Skip to main content

abbaye/
git_ui.rs

1//! Generates a static git repository web UI and a clonable bare clone.
2//!
3//! Produces:
4//! - `<output>/repository/`      - one HTML log page per branch, refs page,
5//!                                  per-commit detail pages
6//! - `<output>/repository.git/`  - bare clone suitable for dumb HTTP serving
7//!
8//! The branch named by `git_ui.default_branch` is rendered to `index.html`;
9//! every other branch gets `<sanitized-name>.html`.
10//!
11//! It also generates `<output>/repository/browse/<hash>/` - a full recursive
12//! static file tree browser with server-side syntax highlighting (via syntect),
13//! generated for every branch tip and every tagged commit.
14//!
15//! This is a site-level step called once from `main.rs`, not a per-version Builder.
16
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19use std::time::Duration;
20
21use chrono::{DateTime, Utc};
22use gix::bstr::ByteSlice;
23use globset::{Glob, GlobSet, GlobSetBuilder};
24use indicatif::{ProgressBar, ProgressStyle};
25use miette::{IntoDiagnostic, Result};
26
27use crate::cli::{CYAN, GREEN, RED, RESET};
28use serde::Serialize;
29use tera::{Context, Tera};
30use tracing::warn;
31
32use crate::config::{AbbayeConfig, GitUiConfig};
33
34// ── Template sources ──────────────────────────────────────────────────────────
35
36pub const TEMPLATE_GIT_LOG: &str = include_str!("templates/git_log.html.j2");
37pub const TEMPLATE_GIT_COMMIT: &str = include_str!("templates/git_commit.html.j2");
38pub const TEMPLATE_GIT_REFS: &str = include_str!("templates/git_refs.html.j2");
39pub const TEMPLATE_GIT_TREE: &str = include_str!("templates/git_tree.html.j2");
40pub const TEMPLATE_GIT_BLOB: &str = include_str!("templates/git_blob.html.j2");
41
42// ── Template-facing data structures ──────────────────────────────────────────
43
44#[derive(Clone, Serialize)]
45struct CommitParent {
46    hash: String,
47    hash_short: String,
48}
49
50/// A single commit's metadata, passed to Tera templates.
51/// `Clone` is required so commits can be deduplicated across branches.
52#[derive(Clone, Serialize)]
53struct CommitInfo {
54    hash: String,
55    hash_short: String,
56    author_name: String,
57    author_email: String,
58    /// ISO-8601 timestamp for `<time datetime="">`.
59    date_iso: String,
60    /// Short date for the log table (YYYY-MM-DD).
61    date: String,
62    /// Date + time for the commit detail page.
63    datetime_display: String,
64    /// First line of the commit message.
65    subject: String,
66    /// Everything after the blank line separator, if present.
67    body: Option<String>,
68    parents: Vec<CommitParent>,
69    /// Tags and branches whose tip is exactly this commit.
70    ref_badges: Vec<RefBadge>,
71}
72
73#[derive(Serialize)]
74struct RefInfo {
75    name: String,
76    short_name: String,
77    hash: String,
78    hash_short: String,
79}
80
81/// The visual kind of a single unified-diff line.
82/// Serialises as lowercase for use as a CSS modifier class.
83#[derive(Serialize)]
84#[serde(rename_all = "lowercase")]
85enum DiffLineKind {
86    Header,  // diff --git, index, ---, +++, mode lines
87    Hunk,    // @@ -a,b +c,d @@
88    Added,   // lines beginning with +
89    Removed, // lines beginning with -
90    Context, // unchanged surrounding lines
91}
92
93#[derive(Serialize)]
94struct DiffLine {
95    kind: DiffLineKind,
96    content: String,
97}
98
99#[derive(Serialize)]
100struct ChangedFile {
101    path: String,
102    /// One of: added, deleted, modified, renamed, copied, changed.
103    status: String,
104    diff_lines: Vec<DiffLine>,
105}
106
107/// Discriminates the two kinds of ref badge shown on log pages.
108/// Serialises as lowercase (`"tag"` / `"branch"`) for use as a CSS modifier class.
109#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
110#[serde(rename_all = "lowercase")]
111enum RefBadgeKind {
112    /// Derived `Ord`: `Tag < Branch`, so tags sort before branches.
113    Tag,
114    Branch,
115}
116
117/// A ref badge shown next to a commit hash on log pages.
118#[derive(Clone, Serialize)]
119struct RefBadge {
120    label: String,
121    kind: RefBadgeKind,
122}
123
124/// One level in the breadcrumb navigation on tree and blob pages.
125#[derive(Serialize)]
126struct Crumb {
127    name: String,
128    /// Relative link to this directory's `index.html`. `None` for the last
129    /// (current) segment - rendered as plain text, not a link.
130    url: Option<String>,
131}
132
133/// Kind of an entry in a directory listing. Serialises as lowercase.
134#[derive(Serialize)]
135#[serde(rename_all = "lowercase")]
136enum TreeEntryKind {
137    Tree,
138    Blob,
139}
140
141/// One row in a directory listing.
142#[derive(Serialize)]
143struct TreeEntry {
144    name: String,
145    kind: TreeEntryKind,
146    /// Relative URL from the current tree page to this entry's page.
147    url: String,
148}
149
150/// One entry in the branch-switcher nav rendered on every log page.
151#[derive(Serialize)]
152struct BranchNav {
153    short_name: String,
154    /// HTML filename for this branch ("index.html" or "<name>.html").
155    filename: String,
156    is_current: bool,
157}
158
159// ── Internal branch descriptor (never serialized) ────────────────────────────
160
161#[derive(Clone)]
162struct BranchEntry {
163    short_name: String,
164    /// Output filename: "index.html" for the default branch, else a sanitized name.
165    filename: String,
166    tip: gix::ObjectId,
167}
168
169// ── Public entry point ────────────────────────────────────────────────────────
170
171/// Generate the repository web UI and bare clone into `config.site.output_dir`.
172/// Shared spinner style - matches the builder spinners in `site.rs`.
173fn make_spinner(label: &str) -> ProgressBar {
174    let pb = ProgressBar::new_spinner();
175    pb.set_style(
176        ProgressStyle::with_template("{spinner:.bold} {prefix} {msg}")
177            .expect("valid template")
178            .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "),
179    );
180    pb.set_prefix(format!("{CYAN}[{label}]{RESET}"));
181    pb.enable_steady_tick(Duration::from_millis(100));
182    pb
183}
184
185pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Result<()> {
186    let output_dir = &config.site.output_dir;
187    let ui_dir = output_dir.join("repository");
188    let bare_dir = output_dir.join("repository.git");
189
190    tokio::fs::create_dir_all(&ui_dir).await.into_diagnostic()?;
191    tokio::fs::create_dir_all(ui_dir.join("commit"))
192        .await
193        .into_diagnostic()?;
194
195    let repo_path: PathBuf = git_cfg
196        .repo_path
197        .clone()
198        .unwrap_or_else(|| PathBuf::from("."));
199
200    let max_commits = git_cfg.max_commits;
201    let default_branch = git_cfg.default_branch.clone();
202    let include = build_globset(&git_cfg.include)?;
203    let exclude = build_globset(&git_cfg.exclude)?;
204    let repo_path_clone = repo_path.clone();
205
206    // `include`/`exclude`/`default_branch` are moved into the `spawn_blocking`
207    // closure below, so keep clones around for `export_bare_clone`, which
208    // needs the same filters to decide what to publish in `repository.git`.
209    let bare_default_branch = default_branch.clone();
210    let bare_include = include.clone();
211    let bare_exclude = exclude.clone();
212
213    // Compute clone URL before the blocking task so we can pass it into the
214    // browse page generator without re-deriving it.
215    let clone_url = generate_clone_command(config, git_cfg);
216
217    // ── All gix work happens inside one blocking task (Repository is !Send) ───
218    //
219    // Returns:
220    //   branch_pages     - (short_name, filename, commits) per branch
221    //   unique_commits   - all commits across all branches, deduplicated
222    //   tags / ref_branches - for refs.html
223    //   browse_revisions - (hex_hash, ObjectId) for every branch tip + tag tip
224    let (branch_pages, unique_commits, tags, ref_branches, browse_revisions) =
225        tokio::task::spawn_blocking(move || -> Result<_> {
226            let repo = match gix::discover(&repo_path_clone) {
227                Ok(r) => r,
228                Err(e) => {
229                    warn!(
230                        "git_ui: could not open repository at {}: {e}",
231                        repo_path_clone.display()
232                    );
233                    return Ok((vec![], vec![], vec![], vec![], vec![]));
234                }
235            };
236
237            let branches = collect_branch_entries(&repo, &default_branch, &include, &exclude)?;
238            let (tags, ref_branches) = collect_refs(&repo, &include, &exclude)?;
239            let ref_labels = build_ref_labels(&repo, &include, &exclude)?;
240
241            // Walk commits per branch; collect unique commits for detail pages.
242            let mut unique_map: HashMap<String, CommitInfo> = HashMap::new();
243            let mut branch_pages: Vec<(String, String, Vec<CommitInfo>)> = Vec::new();
244
245            for branch in &branches {
246                let commits = collect_commits(&repo, branch.tip, max_commits, &ref_labels)?;
247                for c in &commits {
248                    unique_map
249                        .entry(c.hash.clone())
250                        .or_insert_with(|| c.clone());
251                }
252                branch_pages.push((branch.short_name.clone(), branch.filename.clone(), commits));
253            }
254
255            let unique_commits: Vec<CommitInfo> = unique_map.into_values().collect();
256
257            // Collect revisions for the tree browser: branch tips + tag tips.
258            let mut seen: HashMap<String, gix::ObjectId> = HashMap::new();
259            for branch in &branches {
260                seen.insert(branch.tip.to_string(), branch.tip);
261            }
262            let refs_platform = repo.references().into_diagnostic()?;
263            for reference in refs_platform.all().into_diagnostic()? {
264                let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
265                let name = reference.name().as_bstr().to_str_lossy().into_owned();
266                if !name.starts_with("refs/tags/") {
267                    continue;
268                }
269                let short_name = name.trim_start_matches("refs/tags/");
270                if !ref_is_included(short_name, &include, &exclude) {
271                    continue;
272                }
273                if let Ok(id) = reference.peel_to_id() {
274                    let hash = id.to_string();
275                    seen.entry(hash).or_insert_with(|| id.detach());
276                }
277            }
278            let browse_revisions: Vec<(String, gix::ObjectId)> = seen.into_iter().collect();
279
280            Ok((
281                branch_pages,
282                unique_commits,
283                tags,
284                ref_branches,
285                browse_revisions,
286            ))
287        })
288        .await
289        .into_diagnostic()??;
290
291    if branch_pages.is_empty() {
292        // Nothing to render (empty repo or no branches).
293        return Ok(());
294    }
295
296    let pb = make_spinner("git ui");
297
298    // ── Bare clone + dumb HTTP setup ──────────────────────────────────────────
299    pb.set_message("cloning bare repository…");
300    if let Err(e) = export_bare_clone(
301        &repo_path,
302        &bare_dir,
303        &bare_default_branch,
304        &bare_include,
305        &bare_exclude,
306    )
307    .await
308    {
309        pb.finish_with_message(format!("{RED}\u{2717} failed:{RESET} {e}"));
310        return Err(e);
311    }
312
313    // ── Tera setup ────────────────────────────────────────────────────────────
314    let mut tera = Tera::default();
315    let theme_path = PathBuf::from(".abbaye").join("theme");
316    for (name, builtin) in [
317        ("git_log.html", TEMPLATE_GIT_LOG),
318        ("git_commit.html", TEMPLATE_GIT_COMMIT),
319        ("git_refs.html", TEMPLATE_GIT_REFS),
320    ] {
321        let override_path = theme_path.join(format!("{name}.j2"));
322        if override_path.is_file() {
323            tera.add_template_file(&override_path, Some(name))
324                .into_diagnostic()?;
325        } else {
326            tera.add_raw_template(name, builtin).into_diagnostic()?;
327        }
328    }
329    crate::site::load_extra_theme_templates(
330        &mut tera,
331        &theme_path,
332        &["git_log.html", "git_commit.html", "git_refs.html"],
333    )?;
334
335    // ── Branch switcher nav (shared across all log pages) ─────────────────────
336    //
337    // Order: the default branch (index.html) first, then alphabetical.
338    let mut nav_entries: Vec<(String, String)> = branch_pages
339        .iter()
340        .map(|(name, file, _)| (name.clone(), file.clone()))
341        .collect();
342    nav_entries.sort_by(|(na, fa), (nb, fb)| {
343        let a_default = fa == "index.html";
344        let b_default = fb == "index.html";
345        b_default.cmp(&a_default).then(na.cmp(nb))
346    });
347
348    // ── Render one log page per branch ────────────────────────────────────────
349    pb.set_message("rendering log pages…");
350    for (short_name, filename, commits) in &branch_pages {
351        let truncated = commits.len() >= max_commits;
352
353        let branch_nav: Vec<BranchNav> = nav_entries
354            .iter()
355            .map(|(bn, bf)| BranchNav {
356                short_name: bn.clone(),
357                filename: bf.clone(),
358                is_current: bn == short_name,
359            })
360            .collect();
361
362        let mut ctx = Context::new();
363        ctx.insert("project_name", &config.site.name);
364        ctx.insert("lang", &config.site.lang);
365        ctx.insert("clone_url", &clone_url);
366        ctx.insert("current_branch", short_name);
367        ctx.insert("branch_nav", &branch_nav);
368        ctx.insert("commits", commits);
369        ctx.insert("truncated", &truncated);
370        ctx.insert("root_path", "../");
371
372        let html = tera.render("git_log.html", &ctx).into_diagnostic()?;
373        tokio::fs::write(ui_dir.join(filename), html)
374            .await
375            .into_diagnostic()?;
376    }
377
378    // ── Render refs page ──────────────────────────────────────────────────────
379    {
380        let mut ctx = Context::new();
381        ctx.insert("project_name", &config.site.name);
382        ctx.insert("lang", &config.site.lang);
383        ctx.insert("clone_url", &clone_url);
384        ctx.insert("tags", &tags);
385        ctx.insert("branches", &ref_branches);
386        ctx.insert("root_path", "../");
387
388        let html = tera.render("git_refs.html", &ctx).into_diagnostic()?;
389        tokio::fs::write(ui_dir.join("refs.html"), html)
390            .await
391            .into_diagnostic()?;
392    }
393
394    // ── Render per-commit detail pages ────────────────────────────────────────
395    pb.set_message(format!("rendering {} commit pages…", unique_commits.len()));
396    for commit_info in &unique_commits {
397        let changed_files = get_changed_files(&commit_info.hash).await?;
398        let has_browse = !commit_info.ref_badges.is_empty();
399
400        let mut ctx = Context::new();
401        ctx.insert("project_name", &config.site.name);
402        ctx.insert("lang", &config.site.lang);
403        ctx.insert("clone_url", &clone_url);
404        ctx.insert("commit", commit_info);
405        ctx.insert("changed_files", &changed_files);
406        ctx.insert("has_browse", &has_browse);
407        ctx.insert("root_path", "../../");
408
409        let html = tera.render("git_commit.html", &ctx).into_diagnostic()?;
410        tokio::fs::write(
411            ui_dir
412                .join("commit")
413                .join(format!("{}.html", commit_info.hash)),
414            html,
415        )
416        .await
417        .into_diagnostic()?;
418    }
419
420    // ── Tree browser (browse/<hash>/) ─────────────────────────────────────────
421    if !browse_revisions.is_empty() {
422        pb.set_message(format!(
423            "building browse pages for {} revision(s)…",
424            browse_revisions.len()
425        ));
426        let browse_dir = ui_dir.join("browse");
427        tokio::fs::create_dir_all(&browse_dir)
428            .await
429            .into_diagnostic()?;
430
431        let project_name = config.site.name.clone();
432        let lang = config.site.lang.clone();
433        let clone_url_browse = clone_url.clone();
434        let theme_path = PathBuf::from(".abbaye").join("theme");
435        let repo_path_browse = repo_path.clone();
436
437        tokio::task::spawn_blocking(move || {
438            build_browse_pages(
439                &browse_revisions,
440                &browse_dir,
441                &repo_path_browse,
442                &theme_path,
443                &project_name,
444                &lang,
445                &clone_url_browse,
446            )
447        })
448        .await
449        .into_diagnostic()??
450    }
451
452    pb.finish_with_message(format!("{GREEN}\u{2713} done{RESET}"));
453    Ok(())
454}
455
456pub fn generate_clone_command(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Option<String> {
457    let clone_url: Option<String> = git_cfg.clone_url.clone().or_else(|| {
458        config.site.base_url.as_ref().map(|base| {
459            format!(
460                "{}/repository.git {}",
461                base.trim_end_matches('/'),
462                if config.site.name.contains(" ") {
463                    format!("'{}'", config.site.name)
464                } else {
465                    config.site.name.clone()
466                }
467            )
468        })
469    });
470    clone_url
471}
472
473// ── Git data collection ───────────────────────────────────────────────────────
474
475/// Compile a list of glob patterns (as written in `git_ui.exclude`/`include`)
476/// into a [`GlobSet`]. An empty pattern list compiles to an empty `GlobSet`,
477/// which matches nothing.
478fn build_globset(patterns: &[String]) -> Result<GlobSet> {
479    let mut builder = GlobSetBuilder::new();
480    for pattern in patterns {
481        let glob = Glob::new(pattern)
482            .map_err(|e| miette::miette!("git_ui: invalid glob pattern '{pattern}': {e}"))?;
483        builder.add(glob);
484    }
485    builder.build().into_diagnostic()
486}
487
488/// Decide whether a ref (by its short name, e.g. `"main"` or `"v1.0.0"`)
489/// should appear in the generated UI, per `git_ui.exclude`/`git_ui.include`.
490///
491/// When `include` is non-empty, it acts as an allowlist: only refs matching
492/// at least one `include` pattern are kept. When `include` is empty, every
493/// ref is a candidate. From the resulting candidates, any ref matching an
494/// `exclude` pattern is then dropped.
495fn ref_is_included(short_name: &str, include: &GlobSet, exclude: &GlobSet) -> bool {
496    let included = include.is_empty() || include.is_match(short_name);
497    included && !exclude.is_match(short_name)
498}
499
500/// Collect all local branches and assign output filenames.
501///
502/// The branch whose short name matches `default_branch` gets `"index.html"`.
503/// Every other branch gets `"<sanitized-short-name>.html"` where `/` is
504/// replaced by `-`.
505///
506/// If no branch matches `default_branch`, the first branch (alphabetically)
507/// receives `"index.html"` and a warning is emitted.
508///
509/// Branches whose short name doesn't pass `git_ui.include`/`exclude` (see
510/// [`ref_is_included`]) are skipped entirely.
511fn collect_branch_entries(
512    repo: &gix::Repository,
513    default_branch: &str,
514    include: &GlobSet,
515    exclude: &GlobSet,
516) -> Result<Vec<BranchEntry>> {
517    let mut entries: Vec<BranchEntry> = Vec::new();
518
519    let refs_platform = repo.references().into_diagnostic()?;
520    for reference in refs_platform.all().into_diagnostic()? {
521        let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
522        let name = reference.name().as_bstr().to_str_lossy().into_owned();
523
524        if !name.starts_with("refs/heads/") {
525            continue;
526        }
527
528        let short_name = name.trim_start_matches("refs/heads/").to_string();
529        if !ref_is_included(&short_name, include, exclude) {
530            continue;
531        }
532        let tip = match reference.peel_to_id() {
533            Ok(id) => id.detach(),
534            Err(_) => continue,
535        };
536
537        // Tentative filename; replaced for the default branch below.
538        let filename = format!("{}.html", short_name.replace('/', "-"));
539        entries.push(BranchEntry {
540            short_name,
541            filename,
542            tip,
543        });
544    }
545
546    // Stable, predictable page order.
547    entries.sort_by(|a, b| a.short_name.cmp(&b.short_name));
548
549    // Assign index.html to the configured default branch.
550    if let Some(e) = entries.iter_mut().find(|e| e.short_name == default_branch) {
551        e.filename = "index.html".to_string();
552    } else if let Some(first) = entries.first_mut() {
553        warn!(
554            "git_ui: default branch '{}' not found; using '{}' as index.html",
555            default_branch, first.short_name
556        );
557        first.filename = "index.html".to_string();
558    }
559
560    Ok(entries)
561}
562
563/// Build a map from commit hash (hex string) to the ref badges pointing at it.
564/// Tags come before branches within each entry; both are sorted alphabetically.
565///
566/// Refs that don't pass `git_ui.include`/`exclude` (see [`ref_is_included`])
567/// are omitted.
568fn build_ref_labels(
569    repo: &gix::Repository,
570    include: &GlobSet,
571    exclude: &GlobSet,
572) -> Result<HashMap<String, Vec<RefBadge>>> {
573    let mut map: HashMap<String, Vec<RefBadge>> = HashMap::new();
574
575    let refs_platform = repo.references().into_diagnostic()?;
576    for reference in refs_platform.all().into_diagnostic()? {
577        let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
578        let name = reference.name().as_bstr().to_str_lossy().into_owned();
579
580        if name == "HEAD" || name.starts_with("refs/remotes/") {
581            continue;
582        }
583
584        let hash = match reference.peel_to_id() {
585            Ok(id) => id.to_string(),
586            Err(_) => continue,
587        };
588
589        let badge = if let Some(label) = name.strip_prefix("refs/tags/") {
590            if !ref_is_included(label, include, exclude) {
591                continue;
592            }
593            RefBadge {
594                label: label.to_string(),
595                kind: RefBadgeKind::Tag,
596            }
597        } else if let Some(label) = name.strip_prefix("refs/heads/") {
598            if !ref_is_included(label, include, exclude) {
599                continue;
600            }
601            RefBadge {
602                label: label.to_string(),
603                kind: RefBadgeKind::Branch,
604            }
605        } else {
606            continue;
607        };
608
609        map.entry(hash).or_default().push(badge);
610    }
611
612    // Within each entry: tags first (Tag < Branch via Ord), then alphabetical.
613    for badges in map.values_mut() {
614        badges.sort_by(|a, b| a.kind.cmp(&b.kind).then(a.label.cmp(&b.label)));
615    }
616
617    Ok(map)
618}
619
620/// Walk at most `max` commits reachable from `tip`, newest first.
621fn collect_commits(
622    repo: &gix::Repository,
623    tip: gix::ObjectId,
624    max: usize,
625    ref_labels: &HashMap<String, Vec<RefBadge>>,
626) -> Result<Vec<CommitInfo>> {
627    let walk = repo.rev_walk([tip]).all().into_diagnostic()?;
628    let mut commits = Vec::new();
629
630    for info in walk.take(max) {
631        let info = info.into_diagnostic()?;
632        let id = info.id;
633
634        let object = repo.find_object(id).into_diagnostic()?;
635        let commit = object.into_commit();
636        let decoded = commit.decode().into_diagnostic()?;
637
638        let author = decoded.author().into_diagnostic()?;
639        let author_name = author.name.to_str_lossy().into_owned();
640        let author_email = author.email.to_str_lossy().into_owned();
641        let unix_secs: i64 = author
642            .time
643            .split_whitespace()
644            .next()
645            .and_then(|s| s.parse().ok())
646            .unwrap_or(0);
647
648        let dt: DateTime<Utc> = DateTime::from_timestamp(unix_secs, 0).unwrap_or_default();
649        let date = dt.format("%Y-%m-%d").to_string();
650        let date_iso = dt.to_rfc3339();
651        let datetime_display = dt.format("%Y-%m-%d %H:%M UTC").to_string();
652
653        let raw_msg = decoded.message.to_str_lossy();
654        let (subject, body) = parse_message(&raw_msg);
655
656        let hash = id.to_string();
657        let hash_short = hash[..7].to_string();
658
659        let parents = info
660            .parent_ids
661            .iter()
662            .map(|p| {
663                let h = p.to_string();
664                let hs = h[..7].to_string();
665                CommitParent {
666                    hash: h,
667                    hash_short: hs,
668                }
669            })
670            .collect();
671
672        let ref_badges = ref_labels.get(&hash).cloned().unwrap_or_default();
673
674        commits.push(CommitInfo {
675            hash,
676            hash_short,
677            author_name,
678            author_email,
679            date,
680            date_iso,
681            datetime_display,
682            subject,
683            body,
684            parents,
685            ref_badges,
686        });
687    }
688
689    Ok(commits)
690}
691
692/// Collect tags and branches for the refs overview page.
693///
694/// Refs that don't pass `git_ui.include`/`exclude` (see [`ref_is_included`])
695/// are omitted.
696fn collect_refs(
697    repo: &gix::Repository,
698    include: &GlobSet,
699    exclude: &GlobSet,
700) -> Result<(Vec<RefInfo>, Vec<RefInfo>)> {
701    let mut tags: Vec<RefInfo> = Vec::new();
702    let mut branches: Vec<RefInfo> = Vec::new();
703
704    let refs_platform = repo.references().into_diagnostic()?;
705    for reference in refs_platform.all().into_diagnostic()? {
706        let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
707        let name = reference.name().as_bstr().to_str_lossy().into_owned();
708
709        if name.starts_with("refs/remotes/") || name == "HEAD" {
710            continue;
711        }
712
713        let hash = match reference.peel_to_id() {
714            Ok(id) => id.to_string(),
715            Err(_) => continue,
716        };
717        let hash_short = hash[..7.min(hash.len())].to_string();
718
719        if name.starts_with("refs/tags/") {
720            let short_name = name.trim_start_matches("refs/tags/").to_string();
721            if !ref_is_included(&short_name, include, exclude) {
722                continue;
723            }
724            tags.push(RefInfo {
725                name,
726                short_name,
727                hash,
728                hash_short,
729            });
730        } else if name.starts_with("refs/heads/") {
731            let short_name = name.trim_start_matches("refs/heads/").to_string();
732            if !ref_is_included(&short_name, include, exclude) {
733                continue;
734            }
735            branches.push(RefInfo {
736                name,
737                short_name,
738                hash,
739                hash_short,
740            });
741        }
742    }
743
744    // Tags: newest first (reverse-lexicographic ≈ version order for semver tags).
745    tags.sort_by(|a, b| b.name.cmp(&a.name));
746    branches.sort_by(|a, b| a.name.cmp(&b.name));
747
748    Ok((tags, branches))
749}
750
751// ── Changed files via git CLI ─────────────────────────────────────────────────
752
753async fn get_changed_files(commit_hash: &str) -> Result<Vec<ChangedFile>> {
754    let output = tokio::process::Command::new("git")
755        .args(["diff-tree", "-p", "--no-commit-id", "-r", commit_hash])
756        .output()
757        .await
758        .into_diagnostic()?;
759
760    if !output.status.success() {
761        return Ok(vec![]);
762    }
763
764    Ok(parse_diff_output(&String::from_utf8_lossy(&output.stdout)))
765}
766
767/// Parse the output of `git diff-tree -p` into per-file [`ChangedFile`] entries.
768///
769/// Each file section starts with a `diff --git a/<path> b/<path>` line.
770/// Status is inferred from subsequent mode/rename headers.
771/// Diff line kinds are determined by their leading character, with header
772/// lines (`--- `, `+++ `) distinguished from content lines (`-`, `+`) by
773/// the mandatory space that follows the three-character marker.
774fn parse_diff_output(text: &str) -> Vec<ChangedFile> {
775    let mut files: Vec<ChangedFile> = Vec::new();
776    let mut cur_lines: Vec<DiffLine> = Vec::new();
777    let mut cur_path = String::new();
778    let mut cur_status = "modified";
779
780    let push_line = |lines: &mut Vec<DiffLine>, kind, content: &str| {
781        lines.push(DiffLine {
782            kind,
783            content: content.to_string(),
784        });
785    };
786
787    for line in text.lines() {
788        if line.starts_with("diff --git ") {
789            if !cur_path.is_empty() {
790                files.push(ChangedFile {
791                    path: cur_path.clone(),
792                    status: cur_status.to_string(),
793                    diff_lines: std::mem::take(&mut cur_lines),
794                });
795            }
796            // Extract the destination path from the trailing " b/<path>" token.
797            // rsplit_once handles paths that contain spaces.
798            cur_path = line
799                .rsplit_once(" b/")
800                .map(|(_, p)| p.to_string())
801                .unwrap_or_default();
802            cur_status = "modified";
803            push_line(&mut cur_lines, DiffLineKind::Header, line);
804        } else if line.starts_with("new file mode") {
805            cur_status = "added";
806            push_line(&mut cur_lines, DiffLineKind::Header, line);
807        } else if line.starts_with("deleted file mode") {
808            cur_status = "deleted";
809            push_line(&mut cur_lines, DiffLineKind::Header, line);
810        } else if line.starts_with("rename from") || line.starts_with("rename to") {
811            cur_status = "renamed";
812            push_line(&mut cur_lines, DiffLineKind::Header, line);
813        } else if line.starts_with("similarity index")
814            || line.starts_with("copy from")
815            || line.starts_with("copy to")
816            || line.starts_with("index ")
817            || line.starts_with("--- ")   // file header, not a removed line
818            || line.starts_with("+++ ")   // file header, not an added line
819            || line.starts_with("Binary files")
820            || line.starts_with('\\')
821        {
822            push_line(&mut cur_lines, DiffLineKind::Header, line);
823        } else if line.starts_with("@@") {
824            push_line(&mut cur_lines, DiffLineKind::Hunk, line);
825        } else if line.starts_with('+') {
826            push_line(&mut cur_lines, DiffLineKind::Added, line);
827        } else if line.starts_with('-') {
828            push_line(&mut cur_lines, DiffLineKind::Removed, line);
829        } else {
830            push_line(&mut cur_lines, DiffLineKind::Context, line);
831        }
832    }
833
834    if !cur_path.is_empty() {
835        files.push(ChangedFile {
836            path: cur_path,
837            status: cur_status.to_string(),
838            diff_lines: cur_lines,
839        });
840    }
841
842    files
843}
844
845// ── Bare clone export ─────────────────────────────────────────────────────────
846
847/// Clone `source` as a bare repository at `dest`, then prune it down to the
848/// branches/tags allowed by `git_ui.include`/`git_ui.exclude` before enabling
849/// the dumb HTTP transport.
850///
851/// `default_branch` is used to pick a sane `HEAD` for the bare clone if the
852/// branch it previously pointed to got filtered out.
853async fn export_bare_clone(
854    source: &Path,
855    dest: &Path,
856    default_branch: &str,
857    include: &GlobSet,
858    exclude: &GlobSet,
859) -> Result<()> {
860    use tokio::process::Command;
861
862    if dest.exists() {
863        tokio::fs::remove_dir_all(dest).await.into_diagnostic()?;
864    }
865
866    let status = Command::new("git")
867        .arg("clone")
868        .arg("--bare")
869        .arg(source)
870        .arg(dest)
871        .stdout(std::process::Stdio::null())
872        .stderr(std::process::Stdio::null())
873        .status()
874        .await
875        .into_diagnostic()?;
876
877    if !status.success() {
878        return Err(miette::miette!(
879            "git clone --bare failed with exit status {status}"
880        ));
881    }
882
883    prune_excluded_refs(dest, default_branch, include, exclude).await?;
884
885    // Enable dumb HTTP transport so the bare repo is clonable over plain HTTPS.
886    let status = Command::new("git")
887        .arg("-C")
888        .arg(dest)
889        .arg("update-server-info")
890        .stdout(std::process::Stdio::null())
891        .stderr(std::process::Stdio::null())
892        .status()
893        .await
894        .into_diagnostic()?;
895
896    if !status.success() {
897        warn!("git update-server-info failed; dumb HTTP cloning may not work");
898    }
899
900    Ok(())
901}
902
903/// Delete every branch/tag in the bare clone at `dest` that doesn't pass
904/// `git_ui.include`/`git_ui.exclude` (see [`ref_is_included`]), then run
905/// `git gc --prune=now` so the excluded history isn't merely unlisted but
906/// actually removed from what dumb HTTP ends up serving from disk.
907///
908/// A no-op (skips even the ref scan) when both `include` and `exclude` are
909/// empty, which is the common case.
910async fn prune_excluded_refs(
911    dest: &Path,
912    default_branch: &str,
913    include: &GlobSet,
914    exclude: &GlobSet,
915) -> Result<()> {
916    use tokio::process::Command;
917
918    if include.is_empty() && exclude.is_empty() {
919        return Ok(());
920    }
921
922    let output = Command::new("git")
923        .arg("-C")
924        .arg(dest)
925        .arg("for-each-ref")
926        .arg("--format=%(refname)")
927        .arg("refs/heads")
928        .arg("refs/tags")
929        .output()
930        .await
931        .into_diagnostic()?;
932
933    if !output.status.success() {
934        warn!("git for-each-ref failed; skipping include/exclude pruning of repository.git");
935        return Ok(());
936    }
937
938    let refnames = String::from_utf8_lossy(&output.stdout);
939    let mut removed_any = false;
940    let mut kept_branches: Vec<String> = Vec::new();
941
942    for refname in refnames.lines() {
943        let short_name = match refname
944            .strip_prefix("refs/heads/")
945            .or_else(|| refname.strip_prefix("refs/tags/"))
946        {
947            Some(s) => s,
948            None => continue,
949        };
950
951        if ref_is_included(short_name, include, exclude) {
952            if refname.starts_with("refs/heads/") {
953                kept_branches.push(short_name.to_string());
954            }
955            continue;
956        }
957
958        let status = Command::new("git")
959            .arg("-C")
960            .arg(dest)
961            .arg("update-ref")
962            .arg("-d")
963            .arg(refname)
964            .stdout(std::process::Stdio::null())
965            .stderr(std::process::Stdio::null())
966            .status()
967            .await
968            .into_diagnostic()?;
969
970        if status.success() {
971            removed_any = true;
972        } else {
973            warn!("failed to delete excluded ref '{refname}' from repository.git");
974        }
975    }
976
977    if !removed_any {
978        return Ok(());
979    }
980
981    // `HEAD` may have pointed at a branch we just deleted; repoint it at the
982    // configured default branch if it survived the filter, or otherwise at
983    // whatever branch is left, so `git clone` of `repository.git` still
984    // checks out something sensible.
985    let new_head = if kept_branches.iter().any(|b| b == default_branch) {
986        Some(default_branch.to_string())
987    } else if let Some(first) = kept_branches.first() {
988        warn!(
989            "git_ui: default branch '{}' excluded from repository.git; using '{}' for HEAD instead",
990            default_branch, first
991        );
992        Some(first.clone())
993    } else {
994        warn!(
995            "git_ui: include/exclude filtered out every branch; repository.git will have no usable HEAD"
996        );
997        None
998    };
999
1000    if let Some(branch) = new_head {
1001        let status = Command::new("git")
1002            .arg("-C")
1003            .arg(dest)
1004            .arg("symbolic-ref")
1005            .arg("HEAD")
1006            .arg(format!("refs/heads/{branch}"))
1007            .stdout(std::process::Stdio::null())
1008            .stderr(std::process::Stdio::null())
1009            .status()
1010            .await
1011            .into_diagnostic()?;
1012
1013        if !status.success() {
1014            warn!("failed to repoint HEAD in repository.git after pruning excluded refs");
1015        }
1016    }
1017
1018    // Physically remove the now-unreachable objects so excluded branches/tags
1019    // aren't just unlisted but actually absent from the published repo.
1020    let status = Command::new("git")
1021        .arg("-C")
1022        .arg(dest)
1023        .arg("gc")
1024        .arg("--prune=now")
1025        .arg("--quiet")
1026        .status()
1027        .await
1028        .into_diagnostic()?;
1029
1030    if !status.success() {
1031        warn!(
1032            "git gc --prune=now failed on repository.git; excluded objects may still be present on disk"
1033        );
1034    }
1035
1036    Ok(())
1037}
1038
1039// ── Tree browser ──────────────────────────────────────────────────────────────────
1040
1041/// Build the full static tree browser for every revision in `revisions`.
1042///
1043/// Everything here is synchronous (gix + std::fs + syntect), intended to run
1044/// inside `tokio::task::spawn_blocking`.
1045fn build_browse_pages(
1046    revisions: &[(String, gix::ObjectId)],
1047    browse_dir: &Path, // public/repository/browse/
1048    repo_path: &Path,  // for `git cat-file blob`
1049    theme_path: &Path, // .abbaye/theme (theme overrides)
1050    project_name: &str,
1051    lang: &Option<String>,
1052    clone_url: &Option<String>,
1053) -> Result<()> {
1054    use syntect::highlighting::ThemeSet;
1055    use syntect::parsing::SyntaxSet;
1056
1057    let ss = SyntaxSet::load_defaults_newlines();
1058    let ts = ThemeSet::load_defaults();
1059    let theme = &ts.themes["InspiredGitHub"];
1060
1061    // Build a separate Tera instance for browse templates.
1062    let mut tera = Tera::default();
1063    for (name, builtin) in [
1064        ("git_tree.html", TEMPLATE_GIT_TREE),
1065        ("git_blob.html", TEMPLATE_GIT_BLOB),
1066    ] {
1067        let override_path = theme_path.join(format!("{name}.j2"));
1068        if override_path.is_file() {
1069            tera.add_template_file(&override_path, Some(name))
1070                .map_err(|e| miette::miette!("{e}"))?;
1071        } else {
1072            tera.add_raw_template(name, builtin)
1073                .map_err(|e| miette::miette!("{e}"))?;
1074        }
1075    }
1076    crate::site::load_extra_theme_templates(
1077        &mut tera,
1078        theme_path,
1079        &["git_tree.html", "git_blob.html"],
1080    )?;
1081
1082    for (hash, oid) in revisions {
1083        let rev_dir = browse_dir.join(hash);
1084        std::fs::create_dir_all(&rev_dir).into_diagnostic()?;
1085
1086        // Resolve the commit's root tree.
1087        let repo = gix::open(repo_path).into_diagnostic()?;
1088        let commit_obj = repo.find_object(*oid).into_diagnostic()?.into_commit();
1089        let decoded = commit_obj.decode().into_diagnostic()?;
1090        let tree_id = decoded.tree();
1091
1092        walk_tree_dir(
1093            repo_path,
1094            &repo,
1095            tree_id,
1096            "",
1097            hash,
1098            &rev_dir,
1099            &tera,
1100            project_name,
1101            lang,
1102            clone_url,
1103            &ss,
1104            theme,
1105        )?;
1106    }
1107
1108    Ok(())
1109}
1110
1111/// Recursively generate one `index.html` (directory listing) per tree and one
1112/// `<name>.html` per blob, rooted at `rev_dir`.
1113/// TODO: Fix clippy warning about too many arguments
1114#[allow(clippy::too_many_arguments)]
1115fn walk_tree_dir(
1116    repo_path: &Path,
1117    repo: &gix::Repository,
1118    tree_id: gix::ObjectId,
1119    dir_path: &str, // "" = root, "src", "src/utils"
1120    commit_hash: &str,
1121    rev_dir: &Path, // public/repository/browse/<hash>/
1122    tera: &Tera,
1123    project_name: &str,
1124    lang: &Option<String>,
1125    clone_url: &Option<String>,
1126    ss: &syntect::parsing::SyntaxSet,
1127    theme: &syntect::highlighting::Theme,
1128) -> Result<()> {
1129    let tree_obj = repo.find_object(tree_id).into_diagnostic()?.into_tree();
1130    let decoded = tree_obj.decode().into_diagnostic()?;
1131
1132    // Depth = number of path components in dir_path.
1133    let depth: usize = dir_path.split('/').filter(|s| !s.is_empty()).count();
1134
1135    // Public output directory for this tree's index.html.
1136    let page_dir = if dir_path.is_empty() {
1137        rev_dir.to_path_buf()
1138    } else {
1139        dir_path
1140            .split('/')
1141            .filter(|s| !s.is_empty())
1142            .fold(rev_dir.to_path_buf(), |p, c| p.join(c))
1143    };
1144    std::fs::create_dir_all(&page_dir).into_diagnostic()?;
1145
1146    let mut entries: Vec<TreeEntry> = Vec::new();
1147    // Defer recursion until after the listing page is written.
1148    let mut subdirs: Vec<(String, gix::ObjectId)> = Vec::new();
1149    let mut blobs: Vec<(String, gix::ObjectId)> = Vec::new();
1150
1151    for entry in decoded.entries.iter() {
1152        let name = entry.filename.to_str_lossy().into_owned();
1153        let oid: gix::ObjectId = entry.oid.to_owned();
1154
1155        if entry.mode.is_tree() {
1156            entries.push(TreeEntry {
1157                url: format!("{name}/index.html"),
1158                name: name.clone(),
1159                kind: TreeEntryKind::Tree,
1160            });
1161            subdirs.push((name, oid));
1162        } else {
1163            // blob, executable blob, symlink, or submodule commit
1164            entries.push(TreeEntry {
1165                url: format!("{name}.html"),
1166                name: name.clone(),
1167                kind: TreeEntryKind::Blob,
1168            });
1169            if !entry.mode.is_commit() {
1170                // Skip submodule gitlinks (they have no blob content).
1171                blobs.push((name, oid));
1172            }
1173        }
1174    }
1175
1176    // Directories first, then files; both alphabetical.
1177    entries.sort_by(|a, b| {
1178        let a_tree = matches!(a.kind, TreeEntryKind::Tree);
1179        let b_tree = matches!(b.kind, TreeEntryKind::Tree);
1180        b_tree.cmp(&a_tree).then(a.name.cmp(&b.name))
1181    });
1182
1183    // root_path: how many levels up to reach the site root (public/).
1184    // browse/<hash>/[subpath/]  =>  3 + depth levels up.
1185    let root_path = "../".repeat(3 + depth);
1186    // commit_url: link back to the commit detail page.
1187    let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2));
1188    let breadcrumbs = make_crumbs(dir_path, false, None);
1189
1190    let mut ctx = Context::new();
1191    ctx.insert("project_name", project_name);
1192    ctx.insert("lang", lang);
1193    ctx.insert("clone_url", clone_url);
1194    ctx.insert("commit_hash", commit_hash);
1195    ctx.insert("commit_hash_short", &commit_hash[..7]);
1196    ctx.insert("commit_url", &commit_url);
1197    ctx.insert("dir_path", dir_path);
1198    ctx.insert("entries", &entries);
1199    ctx.insert("breadcrumbs", &breadcrumbs);
1200    ctx.insert("root_path", &root_path);
1201
1202    let html = tera
1203        .render("git_tree.html", &ctx)
1204        .map_err(|e| miette::miette!("{e}"))?;
1205    std::fs::write(page_dir.join("index.html"), html).into_diagnostic()?;
1206
1207    // Recurse into subdirectories.
1208    for (name, oid) in subdirs {
1209        let child_path = if dir_path.is_empty() {
1210            name
1211        } else {
1212            format!("{dir_path}/{name}")
1213        };
1214        walk_tree_dir(
1215            repo_path,
1216            repo,
1217            oid,
1218            &child_path,
1219            commit_hash,
1220            rev_dir,
1221            tera,
1222            project_name,
1223            lang,
1224            clone_url,
1225            ss,
1226            theme,
1227        )?;
1228    }
1229
1230    // Render blob pages.
1231    for (name, oid) in blobs {
1232        let file_path = if dir_path.is_empty() {
1233            name.clone()
1234        } else {
1235            format!("{dir_path}/{name}")
1236        };
1237        render_blob_page(
1238            repo_path,
1239            &name,
1240            &file_path,
1241            oid,
1242            commit_hash,
1243            depth,
1244            &page_dir,
1245            tera,
1246            project_name,
1247            lang,
1248            clone_url,
1249            ss,
1250            theme,
1251        )?;
1252    }
1253
1254    Ok(())
1255}
1256
1257/// Write one syntax-highlighted blob page to `page_dir/<name>.html`.
1258#[allow(clippy::too_many_arguments)]
1259fn render_blob_page(
1260    repo_path: &Path,
1261    filename: &str,
1262    file_path: &str, // full path from repo root, e.g. "src/main.rs"
1263    oid: gix::ObjectId,
1264    commit_hash: &str,
1265    depth: usize,    // number of directory components containing the file
1266    page_dir: &Path, // output directory (same as the parent tree's page_dir)
1267    tera: &Tera,
1268    project_name: &str,
1269    lang: &Option<String>,
1270    clone_url: &Option<String>,
1271    ss: &syntect::parsing::SyntaxSet,
1272    theme: &syntect::highlighting::Theme,
1273) -> Result<()> {
1274    const MAX_BLOB_BYTES: usize = 1024 * 1024; // 1 MiB
1275
1276    // Read blob via `git cat-file blob <oid>` to avoid gix private-field access.
1277    let data: Vec<u8> = std::process::Command::new("git")
1278        .current_dir(repo_path)
1279        .args(["cat-file", "blob", &oid.to_string()])
1280        .output()
1281        .map(|o| o.stdout)
1282        .unwrap_or_default();
1283
1284    let is_binary = data[..data.len().min(8192)].contains(&0u8);
1285    let too_large = data.len() > MAX_BLOB_BYTES;
1286
1287    let content_html: Option<String> = if is_binary || too_large || data.is_empty() {
1288        None
1289    } else {
1290        let text = String::from_utf8_lossy(&data);
1291        let ext = std::path::Path::new(filename)
1292            .extension()
1293            .and_then(|s| s.to_str())
1294            .unwrap_or("");
1295        let syntax = ss
1296            .find_syntax_by_extension(ext)
1297            .or_else(|| {
1298                text.lines()
1299                    .next()
1300                    .and_then(|l| ss.find_syntax_by_first_line(l))
1301            })
1302            .unwrap_or_else(|| ss.find_syntax_plain_text());
1303        Some(
1304            syntect::html::highlighted_html_for_string(&text, ss, syntax, theme)
1305                .unwrap_or_else(|_| format!("<pre>{}</pre>", escape_html(&text))),
1306        )
1307    };
1308
1309    let root_path = "../".repeat(3 + depth);
1310    let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2));
1311    let breadcrumbs = make_crumbs(
1312        std::path::Path::new(file_path)
1313            .parent()
1314            .and_then(|p| p.to_str())
1315            .unwrap_or(""),
1316        true,
1317        Some(filename),
1318    );
1319
1320    let mut ctx = Context::new();
1321    ctx.insert("project_name", project_name);
1322    ctx.insert("lang", lang);
1323    ctx.insert("clone_url", clone_url);
1324    ctx.insert("commit_hash", commit_hash);
1325    ctx.insert("commit_hash_short", &commit_hash[..7]);
1326    ctx.insert("commit_url", &commit_url);
1327    ctx.insert("file_path", file_path);
1328    ctx.insert("filename", filename);
1329    ctx.insert("breadcrumbs", &breadcrumbs);
1330    ctx.insert("content_html", &content_html);
1331    ctx.insert("is_binary", &is_binary);
1332    ctx.insert("too_large", &too_large);
1333    ctx.insert("size", &data.len());
1334    ctx.insert("root_path", &root_path);
1335
1336    let html = tera
1337        .render("git_blob.html", &ctx)
1338        .map_err(|e| miette::miette!("{e}"))?;
1339    std::fs::write(page_dir.join(format!("{filename}.html")), html).into_diagnostic()?;
1340
1341    Ok(())
1342}
1343
1344/// Build breadcrumb entries for a tree or blob page.
1345///
1346/// `dir_path` is the path to the containing directory (e.g. `"src"` for
1347/// `src/main.rs`).  `depth` is derived from `dir_path` internally.
1348fn make_crumbs(dir_path: &str, is_blob: bool, filename: Option<&str>) -> Vec<Crumb> {
1349    let parts: Vec<&str> = dir_path.split('/').filter(|s| !s.is_empty()).collect();
1350    let depth = parts.len();
1351    let mut crumbs = Vec::new();
1352
1353    // Root crumb (“~”).
1354    let root_url = if depth == 0 && !is_blob {
1355        None // we ARE the root dir listing
1356    } else {
1357        Some(format!("{}index.html", "../".repeat(depth)))
1358    };
1359    crumbs.push(Crumb {
1360        name: "~".to_string(),
1361        url: root_url,
1362    });
1363
1364    // Intermediate directory crumbs.
1365    for (i, &part) in parts.iter().enumerate() {
1366        let is_last_and_tree = i == depth - 1 && !is_blob;
1367        let url = if is_last_and_tree {
1368            None // current directory
1369        } else {
1370            // levels_up = how many "../" to navigate from current location to this dir
1371            let levels_up = depth - i - 1;
1372            Some(format!("{}index.html", "../".repeat(levels_up)))
1373        };
1374        crumbs.push(Crumb {
1375            name: part.to_string(),
1376            url,
1377        });
1378    }
1379
1380    // Filename crumb for blobs.
1381    if is_blob {
1382        if let Some(name) = filename {
1383            crumbs.push(Crumb {
1384                name: name.to_string(),
1385                url: None,
1386            });
1387        }
1388    }
1389
1390    crumbs
1391}
1392
1393fn escape_html(s: &str) -> String {
1394    s.replace('&', "&amp;")
1395        .replace('<', "&lt;")
1396        .replace('>', "&gt;")
1397}
1398
1399// ── Helpers ────────────────────────────────────────────────────────────────────
1400
1401/// Split a raw commit message into (subject, optional body).
1402fn parse_message(raw: &str) -> (String, Option<String>) {
1403    if let Some(idx) = raw.find("\n\n") {
1404        let subject = raw[..idx].trim().to_string();
1405        let body = raw[idx + 2..].trim().to_string();
1406        let body = if body.is_empty() { None } else { Some(body) };
1407        (subject, body)
1408    } else {
1409        (raw.trim().to_string(), None)
1410    }
1411}