Krolyxon
Commit 4e844bb

ferrenium4e844bb

History: log, commit pages and patches (M3)

- Log with cursor paging, day groups, grep and path filters; path
  history follows git's default history simplification.
- Commit page: metadata, mailmap, diffstat, highlighted unified diff
  against the first parent with renames, whitespace and context options,
  and size limits.
- `.patch` output matching `git diff` (Myers, funcname hunk headers).
- Skip highlighting for minified files (very long lines).
Author
krolyxon
Date
2026-11-05 21:47 +0530
Parent
55c1225
Commit
4e844bbf9199da52dd5256363ed1ff627eb9f66e

+3046 −34

27 files changed

  1. M assets/css/repo.css +313 −0
  2. M dist/ferrenium.toml +5 −0
  3. M src/cache.rs +56 −1
  4. M src/config.rs +26 −0
  5. A src/git/diff.rs +408 −0
  6. A src/git/log.rs +267 −0
  7. M src/git/mod.rs +101 −0
  8. M src/git/tree.rs +23 −1
  9. M src/handlers/blob.rs +10 −18
  10. A src/handlers/commit.rs +375 −0
  11. A src/handlers/log.rs +166 −0
  12. M src/handlers/mod.rs +2 −0
  13. M src/handlers/repo.rs +48 −6
  14. M src/handlers/summary.rs +11 −2
  15. M src/handlers/tree.rs +2 −0
  16. A src/render/diff.rs +484 −0
  17. M src/render/highlight.rs +41 −1
  18. M src/render/mod.rs +1 −0
  19. M src/render/time.rs +30 −0
  20. M templates/blob.html +1 −0
  21. A templates/commit.html +111 −0
  22. A templates/log.html +47 −0
  23. M templates/repo_nav.html +1 −0
  24. M templates/summary.html +1 −1
  25. M templates/tree.html +1 −1
  26. M tests/browse.rs +6 −3
  27. A tests/history.rs +509 −0
