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