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