Abbaye

at 2f4eb91

//! 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 indicatif::{ProgressBar, ProgressStyle};
use miette::{IntoDiagnostic, Result};

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

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

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

pub const TEMPLATE_GIT_LOG: &str = include_str!("templates/git_log.html.j2");
pub const TEMPLATE_GIT_COMMIT: &str = include_str!("templates/git_commit.html.j2");
pub const TEMPLATE_GIT_REFS: &str = include_str!("templates/git_refs.html.j2");
pub const TEMPLATE_GIT_TREE: &str = include_str!("templates/git_tree.html.j2");
pub const TEMPLATE_GIT_BLOB: &str = include_str!("templates/git_blob.html.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 level in the breadcrumb navigation on tree and blob pages.
#[derive(Serialize)]
struct Crumb {
    name: String,
    /// Relative link to this directory's `index.html`. `None` for the last
    /// (current) segment — rendered as plain text, not a link.
    url: Option<String>,
}

/// Kind of an entry in a directory listing. Serialises as lowercase.
#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
enum TreeEntryKind {
    Tree,
    Blob,
}

/// One row in a directory listing.
#[derive(Serialize)]
struct TreeEntry {
    name: String,
    kind: TreeEntryKind,
    /// Relative URL from the current tree page to this entry's page.
    url: String,
}

/// 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 ────────────────────────────────────────────────────────

/// Generate the repository web UI and bare clone into `config.site.output_dir`.
/// Shared spinner style — matches the builder spinners in `site.rs`.
fn make_spinner(label: &str) -> ProgressBar {
    let pb = ProgressBar::new_spinner();
    pb.set_style(
        ProgressStyle::with_template("{spinner:.bold} {prefix} {msg}")
            .expect("valid template")
            .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "),
    );
    pb.set_prefix(format!("{CYAN}[{label}]{RESET}"));
    pb.enable_steady_tick(Duration::from_millis(100));
    pb
}

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 repo_path_clone = repo_path.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)?;
            let (tags, ref_branches) = collect_refs(&repo)?;
            let ref_labels = build_ref_labels(&repo)?;

            // 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;
                }
                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 pb = make_spinner("git ui");

    // ── Bare clone + dumb HTTP setup ──────────────────────────────────────────
    pb.set_message("cloning bare repository…");
    if let Err(e) = export_bare_clone(&repo_path, &bare_dir).await {
        pb.finish_with_message(format!("{RED}\u{2717} failed:{RESET} {e}"));
        return Err(e);
    }

    // ── Tera setup ────────────────────────────────────────────────────────────
    let mut tera = Tera::default();
    let theme_path = PathBuf::from(".abbaye").join("theme");
    for (name, builtin) in [
        ("git_log.html", TEMPLATE_GIT_LOG),
        ("git_commit.html", TEMPLATE_GIT_COMMIT),
        ("git_refs.html", TEMPLATE_GIT_REFS),
    ] {
        let override_path = theme_path.join(format!("{name}.j2"));
        if override_path.is_file() {
            tera.add_template_file(&override_path, Some(name))
                .into_diagnostic()?;
        } else {
            tera.add_raw_template(name, builtin).into_diagnostic()?;
        }
    }
    crate::site::load_extra_theme_templates(
        &mut tera,
        &theme_path,
        &["git_log.html", "git_commit.html", "git_refs.html"],
    )?;

    // ── 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 ────────────────────────────────────────
    pb.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();

        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 html = tera.render("git_log.html", &ctx).into_diagnostic()?;
        tokio::fs::write(ui_dir.join(filename), html)
            .await
            .into_diagnostic()?;
    }

    // ── Render refs page ──────────────────────────────────────────────────────
    {
        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 html = tera.render("git_refs.html", &ctx).into_diagnostic()?;
        tokio::fs::write(ui_dir.join("refs.html"), html)
            .await
            .into_diagnostic()?;
    }

    // ── Render per-commit detail pages ────────────────────────────────────────
    pb.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 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 html = tera.render("git_commit.html", &ctx).into_diagnostic()?;
        tokio::fs::write(
            ui_dir
                .join("commit")
                .join(format!("{}.html", commit_info.hash)),
            html,
        )
        .await
        .into_diagnostic()?;
    }

    // ── Tree browser (browse/<hash>/) ─────────────────────────────────────────
    if !browse_revisions.is_empty() {
        pb.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();

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

    pb.finish_with_message(format!("{GREEN}\u{2713} done{RESET}"));
    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 ───────────────────────────────────────────────────────

/// 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.
fn collect_branch_entries(
    repo: &gix::Repository,
    default_branch: &str,
) -> 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();
        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.
fn build_ref_labels(repo: &gix::Repository) -> 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/") {
            RefBadge {
                label: label.to_string(),
                kind: RefBadgeKind::Tag,
            }
        } else if let Some(label) = name.strip_prefix("refs/heads/") {
            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.
fn collect_refs(repo: &gix::Repository) -> 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();
            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();
            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 ─────────────────────────────────────────────────────────

async fn export_bare_clone(source: &Path, dest: &Path) -> 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}"
        ));
    }

    // 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(())
}

