Krolyxon
Tag v0.3.0

ferreniumtestshistory.rs

509 lines · 17.3 KB · Rust

1//! History pages (log, commit, patch) checked against the git CLI.
2
3mod common;
4
5use std::path::{Path, PathBuf};
6
7use axum::body::{Body, to_bytes};
8use axum::http::{HeaderMap, Request, StatusCode, header};
9use common::{Work, bare, git};
10use ferrenium::config::Config;
11use ferrenium::server;
12use ferrenium::state::{AppState, Overrides};
13use tower::ServiceExt;
14
15/// Commit ids in [`history_repo`], oldest first.
16struct History {
17 bare: PathBuf,
18 root: String,
19 tweak: String,
20 rename: String,
21 misc: String,
22 merge: String,
23 whitespace: String,
24 head: String,
25}
26
27fn lines(n: usize) -> String {
28 (1..=n).map(|i| format!("line {i}\n")).collect()
29}
30
31/// Root commit; a tweak; a rename with an edit; binary + mode change +
32/// deletion; a merge of a side branch; a whitespace-only change; a
33/// `.mailmap`. Authors vary so mailmap and grep have something to do.
34fn history_repo(root: &Path) -> History {
35 let bare = bare(root, "hist.git", &[]);
36 let work = Work::clone_of(&bare);
37 let w = work.path();
38 let write = |name: &str, data: &[u8]| {
39 let path = w.join(name);
40 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
41 std::fs::write(path, data).unwrap();
42 };
43 let commit = |args: &[&str]| {
44 git(w, &["add", "-A"]);
45 let mut all = vec!["commit", "-q"];
46 all.extend_from_slice(args);
47 git(w, &all);
48 git(w, &["rev-parse", "HEAD"])
49 };
50
51 write("a.txt", lines(20).as_bytes());
52 write("src/lib.rs", b"pub fn one() -> u32 {\n 1\n}\n");
53 write("gone.txt", b"bye\n");
54 write("run.sh", b"#!/bin/sh\necho hi\n");
55 let root_commit = commit(&["-m", "Initial import"]);
56
57 write(
58 "a.txt",
59 lines(20).replace("line 5\n", "line five\n").as_bytes(),
60 );
61 let tweak = commit(&[
62 "-m",
63 "Tweak a.txt\n\nThe body mentions a Needle.",
64 "--author",
65 "Bob <bob@old.example>",
66 ]);
67
68 git(w, &["mv", "a.txt", "b.txt"]);
69 write(
70 "b.txt",
71 lines(20)
72 .replace("line 5\n", "line five\n")
73 .replace("line 20\n", "line twenty\n")
74 .as_bytes(),
75 );
76 let rename = commit(&["-m", "Rename a to b"]);
77
78 write("blob.bin", b"\0\x01\x02\x03binary");
79 std::fs::remove_file(w.join("gone.txt")).unwrap();
80 use std::os::unix::fs::PermissionsExt;
81 std::fs::set_permissions(w.join("run.sh"), std::fs::Permissions::from_mode(0o755)).unwrap();
82 let misc = commit(&["-m", "Binary, mode and deletion"]);
83
84 git(w, &["checkout", "-q", "-b", "side"]);
85 write("side.txt", b"from the side\n");
86 commit(&["-m", "Side work"]);
87 git(w, &["checkout", "-q", "main"]);
88 write(
89 "src/lib.rs",
90 b"pub fn one() -> u32 {\n 1\n}\n\npub fn two() -> u32 {\n 2\n}\n",
91 );
92 commit(&["-m", "Add two"]);
93 git(w, &["merge", "-q", "--no-ff", "-m", "Merge side", "side"]);
94 let merge = git(w, &["rev-parse", "HEAD"]);
95
96 write(
97 "src/lib.rs",
98 b"pub fn one() -> u32 {\n\t1\n}\n\npub fn two() -> u32 {\n 2\n}\n",
99 );
100 let whitespace = commit(&["-m", "Tabs"]);
101
102 write(
103 ".mailmap",
104 b"Ada Lovelace <ada@example.com>\nRobert <bob@new.example> <bob@old.example>\n",
105 );
106 let head = commit(&["-m", "Add mailmap"]);
107 git(w, &["push", "-q", "origin", "main", "side"]);
108 History {
109 bare,
110 root: root_commit,
111 tweak,
112 rename,
113 misc,
114 merge,
115 whitespace,
116 head,
117 }
118}
119
120struct Site {
121 _root: tempfile::TempDir,
122 app: axum::Router,
123 h: History,
124}
125
126fn site(extra: &str) -> Site {
127 let root = tempfile::tempdir().unwrap();
128 let h = history_repo(root.path());
129 let toml = format!(
130 "[repos]\nscan_path = {:?}\n[watch]\nenabled = false\n{extra}",
131 root.path().to_str().unwrap()
132 );
133 let mut config = Config::parse(&toml).expect("parse");
134 config.finish().expect("valid");
135 let state = AppState::new(config, Overrides::default());
136 let config = state.config.load_full();
137 state.index.full_scan(&config);
138 Site {
139 app: server::app(state),
140 h,
141 _root: root,
142 }
143}
144
145struct Res {
146 status: StatusCode,
147 headers: HeaderMap,
148 body: String,
149}
150
151async fn fetch(app: &axum::Router, uri: &str, etag: Option<&str>) -> Res {
152 let mut req = Request::get(uri);
153 if let Some(etag) = etag {
154 req = req.header(header::IF_NONE_MATCH, etag);
155 }
156 let res = app
157 .clone()
158 .oneshot(req.body(Body::empty()).unwrap())
159 .await
160 .unwrap();
161 let status = res.status();
162 let headers = res.headers().clone();
163 let body = to_bytes(res.into_body(), usize::MAX).await.unwrap();
164 Res {
165 status,
166 headers,
167 body: String::from_utf8_lossy(&body).into_owned(),
168 }
169}
170
171async fn ok(app: &axum::Router, uri: &str) -> String {
172 let res = fetch(app, uri, None).await;
173 assert_eq!(res.status, StatusCode::OK, "{uri}: {}", res.body);
174 assert!(!res.body.contains("style=\""), "style attribute in {uri}");
175 res.body
176}
177
178/// Commit ids linked from log rows, in page order.
179fn log_ids(html: &str) -> Vec<String> {
180 html.split(r#"class="sha log-sha" href="/hist/commit/"#)
181 .skip(1)
182 .map(|rest| rest[..40].to_string())
183 .collect()
184}
185
186fn next_link(html: &str, rel: &str) -> Option<String> {
187 let end = html.find(&format!(r#"" rel="{rel}""#))?;
188 let start = html[..end].rfind("href=\"")? + 6;
189 Some(html[start..end].replace("&#38;", "&").replace("&amp;", "&"))
190}
191
192/// Follow `Older` links from `uri` and collect every commit id.
193async fn all_pages(app: &axum::Router, uri: &str) -> (Vec<String>, usize) {
194 let (mut ids, mut pages, mut url) = (Vec::new(), 0, uri.to_string());
195 loop {
196 let html = ok(app, &url).await;
197 pages += 1;
198 ids.extend(log_ids(&html));
199 match next_link(&html, "next") {
200 Some(next) => url = next,
201 None => return (ids, pages),
202 }
203 }
204}
205
206fn git_log(bare: &Path, args: &[&str]) -> Vec<String> {
207 let mut all = vec!["log", "--format=%H"];
208 all.extend_from_slice(args);
209 git(bare, &all).lines().map(str::to_string).collect()
210}
211
212#[tokio::test]
213async fn log_pages_match_git() {
214 let s = site("[ui]\nlog_page = 3\n");
215 let (ids, pages) = all_pages(&s.app, "/hist/log").await;
216 assert_eq!(ids, git_log(&s.h.bare, &["main"]));
217 assert_eq!(pages, ids.len().div_ceil(3));
218
219 // Second page: "newer" goes back to the first.
220 let first = ok(&s.app, "/hist/log/main").await;
221 let older = next_link(&first, "next").unwrap();
222 let second = ok(&s.app, &older).await;
223 assert_eq!(next_link(&second, "prev").unwrap(), "/hist/log/main");
224 let third = ok(&s.app, &next_link(&second, "next").unwrap()).await;
225 assert_eq!(next_link(&third, "prev").unwrap(), older);
226
227 // Day groups and nav.
228 assert!(first.contains(r#"class="label section-label""#));
229 assert!(
230 first.contains(r#"class="label active" href="/hist/log/main" aria-current="page">Log<"#),
231 "{first}"
232 );
233 // Mailmap applies to authors.
234 assert!(first.contains("Ada Lovelace"), "{first}");
235 assert!(!first.contains(">Ada ·"));
236}
237
238#[tokio::test]
239async fn path_filtered_log_matches_git() {
240 let s = site("[ui]\nlog_page = 2\n");
241 for path in [
242 "src/lib.rs",
243 "src",
244 "b.txt",
245 "a.txt",
246 "gone.txt",
247 "side.txt",
248 "run.sh",
249 ] {
250 let (ids, _) = all_pages(&s.app, &format!("/hist/log/main/{path}")).await;
251 assert_eq!(ids, git_log(&s.h.bare, &["main", "--", path]), "{path}");
252 }
253 let html = ok(&s.app, "/hist/log/main/src/lib.rs").await;
254 assert!(
255 html.contains(r#"<span class="crumb-here">lib.rs</span>"#),
256 "{html}"
257 );
258 let empty = ok(&s.app, "/hist/log/main/nope").await;
259 assert!(empty.contains("[No commits]"));
260}
261
262#[tokio::test]
263async fn grep_filters_message_and_author() {
264 let s = site("");
265 let html = ok(&s.app, "/hist/log?q=needle").await;
266 assert_eq!(log_ids(&html), vec![s.h.tweak.clone()]);
267 assert!(html.contains(r#"value="needle""#));
268 let html = ok(&s.app, "/hist/log/main?q=BOB%40OLD").await;
269 assert_eq!(log_ids(&html), vec![s.h.tweak.clone()]);
270 let html = ok(&s.app, "/hist/log?q=zzz-nothing").await;
271 assert!(html.contains("[No commits]"));
272
273 // The scan limit stops a search and offers to continue.
274 let s = site("[limits]\nlog_scan = 2\n");
275 let html = ok(&s.app, "/hist/log?q=initial").await;
276 assert!(log_ids(&html).is_empty());
277 assert!(html.contains("[No matches in 2 commits]"), "{html}");
278 let (ids, _) = all_pages(&s.app, "/hist/log?q=initial").await;
279 assert_eq!(ids, vec![s.h.root.clone()]);
280}
281
282#[tokio::test]
283async fn bad_cursors_are_not_found() {
284 let s = site("");
285 let res = fetch(&s.app, "/hist/log?after=xyz", None).await;
286 assert_eq!(res.status, StatusCode::NOT_FOUND);
287 let res = fetch(&s.app, &format!("/hist/log?after={}", "0".repeat(40)), None).await;
288 assert_eq!(res.status, StatusCode::NOT_FOUND);
289 assert!(res.body.contains("COMMIT NOT IN THIS HISTORY"));
290}
291
292/// `+added −removed` per file from `git diff --numstat`, as the commit page
293/// shows them.
294fn numstat(bare: &Path, commit: &str) -> Vec<(String, String)> {
295 let out = git(
296 bare,
297 &["diff", "--numstat", "-M", &format!("{commit}^"), commit],
298 );
299 out.lines()
300 .map(|l| {
301 let mut parts = l.split('\t');
302 let (a, r) = (parts.next().unwrap(), parts.next().unwrap());
303 (a.to_string(), r.to_string())
304 })
305 .collect()
306}
307
308#[tokio::test]
309async fn commit_page_shows_metadata_and_diffstat() {
310 let s = site("");
311 let html = ok(&s.app, &format!("/hist/commit/{}", s.h.tweak)).await;
312 assert!(
313 html.contains(r#"<h2 class="commit-subject">Tweak a.txt</h2>"#),
314 "{html}"
315 );
316 assert!(html.contains("The body mentions a Needle."));
317 // Mailmap: Bob's old address maps to Robert.
318 assert!(
319 html.contains(r#"title="bob@new.example">Robert<"#),
320 "{html}"
321 );
322 assert!(html.contains(&format!(r#"href="/hist/commit/{}""#, s.h.root)));
323 assert!(html.contains(r#"<span class="add">+1</span> <span class="del">&minus;1</span>"#));
324 assert!(
325 html.contains(
326 r#"<tr class="del"><td class="ln">5</td><td class="ln"></td><td class="sign">"#
327 )
328 );
329 assert!(html.contains(r#"<tr class="add"><td class="ln"></td><td class="ln">5</td><td class="sign">+</td><td class="lc">line five</td>"#), "{html}");
330 assert!(html.contains("@@ -2,7 +2,7 @@ line 1"));
331
332 // Rename with an edit: stats match git, the old name is shown.
333 let html = ok(&s.app, &format!("/hist/commit/{}", s.h.rename)).await;
334 let stats = numstat(&s.h.bare, &s.h.rename);
335 assert_eq!(stats, vec![("1".into(), "1".into())]);
336 assert!(html.contains(r#"title="Renamed">R<"#));
337 assert!(html.contains("a.txt &rarr; </span>b.txt"), "{html}");
338
339 // Binary, mode change and deletion.
340 let html = ok(&s.app, &format!("/hist/commit/{}", s.h.misc)).await;
341 assert!(html.contains("[Binary file]"));
342 assert!(html.contains("Mode 100644 &rarr; 100755"), "{html}");
343 assert!(html.contains(r#"title="Deleted">D<"#));
344 // A deleted file links to its last version.
345 let parent = git(&s.h.bare, &["rev-parse", &format!("{}^", s.h.misc)]);
346 assert!(html.contains(&format!(r#"href="/hist/blob/{parent}/gone.txt""#)));
347
348 // Root commit: everything added.
349 let html = ok(&s.app, &format!("/hist/commit/{}", s.h.root)).await;
350 assert!(html.contains("<dd>None</dd>"), "{html}");
351 assert!(html.contains("4 files changed"));
352
353 // Merge: diffed against the first parent, both parents listed.
354 let html = ok(&s.app, &format!("/hist/commit/{}", s.h.merge)).await;
355 assert!(html.contains(">Parents<"));
356 assert!(html.contains("side.txt"));
357 assert!(!html.contains("pub fn two"), "first-parent diff: {html}");
358}
359
360#[tokio::test]
361async fn whitespace_and_context_options() {
362 let s = site("");
363 let url = format!("/hist/commit/{}", s.h.whitespace);
364 let html = ok(&s.app, &url).await;
365 assert!(html.contains(r#"<span class="add">+1</span> <span class="del">&minus;1</span>"#));
366 let html = ok(&s.app, &format!("{url}?ws=ignore")).await;
367 assert!(html.contains("[No content changes]"), "{html}");
368 assert!(html.contains(&format!(
369 r#"href="{url}?ws=ignore" aria-current="true">Ignore<"#
370 )));
371
372 let html = ok(&s.app, &format!("{url}?context=0")).await;
373 assert!(html.contains("@@ -2 +2 @@ pub fn one"), "{html}");
374 assert!(html.contains("Context 0"));
375 let html = ok(&s.app, &format!("{url}?context=999")).await;
376 assert!(html.contains("Context 50"));
377}
378
379#[tokio::test]
380async fn patch_matches_git() {
381 let s = site("");
382 for commit in [
383 &s.h.root,
384 &s.h.tweak,
385 &s.h.rename,
386 &s.h.misc,
387 &s.h.merge,
388 &s.h.head,
389 ] {
390 let res = fetch(&s.app, &format!("/hist/commit/{commit}.patch"), None).await;
391 assert_eq!(res.status, StatusCode::OK);
392 assert_eq!(
393 res.headers[header::CONTENT_TYPE],
394 "text/plain; charset=utf-8"
395 );
396 assert!(
397 res.headers[header::CONTENT_SECURITY_POLICY]
398 .to_str()
399 .unwrap()
400 .contains("sandbox")
401 );
402 assert!(
403 res.body
404 .starts_with(&format!("From {commit} Mon Sep 17 00:00:00 2001\n"))
405 );
406 let ours: String = res
407 .body
408 .split_inclusive('\n')
409 .skip_while(|l| !l.starts_with("diff --git"))
410 .take_while(|l| *l != "-- \n")
411 .collect();
412 let base = if commit == &s.h.root {
413 "4b825dc642cb6eb9a060e54bf8d69288fbee4904".to_string()
414 } else {
415 format!("{commit}^")
416 };
417 let theirs = std::process::Command::new("git")
418 .args([
419 "-c",
420 "core.abbrev=7",
421 "diff",
422 "--no-color",
423 "-M",
424 "--abbrev=7",
425 &base,
426 commit,
427 ])
428 .current_dir(&s.h.bare)
429 .env("GIT_CONFIG_NOSYSTEM", "1")
430 .env("GIT_CONFIG_GLOBAL", "/dev/null")
431 .output()
432 .unwrap()
433 .stdout;
434 assert_eq!(ours, String::from_utf8(theirs).unwrap(), "{commit}");
435 }
436 let res = fetch(&s.app, &format!("/hist/commit/{}.patch", s.h.tweak), None).await;
437 assert!(
438 res.body.contains("From: Bob <bob@old.example>\n"),
439 "patches keep the recorded author"
440 );
441 assert!(
442 res.body
443 .contains("Subject: [PATCH] Tweak a.txt\n\nThe body mentions a Needle.\n---\n")
444 );
445 assert!(
446 res.body
447 .contains(" 1 file changed, 1 insertion(+), 1 deletion(-)\n")
448 );
449}
450
451#[tokio::test]
452async fn diff_limits() {
453 let s = site("[limits]\ndiff_file_lines = 1\n");
454 let html = ok(&s.app, &format!("/hist/commit/{}", s.h.tweak)).await;
455 assert!(html.contains("[Diff too large]"), "{html}");
456 assert!(
457 html.contains(r#"<span class="add">+1</span>"#),
458 "stats still shown"
459 );
460
461 let s = site("[limits]\ndiff_files = 2\n");
462 let html = ok(&s.app, &format!("/hist/commit/{}", s.h.root)).await;
463 assert!(html.contains("[Diff too large: 4 files]"), "{html}");
464 assert!(html.contains("src/lib.rs"));
465
466 // The per-commit line budget.
467 let s = site("[limits]\ndiff_lines = 1\n");
468 let html = ok(&s.app, &format!("/hist/commit/{}", s.h.tweak)).await;
469 assert!(html.contains("[Diff too large]"), "{html}");
470}
471
472#[tokio::test]
473async fn caching_and_resolution() {
474 let s = site("");
475 // Full ids are immutable; refs revalidate.
476 let res = fetch(&s.app, &format!("/hist/commit/{}", s.h.head), None).await;
477 assert_eq!(res.headers[header::CACHE_CONTROL], "public, max-age=86400");
478 let etag = res.headers[header::ETAG].to_str().unwrap().to_string();
479 let res = fetch(&s.app, &format!("/hist/commit/{}", s.h.head), Some(&etag)).await;
480 assert_eq!(res.status, StatusCode::NOT_MODIFIED);
481
482 let res = fetch(&s.app, "/hist/log/main", None).await;
483 assert_eq!(res.headers[header::CACHE_CONTROL], "no-cache");
484 let etag = res.headers[header::ETAG].to_str().unwrap().to_string();
485 let res = fetch(&s.app, "/hist/log/main", Some(&etag)).await;
486 assert_eq!(res.status, StatusCode::NOT_MODIFIED);
487 // A different query is a different page.
488 let res = fetch(&s.app, "/hist/log/main?q=x", Some(&etag)).await;
489 assert_eq!(res.status, StatusCode::OK);
490
491 // Commits by branch name and short id.
492 let html = ok(&s.app, "/hist/commit/main").await;
493 assert!(html.contains("Add mailmap"));
494 let html = ok(&s.app, &format!("/hist/commit/{}", &s.h.tweak[..8])).await;
495 assert!(html.contains("Tweak a.txt"));
496 let res = fetch(&s.app, "/hist/commit/nope", None).await;
497 assert_eq!(res.status, StatusCode::NOT_FOUND);
498 let res = fetch(&s.app, "/hist/commit", None).await;
499 assert_eq!(res.status, StatusCode::NOT_FOUND);
500
501 // Links into history from other pages.
502 let html = ok(&s.app, "/hist").await;
503 assert!(html.contains(r#"<a href="/hist/log">"#), "{html}");
504 assert!(html.contains(&format!(r#"href="/hist/commit/{}""#, s.h.head)));
505 let html = ok(&s.app, "/hist/blob/main/src/lib.rs").await;
506 assert!(html.contains(r#"href="/hist/log/main/src/lib.rs">History<"#));
507 let html = ok(&s.app, "/hist/tree/main/src").await;
508 assert!(html.contains(r#"href="/hist/log/main/src">History<"#));
509}