forks/sorcery

git clone https://git.gae.moe/forks/sorcery

Charlotte Somperf: prioritize push-triggered builds over background scans2688dec

darwin-flake
53.0 KiB1494 linesraw
1use std::collections::BTreeMap;
2use std::fmt::Write as _;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
7
8use anyhow::{Context, Result};
9// Safe for text and double-quoted attribute contexts.
10use html_escape::encode_double_quoted_attribute as escape;
11use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, PercentEncode, percent_encode};
12
13use crate::catalog::Repository;
14use crate::highlight::{Cache as GrammarCache, Highlighter};
15
16/// Blobs larger than this get a raw download instead of a rendered page.
17const MAX_RENDER_BYTES: u64 = 1 << 20;
18
19/// Cooperative cancellation for background builds; set when a
20/// human-triggered build wants the machine instead.
21pub(crate) type BuildCancel = Arc<AtomicBool>;
22
23pub(crate) fn bail_if_cancelled(cancel: &Option<BuildCancel>) -> Result<()> {
24    if cancel
25        .as_ref()
26        .is_some_and(|flag| flag.load(Ordering::Relaxed))
27    {
28        anyhow::bail!("site build cancelled");
29    }
30    Ok(())
31}
32
33const PATH: &AsciiSet = &NON_ALPHANUMERIC
34    .remove(b'-')
35    .remove(b'.')
36    .remove(b'_')
37    .remove(b'~')
38    .remove(b'/');
39
40/// Percent-encodes a slash-separated path for use in a URI.
41pub(crate) fn encode_path(path: &str) -> PercentEncode<'_> {
42    percent_encode(path.as_bytes(), PATH)
43}
44
45/// `tip` names the page's ref and the commit it points at; the client reads
46/// them from `<main data-ref data-tip>` to resolve hash routes relative to
47/// the page.
48fn page(
49    instance_name: &str,
50    title: &str,
51    description: &str,
52    tip: Option<(&str, gix::ObjectId)>,
53    body: &str,
54) -> String {
55    format!(
56        "<!doctype html>\n\
57         <html lang=\"en\">\n\
58         <head>\n\
59         <meta charset=\"utf-8\">\n\
60         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
61         <title>{title}</title>\n\
62         <meta name=\"description\" content=\"{description}\">\n\
63         <meta property=\"og:title\" content=\"{title}\">\n\
64         <meta property=\"og:description\" content=\"{description}\">\n\
65         <meta property=\"og:type\" content=\"website\">\n\
66         <meta property=\"og:site_name\" content=\"{instance_name}\">\n\
67         <link rel=\"stylesheet\" href=\"/css/style.css?v={format_version}\">\n\
68         <script type=\"module\" src=\"/js/main.js?v={format_version}\"></script>\n\
69         </head>\n\
70         <body>\n<main{tip}>\n{noscript}{body}</main>\n</body>\n\
71         </html>\n",
72        instance_name = escape(instance_name),
73        title = escape(title),
74        description = escape(description),
75        format_version = crate::generate::OUTPUT_FORMAT_VERSION,
76        tip = tip
77            .map(|(name, oid)| format!(" data-ref=\"{}\" data-tip=\"{oid}\"", escape(name)))
78            .unwrap_or_default(),
79        // only repo pages lose anything to a missing client
80        noscript = if tip.is_some() { NOSCRIPT } else { "" },
81    )
82}
83
84const NOSCRIPT: &str = r#"<noscript>
85  <div class="callout">
86    <p>
87      hey! this is a static git repo viewer. commit diffs, file history,
88      older commits and other revisions won't load without javascript.
89    </p>
90    <p>
91      you are still able to view source files on branch tips,
92      but the experience is degraded.
93    </p>
94  </div>
95</noscript>
96"#;
97
98pub(crate) fn catalog_index(instance_name: &str, repositories: &[Repository]) -> String {
99    let mut body = String::from("<h1>repositories</h1>\n");
100    if repositories.is_empty() {
101        body.push_str("<p class=\"meta\">no repositories found</p>\n");
102    }
103
104    for repos in repositories.chunk_by(|a, b| a.user == b.user) {
105        let user = &repos[0].user;
106        let _ = writeln!(
107            body,
108            "<h2><a href=\"/{}/\">{}</a></h2>",
109            encode_path(user),
110            escape(user),
111        );
112        catalog_list(&mut body, repos);
113    }
114
115    let title = format!("repositories - {instance_name}");
116    let description = format!("Git repositories hosted on {instance_name}.");
117    page(instance_name, &title, &description, None, &body)
118}
119
120/// `repositories` must all belong to `user`.
121pub(crate) fn user_index(instance_name: &str, user: &str, repositories: &[Repository]) -> String {
122    let mut body = format!("<h1>{}</h1>\n", escape(user));
123    catalog_list(&mut body, repositories);
124    let title = format!("{user} - {instance_name}");
125    let description = format!("Git repositories by {user} on {instance_name}.");
126    page(instance_name, &title, &description, None, &body)
127}
128
129fn catalog_list(body: &mut String, repositories: &[Repository]) {
130    body.push_str("<ul class=\"catalog\">\n");
131    for repo in repositories {
132        let _ = writeln!(
133            body,
134            "<li><a href=\"/{user}/{repo}/\"><span class=\"repo-name\">{repo_name}</span> <span class=\"msg\">{description}</span>{updated}</a></li>",
135            user = encode_path(&repo.user),
136            repo = encode_path(&repo.name),
137            repo_name = escape(&repo.name),
138            description = escape(repo.description.as_deref().unwrap_or_default()),
139            updated = repo
140                .updated
141                .map(|t| {
142                    format!(
143                        " <time datetime=\"{}\">{}</time>",
144                        t.format_or_unix(gix::date::time::format::ISO8601_STRICT),
145                        t.format_or_unix(gix::date::time::format::SHORT),
146                    )
147                })
148                .unwrap_or_default(),
149        );
150    }
151    body.push_str("</ul>\n");
152}
153
154#[derive(Clone, Copy, Debug, PartialEq, Eq)]
155pub enum RefKind {
156    Branch,
157    Tag,
158}
159
160#[derive(Clone, Copy, PartialEq, Eq)]
161pub enum StaticMode {
162    Highlighted,
163    /// Blob pages carry escaped plain text; the client highlights them.
164    Plain,
165}
166
167#[derive(Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
168pub(crate) struct BlobVersion {
169    pub oid: String,
170    pub mode: u16,
171    pub touched: String,
172}
173
174#[derive(Clone, Copy)]
175pub(crate) struct BlobReuse<'a> {
176    pub versions: &'a BTreeMap<String, BlobVersion>,
177    pub previous: Option<(&'a Path, &'a BTreeMap<String, BlobVersion>)>,
178}
179
180pub struct Site<'a> {
181    pub repo: &'a gix::Repository,
182    pub instance_name: String,
183    pub name: String,
184    pub base_url: String,
185    pub description: Option<String>,
186    pub clone_url: Option<String>,
187}
188
189impl Site<'_> {
190    /// Display names like `user/repo` shorten to the repo part in crumbs.
191    fn crumb_name(&self) -> &str {
192        self.name.rsplit('/').next().unwrap_or(&self.name)
193    }
194
195    fn ref_href(&self, tip: &RefTip) -> String {
196        match tip.static_mode {
197            None => format!("{}#{}", self.base_url, tip.commit_id),
198            Some(_) => {
199                format!("{}ref/{}/", self.base_url, encode_path(&tip.name))
200            }
201        }
202    }
203
204    /// Name, description and clone command; the same on every page of the repo.
205    fn repo_header(&self) -> String {
206        let mut html = format!("<header class=\"repo\"><div>\n<h1>{}</h1>\n", escape(&self.name));
207        if let Some(desc) = &self.description {
208            let _ = writeln!(html, "<p class=\"desc\">{}</p>", escape(desc));
209        }
210        html.push_str("</div>\n");
211        if let Some(url) = &self.clone_url {
212            let _ = writeln!(html, "<pre class=\"clone\">git clone {}</pre>", escape(url));
213        }
214        html.push_str("</header>\n");
215        html
216    }
217}
218
219/// Author, summary, short sha and date of one commit, as a status line.
220fn commit_panel(repo: &gix::Repository, oid: gix::ObjectId) -> String {
221    let Ok(commit) = repo.find_object(oid).map(|o| o.try_into_commit()) else {
222        return String::new();
223    };
224    let Ok(commit) = commit else {
225        return String::new();
226    };
227    let summary = commit.message().map(|m| m.summary().to_string()).unwrap_or_default();
228    let author = commit.author().map(|a| a.name.to_string()).unwrap_or_default();
229    format!(
230        "<p class=\"snapshot-commit\">\
231         <span class=\"commit-author\">{author}</span>\
232         <span class=\"commit-message\" data-commit=\"{oid}\">{summary}</span>\
233         <span class=\"snapshot-sha sha\" data-commit=\"{oid}\">{short}</span>\
234         {time}</p>\n",
235        author = escape(&author),
236        summary = escape(&summary),
237        short = commit.id().shorten_or_id(),
238        time = time_of(&commit),
239    )
240}
241
242pub struct RefTip {
243    pub kind: RefKind,
244    pub name: String,
245    pub commit_id: gix::ObjectId,
246    pub static_mode: Option<StaticMode>,
247}
248
249/// All local branches and tags, peeled to commits. Refs that don't peel to a
250/// commit (e.g. tags of blobs) are skipped.
251pub fn list_refs(repo: &gix::Repository) -> Result<Vec<RefTip>> {
252    let platform = repo.references()?;
253    let mut tips: Vec<RefTip> = Vec::new();
254    for (kind, iter) in [
255        (RefKind::Branch, platform.local_branches()?),
256        (RefKind::Tag, platform.tags()?),
257    ] {
258        for r in iter {
259            let mut r = r.map_err(|e| anyhow::anyhow!("failed to iterate refs: {e}"))?;
260            let name = r.name().shorten().to_string();
261            // A branch and tag may share a short name. The flat /ref/<name>
262            // namespace gives the branch precedence.
263            if kind == RefKind::Tag
264                && tips.iter().any(|t| t.kind == RefKind::Branch && t.name == name)
265            {
266                continue;
267            }
268            if let Ok(commit) = r.peel_to_commit() {
269                tips.push(RefTip {
270                    kind,
271                    name,
272                    commit_id: commit.id,
273                    static_mode: None,
274                });
275            }
276        }
277    }
278    Ok(tips)
279}
280
281/// The branch HEAD points at, if it exists among `tips` (falling back to the
282/// first branch, matching forge behaviour for detached/unborn HEADs).
283pub fn head_tip<'a>(repo: &gix::Repository, tips: &'a [RefTip]) -> Option<&'a RefTip> {
284    let head = repo.head_name().ok().flatten().map(|n| n.shorten().to_string());
285    tips.iter()
286        .find(|t| t.kind == RefKind::Branch && Some(&t.name) == head.as_ref())
287        .or_else(|| tips.iter().find(|t| t.kind == RefKind::Branch))
288}
289
290pub fn write_file(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
291    if let Some(parent) = path.parent() {
292        fs::create_dir_all(parent)?;
293    }
294    fs::write(path, contents).with_context(|| format!("writing {}", path.display()))
295}
296
297/// Renders the full static view of one ref (tree pages, blob pages, raw files
298/// for binaries) into `out`, which is typically a staging dir later swapped
299/// into place at `<site>/ref/<name>/`.
300pub fn render_ref(
301    site: &Site,
302    tips: &[RefTip],
303    tip: &RefTip,
304    commit: &gix::Commit,
305    out: &Path,
306    reuse: Option<BlobReuse<'_>>,
307    highlights: Arc<GrammarCache>,
308    cancel: Option<BuildCancel>,
309) -> Result<()> {
310    let mut r = RefRenderer::new(site, tips, tip, commit, out, highlights.clone(), cancel.clone());
311    let tree = commit.tree()?;
312    let mut blobs = Vec::new();
313    r.walk(&tree, &mut Vec::new(), &mut blobs)?;
314
315    // Blob pages dominate build time and are independent of each other, so
316    // fan out; the repository is only ever read, so each worker gets its own
317    // thread-local handle and reusable highlighter. Page costs vary wildly
318    // (and cluster by directory), so workers pull from a shared counter
319    // rather than taking fixed shares.
320    let ctx = BlobContext {
321        instance_name: site.instance_name.clone(),
322        name: site.name.clone(),
323        base_url: site.base_url.clone(),
324        urlbase: r.urlbase.clone(),
325        repo_header: site.repo_header(),
326        label: tip.name.clone(),
327        tip_id: commit.id,
328        highlight: tip.static_mode != Some(StaticMode::Plain),
329        out: out.to_owned(),
330        reuse,
331        highlights,
332        cancel,
333    };
334    let repo = site.repo.clone().into_sync();
335    let next = AtomicUsize::new(0);
336    rayon::broadcast(|_| {
337        let repo = repo.to_thread_local();
338        let mut highlighter = Highlighter::new(ctx.highlights.clone());
339        while let Some(job) = blobs.get(next.fetch_add(1, Ordering::Relaxed)) {
340            if let Err(error) = bail_if_cancelled(&ctx.cancel)
341                .and_then(|()| blob_page(&ctx, &repo, &mut highlighter, job))
342            {
343                next.store(blobs.len(), Ordering::Relaxed);
344                return Err(error);
345            }
346        }
347        Ok(())
348    })
349    .into_iter()
350    .collect()
351}
352
353/// Everything a blob page needs besides the per-worker repository handle and
354/// highlighter.
355struct BlobContext<'a> {
356    instance_name: String,
357    name: String,
358    base_url: String,
359    urlbase: String,
360    repo_header: String,
361    label: String,
362    tip_id: gix::ObjectId,
363    highlight: bool,
364    out: PathBuf,
365    reuse: Option<BlobReuse<'a>>,
366    highlights: Arc<GrammarCache>,
367    cancel: Option<BuildCancel>,
368}
369
370struct BlobJob {
371    path: Vec<String>,
372    oid: gix::ObjectId,
373    is_link: bool,
374    /// Switcher and crumbs, prebuilt during the walk: they need ref-wide
375    /// state that the workers don't carry.
376    chrome: String,
377}
378
379fn blob_page(
380    ctx: &BlobContext,
381    repo: &gix::Repository,
382    hl: &mut Highlighter,
383    job: &BlobJob,
384) -> Result<()> {
385    let joined = job.path.join("/");
386    let rel = format!("blob/{joined}");
387    // Embedded images may change without the markdown blob changing.
388    if !is_markdown(&joined)
389        && let Some(reuse) = ctx.reuse
390        && let Some((previous, previous_versions)) = reuse.previous
391        && reuse_blob(
392            previous,
393            previous_versions,
394            reuse.versions,
395            &joined,
396            &ctx.out,
397        )
398    {
399        return Ok(());
400    }
401    let data = &repo.find_object(job.oid)?.data.to_vec();
402
403    let size = data.len() as u64;
404    let image = !job.is_link && image_mime(&joined).is_some();
405    let raw_url = format!("{}raw/{}/{}", ctx.base_url, job.oid, encode_path(&joined));
406    let looks_binary = data[..data.len().min(8192)].contains(&0);
407    let text = (!job.is_link && !image && !looks_binary && size <= MAX_RENDER_BYTES)
408        .then(|| String::from_utf8_lossy(data));
409    let lines = text.as_ref().map_or(0, |text| text.lines().count());
410    let stats = if job.is_link {
411        format!("<span>symlink \u{2192} {}</span>", escape(&String::from_utf8_lossy(data)))
412    } else if text.is_some() {
413        format!(
414            "<span>{}</span><span>{lines} line{}</span>",
415            human_size(size),
416            if lines == 1 { "" } else { "s" },
417        )
418    } else {
419        format!(
420            "<span>{}</span><span>{}</span>",
421            if image {
422                "image"
423            } else if looks_binary {
424                "binary file"
425            } else {
426                "large file"
427            },
428            human_size(size),
429        )
430    };
431    let touched = ctx
432        .reuse
433        .and_then(|reuse| reuse.versions.get(&joined))
434        .and_then(|version| gix::ObjectId::from_hex(version.touched.as_bytes()).ok())
435        .unwrap_or(ctx.tip_id);
436    let mut body = format!(
437        "{repo_header}{panel}<header class=\"topbar\">{chrome}<span class=\"view-stats\">{stats}</span>\
438         <span class=\"actions\"><a class=\"raw\" href=\"{raw_url}\">raw</a></span></header>\n",
439        repo_header = ctx.repo_header,
440        panel = commit_panel(repo, touched),
441        chrome = job.chrome,
442    );
443
444    if let Some(text) = text {
445        let name = job.path.last().expect("blob path is never empty");
446        // Plain mode defers highlighting to the client, which detects the
447        // language from the path carried in data-hl.
448        let (src_attrs, code) = if ctx.highlight {
449            let lang = crate::grammar_manifest::detect(name);
450            let code = lang
451                .and_then(|lang| hl.highlight_lines(lang, &text).ok())
452                .unwrap_or_else(|| text.lines().map(|line| escape(line).to_string()).collect());
453            let class = lang.map(|l| format!(" language-{l}")).unwrap_or_default();
454            (format!("class=\"code src{class}\""), code)
455        } else {
456            (
457                format!("class=\"code src\" data-hl=\"{}\"", escape(&joined)),
458                text.lines().map(|line| escape(line).to_string()).collect(),
459            )
460        };
461        let mut source = format!("<pre {src_attrs}>");
462        let line_count = code.len();
463        for (i, line) in code.into_iter().enumerate() {
464            let number = i + 1;
465            let _ = write!(
466                source,
467                "<span class=\"code-line\"><a class=\"ln\" id=\"L{number}\" href=\"#L{number}\">{number}</a><span class=\"code-text\">{line}</span>"
468            );
469            if number < line_count {
470                source.push_str("<span class=\"code-break\">\n</span>");
471            }
472            source.push_str("</span>");
473        }
474        source.push_str("</pre>");
475        if is_markdown(name) {
476            let urls = RelativeUrls {
477                repo,
478                base_url: &ctx.base_url,
479                urlbase: &ctx.urlbase,
480                commit: ctx.tip_id,
481                directory: &job.path[..job.path.len() - 1],
482            };
483            let rendered = markdown(hl, &text, |url, image| urls.resolve(url, image));
484            let _ = writeln!(
485                body,
486                "<div class=\"markdown-view\">\
487                 <input type=\"radio\" name=\"markdown-view\" id=\"markdown-rendered\" checked>\
488                 <label for=\"markdown-rendered\">rendered</label>\
489                 <input type=\"radio\" name=\"markdown-view\" id=\"markdown-code\">\
490                 <label for=\"markdown-code\">code</label>\
491                 <section class=\"readme rendered\">{rendered}</section>\
492                 <div class=\"code-panel\">{source}</div>\
493                 </div>",
494            );
495        } else {
496            let _ = writeln!(body, "{source}");
497        }
498    } else if image {
499        let _ = writeln!(
500            body,
501            "<div class=\"image-preview\"><img src=\"{}\" alt=\"{}\"></div>",
502            escape(&raw_url),
503            escape(&joined)
504        );
505    } else if !job.is_link {
506        let _ = writeln!(
507            body,
508            "<div class=\"file-download\"><p>This file cannot be previewed.</p><a href=\"{}\" download=\"{}\">download raw file</a></div>",
509            escape(&raw_url),
510            escape(job.path.last().expect("blob path is never empty"))
511        );
512    }
513
514    let title = format!("{} - {} @ {}", joined, ctx.name, ctx.label);
515    let description = format!("{joined} in {} at {}.", ctx.name, ctx.label);
516    write_file(
517        &ctx.out.join(&rel),
518        page(&ctx.instance_name, &title, &description, Some((&ctx.label, ctx.tip_id)), &body),
519    )
520}
521
522fn reuse_blob(
523    previous: &Path,
524    previous_versions: &BTreeMap<String, BlobVersion>,
525    versions: &BTreeMap<String, BlobVersion>,
526    path: &str,
527    out: &Path,
528) -> bool {
529    let Some(version) = versions.get(path) else {
530        return false;
531    };
532    if previous_versions.get(path) != Some(version) {
533        return false;
534    }
535    let previous = previous.join("blob").join(path);
536    let out = out.join("blob").join(path);
537    if !previous.is_file()
538        || out.parent().is_none_or(|parent| fs::create_dir_all(parent).is_err())
539    {
540        return false;
541    }
542    if fs::hard_link(&previous, &out).is_ok() || fs::copy(&previous, &out).is_ok() {
543        return true;
544    }
545    let _ = fs::remove_file(out);
546    false
547}
548
549/// The landing page: repo header plus the HEAD branch's root tree view, with
550/// entry links pointing into the branch's own pages under `ref/`.
551pub fn render_site_index(
552    site: &Site,
553    tips: &[RefTip],
554    out: &Path,
555    highlights: Arc<GrammarCache>,
556    cancel: Option<BuildCancel>,
557) -> Result<()> {
558    let mut body = format!("<a class=\"back\" href=\"/\">← back</a>\n{}", site.repo_header());
559
560    let head = head_tip(site.repo, tips);
561    match head {
562        None => body.push_str("<p class=\"meta\">no branches yet</p>\n"),
563        Some(tip) => {
564            let commit = site.repo.find_object(tip.commit_id)?.try_into_commit()?;
565            let tree = commit.tree()?;
566            let mut r = RefRenderer::new(site, tips, tip, &commit, out, highlights, cancel);
567            let entries = collect_entries(&tree)?;
568            body.push_str(&commit_panel(site.repo, tip.commit_id));
569            body.push_str(&r.tree_topbar(&[], &entries));
570            if tip.static_mode == Some(StaticMode::Highlighted) {
571                body.push_str(&render_languages(
572                    &crate::languages::analyze(site.repo, &tree)?,
573                    tip.commit_id,
574                ));
575            }
576            body.push_str(&r.overview(&entries));
577            body.push_str(&r.readme_section(&[], &entries));
578        }
579    }
580
581    let description = site
582        .description
583        .clone()
584        .unwrap_or_else(|| format!("{} repository on {}.", site.name, site.instance_name));
585    write_file(
586        &out.join("index.html"),
587        page(&site.instance_name, &site.name, &description, head.map(|tip| (tip.name.as_str(), tip.commit_id)), &body),
588    )
589}
590
591/// `data-tip` and `data-language` let the client turn each label into a
592/// link to that language's files at the tip.
593fn render_languages(stats: &[crate::languages::Stat], tip: gix::ObjectId) -> String {
594    let total = stats.iter().map(|stat| stat.bytes).sum::<u64>();
595    if total == 0 {
596        return String::new();
597    }
598
599    let mut html = format!(
600        "<details class=\"languages\" data-tip=\"{tip}\">\n<summary><span class=\"language-label\">languages</span>",
601    );
602    for stat in stats {
603        let percentage = stat.bytes as f64 * 100.0 / total as f64;
604        let _ = write!(
605            html,
606            "<span title=\"{} {:.1}%\" style=\"background:{};width:{percentage:.6}%\"></span>",
607            escape(stat.name),
608            percentage,
609            stat.color,
610        );
611    }
612    html.push_str("</summary>\n<ul>\n");
613    for stat in stats {
614        let percentage = stat.bytes as f64 * 100.0 / total as f64;
615        let _ = writeln!(
616            html,
617            "<li data-language=\"{}\"><i style=\"background:{}\"></i><span>{} <small>{:.1}%</small></span></li>",
618            stat.id,
619            stat.color,
620            escape(stat.name),
621            percentage,
622        );
623    }
624    html.push_str("</ul>\n</details>\n");
625    html
626}
627
628/// `/refs`: branch and tag tables with tip summaries and dates.
629pub fn render_refs_page(site: &Site, tips: &[RefTip], out: &Path) -> Result<()> {
630    let head = head_tip(site.repo, tips);
631    let mut body = format!(
632        "{}<header class=\"topbar\"><nav class=\"crumbs\"><a href=\"{}\">{}</a> / <span class=\"cur\">refs</span></nav></header>\n",
633        site.repo_header(),
634        site.base_url,
635        escape(site.crumb_name()),
636    );
637
638    for (kind, heading) in [(RefKind::Branch, "branches"), (RefKind::Tag, "tags")] {
639        // HEAD branch first, then alphabetical; tags newest first
640        let mut dated: Vec<_> = tips
641            .iter()
642            .filter(|t| t.kind == kind)
643            .map(|t| {
644                let commit = site
645                    .repo
646                    .find_object(t.commit_id)
647                    .map_err(anyhow::Error::from)
648                    .and_then(|o| Ok(o.try_into_commit()?));
649                let (summary, time, secs) = match &commit {
650                    Ok(c) => (
651                        c.message().map(|m| m.summary().to_string()).unwrap_or_default(),
652                        time_of(c),
653                        author_time(c).map(|t| t.seconds).unwrap_or(0),
654                    ),
655                    Err(_) => (String::new(), String::new(), 0),
656                };
657                (t, summary, time, secs)
658            })
659            .collect();
660        if dated.is_empty() {
661            continue;
662        }
663        match kind {
664            RefKind::Branch => dated.sort_by(|a, b| {
665                let is_head = |t: &RefTip| head.is_some_and(|head| head.name == t.name);
666                is_head(b.0).cmp(&is_head(a.0)).then_with(|| a.0.name.cmp(&b.0.name))
667            }),
668            RefKind::Tag => dated.sort_by_key(|d| std::cmp::Reverse(d.3)),
669        }
670
671        let _ = write!(body, "<h2>{heading}</h2>\n<table class=\"list\">\n");
672        for (tip, summary, time, _) in dated {
673            let _ = writeln!(
674                body,
675                "<tr><td><a href=\"{href}\">{name}</a></td><td class=\"msg\">{summary}</td><td class=\"date\">{time}</td></tr>",
676                href = site.ref_href(tip),
677                name = escape(&tip.name),
678                summary = escape(&summary),
679                time = time,
680            );
681        }
682        body.push_str("</table>\n");
683    }
684
685    let title = format!("refs - {}", site.name);
686    let description = format!("Branches and tags for {}.", site.name);
687    write_file(
688        &out.join("refs"),
689        page(&site.instance_name, &title, &description, head.map(|tip| (tip.name.as_str(), tip.commit_id)), &body),
690    )
691}
692
693struct RefRenderer<'a> {
694    site: &'a Site<'a>,
695    tips: &'a [RefTip],
696    out: &'a Path,
697    label: String,
698    /// Absolute URL prefix of this ref's pages, e.g. `/user/repo/ref/main/`.
699    urlbase: String,
700    tip_id: gix::ObjectId,
701    hl: Highlighter,
702    cancel: Option<BuildCancel>,
703}
704
705enum EntryKind {
706    Dir,
707    File,
708    Link,
709    Submodule,
710}
711
712type Entry = (String, EntryKind, gix::ObjectId);
713
714fn collect_entries(tree: &gix::Tree<'_>) -> Result<Vec<Entry>> {
715    let mut entries = Vec::new();
716    for entry in tree.iter() {
717        let entry = entry?;
718        let mode = entry.mode();
719        let kind = if mode.is_tree() {
720            EntryKind::Dir
721        } else if mode.is_link() {
722            EntryKind::Link
723        } else if mode.is_commit() {
724            EntryKind::Submodule
725        } else {
726            EntryKind::File
727        };
728        entries.push((entry.filename().to_string(), kind, entry.oid().to_owned()));
729    }
730    entries.sort_by(|a, b| {
731        let rank = |k: &EntryKind| !matches!(k, EntryKind::Dir);
732        rank(&a.1).cmp(&rank(&b.1)).then_with(|| a.0.cmp(&b.0))
733    });
734    Ok(entries)
735}
736
737/// `a/b/c` when `a` holds only `b`, which holds only `c`: a chain of lone
738/// directories reads better as one link than as three clicks.
739fn collapse_lone_dirs(repo: &gix::Repository, name: &str, mut oid: gix::ObjectId) -> String {
740    let mut path = name.to_string();
741    while let Ok(tree) = repo.find_tree(oid) {
742        let mut entries = tree.iter();
743        match (entries.next(), entries.next()) {
744            (Some(Ok(only)), None) if only.mode().is_tree() => {
745                let _ = write!(path, "/{}", only.filename());
746                oid = only.oid().to_owned();
747            }
748            _ => break,
749        }
750    }
751    path
752}
753
754impl<'a> RefRenderer<'a> {
755    fn new(
756        site: &'a Site<'a>,
757        tips: &'a [RefTip],
758        tip: &RefTip,
759        commit: &gix::Commit,
760        out: &'a Path,
761        highlights: Arc<GrammarCache>,
762        cancel: Option<BuildCancel>,
763    ) -> Self {
764        RefRenderer {
765            site,
766            tips,
767            out,
768            label: tip.name.clone(),
769            urlbase: format!("{}ref/{}/", site.base_url, encode_path(&tip.name)),
770            tip_id: commit.id,
771            hl: Highlighter::new(highlights),
772            cancel,
773        }
774    }
775
776    fn walk(
777        &mut self,
778        tree: &gix::Tree<'_>,
779        path: &mut Vec<String>,
780        blobs: &mut Vec<BlobJob>,
781    ) -> Result<()> {
782        bail_if_cancelled(&self.cancel)?;
783        let entries = collect_entries(tree)?;
784        self.tree_page(path, &entries)?;
785
786        for (name, kind, oid) in entries {
787            path.push(name);
788            match kind {
789                EntryKind::Dir => {
790                    let subtree = self.site.repo.find_object(oid)?.try_into_tree()?;
791                    self.walk(&subtree, path, blobs)?;
792                }
793                EntryKind::File | EntryKind::Link => {
794                    blobs.push(BlobJob {
795                        chrome: format!("{}{}", self.switcher(), self.crumbs(path, true)),
796                        path: path.clone(),
797                        oid,
798                        is_link: matches!(kind, EntryKind::Link),
799                    });
800                }
801                EntryKind::Submodule => {}
802            }
803            path.pop();
804        }
805        Ok(())
806    }
807
808    fn tree_page(&mut self, path: &[String], entries: &[Entry]) -> Result<()> {
809        let rel = if path.is_empty() {
810            "index.html".to_string()
811        } else {
812            format!("tree/{}/index.html", path.join("/"))
813        };
814        let mut body = if path.is_empty() {
815            "<a class=\"back\" href=\"/\">← back</a>\n".to_string()
816        } else {
817            String::new()
818        };
819        body.push_str(&self.site.repo_header());
820        body.push_str(&commit_panel(self.site.repo, self.tip_id));
821        body.push_str(&self.tree_topbar(path, entries));
822        if path.is_empty() {
823            body.push_str(&self.overview(entries));
824        } else {
825            body.push_str("<h2 class=\"file-heading\">files</h2>\n");
826            body.push_str(&self.listing(path, entries));
827        }
828        body.push_str(&self.readme_section(path, entries));
829
830        let title = if path.is_empty() {
831            format!("{} @ {}", self.site.name, self.label)
832        } else {
833            format!("{}/ - {} @ {}", path.join("/"), self.site.name, self.label)
834        };
835        let description = if path.is_empty() {
836            format!("Source tree for {} at {}.", self.site.name, self.label)
837        } else {
838            format!(
839                "{} in {} at {}.",
840                path.join("/"),
841                self.site.name,
842                self.label,
843            )
844        };
845        write_file(
846            &self.out.join(&rel),
847            page(&self.site.instance_name, &title, &description, Some((&self.label, self.tip_id)), &body),
848        )
849    }
850
851    /// Root-page layout: file listing beside a recent-commits log.
852    fn overview(&self, entries: &[Entry]) -> String {
853        format!(
854            "<div class=\"overview\"><section class=\"files\"><h2>files</h2>\n{}</section><aside class=\"commits\">{}</aside></div>\n",
855            self.listing(&[], entries),
856            self.log_section(),
857        )
858    }
859
860    /// Recent commits from this ref's tip, jj-style: change id (from the
861    /// `change-id` commit header jj can write), short sha, summary.
862    fn log_section(&self) -> String {
863        const SHOWN: usize = 10;
864        let mut s = String::from("<h2 class=\"log-heading\">recent commits</h2>\n<ol class=\"log\">\n");
865        let mut shown = Vec::with_capacity(SHOWN);
866        let mut parents = Vec::new();
867        let mut truncated = false;
868        let Ok(walk) = self.site.repo.rev_walk([self.tip_id]).all() else {
869            return String::new();
870        };
871        for (n, info) in walk.flatten().enumerate() {
872            if n == SHOWN {
873                truncated = true;
874                break;
875            }
876            let Ok(commit) = self
877                .site
878                .repo
879                .find_object(info.id)
880                .map_err(anyhow::Error::from)
881                .and_then(|o| Ok(o.try_into_commit()?))
882            else {
883                continue;
884            };
885            shown.push(info.id);
886            parents.extend(commit.parent_ids().map(|id| id.detach()));
887            let change_id = commit
888                .decode()
889                .ok()
890                .and_then(|c| c.extra_headers().find("change-id").map(|v| v.to_string()));
891            let cid = change_id
892                .as_deref()
893                .map(|c| {
894                    format!(
895                        "<span class=\"cid\" data-commit=\"{}\">{}</span> ",
896                        info.id,
897                        escape(&c[..c.len().min(8)]),
898                    )
899                })
900                .unwrap_or_default();
901            let summary = commit.message().map(|m| m.summary().to_string()).unwrap_or_default();
902            let author = commit.author().map(|a| a.name.to_string()).unwrap_or_default();
903            let _ = writeln!(
904                s,
905                "<li data-oid=\"{full}\">{cid}<span class=\"sha\" data-commit=\"{full}\">{sha}</span> <span class=\"who\"><span>{author}</span>{time}</span><span class=\"msg\">{summary}</span></li>",
906                full = info.id,
907                sha = commit.id().shorten_or_id(),
908                author = escape(&author),
909                time = time_of(&commit),
910                summary = escape(&summary),
911            );
912        }
913        s.push_str("</ol>\n");
914        if truncated {
915            let mut frontier = Vec::new();
916            for parent in parents {
917                if !shown.contains(&parent) && !frontier.contains(&parent) {
918                    frontier.push(parent);
919                }
920            }
921            let frontier = frontier
922                .iter()
923                .map(ToString::to_string)
924                .collect::<Vec<_>>()
925                .join(" ");
926            let _ = writeln!(
927                s,
928                "<p class=\"meta log-pagination\" data-log-frontier=\"{frontier}\">⋯ older commits not shown</p>",
929            );
930        }
931        s
932    }
933
934    fn listing(&self, path: &[String], entries: &[Entry]) -> String {
935        let mut sub = path.join("/");
936        if !sub.is_empty() {
937            sub.push('/');
938        }
939        let mut body = "<table class=\"list\">\n".to_string();
940        for (name, kind, oid) in entries {
941            match kind {
942                EntryKind::Dir => {
943                    let name = collapse_lone_dirs(self.site.repo, name, *oid);
944                    let _ = writeln!(
945                        body,
946                        "<tr><td><a href=\"{base}tree/{href}/\">{name}/</a></td><td class=\"size\"></td></tr>",
947                        base = self.urlbase,
948                        href = encode_path(&format!("{sub}{name}")),
949                        name = escape(&name),
950                    );
951                }
952                EntryKind::File | EntryKind::Link => {
953                    let size = self.site.repo.find_header(*oid).map(|h| h.size()).unwrap_or(0);
954                    let _ = writeln!(
955                        body,
956                        "<tr><td><a href=\"{base}blob/{href}\">{name}{sigil}</a></td><td class=\"size\">{size}</td></tr>",
957                        base = self.urlbase,
958                        href = encode_path(&format!("{sub}{name}")),
959                        name = escape(name),
960                        sigil = if matches!(kind, EntryKind::Link) { "@" } else { "" },
961                        size = human_size(size),
962                    );
963                }
964                EntryKind::Submodule => {
965                    let _ = writeln!(
966                        body,
967                        "<tr><td>{name} @ {oid:.8}</td><td class=\"size\"></td></tr>",
968                        name = escape(name),
969                    );
970                }
971            }
972        }
973        body.push_str("</table>\n");
974        body
975    }
976
977    fn readme_section(&mut self, path: &[String], entries: &[Entry]) -> String {
978        match find_readme(self.site.repo, entries) {
979            Some((name, data)) => {
980                let text = String::from_utf8_lossy(&data);
981                let readme = if is_markdown(&name) {
982                    let urls = RelativeUrls {
983                        repo: self.site.repo,
984                        base_url: &self.site.base_url,
985                        urlbase: &self.urlbase,
986                        commit: self.tip_id,
987                        directory: path,
988                    };
989                    markdown(&mut self.hl, &text, |url, image| urls.resolve(url, image))
990                } else {
991                    format!("<pre>{}</pre>\n", escape(&text))
992                };
993                format!("<section class=\"readme\">\n{readme}</section>\n")
994            }
995            None => String::new(),
996        }
997    }
998
999    fn tree_topbar(&self, path: &[String], entries: &[Entry]) -> String {
1000        let folders = entries.iter().filter(|(_, kind, _)| matches!(kind, EntryKind::Dir)).count();
1001        let files = entries.len() - folders;
1002        let mut stats = String::from("<span class=\"view-stats\">");
1003        if folders > 0 {
1004            let _ = write!(stats, "<span>{folders} folder{}</span>", if folders == 1 { "" } else { "s" });
1005        }
1006        if files > 0 {
1007            let _ = write!(stats, "<span>{files} file{}</span>", if files == 1 { "" } else { "s" });
1008        }
1009        stats.push_str("</span>");
1010        // the empty actions slot is where the client puts the history link
1011        format!(
1012            "<header class=\"topbar\">{}{}{stats}<span class=\"actions\"></span></header>\n",
1013            self.switcher(),
1014            self.crumbs(path, false),
1015        )
1016    }
1017
1018    /// A no-JS `<details>` dropdown listing all refs. Baked at build time, so
1019    /// pages of refs untouched since a ref was added/deleted list it stale;
1020    /// the always-rebuilt index and refs pages stay fresh.
1021    fn switcher(&self) -> String {
1022        let mut s = format!(
1023            "<details class=\"switcher\"><summary>{}</summary><div class=\"menu\">",
1024            escape(&self.label),
1025        );
1026        for (kind, heading) in [(RefKind::Branch, "branches"), (RefKind::Tag, "tags")] {
1027            let mut group = self
1028                .tips
1029                .iter()
1030                .filter(|t| t.kind == kind && t.static_mode.is_some())
1031                .peekable();
1032            if group.peek().is_none() {
1033                continue;
1034            }
1035            let _ = write!(s, "<strong>{heading}</strong>");
1036            for t in group {
1037                let current = t.name == self.label;
1038                let _ = write!(
1039                    s,
1040                    "<a{class} href=\"{href}\">{name}</a>",
1041                    class = if current { " class=\"current\"" } else { "" },
1042                    href = self.site.ref_href(t),
1043                    name = escape(&t.name),
1044                );
1045            }
1046        }
1047        let _ = write!(
1048            s,
1049            "<a class=\"all\" href=\"{}refs\">all refs →</a></div></details>",
1050            self.site.base_url,
1051        );
1052        s
1053    }
1054
1055    /// Breadcrumb nav: site name / path components, all but the last linked.
1056    fn crumbs(&self, path: &[String], last_is_file: bool) -> String {
1057        let mut nav = format!(
1058            "<nav class=\"crumbs\"><a href=\"{href}\">{site}</a>",
1059            href = self.site.base_url,
1060            site = escape(self.site.crumb_name()),
1061        );
1062        for (i, comp) in path.iter().enumerate() {
1063            let is_last = i + 1 == path.len();
1064            if is_last && last_is_file {
1065                let _ = write!(nav, " / <span class=\"cur\">{}</span>", escape(comp));
1066            } else if is_last {
1067                let _ = write!(nav, " / <span class=\"cur\">{}</span> /", escape(comp));
1068            } else {
1069                let _ = write!(
1070                    nav,
1071                    " / <a href=\"{base}tree/{href}/\">{name}</a>",
1072                    base = self.urlbase,
1073                    href = encode_path(&path[..=i].join("/")),
1074                    name = escape(comp),
1075                );
1076            }
1077        }
1078        nav.push_str("</nav>\n");
1079        nav
1080    }
1081}
1082
1083/// Author time as a `<time>`: absolute in `datetime` and, on hover, in
1084/// `title`; date-only as text, which the client relativizes while recent.
1085/// The in-browser client also renders author time, so dates match site-wide.
1086fn time_of(commit: &gix::Commit<'_>) -> String {
1087    author_time(commit)
1088        .map(|t| {
1089            format!(
1090                "<time datetime=\"{}\" title=\"{}\">{}</time>",
1091                t.format_or_unix(gix::date::time::format::ISO8601_STRICT),
1092                t.format_or_unix(gix::date::time::format::ISO8601),
1093                t.format_or_unix(gix::date::time::format::SHORT),
1094            )
1095        })
1096        .unwrap_or_default()
1097}
1098
1099pub(crate) fn author_time(commit: &gix::Commit<'_>) -> Option<gix::date::Time> {
1100    commit.author().ok()?.time().ok()
1101}
1102
1103fn find_readme(repo: &gix::Repository, entries: &[Entry]) -> Option<(String, Vec<u8>)> {
1104    ["readme.md", "readme", "readme.txt"].iter().find_map(|want| {
1105        entries.iter().find_map(|(name, kind, oid)| {
1106            (matches!(kind, EntryKind::File) && name.to_lowercase() == *want)
1107                .then(|| Some((name.clone(), repo.find_object(*oid).ok()?.data.to_vec())))
1108                .flatten()
1109        })
1110    })
1111}
1112
1113fn is_markdown(name: &str) -> bool {
1114    let name = name.to_lowercase();
1115    name.ends_with(".md") || name.ends_with(".markdown")
1116}
1117
1118pub(crate) fn image_mime(path: &str) -> Option<&'static str> {
1119    match Path::new(path)
1120        .extension()?
1121        .to_str()?
1122        .to_ascii_lowercase()
1123        .as_str()
1124    {
1125        "png" => Some("image/png"),
1126        "jpg" | "jpeg" => Some("image/jpeg"),
1127        "gif" => Some("image/gif"),
1128        "webp" => Some("image/webp"),
1129        "avif" => Some("image/avif"),
1130        "bmp" => Some("image/bmp"),
1131        "ico" => Some("image/x-icon"),
1132        _ => None,
1133    }
1134}
1135
1136/// Corrects a markdown file's relative URLs :D
1137struct RelativeUrls<'a> {
1138    repo: &'a gix::Repository,
1139    base_url: &'a str,
1140    urlbase: &'a str,
1141    commit: gix::ObjectId,
1142    directory: &'a [String],
1143}
1144
1145impl RelativeUrls<'_> {
1146    fn resolve(&self, url: &str, image: bool) -> Option<String> {
1147        if url.starts_with(['/', '#']) || url.contains(':') {
1148            return None;
1149        }
1150        let end = url.find(['?', '#']).unwrap_or(url.len());
1151        let suffix = &url[end..];
1152        let decoded = percent_encoding::percent_decode_str(&url[..end])
1153            .decode_utf8()
1154            .ok()?;
1155        let mut path = self.directory.to_vec();
1156        for part in decoded.split('/') {
1157            match part {
1158                "" | "." => {}
1159                ".." => {
1160                    path.pop()?;
1161                }
1162                part => path.push(part.to_string()),
1163            }
1164        }
1165        if path.is_empty() {
1166            return (!image).then(|| format!("{}{suffix}", self.urlbase));
1167        }
1168        let joined = path.join("/");
1169        let tree = self.repo.find_commit(self.commit).ok()?.tree().ok()?;
1170        let entry = tree.lookup_entry_by_path(&joined).ok()??;
1171        let mode = entry.mode();
1172        let href = encode_path(&joined);
1173        if image {
1174            mode.is_blob()
1175                .then(|| format!("{}raw/{}/{href}{suffix}", self.base_url, entry.id()))
1176        } else if mode.is_tree() {
1177            Some(format!("{}tree/{href}/{suffix}", self.urlbase))
1178        } else if mode.is_blob_or_symlink() {
1179            Some(format!("{}blob/{href}{suffix}", self.urlbase))
1180        } else {
1181            None
1182        }
1183    }
1184}
1185
1186fn markdown(
1187    hl: &mut Highlighter,
1188    src: &str,
1189    mut resolve: impl FnMut(&str, bool) -> Option<String>,
1190) -> String {
1191    use pulldown_cmark::{html, CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
1192    let opts = Options::ENABLE_TABLES
1193        | Options::ENABLE_STRIKETHROUGH
1194        | Options::ENABLE_FOOTNOTES
1195        | Options::ENABLE_TASKLISTS;
1196    // Buffer fenced code blocks so they get the same arborium treatment as
1197    // blob pages; escape raw HTML rather than pulling in a sanitizer.
1198    let mut fence: Option<(Option<&'static str>, String)> = None;
1199    let events = Parser::new_ext(src, opts).filter_map(|ev| match ev {
1200        Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
1201            // only the first word names the language: ```rust,ignore
1202            let lang = info
1203                .split(|c: char| c == ',' || c.is_whitespace())
1204                .next()
1205                .and_then(crate::grammar_manifest::canonical);
1206            fence = Some((lang, String::new()));
1207            None
1208        }
1209        Event::Text(t) if fence.is_some() => {
1210            fence.as_mut().expect("checked in guard").1.push_str(&t);
1211            None
1212        }
1213        Event::End(TagEnd::CodeBlock) if fence.is_some() => {
1214            let (lang, code) = fence.take().expect("checked in guard");
1215            let class = lang.map(|l| format!(" class=\"language-{l}\"")).unwrap_or_default();
1216            let code = lang
1217                .and_then(|lang| hl.highlight(lang, &code).ok())
1218                .unwrap_or_else(|| escape(&code).to_string());
1219            Some(Event::Html(format!("<pre{class}>{code}</pre>\n").into()))
1220        }
1221        Event::Html(s) => Some(Event::Text(s)),
1222        Event::Start(Tag::Link {
1223            link_type,
1224            dest_url,
1225            title,
1226            id,
1227        }) => {
1228            let dest_url = resolve(&dest_url, false).map(Into::into).unwrap_or(dest_url);
1229            Some(Event::Start(Tag::Link {
1230                link_type,
1231                dest_url,
1232                title,
1233                id,
1234            }))
1235        }
1236        Event::Start(Tag::Image {
1237            link_type,
1238            dest_url,
1239            title,
1240            id,
1241        }) => {
1242            let dest_url = resolve(&dest_url, true).map(Into::into).unwrap_or(dest_url);
1243            Some(Event::Start(Tag::Image {
1244                link_type,
1245                dest_url,
1246                title,
1247                id,
1248            }))
1249        }
1250        Event::InlineHtml(s) => Some(Event::Text(s)),
1251        ev => Some(ev),
1252    });
1253    let mut out = String::new();
1254    html::push_html(&mut out, events);
1255    out
1256}
1257
1258fn human_size(bytes: u64) -> String {
1259    const UNITS: &[&str] = &["KiB", "MiB", "GiB", "TiB"];
1260    if bytes < 1024 {
1261        return format!("{bytes} B");
1262    }
1263    let mut size = bytes as f64;
1264    let mut unit = "";
1265    for u in UNITS {
1266        size /= 1024.0;
1267        unit = u;
1268        if size < 1024.0 {
1269            break;
1270        }
1271    }
1272    format!("{size:.1} {unit}")
1273}
1274
1275#[cfg(test)]
1276mod tests {
1277    use std::collections::BTreeMap;
1278    use std::fs;
1279    use std::os::unix::fs::symlink;
1280
1281    use anyhow::Result;
1282
1283    use crate::highlight::Highlighter;
1284    use crate::testutil::{TempDir, commit, git, grammar_cache, init_repo};
1285
1286    use super::{
1287        BlobVersion, EntryKind, RefKind, collapse_lone_dirs, collect_entries, encode_path,
1288        find_readme, head_tip, human_size, list_refs, markdown, reuse_blob, time_of,
1289    };
1290
1291    #[test]
1292    fn encodes_url_paths() {
1293        assert_eq!(encode_path("a b/c?d#\u{e9}").to_string(), "a%20b/c%3Fd%23%C3%A9");
1294    }
1295
1296    #[test]
1297    fn human_sizes_switch_units_at_1024() {
1298        assert_eq!(human_size(0), "0 B");
1299        assert_eq!(human_size(1023), "1023 B");
1300        assert_eq!(human_size(1024), "1.0 KiB");
1301        assert_eq!(human_size(1536), "1.5 KiB");
1302        assert_eq!(human_size(1 << 20), "1.0 MiB");
1303        assert_eq!(human_size(1 << 40), "1.0 TiB");
1304        assert_eq!(human_size(1 << 50), "1024.0 TiB");
1305    }
1306
1307    #[test]
1308    fn commit_time_carries_absolute_timestamp() -> Result<()> {
1309        let root = TempDir::new("time-of");
1310        let repo_path = root.join("repo");
1311        init_repo(&repo_path)?;
1312        fs::write(repo_path.join("file"), "")?;
1313        git(&repo_path, &["add", "-A"])?;
1314        git(&repo_path, &["commit", "-qm", "first", "--date=@978307200 +0000"])?;
1315        let repo = gix::open(&repo_path)?;
1316        let commit = repo.head_commit()?;
1317        assert_eq!(
1318            time_of(&commit),
1319            "<time datetime=\"2001-01-01T00:00:00+00:00\" title=\"2001-01-01 00:00:00 +0000\">2001-01-01</time>",
1320        );
1321        Ok(())
1322    }
1323
1324    #[test]
1325    fn tree_entries_list_directories_first_then_by_name() -> Result<()> {
1326        let root = TempDir::new("entries");
1327        let repo_path = root.join("repo");
1328        init_repo(&repo_path)?;
1329        for dir in ["zeta", "alpha"] {
1330            fs::create_dir(repo_path.join(dir))?;
1331            fs::write(repo_path.join(dir).join("keep"), "")?;
1332        }
1333        fs::write(repo_path.join("beta"), "")?;
1334        fs::write(repo_path.join("aardvark"), "")?;
1335        symlink("beta", repo_path.join("gamma"))?;
1336        commit(&repo_path, "first")?;
1337
1338        let repo = gix::open(&repo_path)?;
1339        let entries = collect_entries(&repo.head_commit()?.tree()?)?;
1340        let names: Vec<_> = entries.iter().map(|(name, ..)| name.as_str()).collect();
1341        assert_eq!(names, ["alpha", "zeta", "aardvark", "beta", "gamma"]);
1342        assert!(matches!(entries[1].1, EntryKind::Dir));
1343        assert!(matches!(entries[2].1, EntryKind::File));
1344        assert!(matches!(entries[4].1, EntryKind::Link));
1345        Ok(())
1346    }
1347
1348    #[test]
1349    fn lone_directory_chains_collapse_into_one_name() -> Result<()> {
1350        let root = TempDir::new("collapse");
1351        let repo_path = root.join("repo");
1352        init_repo(&repo_path)?;
1353        fs::create_dir_all(repo_path.join("a/b/c"))?;
1354        fs::write(repo_path.join("a/b/c/keep"), "")?;
1355        fs::create_dir_all(repo_path.join("x/y"))?;
1356        fs::write(repo_path.join("x/y/keep"), "")?;
1357        fs::write(repo_path.join("x/stop"), "")?;
1358        commit(&repo_path, "first")?;
1359
1360        let repo = gix::open(&repo_path)?;
1361        let entries = collect_entries(&repo.head_commit()?.tree()?)?;
1362        let collapsed: Vec<_> = entries
1363            .iter()
1364            .map(|(name, _, oid)| collapse_lone_dirs(&repo, name, *oid))
1365            .collect();
1366        assert_eq!(collapsed, ["a/b/c", "x"]);
1367        Ok(())
1368    }
1369
1370    #[test]
1371    fn readme_lookup_prefers_markdown_ignores_case_and_skips_directories() -> Result<()> {
1372        let root = TempDir::new("readme");
1373        let repo_path = root.join("repo");
1374        init_repo(&repo_path)?;
1375        fs::write(repo_path.join("readme.txt"), "txt")?;
1376        fs::write(repo_path.join("README"), "bare")?;
1377        fs::write(repo_path.join("ReadMe.MD"), "md")?;
1378        commit(&repo_path, "first")?;
1379        let repo = gix::open(&repo_path)?;
1380        let entries = collect_entries(&repo.head_commit()?.tree()?)?;
1381        let (name, data) = find_readme(&repo, &entries).unwrap();
1382        assert_eq!((name.as_str(), data.as_slice()), ("ReadMe.MD", b"md".as_slice()));
1383
1384        fs::remove_file(repo_path.join("ReadMe.MD"))?;
1385        fs::create_dir(repo_path.join("readme.md"))?;
1386        fs::write(repo_path.join("readme.md/keep"), "")?;
1387        commit(&repo_path, "second")?;
1388        let entries = collect_entries(&repo.head_commit()?.tree()?)?;
1389        let (name, data) = find_readme(&repo, &entries).unwrap();
1390        assert_eq!((name.as_str(), data.as_slice()), ("README", b"bare".as_slice()));
1391        Ok(())
1392    }
1393
1394    #[test]
1395    fn refs_skip_non_commit_tags_and_let_branches_shadow_tags() -> Result<()> {
1396        let root = TempDir::new("refs");
1397        let repo_path = root.join("repo");
1398        init_repo(&repo_path)?;
1399        fs::write(repo_path.join("file"), "")?;
1400        commit(&repo_path, "first")?;
1401        git(&repo_path, &["tag", "main"])?;
1402        git(&repo_path, &["tag", "v1"])?;
1403        let blob = git(&repo_path, &["rev-parse", "HEAD:file"])?;
1404        git(&repo_path, &["tag", "blobtag", &blob])?;
1405
1406        let repo = gix::open(&repo_path)?;
1407        let mut tips: Vec<_> = list_refs(&repo)?
1408            .into_iter()
1409            .map(|tip| (tip.kind, tip.name))
1410            .collect();
1411        tips.sort_by(|a, b| a.1.cmp(&b.1));
1412        assert_eq!(tips, [(RefKind::Branch, "main".into()), (RefKind::Tag, "v1".into())]);
1413        Ok(())
1414    }
1415
1416    #[test]
1417    fn head_tip_follows_head_and_falls_back_to_any_branch() -> Result<()> {
1418        let root = TempDir::new("head");
1419        let repo_path = root.join("repo");
1420        init_repo(&repo_path)?;
1421        fs::write(repo_path.join("file"), "")?;
1422        commit(&repo_path, "first")?;
1423        git(&repo_path, &["branch", "aaa"])?;
1424        let repo = gix::open(&repo_path)?;
1425        let tips = list_refs(&repo)?;
1426        assert_eq!(head_tip(&repo, &tips).unwrap().name, "main");
1427
1428        git(&repo_path, &["checkout", "-q", "--detach"])?;
1429        let repo = gix::open(&repo_path)?;
1430        assert_eq!(head_tip(&repo, &tips).unwrap().kind, RefKind::Branch);
1431
1432        git(&repo_path, &["symbolic-ref", "HEAD", "refs/heads/unborn"])?;
1433        let repo = gix::open(&repo_path)?;
1434        assert_eq!(head_tip(&repo, &tips).unwrap().kind, RefKind::Branch);
1435        Ok(())
1436    }
1437
1438    #[test]
1439    fn markdown_never_passes_through_raw_html() {
1440        let mut hl = Highlighter::new(grammar_cache());
1441        let html = markdown(
1442            &mut hl,
1443            "<script>alert(1)</script>\n\n\
1444             text <b onclick=\"x\">bold</b>\n\n\
1445             ```no-such-lang\n<img src=x onerror=alert(1)>\n```\n",
1446            |_, _| None,
1447        );
1448        assert!(html.contains("&lt;script&gt;"), "{html}");
1449        for forbidden in ["<script", "<b ", "<img"] {
1450            assert!(!html.contains(forbidden), "{forbidden} leaked into {html}");
1451        }
1452    }
1453
1454    #[test]
1455    fn reuses_only_matching_blob_pages() -> Result<()> {
1456        let root = TempDir::new("blob-reuse");
1457        let previous = root.join("previous");
1458        let out = root.join("out");
1459        fs::create_dir_all(previous.join("blob/src"))?;
1460        fs::write(previous.join("blob/src/main.rs"), "highlighted")?;
1461        let old = BlobVersion {
1462            oid: "abc123".into(),
1463            mode: 0o100644,
1464            touched: "first".into(),
1465        };
1466        let previous_versions = BTreeMap::from([("src/main.rs".into(), old.clone())]);
1467        let changed = BlobVersion {
1468            touched: "second".into(),
1469            ..old.clone()
1470        };
1471        let changed_versions = BTreeMap::from([("src/main.rs".into(), changed)]);
1472        let versions = BTreeMap::from([("src/main.rs".into(), old)]);
1473
1474        assert!(!reuse_blob(
1475            &previous,
1476            &previous_versions,
1477            &changed_versions,
1478            "src/main.rs",
1479            &out,
1480        ));
1481        assert!(reuse_blob(
1482            &previous,
1483            &previous_versions,
1484            &versions,
1485            "src/main.rs",
1486            &out,
1487        ));
1488        assert_eq!(
1489            fs::read_to_string(out.join("blob/src/main.rs"))?,
1490            "highlighted",
1491        );
1492        Ok(())
1493    }
1494}