// ── Tree browser ──────────────────────────────────────────────────────────────────

/// Build the full static tree browser for every revision in `revisions`.
///
/// Everything here is synchronous (gix + std::fs + syntect), intended to run
/// inside `tokio::task::spawn_blocking`.
fn build_browse_pages(
    revisions: &[(String, gix::ObjectId)],
    browse_dir: &Path, // public/repository/browse/
    repo_path: &Path,  // for `git cat-file blob`
    theme_path: &Path, // .abbaye/theme (theme overrides)
    project_name: &str,
    lang: &Option<String>,
    clone_url: &Option<String>,
) -> Result<()> {
    use syntect::highlighting::ThemeSet;
    use syntect::parsing::SyntaxSet;

    let ss = SyntaxSet::load_defaults_newlines();
    let ts = ThemeSet::load_defaults();
    let theme = &ts.themes["InspiredGitHub"];

    // Build a separate Tera instance for browse templates.
    let mut tera = Tera::default();
    for (name, builtin) in [
        ("git_tree.html", TEMPLATE_GIT_TREE),
        ("git_blob.html", TEMPLATE_GIT_BLOB),
    ] {
        let override_path = theme_path.join(format!("{name}.j2"));
        if override_path.is_file() {
            tera.add_template_file(&override_path, Some(name))
                .map_err(|e| miette::miette!("{e}"))?;
        } else {
            tera.add_raw_template(name, builtin)
                .map_err(|e| miette::miette!("{e}"))?;
        }
    }
    crate::site::load_extra_theme_templates(
        &mut tera,
        theme_path,
        &["git_tree.html", "git_blob.html"],
    )?;

    for (hash, oid) in revisions {
        let rev_dir = browse_dir.join(hash);
        std::fs::create_dir_all(&rev_dir).into_diagnostic()?;

        // Resolve the commit's root tree.
        let repo = gix::open(repo_path).into_diagnostic()?;
        let commit_obj = repo.find_object(*oid).into_diagnostic()?.into_commit();
        let decoded = commit_obj.decode().into_diagnostic()?;
        let tree_id = decoded.tree();

        walk_tree_dir(
            repo_path,
            &repo,
            tree_id,
            "",
            hash,
            &rev_dir,
            &tera,
            project_name,
            lang,
            clone_url,
            &ss,
            theme,
        )?;
    }

    Ok(())
}

