Abbaye

at de43235

//! Generates a static git repository web UI and a clonable bare clone.
//!
//! Produces:
//! - `<output>/repository/`      - one HTML log page per branch, refs page,
//!                                  per-commit detail pages
//! - `<output>/repository.git/`  - bare clone suitable for dumb HTTP serving
//!
//! The branch named by `git_ui.default_branch` is rendered to `index.html`;
//! every other branch gets `<sanitized-name>.html`.
//!
//! It also generates `<output>/repository/browse/<hash>/` - a full recursive
//! static file tree browser with server-side syntax highlighting (via syntect),
//! generated for every branch tip and every tagged commit.
//!
//! This is a site-level step called once from `main.rs`, not a per-version Builder.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;

use chrono::{DateTime, Utc};
use gix::bstr::ByteSlice;
use globset::{Glob, GlobSet, GlobSetBuilder};
use indicatif::ProgressBar;
use miette::{IntoDiagnostic, Result};

use crate::cli::{CYAN, GREEN, RED, RESET};
use serde::Serialize;
use tera::{Context, Tera};
use tracing::{info, warn};

use crate::config::{AbbayeConfig, GitUiConfig};

// ── Template sources ──────────────────────────────────────────────────────────

pub const TEMPLATE_GIT_LOG_HTML: &str = include_str!("templates/git_log.html.j2");
pub const TEMPLATE_GIT_COMMIT_HTML: &str = include_str!("templates/git_commit.html.j2");
pub const TEMPLATE_GIT_REFS_HTML: &str = include_str!("templates/git_refs.html.j2");
pub const TEMPLATE_GIT_TREE_HTML: &str = include_str!("templates/git_tree.html.j2");
pub const TEMPLATE_GIT_BLOB_HTML: &str = include_str!("templates/git_blob.html.j2");

pub const TEMPLATE_GIT_LOG_GEMTEXT: &str = include_str!("templates/git_log.gmi.j2");
pub const TEMPLATE_GIT_COMMIT_GEMTEXT: &str = include_str!("templates/git_commit.gmi.j2");
pub const TEMPLATE_GIT_REFS_GEMTEXT: &str = include_str!("templates/git_refs.gmi.j2");
pub const TEMPLATE_GIT_TREE_GEMTEXT: &str = include_str!("templates/git_tree.gmi.j2");
pub const TEMPLATE_GIT_BLOB_GEMTEXT: &str = include_str!("templates/git_blob.gmi.j2");

// ── Template-facing data structures ──────────────────────────────────────────

#[derive(Clone, Serialize)]
struct CommitParent {
    hash: String,
    hash_short: String,
}

/// A single commit's metadata, passed to Tera templates.
/// `Clone` is required so commits can be deduplicated across branches.
#[derive(Clone, Serialize)]
struct CommitInfo {
    hash: String,
    hash_short: String,
    author_name: String,
    author_email: String,
    /// ISO-8601 timestamp for `<time datetime="">`.
    date_iso: String,
    /// Short date for the log table (YYYY-MM-DD).
    date: String,
    /// Date + time for the commit detail page.
    datetime_display: String,
    /// First line of the commit message.
    subject: String,
    /// Everything after the blank line separator, if present.
    body: Option<String>,
    parents: Vec<CommitParent>,
    /// Tags and branches whose tip is exactly this commit.
    ref_badges: Vec<RefBadge>,
}

#[derive(Serialize)]
struct RefInfo {
    name: String,
    short_name: String,
    hash: String,
    hash_short: String,
}

/// The visual kind of a single unified-diff line.
/// Serialises as lowercase for use as a CSS modifier class.
#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
enum DiffLineKind {
    Header,  // diff --git, index, ---, +++, mode lines
    Hunk,    // @@ -a,b +c,d @@
    Added,   // lines beginning with +
    Removed, // lines beginning with -
    Context, // unchanged surrounding lines
}

#[derive(Serialize)]
struct DiffLine {
    kind: DiffLineKind,
    content: String,
}

#[derive(Serialize)]
struct ChangedFile {
    path: String,
    /// One of: added, deleted, modified, renamed, copied, changed.
    status: String,
    diff_lines: Vec<DiffLine>,
}

/// Discriminates the two kinds of ref badge shown on log pages.
/// Serialises as lowercase (`"tag"` / `"branch"`) for use as a CSS modifier class.
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
enum RefBadgeKind {
    /// Derived `Ord`: `Tag < Branch`, so tags sort before branches.
    Tag,
    Branch,
}

