char/sorcery

static-files based git repo viewer

git clone https://git.t4t.associates/char/sorcery

Charlotte Somexperiment: soft line wrapping on source code rendersf92b8f4

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