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_url(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_url(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Option<String> {
546    git_cfg.clone_url.clone().or_else(|| {
547        config
548            .site
549            .base_url
550            .as_ref()
551            .map(|base| format!("{}/repository.git", base.trim_end_matches('/')))
552    })
553}
554
555// ── Git data collection ───────────────────────────────────────────────────────
556
557/// Compile a list of glob patterns (as written in `git_ui.exclude`/`include`)
558/// into a [`GlobSet`]. An empty pattern list compiles to an empty `GlobSet`,
559/// which matches nothing.
560fn build_globset(patterns: &[String]) -> Result<GlobSet> {
561    let mut builder = GlobSetBuilder::new();
562    for pattern in patterns {
563        let glob = Glob::new(pattern)
564            .map_err(|e| miette::miette!("git_ui: invalid glob pattern '{pattern}': {e}"))?;
565        builder.add(glob);
566    }
567    builder.build().into_diagnostic()
568}
569
570/// Decide whether a ref (by its short name, e.g. `"main"` or `"v1.0.0"`)
571/// should appear in the generated UI, per `git_ui.exclude`/`git_ui.include`.
572///
573/// When `include` is non-empty, it acts as an allowlist: only refs matching
574/// at least one `include` pattern are kept. When `include` is empty, every
575/// ref is a candidate. From the resulting candidates, any ref matching an
576/// `exclude` pattern is then dropped.
577fn ref_is_included(short_name: &str, include: &GlobSet, exclude: &GlobSet) -> bool {
578    let included = include.is_empty() || include.is_match(short_name);
579    included && !exclude.is_match(short_name)
580}
581
582/// Collect all local branches and assign output filenames.
583///
584/// The branch whose short name matches `default_branch` gets `"index.html"`.
585/// Every other branch gets `"<sanitized-short-name>.html"` where `/` is
586/// replaced by `-`.
587///
588/// If no branch matches `default_branch`, the first branch (alphabetically)
589/// receives `"index.html"` and a warning is emitted.
590///
591/// Branches whose short name doesn't pass `git_ui.include`/`exclude` (see
592/// [`ref_is_included`]) are skipped entirely.
593fn collect_branch_entries(
594    repo: &gix::Repository,
595    default_branch: &str,
596    include: &GlobSet,
597    exclude: &GlobSet,
598) -> Result<Vec<BranchEntry>> {
599    let mut entries: Vec<BranchEntry> = Vec::new();
600
601    let refs_platform = repo.references().into_diagnostic()?;
602    for reference in refs_platform.all().into_diagnostic()? {
603        let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
604        let name = reference.name().as_bstr().to_str_lossy().into_owned();
605
606        if !name.starts_with("refs/heads/") {
607            continue;
608        }
609
610        let short_name = name.trim_start_matches("refs/heads/").to_string();
611        if !ref_is_included(&short_name, include, exclude) {
612            continue;
613        }
614        let tip = match reference.peel_to_id() {
615            Ok(id) => id.detach(),
616            Err(_) => continue,
617        };
618
619        // Tentative filename; replaced for the default branch below.
620        let filename = format!("{}.html", short_name.replace('/', "-"));
621        entries.push(BranchEntry {
622            short_name,
623            filename,
624            tip,
625        });
626    }
627
628    // Stable, predictable page order.
629    entries.sort_by(|a, b| a.short_name.cmp(&b.short_name));
630
631    // Assign index.html to the configured default branch.
632    if let Some(e) = entries.iter_mut().find(|e| e.short_name == default_branch) {
633        e.filename = "index.html".to_string();
634    } else if let Some(first) = entries.first_mut() {
635        warn!(
636            "git_ui: default branch '{}' not found; using '{}' as index.html",
637            default_branch, first.short_name
638        );
639        first.filename = "index.html".to_string();
640    }
641
642    Ok(entries)
643}
644
645/// Build a map from commit hash (hex string) to the ref badges pointing at it.
646/// Tags come before branches within each entry; both are sorted alphabetically.
647///
648/// Refs that don't pass `git_ui.include`/`exclude` (see [`ref_is_included`])
649/// are omitted.
650fn build_ref_labels(
651    repo: &gix::Repository,
652    include: &GlobSet,
653    exclude: &GlobSet,
654) -> Result<HashMap<String, Vec<RefBadge>>> {
655    let mut map: HashMap<String, Vec<RefBadge>> = HashMap::new();
656
657    let refs_platform = repo.references().into_diagnostic()?;
658    for reference in refs_platform.all().into_diagnostic()? {
659        let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
660        let name = reference.name().as_bstr().to_str_lossy().into_owned();
661
662        if name == "HEAD" || name.starts_with("refs/remotes/") {
663            continue;
664        }
665
666        let hash = match reference.peel_to_id() {
667            Ok(id) => id.to_string(),
668            Err(_) => continue,
669        };
670
671        let badge = if let Some(label) = name.strip_prefix("refs/tags/") {
672            if !ref_is_included(label, include, exclude) {
673                continue;
674            }
675            RefBadge {
676                label: label.to_string(),
677                kind: RefBadgeKind::Tag,
678                url: Some(format!("browse/{}/", label)),
679            }
680        } else if let Some(label) = name.strip_prefix("refs/heads/") {
681            if !ref_is_included(label, include, exclude) {
682                continue;
683            }
684            RefBadge {
685                label: label.to_string(),
686                kind: RefBadgeKind::Branch,
687                url: Some(format!("browse/{}/", label)),
688            }
689        } else {
690            continue;
691        };
692
693        map.entry(hash).or_default().push(badge);
694    }
695
696    // Within each entry: tags first (Tag < Branch via Ord), then alphabetical.
697    for badges in map.values_mut() {
698        badges.sort_by(|a, b| a.kind.cmp(&b.kind).then(a.label.cmp(&b.label)));
699    }
700
701    Ok(map)
702}
703
704/// Walk at most `max` commits reachable from `tip`, newest first.
705fn collect_commits(
706    repo: &gix::Repository,
707    tip: gix::ObjectId,
708    max: usize,
709    ref_labels: &HashMap<String, Vec<RefBadge>>,
710) -> Result<Vec<CommitInfo>> {
711    let walk = repo.rev_walk([tip]).all().into_diagnostic()?;
712    let mut commits = Vec::new();
713
714    for info in walk.take(max) {
715        let info = info.into_diagnostic()?;
716        let id = info.id;
717
718        let object = repo.find_object(id).into_diagnostic()?;
719        let commit = object.into_commit();
720        let decoded = commit.decode().into_diagnostic()?;
721
722        let author = decoded.author().into_diagnostic()?;
723        let author_name = author.name.to_str_lossy().into_owned();
724        let author_email = author.email.to_str_lossy().into_owned();
725        let unix_secs: i64 = author
726            .time
727            .split_whitespace()
728            .next()
729            .and_then(|s| s.parse().ok())
730            .unwrap_or(0);
731
732        let dt: DateTime<Utc> = DateTime::from_timestamp(unix_secs, 0).unwrap_or_default();
733        let date = dt.format("%Y-%m-%d").to_string();
734        let date_iso = dt.to_rfc3339();
735        let datetime_display = dt.format("%Y-%m-%d %H:%M UTC").to_string();
736
737        let raw_msg = decoded.message.to_str_lossy();
738        let (subject, body) = parse_message(&raw_msg);
739
740        let hash = id.to_string();
741        let hash_short = hash[..7].to_string();
742
743        let parents = info
744            .parent_ids
745            .iter()
746            .map(|p| {
747                let h = p.to_string();
748                let hs = h[..7].to_string();
749                CommitParent {
750                    hash: h,
751                    hash_short: hs,
752                }
753            })
754            .collect();
755
756        let ref_badges = ref_labels.get(&hash).cloned().unwrap_or_default();
757
758        commits.push(CommitInfo {
759            hash,
760            hash_short,
761            author_name,
762            author_email,
763            date,
764            date_iso,
765            datetime_display,
766            subject,
767            body,
768            parents,
769            ref_badges,
770        });
771    }
772
773    Ok(commits)
774}
775
776/// Collect tags and branches for the refs overview page.
777///
778/// Refs that don't pass `git_ui.include`/`exclude` (see [`ref_is_included`])
779/// are omitted.
780fn collect_refs(
781    repo: &gix::Repository,
782    include: &GlobSet,
783    exclude: &GlobSet,
784) -> Result<(Vec<RefInfo>, Vec<RefInfo>)> {
785    let mut tags: Vec<RefInfo> = Vec::new();
786    let mut branches: Vec<RefInfo> = Vec::new();
787
788    let refs_platform = repo.references().into_diagnostic()?;
789    for reference in refs_platform.all().into_diagnostic()? {
790        let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
791        let name = reference.name().as_bstr().to_str_lossy().into_owned();
792
793        if name.starts_with("refs/remotes/") || name == "HEAD" {
794            continue;
795        }
796
797        let hash = match reference.peel_to_id() {
798            Ok(id) => id.to_string(),
799            Err(_) => continue,
800        };
801        let hash_short = hash[..7.min(hash.len())].to_string();
802
803        if name.starts_with("refs/tags/") {
804            let short_name = name.trim_start_matches("refs/tags/").to_string();
805            if !ref_is_included(&short_name, include, exclude) {
806                continue;
807            }
808            tags.push(RefInfo {
809                name,
810                short_name,
811                hash,
812                hash_short,
813            });
814        } else if name.starts_with("refs/heads/") {
815            let short_name = name.trim_start_matches("refs/heads/").to_string();
816            if !ref_is_included(&short_name, include, exclude) {
817                continue;
818            }
819            branches.push(RefInfo {
820                name,
821                short_name,
822                hash,
823                hash_short,
824            });
825        }
826    }
827
828    // Tags: newest first (reverse-lexicographic ≈ version order for semver tags).
829    tags.sort_by(|a, b| b.name.cmp(&a.name));
830    branches.sort_by(|a, b| a.name.cmp(&b.name));
831
832    Ok((tags, branches))
833}
834
835// ── Changed files via git CLI ─────────────────────────────────────────────────
836
837async fn get_changed_files(commit_hash: &str) -> Result<Vec<ChangedFile>> {
838    let output = tokio::process::Command::new("git")
839        .args(["diff-tree", "-p", "--no-commit-id", "-r", commit_hash])
840        .output()
841        .await
842        .into_diagnostic()?;
843
844    if !output.status.success() {
845        return Ok(vec![]);
846    }
847
848    Ok(parse_diff_output(&String::from_utf8_lossy(&output.stdout)))
849}
850
851/// Parse the output of `git diff-tree -p` into per-file [`ChangedFile`] entries.
852///
853/// Each file section starts with a `diff --git a/<path> b/<path>` line.
854/// Status is inferred from subsequent mode/rename headers.
855/// Diff line kinds are determined by their leading character, with header
856/// lines (`--- `, `+++ `) distinguished from content lines (`-`, `+`) by
857/// the mandatory space that follows the three-character marker.
858fn parse_diff_output(text: &str) -> Vec<ChangedFile> {
859    let mut files: Vec<ChangedFile> = Vec::new();
860    let mut cur_lines: Vec<DiffLine> = Vec::new();
861    let mut cur_path = String::new();
862    let mut cur_status = "modified";
863
864    let push_line = |lines: &mut Vec<DiffLine>, kind, content: &str| {
865        lines.push(DiffLine {
866            kind,
867            content: content.to_string(),
868        });
869    };
870
871    for line in text.lines() {
872        if line.starts_with("diff --git ") {
873            if !cur_path.is_empty() {
874                files.push(ChangedFile {
875                    path: cur_path.clone(),
876                    status: cur_status.to_string(),
877                    diff_lines: std::mem::take(&mut cur_lines),
878                });
879            }
880            // Extract the destination path from the trailing " b/<path>" token.
881            // rsplit_once handles paths that contain spaces.
882            cur_path = line
883                .rsplit_once(" b/")
884                .map(|(_, p)| p.to_string())
885                .unwrap_or_default();
886            cur_status = "modified";
887            push_line(&mut cur_lines, DiffLineKind::Header, line);
888        } else if line.starts_with("new file mode") {
889            cur_status = "added";
890            push_line(&mut cur_lines, DiffLineKind::Header, line);
891        } else if line.starts_with("deleted file mode") {
892            cur_status = "deleted";
893            push_line(&mut cur_lines, DiffLineKind::Header, line);
894        } else if line.starts_with("rename from") || line.starts_with("rename to") {
895            cur_status = "renamed";
896            push_line(&mut cur_lines, DiffLineKind::Header, line);
897        } else if line.starts_with("similarity index")
898            || line.starts_with("copy from")
899            || line.starts_with("copy to")
900            || line.starts_with("index ")
901            || line.starts_with("--- ")   // file header, not a removed line
902            || line.starts_with("+++ ")   // file header, not an added line
903            || line.starts_with("Binary files")
904            || line.starts_with('\\')
905        {
906            push_line(&mut cur_lines, DiffLineKind::Header, line);
907        } else if line.starts_with("@@") {
908            push_line(&mut cur_lines, DiffLineKind::Hunk, line);
909        } else if line.starts_with('+') {
910            push_line(&mut cur_lines, DiffLineKind::Added, line);
911        } else if line.starts_with('-') {
912            push_line(&mut cur_lines, DiffLineKind::Removed, line);
913        } else {
914            push_line(&mut cur_lines, DiffLineKind::Context, line);
915        }
916    }
917
918    if !cur_path.is_empty() {
919        files.push(ChangedFile {
920            path: cur_path,
921            status: cur_status.to_string(),
922            diff_lines: cur_lines,
923        });
924    }
925
926    files
927}
928
929// ── Bare clone export ─────────────────────────────────────────────────────────
930
931/// Clone `source` as a bare repository at `dest`, then prune it down to the
932/// branches/tags allowed by `git_ui.include`/`git_ui.exclude` before enabling
933/// the dumb HTTP transport.
934///
935/// `default_branch` is used to pick a sane `HEAD` for the bare clone if the
936/// branch it previously pointed to got filtered out.
937async fn export_bare_clone(
938    source: &Path,
939    dest: &Path,
940    default_branch: &str,
941    include: &GlobSet,
942    exclude: &GlobSet,
943) -> Result<()> {
944    use tokio::process::Command;
945
946    if dest.exists() {
947        tokio::fs::remove_dir_all(dest).await.into_diagnostic()?;
948    }
949
950    let status = Command::new("git")
951        .arg("clone")
952        .arg("--bare")
953        .arg(source)
954        .arg(dest)
955        .stdout(std::process::Stdio::null())
956        .stderr(std::process::Stdio::null())
957        .status()
958        .await
959        .into_diagnostic()?;
960
961    if !status.success() {
962        return Err(miette::miette!(
963            "git clone --bare failed with exit status {status}"
964        ));
965    }
966
967    prune_excluded_refs(dest, default_branch, include, exclude).await?;
968
969    // Enable dumb HTTP transport so the bare repo is clonable over plain HTTPS.
970    let status = Command::new("git")
971        .arg("-C")
972        .arg(dest)
973        .arg("update-server-info")
974        .stdout(std::process::Stdio::null())
975        .stderr(std::process::Stdio::null())
976        .status()
977        .await
978        .into_diagnostic()?;
979
980    if !status.success() {
981        warn!("git update-server-info failed; dumb HTTP cloning may not work");
982    }
983
984    Ok(())
985}
986
987/// Delete every branch/tag in the bare clone at `dest` that doesn't pass
988/// `git_ui.include`/`git_ui.exclude` (see [`ref_is_included`]), then run
989/// `git gc --prune=now` so the excluded history isn't merely unlisted but
990/// actually removed from what dumb HTTP ends up serving from disk.
991///
992/// A no-op (skips even the ref scan) when both `include` and `exclude` are
993/// empty, which is the common case.
994async fn prune_excluded_refs(
995    dest: &Path,
996    default_branch: &str,
997    include: &GlobSet,
998    exclude: &GlobSet,
999) -> Result<()> {
1000    use tokio::process::Command;
1001
1002    if include.is_empty() && exclude.is_empty() {
1003        return Ok(());
1004    }
1005
1006    let output = Command::new("git")
1007        .arg("-C")
1008        .arg(dest)
1009        .arg("for-each-ref")
1010        .arg("--format=%(refname)")
1011        .arg("refs/heads")
1012        .arg("refs/tags")
1013        .output()
1014        .await
1015        .into_diagnostic()?;
1016
1017    if !output.status.success() {
1018        warn!("git for-each-ref failed; skipping include/exclude pruning of repository.git");
1019        return Ok(());
1020    }
1021
1022    let refnames = String::from_utf8_lossy(&output.stdout);
1023    let mut removed_any = false;
1024    let mut kept_branches: Vec<String> = Vec::new();
1025
1026    for refname in refnames.lines() {
1027        let short_name = match refname
1028            .strip_prefix("refs/heads/")
1029            .or_else(|| refname.strip_prefix("refs/tags/"))
1030        {
1031            Some(s) => s,
1032            None => continue,
1033        };
1034
1035        if ref_is_included(short_name, include, exclude) {
1036            if refname.starts_with("refs/heads/") {
1037                kept_branches.push(short_name.to_string());
1038            }
1039            continue;
1040        }
1041
1042        let status = Command::new("git")
1043            .arg("-C")
1044            .arg(dest)
1045            .arg("update-ref")
1046            .arg("-d")
1047            .arg(refname)
1048            .stdout(std::process::Stdio::null())
1049            .stderr(std::process::Stdio::null())
1050            .status()
1051            .await
1052            .into_diagnostic()?;
1053
1054        if status.success() {
1055            removed_any = true;
1056        } else {
1057            warn!("failed to delete excluded ref '{refname}' from repository.git");
1058        }
1059    }
1060
1061    if !removed_any {
1062        return Ok(());
1063    }
1064
1065    // `HEAD` may have pointed at a branch we just deleted; repoint it at the
1066    // configured default branch if it survived the filter, or otherwise at
1067    // whatever branch is left, so `git clone` of `repository.git` still
1068    // checks out something sensible.
1069    let new_head = if kept_branches.iter().any(|b| b == default_branch) {
1070        Some(default_branch.to_string())
1071    } else if let Some(first) = kept_branches.first() {
1072        warn!(
1073            "git_ui: default branch '{}' excluded from repository.git; using '{}' for HEAD instead",
1074            default_branch, first
1075        );
1076        Some(first.clone())
1077    } else {
1078        warn!(
1079            "git_ui: include/exclude filtered out every branch; repository.git will have no usable HEAD"
1080        );
1081        None
1082    };
1083
1084    if let Some(branch) = new_head {
1085        let status = Command::new("git")
1086            .arg("-C")
1087            .arg(dest)
1088            .arg("symbolic-ref")
1089            .arg("HEAD")
1090            .arg(format!("refs/heads/{branch}"))
1091            .stdout(std::process::Stdio::null())
1092            .stderr(std::process::Stdio::null())
1093            .status()
1094            .await
1095            .into_diagnostic()?;
1096
1097        if !status.success() {
1098            warn!("failed to repoint HEAD in repository.git after pruning excluded refs");
1099        }
1100    }
1101
1102    // Physically remove the now-unreachable objects so excluded branches/tags
1103    // aren't just unlisted but actually absent from the published repo.
1104    let status = Command::new("git")
1105        .arg("-C")
1106        .arg(dest)
1107        .arg("gc")
1108        .arg("--prune=now")
1109        .arg("--quiet")
1110        .status()
1111        .await
1112        .into_diagnostic()?;
1113
1114    if !status.success() {
1115        warn!(
1116            "git gc --prune=now failed on repository.git; excluded objects may still be present on disk"
1117        );
1118    }
1119
1120    Ok(())
1121}
1122
1123// ── Helpers ────────────────────────────────────────────────────────────────────
1124
1125/// Split a raw commit message into (subject, optional body).
1126fn parse_message(raw: &str) -> (String, Option<String>) {
1127    if let Some(idx) = raw.find("\n\n") {
1128        let subject = raw[..idx].trim().to_string();
1129        let body = raw[idx + 2..].trim().to_string();
1130        let body = if body.is_empty() { None } else { Some(body) };
1131        (subject, body)
1132    } else {
1133        (raw.trim().to_string(), None)
1134    }
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139    use super::*;
1140
1141    #[test]
1142    fn test_generate_clone_url_custom() {
1143        let config = AbbayeConfig::default();
1144        let git_cfg = GitUiConfig {
1145            clone_url: Some("https://git.example.com/repo.git".into()),
1146            ..Default::default()
1147        };
1148        let url = generate_clone_url(&config, &git_cfg);
1149        assert_eq!(url.as_deref(), Some("https://git.example.com/repo.git"));
1150    }
1151
1152    #[test]
1153    fn test_generate_clone_url_derived() {
1154        let config = AbbayeConfig {
1155            site: crate::config::SiteConfig {
1156                name: String::new(),
1157                base_url: Some("https://example.com".into()),
1158                ..Default::default()
1159            },
1160            ..Default::default()
1161        };
1162        let git_cfg = GitUiConfig::default();
1163        let url = generate_clone_url(&config, &git_cfg);
1164        assert_eq!(url.as_deref(), Some("https://example.com/repository.git"));
1165    }
1166
1167    #[test]
1168    fn test_generate_clone_url_neither() {
1169        let config = AbbayeConfig::default();
1170        let git_cfg = GitUiConfig::default();
1171        let url = generate_clone_url(&config, &git_cfg);
1172        assert!(url.is_none());
1173    }
1174
1175    #[test]
1176    fn test_parse_message_subject_only() {
1177        let (subj, body) = parse_message("fix: a bug");
1178        assert_eq!(subj, "fix: a bug");
1179        assert!(body.is_none());
1180    }
1181
1182    #[test]
1183    fn test_parse_message_subject_and_body() {
1184        let (subj, body) = parse_message("feat: add widget\n\nThis is a long description.");
1185        assert_eq!(subj, "feat: add widget");
1186        assert_eq!(body.as_deref(), Some("This is a long description."));
1187    }
1188
1189    #[test]
1190    fn test_parse_message_empty_body() {
1191        let (subj, body) = parse_message("chore: bump\n\n  ");
1192        assert_eq!(subj, "chore: bump");
1193        assert!(body.is_none());
1194    }
1195
1196    #[test]
1197    fn test_parse_message_multi_paragraph() {
1198        let msg = "major: breaking\n\nFirst paragraph.\n\nSecond paragraph.";
1199        let (subj, body) = parse_message(msg);
1200        assert_eq!(subj, "major: breaking");
1201        assert_eq!(
1202            body.as_deref(),
1203            Some("First paragraph.\n\nSecond paragraph.")
1204        );
1205    }
1206
1207    #[test]
1208    fn test_parse_diff_output_empty() {
1209        let files = parse_diff_output("");
1210        assert!(files.is_empty());
1211    }
1212
1213    #[test]
1214    fn test_parse_diff_output_single_file() {
1215        let diff = "\
1216diff --git a/src/main.rs b/src/main.rs
1217new file mode 100644
1218index 0000000..e69de29
1219--- /dev/null
1220+++ b/src/main.rs
1221@@ -0,0 +1 @@
1222+hello
1223";
1224        let files = parse_diff_output(diff);
1225        assert_eq!(files.len(), 1);
1226        assert_eq!(files[0].path, "src/main.rs");
1227        assert_eq!(files[0].status, "added");
1228        assert!(files[0].diff_lines.iter().any(|l| l.content == "+hello"));
1229    }
1230
1231    #[test]
1232    fn test_parse_diff_output_multiple_files() {
1233        let diff = "\
1234diff --git a/a.txt b/a.txt
1235deleted file mode 100644
1236--- a/a.txt
1237+++ /dev/null
1238@@ -1 +0,0 @@
1239-abc
1240diff --git a/b.txt b/b.txt
1241new file mode 100644
1242--- /dev/null
1243+++ b/b.txt
1244@@ -0,0 +1 @@
1245+xyz
1246";
1247        let files = parse_diff_output(diff);
1248        assert_eq!(files.len(), 2);
1249        assert_eq!(files[0].path, "a.txt");
1250        assert_eq!(files[0].status, "deleted");
1251        assert_eq!(files[1].path, "b.txt");
1252        assert_eq!(files[1].status, "added");
1253    }
1254
1255    #[test]
1256    fn test_parse_diff_output_rename() {
1257        let diff = "\
1258diff --git a/old.rs b/new.rs
1259rename from old.rs
1260rename to new.rs
1261";
1262        let files = parse_diff_output(diff);
1263        assert_eq!(files.len(), 1);
1264        assert_eq!(files[0].status, "renamed");
1265        assert_eq!(files[0].path, "new.rs");
1266    }
1267
1268    #[test]
1269    fn test_parse_diff_output_binary() {
1270        let diff = "\
1271diff --git a/data.bin b/data.bin
1272Binary files a/data.bin and b/data.bin differ
1273";
1274        let files = parse_diff_output(diff);
1275        assert_eq!(files.len(), 1);
1276        assert_eq!(files[0].path, "data.bin");
1277        assert!(
1278            files[0]
1279                .diff_lines
1280                .iter()
1281                .any(|l| l.content.contains("Binary"))
1282        );
1283    }
1284}