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