/// A ref badge shown next to a commit hash on log pages.
#[derive(Clone, Serialize)]
struct RefBadge {
    label: String,
    kind: RefBadgeKind,
}

/// One entry in the branch-switcher nav rendered on every log page.
#[derive(Serialize)]
struct BranchNav {
    short_name: String,
    /// HTML filename for this branch ("index.html" or "<name>.html").
    filename: String,
    is_current: bool,
}

// ── Internal branch descriptor (never serialized) ────────────────────────────

#[derive(Clone)]
struct BranchEntry {
    short_name: String,
    /// Output filename: "index.html" for the default branch, else a sanitized name.
    filename: String,
    tip: gix::ObjectId,
}

// ── Public entry point ────────────────────────────────────────────────────────

/// Shared spinner style - matches the builder spinners in `site.rs`.
fn make_spinner(label: &str) -> ProgressBar {
    let pb = ProgressBar::new_spinner();
    pb.set_style(crate::site::spinner_style(false));
    pb.set_prefix(format!("{CYAN}[{label}]{RESET}"));
    pb.enable_steady_tick(Duration::from_millis(100));
    pb
}

/// A lightweight progress reporter that either drives an `indicatif` spinner
/// (interactive terminals) or logs via `tracing::info!` (dumb / piped terminals).
enum Progress {
    /// Interactive terminal – show a spinner.
    Spinner(ProgressBar),
    /// Non-interactive terminal – just log messages.
    Log,
}

impl Progress {
    fn new(label: &str) -> Self {
        if crate::utils::is_interactive() {
            let pb = make_spinner(label);
            pb.set_message("starting…");
            Self::Spinner(pb)
        } else {
            info!("[{label}] starting …");
            Self::Log
        }
    }

    fn set_message(&self, msg: impl Into<String>) {
        match self {
            Self::Spinner(pb) => pb.set_message(msg.into()),
            Self::Log => info!("[git ui] {}", msg.into()),
        }
    }

    fn finish_done(&self) {
        match self {
            Self::Spinner(pb) => {
                pb.finish_with_message(format!("{GREEN}\u{2713} done{RESET}"));
            }
            Self::Log => info!("[git ui] done"),
        }
    }

    fn finish_failed(&self, detail: &miette::Report) {
        match self {
            Self::Spinner(pb) => {
                pb.finish_with_message(format!("{RED}\u{2717} failed: {detail}{RESET}"));
            }
            Self::Log => warn!("[git ui] failed: {detail}"),
        }
    }
}

pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Result<()> {
    let output_dir = &config.site.output_dir;
    let ui_dir = output_dir.join("repository");
    let bare_dir = output_dir.join("repository.git");

    tokio::fs::create_dir_all(&ui_dir).await.into_diagnostic()?;
    tokio::fs::create_dir_all(ui_dir.join("commit"))
        .await
        .into_diagnostic()?;

    let repo_path: PathBuf = git_cfg
        .repo_path
        .clone()
        .unwrap_or_else(|| PathBuf::from("."));

    let max_commits = git_cfg.max_commits;
    let default_branch = git_cfg.default_branch.clone();
    let include = build_globset(&git_cfg.include)?;
    let exclude = build_globset(&git_cfg.exclude)?;
    let repo_path_clone = repo_path.clone();

    // `include`/`exclude`/`default_branch` are moved into the `spawn_blocking`
    // closure below, so keep clones around for `export_bare_clone`, which
    // needs the same filters to decide what to publish in `repository.git`.
    let bare_default_branch = default_branch.clone();
    let bare_include = include.clone();
    let bare_exclude = exclude.clone();

    // Compute clone URL before the blocking task so we can pass it into the
    // browse page generator without re-deriving it.
    let clone_url = generate_clone_command(config, git_cfg);

    // ── All gix work happens inside one blocking task (Repository is !Send) ───
    //
    // Returns:
    //   branch_pages     - (short_name, filename, commits) per branch
    //   unique_commits   - all commits across all branches, deduplicated
    //   tags / ref_branches - for refs.html
    //   browse_revisions - (hex_hash, ObjectId) for every branch tip + tag tip
    let (branch_pages, unique_commits, tags, ref_branches, browse_revisions) =
        tokio::task::spawn_blocking(move || -> Result<_> {
            let repo = match gix::discover(&repo_path_clone) {
                Ok(r) => r,
                Err(e) => {
                    warn!(
                        "git_ui: could not open repository at {}: {e}",
                        repo_path_clone.display()
                    );
                    return Ok((vec![], vec![], vec![], vec![], vec![]));
                }
            };

            let branches = collect_branch_entries(&repo, &default_branch, &include, &exclude)?;
            let (tags, ref_branches) = collect_refs(&repo, &include, &exclude)?;
            let ref_labels = build_ref_labels(&repo, &include, &exclude)?;

            // Walk commits per branch; collect unique commits for detail pages.
            let mut unique_map: HashMap<String, CommitInfo> = HashMap::new();
            let mut branch_pages: Vec<(String, String, Vec<CommitInfo>)> = Vec::new();

            for branch in &branches {
                let commits = collect_commits(&repo, branch.tip, max_commits, &ref_labels)?;
                for c in &commits {
                    unique_map
                        .entry(c.hash.clone())
                        .or_insert_with(|| c.clone());
                }
                branch_pages.push((branch.short_name.clone(), branch.filename.clone(), commits));
            }

            let unique_commits: Vec<CommitInfo> = unique_map.into_values().collect();

            // Collect revisions for the tree browser: branch tips + tag tips.
            let mut seen: HashMap<String, gix::ObjectId> = HashMap::new();
            for branch in &branches {
                seen.insert(branch.tip.to_string(), branch.tip);
            }
            let refs_platform = repo.references().into_diagnostic()?;
            for reference in refs_platform.all().into_diagnostic()? {
                let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
                let name = reference.name().as_bstr().to_str_lossy().into_owned();
                if !name.starts_with("refs/tags/") {
                    continue;
                }
                let short_name = name.trim_start_matches("refs/tags/");
                if !ref_is_included(short_name, &include, &exclude) {
                    continue;
                }
                if let Ok(id) = reference.peel_to_id() {
                    let hash = id.to_string();
                    seen.entry(hash).or_insert_with(|| id.detach());
                }
            }
            let browse_revisions: Vec<(String, gix::ObjectId)> = seen.into_iter().collect();

            Ok((
                branch_pages,
                unique_commits,
                tags,
                ref_branches,
                browse_revisions,
            ))
        })
        .await
        .into_diagnostic()??;

    if branch_pages.is_empty() {
        // Nothing to render (empty repo or no branches).
        return Ok(());
    }

    let progress = Progress::new("git ui");

    // ── Bare clone + dumb HTTP setup ──────────────────────────────────────────
    progress.set_message("cloning bare repository…");
    if let Err(e) = export_bare_clone(
        &repo_path,
        &bare_dir,
        &bare_default_branch,
        &bare_include,
        &bare_exclude,
    )
    .await
    {
        progress.finish_failed(&e);
        return Err(e);
    }

    // ── Tera setup ────────────────────────────────────────────────────────────
    let mut tera = Tera::default();
    let theme_path = PathBuf::from(".abbaye").join("theme");

    crate::site::register_format_templates(
        &mut tera,
        &theme_path,
        &config.site.formats,
        &[
            ("git_log", TEMPLATE_GIT_LOG_HTML, TEMPLATE_GIT_LOG_GEMTEXT),
            (
                "git_commit",
                TEMPLATE_GIT_COMMIT_HTML,
                TEMPLATE_GIT_COMMIT_GEMTEXT,
            ),
            (
                "git_refs",
                TEMPLATE_GIT_REFS_HTML,
                TEMPLATE_GIT_REFS_GEMTEXT,
            ),
        ],
    )?;

    // ── Branch switcher nav (shared across all log pages) ─────────────────────
    //
    // Order: the default branch (index.html) first, then alphabetical.
    let mut nav_entries: Vec<(String, String)> = branch_pages
        .iter()
        .map(|(name, file, _)| (name.clone(), file.clone()))
        .collect();
    nav_entries.sort_by(|(na, fa), (nb, fb)| {
        let a_default = fa == "index.html";
        let b_default = fb == "index.html";
        b_default.cmp(&a_default).then(na.cmp(nb))
    });

    // ── Render one log page per branch (per format) ───────────────────────────
    progress.set_message("rendering log pages…");
    for (short_name, filename, commits) in &branch_pages {
        let truncated = commits.len() >= max_commits;

        let branch_nav: Vec<BranchNav> = nav_entries
            .iter()
            .map(|(bn, bf)| BranchNav {
                short_name: bn.clone(),
                filename: bf.clone(),
                is_current: bn == short_name,
            })
            .collect();

        for format in &config.site.formats {
            let suffix = format.extension();
            let tmpl_name = format!("git_log.{suffix}");
            let ext = format.extension();
            let out_filename = filename.replace(".html", &format!(".{ext}"));

            let mut ctx = Context::new();
            ctx.insert("project_name", &config.site.name);
            ctx.insert("lang", &config.site.lang);
            ctx.insert("clone_url", &clone_url);
            ctx.insert("current_branch", short_name);
            ctx.insert("branch_nav", &branch_nav);
            ctx.insert("commits", commits);
            ctx.insert("truncated", &truncated);
            ctx.insert("root_path", "../");

            let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?;
            tokio::fs::write(ui_dir.join(&out_filename), content)
                .await
                .into_diagnostic()?;
        }
    }

    // ── Render refs page (per format) ─────────────────────────────────────────
    for format in &config.site.formats {
        let suffix = format.extension();
        let tmpl_name = format!("git_refs.{suffix}");
        let ext = format.extension();
        let out_filename = format!("refs.{ext}");

        let mut ctx = Context::new();
        ctx.insert("project_name", &config.site.name);
        ctx.insert("lang", &config.site.lang);
        ctx.insert("clone_url", &clone_url);
        ctx.insert("tags", &tags);
        ctx.insert("branches", &ref_branches);
        ctx.insert("root_path", "../");

        let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?;
        tokio::fs::write(ui_dir.join(&out_filename), content)
            .await
            .into_diagnostic()?;
    }

    // ── Render per-commit detail pages (per format) ───────────────────────────
    progress.set_message(format!("rendering {} commit pages…", unique_commits.len()));
    for commit_info in &unique_commits {
        let changed_files = get_changed_files(&commit_info.hash).await?;
        let has_browse = !commit_info.ref_badges.is_empty();
        let commit_dir = ui_dir.join("commit");

        for format in &config.site.formats {
            let suffix = format.extension();
            let tmpl_name = format!("git_commit.{suffix}");
            let ext = format.extension();
            let out_filename = format!("{}.{ext}", commit_info.hash);

            let mut ctx = Context::new();
            ctx.insert("project_name", &config.site.name);
            ctx.insert("lang", &config.site.lang);
            ctx.insert("clone_url", &clone_url);
            ctx.insert("commit", commit_info);
            ctx.insert("changed_files", &changed_files);
            ctx.insert("has_browse", &has_browse);
            ctx.insert("root_path", "../../");

            let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?;
            tokio::fs::write(commit_dir.join(&out_filename), content)
                .await
                .into_diagnostic()?;
        }
    }

    // ── Tree browser (browse/<hash>/) ─────────────────────────────────────────
    if !browse_revisions.is_empty() {
        progress.set_message(format!(
            "building browse pages for {} revision(s)…",
            browse_revisions.len()
        ));
        let browse_dir = ui_dir.join("browse");
        tokio::fs::create_dir_all(&browse_dir)
            .await
            .into_diagnostic()?;

        let project_name = config.site.name.clone();
        let lang = config.site.lang.clone();
        let clone_url_browse = clone_url.clone();
        let theme_path = PathBuf::from(".abbaye").join("theme");
        let repo_path_browse = repo_path.clone();
        let formats = config.site.formats.clone();

        tokio::task::spawn_blocking(move || {
            crate::git_browse::build_browse_pages(
                &browse_revisions,
                &browse_dir,
                &repo_path_browse,
                &theme_path,
                &project_name,
                &lang,
                &clone_url_browse,
                &formats,
            )
        })
        .await
        .into_diagnostic()??
    }

    progress.finish_done();
    Ok(())
}

