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 indicatif::{ProgressBar, ProgressStyle};
24use miette::{IntoDiagnostic, Result};
25
26use crate::cli::{CYAN, GREEN, RED, RESET};
27use serde::Serialize;
28use tera::{Context, Tera};
29use tracing::warn;
30
31use crate::config::{AbbayeConfig, GitUiConfig};
32
33// ── Template sources ──────────────────────────────────────────────────────────
34
35pub const TEMPLATE_GIT_LOG: &str = include_str!("templates/git_log.html.j2");
36pub const TEMPLATE_GIT_COMMIT: &str = include_str!("templates/git_commit.html.j2");
37pub const TEMPLATE_GIT_REFS: &str = include_str!("templates/git_refs.html.j2");
38pub const TEMPLATE_GIT_TREE: &str = include_str!("templates/git_tree.html.j2");
39pub const TEMPLATE_GIT_BLOB: &str = include_str!("templates/git_blob.html.j2");
40
41// ── Template-facing data structures ──────────────────────────────────────────
42
43#[derive(Clone, Serialize)]
44struct CommitParent {
45    hash: String,
46    hash_short: String,
47}
48
49/// A single commit's metadata, passed to Tera templates.
50/// `Clone` is required so commits can be deduplicated across branches.
51#[derive(Clone, Serialize)]
52struct CommitInfo {
53    hash: String,
54    hash_short: String,
55    author_name: String,
56    author_email: String,
57    /// ISO-8601 timestamp for `<time datetime="">`.
58    date_iso: String,
59    /// Short date for the log table (YYYY-MM-DD).
60    date: String,
61    /// Date + time for the commit detail page.
62    datetime_display: String,
63    /// First line of the commit message.
64    subject: String,
65    /// Everything after the blank line separator, if present.
66    body: Option<String>,
67    parents: Vec<CommitParent>,
68    /// Tags and branches whose tip is exactly this commit.
69    ref_badges: Vec<RefBadge>,
70}
71
72#[derive(Serialize)]
73struct RefInfo {
74    name: String,
75    short_name: String,
76    hash: String,
77    hash_short: String,
78}
79
80/// The visual kind of a single unified-diff line.
81/// Serialises as lowercase for use as a CSS modifier class.
82#[derive(Serialize)]
83#[serde(rename_all = "lowercase")]
84enum DiffLineKind {
85    Header,  // diff --git, index, ---, +++, mode lines
86    Hunk,    // @@ -a,b +c,d @@
87    Added,   // lines beginning with +
88    Removed, // lines beginning with -
89    Context, // unchanged surrounding lines
90}
91
92#[derive(Serialize)]
93struct DiffLine {
94    kind: DiffLineKind,
95    content: String,
96}
97
98#[derive(Serialize)]
99struct ChangedFile {
100    path: String,
101    /// One of: added, deleted, modified, renamed, copied, changed.
102    status: String,
103    diff_lines: Vec<DiffLine>,
104}
105
106/// Discriminates the two kinds of ref badge shown on log pages.
107/// Serialises as lowercase (`"tag"` / `"branch"`) for use as a CSS modifier class.
108#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
109#[serde(rename_all = "lowercase")]
110enum RefBadgeKind {
111    /// Derived `Ord`: `Tag < Branch`, so tags sort before branches.
112    Tag,
113    Branch,
114}
115
116/// A ref badge shown next to a commit hash on log pages.
117#[derive(Clone, Serialize)]
118struct RefBadge {
119    label: String,
120    kind: RefBadgeKind,
121}
122
123/// One level in the breadcrumb navigation on tree and blob pages.
124#[derive(Serialize)]
125struct Crumb {
126    name: String,
127    /// Relative link to this directory's `index.html`. `None` for the last
128    /// (current) segment — rendered as plain text, not a link.
129    url: Option<String>,
130}
131
132/// Kind of an entry in a directory listing. Serialises as lowercase.
133#[derive(Serialize)]
134#[serde(rename_all = "lowercase")]
135enum TreeEntryKind {
136    Tree,
137    Blob,
138}
139
140/// One row in a directory listing.
141#[derive(Serialize)]
142struct TreeEntry {
143    name: String,
144    kind: TreeEntryKind,
145    /// Relative URL from the current tree page to this entry's page.
146    url: String,
147}
148
149/// One entry in the branch-switcher nav rendered on every log page.
150#[derive(Serialize)]
151struct BranchNav {
152    short_name: String,
153    /// HTML filename for this branch ("index.html" or "<name>.html").
154    filename: String,
155    is_current: bool,
156}
157
158// ── Internal branch descriptor (never serialized) ────────────────────────────
159
160#[derive(Clone)]
161struct BranchEntry {
162    short_name: String,
163    /// Output filename: "index.html" for the default branch, else a sanitized name.
164    filename: String,
165    tip: gix::ObjectId,
166}
167
168// ── Public entry point ────────────────────────────────────────────────────────
169
170/// Generate the repository web UI and bare clone into `config.site.output_dir`.
171/// Shared spinner style — matches the builder spinners in `site.rs`.
172fn make_spinner(label: &str) -> ProgressBar {
173    let pb = ProgressBar::new_spinner();
174    pb.set_style(
175        ProgressStyle::with_template("{spinner:.bold} {prefix} {msg}")
176            .expect("valid template")
177            .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "),
178    );
179    pb.set_prefix(format!("{CYAN}[{label}]{RESET}"));
180    pb.enable_steady_tick(Duration::from_millis(100));
181    pb
182}
183
184pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Result<()> {
185    let output_dir = &config.site.output_dir;
186    let ui_dir = output_dir.join("repository");
187    let bare_dir = output_dir.join("repository.git");
188
189    tokio::fs::create_dir_all(&ui_dir).await.into_diagnostic()?;
190    tokio::fs::create_dir_all(ui_dir.join("commit"))
191        .await
192        .into_diagnostic()?;
193
194    let repo_path: PathBuf = git_cfg
195        .repo_path
196        .clone()
197        .unwrap_or_else(|| PathBuf::from("."));
198
199    let max_commits = git_cfg.max_commits;
200    let default_branch = git_cfg.default_branch.clone();
201    let repo_path_clone = repo_path.clone();
202
203    // Compute clone URL before the blocking task so we can pass it into the
204    // browse page generator without re-deriving it.
205    let clone_url = generate_clone_command(config, git_cfg);
206
207    // ── All gix work happens inside one blocking task (Repository is !Send) ───
208    //
209    // Returns:
210    //   branch_pages     — (short_name, filename, commits) per branch
211    //   unique_commits   — all commits across all branches, deduplicated
212    //   tags / ref_branches — for refs.html
213    //   browse_revisions — (hex_hash, ObjectId) for every branch tip + tag tip
214    let (branch_pages, unique_commits, tags, ref_branches, browse_revisions) =
215        tokio::task::spawn_blocking(move || -> Result<_> {
216            let repo = match gix::discover(&repo_path_clone) {
217                Ok(r) => r,
218                Err(e) => {
219                    warn!(
220                        "git_ui: could not open repository at {}: {e}",
221                        repo_path_clone.display()
222                    );
223                    return Ok((vec![], vec![], vec![], vec![], vec![]));
224                }
225            };
226
227            let branches = collect_branch_entries(&repo, &default_branch)?;
228            let (tags, ref_branches) = collect_refs(&repo)?;
229            let ref_labels = build_ref_labels(&repo)?;
230
231            // Walk commits per branch; collect unique commits for detail pages.
232            let mut unique_map: HashMap<String, CommitInfo> = HashMap::new();
233            let mut branch_pages: Vec<(String, String, Vec<CommitInfo>)> = Vec::new();
234
235            for branch in &branches {
236                let commits = collect_commits(&repo, branch.tip, max_commits, &ref_labels)?;
237                for c in &commits {
238                    unique_map
239                        .entry(c.hash.clone())
240                        .or_insert_with(|| c.clone());
241                }
242                branch_pages.push((branch.short_name.clone(), branch.filename.clone(), commits));
243            }
244
245            let unique_commits: Vec<CommitInfo> = unique_map.into_values().collect();
246
247            // Collect revisions for the tree browser: branch tips + tag tips.
248            let mut seen: HashMap<String, gix::ObjectId> = HashMap::new();
249            for branch in &branches {
250                seen.insert(branch.tip.to_string(), branch.tip);
251            }
252            let refs_platform = repo.references().into_diagnostic()?;
253            for reference in refs_platform.all().into_diagnostic()? {
254                let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
255                let name = reference.name().as_bstr().to_str_lossy().into_owned();
256                if !name.starts_with("refs/tags/") {
257                    continue;
258                }
259                if let Ok(id) = reference.peel_to_id() {
260                    let hash = id.to_string();
261                    seen.entry(hash).or_insert_with(|| id.detach());
262                }
263            }
264            let browse_revisions: Vec<(String, gix::ObjectId)> = seen.into_iter().collect();
265
266            Ok((
267                branch_pages,
268                unique_commits,
269                tags,
270                ref_branches,
271                browse_revisions,
272            ))
273        })
274        .await
275        .into_diagnostic()??;
276
277    if branch_pages.is_empty() {
278        // Nothing to render (empty repo or no branches).
279        return Ok(());
280    }
281
282    let pb = make_spinner("git ui");
283
284    // ── Bare clone + dumb HTTP setup ──────────────────────────────────────────
285    pb.set_message("cloning bare repository…");
286    if let Err(e) = export_bare_clone(&repo_path, &bare_dir).await {
287        pb.finish_with_message(format!("{RED}\u{2717} failed:{RESET} {e}"));
288        return Err(e);
289    }
290
291    // ── Tera setup ────────────────────────────────────────────────────────────
292    let mut tera = Tera::default();
293    let theme_path = PathBuf::from(".abbaye").join("theme");
294    for (name, builtin) in [
295        ("git_log.html", TEMPLATE_GIT_LOG),
296        ("git_commit.html", TEMPLATE_GIT_COMMIT),
297        ("git_refs.html", TEMPLATE_GIT_REFS),
298    ] {
299        let override_path = theme_path.join(format!("{name}.j2"));
300        if override_path.is_file() {
301            tera.add_template_file(&override_path, Some(name))
302                .into_diagnostic()?;
303        } else {
304            tera.add_raw_template(name, builtin).into_diagnostic()?;
305        }
306    }
307    crate::site::load_extra_theme_templates(
308        &mut tera,
309        &theme_path,
310        &["git_log.html", "git_commit.html", "git_refs.html"],
311    )?;
312
313    // ── Branch switcher nav (shared across all log pages) ─────────────────────
314    //
315    // Order: the default branch (index.html) first, then alphabetical.
316    let mut nav_entries: Vec<(String, String)> = branch_pages
317        .iter()
318        .map(|(name, file, _)| (name.clone(), file.clone()))
319        .collect();
320    nav_entries.sort_by(|(na, fa), (nb, fb)| {
321        let a_default = fa == "index.html";
322        let b_default = fb == "index.html";
323        b_default.cmp(&a_default).then(na.cmp(nb))
324    });
325
326    // ── Render one log page per branch ────────────────────────────────────────
327    pb.set_message("rendering log pages…");
328    for (short_name, filename, commits) in &branch_pages {
329        let truncated = commits.len() >= max_commits;
330
331        let branch_nav: Vec<BranchNav> = nav_entries
332            .iter()
333            .map(|(bn, bf)| BranchNav {
334                short_name: bn.clone(),
335                filename: bf.clone(),
336                is_current: bn == short_name,
337            })
338            .collect();
339
340        let mut ctx = Context::new();
341        ctx.insert("project_name", &config.site.name);
342        ctx.insert("lang", &config.site.lang);
343        ctx.insert("clone_url", &clone_url);
344        ctx.insert("current_branch", short_name);
345        ctx.insert("branch_nav", &branch_nav);
346        ctx.insert("commits", commits);
347        ctx.insert("truncated", &truncated);
348        ctx.insert("root_path", "../");
349
350        let html = tera.render("git_log.html", &ctx).into_diagnostic()?;
351        tokio::fs::write(ui_dir.join(filename), html)
352            .await
353            .into_diagnostic()?;
354    }
355
356    // ── Render refs page ──────────────────────────────────────────────────────
357    {
358        let mut ctx = Context::new();
359        ctx.insert("project_name", &config.site.name);
360        ctx.insert("lang", &config.site.lang);
361        ctx.insert("clone_url", &clone_url);
362        ctx.insert("tags", &tags);
363        ctx.insert("branches", &ref_branches);
364        ctx.insert("root_path", "../");
365
366        let html = tera.render("git_refs.html", &ctx).into_diagnostic()?;
367        tokio::fs::write(ui_dir.join("refs.html"), html)
368            .await
369            .into_diagnostic()?;
370    }
371
372    // ── Render per-commit detail pages ────────────────────────────────────────
373    pb.set_message(format!("rendering {} commit pages…", unique_commits.len()));
374    for commit_info in &unique_commits {
375        let changed_files = get_changed_files(&commit_info.hash).await?;
376        let has_browse = !commit_info.ref_badges.is_empty();
377
378        let mut ctx = Context::new();
379        ctx.insert("project_name", &config.site.name);
380        ctx.insert("lang", &config.site.lang);
381        ctx.insert("clone_url", &clone_url);
382        ctx.insert("commit", commit_info);
383        ctx.insert("changed_files", &changed_files);
384        ctx.insert("has_browse", &has_browse);
385        ctx.insert("root_path", "../../");
386
387        let html = tera.render("git_commit.html", &ctx).into_diagnostic()?;
388        tokio::fs::write(
389            ui_dir
390                .join("commit")
391                .join(format!("{}.html", commit_info.hash)),
392            html,
393        )
394        .await
395        .into_diagnostic()?;
396    }
397
398    // ── Tree browser (browse/<hash>/) ─────────────────────────────────────────
399    if !browse_revisions.is_empty() {
400        pb.set_message(format!(
401            "building browse pages for {} revision(s)…",
402            browse_revisions.len()
403        ));
404        let browse_dir = ui_dir.join("browse");
405        tokio::fs::create_dir_all(&browse_dir)
406            .await
407            .into_diagnostic()?;
408
409        let project_name = config.site.name.clone();
410        let lang = config.site.lang.clone();
411        let clone_url_browse = clone_url.clone();
412        let theme_path = PathBuf::from(".abbaye").join("theme");
413        let repo_path_browse = repo_path.clone();
414
415        tokio::task::spawn_blocking(move || {
416            build_browse_pages(
417                &browse_revisions,
418                &browse_dir,
419                &repo_path_browse,
420                &theme_path,
421                &project_name,
422                &lang,
423                &clone_url_browse,
424            )
425        })
426        .await
427        .into_diagnostic()??
428    }
429
430    pb.finish_with_message(format!("{GREEN}\u{2713} done{RESET}"));
431    Ok(())
432}
433
434pub fn generate_clone_command(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Option<String> {
435    let clone_url: Option<String> = git_cfg.clone_url.clone().or_else(|| {
436        config.site.base_url.as_ref().map(|base| {
437            format!(
438                "{}/repository.git {}",
439                base.trim_end_matches('/'),
440                if config.site.name.contains(" ") {
441                    format!("'{}'", config.site.name)
442                } else {
443                    config.site.name.clone()
444                }
445            )
446        })
447    });
448    clone_url
449}
450
451// ── Git data collection ───────────────────────────────────────────────────────
452
453/// Collect all local branches and assign output filenames.
454///
455/// The branch whose short name matches `default_branch` gets `"index.html"`.
456/// Every other branch gets `"<sanitized-short-name>.html"` where `/` is
457/// replaced by `-`.
458///
459/// If no branch matches `default_branch`, the first branch (alphabetically)
460/// receives `"index.html"` and a warning is emitted.
461fn collect_branch_entries(
462    repo: &gix::Repository,
463    default_branch: &str,
464) -> Result<Vec<BranchEntry>> {
465    let mut entries: Vec<BranchEntry> = Vec::new();
466
467    let refs_platform = repo.references().into_diagnostic()?;
468    for reference in refs_platform.all().into_diagnostic()? {
469        let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
470        let name = reference.name().as_bstr().to_str_lossy().into_owned();
471
472        if !name.starts_with("refs/heads/") {
473            continue;
474        }
475
476        let short_name = name.trim_start_matches("refs/heads/").to_string();
477        let tip = match reference.peel_to_id() {
478            Ok(id) => id.detach(),
479            Err(_) => continue,
480        };
481
482        // Tentative filename; replaced for the default branch below.
483        let filename = format!("{}.html", short_name.replace('/', "-"));
484        entries.push(BranchEntry {
485            short_name,
486            filename,
487            tip,
488        });
489    }
490
491    // Stable, predictable page order.
492    entries.sort_by(|a, b| a.short_name.cmp(&b.short_name));
493
494    // Assign index.html to the configured default branch.
495    if let Some(e) = entries.iter_mut().find(|e| e.short_name == default_branch) {
496        e.filename = "index.html".to_string();
497    } else if let Some(first) = entries.first_mut() {
498        warn!(
499            "git_ui: default branch '{}' not found; using '{}' as index.html",
500            default_branch, first.short_name
501        );
502        first.filename = "index.html".to_string();
503    }
504
505    Ok(entries)
506}
507
508/// Build a map from commit hash (hex string) to the ref badges pointing at it.
509/// Tags come before branches within each entry; both are sorted alphabetically.
510fn build_ref_labels(repo: &gix::Repository) -> Result<HashMap<String, Vec<RefBadge>>> {
511    let mut map: HashMap<String, Vec<RefBadge>> = HashMap::new();
512
513    let refs_platform = repo.references().into_diagnostic()?;
514    for reference in refs_platform.all().into_diagnostic()? {
515        let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
516        let name = reference.name().as_bstr().to_str_lossy().into_owned();
517
518        if name == "HEAD" || name.starts_with("refs/remotes/") {
519            continue;
520        }
521
522        let hash = match reference.peel_to_id() {
523            Ok(id) => id.to_string(),
524            Err(_) => continue,
525        };
526
527        let badge = if let Some(label) = name.strip_prefix("refs/tags/") {
528            RefBadge {
529                label: label.to_string(),
530                kind: RefBadgeKind::Tag,
531            }
532        } else if let Some(label) = name.strip_prefix("refs/heads/") {
533            RefBadge {
534                label: label.to_string(),
535                kind: RefBadgeKind::Branch,
536            }
537        } else {
538            continue;
539        };
540
541        map.entry(hash).or_default().push(badge);
542    }
543
544    // Within each entry: tags first (Tag < Branch via Ord), then alphabetical.
545    for badges in map.values_mut() {
546        badges.sort_by(|a, b| a.kind.cmp(&b.kind).then(a.label.cmp(&b.label)));
547    }
548
549    Ok(map)
550}
551
552/// Walk at most `max` commits reachable from `tip`, newest first.
553fn collect_commits(
554    repo: &gix::Repository,
555    tip: gix::ObjectId,
556    max: usize,
557    ref_labels: &HashMap<String, Vec<RefBadge>>,
558) -> Result<Vec<CommitInfo>> {
559    let walk = repo.rev_walk([tip]).all().into_diagnostic()?;
560    let mut commits = Vec::new();
561
562    for info in walk.take(max) {
563        let info = info.into_diagnostic()?;
564        let id = info.id;
565
566        let object = repo.find_object(id).into_diagnostic()?;
567        let commit = object.into_commit();
568        let decoded = commit.decode().into_diagnostic()?;
569
570        let author = decoded.author().into_diagnostic()?;
571        let author_name = author.name.to_str_lossy().into_owned();
572        let author_email = author.email.to_str_lossy().into_owned();
573        let unix_secs: i64 = author
574            .time
575            .split_whitespace()
576            .next()
577            .and_then(|s| s.parse().ok())
578            .unwrap_or(0);
579
580        let dt: DateTime<Utc> = DateTime::from_timestamp(unix_secs, 0).unwrap_or_default();
581        let date = dt.format("%Y-%m-%d").to_string();
582        let date_iso = dt.to_rfc3339();
583        let datetime_display = dt.format("%Y-%m-%d %H:%M UTC").to_string();
584
585        let raw_msg = decoded.message.to_str_lossy();
586        let (subject, body) = parse_message(&raw_msg);
587
588        let hash = id.to_string();
589        let hash_short = hash[..7].to_string();
590
591        let parents = info
592            .parent_ids
593            .iter()
594            .map(|p| {
595                let h = p.to_string();
596                let hs = h[..7].to_string();
597                CommitParent {
598                    hash: h,
599                    hash_short: hs,
600                }
601            })
602            .collect();
603
604        let ref_badges = ref_labels.get(&hash).cloned().unwrap_or_default();
605
606        commits.push(CommitInfo {
607            hash,
608            hash_short,
609            author_name,
610            author_email,
611            date,
612            date_iso,
613            datetime_display,
614            subject,
615            body,
616            parents,
617            ref_badges,
618        });
619    }
620
621    Ok(commits)
622}
623
624/// Collect tags and branches for the refs overview page.
625fn collect_refs(repo: &gix::Repository) -> Result<(Vec<RefInfo>, Vec<RefInfo>)> {
626    let mut tags: Vec<RefInfo> = Vec::new();
627    let mut branches: Vec<RefInfo> = Vec::new();
628
629    let refs_platform = repo.references().into_diagnostic()?;
630    for reference in refs_platform.all().into_diagnostic()? {
631        let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
632        let name = reference.name().as_bstr().to_str_lossy().into_owned();
633
634        if name.starts_with("refs/remotes/") || name == "HEAD" {
635            continue;
636        }
637
638        let hash = match reference.peel_to_id() {
639            Ok(id) => id.to_string(),
640            Err(_) => continue,
641        };
642        let hash_short = hash[..7.min(hash.len())].to_string();
643
644        if name.starts_with("refs/tags/") {
645            let short_name = name.trim_start_matches("refs/tags/").to_string();
646            tags.push(RefInfo {
647                name,
648                short_name,
649                hash,
650                hash_short,
651            });
652        } else if name.starts_with("refs/heads/") {
653            let short_name = name.trim_start_matches("refs/heads/").to_string();
654            branches.push(RefInfo {
655                name,
656                short_name,
657                hash,
658                hash_short,
659            });
660        }
661    }
662
663    // Tags: newest first (reverse-lexicographic ≈ version order for semver tags).
664    tags.sort_by(|a, b| b.name.cmp(&a.name));
665    branches.sort_by(|a, b| a.name.cmp(&b.name));
666
667    Ok((tags, branches))
668}
669
670// ── Changed files via git CLI ─────────────────────────────────────────────────
671
672async fn get_changed_files(commit_hash: &str) -> Result<Vec<ChangedFile>> {
673    let output = tokio::process::Command::new("git")
674        .args(["diff-tree", "-p", "--no-commit-id", "-r", commit_hash])
675        .output()
676        .await
677        .into_diagnostic()?;
678
679    if !output.status.success() {
680        return Ok(vec![]);
681    }
682
683    Ok(parse_diff_output(&String::from_utf8_lossy(&output.stdout)))
684}
685
686/// Parse the output of `git diff-tree -p` into per-file [`ChangedFile`] entries.
687///
688/// Each file section starts with a `diff --git a/<path> b/<path>` line.
689/// Status is inferred from subsequent mode/rename headers.
690/// Diff line kinds are determined by their leading character, with header
691/// lines (`--- `, `+++ `) distinguished from content lines (`-`, `+`) by
692/// the mandatory space that follows the three-character marker.
693fn parse_diff_output(text: &str) -> Vec<ChangedFile> {
694    let mut files: Vec<ChangedFile> = Vec::new();
695    let mut cur_lines: Vec<DiffLine> = Vec::new();
696    let mut cur_path = String::new();
697    let mut cur_status = "modified";
698
699    let push_line = |lines: &mut Vec<DiffLine>, kind, content: &str| {
700        lines.push(DiffLine {
701            kind,
702            content: content.to_string(),
703        });
704    };
705
706    for line in text.lines() {
707        if line.starts_with("diff --git ") {
708            if !cur_path.is_empty() {
709                files.push(ChangedFile {
710                    path: cur_path.clone(),
711                    status: cur_status.to_string(),
712                    diff_lines: std::mem::take(&mut cur_lines),
713                });
714            }
715            // Extract the destination path from the trailing " b/<path>" token.
716            // rsplit_once handles paths that contain spaces.
717            cur_path = line
718                .rsplit_once(" b/")
719                .map(|(_, p)| p.to_string())
720                .unwrap_or_default();
721            cur_status = "modified";
722            push_line(&mut cur_lines, DiffLineKind::Header, line);
723        } else if line.starts_with("new file mode") {
724            cur_status = "added";
725            push_line(&mut cur_lines, DiffLineKind::Header, line);
726        } else if line.starts_with("deleted file mode") {
727            cur_status = "deleted";
728            push_line(&mut cur_lines, DiffLineKind::Header, line);
729        } else if line.starts_with("rename from") || line.starts_with("rename to") {
730            cur_status = "renamed";
731            push_line(&mut cur_lines, DiffLineKind::Header, line);
732        } else if line.starts_with("similarity index")
733            || line.starts_with("copy from")
734            || line.starts_with("copy to")
735            || line.starts_with("index ")
736            || line.starts_with("--- ")   // file header, not a removed line
737            || line.starts_with("+++ ")   // file header, not an added line
738            || line.starts_with("Binary files")
739            || line.starts_with('\\')
740        {
741            push_line(&mut cur_lines, DiffLineKind::Header, line);
742        } else if line.starts_with("@@") {
743            push_line(&mut cur_lines, DiffLineKind::Hunk, line);
744        } else if line.starts_with('+') {
745            push_line(&mut cur_lines, DiffLineKind::Added, line);
746        } else if line.starts_with('-') {
747            push_line(&mut cur_lines, DiffLineKind::Removed, line);
748        } else {
749            push_line(&mut cur_lines, DiffLineKind::Context, line);
750        }
751    }
752
753    if !cur_path.is_empty() {
754        files.push(ChangedFile {
755            path: cur_path,
756            status: cur_status.to_string(),
757            diff_lines: cur_lines,
758        });
759    }
760
761    files
762}
763
764// ── Bare clone export ─────────────────────────────────────────────────────────
765
766async fn export_bare_clone(source: &Path, dest: &Path) -> Result<()> {
767    use tokio::process::Command;
768
769    if dest.exists() {
770        tokio::fs::remove_dir_all(dest).await.into_diagnostic()?;
771    }
772
773    let status = Command::new("git")
774        .arg("clone")
775        .arg("--bare")
776        .arg(source)
777        .arg(dest)
778        .stdout(std::process::Stdio::null())
779        .stderr(std::process::Stdio::null())
780        .status()
781        .await
782        .into_diagnostic()?;
783
784    if !status.success() {
785        return Err(miette::miette!(
786            "git clone --bare failed with exit status {status}"
787        ));
788    }
789
790    // Enable dumb HTTP transport so the bare repo is clonable over plain HTTPS.
791    let status = Command::new("git")
792        .arg("-C")
793        .arg(dest)
794        .arg("update-server-info")
795        .stdout(std::process::Stdio::null())
796        .stderr(std::process::Stdio::null())
797        .status()
798        .await
799        .into_diagnostic()?;
800
801    if !status.success() {
802        warn!("git update-server-info failed; dumb HTTP cloning may not work");
803    }
804
805    Ok(())
806}
807
808// ── Tree browser ──────────────────────────────────────────────────────────────────
809
810/// Build the full static tree browser for every revision in `revisions`.
811///
812/// Everything here is synchronous (gix + std::fs + syntect), intended to run
813/// inside `tokio::task::spawn_blocking`.
814fn build_browse_pages(
815    revisions: &[(String, gix::ObjectId)],
816    browse_dir: &Path, // public/repository/browse/
817    repo_path: &Path,  // for `git cat-file blob`
818    theme_path: &Path, // .abbaye/theme (theme overrides)
819    project_name: &str,
820    lang: &Option<String>,
821    clone_url: &Option<String>,
822) -> Result<()> {
823    use syntect::highlighting::ThemeSet;
824    use syntect::parsing::SyntaxSet;
825
826    let ss = SyntaxSet::load_defaults_newlines();
827    let ts = ThemeSet::load_defaults();
828    let theme = &ts.themes["InspiredGitHub"];
829
830    // Build a separate Tera instance for browse templates.
831    let mut tera = Tera::default();
832    for (name, builtin) in [
833        ("git_tree.html", TEMPLATE_GIT_TREE),
834        ("git_blob.html", TEMPLATE_GIT_BLOB),
835    ] {
836        let override_path = theme_path.join(format!("{name}.j2"));
837        if override_path.is_file() {
838            tera.add_template_file(&override_path, Some(name))
839                .map_err(|e| miette::miette!("{e}"))?;
840        } else {
841            tera.add_raw_template(name, builtin)
842                .map_err(|e| miette::miette!("{e}"))?;
843        }
844    }
845    crate::site::load_extra_theme_templates(
846        &mut tera,
847        theme_path,
848        &["git_tree.html", "git_blob.html"],
849    )?;
850
851    for (hash, oid) in revisions {
852        let rev_dir = browse_dir.join(hash);
853        std::fs::create_dir_all(&rev_dir).into_diagnostic()?;
854
855        // Resolve the commit's root tree.
856        let repo = gix::open(repo_path).into_diagnostic()?;
857        let commit_obj = repo.find_object(*oid).into_diagnostic()?.into_commit();
858        let decoded = commit_obj.decode().into_diagnostic()?;
859        let tree_id = decoded.tree();
860
861        walk_tree_dir(
862            repo_path,
863            &repo,
864            tree_id,
865            "",
866            hash,
867            &rev_dir,
868            &tera,
869            project_name,
870            lang,
871            clone_url,
872            &ss,
873            theme,
874        )?;
875    }
876
877    Ok(())
878}
879
880/// Recursively generate one `index.html` (directory listing) per tree and one
881/// `<name>.html` per blob, rooted at `rev_dir`.
882/// TODO: Fix clippy warning about too many arguments
883#[allow(clippy::too_many_arguments)]
884fn walk_tree_dir(
885    repo_path: &Path,
886    repo: &gix::Repository,
887    tree_id: gix::ObjectId,
888    dir_path: &str, // "" = root, "src", "src/utils"
889    commit_hash: &str,
890    rev_dir: &Path, // public/repository/browse/<hash>/
891    tera: &Tera,
892    project_name: &str,
893    lang: &Option<String>,
894    clone_url: &Option<String>,
895    ss: &syntect::parsing::SyntaxSet,
896    theme: &syntect::highlighting::Theme,
897) -> Result<()> {
898    let tree_obj = repo.find_object(tree_id).into_diagnostic()?.into_tree();
899    let decoded = tree_obj.decode().into_diagnostic()?;
900
901    // Depth = number of path components in dir_path.
902    let depth: usize = dir_path.split('/').filter(|s| !s.is_empty()).count();
903
904    // Public output directory for this tree's index.html.
905    let page_dir = if dir_path.is_empty() {
906        rev_dir.to_path_buf()
907    } else {
908        dir_path
909            .split('/')
910            .filter(|s| !s.is_empty())
911            .fold(rev_dir.to_path_buf(), |p, c| p.join(c))
912    };
913    std::fs::create_dir_all(&page_dir).into_diagnostic()?;
914
915    let mut entries: Vec<TreeEntry> = Vec::new();
916    // Defer recursion until after the listing page is written.
917    let mut subdirs: Vec<(String, gix::ObjectId)> = Vec::new();
918    let mut blobs: Vec<(String, gix::ObjectId)> = Vec::new();
919
920    for entry in decoded.entries.iter() {
921        let name = entry.filename.to_str_lossy().into_owned();
922        let oid: gix::ObjectId = entry.oid.to_owned();
923
924        if entry.mode.is_tree() {
925            entries.push(TreeEntry {
926                url: format!("{name}/index.html"),
927                name: name.clone(),
928                kind: TreeEntryKind::Tree,
929            });
930            subdirs.push((name, oid));
931        } else {
932            // blob, executable blob, symlink, or submodule commit
933            entries.push(TreeEntry {
934                url: format!("{name}.html"),
935                name: name.clone(),
936                kind: TreeEntryKind::Blob,
937            });
938            if !entry.mode.is_commit() {
939                // Skip submodule gitlinks (they have no blob content).
940                blobs.push((name, oid));
941            }
942        }
943    }
944
945    // Directories first, then files; both alphabetical.
946    entries.sort_by(|a, b| {
947        let a_tree = matches!(a.kind, TreeEntryKind::Tree);
948        let b_tree = matches!(b.kind, TreeEntryKind::Tree);
949        b_tree.cmp(&a_tree).then(a.name.cmp(&b.name))
950    });
951
952    // root_path: how many levels up to reach the site root (public/).
953    // browse/<hash>/[subpath/]  =>  3 + depth levels up.
954    let root_path = "../".repeat(3 + depth);
955    // commit_url: link back to the commit detail page.
956    let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2));
957    let breadcrumbs = make_crumbs(dir_path, false, None);
958
959    let mut ctx = Context::new();
960    ctx.insert("project_name", project_name);
961    ctx.insert("lang", lang);
962    ctx.insert("clone_url", clone_url);
963    ctx.insert("commit_hash", commit_hash);
964    ctx.insert("commit_hash_short", &commit_hash[..7]);
965    ctx.insert("commit_url", &commit_url);
966    ctx.insert("dir_path", dir_path);
967    ctx.insert("entries", &entries);
968    ctx.insert("breadcrumbs", &breadcrumbs);
969    ctx.insert("root_path", &root_path);
970
971    let html = tera
972        .render("git_tree.html", &ctx)
973        .map_err(|e| miette::miette!("{e}"))?;
974    std::fs::write(page_dir.join("index.html"), html).into_diagnostic()?;
975
976    // Recurse into subdirectories.
977    for (name, oid) in subdirs {
978        let child_path = if dir_path.is_empty() {
979            name
980        } else {
981            format!("{dir_path}/{name}")
982        };
983        walk_tree_dir(
984            repo_path,
985            repo,
986            oid,
987            &child_path,
988            commit_hash,
989            rev_dir,
990            tera,
991            project_name,
992            lang,
993            clone_url,
994            ss,
995            theme,
996        )?;
997    }
998
999    // Render blob pages.
1000    for (name, oid) in blobs {
1001        let file_path = if dir_path.is_empty() {
1002            name.clone()
1003        } else {
1004            format!("{dir_path}/{name}")
1005        };
1006        render_blob_page(
1007            repo_path,
1008            &name,
1009            &file_path,
1010            oid,
1011            commit_hash,
1012            depth,
1013            &page_dir,
1014            tera,
1015            project_name,
1016            lang,
1017            clone_url,
1018            ss,
1019            theme,
1020        )?;
1021    }
1022
1023    Ok(())
1024}
1025
1026/// Write one syntax-highlighted blob page to `page_dir/<name>.html`.
1027#[allow(clippy::too_many_arguments)]
1028fn render_blob_page(
1029    repo_path: &Path,
1030    filename: &str,
1031    file_path: &str, // full path from repo root, e.g. "src/main.rs"
1032    oid: gix::ObjectId,
1033    commit_hash: &str,
1034    depth: usize,    // number of directory components containing the file
1035    page_dir: &Path, // output directory (same as the parent tree's page_dir)
1036    tera: &Tera,
1037    project_name: &str,
1038    lang: &Option<String>,
1039    clone_url: &Option<String>,
1040    ss: &syntect::parsing::SyntaxSet,
1041    theme: &syntect::highlighting::Theme,
1042) -> Result<()> {
1043    const MAX_BLOB_BYTES: usize = 1024 * 1024; // 1 MiB
1044
1045    // Read blob via `git cat-file blob <oid>` to avoid gix private-field access.
1046    let data: Vec<u8> = std::process::Command::new("git")
1047        .current_dir(repo_path)
1048        .args(["cat-file", "blob", &oid.to_string()])
1049        .output()
1050        .map(|o| o.stdout)
1051        .unwrap_or_default();
1052
1053    let is_binary = data[..data.len().min(8192)].contains(&0u8);
1054    let too_large = data.len() > MAX_BLOB_BYTES;
1055
1056    let content_html: Option<String> = if is_binary || too_large || data.is_empty() {
1057        None
1058    } else {
1059        let text = String::from_utf8_lossy(&data);
1060        let ext = std::path::Path::new(filename)
1061            .extension()
1062            .and_then(|s| s.to_str())
1063            .unwrap_or("");
1064        let syntax = ss
1065            .find_syntax_by_extension(ext)
1066            .or_else(|| {
1067                text.lines()
1068                    .next()
1069                    .and_then(|l| ss.find_syntax_by_first_line(l))
1070            })
1071            .unwrap_or_else(|| ss.find_syntax_plain_text());
1072        Some(
1073            syntect::html::highlighted_html_for_string(&text, ss, syntax, theme)
1074                .unwrap_or_else(|_| format!("<pre>{}</pre>", escape_html(&text))),
1075        )
1076    };
1077
1078    let root_path = "../".repeat(3 + depth);
1079    let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2));
1080    let breadcrumbs = make_crumbs(
1081        std::path::Path::new(file_path)
1082            .parent()
1083            .and_then(|p| p.to_str())
1084            .unwrap_or(""),
1085        true,
1086        Some(filename),
1087    );
1088
1089    let mut ctx = Context::new();
1090    ctx.insert("project_name", project_name);
1091    ctx.insert("lang", lang);
1092    ctx.insert("clone_url", clone_url);
1093    ctx.insert("commit_hash", commit_hash);
1094    ctx.insert("commit_hash_short", &commit_hash[..7]);
1095    ctx.insert("commit_url", &commit_url);
1096    ctx.insert("file_path", file_path);
1097    ctx.insert("filename", filename);
1098    ctx.insert("breadcrumbs", &breadcrumbs);
1099    ctx.insert("content_html", &content_html);
1100    ctx.insert("is_binary", &is_binary);
1101    ctx.insert("too_large", &too_large);
1102    ctx.insert("size", &data.len());
1103    ctx.insert("root_path", &root_path);
1104
1105    let html = tera
1106        .render("git_blob.html", &ctx)
1107        .map_err(|e| miette::miette!("{e}"))?;
1108    std::fs::write(page_dir.join(format!("{filename}.html")), html).into_diagnostic()?;
1109
1110    Ok(())
1111}
1112
1113/// Build breadcrumb entries for a tree or blob page.
1114///
1115/// `dir_path` is the path to the containing directory (e.g. `"src"` for
1116/// `src/main.rs`).  `depth` is derived from `dir_path` internally.
1117fn make_crumbs(dir_path: &str, is_blob: bool, filename: Option<&str>) -> Vec<Crumb> {
1118    let parts: Vec<&str> = dir_path.split('/').filter(|s| !s.is_empty()).collect();
1119    let depth = parts.len();
1120    let mut crumbs = Vec::new();
1121
1122    // Root crumb (“~”).
1123    let root_url = if depth == 0 && !is_blob {
1124        None // we ARE the root dir listing
1125    } else {
1126        Some(format!("{}index.html", "../".repeat(depth)))
1127    };
1128    crumbs.push(Crumb {
1129        name: "~".to_string(),
1130        url: root_url,
1131    });
1132
1133    // Intermediate directory crumbs.
1134    for (i, &part) in parts.iter().enumerate() {
1135        let is_last_and_tree = i == depth - 1 && !is_blob;
1136        let url = if is_last_and_tree {
1137            None // current directory
1138        } else {
1139            // levels_up = how many "../" to navigate from current location to this dir
1140            let levels_up = depth - i - 1;
1141            Some(format!("{}index.html", "../".repeat(levels_up)))
1142        };
1143        crumbs.push(Crumb {
1144            name: part.to_string(),
1145            url,
1146        });
1147    }
1148
1149    // Filename crumb for blobs.
1150    if is_blob {
1151        if let Some(name) = filename {
1152            crumbs.push(Crumb {
1153                name: name.to_string(),
1154                url: None,
1155            });
1156        }
1157    }
1158
1159    crumbs
1160}
1161
1162fn escape_html(s: &str) -> String {
1163    s.replace('&', "&amp;")
1164        .replace('<', "&lt;")
1165        .replace('>', "&gt;")
1166}
1167
1168// ── Helpers ────────────────────────────────────────────────────────────────────
1169
1170/// Split a raw commit message into (subject, optional body).
1171fn parse_message(raw: &str) -> (String, Option<String>) {
1172    if let Some(idx) = raw.find("\n\n") {
1173        let subject = raw[..idx].trim().to_string();
1174        let body = raw[idx + 2..].trim().to_string();
1175        let body = if body.is_empty() { None } else { Some(body) };
1176        (subject, body)
1177    } else {
1178        (raw.trim().to_string(), None)
1179    }
1180}