forks/sorcery

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

Charlotte Somperf: prioritize push-triggered builds over background scans2688dec

darwin-flake
27.1 KiB776 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};
8
9use crate::highlight::Cache as GrammarCache;
10use crate::render::{self, RefKind, RefTip, Site, StaticMode};
11
12pub const MANIFEST_FILE: &str = ".sorcery-blobs.json";
13pub const STATE_FILE: &str = ".sorcery-state";
14pub const STYLESHEET: &str = include_str!("style.css");
15pub(crate) const OUTPUT_FORMAT_VERSION: u32 = 61;
16const MAX_STATIC_BRANCHES: usize = 8;
17const MAX_HIGHLIGHTED_FILES: usize = 10_000;
18
19#[derive(Clone)]
20pub struct Config {
21    pub repo: PathBuf,
22    pub out: PathBuf,
23    pub instance_name: String,
24    pub name: Option<String>,
25    pub clone_url: Option<String>,
26}
27
28#[derive(serde::Deserialize, serde::Serialize)]
29struct BlobManifest {
30    format: u32,
31    instance_name: String,
32    name: String,
33    refs: BTreeMap<String, RefManifest>,
34}
35
36#[derive(serde::Deserialize, serde::Serialize)]
37struct RefManifest {
38    tip: String,
39    files: BTreeMap<String, render::BlobVersion>,
40}
41
42struct PendingBlob {
43    oid: String,
44    mode: u16,
45    touched: Option<String>,
46}
47
48impl BlobManifest {
49    fn load(out: &Path, instance_name: &str, name: &str) -> Option<Self> {
50        let data = fs::read(out.join(MANIFEST_FILE)).ok()?;
51        let manifest = serde_json::from_slice::<Self>(&data).ok()?;
52        (manifest.format == OUTPUT_FORMAT_VERSION
53            && manifest.instance_name == instance_name
54            && manifest.name == name)
55            .then_some(manifest)
56    }
57}
58
59/// The complete input identity of generated output. Bump
60/// `OUTPUT_FORMAT_VERSION` whenever templates or styling change incompatibly.
61pub fn expected_state(config: &Config) -> Result<String> {
62    let repo = gix::open(&config.repo)
63        .with_context(|| format!("opening git repo at {}", config.repo.display()))?;
64    let name = config.name.clone().unwrap_or_else(|| repo_name(&repo));
65    let description = description(&repo);
66    let tips = render::list_refs(&repo)?;
67    Ok(state_for(config, &repo, &tips, &name, description.as_deref()))
68}
69
70pub fn full(config: &Config, previous: Option<&Path>, highlights: Arc<GrammarCache>) -> Result<()> {
71    full_cancellable(config, previous, highlights, None)
72}
73
74pub fn full_cancellable(
75    config: &Config,
76    previous: Option<&Path>,
77    highlights: Arc<GrammarCache>,
78    cancel: Option<render::BuildCancel>,
79) -> Result<()> {
80    render::bail_if_cancelled(&cancel)?;
81    let repo = gix::open(&config.repo)
82        .with_context(|| format!("opening git repo at {}", config.repo.display()))?;
83    let name = config.name.clone().unwrap_or_else(|| repo_name(&repo));
84    let description = description(&repo);
85    let mut tips = render::list_refs(&repo)?;
86    let state = state_for(config, &repo, &tips, &name, description.as_deref());
87    plan_rendering(&repo, &mut tips)?;
88    let base_url = format!("/{}/", render::encode_path(&name));
89    let site = Site {
90        repo: &repo,
91        instance_name: config.instance_name.clone(),
92        name,
93        base_url,
94        description,
95        clone_url: config.clone_url.clone(),
96    };
97
98    fs::create_dir_all(&config.out)?;
99    let previous_manifest = previous
100        .and_then(|out| BlobManifest::load(out, &site.instance_name, &site.name));
101    let refs = rebuild_all(
102        &site,
103        &tips,
104        &config.out,
105        previous,
106        previous_manifest.as_ref(),
107        highlights.clone(),
108        cancel.clone(),
109    )?;
110    render::bail_if_cancelled(&cancel)?;
111    render::render_refs_page(&site, &tips, &config.out)?;
112    render::render_site_index(&site, &tips, &config.out, highlights, cancel)?;
113    render::write_file(&config.out.join("gitinfo.json"), gitinfo(&repo, &tips)?)?;
114    let manifest = BlobManifest {
115        format: OUTPUT_FORMAT_VERSION,
116        instance_name: site.instance_name.clone(),
117        name: site.name.clone(),
118        refs,
119    };
120    render::write_file(
121        &config.out.join(MANIFEST_FILE),
122        serde_json::to_vec(&manifest)?,
123    )?;
124    render::write_file(&config.out.join(STATE_FILE), state)
125}
126
127fn plan_rendering(repo: &gix::Repository, tips: &mut [RefTip]) -> Result<()> {
128    let branch_count = tips.iter().filter(|tip| tip.kind == RefKind::Branch).count();
129    let head = render::head_tip(repo, tips).map(|tip| tip.name.clone());
130    for tip in tips {
131        if tip.kind != RefKind::Branch
132            || (branch_count > MAX_STATIC_BRANCHES && Some(&tip.name) != head.as_ref())
133        {
134            continue;
135        }
136        let commit = repo.find_object(tip.commit_id)?.try_into_commit()?;
137        tip.static_mode = Some(if exceeds_file_limit(repo, &commit.tree()?, MAX_HIGHLIGHTED_FILES)? {
138            StaticMode::Plain
139        } else {
140            StaticMode::Highlighted
141        });
142    }
143    Ok(())
144}
145
146fn exceeds_file_limit(
147    repo: &gix::Repository,
148    tree: &gix::Tree<'_>,
149    limit: usize,
150) -> Result<bool> {
151    fn walk(
152        repo: &gix::Repository,
153        tree: &gix::Tree<'_>,
154        files: &mut usize,
155        limit: usize,
156    ) -> Result<bool> {
157        for entry in tree.iter() {
158            let entry = entry?;
159            let mode = entry.mode();
160            if mode.is_tree() {
161                let tree = repo.find_object(entry.oid())?.try_into_tree()?;
162                if walk(repo, &tree, files, limit)? {
163                    return Ok(true);
164                }
165            } else if !mode.is_commit() {
166                *files += 1;
167                if *files > limit {
168                    return Ok(true);
169                }
170            }
171        }
172        Ok(false)
173    }
174
175    let mut files = 0;
176    walk(repo, tree, &mut files, limit)
177}
178
179/// Discovery manifest for the in-browser git client: HEAD, refs (peeled to
180/// commits, branch-shadows-tag like the rest of the site), and pack names so
181/// the client can find `.idx` files without directory listings.
182fn gitinfo(repo: &gix::Repository, tips: &[RefTip]) -> Result<String> {
183    let head = render::head_tip(repo, tips).map(|t| t.name.clone());
184    let refs = tips
185        .iter()
186        .map(|t| {
187            serde_json::json!({
188                "kind": match t.kind {
189                    RefKind::Branch => "branch",
190                    RefKind::Tag => "tag",
191                },
192                "name": t.name,
193                "oid": t.commit_id.to_string(),
194            })
195        })
196        .collect::<Vec<_>>();
197    Ok(serde_json::json!({
198        "head": head,
199        "refs": refs,
200        "packs": pack_names(repo),
201    })
202    .to_string())
203}
204
205/// Sorted `pack-*` stems in the repo's object store.
206fn pack_names(repo: &gix::Repository) -> Vec<String> {
207    let mut packs: Vec<String> = fs::read_dir(repo.git_dir().join("objects/pack"))
208        .into_iter()
209        .flatten()
210        .flatten()
211        .filter_map(|entry| {
212            let name = entry.file_name().to_string_lossy().into_owned();
213            name.strip_suffix(".pack")
214                .filter(|stem| stem.starts_with("pack-"))
215                .map(str::to_owned)
216        })
217        .collect();
218    packs.sort();
219    packs
220}
221
222pub(crate) fn description(repo: &gix::Repository) -> Option<String> {
223    fs::read_to_string(repo.git_dir().join("description"))
224        .ok()
225        .map(|d| d.trim().to_string())
226        .filter(|d| !d.is_empty() && !d.starts_with("Unnamed repository"))
227}
228
229fn state_for(
230    config: &Config,
231    repo: &gix::Repository,
232    tips: &[RefTip],
233    name: &str,
234    description: Option<&str>,
235) -> String {
236    let mut refs = tips.iter().collect::<Vec<_>>();
237    refs.sort_by(|a, b| a.name.cmp(&b.name));
238    let mut state = format!(
239        "format {OUTPUT_FORMAT_VERSION}\ninstance {:?}\nname {name:?}\nclone {:?}\ndescription {description:?}\nhead {:?}\n",
240        config.instance_name,
241        config.clone_url,
242        repo.head_name().ok().flatten().map(|n| n.to_string()),
243    );
244    for tip in refs {
245        let kind = match tip.kind {
246            RefKind::Branch => "branch",
247            RefKind::Tag => "tag",
248        };
249        let _ = writeln!(state, "{kind} {:?} {}", tip.name, tip.commit_id);
250    }
251    // a gc changes packs without changing refs; the git-dir client cares
252    for pack in pack_names(repo) {
253        let _ = writeln!(state, "pack {pack}");
254    }
255    state
256}
257
258/// `/srv/git/foo.git` → `foo`, `/home/x/foo/.git` → `foo`
259fn repo_name(repo: &gix::Repository) -> String {
260    let dir = repo.git_dir().canonicalize().unwrap_or_else(|_| repo.git_dir().into());
261    let dir = if dir.file_name() == Some(".git".as_ref()) {
262        dir.parent().unwrap_or(&dir)
263    } else {
264        &dir
265    };
266    dir.file_name()
267        .map(|n| n.to_string_lossy().trim_end_matches(".git").to_string())
268        .filter(|n| !n.is_empty())
269        .unwrap_or_else(|| "repository".into())
270}
271
272fn blob_versions(
273    repo: &gix::Repository,
274    tip: &gix::Commit,
275    previous: Option<&RefManifest>,
276    cancel: &Option<render::BuildCancel>,
277) -> Result<BTreeMap<String, render::BlobVersion>> {
278    let mut files = BTreeMap::new();
279    collect_blobs(repo, tip.tree_id()?.detach(), &mut Vec::new(), &mut files)?;
280    let mut unresolved = files.len();
281    let mut commit_id = tip.id;
282    let mut diff_state = gix::diff::tree::State::default();
283
284    // Match the browser's first-parent path history. Once its previous tip is
285    // reached, paths untouched by newer commits can inherit their old result.
286    while unresolved > 0 {
287        render::bail_if_cancelled(cancel)?;
288        let commit = repo.find_object(commit_id)?.try_into_commit()?;
289        let commit_id_string = commit_id.to_string();
290        if let Some(previous) = previous.filter(|previous| previous.tip == commit_id_string) {
291            for (path, file) in &mut files {
292                if file.touched.is_some() {
293                    continue;
294                }
295                if let Some(old) = previous.files.get(path)
296                    && old.oid == file.oid
297                    && old.mode == file.mode
298                {
299                    file.touched = Some(old.touched.clone());
300                    unresolved -= 1;
301                }
302            }
303            if unresolved == 0 {
304                break;
305            }
306        }
307
308        let tree = commit.tree()?;
309        let parent = commit.parent_ids().next().map(|id| id.detach());
310        let parent_tree = match parent {
311            Some(id) => repo.find_commit(id)?.tree()?,
312            None => repo.empty_tree(),
313        };
314        // repo.diff_tree_to_tree() sets up blob diffing per call, which in a
315        // bare repo means rebuilding an index from HEAD every commit
316        let mut changes = gix::diff::tree::Recorder::default();
317        gix::diff::tree(
318            gix::objs::TreeRefIter::from_bytes(&parent_tree.data, repo.object_hash()),
319            gix::objs::TreeRefIter::from_bytes(&tree.data, repo.object_hash()),
320            &mut diff_state,
321            &repo.objects,
322            &mut changes,
323        )?;
324        for change in changes.records {
325            use gix::diff::tree::recorder::Change;
326            let (Change::Addition { path, .. }
327            | Change::Deletion { path, .. }
328            | Change::Modification { path, .. }) = change;
329            if let Some(file) = files.get_mut(&path.to_string())
330                && file.touched.is_none()
331            {
332                file.touched = Some(commit_id_string.clone());
333                unresolved -= 1;
334            }
335        }
336        let Some(parent) = parent else {
337            break;
338        };
339        commit_id = parent;
340    }
341
342    files
343        .into_iter()
344        .map(|(path, file)| {
345            Ok((
346                path,
347                render::BlobVersion {
348                    oid: file.oid,
349                    mode: file.mode,
350                    touched: file.touched.context("file has no introducing commit")?,
351                },
352            ))
353        })
354        .collect()
355}
356
357fn collect_blobs(
358    repo: &gix::Repository,
359    tree_id: gix::ObjectId,
360    path: &mut Vec<String>,
361    files: &mut BTreeMap<String, PendingBlob>,
362) -> Result<()> {
363    let tree = repo.find_object(tree_id)?.try_into_tree()?;
364    for entry in tree.iter() {
365        let entry = entry?;
366        let mode = entry.mode();
367        path.push(entry.filename().to_string());
368        if mode.is_tree() {
369            collect_blobs(repo, entry.oid().to_owned(), path, files)?;
370        } else if !mode.is_commit() {
371            files.insert(
372                path.join("/"),
373                PendingBlob {
374                    oid: entry.oid().to_string(),
375                    mode: mode.value(),
376                    touched: None,
377                },
378            );
379        }
380        path.pop();
381    }
382    Ok(())
383}
384
385/// Rebuild all visible refs, replacing `ref/` wholesale (which also prunes
386/// refs deleted since the last run).
387fn rebuild_all(
388    site: &Site,
389    tips: &[RefTip],
390    out: &Path,
391    previous: Option<&Path>,
392    previous_manifest: Option<&BlobManifest>,
393    highlights: Arc<GrammarCache>,
394    cancel: Option<render::BuildCancel>,
395) -> Result<BTreeMap<String, RefManifest>> {
396    let staging = out.join(".new-ref");
397    if staging.exists() {
398        fs::remove_dir_all(&staging)?;
399    }
400    fs::create_dir_all(&staging)?;
401    let mut refs = BTreeMap::new();
402    for tip in tips.iter().filter(|tip| tip.static_mode.is_some()) {
403        render::bail_if_cancelled(&cancel)?;
404        let commit = site.repo.find_object(tip.commit_id)?.try_into_commit()?;
405        let previous_ref = previous.map(|out| out.join("ref").join(&tip.name));
406        let previous_version = previous_manifest.and_then(|manifest| manifest.refs.get(&tip.name));
407        let files = if tip.static_mode == Some(StaticMode::Highlighted) {
408            Some(blob_versions(site.repo, &commit, previous_version, &cancel)?)
409        } else {
410            None
411        };
412        let reuse = files.as_ref().map(|versions| render::BlobReuse {
413            versions,
414            previous: previous_ref
415                .as_deref()
416                .zip(previous_version.map(|version| &version.files)),
417        });
418        render::render_ref(
419            site,
420            tips,
421            tip,
422            &commit,
423            &staging.join(&tip.name),
424            reuse,
425            highlights.clone(),
426            cancel.clone(),
427        )
428        .with_context(|| format!("rendering {}", tip.name))?;
429        if let Some(files) = files {
430            refs.insert(
431                tip.name.clone(),
432                RefManifest {
433                    tip: tip.commit_id.to_string(),
434                    files,
435                },
436            );
437        }
438    }
439    swap_dir(&staging, &out.join("ref"))?;
440    Ok(refs)
441}
442
443/// Move `new` into place at `dst`, displacing any existing tree with only a
444/// brief window where `dst` is missing (rename is atomic, recursive delete
445/// is not, so the old tree is renamed aside before removal, and restored if
446/// the install rename fails).
447pub(crate) fn swap_dir(new: &Path, dst: &Path) -> Result<()> {
448    if let Some(parent) = dst.parent() {
449        fs::create_dir_all(parent)?;
450    }
451    let trash = new.with_extension("old");
452    if trash.exists() {
453        fs::remove_dir_all(&trash)?;
454    }
455    let displaced = dst.exists();
456    if displaced {
457        fs::rename(dst, &trash)?;
458    }
459    if let Err(error) = fs::rename(new, dst) {
460        if displaced {
461            let _ = fs::rename(&trash, dst);
462        }
463        return Err(error).with_context(|| format!("installing {}", dst.display()));
464    }
465    if displaced {
466        if let Err(error) = fs::remove_dir_all(&trash) {
467            eprintln!("sorcery: removing {}: {error}", trash.display());
468        }
469    }
470    Ok(())
471}
472
473#[cfg(test)]
474mod tests {
475    use std::collections::BTreeMap;
476    use std::fs;
477    use std::path::{Path, PathBuf};
478
479    use anyhow::Result;
480    use percent_encoding::percent_decode_str;
481
482    use crate::testutil::{TempDir, commit, git, grammar_cache, init_repo, init_sha256_repo};
483
484    use super::{
485        BlobManifest, Config, MANIFEST_FILE, OUTPUT_FORMAT_VERSION, RefManifest, blob_versions,
486        full, full_cancellable,
487    };
488
489    #[test]
490    fn tracks_last_touch_across_incremental_rebuilds_and_reverts() -> Result<()> {
491        let root = TempDir::new("last-touch");
492        let repo_path = root.join("repo");
493        init_repo(&repo_path)?;
494        fs::create_dir(repo_path.join("src"))?;
495        fs::write(repo_path.join("src/stable.txt"), "stable")?;
496        fs::write(repo_path.join("changed.txt"), "one")?;
497        let first = commit(&repo_path, "first")?;
498        fs::write(repo_path.join("changed.txt"), "two")?;
499        let second = commit(&repo_path, "second")?;
500        fs::write(repo_path.join("changed.txt"), "one")?;
501        let third = commit(&repo_path, "revert")?;
502
503        let repo = gix::open(&repo_path)?;
504        let commit_at = |hex: &str| -> Result<gix::Commit<'_>> {
505            Ok(repo
506                .find_object(gix::ObjectId::from_hex(hex.as_bytes())?)?
507                .try_into_commit()?)
508        };
509        let first_files = blob_versions(&repo, &commit_at(&first)?, None, &None)?;
510        assert_eq!(first_files["src/stable.txt"].touched, first);
511        assert_eq!(first_files["changed.txt"].touched, first);
512
513        let first_manifest = RefManifest {
514            tip: first.clone(),
515            files: first_files.clone(),
516        };
517        let second_files = blob_versions(&repo, &commit_at(&second)?, Some(&first_manifest), &None)?;
518        assert_eq!(second_files["src/stable.txt"].touched, first);
519        assert_eq!(second_files["changed.txt"].touched, second);
520
521        let third_files = blob_versions(&repo, &commit_at(&third)?, Some(&first_manifest), &None)?;
522        assert_eq!(third_files["changed.txt"].oid, first_files["changed.txt"].oid);
523        assert_eq!(third_files["changed.txt"].touched, third);
524        Ok(())
525    }
526
527    #[test]
528    fn cancellable_build_aborts_before_rendering() -> Result<()> {
529        let root = TempDir::new("cancelled");
530        let repo = root.join("repo");
531        init_repo(&repo)?;
532        fs::write(repo.join("README.md"), "# hi\n")?;
533        commit(&repo, "initial")?;
534        let config = Config {
535            repo,
536            out: root.join("out"),
537            instance_name: "test".into(),
538            name: Some("u/r".into()),
539            clone_url: None,
540        };
541        let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
542        assert!(full_cancellable(&config, None, grammar_cache(), Some(cancel)).is_err());
543        assert!(!config.out.exists());
544        Ok(())
545    }
546
547    #[test]
548    fn renders_sha256_repositories() -> Result<()> {
549        let root = TempDir::new("sha256");
550        let repo = root.join("repo");
551        init_sha256_repo(&repo)?;
552        fs::write(repo.join("README.md"), "# SHA-256\n")?;
553        let head = commit(&repo, "initial")?;
554        assert_eq!(head.len(), 64);
555
556        let out = root.join("out");
557        full(
558            &Config {
559                repo,
560                out: out.clone(),
561                instance_name: "test".into(),
562                name: Some("sha256".into()),
563                clone_url: None,
564            },
565            None,
566            grammar_cache(),
567        )?;
568
569        let info: serde_json::Value = serde_json::from_slice(&fs::read(out.join("gitinfo.json"))?)?;
570        assert_eq!(info["refs"][0]["oid"].as_str(), Some(head.as_str()));
571        assert!(out.join("ref/main/blob/README.md").is_file());
572        Ok(())
573    }
574
575    #[test]
576    fn loads_only_compatible_blob_manifests() -> Result<()> {
577        let root = TempDir::new("blob-manifest");
578        let files = BTreeMap::from([(
579            "odd\npath.rs".into(),
580            crate::render::BlobVersion {
581                oid: "abc123".into(),
582                mode: 0o100644,
583                touched: "def456".into(),
584            },
585        )]);
586        let manifest = BlobManifest {
587            format: OUTPUT_FORMAT_VERSION,
588            instance_name: "example".into(),
589            name: "alice/repo".into(),
590            refs: BTreeMap::from([(
591                "main".into(),
592                RefManifest {
593                    tip: "def456".into(),
594                    files,
595                },
596            )]),
597        };
598        fs::write(root.join(MANIFEST_FILE), serde_json::to_vec(&manifest)?)?;
599
600        let loaded = BlobManifest::load(&root, "example", "alice/repo").unwrap();
601        assert_eq!(loaded.refs["main"].files["odd\npath.rs"].oid, "abc123");
602        assert_eq!(loaded.refs["main"].files["odd\npath.rs"].touched, "def456");
603        assert!(BlobManifest::load(&root, "other", "alice/repo").is_none());
604        assert!(BlobManifest::load(&root, "example", "other/repo").is_none());
605
606        let incompatible = BlobManifest {
607            format: OUTPUT_FORMAT_VERSION + 1,
608            ..manifest
609        };
610        fs::write(root.join(MANIFEST_FILE), serde_json::to_vec(&incompatible)?)?;
611        assert!(BlobManifest::load(&root, "example", "alice/repo").is_none());
612
613        fs::write(root.join(MANIFEST_FILE), "not json")?;
614        assert!(BlobManifest::load(&root, "example", "alice/repo").is_none());
615        Ok(())
616    }
617
618    fn html_files(dir: &Path, into: &mut Vec<PathBuf>) -> Result<()> {
619        for entry in fs::read_dir(dir)? {
620            let path = entry?.path();
621            if path.is_dir() {
622                html_files(&path, into)?;
623            } else if fs::read(&path)?.starts_with(b"<!doctype html>") {
624                into.push(path);
625            }
626        }
627        Ok(())
628    }
629
630    #[test]
631    fn previews_images_and_resolves_readme_images_at_each_revision() -> Result<()> {
632        let root = TempDir::new("images");
633        let repo = root.join("repo");
634        init_repo(&repo)?;
635        fs::create_dir_all(repo.join("demos"))?;
636        fs::create_dir_all(repo.join("docs"))?;
637        let image = "demos/a #.png";
638        fs::write(repo.join(image), b"\x89PNG\r\n\x1a\nold")?;
639        fs::write(repo.join("large.PNG"), vec![0; (1 << 20) + 1])?;
640        fs::write(repo.join("archive.bin"), b"binary\0data")?;
641        fs::write(repo.join("large.txt"), vec![b'x'; (1 << 20) + 1])?;
642        fs::write(repo.join("README.md"), "![demo](./demos/a%20%23.png)\n")?;
643        fs::write(
644            repo.join("docs/README.md"),
645            "![demo](../demos/a%20%23.png)\n\n![external](https://example.org/demo.png)\n",
646        )?;
647        commit(&repo, "first")?;
648        let config = Config {
649            repo: repo.clone(),
650            out: root.join("first"),
651            instance_name: "test".into(),
652            name: Some("u/r".into()),
653            clone_url: None,
654        };
655        full(&config, None, grammar_cache())?;
656        let first_oid = git(&repo, &["rev-parse", "HEAD:demos/a #.png"])?;
657        let first_url = format!("/u/r/raw/{first_oid}/demos/a%20%23.png");
658        for page in [
659            "index.html",
660            "ref/main/index.html",
661            "ref/main/blob/README.md",
662            "ref/main/tree/docs/index.html",
663            "ref/main/blob/docs/README.md",
664            "ref/main/blob/demos/a #.png",
665        ] {
666            let html = fs::read_to_string(config.out.join(page))?;
667            assert!(
668                html.contains(&format!("src=\"{first_url}\"")),
669                "{page}: {html}"
670            );
671        }
672        let nested = fs::read_to_string(config.out.join("ref/main/blob/docs/README.md"))?;
673        assert!(nested.contains("src=\"https://example.org/demo.png\""));
674        let large = fs::read_to_string(config.out.join("ref/main/blob/large.PNG"))?;
675        assert!(large.contains("<img src=\"/u/r/raw/"));
676        for name in ["archive.bin", "large.txt"] {
677            let html = fs::read_to_string(config.out.join(format!("ref/main/blob/{name}")))?;
678            let oid = git(&repo, &["rev-parse", &format!("HEAD:{name}")])?;
679            assert!(html.contains(&format!(
680                "href=\"/u/r/raw/{oid}/{name}\" download=\"{name}\""
681            )));
682            assert!(html.contains("cannot be previewed"));
683            assert!(!html.contains("<img"));
684        }
685
686        fs::write(repo.join(image), b"\x89PNG\r\n\x1a\nnew")?;
687        commit(&repo, "image changed, markdown unchanged")?;
688        let second = Config {
689            out: root.join("second"),
690            ..config.clone()
691        };
692        full(&second, Some(&config.out), grammar_cache())?;
693        let second_oid = git(&repo, &["rev-parse", "HEAD:demos/a #.png"])?;
694        for page in ["ref/main/blob/README.md", "ref/main/blob/docs/README.md"] {
695            let html = fs::read_to_string(second.out.join(page))?;
696            assert!(html.contains(&format!("src=\"/u/r/raw/{second_oid}/demos/a%20%23.png\"")));
697            assert!(!html.contains(&first_url));
698        }
699        Ok(())
700    }
701
702    /// The generator's links and its output paths are produced by separate
703    /// code, so a mismatch in encoding or layout between them only shows up
704    /// by following the links.
705    #[test]
706    fn every_internal_link_resolves() -> Result<()> {
707        let root = TempDir::new("links");
708        let repo = root.join("repo");
709        init_repo(&repo)?;
710        fs::create_dir_all(repo.join("dir with space/nested"))?;
711        fs::write(
712            repo.join("README.md"),
713            "[folder](dir%20with%20space) [file](<dir with space/nested/f%231%3F.txt#L1>) \
714             [symlink](./link) [anchor](#top)\n",
715        )?;
716        fs::write(
717            repo.join("dir with space/nested/notes.md"),
718            "[root](../..) [up](../../README.md) [sibling](f%231%3F.txt) [parent](../\u{e9}.txt)\n",
719        )?;
720        fs::write(repo.join("dir with space/nested/f#1?.txt"), "one")?;
721        fs::write(repo.join("dir with space/\u{e9}.txt"), "two")?;
722        std::os::unix::fs::symlink("README.md", repo.join("link"))?;
723        commit(&repo, "first")?;
724        git(&repo, &["tag", "v1"])?;
725        git(&repo, &["branch", "feature/x"])?;
726
727        let out = root.join("out");
728        let config = Config {
729            repo: repo.clone(),
730            out: out.clone(),
731            instance_name: "test".into(),
732            name: Some("alice/my repo".into()),
733            clone_url: None,
734        };
735        full(&config, None, grammar_cache())?;
736
737        let base = "/alice/my%20repo/";
738        let mut pages = Vec::new();
739        html_files(&out, &mut pages)?;
740        assert!(!pages.is_empty());
741        let mut checked = 0;
742        for page in pages {
743            let html = fs::read_to_string(&page)?;
744            let links = ["href=\"", "src=\""]
745                .iter()
746                .flat_map(|attr| html.split(attr).skip(1))
747                .map(|rest| rest.split('"').next().unwrap());
748            for link in links {
749                assert!(link.starts_with(['/', '#']), "{}: relative link {link}", page.display());
750                let Some(rel) = link.strip_prefix(base) else {
751                    continue;
752                };
753                let rel = rel.split('#').next().unwrap();
754                // Raw blobs are served by the daemon straight from the repo.
755                if rel.starts_with("raw/") {
756                    continue;
757                }
758                let rel = percent_decode_str(rel).decode_utf8()?;
759                let target = if rel.is_empty() || rel.ends_with('/') {
760                    out.join(&*rel).join("index.html")
761                } else {
762                    out.join(&*rel)
763                };
764                assert!(
765                    target.is_file(),
766                    "{}: {link} -> missing {}",
767                    page.display(),
768                    target.display(),
769                );
770                checked += 1;
771            }
772        }
773        assert!(checked > 0);
774        Ok(())
775    }
776}