pub fn generate_clone_command(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Option<String> {
    let clone_url: Option<String> = git_cfg.clone_url.clone().or_else(|| {
        config.site.base_url.as_ref().map(|base| {
            format!(
                "{}/repository.git {}",
                base.trim_end_matches('/'),
                if config.site.name.contains(" ") {
                    format!("'{}'", config.site.name)
                } else {
                    config.site.name.clone()
                }
            )
        })
    });
    clone_url
}

// ── Git data collection ───────────────────────────────────────────────────────

/// Compile a list of glob patterns (as written in `git_ui.exclude`/`include`)
/// into a [`GlobSet`]. An empty pattern list compiles to an empty `GlobSet`,
/// which matches nothing.
fn build_globset(patterns: &[String]) -> Result<GlobSet> {
    let mut builder = GlobSetBuilder::new();
    for pattern in patterns {
        let glob = Glob::new(pattern)
            .map_err(|e| miette::miette!("git_ui: invalid glob pattern '{pattern}': {e}"))?;
        builder.add(glob);
    }
    builder.build().into_diagnostic()
}

/// Decide whether a ref (by its short name, e.g. `"main"` or `"v1.0.0"`)
/// should appear in the generated UI, per `git_ui.exclude`/`git_ui.include`.
///
/// When `include` is non-empty, it acts as an allowlist: only refs matching
/// at least one `include` pattern are kept. When `include` is empty, every
/// ref is a candidate. From the resulting candidates, any ref matching an
/// `exclude` pattern is then dropped.
fn ref_is_included(short_name: &str, include: &GlobSet, exclude: &GlobSet) -> bool {
    let included = include.is_empty() || include.is_match(short_name);
    included && !exclude.is_match(short_name)
}

/// Collect all local branches and assign output filenames.
///
/// The branch whose short name matches `default_branch` gets `"index.html"`.
/// Every other branch gets `"<sanitized-short-name>.html"` where `/` is
/// replaced by `-`.
///
/// If no branch matches `default_branch`, the first branch (alphabetically)
/// receives `"index.html"` and a warning is emitted.
///
/// Branches whose short name doesn't pass `git_ui.include`/`exclude` (see
/// [`ref_is_included`]) are skipped entirely.
fn collect_branch_entries(
    repo: &gix::Repository,
    default_branch: &str,
    include: &GlobSet,
    exclude: &GlobSet,
) -> Result<Vec<BranchEntry>> {
    let mut entries: Vec<BranchEntry> = Vec::new();

    let refs_platform = repo.references().into_diagnostic()?;
    for reference in refs_platform.all().into_diagnostic()? {
        let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
        let name = reference.name().as_bstr().to_str_lossy().into_owned();

        if !name.starts_with("refs/heads/") {
            continue;
        }

        let short_name = name.trim_start_matches("refs/heads/").to_string();
        if !ref_is_included(&short_name, include, exclude) {
            continue;
        }
        let tip = match reference.peel_to_id() {
            Ok(id) => id.detach(),
            Err(_) => continue,
        };

        // Tentative filename; replaced for the default branch below.
        let filename = format!("{}.html", short_name.replace('/', "-"));
        entries.push(BranchEntry {
            short_name,
            filename,
            tip,
        });
    }

    // Stable, predictable page order.
    entries.sort_by(|a, b| a.short_name.cmp(&b.short_name));

    // Assign index.html to the configured default branch.
    if let Some(e) = entries.iter_mut().find(|e| e.short_name == default_branch) {
        e.filename = "index.html".to_string();
    } else if let Some(first) = entries.first_mut() {
        warn!(
            "git_ui: default branch '{}' not found; using '{}' as index.html",
            default_branch, first.short_name
        );
        first.filename = "index.html".to_string();
    }

    Ok(entries)
}

/// Build a map from commit hash (hex string) to the ref badges pointing at it.
/// Tags come before branches within each entry; both are sorted alphabetically.
///
/// Refs that don't pass `git_ui.include`/`exclude` (see [`ref_is_included`])
/// are omitted.
fn build_ref_labels(
    repo: &gix::Repository,
    include: &GlobSet,
    exclude: &GlobSet,
) -> Result<HashMap<String, Vec<RefBadge>>> {
    let mut map: HashMap<String, Vec<RefBadge>> = HashMap::new();

    let refs_platform = repo.references().into_diagnostic()?;
    for reference in refs_platform.all().into_diagnostic()? {
        let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
        let name = reference.name().as_bstr().to_str_lossy().into_owned();

        if name == "HEAD" || name.starts_with("refs/remotes/") {
            continue;
        }

        let hash = match reference.peel_to_id() {
            Ok(id) => id.to_string(),
            Err(_) => continue,
        };

        let badge = if let Some(label) = name.strip_prefix("refs/tags/") {
            if !ref_is_included(label, include, exclude) {
                continue;
            }
            RefBadge {
                label: label.to_string(),
                kind: RefBadgeKind::Tag,
            }
        } else if let Some(label) = name.strip_prefix("refs/heads/") {
            if !ref_is_included(label, include, exclude) {
                continue;
            }
            RefBadge {
                label: label.to_string(),
                kind: RefBadgeKind::Branch,
            }
        } else {
            continue;
        };

        map.entry(hash).or_default().push(badge);
    }

    // Within each entry: tags first (Tag < Branch via Ord), then alphabetical.
    for badges in map.values_mut() {
        badges.sort_by(|a, b| a.kind.cmp(&b.kind).then(a.label.cmp(&b.label)));
    }

    Ok(map)
}

/// Walk at most `max` commits reachable from `tip`, newest first.
fn collect_commits(
    repo: &gix::Repository,
    tip: gix::ObjectId,
    max: usize,
    ref_labels: &HashMap<String, Vec<RefBadge>>,
) -> Result<Vec<CommitInfo>> {
    let walk = repo.rev_walk([tip]).all().into_diagnostic()?;
    let mut commits = Vec::new();

    for info in walk.take(max) {
        let info = info.into_diagnostic()?;
        let id = info.id;

        let object = repo.find_object(id).into_diagnostic()?;
        let commit = object.into_commit();
        let decoded = commit.decode().into_diagnostic()?;

        let author = decoded.author().into_diagnostic()?;
        let author_name = author.name.to_str_lossy().into_owned();
        let author_email = author.email.to_str_lossy().into_owned();
        let unix_secs: i64 = author
            .time
            .split_whitespace()
            .next()
            .and_then(|s| s.parse().ok())
            .unwrap_or(0);

        let dt: DateTime<Utc> = DateTime::from_timestamp(unix_secs, 0).unwrap_or_default();
        let date = dt.format("%Y-%m-%d").to_string();
        let date_iso = dt.to_rfc3339();
        let datetime_display = dt.format("%Y-%m-%d %H:%M UTC").to_string();

        let raw_msg = decoded.message.to_str_lossy();
        let (subject, body) = parse_message(&raw_msg);

        let hash = id.to_string();
        let hash_short = hash[..7].to_string();

        let parents = info
            .parent_ids
            .iter()
            .map(|p| {
                let h = p.to_string();
                let hs = h[..7].to_string();
                CommitParent {
                    hash: h,
                    hash_short: hs,
                }
            })
            .collect();

        let ref_badges = ref_labels.get(&hash).cloned().unwrap_or_default();

        commits.push(CommitInfo {
            hash,
            hash_short,
            author_name,
            author_email,
            date,
            date_iso,
            datetime_display,
            subject,
            body,
            parents,
            ref_badges,
        });
    }

    Ok(commits)
}

/// Collect tags and branches for the refs overview page.
///
/// Refs that don't pass `git_ui.include`/`exclude` (see [`ref_is_included`])
/// are omitted.
fn collect_refs(
    repo: &gix::Repository,
    include: &GlobSet,
    exclude: &GlobSet,
) -> Result<(Vec<RefInfo>, Vec<RefInfo>)> {
    let mut tags: Vec<RefInfo> = Vec::new();
    let mut branches: Vec<RefInfo> = Vec::new();

    let refs_platform = repo.references().into_diagnostic()?;
    for reference in refs_platform.all().into_diagnostic()? {
        let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
        let name = reference.name().as_bstr().to_str_lossy().into_owned();

        if name.starts_with("refs/remotes/") || name == "HEAD" {
            continue;
        }

        let hash = match reference.peel_to_id() {
            Ok(id) => id.to_string(),
            Err(_) => continue,
        };
        let hash_short = hash[..7.min(hash.len())].to_string();

        if name.starts_with("refs/tags/") {
            let short_name = name.trim_start_matches("refs/tags/").to_string();
            if !ref_is_included(&short_name, include, exclude) {
                continue;
            }
            tags.push(RefInfo {
                name,
                short_name,
                hash,
                hash_short,
            });
        } else if name.starts_with("refs/heads/") {
            let short_name = name.trim_start_matches("refs/heads/").to_string();
            if !ref_is_included(&short_name, include, exclude) {
                continue;
            }
            branches.push(RefInfo {
                name,
                short_name,
                hash,
                hash_short,
            });
        }
    }

    // Tags: newest first (reverse-lexicographic ≈ version order for semver tags).
    tags.sort_by(|a, b| b.name.cmp(&a.name));
    branches.sort_by(|a, b| a.name.cmp(&b.name));

    Ok((tags, branches))
}

// ── Changed files via git CLI ─────────────────────────────────────────────────

async fn get_changed_files(commit_hash: &str) -> Result<Vec<ChangedFile>> {
    let output = tokio::process::Command::new("git")
        .args(["diff-tree", "-p", "--no-commit-id", "-r", commit_hash])
        .output()
        .await
        .into_diagnostic()?;

    if !output.status.success() {
        return Ok(vec![]);
    }

    Ok(parse_diff_output(&String::from_utf8_lossy(&output.stdout)))
}

/// Parse the output of `git diff-tree -p` into per-file [`ChangedFile`] entries.
///
/// Each file section starts with a `diff --git a/<path> b/<path>` line.
/// Status is inferred from subsequent mode/rename headers.
/// Diff line kinds are determined by their leading character, with header
/// lines (`--- `, `+++ `) distinguished from content lines (`-`, `+`) by
/// the mandatory space that follows the three-character marker.
fn parse_diff_output(text: &str) -> Vec<ChangedFile> {
    let mut files: Vec<ChangedFile> = Vec::new();
    let mut cur_lines: Vec<DiffLine> = Vec::new();
    let mut cur_path = String::new();
    let mut cur_status = "modified";

    let push_line = |lines: &mut Vec<DiffLine>, kind, content: &str| {
        lines.push(DiffLine {
            kind,
            content: content.to_string(),
        });
    };

    for line in text.lines() {
        if line.starts_with("diff --git ") {
            if !cur_path.is_empty() {
                files.push(ChangedFile {
                    path: cur_path.clone(),
                    status: cur_status.to_string(),
                    diff_lines: std::mem::take(&mut cur_lines),
                });
            }
            // Extract the destination path from the trailing " b/<path>" token.
            // rsplit_once handles paths that contain spaces.
            cur_path = line
                .rsplit_once(" b/")
                .map(|(_, p)| p.to_string())
                .unwrap_or_default();
            cur_status = "modified";
            push_line(&mut cur_lines, DiffLineKind::Header, line);
        } else if line.starts_with("new file mode") {
            cur_status = "added";
            push_line(&mut cur_lines, DiffLineKind::Header, line);
        } else if line.starts_with("deleted file mode") {
            cur_status = "deleted";
            push_line(&mut cur_lines, DiffLineKind::Header, line);
        } else if line.starts_with("rename from") || line.starts_with("rename to") {
            cur_status = "renamed";
            push_line(&mut cur_lines, DiffLineKind::Header, line);
        } else if line.starts_with("similarity index")
            || line.starts_with("copy from")
            || line.starts_with("copy to")
            || line.starts_with("index ")
            || line.starts_with("--- ")   // file header, not a removed line
            || line.starts_with("+++ ")   // file header, not an added line
            || line.starts_with("Binary files")
            || line.starts_with('\\')
        {
            push_line(&mut cur_lines, DiffLineKind::Header, line);
        } else if line.starts_with("@@") {
            push_line(&mut cur_lines, DiffLineKind::Hunk, line);
        } else if line.starts_with('+') {
            push_line(&mut cur_lines, DiffLineKind::Added, line);
        } else if line.starts_with('-') {
            push_line(&mut cur_lines, DiffLineKind::Removed, line);
        } else {
            push_line(&mut cur_lines, DiffLineKind::Context, line);
        }
    }

    if !cur_path.is_empty() {
        files.push(ChangedFile {
            path: cur_path,
            status: cur_status.to_string(),
            diff_lines: cur_lines,
        });
    }

    files
}

// ── Bare clone export ─────────────────────────────────────────────────────────

/// Clone `source` as a bare repository at `dest`, then prune it down to the
/// branches/tags allowed by `git_ui.include`/`git_ui.exclude` before enabling
/// the dumb HTTP transport.
///
/// `default_branch` is used to pick a sane `HEAD` for the bare clone if the
/// branch it previously pointed to got filtered out.
async fn export_bare_clone(
    source: &Path,
    dest: &Path,
    default_branch: &str,
    include: &GlobSet,
    exclude: &GlobSet,
) -> Result<()> {
    use tokio::process::Command;

    if dest.exists() {
        tokio::fs::remove_dir_all(dest).await.into_diagnostic()?;
    }

    let status = Command::new("git")
        .arg("clone")
        .arg("--bare")
        .arg(source)
        .arg(dest)
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .await
        .into_diagnostic()?;

    if !status.success() {
        return Err(miette::miette!(
            "git clone --bare failed with exit status {status}"
        ));
    }

    prune_excluded_refs(dest, default_branch, include, exclude).await?;

    // Enable dumb HTTP transport so the bare repo is clonable over plain HTTPS.
    let status = Command::new("git")
        .arg("-C")
        .arg(dest)
        .arg("update-server-info")
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .await
        .into_diagnostic()?;

    if !status.success() {
        warn!("git update-server-info failed; dumb HTTP cloning may not work");
    }

    Ok(())
}

/// Delete every branch/tag in the bare clone at `dest` that doesn't pass
/// `git_ui.include`/`git_ui.exclude` (see [`ref_is_included`]), then run
/// `git gc --prune=now` so the excluded history isn't merely unlisted but
/// actually removed from what dumb HTTP ends up serving from disk.
///
/// A no-op (skips even the ref scan) when both `include` and `exclude` are
/// empty, which is the common case.
async fn prune_excluded_refs(
    dest: &Path,
    default_branch: &str,
    include: &GlobSet,
    exclude: &GlobSet,
) -> Result<()> {
    use tokio::process::Command;

    if include.is_empty() && exclude.is_empty() {
        return Ok(());
    }

    let output = Command::new("git")
        .arg("-C")
        .arg(dest)
        .arg("for-each-ref")
        .arg("--format=%(refname)")
        .arg("refs/heads")
        .arg("refs/tags")
        .output()
        .await
        .into_diagnostic()?;

    if !output.status.success() {
        warn!("git for-each-ref failed; skipping include/exclude pruning of repository.git");
        return Ok(());
    }

    let refnames = String::from_utf8_lossy(&output.stdout);
    let mut removed_any = false;
    let mut kept_branches: Vec<String> = Vec::new();

    for refname in refnames.lines() {
        let short_name = match refname
            .strip_prefix("refs/heads/")
            .or_else(|| refname.strip_prefix("refs/tags/"))
        {
            Some(s) => s,
            None => continue,
        };

        if ref_is_included(short_name, include, exclude) {
            if refname.starts_with("refs/heads/") {
                kept_branches.push(short_name.to_string());
            }
            continue;
        }

        let status = Command::new("git")
            .arg("-C")
            .arg(dest)
            .arg("update-ref")
            .arg("-d")
            .arg(refname)
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .await
            .into_diagnostic()?;

        if status.success() {
            removed_any = true;
        } else {
            warn!("failed to delete excluded ref '{refname}' from repository.git");
        }
    }

    if !removed_any {
        return Ok(());
    }

    // `HEAD` may have pointed at a branch we just deleted; repoint it at the
    // configured default branch if it survived the filter, or otherwise at
    // whatever branch is left, so `git clone` of `repository.git` still
    // checks out something sensible.
    let new_head = if kept_branches.iter().any(|b| b == default_branch) {
        Some(default_branch.to_string())
    } else if let Some(first) = kept_branches.first() {
        warn!(
            "git_ui: default branch '{}' excluded from repository.git; using '{}' for HEAD instead",
            default_branch, first
        );
        Some(first.clone())
    } else {
        warn!(
            "git_ui: include/exclude filtered out every branch; repository.git will have no usable HEAD"
        );
        None
    };

    if let Some(branch) = new_head {
        let status = Command::new("git")
            .arg("-C")
            .arg(dest)
            .arg("symbolic-ref")
            .arg("HEAD")
            .arg(format!("refs/heads/{branch}"))
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .await
            .into_diagnostic()?;

        if !status.success() {
            warn!("failed to repoint HEAD in repository.git after pruning excluded refs");
        }
    }

    // Physically remove the now-unreachable objects so excluded branches/tags
    // aren't just unlisted but actually absent from the published repo.
    let status = Command::new("git")
        .arg("-C")
        .arg(dest)
        .arg("gc")
        .arg("--prune=now")
        .arg("--quiet")
        .status()
        .await
        .into_diagnostic()?;

    if !status.success() {
        warn!(
            "git gc --prune=now failed on repository.git; excluded objects may still be present on disk"
        );
    }

    Ok(())
}

// ── Helpers ────────────────────────────────────────────────────────────────────

/// Split a raw commit message into (subject, optional body).
fn parse_message(raw: &str) -> (String, Option<String>) {
    if let Some(idx) = raw.find("\n\n") {
        let subject = raw[..idx].trim().to_string();
        let body = raw[idx + 2..].trim().to_string();
        let body = if body.is_empty() { None } else { Some(body) };
        (subject, body)
    } else {
        (raw.trim().to_string(), None)
    }
}