| 1 | //! History pages (log, commit, patch) checked against the git CLI. |
| 2 | |
| 3 | mod common; |
| 4 | |
| 5 | use std::path::{Path, PathBuf}; |
| 6 | |
| 7 | use axum::body::{Body, to_bytes}; |
| 8 | use axum::http::{HeaderMap, Request, StatusCode, header}; |
| 9 | use common::{Work, bare, git}; |
| 10 | use ferrenium::config::Config; |
| 11 | use ferrenium::server; |
| 12 | use ferrenium::state::{AppState, Overrides}; |
| 13 | use tower::ServiceExt; |
| 14 | |
| 15 | /// Commit ids in [`history_repo`], oldest first. |
| 16 | struct 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 | |
| 27 | fn 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. |
| 34 | fn 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 | |
| 120 | struct Site { |
| 121 | _root: tempfile::TempDir, |
| 122 | app: axum::Router, |
| 123 | h: History, |
| 124 | } |
| 125 | |
| 126 | fn 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 | |
| 145 | struct Res { |
| 146 | status: StatusCode, |
| 147 | headers: HeaderMap, |
| 148 | body: String, |
| 149 | } |
| 150 | |
| 151 | async 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 | |
| 171 | async 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. |
| 179 | fn 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 | |
| 186 | fn 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("&", "&").replace("&", "&")) |
| 190 | } |
| 191 | |
| 192 | /// Follow `Older` links from `uri` and collect every commit id. |
| 193 | async 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 | |
| 206 | fn 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] |
| 213 | async 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] |
| 239 | async 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] |
| 263 | async 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] |
| 283 | async 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. |
| 294 | fn 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] |
| 309 | async 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">−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 → </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 → 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] |
| 361 | async 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">−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] |
| 380 | async 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] |
| 452 | async 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] |
| 473 | async 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 | } |