oni.git

oni

~ A static Git browser written in Rust.
git clone https://github.com/debuman/oni.git

generator/src/main.rs (20702B)

      1 mod html;
      2 mod markdown;
      3 mod repo;
      4 
      5 use html::{escape, link, url_path, Escaped};
      6 use repo::{Change, Commit, Diffs, Objects, Reference, Repository};
      7 use std::collections::{HashMap, HashSet};
      8 use std::fmt::Write as _;
      9 use std::fs;
     10 use std::io::{self, Write as _};
     11 use std::path::{Path, PathBuf};
     12 use std::sync::atomic::{AtomicU64, Ordering};
     13 
     14 type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
     15 
     16 fn save(root: &Path, relative: &str, content: &str) -> Result<()> {
     17     static NEXT_FILE: AtomicU64 = AtomicU64::new(0);
     18     if relative.contains(['\\', ':']) || Path::new(relative).components()
     19         .any(|part| !matches!(part, std::path::Component::Normal(_))) {
     20         return Err("unsafe output path".into());
     21     }
     22     let target = root.join(relative);
     23     let temporary = target.parent().ok_or("output has no parent")?.join(format!(
     24         ".oni-write-{}-{}",
     25         std::process::id(),
     26         NEXT_FILE.fetch_add(1, Ordering::Relaxed)
     27     ));
     28     // Creating a new file and renaming it never follows a preexisting file link.
     29     let mut file = fs::OpenOptions::new()
     30         .write(true)
     31         .create_new(true)
     32         .open(&temporary)?;
     33     file.write_all(content.as_bytes())?;
     34     drop(file);
     35     fs::rename(temporary, target)?;
     36     Ok(())
     37 }
     38 
     39 fn output_directory(root: &Path, relative: &Path) -> Result<()> {
     40     let mut current = root.to_owned();
     41     for part in relative.components() {
     42         if !matches!(part, std::path::Component::Normal(_)) {
     43             return Err("unsafe output directory".into());
     44         }
     45         current.push(part);
     46         match fs::symlink_metadata(&current) {
     47             Ok(info) if info.is_dir() && !info.file_type().is_symlink() => (),
     48             Ok(_) => return Err("output directory is a link or non-directory".into()),
     49             Err(error) if error.kind() == io::ErrorKind::NotFound => fs::create_dir(&current)?,
     50             Err(error) => return Err(error.into()),
     51         }
     52     }
     53     Ok(())
     54 }
     55 
     56 fn prepare_output(repo: &Repository, out: &Path) -> Result<()> {
     57     fs::create_dir_all(out)?;
     58     if fs::symlink_metadata(out)?.file_type().is_symlink() {
     59         return Err("output root must not be a symlink".into());
     60     }
     61     output_directory(out, Path::new("commit"))?;
     62     let mut directories = HashSet::new();
     63     for entry in repo.entries.iter().filter(|entry| entry.kind == "blob") {
     64         let path = Path::new("file").join(&entry.path);
     65         let parent = path.parent().ok_or("output path has no parent")?;
     66         if directories.insert(parent.to_owned()) {
     67             output_directory(out, parent)?;
     68         }
     69     }
     70     Ok(())
     71 }
     72 
     73 fn file_view(path: &str, bytes: &[u8]) -> (String, String) {
     74     let mut body = format!(
     75         "<div class=\"file-caption\"><p>{} <span class=\"summary-text\">({}B)</span></p></div>\n",
     76         escape(path),
     77         bytes.len()
     78     );
     79     if bytes.contains(&0) {
     80         body.push_str("<p>Binary file.</p>\n");
     81         return (body, format!("{}B", bytes.len()));
     82     }
     83     body.push_str("<pre id=\"blob\">\n");
     84     let text = String::from_utf8_lossy(bytes);
     85     let mut count = 0;
     86     for (index, line) in text.split_inclusive('\n').enumerate() {
     87         count = index + 1;
     88         writeln!(
     89             body,
     90             "<a class=\"line\" id=\"l{count}\" href=\"#l{count}\">{count:7}</a> {}",
     91             Escaped(line.trim_end_matches(['\r', '\n']))
     92         )
     93         .unwrap();
     94     }
     95     body.push_str("</pre>\n");
     96     (body, format!("{count}L"))
     97 }
     98 
     99 fn files(repo: &Repository, out: &Path, objects: &mut Objects) -> Result<()> {
    100     let mut table = String::from(
    101         "<table class=\"listing\" id=\"files\"><thead>\
    102         <tr><td><b>Mode</b></td><td><b>Name</b></td><td class=\"num\"><b>Size</b></td>\
    103         </tr></thead><tbody>\n",
    104     );
    105     for entry in &repo.entries {
    106         let mode = match entry.mode.as_str() {
    107             "100755" => "-rwxr-xr-x",
    108             "120000" => "lrwxrwxrwx",
    109             "160000" => "m---------",
    110             _ => "-rw-r--r--",
    111         };
    112         let (name, size) = if entry.kind == "blob" {
    113             let bytes = objects.blob(&entry.oid)?;
    114             let (body, size) = file_view(&entry.path, &bytes);
    115             let prefix = "../".repeat(entry.path.matches('/').count() + 1);
    116             save(
    117                 out,
    118                 &format!("file/{}.html", entry.path),
    119                 &html::repository(repo, &entry.path, &prefix, &body),
    120             )?;
    121             (
    122                 link(&format!("file/{}.html", url_path(&entry.path)), &entry.path),
    123                 size,
    124             )
    125         } else {
    126             (
    127                 format!("{} @ {}", escape(&entry.path), escape(&entry.oid)),
    128                 format!("{}B", entry.size),
    129             )
    130         };
    131         writeln!(
    132             table,
    133             "<tr><td>{mode}</td><td>{name}</td><td class=\"num\">{size}</td></tr>"
    134         )
    135         .unwrap();
    136     }
    137     table.push_str("</tbody></table>\n");
    138     save(
    139         out,
    140         "files.html",
    141         &html::repository(repo, "files", "", &table),
    142     )?;
    143     if let Some(entry) = repo.readme() {
    144         let bytes = objects.blob(&entry.oid)?;
    145         let rendered = if entry.path.ends_with(".md") || entry.path.ends_with(".markdown") {
    146             format!(
    147                 "<article class=\"readme\">\n{}</article>\n",
    148                 markdown::render(&bytes)?
    149             )
    150         } else {
    151             format!(
    152                 "<pre class=\"plain-readme\">{}</pre>\n",
    153                 escape(&String::from_utf8_lossy(&bytes))
    154             )
    155         };
    156         save(
    157             out,
    158             "README.html",
    159             &html::repository(repo, "README", "", &rendered),
    160         )?;
    161     }
    162     Ok(())
    163 }
    164 
    165 fn diff_sections(patch: &str) -> Vec<&str> {
    166     let starts: Vec<_> = patch
    167         .match_indices("diff --git ")
    168         .filter(|(offset, _)| *offset == 0 || patch.as_bytes()[offset - 1] == b'\n')
    169         .map(|(offset, _)| offset)
    170         .collect();
    171     starts
    172         .iter()
    173         .enumerate()
    174         .map(|(index, start)| &patch[*start..starts.get(index + 1).copied().unwrap_or(patch.len())])
    175         .collect()
    176 }
    177 
    178 fn diff_html(changes: &[Change], sections: &[&str], available: &HashSet<&str>) -> String {
    179     let mut out = String::new();
    180     for (index, section) in sections.iter().enumerate() {
    181         let Some(change) = changes.get(index) else {
    182             break;
    183         };
    184         let mut lines = section.lines();
    185         lines.next();
    186         let file_link = |path: &str| {
    187             if available.contains(path) {
    188                 link(&format!("../file/{}.html", url_path(path)), path)
    189             } else {
    190                 escape(path)
    191             }
    192         };
    193         let before = file_link(&change.old_path);
    194         let after = file_link(&change.path);
    195         write!(
    196             out,
    197             "<pre class=\"diff\"><b id=\"h{index}\">diff --git a/{before} b/{after}</b>\n"
    198         )
    199         .unwrap();
    200         let mut in_hunk = false;
    201         for (line_index, line) in lines.enumerate() {
    202             in_hunk |= line.starts_with("@@");
    203             if !in_hunk {
    204                 if line.starts_with("Binary files ") {
    205                     out.push_str("Binary files differ.\n");
    206                 }
    207                 continue;
    208             }
    209             let class = if line.starts_with("@@") {
    210                 "diff-hunk"
    211             } else if in_hunk && line.starts_with('+') {
    212                 "diff-add"
    213             } else if in_hunk && line.starts_with('-') {
    214                 "diff-remove"
    215             } else {
    216                 ""
    217             };
    218             if class.is_empty() {
    219                 writeln!(out, "{}", Escaped(line)).unwrap();
    220             } else {
    221                 writeln!(out, "<a class=\"{class}\" id=\"h{index}-{line_index}\" href=\"#h{index}-{line_index}\">{}</a>", Escaped(line)).unwrap();
    222             }
    223         }
    224         out.push_str("</pre>\n");
    225     }
    226     out
    227 }
    228 
    229 fn graph_widths(added: usize, removed: usize) -> (usize, usize) {
    230     let total = added as u128 + removed as u128;
    231     if total <= 78 {
    232         return (added, removed);
    233     }
    234     let scaled = |count: usize| {
    235         if count == 0 {
    236             0
    237         } else {
    238             (count as u128 * 78 / total) as usize + 1
    239         }
    240     };
    241     (scaled(added), scaled(removed))
    242 }
    243 
    244 fn commit_page(
    245     repo: &Repository,
    246     commit: &Commit,
    247     changes: &[Change],
    248     patch: &str,
    249     available: &HashSet<&str>,
    250 ) -> String {
    251     let mut body = format!(
    252         "<div class=\"panel commit-summary\"><table><tbody><tr><td>\
    253         <b>commit</b> {}",
    254         link(&format!("../commit/{}.html", commit.oid), &commit.oid)
    255     );
    256     for parent in &commit.parents {
    257         write!(
    258             body,
    259             "<br><b>parent</b> {}",
    260             link(&format!("../commit/{parent}.html"), parent)
    261         )
    262         .unwrap();
    263     }
    264     write!(
    265         body,
    266         "<br><b>Author:</b> {} &lt;{}&gt;<br><b>Date:</b> {}\
    267         <br><br>{}<br><br><b>Diffstat:</b>\n<table><tbody>\n",
    268         escape(&commit.author),
    269         link(&format!("mailto:{}", commit.email), &commit.email),
    270         escape(&commit.display_date),
    271         escape(commit.message.trim_end()).replace('\n', "<br>\n")
    272     )
    273     .unwrap();
    274     let sections = diff_sections(patch);
    275     let mut added = 0;
    276     let mut removed = 0;
    277     for (index, change) in changes.iter().enumerate() {
    278         added += change.added;
    279         removed += change.removed;
    280         let section = sections.get(index).copied().unwrap_or("");
    281         let status = if section.contains("\nrename from ") {
    282             "R"
    283         } else if section.contains("\ncopy from ") {
    284             "C"
    285         } else if section.contains("\nnew file mode ") {
    286             "A"
    287         } else if section.contains("\ndeleted file mode ") {
    288             "D"
    289         } else {
    290             "M"
    291         };
    292         let graph = if change.binary {
    293             "binary".into()
    294         } else {
    295             let (plus, minus) = graph_widths(change.added, change.removed);
    296             format!(
    297                 "<span class=\"diff-add\">{}</span><span class=\"diff-remove\">{}</span>",
    298                 "+".repeat(plus),
    299                 "-".repeat(minus)
    300             )
    301         };
    302         let label = if change.old_path == change.path {
    303             change.path.clone()
    304         } else {
    305             format!("{} -> {}", change.old_path, change.path)
    306         };
    307         writeln!(
    308             body,
    309             "<tr><td class=\"{status}\">{status}</td><td>{}</td>\
    310             <td> | </td><td class=\"num\">{}</td><td>{graph}</td></tr>",
    311             link(&format!("#h{index}"), &label),
    312             change.added + change.removed
    313         )
    314         .unwrap();
    315     }
    316     write!(
    317         body,
    318         "</tbody></table></td></tr></tbody></table></div>\n\
    319         <div class=\"panel commit-diff\"><table><tbody><tr><td class=\"diff-total\">\
    320         {} file{} changed, {added} insertion(+), {removed} deletion(-)<br><br></td></tr>\
    321         <tr><td>{}</td></tr></tbody></table></div>\n",
    322         changes.len(),
    323         if changes.len() == 1 { "" } else { "s" },
    324         diff_html(changes, &sections, available)
    325     )
    326     .unwrap();
    327     html::repository(repo, &commit.subject, "../", &body)
    328 }
    329 
    330 fn write_commit(
    331     repo: &Repository,
    332     out: &Path,
    333     commit: &Commit,
    334     available: &HashSet<&str>,
    335     diffs: &mut Diffs,
    336 ) -> Result<Vec<Change>> {
    337     let diff = diffs.read(commit)?;
    338     save(
    339         out,
    340         &format!("commit/{}.html", commit.oid),
    341         &commit_page(repo, commit, &diff.changes, &diff.patch(), available),
    342     )?;
    343     Ok(diff.changes)
    344 }
    345 
    346 fn history(
    347     repo: &Repository,
    348     out: &Path,
    349     limit: usize,
    350     available: &HashSet<&str>,
    351     diffs: &mut Diffs,
    352 ) -> Result<()> {
    353     let mut table = String::from(
    354         "<table class=\"listing\" id=\"log\"><thead><tr>\
    355         <td><b>date</b></td><td><b>commit message</b></td>\
    356         <td class=\"num\"><b>files</b></td><td class=\"num\"><b>+</b></td>\
    357         <td class=\"num\"><b>-</b></td></tr></thead><tbody>\n",
    358     );
    359     for (index, commit) in repo.commits.iter().enumerate() {
    360         let changes = write_commit(repo, out, commit, available, diffs)?;
    361         if index < limit {
    362             let added: usize = changes.iter().map(|change| change.added).sum();
    363             let removed: usize = changes.iter().map(|change| change.removed).sum();
    364             writeln!(
    365                 table,
    366                 "<tr><td>{}</td><td>{}</td><td class=\"num\">{}</td>\
    367                 <td class=\"num\">+{added}</td><td class=\"num\">-{removed}</td></tr>",
    368                 html::date(commit.timestamp),
    369                 link(&format!("commit/{}.html", commit.oid), &commit.subject),
    370                 changes.len()
    371             )
    372             .unwrap();
    373         }
    374     }
    375     if repo.commits.len() > limit {
    376         writeln!(table, "<tr><td></td><td colspan=\"4\">{} more commits remaining, fetch the repository</td></tr>",
    377             repo.commits.len() - limit).unwrap();
    378     }
    379     table.push_str("</tbody></table>\n");
    380     let page = html::repository(repo, "log", "", &table);
    381     save(out, "log.html", &page)?;
    382     save(out, "index.html", &page)
    383 }
    384 
    385 fn references(repo: &Repository, out: &Path, refs: &[Reference]) -> Result<()> {
    386     let mut body = String::new();
    387     for (tag, title, id) in [(false, "Branches", "branches"), (true, "Tags", "tags")] {
    388         write!(
    389             body,
    390             "<h2 class=\"reference-title\">{title}</h2><table class=\"listing\" id=\"{id}\"><thead><tr>\
    391             <td><b>Name</b></td><td><b>Last commit date</b></td><td><b>Author</b></td>\
    392             </tr></thead><tbody>\n"
    393         )
    394         .unwrap();
    395         let mut group: Vec<_> = refs
    396             .iter()
    397             .filter(|reference| reference.tag == tag)
    398             .collect();
    399         group.sort_by(|a, b| {
    400             b.timestamp
    401                 .cmp(&a.timestamp)
    402                 .then_with(|| a.name.cmp(&b.name))
    403         });
    404         for reference in group {
    405             writeln!(
    406                 body,
    407                 "<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
    408                 escape(&reference.name),
    409                 html::date(reference.timestamp),
    410                 escape(&reference.author)
    411             )
    412             .unwrap();
    413         }
    414         body.push_str("</tbody></table><br>\n");
    415     }
    416     save(out, "refs.html", &html::repository(repo, "refs", "", &body))
    417 }
    418 
    419 fn feed(
    420     repo: &Repository,
    421     base_url: &str,
    422     entries: &[(&Commit, Option<&str>)],
    423     name: &str,
    424 ) -> String {
    425     let base = base_url.trim_end_matches('/');
    426     let mut out = format!(
    427         "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
    428         <feed xmlns=\"http://www.w3.org/2005/Atom\"><id>{}/{name}</id>\
    429         <title>{}</title><subtitle>{}</subtitle>\
    430         <link rel=\"self\" href=\"{}/{name}\"/>",
    431         escape(base),
    432         escape(&repo.name),
    433         escape(&repo.description),
    434         escape(base)
    435     );
    436     let updated = entries
    437         .iter()
    438         .max_by_key(|(commit, _)| commit.timestamp)
    439         .map(|(commit, _)| commit.iso_date.as_str())
    440         .unwrap_or("1970-01-01T00:00:00Z");
    441     write!(out, "<updated>{}</updated>", escape(updated)).unwrap();
    442     for (commit, tag) in entries.iter().take(100) {
    443         let title = tag
    444             .map(|tag| format!("[{tag}] {}", commit.subject))
    445             .unwrap_or_else(|| commit.subject.clone());
    446         let id = tag
    447             .map(|tag| format!("urn:git:{}:tag:{}", commit.oid, url_path(tag)))
    448             .unwrap_or_else(|| format!("urn:git:{}", commit.oid));
    449         write!(
    450             out,
    451             "<entry><id>{}</id><title>{}</title>\
    452             <published>{}</published><updated>{}</updated>\
    453             <author><name>{}</name><email>{}</email></author>\
    454             <link rel=\"alternate\" href=\"{}/commit/{}.html\"/>\
    455             <content type=\"text\">{}</content></entry>\n",
    456             escape(&id),
    457             escape(&title),
    458             escape(&commit.iso_date),
    459             escape(&commit.iso_date),
    460             escape(&commit.author),
    461             escape(&commit.email),
    462             escape(base),
    463             commit.oid,
    464             escape(&commit.message)
    465         )
    466         .unwrap();
    467     }
    468     out.push_str("</feed>\n");
    469     out
    470 }
    471 
    472 fn generate(repo: &Repository, out: &Path, limit: usize, base_url: &str) -> Result<()> {
    473     prepare_output(repo, out)?;
    474     let mut objects = Objects::new(repo)?;
    475     files(repo, out, &mut objects)?;
    476     let refs = repo.references()?;
    477     references(repo, out, &refs)?;
    478     let tagged_commits = repo.tagged_history(&refs)?;
    479     let available: HashSet<_> = repo
    480         .entries
    481         .iter()
    482         .filter(|entry| entry.kind == "blob")
    483         .map(|entry| entry.path.as_str())
    484         .collect();
    485     let mut diffs = Diffs::new(repo)?;
    486     history(repo, out, limit, &available, &mut diffs)?;
    487     for commit in &tagged_commits {
    488         write_commit(repo, out, commit, &available, &mut diffs)?;
    489     }
    490     diffs.finish()?;
    491     let entries: Vec<_> = repo
    492         .commits
    493         .iter()
    494         .take(100)
    495         .map(|commit| (commit, None))
    496         .collect();
    497     save(out, "atom.xml", &feed(repo, base_url, &entries, "atom.xml"))?;
    498     let by_oid: HashMap<_, _> = repo
    499         .commits
    500         .iter()
    501         .chain(tagged_commits.iter())
    502         .map(|commit| (commit.oid.as_str(), commit))
    503         .collect();
    504     let tags: Vec<_> = refs
    505         .iter()
    506         .filter(|reference| reference.tag)
    507         .filter_map(|reference| {
    508             by_oid
    509                 .get(reference.oid.as_str())
    510                 .map(|commit| (*commit, Some(reference.name.as_str())))
    511         })
    512         .collect();
    513     save(out, "tags.xml", &feed(repo, base_url, &tags, "tags.xml"))?;
    514     Ok(())
    515 }
    516 
    517 fn run() -> Result<()> {
    518     let args: Vec<_> = std::env::args().skip(1).collect();
    519     if args.first().map(String::as_str) == Some("--index") {
    520         let repositories: Result<Vec<_>> = args[1..]
    521             .iter()
    522             .map(|arg| Repository::open_index(Path::new(arg)))
    523             .collect();
    524         io::stdout().write_all(html::index(&repositories?).as_bytes())?;
    525         return Ok(());
    526     }
    527     if args.first().map(String::as_str) == Some("--version") {
    528         println!("sqzer-stagit {}", env!("CARGO_PKG_VERSION"));
    529         return Ok(());
    530     }
    531     let mut path = None;
    532     let mut limit = 50;
    533     let mut base_url = String::from("http://127.0.0.1:4174");
    534     let mut output = PathBuf::from(".");
    535     let mut index = 0;
    536     while index < args.len() {
    537         match args[index].as_str() {
    538             "-l" | "-u" | "--output" => {
    539                 let value = args.get(index + 1).ok_or("missing option value")?;
    540                 match args[index].as_str() {
    541                     "-l" => limit = value.parse()?,
    542                     "-u" => base_url = value.clone(),
    543                     _ => output = value.into(),
    544                 }
    545                 index += 2;
    546             }
    547             arg if arg.starts_with('-') => return Err(format!("unknown option {arg}").into()),
    548             arg => {
    549                 if path.replace(PathBuf::from(arg)).is_some() {
    550                     return Err("multiple repository paths".into());
    551                 }
    552                 index += 1;
    553             }
    554         }
    555     }
    556     let path = path.ok_or("usage: sqzer-stagit [-l limit] [-u base-url] [--output directory] repository\n       sqzer-stagit --index repository...")?;
    557     generate(&Repository::open(&path)?, &output, limit, &base_url)
    558 }
    559 
    560 fn main() {
    561     if let Err(error) = run() {
    562         eprintln!("sqzer-stagit: {error}");
    563         std::process::exit(1);
    564     }
    565 }
    566 
    567 #[cfg(test)]
    568 mod tests {
    569     use super::*;
    570 
    571     #[test]
    572     fn diff_preserves_source_lines_that_resemble_metadata() {
    573         let changes = [Change {
    574             old_path: "a.txt".into(),
    575             path: "a.txt".into(),
    576             added: 1,
    577             removed: 1,
    578             binary: false,
    579         }];
    580         let patch = "diff --git a/a.txt b/a.txt\nindex 123..456 100644\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n--- old\n+++ new\n";
    581         let html = diff_html(&changes, &diff_sections(patch), &HashSet::new());
    582         assert!(!html.contains("index 123"));
    583         assert!(!html.contains("--- a/a.txt"));
    584         assert!(html.contains("--- old</a>"));
    585         assert!(html.contains("+++ new</a>"));
    586         assert!(html.contains("class=\"diff-remove\""));
    587         assert!(html.contains("class=\"diff-add\""));
    588     }
    589 
    590     #[test]
    591     fn diff_graph_keeps_proportions_and_small_counts() {
    592         assert_eq!(graph_widths(17883, 566), (76, 3));
    593         assert_eq!(graph_widths(1, 1), (1, 1));
    594         assert_eq!(graph_widths(0, 0), (0, 0));
    595         assert_eq!(graph_widths(10000, 0), (79, 0));
    596         assert_eq!(graph_widths(0, 10000), (0, 79));
    597     }
    598 }