M assets/css/repo.css View
@@ -554,6 +554,319 @@ html:not(.js) .copy-status {
554554 white-space: nowrap;
555555 }
556556
557+/* ---- Log ------------------------------------------------------------------ */
558+
559+.log-tools {
560+ margin-top: var(--space-lg);
561+ max-width: 420px;
562+}
563+
564+.log-day {
565+ margin-top: var(--space-xl);
566+}
567+
568+.log {
569+ list-style: none;
570+ border-top: 1px solid var(--border);
571+}
572+
573+.log-row {
574+ display: flex;
575+ align-items: baseline;
576+ justify-content: space-between;
577+ gap: var(--space-md);
578+ padding-block: 12px;
579+ border-bottom: 1px solid var(--border);
580+}
581+
582+.log-main {
583+ min-width: 0;
584+}
585+
586+.log-subject {
587+ color: var(--text-primary);
588+ overflow-wrap: anywhere;
589+}
590+
591+.log-meta {
592+ margin-top: var(--space-2xs);
593+ font-family: var(--font-mono);
594+ font-size: var(--caption);
595+ color: var(--text-secondary);
596+}
597+
598+.log-sha {
599+ flex: none;
600+ font-size: var(--caption);
601+ color: var(--interactive);
602+}
603+
604+.log-note {
605+ margin-top: var(--space-lg);
606+}
607+
608+.pager {
609+ display: flex;
610+ justify-content: space-between;
611+ gap: var(--space-md);
612+ margin-top: var(--space-xl);
613+}
614+
615+.pager a,
616+.pager span {
617+ display: inline-flex;
618+ align-items: center;
619+ min-height: 44px;
620+}
621+
622+/* ---- Commit --------------------------------------------------------------- */
623+
624+.commit-head {
625+ margin-top: var(--space-lg);
626+}
627+
628+.commit-subject {
629+ font-size: var(--heading);
630+ font-weight: 400;
631+ line-height: 1.25;
632+ color: var(--text-display);
633+ overflow-wrap: anywhere;
634+}
635+
636+.commit-body {
637+ max-width: 72ch;
638+ margin-top: var(--space-md);
639+ font-family: var(--font-body);
640+ font-size: var(--body);
641+ white-space: pre-wrap;
642+ overflow-wrap: anywhere;
643+ color: var(--text-secondary);
644+}
645+
646+.commit-grid {
647+ display: grid;
648+ gap: var(--space-xl);
649+ margin-top: var(--space-xl);
650+}
651+
652+@media (min-width: 768px) {
653+ .commit-grid {
654+ grid-template-columns: minmax(0, 1fr) auto;
655+ align-items: end;
656+ }
657+}
658+
659+/* The one break on the commit page: a hero diffstat. */
660+.diffstat-hero {
661+ text-align: right;
662+}
663+
664+.diffstat-num {
665+ font-family: var(--font-mono);
666+ font-size: 48px;
667+ line-height: 1;
668+ letter-spacing: -0.02em;
669+ color: var(--text-display);
670+ white-space: nowrap;
671+}
672+
673+.diffstat-hero .label {
674+ margin-top: var(--space-sm);
675+}
676+
677+.add {
678+ color: var(--success);
679+}
680+
681+.del {
682+ color: var(--accent);
683+}
684+
685+.commit-actions {
686+ display: flex;
687+ flex-wrap: wrap;
688+ align-items: center;
689+ justify-content: space-between;
690+ gap: var(--space-md);
691+ margin-top: var(--space-xl);
692+}
693+
694+.context-ctl {
695+ display: inline-flex;
696+ align-items: center;
697+ gap: var(--space-sm);
698+}
699+
700+.context-ctl a,
701+.context-ctl > span:first-child,
702+.context-ctl > span:last-child {
703+ display: inline-flex;
704+ align-items: center;
705+ justify-content: center;
706+ min-width: 28px;
707+ min-height: 28px;
708+}
709+
710+.file-list {
711+ list-style: none;
712+ margin-top: var(--space-lg);
713+ border-top: 1px solid var(--border);
714+}
715+
716+.file-row {
717+ display: flex;
718+ align-items: center;
719+ gap: var(--space-md);
720+ min-height: 40px;
721+ padding-block: var(--space-xs);
722+ border-bottom: 1px solid var(--border);
723+}
724+
725+.file-status {
726+ flex: none;
727+ width: 1.5em;
728+ font-family: var(--font-mono);
729+ font-size: var(--caption);
730+ color: var(--text-secondary);
731+}
732+
733+.file-path {
734+ flex: 1;
735+ min-width: 0;
736+ font-family: var(--font-mono);
737+ font-size: var(--body-sm);
738+ color: var(--text-primary);
739+ overflow-wrap: anywhere;
740+}
741+
742+.file-path .dim,
743+.diff-path .dim {
744+ color: var(--text-disabled);
745+}
746+
747+.file-counts {
748+ flex: none;
749+ font-family: var(--font-mono);
750+ font-size: var(--caption);
751+ white-space: nowrap;
752+}
753+
754+.blocks {
755+ display: inline-flex;
756+ flex: none;
757+ gap: 2px;
758+}
759+
760+.blocks i {
761+ width: 8px;
762+ height: 8px;
763+ background: var(--segment-empty);
764+}
765+
766+.blocks i.add {
767+ background: var(--success);
768+}
769+
770+.blocks i.del {
771+ background: var(--accent);
772+}
773+
774+.diff-file {
775+ margin-top: var(--space-2xl);
776+}
777+
778+.diff-head {
779+ position: sticky;
780+ top: 0;
781+ z-index: 1;
782+ display: flex;
783+ align-items: center;
784+ gap: var(--space-md);
785+ min-height: 44px;
786+ background: var(--black);
787+ border-bottom: 1px solid var(--border-visible);
788+}
789+
790+.diff-path {
791+ flex: 1;
792+ min-width: 0;
793+ font-family: var(--font-mono);
794+ font-size: var(--body-sm);
795+ color: var(--text-display);
796+ overflow-wrap: anywhere;
797+}
798+
799+.diff-view {
800+ flex: none;
801+}
802+
803+.diff-note {
804+ padding-block: var(--space-md);
805+ border-bottom: 1px solid var(--border);
806+ overflow-wrap: anywhere;
807+}
808+
809+.diff-file .code-wrap {
810+ margin-top: 0;
811+ border-top: 0;
812+}
813+
814+.code.diff .ln {
815+ padding: 0 var(--space-sm);
816+ color: var(--text-disabled);
817+ font-size: 12px;
818+}
819+
820+.code.diff .ln + .ln {
821+ border-left: 0;
822+}
823+
824+.code.diff .sign {
825+ width: 1%;
826+ padding: 0 var(--space-xs) 0 var(--space-sm);
827+ color: var(--text-disabled);
828+ user-select: none;
829+ -webkit-user-select: none;
830+}
831+
832+.code.diff .lc {
833+ padding-left: 0;
834+}
835+
836+.code.diff tr.add .ln:first-child {
837+ border-left-color: var(--success);
838+}
839+
840+.code.diff tr.add .sign {
841+ color: var(--success);
842+}
843+
844+.code.diff tr.del .ln:first-child {
845+ border-left-color: var(--accent);
846+}
847+
848+.code.diff tr.del .sign {
849+ color: var(--accent);
850+}
851+
852+.code.diff tr.del .lc {
853+ opacity: 0.55;
854+}
855+
856+.code.diff tr.hunk td {
857+ padding-block: var(--space-xs);
858+ color: var(--text-disabled);
859+}
860+
861+.code.diff tr.hunk .lc {
862+ font-family: var(--font-mono);
863+ font-size: 12px;
864+}
865+
866+.code.diff tr.hunk:not(:first-child) td {
867+ border-top: 1px solid var(--border);
868+}
869+
557870 /* ---- Markdown ------------------------------------------------------------ */
558871
559872 .markdown {
M dist/ferrenium.toml View
@@ -42,6 +42,7 @@ theme = "auto" # auto | dark | light
4242 code_font = "Space Mono"
4343 max_blob_kb = 1024
4444 max_highlight_kb = 256
45+log_page = 50 # commits per log page
4546
4647 [cache]
4748 size_mb = 64
@@ -55,6 +56,10 @@ poll_interval_secs = 300 # 0 disables the periodic full rescan
5556 git_parallel = 0 # 0 = 2 × CPUs
5657 heavy_parallel = 2
5758 request_timeout_secs = 30
59+log_scan = 10000 # commits examined per page by path/grep log filters
60+diff_files = 300 # larger commits show the file list only
61+diff_lines = 10000 # changed lines rendered per commit
62+diff_file_lines = 2000 # changed lines rendered per file
5863
5964 [log]
6065 level = "info" # RUST_LOG takes precedence when set
M src/cache.rs View
@@ -6,9 +6,11 @@
66
77 use std::sync::Arc;
88
9−use crate::git::ObjectId;
9+use crate::git::log::{LogPage, LogQuery};
1010 use crate::git::refs::RefList;
1111 use crate::git::tree::TreeEntry;
12+use crate::git::{Mailmap, ObjectId};
13+use crate::render::diff::{CommitDiff, Options as DiffOptions};
1214
1315 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
1416 pub enum Key {
@@ -28,6 +30,15 @@ pub enum Key {
2830 repo: String,
2931 generation: u64,
3032 },
33+ Log {
34+ tip: ObjectId,
35+ query: LogQuery,
36+ },
37+ Diff {
38+ commit: ObjectId,
39+ options: DiffOptions,
40+ },
41+ Mailmap(ObjectId),
3142 }
3243
3344 #[derive(Clone)]
@@ -37,6 +48,9 @@ pub enum Value {
3748 Tree(Arc<Vec<TreeEntry>>),
3849 Count(u64),
3950 Refs(Arc<RefList>),
51+ Log(Arc<LogPage>),
52+ Diff(Arc<CommitDiff>),
53+ Mailmap(Arc<Mailmap>),
4054 }
4155
4256 impl Value {
@@ -47,6 +61,9 @@ impl Value {
4761 Value::Tree(entries) => entries.iter().map(|e| e.name.len() + 64).sum(),
4862 Value::Count(_) => 8,
4963 Value::Refs(refs) => refs.weight(),
64+ Value::Log(page) => page.weight(),
65+ Value::Diff(diff) => diff.weight(),
66+ Value::Mailmap(map) => map.weight(),
5067 }
5168 }
5269 }
@@ -64,6 +81,9 @@ impl Cache {
6481 Key::Highlight { syntax, .. } => syntax.len(),
6582 Key::Markdown { context, .. } => context.len(),
6683 Key::Refs { repo, .. } => repo.len(),
84+ Key::Log { query, .. } => {
85+ query.path.len() + query.grep.as_ref().map_or(0, String::len)
86+ }
6787 _ => 0,
6888 };
6989 u32::try_from(64 + key + value.weight()).unwrap_or(u32::MAX)
@@ -136,6 +156,41 @@ impl Cache {
136156 }
137157 }
138158
159+ pub fn log<E>(
160+ &self,
161+ key: Key,
162+ compute: impl FnOnce() -> Result<LogPage, E>,
163+ ) -> Result<Arc<LogPage>, E> {
164+ match self.get_or(key, || compute().map(|v| Value::Log(Arc::new(v))))? {
165+ Value::Log(v) => Ok(v),
166+ _ => unreachable!("cache key/value kind mismatch"),
167+ }
168+ }
169+
170+ pub fn diff<E>(
171+ &self,
172+ key: Key,
173+ compute: impl FnOnce() -> Result<CommitDiff, E>,
174+ ) -> Result<Arc<CommitDiff>, E> {
175+ match self.get_or(key, || compute().map(|v| Value::Diff(Arc::new(v))))? {
176+ Value::Diff(v) => Ok(v),
177+ _ => unreachable!("cache key/value kind mismatch"),
178+ }
179+ }
180+
181+ pub fn mailmap<E>(
182+ &self,
183+ blob: ObjectId,
184+ compute: impl FnOnce() -> Result<Mailmap, E>,
185+ ) -> Result<Arc<Mailmap>, E> {
186+ match self.get_or(Key::Mailmap(blob), || {
187+ compute().map(|v| Value::Mailmap(Arc::new(v)))
188+ })? {
189+ Value::Mailmap(v) => Ok(v),
190+ _ => unreachable!("cache key/value kind mismatch"),
191+ }
192+ }
193+
139194 pub fn entry_count(&self) -> u64 {
140195 self.inner.run_pending_tasks();
141196 self.inner.entry_count()
M src/config.rs View
@@ -157,6 +157,8 @@ pub struct UiConfig {
157157 pub code_font: String,
158158 pub max_blob_kb: u64,
159159 pub max_highlight_kb: u64,
160+ /// Commits per log page.
161+ pub log_page: usize,
160162 }
161163
162164 impl Default for UiConfig {
@@ -166,6 +168,7 @@ impl Default for UiConfig {
166168 code_font: DEFAULT_CODE_FONT.into(),
167169 max_blob_kb: 1024,
168170 max_highlight_kb: 256,
171+ log_page: 50,
169172 }
170173 }
171174 }
@@ -229,6 +232,12 @@ pub struct LimitsConfig {
229232 pub git_parallel: usize,
230233 pub heavy_parallel: usize,
231234 pub request_timeout_secs: u64,
235+ /// Commits a filtered log (path or grep) examines per page.
236+ pub log_scan: usize,
237+ /// Above these, a commit's diff is not shown in full.
238+ pub diff_files: usize,
239+ pub diff_lines: usize,
240+ pub diff_file_lines: usize,
232241 }
233242
234243 impl Default for LimitsConfig {
@@ -237,6 +246,10 @@ impl Default for LimitsConfig {
237246 git_parallel: 0,
238247 heavy_parallel: 2,
239248 request_timeout_secs: 30,
249+ log_scan: 10_000,
250+ diff_files: 300,
251+ diff_lines: 10_000,
252+ diff_file_lines: 2_000,
240253 }
241254 }
242255 }
@@ -459,6 +472,19 @@ impl Config {
459472 if self.limits.request_timeout_secs == 0 {
460473 errors.push("limits.request_timeout_secs must be at least 1".into());
461474 }
475+ if !(1..=1000).contains(&self.ui.log_page) {
476+ errors.push("ui.log_page must be between 1 and 1000".into());
477+ }
478+ for (key, value) in [
479+ ("log_scan", self.limits.log_scan),
480+ ("diff_files", self.limits.diff_files),
481+ ("diff_lines", self.limits.diff_lines),
482+ ("diff_file_lines", self.limits.diff_file_lines),
483+ ] {
484+ if value == 0 {
485+ errors.push(format!("limits.{key} must be at least 1"));
486+ }
487+ }
462488
463489 // log
464490 if let Err(e) = tracing_subscriber::EnvFilter::try_new(&self.log.level) {
A src/git/diff.rs View
@@ -0,0 +1,408 @@
1+//! What a commit changed: the file list (with renames) against its first
2+//! parent, and line diffs grouped into hunks.
3+
4+use std::hash::Hash;
5+
6+use anyhow::Context;
7+use gix::bstr::BString;
8+use gix::diff::blob::{Algorithm, Diff, InternedInput};
9+use gix::object::tree::diff::ChangeDetached;
10+
11+use super::ObjectId;
12+use super::tree::EntryKind;
13+
14+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15+pub enum Status {
16+ Added,
17+ Deleted,
18+ Modified,
19+ Renamed,
20+ Copied,
21+}
22+
23+/// One side of a change.
24+#[derive(Debug, Clone, PartialEq, Eq)]
25+pub struct Side {
26+ pub path: BString,
27+ pub oid: ObjectId,
28+ pub kind: EntryKind,
29+}
30+
31+#[derive(Debug, Clone, PartialEq, Eq)]
32+pub struct FileChange {
33+ pub status: Status,
34+ pub old: Option<Side>,
35+ pub new: Option<Side>,
36+ /// Rename/copy similarity in percent.
37+ pub similarity: Option<u8>,
38+}
39+
40+impl FileChange {
41+ /// The path after the change (before it, for deletions).
42+ pub fn path(&self) -> &BString {
43+ match (&self.new, &self.old) {
44+ (Some(side), _) | (None, Some(side)) => &side.path,
45+ (None, None) => unreachable!("a change has at least one side"),
46+ }
47+ }
48+}
49+
50+#[derive(Debug, Clone)]
51+pub struct Changes {
52+ /// The first parent; `None` for a root commit (compared to nothing).
53+ pub parent: Option<ObjectId>,
54+ pub files: Vec<FileChange>,
55+}
56+
57+/// Files changed by `commit` relative to its first parent, renames
58+/// detected like `git diff -M` (50% similarity), sorted by path.
59+pub fn changes(repo: &gix::Repository, commit: ObjectId) -> anyhow::Result<Changes> {
60+ let commit = repo.find_commit(commit).context("commit not found")?;
61+ let new_tree = commit.tree().context("commit has no tree")?;
62+ let parent = commit.parent_ids().next().map(|id| id.detach());
63+ let old_tree = match parent {
64+ Some(id) => Some(
65+ repo.find_commit(id)
66+ .context("parent commit not found")?
67+ .tree()
68+ .context("parent has no tree")?,
69+ ),
70+ None => None,
71+ };
72+ let options = gix::diff::Options::default().with_rewrites(Some(Default::default()));
73+ let changes = repo
74+ .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), options)
75+ .context("cannot diff trees")?;
76+
77+ let side = |path: &BString, mode: gix::object::tree::EntryMode, oid: ObjectId| {
78+ (!mode.is_tree()).then(|| Side {
79+ path: path.clone(),
80+ oid,
81+ kind: EntryKind::from_gix(mode.kind()),
82+ })
83+ };
84+ let mut files = Vec::new();
85+ for change in changes {
86+ let file = match change {
87+ ChangeDetached::Addition {
88+ location,
89+ entry_mode,
90+ id,
91+ ..
92+ } => FileChange {
93+ status: Status::Added,
94+ old: None,
95+ new: side(&location, entry_mode, id),
96+ similarity: None,
97+ },
98+ ChangeDetached::Deletion {
99+ location,
100+ entry_mode,
101+ id,
102+ ..
103+ } => FileChange {
104+ status: Status::Deleted,
105+ old: side(&location, entry_mode, id),
106+ new: None,
107+ similarity: None,
108+ },
109+ ChangeDetached::Modification {
110+ location,
111+ previous_entry_mode,
112+ previous_id,
113+ entry_mode,
114+ id,
115+ } => {
116+ // A tree replaced by a file (or the reverse) is just an
117+ // addition (deletion) of the file. A file that changed type
118+ // (regular, symlink, submodule) is both, as git shows it.
119+ let old = side(&location, previous_entry_mode, previous_id);
120+ let new = side(&location, entry_mode, id);
121+ match (old, new) {
122+ (Some(old), Some(new)) if !old.kind.same_type(new.kind) => {
123+ files.push(FileChange {
124+ status: Status::Deleted,
125+ old: Some(old),
126+ new: None,
127+ similarity: None,
128+ });
129+ FileChange {
130+ status: Status::Added,
131+ old: None,
132+ new: Some(new),
133+ similarity: None,
134+ }
135+ }
136+ (old, new) => FileChange {
137+ status: match (&old, &new) {
138+ (None, _) => Status::Added,
139+ (_, None) => Status::Deleted,
140+ _ => Status::Modified,
141+ },
142+ old,
143+ new,
144+ similarity: None,
145+ },
146+ }
147+ }
148+ ChangeDetached::Rewrite {
149+ source_location,
150+ source_entry_mode,
151+ source_id,
152+ diff,
153+ entry_mode,
154+ id,
155+ location,
156+ copy,
157+ ..
158+ } => FileChange {
159+ status: if copy {
160+ Status::Copied
161+ } else {
162+ Status::Renamed
163+ },
164+ old: side(&source_location, source_entry_mode, source_id),
165+ new: side(&location, entry_mode, id),
166+ // Truncated like git's percentage.
167+ similarity: Some(
168+ diff.map_or(100.0, |d| d.similarity * 100.0)
169+ .floor()
170+ .clamp(0.0, 100.0) as u8,
171+ ),
172+ },
173+ };
174+ if file.old.is_some() || file.new.is_some() {
175+ files.push(file);
176+ }
177+ }
178+ files.sort_by(|a, b| a.path().cmp(b.path()));
179+ Ok(Changes { parent, files })
180+}
181+
182+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183+pub enum LineKind {
184+ Context,
185+ Added,
186+ Removed,
187+}
188+
189+/// A line in a hunk; `old`/`new` are 0-based line indexes into each side.
190+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191+pub struct Line {
192+ pub kind: LineKind,
193+ pub old: Option<u32>,
194+ pub new: Option<u32>,
195+}
196+
197+#[derive(Debug, Clone, PartialEq, Eq)]
198+pub struct Hunk {
199+ /// 0-based first line and length on each side.
200+ pub old_start: u32,
201+ pub old_len: u32,
202+ pub new_start: u32,
203+ pub new_len: u32,
204+ pub lines: Vec<Line>,
205+ /// The enclosing "function" line git shows after the ranges.
206+ pub func: Option<String>,
207+}
208+
209+impl Hunk {
210+ /// `@@ -1,5 +1,8 @@ fn main() {`
211+ pub fn header(&self) -> String {
212+ fn range(start: u32, len: u32) -> String {
213+ match len {
214+ 0 => format!("{start},0"),
215+ 1 => format!("{}", start + 1),
216+ _ => format!("{},{len}", start + 1),
217+ }
218+ }
219+ let mut header = format!(
220+ "@@ -{} +{} @@",
221+ range(self.old_start, self.old_len),
222+ range(self.new_start, self.new_len)
223+ );
224+ if let Some(func) = &self.func {
225+ header.push(' ');
226+ header.push_str(func);
227+ }
228+ header
229+ }
230+}
231+
232+#[derive(Debug, Clone, Default, PartialEq, Eq)]
233+pub struct LineDiff {
234+ pub hunks: Vec<Hunk>,
235+ pub added: u32,
236+ pub removed: u32,
237+}
238+
239+/// Line diff of two texts split into lines (each with its `\n`, so a
240+/// missing final newline counts as a change), with `context` unchanged
241+/// lines around each change. `ignore_ws` compares lines with all
242+/// whitespace removed, like `git diff -w`.
243+pub fn lines(old: &[&str], new: &[&str], context: u32, ignore_ws: bool) -> LineDiff {
244+ let diff = if ignore_ws {
245+ let strip =
246+ |l: &&str| -> Vec<u8> { l.bytes().filter(|b| !b.is_ascii_whitespace()).collect() };
247+ compute(old.iter().map(strip), new.iter().map(strip))
248+ } else {
249+ compute(
250+ old.iter().map(|l| l.as_bytes()),
251+ new.iter().map(|l| l.as_bytes()),
252+ )
253+ };
254+ let raw: Vec<_> = diff.hunks().collect();
255+ let (old_len, new_len) = (old.len() as u32, new.len() as u32);
256+ let mut out = LineDiff {
257+ hunks: Vec::new(),
258+ added: diff.count_additions(),
259+ removed: diff.count_removals(),
260+ };
261+ let mut i = 0;
262+ while i < raw.len() {
263+ // Merge changes whose context would overlap or touch.
264+ let mut j = i;
265+ while j + 1 < raw.len() && raw[j + 1].before.start - raw[j].before.end <= 2 * context {
266+ j += 1;
267+ }
268+ let lead = raw[i].before.start.min(context);
269+ let (old_start, new_start) = (raw[i].before.start - lead, raw[i].after.start - lead);
270+ let trail = context.min(old_len - raw[j].before.end);
271+ let (old_end, new_end) = (raw[j].before.end + trail, raw[j].after.end + trail);
272+ debug_assert!(new_end <= new_len);
273+
274+ let mut lines = Vec::new();
275+ let (mut o, mut n) = (old_start, new_start);
276+ for h in &raw[i..=j] {
277+ while o < h.before.start {
278+ lines.push(Line {
279+ kind: LineKind::Context,
280+ old: Some(o),
281+ new: Some(n),
282+ });
283+ o += 1;
284+ n += 1;
285+ }
286+ for r in h.before.clone() {
287+ lines.push(Line {
288+ kind: LineKind::Removed,
289+ old: Some(r),
290+ new: None,
291+ });
292+ }
293+ for a in h.after.clone() {
294+ lines.push(Line {
295+ kind: LineKind::Added,
296+ old: None,
297+ new: Some(a),
298+ });
299+ }
300+ o = h.before.end;
301+ n = h.after.end;
302+ }
303+ while o < old_end {
304+ lines.push(Line {
305+ kind: LineKind::Context,
306+ old: Some(o),
307+ new: Some(n),
308+ });
309+ o += 1;
310+ n += 1;
311+ }
312+ out.hunks.push(Hunk {
313+ old_start,
314+ old_len: old_end - old_start,
315+ new_start,
316+ new_len: new_end - new_start,
317+ lines,
318+ func: func_line(&old[..old_start as usize]),
319+ });
320+ i = j + 1;
321+ }
322+ out
323+}
324+
325+/// git's default hunk-header context: the closest line above the hunk
326+/// that starts like an identifier, trailing whitespace trimmed, at most
327+/// 80 bytes.
328+fn func_line(before: &[&str]) -> Option<String> {
329+ let line = before.iter().rev().find(|l| {
330+ l.bytes()
331+ .next()
332+ .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_' || b == b'$')
333+ })?;
334+ let bytes = &line.as_bytes()[..line.len().min(80)];
335+ let end = bytes
336+ .iter()
337+ .rposition(|b| !b.is_ascii_whitespace())
338+ .map_or(0, |i| i + 1);
339+ Some(String::from_utf8_lossy(&bytes[..end]).into_owned())
340+}
341+
342+fn compute<T: AsRef<[u8]> + Hash + Eq + Default>(
343+ old: impl Iterator<Item = T>,
344+ new: impl Iterator<Item = T>,
345+) -> Diff {
346+ let mut input = InternedInput::default();
347+ input.update_before(old);
348+ input.update_after(new);
349+ gix::diff::blob::diff_with_slider_heuristics(Algorithm::Myers, &input)
350+}
351+
352+/// Git's mode for an entry kind (`100644`, …).
353+pub fn mode(kind: EntryKind) -> &'static str {
354+ match kind {
355+ EntryKind::Blob => "100644",
356+ EntryKind::Executable => "100755",
357+ EntryKind::Symlink => "120000",
358+ EntryKind::Submodule => "160000",
359+ EntryKind::Tree => "040000",
360+ }
361+}
362+
363+#[cfg(test)]
364+mod tests {
365+ use super::*;
366+
367+ fn split(text: &str) -> Vec<&str> {
368+ text.split_inclusive('\n').collect()
369+ }
370+
371+ #[test]
372+ fn hunks_with_context_and_merging() {
373+ let old = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
374+ let new = "a\nB\nc\nd\ne\nf\ng\nh\ni\nJ\n";
375+ let diff = lines(&split(old), &split(new), 3, false);
376+ assert_eq!((diff.added, diff.removed), (2, 2));
377+ // Changes at lines 2 and 10 are 7 apart: with 3 lines of context
378+ // they stay separate hunks.
379+ assert_eq!(diff.hunks.len(), 2);
380+ assert_eq!(diff.hunks[0].header(), "@@ -1,5 +1,5 @@");
381+ assert_eq!(diff.hunks[1].header(), "@@ -7,4 +7,4 @@ f");
382+ // With more context they merge.
383+ let diff = lines(&split(old), &split(new), 4, false);
384+ assert_eq!(diff.hunks.len(), 1);
385+ assert_eq!(diff.hunks[0].header(), "@@ -1,10 +1,10 @@");
386+ }
387+
388+ #[test]
389+ fn additions_to_empty_and_whitespace() {
390+ let diff = lines(&[], &split("x\ny\n"), 3, false);
391+ assert_eq!(diff.hunks[0].header(), "@@ -0,0 +1,2 @@");
392+ assert_eq!(diff.hunks[0].lines.len(), 2);
393+
394+ let old = split("fn x() {\n a;\n}\n");
395+ let new = split("fn x() {\n\ta;\n}\n");
396+ assert_eq!(lines(&old, &new, 3, false).added, 1);
397+ let ws = lines(&old, &new, 3, true);
398+ assert!(ws.hunks.is_empty());
399+ assert_eq!((ws.added, ws.removed), (0, 0));
400+ }
401+
402+ #[test]
403+ fn missing_final_newline_is_a_change() {
404+ let diff = lines(&split("a\nb\n"), &split("a\nb"), 0, false);
405+ assert_eq!((diff.added, diff.removed), (1, 1));
406+ assert_eq!(diff.hunks[0].header(), "@@ -2 +2 @@ a");
407+ }
408+}
A src/git/log.rs View
@@ -0,0 +1,267 @@
1+//! Commit history: newest-first walks, paged with a cursor, optionally
2+//! filtered by path and message.
3+//!
4+//! Path filters follow git's default history simplification: at a merge
5+//! that kept one parent's version of the path, only that parent is
6+//! followed, so side branches whose changes didn't survive are left out
7+//! (`git log -- path`, not `--full-history`).
8+
9+use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
10+
11+use anyhow::Context;
12+use gix::bstr::ByteSlice;
13+use gix::revision::walk::Sorting;
14+use gix::traverse::commit::simple::CommitTimeOrder;
15+
16+use super::tree::{self, EntryKind};
17+use super::{CommitInfo, ObjectId, commit_info};
18+
19+#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
20+pub struct LogQuery {
21+ /// Only commits that change this path (empty: all commits).
22+ pub path: Vec<u8>,
23+ /// Case-insensitive match on the message, author name and email.
24+ pub grep: Option<String>,
25+ /// Start after this commit (the last one on the previous page).
26+ pub after: Option<ObjectId>,
27+ pub page: usize,
28+ /// Commits a filtered walk may examine before giving up on this page.
29+ pub scan_limit: usize,
30+}
31+
32+impl LogQuery {
33+ fn filtered(&self) -> bool {
34+ !self.path.is_empty() || self.grep.is_some()
35+ }
36+}
37+
38+/// Where the "newer" link of a page goes.
39+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40+pub enum Newer {
41+ /// This is the first page.
42+ None,
43+ /// Back to the first page (filtered logs can't step back exactly).
44+ First,
45+ After(ObjectId),
46+}
47+
48+#[derive(Debug, Clone)]
49+pub struct LogPage {
50+ pub commits: Vec<CommitInfo>,
51+ /// Cursor for the next (older) page.
52+ pub older: Option<ObjectId>,
53+ pub newer: Newer,
54+ /// The filter gave up after `scan_limit` commits; `older` continues.
55+ pub limit_reached: bool,
56+}
57+
58+impl LogPage {
59+ pub fn weight(&self) -> usize {
60+ 64 + self.commits.iter().map(CommitInfo::weight).sum::<usize>()
61+ }
62+}
63+
64+#[derive(Debug, thiserror::Error)]
65+pub enum LogError {
66+ #[error("the cursor commit is not in this history")]
67+ UnknownCursor,
68+ #[error(transparent)]
69+ Git(#[from] anyhow::Error),
70+}
71+
72+/// One page of the history of `tip`, newest commit first.
73+pub fn page(repo: &gix::Repository, tip: ObjectId, query: &LogQuery) -> Result<LogPage, LogError> {
74+ let filtered = query.filtered();
75+ let grep = query.grep.as_ref().map(|q| q.to_lowercase());
76+ let mut walk = if query.path.is_empty() {
77+ Walk::All(
78+ repo.rev_walk([tip])
79+ .sorting(Sorting::ByCommitTime(CommitTimeOrder::NewestFirst))
80+ .all()
81+ .context("cannot walk history")?,
82+ )
83+ } else {
84+ Walk::Path(PathWalk::new(repo, tip, &query.path)?)
85+ };
86+
87+ // Unfiltered: the commits up to and including the cursor, enough to
88+ // find where the previous page started.
89+ let mut recent: VecDeque<ObjectId> = VecDeque::new();
90+ let mut seeking = query.after;
91+ let mut out = LogPage {
92+ commits: Vec::new(),
93+ older: None,
94+ newer: Newer::None,
95+ limit_reached: false,
96+ };
97+ let mut scanned = 0;
98+ let mut last_examined = None;
99+ while let Some(step) = walk.next() {
100+ let (id, touches) = step?;
101+ if let Some(cursor) = seeking {
102+ if !filtered {
103+ recent.push_back(id);
104+ if recent.len() > query.page + 1 {
105+ recent.pop_front();
106+ }
107+ }
108+ if id == cursor {
109+ seeking = None;
110+ }
111+ continue;
112+ }
113+ if filtered {
114+ if scanned == query.scan_limit {
115+ out.limit_reached = true;
116+ out.older = last_examined;
117+ break;
118+ }
119+ scanned += 1;
120+ last_examined = Some(id);
121+ }
122+ if !touches {
123+ continue;
124+ }
125+ let commit = commit_info(repo, id)?;
126+ if let Some(grep) = &grep
127+ && !message_matches(repo, &commit, grep)?
128+ {
129+ continue;
130+ }
131+ if out.commits.len() == query.page {
132+ out.older = out.commits.last().map(|c| c.id);
133+ break;
134+ }
135+ out.commits.push(commit);
136+ }
137+ if seeking.is_some() {
138+ return Err(LogError::UnknownCursor);
139+ }
140+ if query.after.is_some() {
141+ out.newer = if filtered || recent.len() <= query.page {
142+ Newer::First
143+ } else {
144+ Newer::After(recent[0])
145+ };
146+ }
147+ Ok(out)
148+}
149+
150+fn message_matches(repo: &gix::Repository, info: &CommitInfo, grep: &str) -> anyhow::Result<bool> {
151+ if info.author_name.to_lowercase().contains(grep)
152+ || info.author_email.to_lowercase().contains(grep)
153+ {
154+ return Ok(true);
155+ }
156+ let commit = repo.find_commit(info.id).context("commit not found")?;
157+ let message = commit.message_raw_sloppy().to_str_lossy().to_lowercase();
158+ Ok(message.contains(grep))
159+}
160+
161+enum Walk<'repo> {
162+ All(gix::revision::Walk<'repo>),
163+ Path(PathWalk<'repo>),
164+}
165+
166+impl Walk<'_> {
167+ /// The next commit, and whether it passes the path filter.
168+ fn next(&mut self) -> Option<anyhow::Result<(ObjectId, bool)>> {
169+ match self {
170+ Walk::All(walk) => Some(
171+ walk.next()?
172+ .map(|info| (info.id, true))
173+ .context("cannot walk history"),
174+ ),
175+ Walk::Path(walk) => walk.next().transpose(),
176+ }
177+ }
178+}
179+
180+/// Newest-first walk that simplifies history for `path`.
181+struct PathWalk<'repo> {
182+ repo: &'repo gix::Repository,
183+ path: Vec<u8>,
184+ queue: BinaryHeap<(i64, ObjectId)>,
185+ seen: HashSet<ObjectId>,
186+ oids: PathOids,
187+}
188+
189+impl<'repo> PathWalk<'repo> {
190+ fn new(repo: &'repo gix::Repository, tip: ObjectId, path: &[u8]) -> anyhow::Result<Self> {
191+ let mut walk = PathWalk {
192+ repo,
193+ path: path.to_vec(),
194+ queue: BinaryHeap::new(),
195+ seen: HashSet::new(),
196+ oids: PathOids::default(),
197+ };
198+ walk.push(tip)?;
199+ Ok(walk)
200+ }
201+
202+ fn push(&mut self, id: ObjectId) -> anyhow::Result<()> {
203+ if !self.seen.insert(id) {
204+ return Ok(());
205+ }
206+ // Missing parents (shallow clones) end the walk there.
207+ let Ok(commit) = self.repo.find_commit(id) else {
208+ return Ok(());
209+ };
210+ let time = commit.time().context("malformed commit")?.seconds;
211+ self.queue.push((time, id));
212+ Ok(())
213+ }
214+
215+ fn next(&mut self) -> anyhow::Result<Option<(ObjectId, bool)>> {
216+ let Some((_, id)) = self.queue.pop() else {
217+ return Ok(None);
218+ };
219+ let commit = self.repo.find_commit(id).context("commit not found")?;
220+ let parents: Vec<ObjectId> = commit.parent_ids().map(|p| p.detach()).collect();
221+ let here = self.oids.get(self.repo, id, &self.path)?;
222+ let mut same = None;
223+ for parent in &parents {
224+ if self.oids.get(self.repo, *parent, &self.path)? == here {
225+ same = Some(*parent);
226+ break;
227+ }
228+ }
229+ let (touches, follow) = match same {
230+ // Identical to a parent: not interesting, and only that
231+ // parent's history explains the path's state.
232+ Some(parent) => (false, vec![parent]),
233+ None if parents.is_empty() => (here.is_some(), Vec::new()),
234+ None => (true, parents),
235+ };
236+ for parent in follow {
237+ self.push(parent)?;
238+ }
239+ Ok(Some((id, touches)))
240+ }
241+}
242+
243+/// What one path holds (id and mode), memoized per commit: each commit is
244+/// usually asked about again as the parent of the next one.
245+#[derive(Default)]
246+struct PathOids {
247+ by_commit: HashMap<ObjectId, Option<(ObjectId, EntryKind)>>,
248+}
249+
250+impl PathOids {
251+ fn get(
252+ &mut self,
253+ repo: &gix::Repository,
254+ commit: ObjectId,
255+ path: &[u8],
256+ ) -> anyhow::Result<Option<(ObjectId, EntryKind)>> {
257+ if let Some(found) = self.by_commit.get(&commit) {
258+ return Ok(*found);
259+ }
260+ let found = tree::entry_at(repo, commit, path)?;
261+ if self.by_commit.len() > 4096 {
262+ self.by_commit.clear();
263+ }
264+ self.by_commit.insert(commit, found);
265+ Ok(found)
266+ }
267+}
M src/git/mod.rs View
@@ -1,5 +1,7 @@
11 //! All repository access goes through this module; nothing else touches gix.
22
3+pub mod diff;
4+pub mod log;
35 pub mod refs;
46 pub mod tree;
57
@@ -224,3 +226,102 @@ pub fn commit_count(repo: &gix::Repository, tip: ObjectId) -> anyhow::Result<u64
224226 }
225227 Ok(count)
226228 }
229+
230+/// Everything the commit page shows about a commit.
231+#[derive(Debug, Clone)]
232+pub struct CommitDetail {
233+ pub info: CommitInfo,
234+ /// The message after the subject line, trimmed.
235+ pub body: String,
236+ /// The full message as stored, for patches.
237+ pub message: String,
238+ pub author: Person,
239+ pub committer: Person,
240+ pub parents: Vec<ObjectId>,
241+ pub tree: ObjectId,
242+}
243+
244+#[derive(Debug, Clone, PartialEq, Eq)]
245+pub struct Person {
246+ pub name: String,
247+ pub email: String,
248+ /// Unix seconds and the UTC offset in seconds, as recorded.
249+ pub time: i64,
250+ pub offset: i32,
251+}
252+
253+pub fn commit_detail(repo: &gix::Repository, id: ObjectId) -> anyhow::Result<CommitDetail> {
254+ let commit = repo.find_commit(id).context("commit not found")?;
255+ let decoded = commit.decode().context("malformed commit")?;
256+ let person = |sig: gix::actor::SignatureRef<'_>| {
257+ let time = sig.time().unwrap_or_default();
258+ Person {
259+ name: sig.name.to_str_lossy().trim().to_string(),
260+ email: sig.email.to_str_lossy().trim().to_string(),
261+ time: time.seconds,
262+ offset: time.offset,
263+ }
264+ };
265+ let message = decoded.message();
266+ Ok(CommitDetail {
267+ info: commit_info(repo, id)?,
268+ body: message
269+ .body()
270+ .map(|b| b.to_str_lossy().trim().to_string())
271+ .unwrap_or_default(),
272+ message: decoded.message.to_str_lossy().into_owned(),
273+ author: person(decoded.author().context("malformed author")?),
274+ committer: person(decoded.committer().context("malformed committer")?),
275+ parents: decoded.parents().collect(),
276+ tree: decoded.tree(),
277+ })
278+}
279+
280+/// A `.mailmap`: canonical names and emails for authors.
281+#[derive(Default)]
282+pub struct Mailmap(gix::mailmap::Snapshot);
283+
284+impl Mailmap {
285+ pub fn parse(data: &[u8]) -> Self {
286+ Mailmap(gix::mailmap::Snapshot::from_bytes(data))
287+ }
288+
289+ /// Canonical `(name, email)` for an identity.
290+ pub fn resolve(&self, name: &str, email: &str) -> (String, String) {
291+ let signature = gix::actor::SignatureRef {
292+ name: name.as_bytes().as_bstr(),
293+ email: email.as_bytes().as_bstr(),
294+ time: "",
295+ };
296+ match self.0.try_resolve_ref(signature) {
297+ Some(found) => (
298+ found
299+ .name
300+ .map_or_else(|| name.to_string(), |n| n.to_str_lossy().into_owned()),
301+ found
302+ .email
303+ .map_or_else(|| email.to_string(), |e| e.to_str_lossy().into_owned()),
304+ ),
305+ None => (name.to_string(), email.to_string()),
306+ }
307+ }
308+
309+ pub fn weight(&self) -> usize {
310+ self.0
311+ .iter()
312+ .map(|e| {
313+ 64 + e.old_email().len()
314+ + e.old_name().map_or(0, |n| n.len())
315+ + e.new_email().map_or(0, |n| n.len())
316+ + e.new_name().map_or(0, |n| n.len())
317+ })
318+ .sum()
319+ }
320+}
321+
322+/// The `.mailmap` blob at the root of `commit`, if there is one.
323+pub fn mailmap_blob(repo: &gix::Repository, commit: ObjectId) -> Option<ObjectId> {
324+ let tree = repo.find_commit(commit).ok()?.tree().ok()?;
325+ let entry = tree.find_entry(".mailmap")?;
326+ entry.mode().is_blob().then(|| entry.object_id())
327+}
M src/git/tree.rs View
@@ -20,7 +20,7 @@ pub enum EntryKind {
2020 }
2121
2222 impl EntryKind {
23− fn from_gix(kind: GixKind) -> Self {
23+ pub(super) fn from_gix(kind: GixKind) -> Self {
2424 match kind {
2525 GixKind::Tree => EntryKind::Tree,
2626 GixKind::Blob => EntryKind::Blob,
@@ -30,6 +30,12 @@ impl EntryKind {
3030 }
3131 }
3232
33+ /// Same kind of object, ignoring the executable bit.
34+ pub fn same_type(self, other: EntryKind) -> bool {
35+ let regular = |k| matches!(k, EntryKind::Blob | EntryKind::Executable);
36+ self == other || (regular(self) && regular(other))
37+ }
38+
3339 pub fn is_blob(self) -> bool {
3440 matches!(
3541 self,
@@ -84,6 +90,22 @@ pub fn lookup(
8490 }))
8591 }
8692
93+/// Id and kind of whatever `path` names in `commit`'s tree. `None` when
94+/// the path doesn't exist, or the commit itself is missing (shallow clones).
95+pub fn entry_at(
96+ repo: &gix::Repository,
97+ commit: ObjectId,
98+ path: &[u8],
99+) -> anyhow::Result<Option<(ObjectId, EntryKind)>> {
100+ let Ok(commit) = repo.find_commit(commit) else {
101+ return Ok(None);
102+ };
103+ let tree = commit.tree().context("commit has no tree")?;
104+ Ok(tree
105+ .lookup_entry_by_path(as_path(path))?
106+ .map(|entry| (entry.object_id(), EntryKind::from_gix(entry.mode().kind()))))
107+}
108+
87109 /// Entries of a tree: directories first, then files, each by name.
88110 pub fn list(repo: &gix::Repository, tree: ObjectId) -> anyhow::Result<Vec<TreeEntry>> {
89111 let tree = repo.find_tree(tree).context("tree not found")?;
M src/handlers/blob.rs View
@@ -44,6 +44,7 @@ pub struct BlobPage {
4444 /// `412 lines`, `12.4 KB`, `Rust`.
4545 pub facts: Vec<String>,
4646 pub raw_url: String,
47+ pub log_url: String,
4748 pub download_url: String,
4849 pub toggle: Option<ViewToggle>,
4950 pub body: Body,
@@ -132,24 +133,14 @@ pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response {
132133 html: html.unwrap_or_else(|never| match never {}),
133134 }
134135 } else {
135− let first_line = text.lines().next().unwrap_or_default();
136− let syntax = highlight::detect(&path_str, first_line);
137− language = syntax.map(|s| s.name.clone());
138− let lines = match syntax {
139− Some(syntax) if size <= ctx.config.ui.max_highlight_kb * 1024 => {
140− let key = Key::Highlight {
141− blob: oid,
142− syntax: syntax.name.clone(),
143− };
144− ctx.state
145− .cache
146− .lines(key, || {
147− Ok::<_, std::convert::Infallible>(highlight::lines(&text, syntax))
148− })
149− .unwrap_or_else(|never| match never {})
150− }
151− _ => Arc::new(highlight::plain_lines(&text)),
152− };
136+ let (lines, lang) = highlight::cached_lines(
137+ &ctx.state.cache,
138+ oid,
139+ &path_str,
140+ &text,
141+ ctx.config.ui.max_highlight_kb * 1024,
142+ );
143+ language = lang.map(str::to_string);
153144 Body::Code { lines }
154145 }
155146 }
@@ -173,6 +164,7 @@ pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response {
173164 here: Some(name),
174165 facts,
175166 download_url: format!("{raw_url}?download"),
167+ log_url: ctx.url("log", &rev.name, &path),
176168 raw_url,
177169 toggle,
178170 body,
A src/handlers/commit.rs View
@@ -0,0 +1,375 @@
1+//! `/{repo}/commit/{rev}` — message, metadata, diffstat and the unified
2+//! diff against the first parent (`?context=N`, `?ws=ignore`).
3+//! `/{repo}/commit/{rev}.patch` — the same as a plain-text patch.
4+
5+use askama::Template;
6+use axum::http::{HeaderValue, StatusCode, header};
7+use axum::response::{IntoResponse, Response};
8+use gix::bstr::ByteSlice;
9+
10+use super::raw::RAW_CSP;
11+use super::repo::{Active, Crumb, Ctx, RepoNav, set_cache_headers, short};
12+use super::{Layout, render};
13+use crate::cache::Key;
14+use crate::git::diff::Status;
15+use crate::git::refs::{self, Rev};
16+use crate::git::{self, CommitDetail};
17+use crate::render::diff::{self, Body, CommitDiff, FileDiff};
18+use crate::render::time;
19+
20+const DEFAULT_CONTEXT: u32 = 3;
21+const MAX_CONTEXT: u32 = 50;
22+
23+pub struct Meta {
24+ pub label: &'static str,
25+ pub value: String,
26+ pub title: Option<String>,
27+ pub links: Vec<(String, String)>,
28+}
29+
30+pub struct FileRow<'a> {
31+ pub file: &'a FileDiff,
32+ pub anchor: String,
33+ pub path: String,
34+ /// Source path of a rename or copy.
35+ pub from: Option<String>,
36+ pub status: &'static str,
37+ /// Single-letter status (`A`, `M`, `D`, `R`, `C`).
38+ pub letter: &'static str,
39+ /// Five blocks: `add`, `del` or `none`.
40+ pub bar: [&'static str; 5],
41+ /// The file at this commit (or its parent, for deletions).
42+ pub url: Option<String>,
43+}
44+
45+pub struct Toggle {
46+ pub label: &'static str,
47+ pub url: String,
48+ pub active: bool,
49+}
50+
51+#[derive(Template)]
52+#[template(path = "commit.html")]
53+pub struct CommitPage<'a> {
54+ pub layout: Layout,
55+ pub nav: RepoNav,
56+ pub crumbs: Vec<Crumb>,
57+ pub here: Option<String>,
58+ pub subject: String,
59+ pub body: String,
60+ pub meta: Vec<Meta>,
61+ pub added: u64,
62+ pub removed: u64,
63+ pub files: Vec<FileRow<'a>>,
64+ pub diff: &'a CommitDiff,
65+ pub patch_url: String,
66+ pub tree_url: String,
67+ pub ws_toggle: Vec<Toggle>,
68+ pub context_less: Option<String>,
69+ pub context_more: Option<String>,
70+ pub context: u32,
71+}
72+
73+pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response {
74+ let Some((rev, patch)) = resolve(ctx, segments) else {
75+ return ctx.not_found("UNKNOWN REVISION");
76+ };
77+ let context = ctx
78+ .query
79+ .context
80+ .as_deref()
81+ .and_then(|c| c.parse::<u32>().ok())
82+ .map_or(DEFAULT_CONTEXT, |c| c.min(MAX_CONTEXT));
83+ let ignore_ws = ctx.query.ws.as_deref() == Some("ignore");
84+ let etag = if patch {
85+ ctx.etag(("patch", rev.commit))
86+ } else {
87+ ctx.etag(("commit", rev.commit, context, ignore_ws))
88+ };
89+ let immutable = rev.is_immutable();
90+ if let Some(res) = ctx.not_modified(&etag, immutable) {
91+ return res;
92+ }
93+ let detail = match git::commit_detail(&ctx.git, rev.commit) {
94+ Ok(detail) => detail,
95+ Err(e) => return ctx.internal_error(e),
96+ };
97+ let changes = match git::diff::changes(&ctx.git, rev.commit) {
98+ Ok(changes) => changes,
99+ Err(e) => return ctx.internal_error(e),
100+ };
101+ if patch {
102+ return match diff::patch(&ctx.git, &detail, &changes) {
103+ Ok(text) => {
104+ let mut res = text.into_response();
105+ let headers = res.headers_mut();
106+ headers.insert(
107+ header::CONTENT_TYPE,
108+ HeaderValue::from_static("text/plain; charset=utf-8"),
109+ );
110+ headers.insert(
111+ header::CONTENT_SECURITY_POLICY,
112+ HeaderValue::from_static(RAW_CSP),
113+ );
114+ headers.insert(
115+ header::X_CONTENT_TYPE_OPTIONS,
116+ HeaderValue::from_static("nosniff"),
117+ );
118+ set_cache_headers(&mut res, &etag, immutable);
119+ res
120+ }
121+ Err(e) => ctx.internal_error(e),
122+ };
123+ }
124+
125+ let options = diff::Options {
126+ context,
127+ ignore_ws,
128+ max_files: ctx.config.limits.diff_files,
129+ max_lines: ctx.config.limits.diff_lines,
130+ max_file_lines: ctx.config.limits.diff_file_lines,
131+ max_blob_bytes: ctx.config.ui.max_blob_kb * 1024,
132+ max_highlight_bytes: ctx.config.ui.max_highlight_kb * 1024,
133+ };
134+ let key = Key::Diff {
135+ commit: rev.commit,
136+ options,
137+ };
138+ let built = ctx.state.cache.diff(key, || {
139+ diff::build(&ctx.git, &ctx.state.cache, &changes, &options)
140+ });
141+ let built = match built {
142+ Ok(built) => built,
143+ Err(e) => return ctx.internal_error(e),
144+ };
145+
146+ let mut page = commit_page(ctx, &rev, &detail, &built, changes.parent);
147+ page.context = context;
148+ let base = ctx.commit_url(&rev.commit);
149+ let url = |context: u32, ignore_ws: bool| {
150+ let mut params = Vec::new();
151+ if context != DEFAULT_CONTEXT {
152+ params.push(format!("context={context}"));
153+ }
154+ if ignore_ws {
155+ params.push("ws=ignore".to_string());
156+ }
157+ if params.is_empty() {
158+ base.clone()
159+ } else {
160+ format!("{base}?{}", params.join("&"))
161+ }
162+ };
163+ page.ws_toggle = vec![
164+ Toggle {
165+ label: "Whitespace",
166+ url: url(context, false),
167+ active: !ignore_ws,
168+ },
169+ Toggle {
170+ label: "Ignore",
171+ url: url(context, true),
172+ active: ignore_ws,
173+ },
174+ ];
175+ page.context_less = (context > 0).then(|| url(context.saturating_sub(3), ignore_ws));
176+ page.context_more =
177+ (context < MAX_CONTEXT).then(|| url((context + 3).min(MAX_CONTEXT), ignore_ws));
178+
179+ let mut res = render(StatusCode::OK, &page);
180+ set_cache_headers(&mut res, &etag, immutable);
181+ res
182+}
183+
184+/// The revision and whether a patch was asked for. `{rev}.patch` is a
185+/// patch unless a ref with that literal name exists.
186+fn resolve(ctx: &Ctx, segments: &[Vec<u8>]) -> Option<(Rev, bool)> {
187+ let name = std::str::from_utf8(&segments.join(&b'/')).ok()?.to_string();
188+ if name.is_empty() {
189+ return None;
190+ }
191+ if let Some(stem) = name.strip_suffix(".patch")
192+ && let Some(rev) = refs::resolve(&ctx.git, stem)
193+ && refs::resolve(&ctx.git, &name).is_none()
194+ {
195+ return Some((rev, true));
196+ }
197+ refs::resolve(&ctx.git, &name).map(|rev| (rev, false))
198+}
199+
200+fn commit_page<'a>(
201+ ctx: &Ctx,
202+ rev: &Rev,
203+ detail: &CommitDetail,
204+ built: &'a CommitDiff,
205+ parent: Option<git::ObjectId>,
206+) -> CommitPage<'a> {
207+ let mailmap = ctx.mailmap();
208+ let id = detail.info.id.to_string();
209+ let (author, author_email) = mailmap.resolve(&detail.author.name, &detail.author.email);
210+ let (committer, committer_email) =
211+ mailmap.resolve(&detail.committer.name, &detail.committer.email);
212+
213+ let mut meta = vec![Meta {
214+ label: "Author",
215+ value: author.clone(),
216+ title: Some(author_email.clone()),
217+ links: Vec::new(),
218+ }];
219+ meta.push(Meta {
220+ label: "Date",
221+ value: time::local(detail.author.time, detail.author.offset),
222+ title: Some(time::iso(detail.author.time)),
223+ links: Vec::new(),
224+ });
225+ if (&committer, &committer_email) != (&author, &author_email)
226+ || detail.committer.time != detail.author.time
227+ {
228+ meta.push(Meta {
229+ label: "Committer",
230+ value: format!(
231+ "{committer} · {}",
232+ time::local(detail.committer.time, detail.committer.offset)
233+ ),
234+ title: Some(committer_email),
235+ links: Vec::new(),
236+ });
237+ }
238+ meta.push(Meta {
239+ label: if detail.parents.len() == 1 {
240+ "Parent"
241+ } else {
242+ "Parents"
243+ },
244+ value: if detail.parents.is_empty() {
245+ "None".into()
246+ } else {
247+ String::new()
248+ },
249+ title: None,
250+ links: detail
251+ .parents
252+ .iter()
253+ .map(|p| (short(&p.to_string()), ctx.commit_url(p)))
254+ .collect(),
255+ });
256+ meta.push(Meta {
257+ label: "Commit",
258+ value: id.clone(),
259+ title: None,
260+ links: Vec::new(),
261+ });
262+
263+ let tree_url = ctx.url("tree", &id, b"");
264+ let parent_id = parent.map(|p| p.to_string());
265+ let files = built
266+ .files
267+ .iter()
268+ .enumerate()
269+ .map(|(i, file)| {
270+ let change = &file.change;
271+ let path = change.path().to_str_lossy().into_owned();
272+ let url = match change.status {
273+ Status::Deleted => parent_id
274+ .as_ref()
275+ .map(|p| ctx.url("blob", p, change.path())),
276+ _ => Some(ctx.url("blob", &id, change.path())),
277+ };
278+ let from = match change.status {
279+ Status::Renamed | Status::Copied => change
280+ .old
281+ .as_ref()
282+ .map(|o| o.path.to_str_lossy().into_owned()),
283+ _ => None,
284+ };
285+ let (status, letter) = match change.status {
286+ Status::Added => ("Added", "A"),
287+ Status::Deleted => ("Deleted", "D"),
288+ Status::Modified => ("Modified", "M"),
289+ Status::Renamed => ("Renamed", "R"),
290+ Status::Copied => ("Copied", "C"),
291+ };
292+ // Submodules and binaries have no line counts but still have
293+ // a URL; only blobs get one.
294+ let url = url.filter(|_| !matches!(file.body, Body::Submodule { .. }));
295+ FileRow {
296+ file,
297+ anchor: format!("f{}", i + 1),
298+ path,
299+ from,
300+ status,
301+ letter,
302+ bar: bar(file.stat),
303+ url,
304+ }
305+ })
306+ .collect();
307+
308+ CommitPage {
309+ layout: Layout::new(
310+ ctx.state,
311+ ctx.config,
312+ Some(&format!("{} · {}", detail.info.subject, ctx.repo.name)),
313+ ),
314+ nav: ctx.nav(Active::Log, Some(rev), |name| ctx.url("log", name, b"")),
315+ crumbs: vec![Crumb {
316+ name: ctx.repo.name.clone(),
317+ url: ctx.repo_url(),
318+ }],
319+ here: Some(short(&id)),
320+ subject: detail.info.subject.clone(),
321+ body: detail.body.clone(),
322+ meta,
323+ added: built.added,
324+ removed: built.removed,
325+ files,
326+ diff: built,
327+ patch_url: format!("{}.patch", ctx.commit_url(&detail.info.id)),
328+ tree_url,
329+ ws_toggle: Vec::new(),
330+ context_less: None,
331+ context_more: None,
332+ context: DEFAULT_CONTEXT,
333+ }
334+}
335+
336+/// Five blocks split between additions and removals; fewer than five
337+/// changed lines leave the rest empty.
338+fn bar(stat: Option<(u32, u32)>) -> [&'static str; 5] {
339+ let mut out = ["none"; 5];
340+ let Some((added, removed)) = stat else {
341+ return out;
342+ };
343+ let total = added + removed;
344+ if total == 0 {
345+ return out;
346+ }
347+ let filled = total.min(5);
348+ let mut adds = ((filled * added) as f64 / total as f64).round() as u32;
349+ // Keep at least one block for each side that changed.
350+ if added > 0 && adds == 0 {
351+ adds = 1;
352+ }
353+ if removed > 0 && adds == filled {
354+ adds = filled - 1;
355+ }
356+ for (i, slot) in out.iter_mut().enumerate().take(filled as usize) {
357+ *slot = if (i as u32) < adds { "add" } else { "del" };
358+ }
359+ out
360+}
361+
362+#[cfg(test)]
363+mod tests {
364+ use super::bar;
365+
366+ #[test]
367+ fn diffstat_bars() {
368+ assert_eq!(bar(None), ["none"; 5]);
369+ assert_eq!(bar(Some((0, 0))), ["none"; 5]);
370+ assert_eq!(bar(Some((2, 0))), ["add", "add", "none", "none", "none"]);
371+ assert_eq!(bar(Some((100, 1))), ["add", "add", "add", "add", "del"]);
372+ assert_eq!(bar(Some((1, 100))), ["add", "del", "del", "del", "del"]);
373+ assert_eq!(bar(Some((30, 20))), ["add", "add", "add", "del", "del"]);
374+ }
375+}
A src/handlers/log.rs View
@@ -0,0 +1,166 @@
1+//! `/{repo}/log/{rev}[/{path…}]` — history, newest first, grouped by day.
2+//! `?after=<oid>` pages, `?q=` filters by message and author.
3+
4+use askama::Template;
5+use axum::http::StatusCode;
6+use axum::response::Response;
7+use gix::bstr::ByteSlice;
8+
9+use super::repo::{Active, Crumb, Ctx, RepoNav, crumbs, set_cache_headers, short};
10+use super::{Layout, render};
11+use crate::cache::Key;
12+use crate::git::ObjectId;
13+use crate::git::log::{self, LogError, LogQuery, Newer};
14+use crate::render::{encode_path, time};
15+
16+pub struct LogRow {
17+ pub subject: String,
18+ pub url: String,
19+ pub author: String,
20+ pub age: String,
21+ pub iso: String,
22+ pub id: String,
23+}
24+
25+pub struct Day {
26+ pub label: String,
27+ pub rows: Vec<LogRow>,
28+}
29+
30+#[derive(Template)]
31+#[template(path = "log.html")]
32+pub struct LogPage {
33+ pub layout: Layout,
34+ pub nav: RepoNav,
35+ pub crumbs: Vec<Crumb>,
36+ pub here: Option<String>,
37+ pub days: Vec<Day>,
38+ /// The form posts here (the log URL without query).
39+ pub action: String,
40+ pub q: String,
41+ pub newer_url: Option<String>,
42+ /// `Newer`, or `Newest` when the link jumps back to the first page.
43+ pub newer_label: &'static str,
44+ pub older_url: Option<String>,
45+ pub limit_reached: bool,
46+ pub scan_limit: usize,
47+}
48+
49+pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response {
50+ let (rev, path) = match ctx.resolve(segments) {
51+ Ok(found) => found,
52+ Err(res) => return *res,
53+ };
54+ let after = match ctx.query.after.as_deref().filter(|a| !a.is_empty()) {
55+ None => None,
56+ Some(hex) => match ObjectId::from_hex(hex.as_bytes()) {
57+ Ok(id) => Some(id),
58+ Err(_) => return ctx.not_found("INVALID CURSOR"),
59+ },
60+ };
61+ let grep = ctx
62+ .query
63+ .q
64+ .as_deref()
65+ .map(str::trim)
66+ .filter(|q| !q.is_empty())
67+ .map(str::to_string);
68+ let query = LogQuery {
69+ path: path.clone(),
70+ grep,
71+ after,
72+ page: ctx.config.ui.log_page,
73+ scan_limit: ctx.config.limits.log_scan,
74+ };
75+ let etag = ctx.etag(("log", rev.commit, &query));
76+ let immutable = rev.is_immutable();
77+ if let Some(res) = ctx.not_modified(&etag, immutable) {
78+ return res;
79+ }
80+ let key = Key::Log {
81+ tip: rev.commit,
82+ query: query.clone(),
83+ };
84+ let result = ctx
85+ .state
86+ .cache
87+ .log(key, || log::page(&ctx.git, rev.commit, &query));
88+ let found = match result {
89+ Ok(found) => found,
90+ Err(LogError::UnknownCursor) => return ctx.not_found("COMMIT NOT IN THIS HISTORY"),
91+ Err(LogError::Git(e)) => return ctx.internal_error(e),
92+ };
93+
94+ let action = ctx.url("log", &rev.name, &path);
95+ let link = |after: Option<ObjectId>| {
96+ let mut params = Vec::new();
97+ if let Some(q) = &query.grep {
98+ params.push(format!("q={}", encode_path(q)));
99+ }
100+ if let Some(id) = after {
101+ params.push(format!("after={id}"));
102+ }
103+ if params.is_empty() {
104+ action.clone()
105+ } else {
106+ format!("{action}?{}", params.join("&"))
107+ }
108+ };
109+ let (newer_url, newer_label) = match found.newer {
110+ Newer::None => (None, "Newer"),
111+ Newer::First => (Some(link(None)), "Newest"),
112+ Newer::After(id) => (Some(link(Some(id))), "Newer"),
113+ };
114+
115+ let mailmap = ctx.mailmap();
116+ let now = time::now();
117+ let mut days: Vec<Day> = Vec::new();
118+ for commit in &found.commits {
119+ let label = time::day(commit.committer_time);
120+ let id = commit.id.to_string();
121+ let row = LogRow {
122+ subject: if commit.subject.is_empty() {
123+ "(no message)".into()
124+ } else {
125+ commit.subject.clone()
126+ },
127+ url: ctx.commit_url(&commit.id),
128+ author: mailmap.resolve(&commit.author_name, &commit.author_email).0,
129+ age: time::relative(commit.committer_time, now),
130+ iso: time::iso(commit.committer_time),
131+ id: short(&id),
132+ };
133+ match days.last_mut() {
134+ Some(day) if day.label == label => day.rows.push(row),
135+ _ => days.push(Day {
136+ label,
137+ rows: vec![row],
138+ }),
139+ }
140+ }
141+
142+ let (crumbs, last) = crumbs(ctx, &rev.name, &path);
143+ let title = if path.is_empty() {
144+ format!("Log · {}", ctx.repo.name)
145+ } else {
146+ format!("History of {} · {}", path.to_str_lossy(), ctx.repo.name)
147+ };
148+ let older_url = found.older.map(|id| link(Some(id)));
149+ let page = LogPage {
150+ layout: Layout::new(ctx.state, ctx.config, Some(&title)),
151+ nav: ctx.nav(Active::Log, Some(&rev), |name| ctx.url("log", name, &path)),
152+ crumbs,
153+ here: last,
154+ days,
155+ action,
156+ q: query.grep.clone().unwrap_or_default(),
157+ newer_url,
158+ newer_label,
159+ older_url,
160+ limit_reached: found.limit_reached,
161+ scan_limit: query.scan_limit,
162+ };
163+ let mut res = render(StatusCode::OK, &page);
164+ set_cache_headers(&mut res, &etag, immutable);
165+ res
166+}
M src/handlers/mod.rs View
@@ -1,8 +1,10 @@
11 //! Request handlers. Each page is an askama template that embeds a [`Layout`].
22
33 pub mod blob;
4+pub mod commit;
45 pub mod error;
56 pub mod index;
7+pub mod log;
68 pub mod raw;
79 pub mod refs;
810 pub mod repo;
M src/handlers/repo.rs View
@@ -11,11 +11,11 @@ use axum::http::{HeaderValue, StatusCode, header};
1111 use axum::response::{IntoResponse, Redirect, Response};
1212 use serde::Deserialize;
1313
14−use super::{blob, error, raw, refs, summary, tree};
14+use super::{blob, commit, error, log, raw, refs, summary, tree};
1515 use crate::cache::Key;
1616 use crate::config::Config;
1717 use crate::git::refs::{RefKind, RefList, Rev};
18−use crate::git::{self, Handle};
18+use crate::git::{self, Handle, Mailmap, ObjectId};
1919 use crate::index::Repo;
2020 use crate::render::{encode_bytes, encode_path};
2121 use crate::state::AppState;
@@ -29,6 +29,14 @@ pub struct PageQuery {
2929 pub view: Option<String>,
3030 /// Raw: serve as an attachment.
3131 pub download: Option<String>,
32+ /// Log: cursor (the last commit of the previous page).
33+ pub after: Option<String>,
34+ /// Log: message/author filter.
35+ pub q: Option<String>,
36+ /// Commit: lines of context.
37+ pub context: Option<String>,
38+ /// Commit: `ignore` compares lines ignoring whitespace.
39+ pub ws: Option<String>,
3240 }
3341
3442 pub struct RepoRequest {
@@ -66,6 +74,8 @@ pub fn handle(state: &AppState, req: RepoRequest) -> Response {
6674 b"tree" => tree::page(&ctx, rest),
6775 b"blob" => blob::page(&ctx, rest),
6876 b"raw" => raw::serve(&ctx, rest),
77+ b"log" => log::page(&ctx, rest),
78+ b"commit" => commit::page(&ctx, rest),
6979 b"refs" if rest.is_empty() => refs::page(&ctx),
7080 _ => ctx.not_found("PAGE NOT FOUND"),
7181 }
@@ -91,6 +101,28 @@ impl Ctx<'_> {
91101 url
92102 }
93103
104+ pub fn commit_url(&self, id: &ObjectId) -> String {
105+ format!("{}/commit/{id}", self.repo_url())
106+ }
107+
108+ /// The `.mailmap` on the default branch (empty when there is none).
109+ pub fn mailmap(&self) -> Arc<Mailmap> {
110+ let blob = self
111+ .repo
112+ .meta
113+ .head
114+ .and_then(|head| git::mailmap_blob(&self.git, head));
115+ let Some(blob) = blob else {
116+ return Arc::default();
117+ };
118+ self.state
119+ .cache
120+ .mailmap(blob, || {
121+ git::tree::blob(&self.git, blob).map(|data| Mailmap::parse(&data))
122+ })
123+ .unwrap_or_default()
124+ }
125+
94126 pub fn not_found(&self, message: &str) -> Response {
95127 error::page(self.state, StatusCode::NOT_FOUND, message)
96128 }
@@ -171,9 +203,15 @@ impl Ctx<'_> {
171203 current: Option<&Rev>,
172204 picker: impl Fn(&str) -> String,
173205 ) -> RepoNav {
174− let tree_url = match current {
175− Some(rev) => self.url("tree", &rev.name, b""),
176− None => format!("{}/tree", self.repo_url()),
206+ let (tree_url, log_url) = match current {
207+ Some(rev) => (
208+ self.url("tree", &rev.name, b""),
209+ self.url("log", &rev.name, b""),
210+ ),
211+ None => (
212+ format!("{}/tree", self.repo_url()),
213+ format!("{}/log", self.repo_url()),
214+ ),
177215 };
178216 let picker = current.and_then(|rev| {
179217 let refs = self.refs().ok()?;
@@ -206,6 +244,7 @@ impl Ctx<'_> {
206244 name: self.repo.name.clone(),
207245 url: self.repo_url(),
208246 tree_url,
247+ log_url,
209248 refs_url: format!("{}/refs", self.repo_url()),
210249 active: active.as_str(),
211250 picker,
@@ -240,6 +279,7 @@ pub fn short(oid: &str) -> String {
240279 pub enum Active {
241280 Summary,
242281 Tree,
282+ Log,
243283 Refs,
244284 }
245285
@@ -248,6 +288,7 @@ impl Active {
248288 match self {
249289 Active::Summary => "summary",
250290 Active::Tree => "tree",
291+ Active::Log => "log",
251292 Active::Refs => "refs",
252293 }
253294 }
@@ -257,8 +298,9 @@ pub struct RepoNav {
257298 pub name: String,
258299 pub url: String,
259300 pub tree_url: String,
301+ pub log_url: String,
260302 pub refs_url: String,
261− /// `summary`, `tree` or `refs`.
303+ /// `summary`, `tree`, `log` or `refs`.
262304 pub active: &'static str,
263305 pub picker: Option<Picker>,
264306 }
M src/handlers/summary.rs View
@@ -17,6 +17,7 @@ pub struct Stat {
1717 pub label: &'static str,
1818 pub value: String,
1919 pub title: Option<String>,
20+ pub url: Option<String>,
2021 }
2122
2223 pub struct CloneUrl {
@@ -117,16 +118,19 @@ fn stats(ctx: &Ctx, commits: Option<u64>) -> Vec<Stat> {
117118 label: "Commits",
118119 value: commits.unwrap_or(0).to_string(),
119120 title: None,
121+ url: commits.map(|_| format!("{}/log", ctx.repo_url())),
120122 },
121123 Stat {
122124 label: "Branches",
123125 value: meta.branches.to_string(),
124126 title: None,
127+ url: None,
125128 },
126129 Stat {
127130 label: "Tags",
128131 value: meta.tags.to_string(),
129132 title: None,
133+ url: None,
130134 },
131135 ];
132136 if let Some(t) = meta.last_activity {
@@ -134,6 +138,7 @@ fn stats(ctx: &Ctx, commits: Option<u64>) -> Vec<Stat> {
134138 label: "Updated",
135139 value: time::relative(t, time::now()),
136140 title: Some(time::iso(t)),
141+ url: None,
137142 });
138143 }
139144 if let Some(owner) = &meta.owner {
@@ -141,6 +146,7 @@ fn stats(ctx: &Ctx, commits: Option<u64>) -> Vec<Stat> {
141146 label: "Owner",
142147 value: owner.clone(),
143148 title: None,
149+ url: None,
144150 });
145151 }
146152 stats
@@ -150,10 +156,13 @@ fn latest(ctx: &Ctx, info: &CommitInfo) -> Latest {
150156 let id = info.id.to_string();
151157 Latest {
152158 subject: info.subject.clone(),
153− author: info.author_name.clone(),
159+ author: ctx
160+ .mailmap()
161+ .resolve(&info.author_name, &info.author_email)
162+ .0,
154163 age: time::relative(info.committer_time, time::now()),
155164 iso: time::iso(info.committer_time),
156− url: ctx.url("tree", &id, b""),
165+ url: ctx.commit_url(&info.id),
157166 id: short(&id),
158167 }
159168 }
M src/handlers/tree.rs View
@@ -41,6 +41,7 @@ pub struct TreePage {
4141 pub rows: Vec<TreeRow>,
4242 pub entries: usize,
4343 pub tree_id: String,
44+ pub log_url: String,
4445 pub readme: Option<Readme>,
4546 }
4647
@@ -103,6 +104,7 @@ pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response {
103104 rows,
104105 entries: entries.len(),
105106 tree_id: short(&tree_id.to_string()),
107+ log_url: ctx.url("log", &rev.name, &path),
106108 readme: dir_readme(ctx, &rev, &path, &entries),
107109 };
108110 let mut res = render(StatusCode::OK, &page);
A src/render/diff.rs View
@@ -0,0 +1,484 @@
1+//! Commit diffs: the cached, render-ready model behind the commit page,
2+//! and plain-text patches.
3+
4+use std::fmt::Write;
5+use std::sync::Arc;
6+
7+use crate::cache::Cache;
8+use crate::git::diff::{self, Changes, FileChange, LineKind, Side, Status};
9+use crate::git::tree::{self, EntryKind};
10+use crate::git::{CommitDetail, Handle, ObjectId};
11+use crate::render::highlight;
12+
13+/// Knobs for [`build`]; everything that changes the output is part of the
14+/// cache key.
15+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16+pub struct Options {
17+ pub context: u32,
18+ pub ignore_ws: bool,
19+ /// Above this many files only the file list is shown.
20+ pub max_files: usize,
21+ /// Changed lines rendered for the whole commit, and per file.
22+ pub max_lines: usize,
23+ pub max_file_lines: usize,
24+ /// Blobs larger than this are not diffed.
25+ pub max_blob_bytes: u64,
26+ pub max_highlight_bytes: u64,
27+}
28+
29+pub struct CommitDiff {
30+ pub files: Vec<FileDiff>,
31+ /// Totals over the files that were diffed.
32+ pub added: u64,
33+ pub removed: u64,
34+ /// More than `max_files` files: no per-file diffs at all.
35+ pub too_many_files: bool,
36+}
37+
38+impl CommitDiff {
39+ pub fn weight(&self) -> usize {
40+ 64 + self.files.iter().map(FileDiff::weight).sum::<usize>()
41+ }
42+}
43+
44+pub struct FileDiff {
45+ pub change: FileChange,
46+ /// Lines added and removed; `None` when the file wasn't diffed.
47+ pub stat: Option<(u32, u32)>,
48+ /// `(old, new)` modes when only the mode or type changed alongside.
49+ pub mode_change: Option<(&'static str, &'static str)>,
50+ pub body: Body,
51+}
52+
53+impl FileDiff {
54+ fn weight(&self) -> usize {
55+ let body = match &self.body {
56+ Body::Hunks(hunks) => hunks
57+ .iter()
58+ .map(|h| h.header.len() + h.lines.iter().map(|l| l.html.len() + 32).sum::<usize>())
59+ .sum(),
60+ _ => 0,
61+ };
62+ 128 + self.change.path().len() * 2 + body
63+ }
64+}
65+
66+pub enum Body {
67+ Hunks(Vec<Hunk>),
68+ /// Content unchanged (pure rename, mode change, whitespace-only with
69+ /// `ignore_ws`).
70+ Unchanged,
71+ Binary,
72+ Submodule {
73+ old: Option<ObjectId>,
74+ new: Option<ObjectId>,
75+ },
76+ /// Over a size limit: the blob, the per-file or the per-commit lines.
77+ TooLarge,
78+ /// Not diffed because the commit has too many files.
79+ NotShown,
80+}
81+
82+pub struct Hunk {
83+ pub header: String,
84+ pub lines: Vec<Line>,
85+}
86+
87+pub struct Line {
88+ pub kind: LineKind,
89+ /// 1-based line numbers.
90+ pub old: Option<u32>,
91+ pub new: Option<u32>,
92+ pub html: String,
93+}
94+
95+impl Line {
96+ pub fn sign(&self) -> &'static str {
97+ match self.kind {
98+ LineKind::Context => " ",
99+ LineKind::Added => "+",
100+ LineKind::Removed => "\u{2212}",
101+ }
102+ }
103+
104+ pub fn class(&self) -> &'static str {
105+ match self.kind {
106+ LineKind::Context => "ctx",
107+ LineKind::Added => "add",
108+ LineKind::Removed => "del",
109+ }
110+ }
111+}
112+
113+enum Content {
114+ Text(String),
115+ Binary,
116+ TooLarge,
117+ Missing,
118+}
119+
120+impl Content {
121+ fn text(&self) -> &str {
122+ match self {
123+ Content::Text(t) => t,
124+ _ => "",
125+ }
126+ }
127+}
128+
129+fn load(repo: &Handle, side: Option<&Side>, max_bytes: Option<u64>) -> anyhow::Result<Content> {
130+ let Some(side) = side else {
131+ return Ok(Content::Missing);
132+ };
133+ if let Some(max) = max_bytes
134+ && tree::size(repo, side.oid)? > max
135+ {
136+ return Ok(Content::TooLarge);
137+ }
138+ let data = tree::blob(repo, side.oid)?;
139+ if data[..data.len().min(8000)].contains(&0) {
140+ return Ok(Content::Binary);
141+ }
142+ Ok(Content::Text(String::from_utf8_lossy(&data).into_owned()))
143+}
144+
145+fn split(text: &str) -> Vec<&str> {
146+ text.split_inclusive('\n').collect()
147+}
148+
149+fn mode_change(change: &FileChange) -> Option<(&'static str, &'static str)> {
150+ let (old, new) = (change.old.as_ref()?, change.new.as_ref()?);
151+ (old.kind != new.kind).then(|| (diff::mode(old.kind), diff::mode(new.kind)))
152+}
153+
154+fn is_submodule(side: Option<&Side>) -> bool {
155+ submodule(side).is_some()
156+}
157+
158+fn submodule(side: Option<&Side>) -> Option<&Side> {
159+ side.filter(|s| s.kind == EntryKind::Submodule)
160+}
161+
162+/// Diff every changed file, highlighting both sides as whole files.
163+pub fn build(
164+ repo: &Handle,
165+ cache: &Cache,
166+ changes: &Changes,
167+ options: &Options,
168+) -> anyhow::Result<CommitDiff> {
169+ let too_many_files = changes.files.len() > options.max_files;
170+ let mut out = CommitDiff {
171+ files: Vec::with_capacity(changes.files.len()),
172+ added: 0,
173+ removed: 0,
174+ too_many_files,
175+ };
176+ let mut budget = options.max_lines;
177+ for change in &changes.files {
178+ let mut file = FileDiff {
179+ change: change.clone(),
180+ stat: None,
181+ mode_change: mode_change(change),
182+ body: Body::NotShown,
183+ };
184+ if too_many_files {
185+ out.files.push(file);
186+ continue;
187+ }
188+ let (old_side, new_side) = (change.old.as_ref(), change.new.as_ref());
189+ if is_submodule(old_side) || is_submodule(new_side) {
190+ file.body = Body::Submodule {
191+ old: submodule(old_side).map(|s| s.oid),
192+ new: submodule(new_side).map(|s| s.oid),
193+ };
194+ out.files.push(file);
195+ continue;
196+ }
197+ if old_side.map(|s| s.oid) == new_side.map(|s| s.oid) {
198+ file.stat = Some((0, 0));
199+ file.body = Body::Unchanged;
200+ out.files.push(file);
201+ continue;
202+ }
203+ let max = Some(options.max_blob_bytes);
204+ let (old, new) = (load(repo, old_side, max)?, load(repo, new_side, max)?);
205+ file.body = match (&old, &new) {
206+ (Content::TooLarge, _) | (_, Content::TooLarge) => Body::TooLarge,
207+ (Content::Binary, _) | (_, Content::Binary) => Body::Binary,
208+ _ => {
209+ let (old_text, new_text) = (old.text(), new.text());
210+ let (old_lines, new_lines) = (split(old_text), split(new_text));
211+ let lines = diff::lines(&old_lines, &new_lines, options.context, options.ignore_ws);
212+ file.stat = Some((lines.added, lines.removed));
213+ out.added += u64::from(lines.added);
214+ out.removed += u64::from(lines.removed);
215+ let changed = (lines.added + lines.removed) as usize;
216+ if lines.hunks.is_empty() {
217+ Body::Unchanged
218+ } else if changed > options.max_file_lines || changed > budget {
219+ Body::TooLarge
220+ } else {
221+ budget -= changed;
222+ let html = |side: Option<&Side>, text: &str| match side {
223+ Some(side) => {
224+ highlight::cached_lines(
225+ cache,
226+ side.oid,
227+ &String::from_utf8_lossy(&side.path),
228+ text,
229+ options.max_highlight_bytes,
230+ )
231+ .0
232+ }
233+ None => Arc::new(Vec::new()),
234+ };
235+ let old_html = html(old_side, old_text);
236+ let new_html = html(new_side, new_text);
237+ let get = |lines: &[String], i: Option<u32>| {
238+ i.and_then(|i| lines.get(i as usize))
239+ .cloned()
240+ .unwrap_or_default()
241+ };
242+ Body::Hunks(
243+ lines
244+ .hunks
245+ .iter()
246+ .map(|h| Hunk {
247+ header: h.header(),
248+ lines: h
249+ .lines
250+ .iter()
251+ .map(|l| Line {
252+ kind: l.kind,
253+ old: l.old.map(|n| n + 1),
254+ new: l.new.map(|n| n + 1),
255+ html: match l.kind {
256+ LineKind::Removed => get(&old_html, l.old),
257+ _ => get(&new_html, l.new),
258+ },
259+ })
260+ .collect(),
261+ })
262+ .collect(),
263+ )
264+ }
265+ }
266+ };
267+ out.files.push(file);
268+ }
269+ Ok(out)
270+}
271+
272+/// A `git format-patch`-style patch of `commit` against its first parent.
273+pub fn patch(repo: &Handle, commit: &CommitDetail, changes: &Changes) -> anyhow::Result<String> {
274+ let mut stat = String::new();
275+ let mut body = String::new();
276+ let (mut files, mut added, mut removed) = (0, 0, 0);
277+ let width = changes
278+ .files
279+ .iter()
280+ .map(|f| display_path(f).chars().count())
281+ .max()
282+ .unwrap_or(0);
283+ let mut stats = Vec::new();
284+ for change in &changes.files {
285+ files += 1;
286+ let (a, r, bin) = file_patch(repo, change, &mut body)?;
287+ added += a;
288+ removed += r;
289+ stats.push((display_path(change), a, r, bin));
290+ }
291+ let max = stats
292+ .iter()
293+ .map(|(_, a, r, _)| a + r)
294+ .max()
295+ .unwrap_or(0)
296+ .max(1);
297+ for (path, a, r, bin) in &stats {
298+ let _ = write!(stat, " {path:<width$} | ");
299+ match bin {
300+ Some(sizes) => {
301+ let _ = writeln!(stat, "{sizes}");
302+ }
303+ None => {
304+ // Scale the bar like git (to at most 50 columns).
305+ let scale = |n: u32| -> usize {
306+ if max <= 50 {
307+ n as usize
308+ } else {
309+ ((n as u64 * 50).div_ceil(max as u64)) as usize
310+ }
311+ };
312+ let _ = writeln!(
313+ stat,
314+ "{} {}{}",
315+ a + r,
316+ "+".repeat(scale(*a)),
317+ "-".repeat(scale(*r))
318+ );
319+ }
320+ }
321+ }
322+ let plural = |n: u32, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many });
323+ let mut summary = format!(" {} changed", plural(files, "file", "files"));
324+ if added > 0 || removed == 0 {
325+ let _ = write!(summary, ", {}(+)", plural(added, "insertion", "insertions"));
326+ }
327+ if removed > 0 {
328+ let _ = write!(summary, ", {}(-)", plural(removed, "deletion", "deletions"));
329+ }
330+
331+ let (subject, rest) = commit
332+ .message
333+ .split_once('\n')
334+ .unwrap_or((commit.message.as_str(), ""));
335+ let mut out = String::new();
336+ let _ = writeln!(out, "From {} Mon Sep 17 00:00:00 2001", commit.info.id);
337+ let _ = writeln!(
338+ out,
339+ "From: {} <{}>",
340+ commit.author.name, commit.author.email
341+ );
342+ let _ = writeln!(
343+ out,
344+ "Date: {}",
345+ crate::render::time::rfc2822(commit.author.time, commit.author.offset)
346+ );
347+ let _ = writeln!(out, "Subject: [PATCH] {}", subject.trim());
348+ out.push('\n');
349+ let rest = rest.trim();
350+ if !rest.is_empty() {
351+ out.push_str(rest);
352+ out.push('\n');
353+ }
354+ out.push_str("---\n");
355+ out.push_str(&stat);
356+ out.push_str(&summary);
357+ out.push_str("\n\n");
358+ out.push_str(&body);
359+ let _ = write!(out, "-- \nferrenium {}\n\n", env!("CARGO_PKG_VERSION"));
360+ Ok(out)
361+}
362+
363+fn display_path(change: &FileChange) -> String {
364+ let new = String::from_utf8_lossy(change.path()).into_owned();
365+ match (&change.status, &change.old) {
366+ (Status::Renamed | Status::Copied, Some(old)) => {
367+ format!("{} => {new}", String::from_utf8_lossy(&old.path))
368+ }
369+ _ => new,
370+ }
371+}
372+
373+fn short(oid: &ObjectId) -> String {
374+ oid.to_hex_with_len(7).to_string()
375+}
376+
377+/// Append one file's `diff --git` section; returns lines added, removed,
378+/// and the stat text for binary files.
379+fn file_patch(
380+ repo: &Handle,
381+ change: &FileChange,
382+ out: &mut String,
383+) -> anyhow::Result<(u32, u32, Option<String>)> {
384+ let path = |s: Option<&Side>| s.map(|s| String::from_utf8_lossy(&s.path).into_owned());
385+ let (old, new) = (change.old.as_ref(), change.new.as_ref());
386+ let a = path(old).unwrap_or_else(|| path(new).unwrap_or_default());
387+ let b = path(new).unwrap_or_else(|| a.clone());
388+ let _ = writeln!(out, "diff --git a/{a} b/{b}");
389+ match (old, new) {
390+ (None, Some(n)) => {
391+ let _ = writeln!(out, "new file mode {}", diff::mode(n.kind));
392+ }
393+ (Some(o), None) => {
394+ let _ = writeln!(out, "deleted file mode {}", diff::mode(o.kind));
395+ }
396+ (Some(o), Some(n)) => {
397+ if o.kind != n.kind {
398+ let _ = writeln!(out, "old mode {}", diff::mode(o.kind));
399+ let _ = writeln!(out, "new mode {}", diff::mode(n.kind));
400+ }
401+ if let (Some(similarity), true) = (
402+ change.similarity,
403+ matches!(change.status, Status::Renamed | Status::Copied),
404+ ) {
405+ let verb = if change.status == Status::Copied {
406+ "copy"
407+ } else {
408+ "rename"
409+ };
410+ let _ = writeln!(out, "similarity index {similarity}%");
411+ let _ = writeln!(out, "{verb} from {a}");
412+ let _ = writeln!(out, "{verb} to {b}");
413+ }
414+ }
415+ (None, None) => {}
416+ }
417+ let old_id = old.map_or_else(|| "0000000".to_string(), |s| short(&s.oid));
418+ let new_id = new.map_or_else(|| "0000000".to_string(), |s| short(&s.oid));
419+ if old.map(|s| s.oid) == new.map(|s| s.oid) {
420+ return Ok((0, 0, None));
421+ }
422+ match (old, new) {
423+ (Some(o), Some(n)) if o.kind == n.kind => {
424+ let _ = writeln!(out, "index {old_id}..{new_id} {}", diff::mode(n.kind));
425+ }
426+ _ => {
427+ let _ = writeln!(out, "index {old_id}..{new_id}");
428+ }
429+ }
430+ let minus = old.map_or_else(|| "/dev/null".to_string(), |_| format!("a/{a}"));
431+ let plus = new.map_or_else(|| "/dev/null".to_string(), |_| format!("b/{b}"));
432+
433+ if is_submodule(old) || is_submodule(new) {
434+ let _ = writeln!(out, "--- {minus}\n+++ {plus}");
435+ let (o, n) = (submodule(old), submodule(new));
436+ let _ = writeln!(
437+ out,
438+ "@@ -{} +{} @@",
439+ if o.is_some() { "1" } else { "0,0" },
440+ if n.is_some() { "1" } else { "0,0" }
441+ );
442+ if let Some(o) = o {
443+ let _ = writeln!(out, "-Subproject commit {}", o.oid);
444+ }
445+ if let Some(n) = n {
446+ let _ = writeln!(out, "+Subproject commit {}", n.oid);
447+ }
448+ return Ok((u32::from(n.is_some()), u32::from(o.is_some()), None));
449+ }
450+
451+ let (old_c, new_c) = (load(repo, old, None)?, load(repo, new, None)?);
452+ if matches!(old_c, Content::Binary) || matches!(new_c, Content::Binary) {
453+ let _ = writeln!(out, "Binary files {minus} and {plus} differ");
454+ let size = |s: Option<&Side>| -> anyhow::Result<u64> {
455+ s.map_or(Ok(0), |s| tree::size(repo, s.oid))
456+ };
457+ let sizes = format!("Bin {} -> {} bytes", size(old)?, size(new)?);
458+ return Ok((0, 0, Some(sizes)));
459+ }
460+ let (old_l, new_l) = (split(old_c.text()), split(new_c.text()));
461+ let lines = diff::lines(&old_l, &new_l, 3, false);
462+ // Like git, an empty file added or removed has no `---`/`+++` lines.
463+ if lines.hunks.is_empty() {
464+ return Ok((0, 0, None));
465+ }
466+ let _ = writeln!(out, "--- {minus}\n+++ {plus}");
467+ for hunk in &lines.hunks {
468+ out.push_str(&hunk.header());
469+ out.push('\n');
470+ for line in &hunk.lines {
471+ let (sign, text) = match line.kind {
472+ LineKind::Context => (' ', old_l[line.old.unwrap_or(0) as usize]),
473+ LineKind::Removed => ('-', old_l[line.old.unwrap_or(0) as usize]),
474+ LineKind::Added => ('+', new_l[line.new.unwrap_or(0) as usize]),
475+ };
476+ out.push(sign);
477+ out.push_str(text);
478+ if !text.ends_with('\n') {
479+ out.push_str("\n\\ No newline at end of file\n");
480+ }
481+ }
482+ }
483+ Ok((lines.added, lines.removed, None))
484+}
M src/render/highlight.rs View
@@ -8,12 +8,14 @@
88 //! `hl-k` keywords/storage, `hl-d` names/numbers/constants, `hl-s`
99 //! strings/punctuation/operators, `hl-c` comments, `hl-x` invalid.
1010
11−use std::sync::OnceLock;
11+use std::sync::{Arc, OnceLock};
1212
1313 use syntect::parsing::{ParseState, Scope, ScopeStack, SyntaxReference, SyntaxSet};
1414 use syntect::util::LinesWithEndings;
1515
1616 use super::escape_html;
17+use crate::cache::{Cache, Key};
18+use crate::git::ObjectId;
1719
1820 static SYNTAXES: OnceLock<SyntaxSet> = OnceLock::new();
1921
@@ -187,6 +189,44 @@ pub fn lines(text: &str, syntax: &SyntaxReference) -> Vec<String> {
187189 out
188190 }
189191
192+/// Lines longer than this (minified code) make syntect crawl; such files
193+/// are shown plain.
194+const MAX_LINE_BYTES: usize = 5000;
195+
196+/// HTML lines of blob `oid` (the file at `path`, contents `text`),
197+/// highlighted when a syntax is detected, the blob is at most `max_bytes`
198+/// and no line is minified-long, plain otherwise. Highlighted lines are cached by blob and
199+/// shared by every view of it. Also returns the detected language.
200+pub fn cached_lines(
201+ cache: &Cache,
202+ oid: ObjectId,
203+ path: &str,
204+ text: &str,
205+ max_bytes: u64,
206+) -> (Arc<Vec<String>>, Option<&'static str>) {
207+ let first_line = text.lines().next().unwrap_or_default();
208+ let syntax = detect(path, first_line);
209+ let language = syntax.map(|s| s.name.as_str());
210+ let lines = match syntax {
211+ Some(syntax)
212+ if text.len() as u64 <= max_bytes
213+ && !text.lines().any(|l| l.len() > MAX_LINE_BYTES) =>
214+ {
215+ let key = Key::Highlight {
216+ blob: oid,
217+ syntax: syntax.name.clone(),
218+ };
219+ cache
220+ .lines(key, || {
221+ Ok::<_, std::convert::Infallible>(lines(text, syntax))
222+ })
223+ .unwrap_or_else(|never| match never {})
224+ }
225+ _ => Arc::new(plain_lines(text)),
226+ };
227+ (lines, language)
228+}
229+
190230 /// Escaped lines without highlighting.
191231 pub fn plain_lines(text: &str) -> Vec<String> {
192232 LinesWithEndings::from(text).map(plain_line).collect()
M src/render/mod.rs View
@@ -1,5 +1,6 @@
11 //! Presentation helpers shared by handlers and templates.
22
3+pub mod diff;
34 pub mod highlight;
45 pub mod markdown;
56 pub mod time;
M src/render/time.rs View
@@ -38,6 +38,33 @@ pub fn iso(then: i64) -> String {
3838 .unwrap_or_default()
3939 }
4040
41+fn zoned(then: i64, offset: i32) -> Option<jiff::Zoned> {
42+ let offset = jiff::tz::Offset::from_seconds(offset).unwrap_or(jiff::tz::Offset::UTC);
43+ let ts = jiff::Timestamp::from_second(then).ok()?;
44+ Some(ts.to_zoned(jiff::tz::TimeZone::fixed(offset)))
45+}
46+
47+/// `Thu, 5 Nov 2026 14:03:00 +0100` in the recorded offset (patch headers).
48+pub fn rfc2822(then: i64, offset: i32) -> String {
49+ zoned(then, offset)
50+ .map(|z| z.strftime("%a, %-d %b %Y %H:%M:%S %z").to_string())
51+ .unwrap_or_default()
52+}
53+
54+/// `2026-11-05 14:03 +0100` in the recorded offset.
55+pub fn local(then: i64, offset: i32) -> String {
56+ zoned(then, offset)
57+ .map(|z| z.strftime("%Y-%m-%d %H:%M %z").to_string())
58+ .unwrap_or_default()
59+}
60+
61+/// `Nov 5, 2026` (UTC), for grouping commits by day.
62+pub fn day(then: i64) -> String {
63+ jiff::Timestamp::from_second(then)
64+ .map(|t| t.strftime("%b %-d, %Y").to_string())
65+ .unwrap_or_default()
66+}
67+
4168 #[cfg(test)]
4269 mod tests {
4370 use super::*;
@@ -59,5 +86,8 @@ mod tests {
5986 #[test]
6087 fn iso_format() {
6188 assert_eq!(iso(0), "1970-01-01T00:00:00Z");
89+ assert_eq!(rfc2822(0, 3600), "Thu, 1 Jan 1970 01:00:00 +0100");
90+ assert_eq!(local(0, -7200), "1969-12-31 22:00 -0200");
91+ assert_eq!(day(86400 * 5), "Jan 6, 1970");
6292 }
6393 }
M templates/blob.html View
@@ -12,6 +12,7 @@
1212 </nav>
1313 {%- endif %}
1414 <a class="btn-ghost" href="{{ raw_url }}">Raw</a>
15+ <a class="btn-ghost" href="{{ log_url }}">History</a>
1516 <a class="btn-ghost" href="{{ download_url }}">Download</a>
1617 </div>
1718 </div>
A templates/commit.html View
@@ -0,0 +1,111 @@
1+{% extends "base.html" %}
2+{% block content %}
3+{% include "repo_nav.html" %}
4+{% include "crumbs.html" %}
5+<section class="commit-head">
6+ <h2 class="commit-subject">{{ subject }}</h2>
7+ {%- if !body.is_empty() %}
8+ <pre class="commit-body">{{ body }}</pre>
9+ {%- endif %}
10+</section>
11+<div class="commit-grid">
12+ <dl class="stats commit-meta">
13+ {%- for m in meta %}
14+ <div class="stat">
15+ <dt class="label">{{ m.label }}</dt>
16+ <dd>
17+ {%- if let Some(title) = m.title %}<span title="{{ title }}">{{ m.value }}</span>{% else %}{{ m.value }}{% endif %}
18+ {%- for (text, url) in m.links %}{% if !loop.first %} {% endif %}<a class="sha" href="{{ url }}">{{ text }}</a>{% endfor -%}
19+ </dd>
20+ </div>
21+ {%- endfor %}
22+ </dl>
23+ <div class="diffstat-hero">
24+ {%- if diff.too_many_files %}
25+ <p class="diffstat-num">{{ files.len() }}</p>
26+ <p class="label dim">Files changed</p>
27+ {%- else %}
28+ <p class="diffstat-num"><span class="add">+{{ added }}</span> <span class="del">&minus;{{ removed }}</span></p>
29+ <p class="label dim">{{ files.len() }} {% if files.len() == 1 %}file{% else %}files{% endif %} changed</p>
30+ {%- endif %}
31+ </div>
32+</div>
33+<div class="commit-actions">
34+ <div class="blob-actions">
35+ <a class="btn-ghost" href="{{ tree_url }}">Browse files</a>
36+ <a class="btn-ghost" href="{{ patch_url }}">Patch</a>
37+ </div>
38+ <div class="blob-actions">
39+ <nav class="segmented segmented-sm" aria-label="Whitespace">
40+ {%- for t in ws_toggle %}
41+ <a href="{{ t.url }}"{% if t.active %} aria-current="true"{% endif %}>{{ t.label }}</a>
42+ {%- endfor %}
43+ </nav>
44+ <span class="label dim context-ctl">
45+ {%- if let Some(url) = context_less %}<a href="{{ url }}" aria-label="Less context">&minus;</a>{% else %}<span>&minus;</span>{% endif -%}
46+ <span>Context {{ context }}</span>
47+ {%- if let Some(url) = context_more %}<a href="{{ url }}" aria-label="More context">+</a>{% else %}<span>+</span>{% endif -%}
48+ </span>
49+ </div>
50+</div>
51+{%- if files.is_empty() %}
52+<p class="status blob-note">[No changes]</p>
53+{%- else %}
54+<ol class="file-list">
55+ {%- for row in files %}
56+ <li class="file-row">
57+ <span class="file-status" title="{{ row.status }}">{{ row.letter }}</span>
58+ <a class="file-path" href="#{{ row.anchor }}">{% if let Some(from) = row.from %}<span class="dim">{{ from }} &rarr; </span>{% endif %}{{ row.path }}</a>
59+ {%- if let Some((a, r)) = row.file.stat %}
60+ <span class="file-counts"><span class="add">+{{ a }}</span> <span class="del">&minus;{{ r }}</span></span>
61+ {%- else %}
62+ <span class="file-counts"></span>
63+ {%- endif %}
64+ <span class="blocks" aria-hidden="true">{% for b in row.bar %}<i class="{{ b }}"></i>{% endfor %}</span>
65+ </li>
66+ {%- endfor %}
67+</ol>
68+{%- endif %}
69+{%- if diff.too_many_files %}
70+<p class="status blob-note">[Diff too large: {{ files.len() }} files] <a href="{{ patch_url }}">View patch</a></p>
71+{%- else %}
72+{%- for row in files %}
73+<section class="diff-file" id="{{ row.anchor }}">
74+ <header class="diff-head">
75+ <span class="file-status" title="{{ row.status }}">{{ row.letter }}</span>
76+ <span class="diff-path">{% if let Some(from) = row.from %}<span class="dim">{{ from }} &rarr; </span>{% endif %}{{ row.path }}</span>
77+ {%- if let Some(url) = row.url %}
78+ <a class="label diff-view" href="{{ url }}">View</a>
79+ {%- endif %}
80+ </header>
81+ {%- if let Some((old_mode, new_mode)) = row.file.mode_change %}
82+ <p class="label dim diff-note">Mode {{ old_mode }} &rarr; {{ new_mode }}</p>
83+ {%- endif %}
84+ {%- match row.file.body %}
85+ {%- when Body::Hunks with (hunks) %}
86+ <div class="code-wrap">
87+ <table class="code diff">
88+ <tbody>
89+ {%- for hunk in hunks %}
90+ <tr class="hunk"><td class="ln" colspan="2"></td><td class="lc" colspan="2">{{ hunk.header }}</td></tr>
91+ {%- for line in hunk.lines %}
92+ <tr class="{{ line.class() }}"><td class="ln">{% if let Some(n) = line.old %}{{ n }}{% endif %}</td><td class="ln">{% if let Some(n) = line.new %}{{ n }}{% endif %}</td><td class="sign">{{ line.sign() }}</td><td class="lc">{{ line.html|safe }}</td></tr>
93+ {%- endfor %}
94+ {%- endfor %}
95+ </tbody>
96+ </table>
97+ </div>
98+ {%- when Body::Unchanged %}
99+ <p class="status diff-note">[No content changes]</p>
100+ {%- when Body::Binary %}
101+ <p class="status diff-note">[Binary file]</p>
102+ {%- when Body::Submodule with { old, new } %}
103+ <p class="diff-note"><span class="label">Submodule</span> <span class="mono">{% if let Some(o) = old %}{{ o.to_hex_with_len(7) }}{% else %}&mdash;{% endif %} &rarr; {% if let Some(n) = new %}{{ n.to_hex_with_len(7) }}{% else %}&mdash;{% endif %}</span></p>
104+ {%- when Body::TooLarge %}
105+ <p class="status diff-note">[Diff too large]{% if let Some(url) = row.url %} <a href="{{ url }}">View file</a>{% endif %}</p>
106+ {%- when Body::NotShown %}
107+ {%- endmatch %}
108+</section>
109+{%- endfor %}
110+{%- endif %}
111+{% endblock %}
A templates/log.html View
@@ -0,0 +1,47 @@
1+{% extends "base.html" %}
2+{% block content %}
3+{% include "repo_nav.html" %}
4+{% include "crumbs.html" %}
5+<form class="log-tools" method="get" action="{{ action }}" role="search">
6+ <label class="field">
7+ <span class="label">Grep</span>
8+ <input type="search" name="q" value="{{ q }}" autocomplete="off" spellcheck="false" placeholder="Message, author or email">
9+ </label>
10+</form>
11+{%- if days.is_empty() %}
12+<p class="status log-note">{% if limit_reached %}[No matches in {{ scan_limit }} commits]{% else %}[No commits]{% endif %}</p>
13+{%- endif %}
14+{%- for day in days %}
15+<section class="log-day">
16+ <h2 class="label section-label">{{ day.label }}</h2>
17+ <ol class="log">
18+ {%- for row in day.rows %}
19+ <li class="log-row">
20+ <div class="log-main">
21+ <a class="log-subject" href="{{ row.url }}">{{ row.subject }}</a>
22+ <p class="log-meta">{{ row.author }} · <time datetime="{{ row.iso }}" title="{{ row.iso }}">{{ row.age }}</time></p>
23+ </div>
24+ <a class="sha log-sha" href="{{ row.url }}">{{ row.id }}</a>
25+ </li>
26+ {%- endfor %}
27+ </ol>
28+</section>
29+{%- endfor %}
30+{%- if limit_reached && !days.is_empty() %}
31+<p class="status log-note">[Search limit reached: {{ scan_limit }} commits scanned]</p>
32+{%- endif %}
33+{%- if newer_url.is_some() || older_url.is_some() %}
34+<nav class="pager" aria-label="Pages">
35+ {%- if let Some(url) = newer_url %}
36+ <a class="label" href="{{ url }}" rel="prev">&lt; {{ newer_label }}</a>
37+ {%- else %}
38+ <span class="label dim">&lt; Newer</span>
39+ {%- endif %}
40+ {%- if let Some(url) = older_url %}
41+ <a class="label" href="{{ url }}" rel="next">Older &gt;</a>
42+ {%- else %}
43+ <span class="label dim">Older &gt;</span>
44+ {%- endif %}
45+</nav>
46+{%- endif %}
47+{% endblock %}
M templates/repo_nav.html View
@@ -2,6 +2,7 @@
22 <nav class="bracket-nav" aria-label="Repository">
33 <a class="label{% if nav.active == "summary" %} active{% endif %}" href="{{ nav.url }}"{% if nav.active == "summary" %} aria-current="page"{% endif %}>Summary</a>
44 <a class="label{% if nav.active == "tree" %} active{% endif %}" href="{{ nav.tree_url }}"{% if nav.active == "tree" %} aria-current="page"{% endif %}>Tree</a>
5+ <a class="label{% if nav.active == "log" %} active{% endif %}" href="{{ nav.log_url }}"{% if nav.active == "log" %} aria-current="page"{% endif %}>Log</a>
56 <a class="label{% if nav.active == "refs" %} active{% endif %}" href="{{ nav.refs_url }}"{% if nav.active == "refs" %} aria-current="page"{% endif %}>Refs</a>
67 </nav>
78 {%- if let Some(picker) = nav.picker %}
M templates/summary.html View
@@ -27,7 +27,7 @@
2727 {%- for stat in stats %}
2828 <div class="stat">
2929 <dt class="label">{{ stat.label }}</dt>
30− <dd>{% if let Some(title) = stat.title %}<time datetime="{{ title }}" title="{{ title }}">{{ stat.value }}</time>{% else %}{{ stat.value }}{% endif %}</dd>
30+ <dd>{% if let Some(url) = stat.url %}<a href="{{ url }}">{{ stat.value }}</a>{% else if let Some(title) = stat.title %}<time datetime="{{ title }}" title="{{ title }}">{{ stat.value }}</time>{% else %}{{ stat.value }}{% endif %}</dd>
3131 </div>
3232 {%- endfor %}
3333 </dl>
M templates/tree.html View
@@ -4,7 +4,7 @@
44 {% include "repo_nav.html" %}
55 {% include "crumbs.html" %}
66 {% call m::tree_list(rows, layout.static_base) %}{% endcall %}
7−<p class="label dim tree-foot">{{ entries }} {% if entries == 1 %}entry{% else %}entries{% endif %} · Tree {{ tree_id }}</p>
7+<p class="label dim tree-foot">{{ entries }} {% if entries == 1 %}entry{% else %}entries{% endif %} · Tree {{ tree_id }} · <a href="{{ log_url }}">History</a></p>
88 {%- if let Some(readme) = readme %}
99 {% call m::readme(readme) %}{% endcall %}
1010 {%- endif %}
M tests/browse.rs View
@@ -111,12 +111,15 @@ async fn summary_shows_tree_readme_and_rail() {
111111 assert!(html.contains(r#"src="/rich/raw/main/img/logo.png""#));
112112 assert!(!html.contains("alert(1)"));
113113 // Rail.
114− assert!(html.contains("<dd>2</dd>"), "commit count: {html}");
114+ assert!(
115+ html.contains(r#"<dd><a href="/rich/log">2</a></dd>"#),
116+ "commit count: {html}"
117+ );
115118 assert!(html.contains("https://git.example.com/rich"));
116119 assert!(html.contains("git@example.com:rich"));
117120 assert!(html.contains(">HTTPS<") && html.contains(">SSH<"));
118121 assert!(html.contains("add everything"));
119− assert!(html.contains(&format!(r#"href="/rich/tree/{}""#, s.rich.head)));
122+ assert!(html.contains(&format!(r#"href="/rich/commit/{}""#, s.rich.head)));
120123 // Nav + picker.
121124 assert!(html.contains(r#"class="label active" href="/rich" aria-current="page">Summary"#));
122125 assert!(html.contains(r#"href="/rich/tree/feature/x""#));
@@ -172,7 +175,7 @@ async fn dispatcher_matches_repos_and_revisions() {
172175 ("/rich/tree/nope", "UNKNOWN REVISION"),
173176 ("/rich/tree/main/nope", "PATH NOT FOUND IN main"),
174177 ("/rich/blob/main/vendor/lib", "PATH IS A SUBMODULE"),
175− ("/rich/log", "PAGE NOT FOUND"),
178+ ("/rich/blame-nope", "PAGE NOT FOUND"),
176179 ("/rich/tree/main/../x", "PAGE NOT FOUND"),
177180 ("/-/nope", "PAGE NOT FOUND"),
178181 ] {
A tests/history.rs View
@@ -0,0 +1,509 @@
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("&#38;", "&").replace("&amp;", "&"))
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">&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]
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">&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]
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+}