/// Recursively generate one `index.html` (directory listing) per tree and one
/// `<name>.html` per blob, rooted at `rev_dir`.
/// TODO: Fix clippy warning about too many arguments
#[allow(clippy::too_many_arguments)]
fn walk_tree_dir(
    repo_path: &Path,
    repo: &gix::Repository,
    tree_id: gix::ObjectId,
    dir_path: &str, // "" = root, "src", "src/utils"
    commit_hash: &str,
    rev_dir: &Path, // public/repository/browse/<hash>/
    tera: &Tera,
    project_name: &str,
    lang: &Option<String>,
    clone_url: &Option<String>,
    ss: &syntect::parsing::SyntaxSet,
    theme: &syntect::highlighting::Theme,
) -> Result<()> {
    let tree_obj = repo.find_object(tree_id).into_diagnostic()?.into_tree();
    let decoded = tree_obj.decode().into_diagnostic()?;

    // Depth = number of path components in dir_path.
    let depth: usize = dir_path.split('/').filter(|s| !s.is_empty()).count();

    // Public output directory for this tree's index.html.
    let page_dir = if dir_path.is_empty() {
        rev_dir.to_path_buf()
    } else {
        dir_path
            .split('/')
            .filter(|s| !s.is_empty())
            .fold(rev_dir.to_path_buf(), |p, c| p.join(c))
    };
    std::fs::create_dir_all(&page_dir).into_diagnostic()?;

    let mut entries: Vec<TreeEntry> = Vec::new();
    // Defer recursion until after the listing page is written.
    let mut subdirs: Vec<(String, gix::ObjectId)> = Vec::new();
    let mut blobs: Vec<(String, gix::ObjectId)> = Vec::new();

    for entry in decoded.entries.iter() {
        let name = entry.filename.to_str_lossy().into_owned();
        let oid: gix::ObjectId = entry.oid.to_owned();

        if entry.mode.is_tree() {
            entries.push(TreeEntry {
                url: format!("{name}/index.html"),
                name: name.clone(),
                kind: TreeEntryKind::Tree,
            });
            subdirs.push((name, oid));
        } else {
            // blob, executable blob, symlink, or submodule commit
            entries.push(TreeEntry {
                url: format!("{name}.html"),
                name: name.clone(),
                kind: TreeEntryKind::Blob,
            });
            if !entry.mode.is_commit() {
                // Skip submodule gitlinks (they have no blob content).
                blobs.push((name, oid));
            }
        }
    }

    // Directories first, then files; both alphabetical.
    entries.sort_by(|a, b| {
        let a_tree = matches!(a.kind, TreeEntryKind::Tree);
        let b_tree = matches!(b.kind, TreeEntryKind::Tree);
        b_tree.cmp(&a_tree).then(a.name.cmp(&b.name))
    });

    // root_path: how many levels up to reach the site root (public/).
    // browse/<hash>/[subpath/]  =>  3 + depth levels up.
    let root_path = "../".repeat(3 + depth);
    // commit_url: link back to the commit detail page.
    let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2));
    let breadcrumbs = make_crumbs(dir_path, false, None);

    let mut ctx = Context::new();
    ctx.insert("project_name", project_name);
    ctx.insert("lang", lang);
    ctx.insert("clone_url", clone_url);
    ctx.insert("commit_hash", commit_hash);
    ctx.insert("commit_hash_short", &commit_hash[..7]);
    ctx.insert("commit_url", &commit_url);
    ctx.insert("dir_path", dir_path);
    ctx.insert("entries", &entries);
    ctx.insert("breadcrumbs", &breadcrumbs);
    ctx.insert("root_path", &root_path);

    let html = tera
        .render("git_tree.html", &ctx)
        .map_err(|e| miette::miette!("{e}"))?;
    std::fs::write(page_dir.join("index.html"), html).into_diagnostic()?;

    // Recurse into subdirectories.
    for (name, oid) in subdirs {
        let child_path = if dir_path.is_empty() {
            name
        } else {
            format!("{dir_path}/{name}")
        };
        walk_tree_dir(
            repo_path,
            repo,
            oid,
            &child_path,
            commit_hash,
            rev_dir,
            tera,
            project_name,
            lang,
            clone_url,
            ss,
            theme,
        )?;
    }

    // Render blob pages.
    for (name, oid) in blobs {
        let file_path = if dir_path.is_empty() {
            name.clone()
        } else {
            format!("{dir_path}/{name}")
        };
        render_blob_page(
            repo_path,
            &name,
            &file_path,
            oid,
            commit_hash,
            depth,
            &page_dir,
            tera,
            project_name,
            lang,
            clone_url,
            ss,
            theme,
        )?;
    }

    Ok(())
}

