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