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;
25use miette::{IntoDiagnostic, Result};
26
27use crate::cli::{CYAN, GREEN, RED, RESET};
28use serde::Serialize;
29use tera::{Context, Tera};
30use tracing::{info, warn};
31
32use crate::config::{AbbayeConfig, GitUiConfig};
33
34// ── Template sources ──────────────────────────────────────────────────────────
35
36pub const TEMPLATE_GIT_LOG_HTML: &str = include_str!("templates/git_log.html.j2");
37pub const TEMPLATE_GIT_COMMIT_HTML: &str = include_str!("templates/git_commit.html.j2");
38pub const TEMPLATE_GIT_REFS_HTML: &str = include_str!("templates/git_refs.html.j2");
39pub const TEMPLATE_GIT_TREE_HTML: &str = include_str!("templates/git_tree.html.j2");
40pub const TEMPLATE_GIT_BLOB_HTML: &str = include_str!("templates/git_blob.html.j2");
41
42pub const TEMPLATE_GIT_LOG_GEMTEXT: &str = include_str!("templates/git_log.gmi.j2");
43pub const TEMPLATE_GIT_COMMIT_GEMTEXT: &str = include_str!("templates/git_commit.gmi.j2");
44pub const TEMPLATE_GIT_REFS_GEMTEXT: &str = include_str!("templates/git_refs.gmi.j2");
45pub const TEMPLATE_GIT_TREE_GEMTEXT: &str = include_str!("templates/git_tree.gmi.j2");
46pub const TEMPLATE_GIT_BLOB_GEMTEXT: &str = include_str!("templates/git_blob.gmi.j2");
47
48// ── Template-facing data structures ──────────────────────────────────────────
49
50#[derive(Clone, Serialize)]
51struct CommitParent {
52    hash: String,
53    hash_short: String,
54}
55
56/// A single commit's metadata, passed to Tera templates.
57/// `Clone` is required so commits can be deduplicated across branches.
58#[derive(Clone, Serialize)]
59struct CommitInfo {
60    hash: String,
61    hash_short: String,
62    author_name: String,
63    author_email: String,
64    /// ISO-8601 timestamp for `<time datetime="">`.
65    date_iso: String,
66    /// Short date for the log table (YYYY-MM-DD).
67    date: String,
68    /// Date + time for the commit detail page.
69    datetime_display: String,
70    /// First line of the commit message.
71    subject: String,
72    /// Everything after the blank line separator, if present.
73    body: Option<String>,
74    parents: Vec<CommitParent>,
75    /// Tags and branches whose tip is exactly this commit.
76    ref_badges: Vec<RefBadge>,
77}
78
79#[derive(Serialize)]
80struct RefInfo {
81    name: String,
82    short_name: String,
83    hash: String,
84    hash_short: String,
85}
86
87/// The visual kind of a single unified-diff line.
88/// Serialises as lowercase for use as a CSS modifier class.
89#[derive(Serialize)]
90#[serde(rename_all = "lowercase")]
91enum DiffLineKind {
92    Header,  // diff --git, index, ---, +++, mode lines
93    Hunk,    // @@ -a,b +c,d @@
94    Added,   // lines beginning with +
95    Removed, // lines beginning with -
96    Context, // unchanged surrounding lines
97}
98
99#[derive(Serialize)]
100struct DiffLine {
101    kind: DiffLineKind,
102    content: String,
103}
104
105#[derive(Serialize)]
106struct ChangedFile {
107    path: String,
108    /// One of: added, deleted, modified, renamed, copied, changed.
109    status: String,
110    diff_lines: Vec<DiffLine>,
111}
112
113/// Discriminates the two kinds of ref badge shown on log pages.
114/// Serialises as lowercase (`"tag"` / `"branch"`) for use as a CSS modifier class.
115#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
116#[serde(rename_all = "lowercase")]
117enum RefBadgeKind {
118    /// Derived `Ord`: `Tag < Branch`, so tags sort before branches.
119    Tag,
120    Branch,
121}
122
123/// A ref badge shown next to a commit hash on log pages.
124#[derive(Clone, Serialize)]
125struct RefBadge {
126    label: String,
127    kind: RefBadgeKind,
128}
129
130/// One entry in the branch-switcher nav rendered on every log page.
131#[derive(Serialize)]
132struct BranchNav {
133    short_name: String,
134    /// HTML filename for this branch ("index.html" or "<name>.html").
135    filename: String,
136    is_current: bool,
137}
138
139// ── Internal branch descriptor (never serialized) ────────────────────────────
140
141#[derive(Clone)]
142struct BranchEntry {
143    short_name: String,
144    /// Output filename: "index.html" for the default branch, else a sanitized name.
145    filename: String,
146    tip: gix::ObjectId,
147}
148
149// ── Public entry point ────────────────────────────────────────────────────────
150
151/// Shared spinner style - matches the builder spinners in `site.rs`.
152fn make_spinner(label: &str) -> ProgressBar {
153    let pb = ProgressBar::new_spinner();
154    pb.set_style(crate::site::spinner_style(false));
155    pb.set_prefix(format!("{CYAN}[{label}]{RESET}"));
156    pb.enable_steady_tick(Duration::from_millis(100));
157    pb
158}
159
160/// A lightweight progress reporter that either drives an `indicatif` spinner
161/// (interactive terminals) or logs via `tracing::info!` (dumb / piped terminals).
162enum Progress {
163    /// Interactive terminal – show a spinner.
164    Spinner(ProgressBar),
165    /// Non-interactive terminal – just log messages.
166    Log,
167}
168
169impl Progress {
170    fn new(label: &str) -> Self {
171        if crate::utils::is_interactive() {
172            let pb = make_spinner(label);
173            pb.set_message("starting…");
174            Self::Spinner(pb)
175        } else {
176            info!("[{label}] starting …");
177            Self::Log
178        }
179    }
180
181    fn set_message(&self, msg: impl Into<String>) {
182        match self {
183            Self::Spinner(pb) => pb.set_message(msg.into()),
184            Self::Log => info!("[git ui] {}", msg.into()),
185        }
186    }
187
188    fn finish_done(&self) {
189        match self {
190            Self::Spinner(pb) => {
191                pb.finish_with_message(format!("{GREEN}\u{2713} done{RESET}"));
192            }
193            Self::Log => info!("[git ui] done"),
194        }
195    }
196
197    fn finish_failed(&self, detail: &miette::Report) {
198        match self {
199            Self::Spinner(pb) => {
200                pb.finish_with_message(format!("{RED}\u{2717} failed: {detail}{RESET}"));
201            }
202            Self::Log => warn!("[git ui] failed: {detail}"),
203        }
204    }
205}
206
207pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Result<()> {
208    let output_dir = &config.site.output_dir;
209    let ui_dir = output_dir.join("repository");
210    let bare_dir = output_dir.join("repository.git");
211
212    tokio::fs::create_dir_all(&ui_dir).await.into_diagnostic()?;
213    tokio::fs::create_dir_all(ui_dir.join("commit"))
214        .await
215        .into_diagnostic()?;
216
217    let repo_path: PathBuf = git_cfg
218        .repo_path
219        .clone()
220        .unwrap_or_else(|| PathBuf::from("."));
221
222    let max_commits = git_cfg.max_commits;
223    let default_branch = git_cfg.default_branch.clone();
224    let include = build_globset(&git_cfg.include)?;
225    let exclude = build_globset(&git_cfg.exclude)?;
226    let repo_path_clone = repo_path.clone();
227
228    // `include`/`exclude`/`default_branch` are moved into the `spawn_blocking`
229    // closure below, so keep clones around for `export_bare_clone`, which
230    // needs the same filters to decide what to publish in `repository.git`.
231    let bare_default_branch = default_branch.clone();
232    let bare_include = include.clone();
233    let bare_exclude = exclude.clone();
234
235    // Compute clone URL before the blocking task so we can pass it into the
236    // browse page generator without re-deriving it.
237    let clone_url = generate_clone_command(config, git_cfg);
238
239    // ── All gix work happens inside one blocking task (Repository is !Send) ───
240    //
241    // Returns:
242    //   branch_pages     - (short_name, filename, commits) per branch
243    //   unique_commits   - all commits across all branches, deduplicated
244    //   tags / ref_branches - for refs.html
245    //   browse_revisions - (hex_hash, ObjectId) for every branch tip + tag tip
246    let (branch_pages, unique_commits, tags, ref_branches, browse_revisions) =
247        tokio::task::spawn_blocking(move || -> Result<_> {
248            let repo = match gix::discover(&repo_path_clone) {
249                Ok(r) => r,
250                Err(e) => {
251                    warn!(
252                        "git_ui: could not open repository at {}: {e}",
253                        repo_path_clone.display()
254                    );
255                    return Ok((vec![], vec![], vec![], vec![], vec![]));
256                }
257            };
258
259            let branches = collect_branch_entries(&repo, &default_branch, &include, &exclude)?;
260            let (tags, ref_branches) = collect_refs(&repo, &include, &exclude)?;
261            let ref_labels = build_ref_labels(&repo, &include, &exclude)?;
262
263            // Walk commits per branch; collect unique commits for detail pages.
264            let mut unique_map: HashMap<String, CommitInfo> = HashMap::new();
265            let mut branch_pages: Vec<(String, String, Vec<CommitInfo>)> = Vec::new();
266
267            for branch in &branches {
268                let commits = collect_commits(&repo, branch.tip, max_commits, &ref_labels)?;
269                for c in &commits {
270                    unique_map
271                        .entry(c.hash.clone())
272                        .or_insert_with(|| c.clone());
273                }
274                branch_pages.push((branch.short_name.clone(), branch.filename.clone(), commits));
275            }
276
277            let unique_commits: Vec<CommitInfo> = unique_map.into_values().collect();
278
279            // Collect revisions for the tree browser: branch tips + tag tips.
280            let mut seen: HashMap<String, gix::ObjectId> = HashMap::new();
281            for branch in &branches {
282                seen.insert(branch.tip.to_string(), branch.tip);
283            }
284            let refs_platform = repo.references().into_diagnostic()?;
285            for reference in refs_platform.all().into_diagnostic()? {
286                let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
287                let name = reference.name().as_bstr().to_str_lossy().into_owned();
288                if !name.starts_with("refs/tags/") {
289                    continue;
290                }
291                let short_name = name.trim_start_matches("refs/tags/");
292                if !ref_is_included(short_name, &include, &exclude) {
293                    continue;
294                }
295                if let Ok(id) = reference.peel_to_id() {
296                    let hash = id.to_string();
297                    seen.entry(hash).or_insert_with(|| id.detach());
298                }
299            }
300            let browse_revisions: Vec<(String, gix::ObjectId)> = seen.into_iter().collect();
301
302            Ok((
303                branch_pages,
304                unique_commits,
305                tags,
306                ref_branches,
307                browse_revisions,
308            ))
309        })
310        .await
311        .into_diagnostic()??;
312
313    if branch_pages.is_empty() {
314        // Nothing to render (empty repo or no branches).
315        return Ok(());
316    }
317
318    let progress = Progress::new("git ui");
319
320    // ── Bare clone + dumb HTTP setup ──────────────────────────────────────────
321    progress.set_message("cloning bare repository…");
322    if let Err(e) = export_bare_clone(
323        &repo_path,
324        &bare_dir,
325        &bare_default_branch,
326        &bare_include,
327        &bare_exclude,
328    )
329    .await
330    {
331        progress.finish_failed(&e);
332        return Err(e);
333    }
334
335    // ── Tera setup ────────────────────────────────────────────────────────────
336    let mut tera = Tera::default();
337    let theme_path = PathBuf::from(".abbaye").join("theme");
338
339    crate::site::register_format_templates(
340        &mut tera,
341        &theme_path,
342        &config.site.formats,
343        &[
344            ("git_log", TEMPLATE_GIT_LOG_HTML, TEMPLATE_GIT_LOG_GEMTEXT),
345            (
346                "git_commit",
347                TEMPLATE_GIT_COMMIT_HTML,
348                TEMPLATE_GIT_COMMIT_GEMTEXT,
349            ),
350            (
351                "git_refs",
352                TEMPLATE_GIT_REFS_HTML,
353                TEMPLATE_GIT_REFS_GEMTEXT,
354            ),
355        ],
356    )?;
357
358    // ── Branch switcher nav (shared across all log pages) ─────────────────────
359    //
360    // Order: the default branch (index.html) first, then alphabetical.
361    let mut nav_entries: Vec<(String, String)> = branch_pages
362        .iter()
363        .map(|(name, file, _)| (name.clone(), file.clone()))
364        .collect();
365    nav_entries.sort_by(|(na, fa), (nb, fb)| {
366        let a_default = fa == "index.html";
367        let b_default = fb == "index.html";
368        b_default.cmp(&a_default).then(na.cmp(nb))
369    });
370
371    // ── Render one log page per branch (per format) ───────────────────────────
372    progress.set_message("rendering log pages…");
373    for (short_name, filename, commits) in &branch_pages {
374        let truncated = commits.len() >= max_commits;
375
376        let branch_nav: Vec<BranchNav> = nav_entries
377            .iter()
378            .map(|(bn, bf)| BranchNav {
379                short_name: bn.clone(),
380                filename: bf.clone(),
381                is_current: bn == short_name,
382            })
383            .collect();
384
385        for format in &config.site.formats {
386            let suffix = format.extension();
387            let tmpl_name = format!("git_log.{suffix}");
388            let ext = format.extension();
389            let out_filename = filename.replace(".html", &format!(".{ext}"));
390
391            let mut ctx = Context::new();
392            ctx.insert("project_name", &config.site.name);
393            ctx.insert("lang", &config.site.lang);
394            ctx.insert("clone_url", &clone_url);
395            ctx.insert("current_branch", short_name);
396            ctx.insert("branch_nav", &branch_nav);
397            ctx.insert("commits", commits);
398            ctx.insert("truncated", &truncated);
399            ctx.insert("root_path", "../");
400
401            let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?;
402            tokio::fs::write(ui_dir.join(&out_filename), content)
403                .await
404                .into_diagnostic()?;
405        }
406    }
407
408    // ── Render refs page (per format) ─────────────────────────────────────────
409    for format in &config.site.formats {
410        let suffix = format.extension();
411        let tmpl_name = format!("git_refs.{suffix}");
412        let ext = format.extension();
413        let out_filename = format!("refs.{ext}");
414
415        let mut ctx = Context::new();
416        ctx.insert("project_name", &config.site.name);
417        ctx.insert("lang", &config.site.lang);
418        ctx.insert("clone_url", &clone_url);
419        ctx.insert("tags", &tags);
420        ctx.insert("branches", &ref_branches);
421        ctx.insert("root_path", "../");
422
423        let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?;
424        tokio::fs::write(ui_dir.join(&out_filename), content)
425            .await
426            .into_diagnostic()?;
427    }
428
429    // ── Render per-commit detail pages (per format) ───────────────────────────
430    progress.set_message(format!("rendering {} commit pages…", unique_commits.len()));
431    for commit_info in &unique_commits {
432        let changed_files = get_changed_files(&commit_info.hash).await?;
433        let has_browse = !commit_info.ref_badges.is_empty();
434        let commit_dir = ui_dir.join("commit");
435
436        for format in &config.site.formats {
437            let suffix = format.extension();
438            let tmpl_name = format!("git_commit.{suffix}");
439            let ext = format.extension();
440            let out_filename = format!("{}.{ext}", commit_info.hash);
441
442            let mut ctx = Context::new();
443            ctx.insert("project_name", &config.site.name);
444            ctx.insert("lang", &config.site.lang);
445            ctx.insert("clone_url", &clone_url);
446            ctx.insert("commit", commit_info);
447            ctx.insert("changed_files", &changed_files);
448            ctx.insert("has_browse", &has_browse);
449            ctx.insert("root_path", "../../");
450
451            let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?;
452            tokio::fs::write(commit_dir.join(&out_filename), content)
453                .await
454                .into_diagnostic()?;
455        }
456    }
457
458    // ── Tree browser (browse/<hash>/) ─────────────────────────────────────────
459    if !browse_revisions.is_empty() {
460        progress.set_message(format!(
461            "building browse pages for {} revision(s)…",
462            browse_revisions.len()
463        ));
464        let browse_dir = ui_dir.join("browse");
465        tokio::fs::create_dir_all(&browse_dir)
466            .await
467            .into_diagnostic()?;
468
469        let project_name = config.site.name.clone();
470        let lang = config.site.lang.clone();
471        let clone_url_browse = clone_url.clone();
472        let theme_path = PathBuf::from(".abbaye").join("theme");
473        let repo_path_browse = repo_path.clone();
474        let formats = config.site.formats.clone();
475
476        tokio::task::spawn_blocking(move || {
477            crate::git_browse::build_browse_pages(
478                &browse_revisions,
479                &browse_dir,
480                &repo_path_browse,
481                &theme_path,
482                &project_name,
483                &lang,
484                &clone_url_browse,
485                &formats,
486            )
487        })
488        .await
489        .into_diagnostic()??
490    }
491
492    progress.finish_done();
493    Ok(())
494}
495
496pub fn generate_clone_command(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Option<String> {
497    let clone_url: Option<String> = git_cfg.clone_url.clone().or_else(|| {
498        config.site.base_url.as_ref().map(|base| {
499            format!(
500                "{}/repository.git {}",
501                base.trim_end_matches('/'),
502                if config.site.name.contains(" ") {
503                    format!("'{}'", config.site.name)
504                } else {
505                    config.site.name.clone()
506                }
507            )
508        })
509    });
510    clone_url
511}
512
513// ── Git data collection ───────────────────────────────────────────────────────
514
515/// Compile a list of glob patterns (as written in `git_ui.exclude`/`include`)
516/// into a [`GlobSet`]. An empty pattern list compiles to an empty `GlobSet`,
517/// which matches nothing.
518fn build_globset(patterns: &[String]) -> Result<GlobSet> {
519    let mut builder = GlobSetBuilder::new();
520    for pattern in patterns {
521        let glob = Glob::new(pattern)
522            .map_err(|e| miette::miette!("git_ui: invalid glob pattern '{pattern}': {e}"))?;
523        builder.add(glob);
524    }
525    builder.build().into_diagnostic()
526}
527
528/// Decide whether a ref (by its short name, e.g. `"main"` or `"v1.0.0"`)
529/// should appear in the generated UI, per `git_ui.exclude`/`git_ui.include`.
530///
531/// When `include` is non-empty, it acts as an allowlist: only refs matching
532/// at least one `include` pattern are kept. When `include` is empty, every
533/// ref is a candidate. From the resulting candidates, any ref matching an
534/// `exclude` pattern is then dropped.
535fn ref_is_included(short_name: &str, include: &GlobSet, exclude: &GlobSet) -> bool {
536    let included = include.is_empty() || include.is_match(short_name);
537    included && !exclude.is_match(short_name)
538}
539
540/// Collect all local branches and assign output filenames.
541///
542/// The branch whose short name matches `default_branch` gets `"index.html"`.
543/// Every other branch gets `"<sanitized-short-name>.html"` where `/` is
544/// replaced by `-`.
545///
546/// If no branch matches `default_branch`, the first branch (alphabetically)
547/// receives `"index.html"` and a warning is emitted.
548///
549/// Branches whose short name doesn't pass `git_ui.include`/`exclude` (see
550/// [`ref_is_included`]) are skipped entirely.
551fn collect_branch_entries(
552    repo: &gix::Repository,
553    default_branch: &str,
554    include: &GlobSet,
555    exclude: &GlobSet,
556) -> Result<Vec<BranchEntry>> {
557    let mut entries: Vec<BranchEntry> = Vec::new();
558
559    let refs_platform = repo.references().into_diagnostic()?;
560    for reference in refs_platform.all().into_diagnostic()? {
561        let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
562        let name = reference.name().as_bstr().to_str_lossy().into_owned();
563
564        if !name.starts_with("refs/heads/") {
565            continue;
566        }
567
568        let short_name = name.trim_start_matches("refs/heads/").to_string();
569        if !ref_is_included(&short_name, include, exclude) {
570            continue;
571        }
572        let tip = match reference.peel_to_id() {
573            Ok(id) => id.detach(),
574            Err(_) => continue,
575        };
576
577        // Tentative filename; replaced for the default branch below.
578        let filename = format!("{}.html", short_name.replace('/', "-"));
579        entries.push(BranchEntry {
580            short_name,
581            filename,
582            tip,
583        });
584    }
585
586    // Stable, predictable page order.
587    entries.sort_by(|a, b| a.short_name.cmp(&b.short_name));
588
589    // Assign index.html to the configured default branch.
590    if let Some(e) = entries.iter_mut().find(|e| e.short_name == default_branch) {
591        e.filename = "index.html".to_string();
592    } else if let Some(first) = entries.first_mut() {
593        warn!(
594            "git_ui: default branch '{}' not found; using '{}' as index.html",
595            default_branch, first.short_name
596        );
597        first.filename = "index.html".to_string();
598    }
599
600    Ok(entries)
601}
602
603/// Build a map from commit hash (hex string) to the ref badges pointing at it.
604/// Tags come before branches within each entry; both are sorted alphabetically.
605///
606/// Refs that don't pass `git_ui.include`/`exclude` (see [`ref_is_included`])
607/// are omitted.
608fn build_ref_labels(
609    repo: &gix::Repository,
610    include: &GlobSet,
611    exclude: &GlobSet,
612) -> Result<HashMap<String, Vec<RefBadge>>> {
613    let mut map: HashMap<String, Vec<RefBadge>> = HashMap::new();
614
615    let refs_platform = repo.references().into_diagnostic()?;
616    for reference in refs_platform.all().into_diagnostic()? {
617        let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
618        let name = reference.name().as_bstr().to_str_lossy().into_owned();
619
620        if name == "HEAD" || name.starts_with("refs/remotes/") {
621            continue;
622        }
623
624        let hash = match reference.peel_to_id() {
625            Ok(id) => id.to_string(),
626            Err(_) => continue,
627        };
628
629        let badge = if let Some(label) = name.strip_prefix("refs/tags/") {
630            if !ref_is_included(label, include, exclude) {
631                continue;
632            }
633            RefBadge {
634                label: label.to_string(),
635                kind: RefBadgeKind::Tag,
636            }
637        } else if let Some(label) = name.strip_prefix("refs/heads/") {
638            if !ref_is_included(label, include, exclude) {
639                continue;
640            }
641            RefBadge {
642                label: label.to_string(),
643                kind: RefBadgeKind::Branch,
644            }
645        } else {
646            continue;
647        };
648
649        map.entry(hash).or_default().push(badge);
650    }
651
652    // Within each entry: tags first (Tag < Branch via Ord), then alphabetical.
653    for badges in map.values_mut() {
654        badges.sort_by(|a, b| a.kind.cmp(&b.kind).then(a.label.cmp(&b.label)));
655    }
656
657    Ok(map)
658}
659
660/// Walk at most `max` commits reachable from `tip`, newest first.
661fn collect_commits(
662    repo: &gix::Repository,
663    tip: gix::ObjectId,
664    max: usize,
665    ref_labels: &HashMap<String, Vec<RefBadge>>,
666) -> Result<Vec<CommitInfo>> {
667    let walk = repo.rev_walk([tip]).all().into_diagnostic()?;
668    let mut commits = Vec::new();
669
670    for info in walk.take(max) {
671        let info = info.into_diagnostic()?;
672        let id = info.id;
673
674        let object = repo.find_object(id).into_diagnostic()?;
675        let commit = object.into_commit();
676        let decoded = commit.decode().into_diagnostic()?;
677
678        let author = decoded.author().into_diagnostic()?;
679        let author_name = author.name.to_str_lossy().into_owned();
680        let author_email = author.email.to_str_lossy().into_owned();
681        let unix_secs: i64 = author
682            .time
683            .split_whitespace()
684            .next()
685            .and_then(|s| s.parse().ok())
686            .unwrap_or(0);
687
688        let dt: DateTime<Utc> = DateTime::from_timestamp(unix_secs, 0).unwrap_or_default();
689        let date = dt.format("%Y-%m-%d").to_string();
690        let date_iso = dt.to_rfc3339();
691        let datetime_display = dt.format("%Y-%m-%d %H:%M UTC").to_string();
692
693        let raw_msg = decoded.message.to_str_lossy();
694        let (subject, body) = parse_message(&raw_msg);
695
696        let hash = id.to_string();
697        let hash_short = hash[..7].to_string();
698
699        let parents = info
700            .parent_ids
701            .iter()
702            .map(|p| {
703                let h = p.to_string();
704                let hs = h[..7].to_string();
705                CommitParent {
706                    hash: h,
707                    hash_short: hs,
708                }
709            })
710            .collect();
711
712        let ref_badges = ref_labels.get(&hash).cloned().unwrap_or_default();
713
714        commits.push(CommitInfo {
715            hash,
716            hash_short,
717            author_name,
718            author_email,
719            date,
720            date_iso,
721            datetime_display,
722            subject,
723            body,
724            parents,
725            ref_badges,
726        });
727    }
728
729    Ok(commits)
730}
731
732/// Collect tags and branches for the refs overview page.
733///
734/// Refs that don't pass `git_ui.include`/`exclude` (see [`ref_is_included`])
735/// are omitted.
736fn collect_refs(
737    repo: &gix::Repository,
738    include: &GlobSet,
739    exclude: &GlobSet,
740) -> Result<(Vec<RefInfo>, Vec<RefInfo>)> {
741    let mut tags: Vec<RefInfo> = Vec::new();
742    let mut branches: Vec<RefInfo> = Vec::new();
743
744    let refs_platform = repo.references().into_diagnostic()?;
745    for reference in refs_platform.all().into_diagnostic()? {
746        let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
747        let name = reference.name().as_bstr().to_str_lossy().into_owned();
748
749        if name.starts_with("refs/remotes/") || name == "HEAD" {
750            continue;
751        }
752
753        let hash = match reference.peel_to_id() {
754            Ok(id) => id.to_string(),
755            Err(_) => continue,
756        };
757        let hash_short = hash[..7.min(hash.len())].to_string();
758
759        if name.starts_with("refs/tags/") {
760            let short_name = name.trim_start_matches("refs/tags/").to_string();
761            if !ref_is_included(&short_name, include, exclude) {
762                continue;
763            }
764            tags.push(RefInfo {
765                name,
766                short_name,
767                hash,
768                hash_short,
769            });
770        } else if name.starts_with("refs/heads/") {
771            let short_name = name.trim_start_matches("refs/heads/").to_string();
772            if !ref_is_included(&short_name, include, exclude) {
773                continue;
774            }
775            branches.push(RefInfo {
776                name,
777                short_name,
778                hash,
779                hash_short,
780            });
781        }
782    }
783
784    // Tags: newest first (reverse-lexicographic ≈ version order for semver tags).
785    tags.sort_by(|a, b| b.name.cmp(&a.name));
786    branches.sort_by(|a, b| a.name.cmp(&b.name));
787
788    Ok((tags, branches))
789}
790
791// ── Changed files via git CLI ─────────────────────────────────────────────────
792
793async fn get_changed_files(commit_hash: &str) -> Result<Vec<ChangedFile>> {
794    let output = tokio::process::Command::new("git")
795        .args(["diff-tree", "-p", "--no-commit-id", "-r", commit_hash])
796        .output()
797        .await
798        .into_diagnostic()?;
799
800    if !output.status.success() {
801        return Ok(vec![]);
802    }
803
804    Ok(parse_diff_output(&String::from_utf8_lossy(&output.stdout)))
805}
806
807/// Parse the output of `git diff-tree -p` into per-file [`ChangedFile`] entries.
808///
809/// Each file section starts with a `diff --git a/<path> b/<path>` line.
810/// Status is inferred from subsequent mode/rename headers.
811/// Diff line kinds are determined by their leading character, with header
812/// lines (`--- `, `+++ `) distinguished from content lines (`-`, `+`) by
813/// the mandatory space that follows the three-character marker.
814fn parse_diff_output(text: &str) -> Vec<ChangedFile> {
815    let mut files: Vec<ChangedFile> = Vec::new();
816    let mut cur_lines: Vec<DiffLine> = Vec::new();
817    let mut cur_path = String::new();
818    let mut cur_status = "modified";
819
820    let push_line = |lines: &mut Vec<DiffLine>, kind, content: &str| {
821        lines.push(DiffLine {
822            kind,
823            content: content.to_string(),
824        });
825    };
826
827    for line in text.lines() {
828        if line.starts_with("diff --git ") {
829            if !cur_path.is_empty() {
830                files.push(ChangedFile {
831                    path: cur_path.clone(),
832                    status: cur_status.to_string(),
833                    diff_lines: std::mem::take(&mut cur_lines),
834                });
835            }
836            // Extract the destination path from the trailing " b/<path>" token.
837            // rsplit_once handles paths that contain spaces.
838            cur_path = line
839                .rsplit_once(" b/")
840                .map(|(_, p)| p.to_string())
841                .unwrap_or_default();
842            cur_status = "modified";
843            push_line(&mut cur_lines, DiffLineKind::Header, line);
844        } else if line.starts_with("new file mode") {
845            cur_status = "added";
846            push_line(&mut cur_lines, DiffLineKind::Header, line);
847        } else if line.starts_with("deleted file mode") {
848            cur_status = "deleted";
849            push_line(&mut cur_lines, DiffLineKind::Header, line);
850        } else if line.starts_with("rename from") || line.starts_with("rename to") {
851            cur_status = "renamed";
852            push_line(&mut cur_lines, DiffLineKind::Header, line);
853        } else if line.starts_with("similarity index")
854            || line.starts_with("copy from")
855            || line.starts_with("copy to")
856            || line.starts_with("index ")
857            || line.starts_with("--- ")   // file header, not a removed line
858            || line.starts_with("+++ ")   // file header, not an added line
859            || line.starts_with("Binary files")
860            || line.starts_with('\\')
861        {
862            push_line(&mut cur_lines, DiffLineKind::Header, line);
863        } else if line.starts_with("@@") {
864            push_line(&mut cur_lines, DiffLineKind::Hunk, line);
865        } else if line.starts_with('+') {
866            push_line(&mut cur_lines, DiffLineKind::Added, line);
867        } else if line.starts_with('-') {
868            push_line(&mut cur_lines, DiffLineKind::Removed, line);
869        } else {
870            push_line(&mut cur_lines, DiffLineKind::Context, line);
871        }
872    }
873
874    if !cur_path.is_empty() {
875        files.push(ChangedFile {
876            path: cur_path,
877            status: cur_status.to_string(),
878            diff_lines: cur_lines,
879        });
880    }
881
882    files
883}
884
885// ── Bare clone export ─────────────────────────────────────────────────────────
886
887/// Clone `source` as a bare repository at `dest`, then prune it down to the
888/// branches/tags allowed by `git_ui.include`/`git_ui.exclude` before enabling
889/// the dumb HTTP transport.
890///
891/// `default_branch` is used to pick a sane `HEAD` for the bare clone if the
892/// branch it previously pointed to got filtered out.
893async fn export_bare_clone(
894    source: &Path,
895    dest: &Path,
896    default_branch: &str,
897    include: &GlobSet,
898    exclude: &GlobSet,
899) -> Result<()> {
900    use tokio::process::Command;
901
902    if dest.exists() {
903        tokio::fs::remove_dir_all(dest).await.into_diagnostic()?;
904    }
905
906    let status = Command::new("git")
907        .arg("clone")
908        .arg("--bare")
909        .arg(source)
910        .arg(dest)
911        .stdout(std::process::Stdio::null())
912        .stderr(std::process::Stdio::null())
913        .status()
914        .await
915        .into_diagnostic()?;
916
917    if !status.success() {
918        return Err(miette::miette!(
919            "git clone --bare failed with exit status {status}"
920        ));
921    }
922
923    prune_excluded_refs(dest, default_branch, include, exclude).await?;
924
925    // Enable dumb HTTP transport so the bare repo is clonable over plain HTTPS.
926    let status = Command::new("git")
927        .arg("-C")
928        .arg(dest)
929        .arg("update-server-info")
930        .stdout(std::process::Stdio::null())
931        .stderr(std::process::Stdio::null())
932        .status()
933        .await
934        .into_diagnostic()?;
935
936    if !status.success() {
937        warn!("git update-server-info failed; dumb HTTP cloning may not work");
938    }
939
940    Ok(())
941}
942
943/// Delete every branch/tag in the bare clone at `dest` that doesn't pass
944/// `git_ui.include`/`git_ui.exclude` (see [`ref_is_included`]), then run
945/// `git gc --prune=now` so the excluded history isn't merely unlisted but
946/// actually removed from what dumb HTTP ends up serving from disk.
947///
948/// A no-op (skips even the ref scan) when both `include` and `exclude` are
949/// empty, which is the common case.
950async fn prune_excluded_refs(
951    dest: &Path,
952    default_branch: &str,
953    include: &GlobSet,
954    exclude: &GlobSet,
955) -> Result<()> {
956    use tokio::process::Command;
957
958    if include.is_empty() && exclude.is_empty() {
959        return Ok(());
960    }
961
962    let output = Command::new("git")
963        .arg("-C")
964        .arg(dest)
965        .arg("for-each-ref")
966        .arg("--format=%(refname)")
967        .arg("refs/heads")
968        .arg("refs/tags")
969        .output()
970        .await
971        .into_diagnostic()?;
972
973    if !output.status.success() {
974        warn!("git for-each-ref failed; skipping include/exclude pruning of repository.git");
975        return Ok(());
976    }
977
978    let refnames = String::from_utf8_lossy(&output.stdout);
979    let mut removed_any = false;
980    let mut kept_branches: Vec<String> = Vec::new();
981
982    for refname in refnames.lines() {
983        let short_name = match refname
984            .strip_prefix("refs/heads/")
985            .or_else(|| refname.strip_prefix("refs/tags/"))
986        {
987            Some(s) => s,
988            None => continue,
989        };
990
991        if ref_is_included(short_name, include, exclude) {
992            if refname.starts_with("refs/heads/") {
993                kept_branches.push(short_name.to_string());
994            }
995            continue;
996        }
997
998        let status = Command::new("git")
999            .arg("-C")
1000            .arg(dest)
1001            .arg("update-ref")
1002            .arg("-d")
1003            .arg(refname)
1004            .stdout(std::process::Stdio::null())
1005            .stderr(std::process::Stdio::null())
1006            .status()
1007            .await
1008            .into_diagnostic()?;
1009
1010        if status.success() {
1011            removed_any = true;
1012        } else {
1013            warn!("failed to delete excluded ref '{refname}' from repository.git");
1014        }
1015    }
1016
1017    if !removed_any {
1018        return Ok(());
1019    }
1020
1021    // `HEAD` may have pointed at a branch we just deleted; repoint it at the
1022    // configured default branch if it survived the filter, or otherwise at
1023    // whatever branch is left, so `git clone` of `repository.git` still
1024    // checks out something sensible.
1025    let new_head = if kept_branches.iter().any(|b| b == default_branch) {
1026        Some(default_branch.to_string())
1027    } else if let Some(first) = kept_branches.first() {
1028        warn!(
1029            "git_ui: default branch '{}' excluded from repository.git; using '{}' for HEAD instead",
1030            default_branch, first
1031        );
1032        Some(first.clone())
1033    } else {
1034        warn!(
1035            "git_ui: include/exclude filtered out every branch; repository.git will have no usable HEAD"
1036        );
1037        None
1038    };
1039
1040    if let Some(branch) = new_head {
1041        let status = Command::new("git")
1042            .arg("-C")
1043            .arg(dest)
1044            .arg("symbolic-ref")
1045            .arg("HEAD")
1046            .arg(format!("refs/heads/{branch}"))
1047            .stdout(std::process::Stdio::null())
1048            .stderr(std::process::Stdio::null())
1049            .status()
1050            .await
1051            .into_diagnostic()?;
1052
1053        if !status.success() {
1054            warn!("failed to repoint HEAD in repository.git after pruning excluded refs");
1055        }
1056    }
1057
1058    // Physically remove the now-unreachable objects so excluded branches/tags
1059    // aren't just unlisted but actually absent from the published repo.
1060    let status = Command::new("git")
1061        .arg("-C")
1062        .arg(dest)
1063        .arg("gc")
1064        .arg("--prune=now")
1065        .arg("--quiet")
1066        .status()
1067        .await
1068        .into_diagnostic()?;
1069
1070    if !status.success() {
1071        warn!(
1072            "git gc --prune=now failed on repository.git; excluded objects may still be present on disk"
1073        );
1074    }
1075
1076    Ok(())
1077}
1078
1079// ── Helpers ────────────────────────────────────────────────────────────────────
1080
1081/// Split a raw commit message into (subject, optional body).
1082fn parse_message(raw: &str) -> (String, Option<String>) {
1083    if let Some(idx) = raw.find("\n\n") {
1084        let subject = raw[..idx].trim().to_string();
1085        let body = raw[idx + 2..].trim().to_string();
1086        let body = if body.is_empty() { None } else { Some(body) };
1087        (subject, body)
1088    } else {
1089        (raw.trim().to_string(), None)
1090    }
1091}