/// Write one syntax-highlighted blob page to `page_dir/<name>.html`.
#[allow(clippy::too_many_arguments)]
fn render_blob_page(
    repo_path: &Path,
    filename: &str,
    file_path: &str, // full path from repo root, e.g. "src/main.rs"
    oid: gix::ObjectId,
    commit_hash: &str,
    depth: usize,    // number of directory components containing the file
    page_dir: &Path, // output directory (same as the parent tree's page_dir)
    tera: &Tera,
    project_name: &str,
    lang: &Option<String>,
    clone_url: &Option<String>,
    ss: &syntect::parsing::SyntaxSet,
    theme: &syntect::highlighting::Theme,
) -> Result<()> {
    const MAX_BLOB_BYTES: usize = 1024 * 1024; // 1 MiB

    // Read blob via `git cat-file blob <oid>` to avoid gix private-field access.
    let data: Vec<u8> = std::process::Command::new("git")
        .current_dir(repo_path)
        .args(["cat-file", "blob", &oid.to_string()])
        .output()
        .map(|o| o.stdout)
        .unwrap_or_default();

    let is_binary = data[..data.len().min(8192)].contains(&0u8);
    let too_large = data.len() > MAX_BLOB_BYTES;

    let content_html: Option<String> = if is_binary || too_large || data.is_empty() {
        None
    } else {
        let text = String::from_utf8_lossy(&data);
        let ext = std::path::Path::new(filename)
            .extension()
            .and_then(|s| s.to_str())
            .unwrap_or("");
        let syntax = ss
            .find_syntax_by_extension(ext)
            .or_else(|| {
                text.lines()
                    .next()
                    .and_then(|l| ss.find_syntax_by_first_line(l))
            })
            .unwrap_or_else(|| ss.find_syntax_plain_text());
        Some(
            syntect::html::highlighted_html_for_string(&text, ss, syntax, theme)
                .unwrap_or_else(|_| format!("<pre>{}</pre>", escape_html(&text))),
        )
    };

    let root_path = "../".repeat(3 + depth);
    let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2));
    let breadcrumbs = make_crumbs(
        std::path::Path::new(file_path)
            .parent()
            .and_then(|p| p.to_str())
            .unwrap_or(""),
        true,
        Some(filename),
    );

    let mut ctx = Context::new();
    ctx.insert("project_name", project_name);
    ctx.insert("lang", lang);
    ctx.insert("clone_url", clone_url);
    ctx.insert("commit_hash", commit_hash);
    ctx.insert("commit_hash_short", &commit_hash[..7]);
    ctx.insert("commit_url", &commit_url);
    ctx.insert("file_path", file_path);
    ctx.insert("filename", filename);
    ctx.insert("breadcrumbs", &breadcrumbs);
    ctx.insert("content_html", &content_html);
    ctx.insert("is_binary", &is_binary);
    ctx.insert("too_large", &too_large);
    ctx.insert("size", &data.len());
    ctx.insert("root_path", &root_path);

    let html = tera
        .render("git_blob.html", &ctx)
        .map_err(|e| miette::miette!("{e}"))?;
    std::fs::write(page_dir.join(format!("{filename}.html")), html).into_diagnostic()?;

    Ok(())
}

/// Build breadcrumb entries for a tree or blob page.
///
/// `dir_path` is the path to the containing directory (e.g. `"src"` for
/// `src/main.rs`).  `depth` is derived from `dir_path` internally.
fn make_crumbs(dir_path: &str, is_blob: bool, filename: Option<&str>) -> Vec<Crumb> {
    let parts: Vec<&str> = dir_path.split('/').filter(|s| !s.is_empty()).collect();
    let depth = parts.len();
    let mut crumbs = Vec::new();

    // Root crumb (“~”).
    let root_url = if depth == 0 && !is_blob {
        None // we ARE the root dir listing
    } else {
        Some(format!("{}index.html", "../".repeat(depth)))
    };
    crumbs.push(Crumb {
        name: "~".to_string(),
        url: root_url,
    });

    // Intermediate directory crumbs.
    for (i, &part) in parts.iter().enumerate() {
        let is_last_and_tree = i == depth - 1 && !is_blob;
        let url = if is_last_and_tree {
            None // current directory
        } else {
            // levels_up = how many "../" to navigate from current location to this dir
            let levels_up = depth - i - 1;
            Some(format!("{}index.html", "../".repeat(levels_up)))
        };
        crumbs.push(Crumb {
            name: part.to_string(),
            url,
        });
    }

    // Filename crumb for blobs.
    if is_blob {
        if let Some(name) = filename {
            crumbs.push(Crumb {
                name: name.to_string(),
                url: None,
            });
        }
    }

    crumbs
}

fn escape_html(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

// ── 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)
    }
}