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(¤t) {
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(¤t)?,
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> {} <{}><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, §ions, 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 }