Krolyxon
Commit 18ca21c

ferrenium18ca21c

Extras: blame, archives, feed, finder, languages, clone (M4)

- Blame with per-hunk commit info and an age bar; follows renames.
- Streamed tar.gz/zip snapshots (no temp files), linked on tags.
- Atom feed per branch, advertised in page heads.
- File finder over a cached path listing.
- Linguist-style language stats (generated table from Linguist v9.7.0,
  vendored/generated/documentation exclusions, .gitattributes overrides),
  computed by a background warmer; bar on the summary, language on the
  index.
- Read-only smart-HTTP clone through git http-backend; pushes refused.
- robots.txt for expensive endpoints; heavy-request permits.
Author
krolyxon
Date
2026-11-05 22:08 +0530
Parent
4e844bb
Commit
18ca21cf1917c6f5a5c3a76fba64d9d3705010c1

+9127 −29

38 files changed

  1. M Cargo.lock +37 −0
  2. M Cargo.toml +7 −1
  3. M assets/css/components.css +10 −0
  4. M assets/css/repo.css +158 −0
  5. M src/cache.rs +65 −0
  6. A src/git/archive.rs +97 −0
  7. A src/git/blame.rs +67 −0
  8. M src/git/mod.rs +2 −0
  9. M src/git/tree.rs +63 −0
  10. A src/handlers/archive.rs +108 −0
  11. A src/handlers/blame.rs +211 −0
  12. M src/handlers/blob.rs +2 −0
  13. A src/handlers/feed.rs +127 −0
  14. A src/handlers/find.rs +170 −0
  15. M src/handlers/index.rs +3 −0
  16. M src/handlers/log.rs +4 −1
  17. M src/handlers/mod.rs +7 −0
  18. M src/handlers/refs.rs +31 −1
  19. M src/handlers/repo.rs +46 −3
  20. M src/handlers/summary.rs +126 −2
  21. A src/index/languages.rs +410 −0
  22. A src/index/languages_table.rs +6282 −0
  23. M src/index/mod.rs +2 −0
  24. A src/server/clone.rs +264 −0
  25. M src/server/dispatch.rs +46 −14
  26. M src/server/mod.rs +44 −4
  27. M src/state.rs +16 −2
  28. A templates/atom.xml +26 −0
  29. M templates/base.html +3 −0
  30. A templates/blame.html +34 −0
  31. M templates/blob.html +1 −0
  32. A templates/find.html +28 −0
  33. M templates/index.html +1 −0
  34. M templates/ref_table.html +1 −1
  35. M templates/repo_nav.html +1 −0
  36. M templates/summary.html +13 −0
  37. A tests/extras.rs +445 −0
  38. A tools/gen-languages/gen.py +169 −0
M Cargo.lock View
@@ -728,9 +728,13 @@ dependencies = [
728728 "arc-swap",
729729 "askama",
730730 "axum",
731+ "bytes",
731732 "clap",
732733 "comrak",
734+ "fancy-regex",
735+ "futures-util",
733736 "gix",
737+ "gix-archive",
734738 "jiff",
735739 "mime_guess",
736740 "moka",
@@ -742,6 +746,7 @@ dependencies = [
742746 "tempfile",
743747 "thiserror",
744748 "tokio",
749+ "tokio-util",
745750 "toml",
746751 "tower",
747752 "tower-http",
@@ -912,10 +917,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
912917 checksum = "cb5fef3fbef9788069016ae1b18e899ac22a508387f9935bb8185c280ef01333"
913918 dependencies = [
914919 "bstr",
920+ "flate2",
915921 "gix-date",
916922 "gix-error",
917923 "gix-object",
924+ "gix-path",
918925 "gix-worktree-stream",
926+ "rawzip",
927+ "tar",
919928 ]
920929
921930 [[package]]
@@ -2402,6 +2411,12 @@ version = "6.0.0"
24022411 source = "registry+https://github.com/rust-lang/crates.io-index"
24032412 checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
24042413
2414+[[package]]
2415+name = "rawzip"
2416+version = "0.4.4"
2417+source = "registry+https://github.com/rust-lang/crates.io-index"
2418+checksum = "9d9575f44c8cf85bc843ad666dcdf20d05a7753772bef56eb2a5140282b32150"
2419+
24052420 [[package]]
24062421 name = "redox_syscall"
24072422 version = "0.5.18"
@@ -2768,6 +2783,17 @@ version = "0.2.0"
27682783 source = "registry+https://github.com/rust-lang/crates.io-index"
27692784 checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
27702785
2786+[[package]]
2787+name = "tar"
2788+version = "0.4.46"
2789+source = "registry+https://github.com/rust-lang/crates.io-index"
2790+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
2791+dependencies = [
2792+ "filetime",
2793+ "libc",
2794+ "xattr",
2795+]
2796+
27712797 [[package]]
27722798 name = "tempfile"
27732799 version = "3.27.0"
@@ -2841,6 +2867,7 @@ version = "1.54.1"
28412867 source = "registry+https://github.com/rust-lang/crates.io-index"
28422868 checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
28432869 dependencies = [
2870+ "bytes",
28442871 "libc",
28452872 "mio",
28462873 "pin-project-lite",
@@ -3244,6 +3271,16 @@ version = "0.6.4"
32443271 source = "registry+https://github.com/rust-lang/crates.io-index"
32453272 checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
32463273
3274+[[package]]
3275+name = "xattr"
3276+version = "1.6.1"
3277+source = "registry+https://github.com/rust-lang/crates.io-index"
3278+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
3279+dependencies = [
3280+ "libc",
3281+ "rustix",
3282+]
3283+
32473284 [[package]]
32483285 name = "yoke"
32493286 version = "0.8.4"
M Cargo.toml View
@@ -10,12 +10,17 @@ publish = false
1010 ammonia = "4.2"
1111 anyhow = "1.0.104"
1212 arc-swap = "1.9.3"
13+bytes = "1"
1314 askama = "0.16.1"
1415 axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio", "query"] }
1516 clap = { version = "4.6.7", features = ["derive", "env"] }
1617 comrak = { version = "0.59", default-features = false }
18+fancy-regex = "0.16"
19+futures-util = { version = "0.3", default-features = false, features = ["std"] }
1720 # No `extras`/`interrupt`: they install their own signal handlers.
1821 gix = { version = "0.90.0", default-features = false, features = ["max-performance", "revision", "blob-diff", "blame", "mailmap", "worktree-archive", "sha1"] }
22+# Snapshot formats (gix only enables the bare crate).
23+gix-archive = { version = "0.39", default-features = false, features = ["tar_gz", "zip"] }
1924 jiff = "0.2.39"
2025 mime_guess = "2.0.5"
2126 moka = { version = "0.12", features = ["sync"] }
@@ -25,7 +30,8 @@ sd-notify = "0.5.0"
2530 serde = { version = "1.0.229", features = ["derive"] }
2631 syntect = { version = "5.3", default-features = false, features = ["parsing", "html", "regex-fancy"] }
2732 thiserror = "2.0.21"
28−tokio = { version = "1.54.1", features = ["rt-multi-thread", "macros", "net", "signal", "sync", "time"] }
33+tokio = { version = "1.54.1", features = ["rt-multi-thread", "macros", "net", "signal", "sync", "time", "process", "io-util"] }
34+tokio-util = { version = "0.7", features = ["io", "io-util"] }
2935 toml = "1.2.0"
3036 tower = { version = "0.5.3", features = ["util"] }
3137 tower-http = { version = "0.7.1", features = ["trace", "timeout", "catch-panic", "compression-gzip", "compression-br", "set-header"] }
M assets/css/components.css View
@@ -380,6 +380,16 @@ html:not(.js) .theme-switch {
380380 white-space: nowrap;
381381 }
382382
383+.repo-lang {
384+ color: var(--text-secondary);
385+}
386+
387+.repo-lang::after {
388+ content: "\00B7";
389+ margin-left: var(--space-sm);
390+ color: var(--text-disabled);
391+}
392+
383393 /* Data-status dot: pushed in the last 24 hours. */
384394 .dot {
385395 width: 6px;
M assets/css/repo.css View
@@ -290,6 +290,66 @@ html:not(.js) .copy-status {
290290 margin-top: var(--space-xs);
291291 }
292292
293+/* The one break on the summary: a segmented language bar. */
294+.lang-bar {
295+ display: grid;
296+ grid-template-columns: repeat(40, 1fr);
297+ gap: 2px;
298+ margin-top: var(--space-sm);
299+}
300+
301+.lang-bar i {
302+ aspect-ratio: 1;
303+}
304+
305+.lang-bar i,
306+.lang-legend i {
307+ background: var(--text-display);
308+}
309+
310+.lang-bar .o1,
311+.lang-legend .o1 {
312+ opacity: 0.7;
313+}
314+
315+.lang-bar .o2,
316+.lang-legend .o2 {
317+ opacity: 0.45;
318+}
319+
320+.lang-bar .o3,
321+.lang-legend .o3 {
322+ opacity: 0.25;
323+}
324+
325+.lang-legend {
326+ list-style: none;
327+ margin-top: var(--space-sm);
328+}
329+
330+.lang-legend li {
331+ display: flex;
332+ align-items: center;
333+ gap: var(--space-sm);
334+ padding-block: 2px;
335+}
336+
337+.lang-legend i {
338+ width: 8px;
339+ height: 8px;
340+ flex: none;
341+}
342+
343+.lang-legend .label {
344+ flex: 1;
345+}
346+
347+.lang-pct {
348+ font-family: var(--font-mono);
349+ font-size: var(--caption);
350+ color: var(--text-secondary);
351+}
352+
293353 .latest-subject {
294354 margin-top: var(--space-sm);
295355 color: var(--text-primary);
@@ -540,6 +600,20 @@ html:not(.js) .copy-status {
540600 overflow-wrap: anywhere;
541601 }
542602
603+.ref-archives {
604+ display: flex;
605+ gap: var(--space-md);
606+ margin-top: var(--space-xs);
607+}
608+
609+.ref-archives a {
610+ color: var(--text-secondary);
611+}
612+
613+.ref-archives a:hover {
614+ color: var(--text-display);
615+}
616+
543617 .chip {
544618 display: inline-block;
545619 margin-left: var(--space-sm);
@@ -867,6 +941,90 @@ html:not(.js) .copy-status {
867941 border-top: 1px solid var(--border);
868942 }
869943
944+/* ---- Find ----------------------------------------------------------------- */
945+
946+.find-count {
947+ margin-top: var(--space-lg);
948+}
949+
950+.find-list {
951+ margin-top: var(--space-sm);
952+}
953+
954+.find-dir {
955+ color: var(--text-disabled);
956+}
957+
958+/* ---- Blame ---------------------------------------------------------------- */
959+
960+.code.blame .blame-meta {
961+ width: 1%;
962+ max-width: 320px;
963+ padding: 0 var(--space-md) 0 var(--space-sm);
964+ border-left: 2px solid transparent;
965+ font-family: var(--font-mono);
966+ font-size: var(--caption);
967+ line-height: 20px;
968+ color: var(--text-secondary);
969+ white-space: nowrap;
970+ vertical-align: top;
971+ overflow: hidden;
972+ text-overflow: ellipsis;
973+}
974+
975+.code.blame .blame-meta .sha {
976+ color: var(--interactive);
977+}
978+
979+.code.blame .blame-meta time {
980+ color: var(--text-disabled);
981+}
982+
983+.code.blame tr.hunk-start td {
984+ border-top: 1px solid var(--border);
985+}
986+
987+.code.blame tr:first-child td {
988+ border-top: 0;
989+}
990+
991+/* The one break on the blame page: heat by opacity, newest to oldest. */
992+.code.blame .age-8 {
993+ border-left-color: var(--text-display);
994+}
995+
996+.code.blame .age-7 {
997+ border-left-color: color-mix(in srgb, var(--text-display) 90%, transparent);
998+}
999+
1000+.code.blame .age-6 {
1001+ border-left-color: color-mix(in srgb, var(--text-display) 80%, transparent);
1002+}
1003+
1004+.code.blame .age-5 {
1005+ border-left-color: color-mix(in srgb, var(--text-display) 70%, transparent);
1006+}
1007+
1008+.code.blame .age-4 {
1009+ border-left-color: color-mix(in srgb, var(--text-display) 60%, transparent);
1010+}
1011+
1012+.code.blame .age-3 {
1013+ border-left-color: color-mix(in srgb, var(--text-display) 50%, transparent);
1014+}
1015+
1016+.code.blame .age-2 {
1017+ border-left-color: color-mix(in srgb, var(--text-display) 40%, transparent);
1018+}
1019+
1020+.code.blame .age-1 {
1021+ border-left-color: color-mix(in srgb, var(--text-display) 30%, transparent);
1022+}
1023+
1024+.code.blame .age-0 {
1025+ border-left-color: color-mix(in srgb, var(--text-display) 20%, transparent);
1026+}
1027+
8701028 /* ---- Markdown ------------------------------------------------------------ */
8711029
8721030 .markdown {
M src/cache.rs View
@@ -6,10 +6,12 @@
66
77 use std::sync::Arc;
88
9+use crate::git::blame::BlameHunk;
910 use crate::git::log::{LogPage, LogQuery};
1011 use crate::git::refs::RefList;
1112 use crate::git::tree::TreeEntry;
1213 use crate::git::{Mailmap, ObjectId};
14+use crate::index::languages::Stats as LanguageStats;
1315 use crate::render::diff::{CommitDiff, Options as DiffOptions};
1416
1517 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -39,6 +41,14 @@ pub enum Key {
3941 options: DiffOptions,
4042 },
4143 Mailmap(ObjectId),
44+ Blame {
45+ commit: ObjectId,
46+ path: Vec<u8>,
47+ },
48+ /// Every file path in a tree (the file finder).
49+ Paths(ObjectId),
50+ /// Language statistics of a tree.
51+ Languages(ObjectId),
4252 }
4353
4454 #[derive(Clone)]
@@ -51,6 +61,9 @@ pub enum Value {
5161 Log(Arc<LogPage>),
5262 Diff(Arc<CommitDiff>),
5363 Mailmap(Arc<Mailmap>),
64+ Blame(Arc<Vec<BlameHunk>>),
65+ Paths(Arc<(Vec<String>, bool)>),
66+ Languages(Arc<LanguageStats>),
5467 }
5568
5669 impl Value {
@@ -64,6 +77,12 @@ impl Value {
6477 Value::Log(page) => page.weight(),
6578 Value::Diff(diff) => diff.weight(),
6679 Value::Mailmap(map) => map.weight(),
80+ Value::Languages(stats) => stats.weight(),
81+ Value::Paths(paths) => paths.0.iter().map(|p| p.len() + 24).sum(),
82+ Value::Blame(hunks) => hunks
83+ .iter()
84+ .map(|h| 48 + h.source_path.as_ref().map_or(0, String::len))
85+ .sum(),
6786 }
6887 }
6988 }
@@ -81,6 +100,7 @@ impl Cache {
81100 Key::Highlight { syntax, .. } => syntax.len(),
82101 Key::Markdown { context, .. } => context.len(),
83102 Key::Refs { repo, .. } => repo.len(),
103+ Key::Blame { path, .. } => path.len(),
84104 Key::Log { query, .. } => {
85105 query.path.len() + query.grep.as_ref().map_or(0, String::len)
86106 }
@@ -191,6 +211,51 @@ impl Cache {
191211 }
192212 }
193213
214+ pub fn blame<E>(
215+ &self,
216+ key: Key,
217+ compute: impl FnOnce() -> Result<Vec<BlameHunk>, E>,
218+ ) -> Result<Arc<Vec<BlameHunk>>, E> {
219+ match self.get_or(key, || compute().map(|v| Value::Blame(Arc::new(v))))? {
220+ Value::Blame(v) => Ok(v),
221+ _ => unreachable!("cache key/value kind mismatch"),
222+ }
223+ }
224+
225+ pub fn paths<E>(
226+ &self,
227+ tree: ObjectId,
228+ compute: impl FnOnce() -> Result<(Vec<String>, bool), E>,
229+ ) -> Result<Arc<(Vec<String>, bool)>, E> {
230+ match self.get_or(Key::Paths(tree), || {
231+ compute().map(|v| Value::Paths(Arc::new(v)))
232+ })? {
233+ Value::Paths(v) => Ok(v),
234+ _ => unreachable!("cache key/value kind mismatch"),
235+ }
236+ }
237+
238+ pub fn languages<E>(
239+ &self,
240+ tree: ObjectId,
241+ compute: impl FnOnce() -> Result<LanguageStats, E>,
242+ ) -> Result<Arc<LanguageStats>, E> {
243+ match self.get_or(Key::Languages(tree), || {
244+ compute().map(|v| Value::Languages(Arc::new(v)))
245+ })? {
246+ Value::Languages(v) => Ok(v),
247+ _ => unreachable!("cache key/value kind mismatch"),
248+ }
249+ }
250+
251+ /// Language statistics if they are cached; never computes.
252+ pub fn peek_languages(&self, tree: ObjectId) -> Option<Arc<LanguageStats>> {
253+ match self.inner.get(&Key::Languages(tree))? {
254+ Value::Languages(v) => Some(v),
255+ _ => None,
256+ }
257+ }
258+
194259 pub fn entry_count(&self) -> u64 {
195260 self.inner.run_pending_tasks();
196261 self.inner.entry_count()
A src/git/archive.rs View
@@ -0,0 +1,97 @@
1+//! Snapshots of a commit's tree as `.tar.gz` or `.zip`.
2+
3+use std::io::{self, Seek, SeekFrom, Write};
4+use std::sync::atomic::AtomicBool;
5+
6+use anyhow::Context;
7+
8+use super::ObjectId;
9+
10+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11+pub enum Format {
12+ TarGz,
13+ Zip,
14+}
15+
16+impl Format {
17+ pub fn extension(self) -> &'static str {
18+ match self {
19+ Format::TarGz => "tar.gz",
20+ Format::Zip => "zip",
21+ }
22+ }
23+
24+ pub fn content_type(self) -> &'static str {
25+ match self {
26+ Format::TarGz => "application/gzip",
27+ Format::Zip => "application/zip",
28+ }
29+ }
30+}
31+
32+/// Write the tree of `commit` to `out`, every path under `prefix` (which
33+/// should end in `/`), with the commit's time as every file's mtime.
34+pub fn write(
35+ repo: &gix::Repository,
36+ commit: ObjectId,
37+ format: Format,
38+ prefix: &str,
39+ out: impl Write,
40+) -> anyhow::Result<()> {
41+ let commit = repo.find_commit(commit).context("commit not found")?;
42+ let tree = commit.tree_id().context("commit has no tree")?.detach();
43+ let mtime = commit.time().context("malformed commit")?.seconds;
44+ let (stream, _index) = repo.worktree_stream(tree).context("cannot read the tree")?;
45+ let options = gix::worktree::archive::Options {
46+ format: match format {
47+ Format::TarGz => gix::worktree::archive::Format::TarGz {
48+ compression_level: Some(6),
49+ },
50+ Format::Zip => gix::worktree::archive::Format::Zip {
51+ compression_level: Some(6),
52+ },
53+ },
54+ tree_prefix: Some(prefix.into()),
55+ modification_time: mtime,
56+ };
57+ repo.worktree_archive(
58+ stream,
59+ Positioned { inner: out, pos: 0 },
60+ gix::progress::Discard,
61+ &AtomicBool::new(false),
62+ options,
63+ )
64+ .context("cannot write archive")?;
65+ Ok(())
66+}
67+
68+/// A writer that can report its position but not move. The zip writer
69+/// only asks where it is, so archives stream without a temporary file.
70+struct Positioned<W> {
71+ inner: W,
72+ pos: u64,
73+}
74+
75+impl<W: Write> Write for Positioned<W> {
76+ fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
77+ let n = self.inner.write(buf)?;
78+ self.pos += n as u64;
79+ Ok(n)
80+ }
81+
82+ fn flush(&mut self) -> io::Result<()> {
83+ self.inner.flush()
84+ }
85+}
86+
87+impl<W> Seek for Positioned<W> {
88+ fn seek(&mut self, to: SeekFrom) -> io::Result<u64> {
89+ match to {
90+ SeekFrom::Current(0) => Ok(self.pos),
91+ _ => Err(io::Error::new(
92+ io::ErrorKind::Unsupported,
93+ "archive output is a stream",
94+ )),
95+ }
96+ }
97+}
A src/git/blame.rs View
@@ -0,0 +1,67 @@
1+//! Line attribution: which commit last touched each line of a file.
2+
3+use anyhow::Context;
4+use gix::bstr::{BStr, ByteSlice};
5+
6+use super::ObjectId;
7+
8+/// A run of consecutive lines last changed by the same commit.
9+#[derive(Debug, Clone, PartialEq, Eq)]
10+pub struct BlameHunk {
11+ /// 0-based first line in the blamed file, and the number of lines.
12+ pub start: u32,
13+ pub len: u32,
14+ pub commit: ObjectId,
15+ /// 0-based first line in that commit's version of the file.
16+ pub source_start: u32,
17+ /// The file's path in that commit, when it was renamed since.
18+ pub source_path: Option<String>,
19+}
20+
21+/// Blame `path` as of `commit`, following renames like `git blame`.
22+/// Hunks are in file order and cover every line.
23+pub fn blame(
24+ repo: &gix::Repository,
25+ commit: ObjectId,
26+ path: &[u8],
27+) -> anyhow::Result<Vec<BlameHunk>> {
28+ let options = gix::repository::blame_file::Options {
29+ diff_algorithm: Some(gix::diff::blob::Algorithm::Myers),
30+ rewrites: Some(Default::default()),
31+ ..Default::default()
32+ };
33+ let outcome = repo
34+ .blame_file(BStr::new(path), commit, options)
35+ .context("blame failed")?;
36+ let mut hunks: Vec<BlameHunk> = outcome
37+ .entries
38+ .iter()
39+ .map(|e| BlameHunk {
40+ start: e.start_in_blamed_file,
41+ len: e.len.get(),
42+ commit: e.commit_id,
43+ source_start: e.start_in_source_file,
44+ source_path: e
45+ .source_file_name
46+ .as_ref()
47+ .map(|p| p.to_str_lossy().into_owned()),
48+ })
49+ .collect();
50+ hunks.sort_by_key(|h| h.start);
51+ // Adjacent entries from the same commit read as one hunk.
52+ let mut merged: Vec<BlameHunk> = Vec::with_capacity(hunks.len());
53+ for hunk in hunks {
54+ match merged.last_mut() {
55+ Some(last)
56+ if last.commit == hunk.commit
57+ && last.start + last.len == hunk.start
58+ && last.source_start + last.len == hunk.source_start
59+ && last.source_path == hunk.source_path =>
60+ {
61+ last.len += hunk.len;
62+ }
63+ _ => merged.push(hunk),
64+ }
65+ }
66+ Ok(merged)
67+}
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 archive;
4+pub mod blame;
35 pub mod diff;
46 pub mod log;
57 pub mod refs;
M src/git/tree.rs View
@@ -135,6 +135,69 @@ pub fn list(repo: &gix::Repository, tree: ObjectId) -> anyhow::Result<Vec<TreeEn
135135 Ok(entries)
136136 }
137137
138+/// Every file path under `tree` (blobs, symlinks and submodules), in
139+/// tree order, up to `limit` paths. The flag reports truncation.
140+pub fn all_paths(
141+ repo: &gix::Repository,
142+ tree: ObjectId,
143+ limit: usize,
144+) -> anyhow::Result<(Vec<String>, bool)> {
145+ let mut out = Vec::new();
146+ let mut stack = vec![(tree, String::new())];
147+ while let Some((id, prefix)) = stack.pop() {
148+ let tree = repo.find_tree(id).context("tree not found")?;
149+ let mut subtrees = Vec::new();
150+ for entry in tree.iter() {
151+ let entry = entry?;
152+ let path = format!("{prefix}{}", entry.filename().to_str_lossy());
153+ if entry.mode().is_tree() {
154+ subtrees.push((entry.oid().to_owned(), format!("{path}/")));
155+ } else {
156+ if out.len() == limit {
157+ return Ok((out, true));
158+ }
159+ out.push(path);
160+ }
161+ }
162+ // Depth first, in name order.
163+ stack.extend(subtrees.into_iter().rev());
164+ }
165+ Ok((out, false))
166+}
167+
168+/// Walk the files under `tree` depth-first. `enter` gets each directory
169+/// path (with a trailing `/`) and decides whether to descend; `file` gets
170+/// every blob and symlink with its size, read from the object header.
171+pub fn walk(
172+ repo: &gix::Repository,
173+ tree: ObjectId,
174+ enter: &mut dyn FnMut(&str) -> bool,
175+ file: &mut dyn FnMut(&str, EntryKind, u64),
176+) -> anyhow::Result<()> {
177+ let mut stack = vec![(tree, String::new())];
178+ while let Some((id, prefix)) = stack.pop() {
179+ let tree = repo.find_tree(id).context("tree not found")?;
180+ for entry in tree.iter() {
181+ let entry = entry?;
182+ let path = format!("{prefix}{}", entry.filename().to_str_lossy());
183+ match EntryKind::from_gix(entry.mode().kind()) {
184+ EntryKind::Tree => {
185+ let dir = format!("{path}/");
186+ if enter(&dir) {
187+ stack.push((entry.oid().to_owned(), dir));
188+ }
189+ }
190+ EntryKind::Submodule => {}
191+ kind => {
192+ let size = repo.find_header(entry.oid().to_owned())?.size();
193+ file(&path, kind, size);
194+ }
195+ }
196+ }
197+ }
198+ Ok(())
199+}
200+
138201 /// Full contents of a blob.
139202 pub fn blob(repo: &gix::Repository, oid: ObjectId) -> anyhow::Result<Vec<u8>> {
140203 let mut blob = repo.find_blob(oid).context("blob not found")?;
A src/handlers/archive.rs View
@@ -0,0 +1,108 @@
1+//! `/{repo}/archive/{rev}.tar.gz|.zip` — a snapshot of a revision,
2+//! streamed while it is being written.
3+
4+use axum::body::Body;
5+use axum::http::{HeaderValue, StatusCode, header};
6+use axum::response::{IntoResponse, Response};
7+use tokio_util::io::{ReaderStream, SyncIoBridge};
8+
9+use super::repo::{Ctx, set_cache_headers};
10+use crate::config::SnapshotFormat;
11+use crate::git::archive::{self, Format};
12+use crate::git::refs;
13+use crate::render::encode_path;
14+
15+const PIPE_BYTES: usize = 64 * 1024;
16+
17+pub fn serve(ctx: &Ctx, segments: &[Vec<u8>]) -> Response {
18+ let Ok(name) = String::from_utf8(segments.join(&b'/')) else {
19+ return ctx.not_found("UNKNOWN REVISION");
20+ };
21+ let (rev_name, format) = if let Some(stem) = name.strip_suffix(".tar.gz") {
22+ (stem, Format::TarGz)
23+ } else if let Some(stem) = name.strip_suffix(".zip") {
24+ (stem, Format::Zip)
25+ } else {
26+ return ctx.not_found("UNKNOWN ARCHIVE FORMAT");
27+ };
28+ let wanted = match format {
29+ Format::TarGz => SnapshotFormat::TarGz,
30+ Format::Zip => SnapshotFormat::Zip,
31+ };
32+ if !ctx.config.repos.snapshots.contains(&wanted) {
33+ return ctx.not_found("ARCHIVE FORMAT DISABLED");
34+ }
35+ let Some(rev) = refs::resolve(&ctx.git, rev_name) else {
36+ return ctx.not_found("UNKNOWN REVISION");
37+ };
38+ let etag = format!("W/\"{}-{}\"", rev.commit, format.extension());
39+ let immutable = rev.is_immutable();
40+ if let Some(res) = ctx.not_modified(&etag, immutable) {
41+ return res;
42+ }
43+
44+ let base = archive_name(&ctx.repo.name, rev_name);
45+ let prefix = format!("{base}/");
46+ let (reader, writer) = tokio::io::duplex(PIPE_BYTES);
47+ let gix = ctx.repo.gix.clone();
48+ let permit = ctx.take_heavy_permit();
49+ let repo_name = ctx.repo.name.clone();
50+ let commit = rev.commit;
51+ tokio::task::spawn_blocking(move || {
52+ let _permit = permit;
53+ let repo = crate::git::handle(&gix);
54+ let out = std::io::BufWriter::with_capacity(PIPE_BYTES, SyncIoBridge::new(writer));
55+ if let Err(e) = archive::write(&repo, commit, format, &prefix, out) {
56+ // Usually the client went away.
57+ tracing::debug!("{repo_name}: archive of {commit} stopped: {e:#}");
58+ }
59+ });
60+
61+ let mut res = Body::from_stream(ReaderStream::new(reader)).into_response();
62+ *res.status_mut() = StatusCode::OK;
63+ let headers = res.headers_mut();
64+ headers.insert(
65+ header::CONTENT_TYPE,
66+ HeaderValue::from_static(format.content_type()),
67+ );
68+ let file = format!("{base}.{}", format.extension());
69+ let disposition = format!(
70+ "attachment; filename=\"{file}\"; filename*=UTF-8''{}",
71+ encode_path(&file)
72+ );
73+ if let Ok(value) = HeaderValue::from_str(&disposition) {
74+ headers.insert(header::CONTENT_DISPOSITION, value);
75+ }
76+ set_cache_headers(&mut res, &etag, immutable);
77+ res
78+}
79+
80+/// `{repo}-{rev}`, using the last component of the repository name and
81+/// a revision name made filename-safe (`feature/x` → `feature-x`).
82+pub fn archive_name(repo: &str, rev: &str) -> String {
83+ let repo = repo.rsplit('/').next().unwrap_or(repo);
84+ let clean = |s: &str| -> String {
85+ s.chars()
86+ .map(|c| {
87+ if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
88+ c
89+ } else {
90+ '-'
91+ }
92+ })
93+ .collect()
94+ };
95+ format!("{}-{}", clean(repo), clean(rev))
96+}
97+
98+#[cfg(test)]
99+mod tests {
100+ use super::archive_name;
101+
102+ #[test]
103+ fn names() {
104+ assert_eq!(archive_name("forks/foo", "v1.0"), "foo-v1.0");
105+ assert_eq!(archive_name("foo", "feature/x"), "foo-feature-x");
106+ assert_eq!(archive_name("a b", "\"x\""), "a-b--x-");
107+ }
108+}
A src/handlers/blame.rs View
@@ -0,0 +1,211 @@
1+//! `/{repo}/blame/{rev}/{path…}` — each line with the commit that last
2+//! changed it. Hunks carry an age bar: newest at full strength, oldest
3+//! faint.
4+
5+use std::collections::HashMap;
6+use std::sync::Arc;
7+
8+use askama::Template;
9+use axum::http::StatusCode;
10+use axum::response::Response;
11+use gix::bstr::ByteSlice;
12+
13+use super::repo::{Active, Crumb, Ctx, RepoNav, crumbs, redirect, set_cache_headers, short};
14+use super::{Layout, render};
15+use crate::cache::Key;
16+use crate::git::tree::{self, PathTarget};
17+use crate::git::{self, CommitInfo, ObjectId};
18+use crate::render::{highlight, human_size, time};
19+
20+/// Opacity steps of the age bar (`age-0` oldest … `age-8` newest).
21+const AGE_STEPS: i64 = 8;
22+
23+pub struct HunkHead {
24+ pub id: String,
25+ pub url: String,
26+ pub subject: String,
27+ pub author: String,
28+ pub age: String,
29+ pub iso: String,
30+ pub age_class: String,
31+ pub rows: u32,
32+}
33+
34+pub struct BlameRow {
35+ pub n: u32,
36+ pub html: String,
37+ /// Set on the first line of each hunk.
38+ pub head: Option<HunkHead>,
39+}
40+
41+pub enum Body {
42+ Lines(Vec<BlameRow>),
43+ Binary,
44+ TooLarge,
45+ Empty,
46+}
47+
48+#[derive(Template)]
49+#[template(path = "blame.html")]
50+pub struct BlamePage {
51+ pub layout: Layout,
52+ pub nav: RepoNav,
53+ pub crumbs: Vec<Crumb>,
54+ pub here: Option<String>,
55+ pub facts: Vec<String>,
56+ pub blob_url: String,
57+ pub log_url: String,
58+ pub body: Body,
59+}
60+
61+pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response {
62+ let (rev, path) = match ctx.resolve(segments) {
63+ Ok(found) => found,
64+ Err(res) => return *res,
65+ };
66+ let oid = match tree::lookup(&ctx.git, rev.commit, &path) {
67+ Ok(Some(PathTarget::Blob(oid, _))) => oid,
68+ Ok(Some(PathTarget::Tree(_))) => return redirect(&ctx.url("tree", &rev.name, &path)),
69+ Ok(Some(PathTarget::Submodule(_))) => return ctx.not_found("PATH IS A SUBMODULE"),
70+ Ok(None) => return ctx.not_found(&format!("PATH NOT FOUND IN {}", rev.name)),
71+ Err(e) => return ctx.internal_error(e),
72+ };
73+ let etag = ctx.etag(("blame", rev.commit, &path));
74+ let immutable = rev.is_immutable();
75+ if let Some(res) = ctx.not_modified(&etag, immutable) {
76+ return res;
77+ }
78+ let size = match tree::size(&ctx.git, oid) {
79+ Ok(size) => size,
80+ Err(e) => return ctx.internal_error(e),
81+ };
82+ let path_str = path.to_str_lossy().into_owned();
83+ let mut facts = vec![human_size(size)];
84+
85+ let body = if size == 0 {
86+ Body::Empty
87+ } else if size > ctx.config.ui.max_blob_kb * 1024 {
88+ Body::TooLarge
89+ } else {
90+ let data = match tree::blob(&ctx.git, oid) {
91+ Ok(data) => data,
92+ Err(e) => return ctx.internal_error(e),
93+ };
94+ if data[..data.len().min(8000)].contains(&0) {
95+ Body::Binary
96+ } else {
97+ match lines(ctx, rev.commit, &path, oid, &path_str, &data) {
98+ Ok((rows, commits)) => {
99+ facts.insert(0, format!("{commits} {}", plural(commits, "commit")));
100+ facts.insert(0, format!("{} {}", rows.len(), plural(rows.len(), "line")));
101+ Body::Lines(rows)
102+ }
103+ Err(e) => return ctx.internal_error(e),
104+ }
105+ }
106+ };
107+
108+ let (crumbs, here) = crumbs(ctx, &rev.name, &path);
109+ let page = BlamePage {
110+ layout: Layout::new(
111+ ctx.state,
112+ ctx.config,
113+ Some(&format!("Blame {path_str} · {}", ctx.repo.name)),
114+ ),
115+ nav: ctx.nav(Active::Tree, Some(&rev), |name| {
116+ ctx.url("blame", name, &path)
117+ }),
118+ crumbs,
119+ here,
120+ facts,
121+ blob_url: ctx.url("blob", &rev.name, &path),
122+ log_url: ctx.url("log", &rev.name, &path),
123+ body,
124+ };
125+ let mut res = render(StatusCode::OK, &page);
126+ set_cache_headers(&mut res, &etag, immutable);
127+ res
128+}
129+
130+fn plural(n: usize, word: &str) -> String {
131+ if n == 1 {
132+ word.to_string()
133+ } else {
134+ format!("{word}s")
135+ }
136+}
137+
138+/// Rows for every line, plus the number of distinct commits.
139+fn lines(
140+ ctx: &Ctx,
141+ commit: ObjectId,
142+ path: &[u8],
143+ oid: ObjectId,
144+ path_str: &str,
145+ data: &[u8],
146+) -> anyhow::Result<(Vec<BlameRow>, usize)> {
147+ let key = Key::Blame {
148+ commit,
149+ path: path.to_vec(),
150+ };
151+ let hunks = ctx
152+ .state
153+ .cache
154+ .blame(key, || git::blame::blame(&ctx.git, commit, path))?;
155+ let text = String::from_utf8_lossy(data);
156+ let (html, _) = highlight::cached_lines(
157+ &ctx.state.cache,
158+ oid,
159+ path_str,
160+ &text,
161+ ctx.config.ui.max_highlight_kb * 1024,
162+ );
163+
164+ let mut infos: HashMap<ObjectId, CommitInfo> = HashMap::new();
165+ for hunk in hunks.iter() {
166+ if let std::collections::hash_map::Entry::Vacant(slot) = infos.entry(hunk.commit) {
167+ slot.insert(git::commit_info(&ctx.git, hunk.commit)?);
168+ }
169+ }
170+ let times = infos.values().map(|i| i.committer_time);
171+ let (oldest, newest) = (times.clone().min().unwrap_or(0), times.max().unwrap_or(0));
172+ let mailmap = ctx.mailmap();
173+ let now = time::now();
174+
175+ let mut heads: HashMap<u32, HunkHead> = HashMap::new();
176+ for hunk in hunks.iter() {
177+ let info = &infos[&hunk.commit];
178+ let step = if newest > oldest {
179+ (info.committer_time - oldest) * AGE_STEPS / (newest - oldest)
180+ } else {
181+ AGE_STEPS
182+ };
183+ let id = info.id.to_string();
184+ heads.insert(
185+ hunk.start,
186+ HunkHead {
187+ url: ctx.commit_url(&info.id),
188+ id: short(&id),
189+ subject: info.subject.clone(),
190+ author: mailmap.resolve(&info.author_name, &info.author_email).0,
191+ age: time::relative(info.committer_time, now),
192+ iso: time::iso(info.committer_time),
193+ age_class: format!("age-{step}"),
194+ rows: hunk.len,
195+ },
196+ );
197+ }
198+ let rows = rows(&html, heads);
199+ Ok((rows, infos.len()))
200+}
201+
202+fn rows(html: &Arc<Vec<String>>, mut heads: HashMap<u32, HunkHead>) -> Vec<BlameRow> {
203+ html.iter()
204+ .enumerate()
205+ .map(|(i, line)| BlameRow {
206+ n: i as u32 + 1,
207+ html: line.clone(),
208+ head: heads.remove(&(i as u32)),
209+ })
210+ .collect()
211+}
M src/handlers/blob.rs View
@@ -45,6 +45,7 @@ pub struct BlobPage {
4545 pub facts: Vec<String>,
4646 pub raw_url: String,
4747 pub log_url: String,
48+ pub blame_url: String,
4849 pub download_url: String,
4950 pub toggle: Option<ViewToggle>,
5051 pub body: Body,
@@ -165,6 +166,7 @@ pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response {
165166 facts,
166167 download_url: format!("{raw_url}?download"),
167168 log_url: ctx.url("log", &rev.name, &path),
169+ blame_url: ctx.url("blame", &rev.name, &path),
168170 raw_url,
169171 toggle,
170172 body,
A src/handlers/feed.rs View
@@ -0,0 +1,127 @@
1+//! `/{repo}/atom` — Atom feed of the newest commits on the default branch
2+//! (`?h=branch` for another one).
3+
4+use askama::Template;
5+use axum::http::{HeaderValue, StatusCode, header};
6+use axum::response::Response;
7+
8+use super::render;
9+use super::repo::{Ctx, set_cache_headers};
10+use crate::cache::Key;
11+use crate::git::log::{self, LogQuery};
12+use crate::git::refs::{RefKind, Rev};
13+use crate::git::{self, refs};
14+use crate::render::time;
15+
16+const ENTRIES: usize = 20;
17+
18+pub struct Entry {
19+ pub title: String,
20+ pub url: String,
21+ pub updated: String,
22+ pub published: String,
23+ pub author: String,
24+ pub email: String,
25+ pub message: String,
26+}
27+
28+#[derive(Template)]
29+#[template(path = "atom.xml")]
30+pub struct Feed {
31+ pub title: String,
32+ pub subtitle: Option<String>,
33+ pub self_url: String,
34+ pub html_url: String,
35+ pub updated: String,
36+ pub version: &'static str,
37+ pub entries: Vec<Entry>,
38+}
39+
40+pub fn serve(ctx: &Ctx) -> Response {
41+ let rev = match ctx.query.h.as_deref().filter(|h| !h.is_empty()) {
42+ Some(branch) => match refs::resolve(&ctx.git, branch) {
43+ Some(rev) if rev.kind == RefKind::Branch => rev,
44+ _ => return ctx.not_found("UNKNOWN BRANCH"),
45+ },
46+ None => {
47+ let meta = &ctx.repo.meta;
48+ match (&meta.default_branch, meta.head) {
49+ (Some(name), Some(commit)) => Rev {
50+ name: name.clone(),
51+ kind: RefKind::Branch,
52+ commit,
53+ },
54+ _ => return ctx.not_found("REPOSITORY IS EMPTY"),
55+ }
56+ }
57+ };
58+ let etag = ctx.etag(("atom", rev.commit));
59+ if let Some(res) = ctx.not_modified(&etag, false) {
60+ return res;
61+ }
62+ let query = LogQuery {
63+ page: ENTRIES,
64+ scan_limit: ctx.config.limits.log_scan,
65+ ..Default::default()
66+ };
67+ let key = Key::Log {
68+ tip: rev.commit,
69+ query: query.clone(),
70+ };
71+ let page = match ctx
72+ .state
73+ .cache
74+ .log(key, || log::page(&ctx.git, rev.commit, &query))
75+ {
76+ Ok(page) => page,
77+ Err(e) => return ctx.internal_error(anyhow::anyhow!("{e}")),
78+ };
79+
80+ let origin = ctx.origin();
81+ let mailmap = ctx.mailmap();
82+ let mut entries = Vec::with_capacity(page.commits.len());
83+ for commit in &page.commits {
84+ let detail = match git::commit_detail(&ctx.git, commit.id) {
85+ Ok(detail) => detail,
86+ Err(e) => return ctx.internal_error(e),
87+ };
88+ let (author, email) = mailmap.resolve(&detail.author.name, &detail.author.email);
89+ entries.push(Entry {
90+ title: commit.subject.clone(),
91+ url: format!("{origin}{}", ctx.commit_url(&commit.id)),
92+ updated: time::iso(detail.committer.time),
93+ published: time::iso(detail.author.time),
94+ author,
95+ email,
96+ message: detail.message.trim_end().to_string(),
97+ });
98+ }
99+ let self_url = match ctx.query.h.as_deref().filter(|h| !h.is_empty()) {
100+ Some(_) => format!(
101+ "{origin}{}/atom?h={}",
102+ ctx.repo_url(),
103+ crate::render::encode_path(&rev.name)
104+ ),
105+ None => format!("{origin}{}/atom", ctx.repo_url()),
106+ };
107+ let feed = Feed {
108+ title: format!("{}: {}", ctx.repo.name, rev.name),
109+ subtitle: ctx.repo.meta.description.clone(),
110+ html_url: format!("{origin}{}", ctx.url("log", &rev.name, b"")),
111+ self_url,
112+ updated: page
113+ .commits
114+ .first()
115+ .map(|c| time::iso(c.committer_time))
116+ .unwrap_or_default(),
117+ version: env!("CARGO_PKG_VERSION"),
118+ entries,
119+ };
120+ let mut res = render(StatusCode::OK, &feed);
121+ res.headers_mut().insert(
122+ header::CONTENT_TYPE,
123+ HeaderValue::from_static("application/atom+xml; charset=utf-8"),
124+ );
125+ set_cache_headers(&mut res, &etag, false);
126+ res
127+}
A src/handlers/find.rs View
@@ -0,0 +1,170 @@
1+//! `/{repo}/find/{rev}?q=` — find files by path.
2+//!
3+//! Every term must appear in the path (case-insensitively); matches in the
4+//! file name rank first, then shorter paths.
5+
6+use askama::Template;
7+use axum::http::StatusCode;
8+use axum::response::Response;
9+
10+use super::repo::{Active, Crumb, Ctx, RepoNav, set_cache_headers};
11+use super::{Layout, render};
12+use crate::git::tree::{self, PathTarget};
13+
14+/// Paths indexed per tree; larger trees are searched partially.
15+const MAX_PATHS: usize = 200_000;
16+const MAX_RESULTS: usize = 100;
17+
18+pub struct Hit {
19+ pub dir: String,
20+ pub name: String,
21+ pub url: String,
22+}
23+
24+#[derive(Template)]
25+#[template(path = "find.html")]
26+pub struct FindPage {
27+ pub layout: Layout,
28+ pub nav: RepoNav,
29+ pub crumbs: Vec<Crumb>,
30+ pub here: Option<String>,
31+ pub action: String,
32+ pub q: String,
33+ pub hits: Vec<Hit>,
34+ pub matched: usize,
35+ pub total: usize,
36+ pub truncated: bool,
37+}
38+
39+pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response {
40+ let (rev, path) = match ctx.resolve(segments) {
41+ Ok(found) => found,
42+ Err(res) => return *res,
43+ };
44+ if !path.is_empty() {
45+ return ctx.not_found("PAGE NOT FOUND");
46+ }
47+ let q = ctx
48+ .query
49+ .q
50+ .as_deref()
51+ .unwrap_or_default()
52+ .trim()
53+ .to_string();
54+ let etag = ctx.etag(("find", rev.commit, &q));
55+ let immutable = rev.is_immutable();
56+ if let Some(res) = ctx.not_modified(&etag, immutable) {
57+ return res;
58+ }
59+ let root = match tree::lookup(&ctx.git, rev.commit, b"") {
60+ Ok(Some(PathTarget::Tree(id))) => id,
61+ Ok(_) => return ctx.not_found("COMMIT HAS NO TREE"),
62+ Err(e) => return ctx.internal_error(e),
63+ };
64+ let paths = match ctx
65+ .state
66+ .cache
67+ .paths(root, || tree::all_paths(&ctx.git, root, MAX_PATHS))
68+ {
69+ Ok(paths) => paths,
70+ Err(e) => return ctx.internal_error(e),
71+ };
72+ let (all, truncated) = (&paths.0, paths.1);
73+ let ranked = search(all, &q);
74+ let hits = ranked
75+ .iter()
76+ .take(MAX_RESULTS)
77+ .map(|&i| {
78+ let path = &all[i];
79+ let (dir, name) = match path.rsplit_once('/') {
80+ Some((dir, name)) => (format!("{dir}/"), name.to_string()),
81+ None => (String::new(), path.clone()),
82+ };
83+ Hit {
84+ dir,
85+ name,
86+ url: ctx.url("blob", &rev.name, path.as_bytes()),
87+ }
88+ })
89+ .collect();
90+
91+ let page = FindPage {
92+ layout: Layout::new(
93+ ctx.state,
94+ ctx.config,
95+ Some(&format!("Find · {}", ctx.repo.name)),
96+ ),
97+ nav: ctx.nav(Active::Find, Some(&rev), |name| ctx.url("find", name, b"")),
98+ crumbs: vec![Crumb {
99+ name: ctx.repo.name.clone(),
100+ url: ctx.url("tree", &rev.name, b""),
101+ }],
102+ here: Some("Find".into()),
103+ action: ctx.url("find", &rev.name, b""),
104+ hits,
105+ matched: ranked.len(),
106+ total: all.len(),
107+ truncated,
108+ q,
109+ };
110+ let mut res = render(StatusCode::OK, &page);
111+ set_cache_headers(&mut res, &etag, immutable);
112+ res
113+}
114+
115+/// Indexes of the paths matching every term of `q`, best first.
116+pub fn search(paths: &[String], q: &str) -> Vec<usize> {
117+ let terms: Vec<String> = q.split_whitespace().map(str::to_lowercase).collect();
118+ if terms.is_empty() {
119+ return Vec::new();
120+ }
121+ let mut hits: Vec<(bool, usize, usize)> = paths
122+ .iter()
123+ .enumerate()
124+ .filter_map(|(i, path)| {
125+ let lower = path.to_lowercase();
126+ if !terms.iter().all(|t| lower.contains(t.as_str())) {
127+ return None;
128+ }
129+ let name = lower.rsplit('/').next().unwrap_or(&lower);
130+ let in_name = terms.iter().any(|t| name.contains(t.as_str()));
131+ Some((!in_name, path.len(), i))
132+ })
133+ .collect();
134+ hits.sort();
135+ hits.into_iter().map(|(_, _, i)| i).collect()
136+}
137+
138+#[cfg(test)]
139+mod tests {
140+ use super::search;
141+
142+ #[test]
143+ fn ranking() {
144+ let paths: Vec<String> = [
145+ "src/main.rs",
146+ "main/readme.md",
147+ "src/very/deep/main.rs",
148+ "docs/Main.md",
149+ "src/lib.rs",
150+ ]
151+ .iter()
152+ .map(|s| s.to_string())
153+ .collect();
154+ let hits: Vec<&str> = search(&paths, "MAIN")
155+ .into_iter()
156+ .map(|i| paths[i].as_str())
157+ .collect();
158+ assert_eq!(
159+ hits,
160+ [
161+ "src/main.rs",
162+ "docs/Main.md",
163+ "src/very/deep/main.rs",
164+ "main/readme.md"
165+ ]
166+ );
167+ assert_eq!(search(&paths, "src rs").len(), 3);
168+ assert!(search(&paths, " ").is_empty());
169+ }
170+}
M src/handlers/index.rs View
@@ -31,6 +31,8 @@ pub struct Row {
3131 pub age: Option<String>,
3232 pub iso: Option<String>,
3333 pub fresh: bool,
34+ /// Main language, once the background statistics have it.
35+ pub language: Option<&'static str>,
3436 /// Lowercased text the client-side filter matches against.
3537 pub search: String,
3638 }
@@ -104,6 +106,7 @@ pub async fn index(
104106 .meta
105107 .last_activity
106108 .is_some_and(|t| now - t < FRESH_SECS),
109+ language: state.warmer.primary(&repo.name),
107110 search: search_text(repo),
108111 };
109112
M src/handlers/log.rs View
@@ -147,7 +147,10 @@ pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response {
147147 };
148148 let older_url = found.older.map(|id| link(Some(id)));
149149 let page = LogPage {
150− layout: Layout::new(ctx.state, ctx.config, Some(&title)),
150+ layout: Layout {
151+ feed: Some(ctx.feed_url()),
152+ ..Layout::new(ctx.state, ctx.config, Some(&title))
153+ },
151154 nav: ctx.nav(Active::Log, Some(&rev), |name| ctx.url("log", name, &path)),
152155 crumbs,
153156 here: last,
M src/handlers/mod.rs View
@@ -1,8 +1,12 @@
11 //! Request handlers. Each page is an askama template that embeds a [`Layout`].
22
3+pub mod archive;
4+pub mod blame;
35 pub mod blob;
46 pub mod commit;
57 pub mod error;
8+pub mod feed;
9+pub mod find;
610 pub mod index;
711 pub mod log;
812 pub mod raw;
@@ -37,6 +41,8 @@ pub struct Layout {
3741 pub header: Option<Arc<str>>,
3842 pub footer: Option<Arc<str>>,
3943 pub version: &'static str,
44+ /// Atom feed advertised in `<head>`.
45+ pub feed: Option<String>,
4046 }
4147
4248 impl Layout {
@@ -57,6 +63,7 @@ impl Layout {
5763 header: config.custom.header.clone(),
5864 footer: config.custom.footer.clone(),
5965 version: env!("CARGO_PKG_VERSION"),
66+ feed: None,
6067 }
6168 }
6269 }
M src/handlers/refs.rs View
@@ -6,7 +6,9 @@ use axum::response::Response;
66
77 use super::repo::{Active, Crumb, Ctx, RepoNav, set_cache_headers};
88 use super::{Layout, render};
9+use crate::config::SnapshotFormat;
910 use crate::git::refs::RefEntry;
11+use crate::render::encode_path;
1012 use crate::render::time;
1113
1214 pub struct RefRow {
@@ -16,6 +18,8 @@ pub struct RefRow {
1618 pub age: String,
1719 pub iso: String,
1820 pub is_default: bool,
21+ /// Snapshot downloads (`tar.gz`, `zip`), on tags.
22+ pub archives: Vec<(&'static str, String)>,
1923 }
2024
2125 #[derive(Template)]
@@ -50,6 +54,7 @@ pub fn page(ctx: &Ctx) -> Response {
5054 age: time::relative(r.info.committer_time, now),
5155 iso: time::iso(r.info.committer_time),
5256 is_default: false,
57+ archives: Vec::new(),
5358 };
5459 let mut branches: Vec<RefRow> = refs.branches.iter().map(row).collect();
5560 // The default branch leads the list.
@@ -75,9 +80,34 @@ pub fn page(ctx: &Ctx) -> Response {
7580 crumbs,
7681 here: Some("Refs".into()),
7782 branches,
78− tags: refs.tags.iter().map(row).collect(),
83+ tags: refs
84+ .tags
85+ .iter()
86+ .map(|r| {
87+ let mut row = row(r);
88+ row.archives = archives(ctx, &r.name);
89+ row
90+ })
91+ .collect(),
7992 };
8093 let mut res = render(StatusCode::OK, &page);
8194 set_cache_headers(&mut res, &etag, false);
8295 res
8396 }
97+
98+/// Links to the enabled snapshot formats of `rev`.
99+fn archives(ctx: &Ctx, rev: &str) -> Vec<(&'static str, String)> {
100+ ctx.config
101+ .repos
102+ .snapshots
103+ .iter()
104+ .map(|format| {
105+ let ext = match format {
106+ SnapshotFormat::TarGz => "tar.gz",
107+ SnapshotFormat::Zip => "zip",
108+ };
109+ let url = format!("{}/archive/{}.{ext}", ctx.repo_url(), encode_path(rev));
110+ (ext, url)
111+ })
112+ .collect()
113+}
M src/handlers/repo.rs View
@@ -3,6 +3,7 @@
33 //!
44 //! Everything here runs on the blocking pool (see [`AppState::blocking`]).
55
6+use std::cell::Cell;
67 use std::hash::{Hash, Hasher};
78 use std::sync::Arc;
89 use std::sync::atomic::Ordering;
@@ -10,8 +11,9 @@ use std::sync::atomic::Ordering;
1011 use axum::http::{HeaderValue, StatusCode, header};
1112 use axum::response::{IntoResponse, Redirect, Response};
1213 use serde::Deserialize;
14+use tokio::sync::OwnedSemaphorePermit;
1315
14−use super::{blob, commit, error, log, raw, refs, summary, tree};
16+use super::{archive, blame, blob, commit, error, feed, find, log, raw, refs, summary, tree};
1517 use crate::cache::Key;
1618 use crate::config::Config;
1719 use crate::git::refs::{RefKind, RefList, Rev};
@@ -37,10 +39,16 @@ pub struct PageQuery {
3739 pub context: Option<String>,
3840 /// Commit: `ignore` compares lines ignoring whitespace.
3941 pub ws: Option<String>,
42+ /// Atom: the branch (default branch when absent).
43+ pub h: Option<String>,
4044 }
4145
4246 pub struct RepoRequest {
4347 pub repo: Arc<Repo>,
48+ /// Held for heavy requests (see `dispatch::is_heavy`).
49+ pub heavy: Option<OwnedSemaphorePermit>,
50+ /// `Host` header, for absolute URLs when `server.base_url` is unset.
51+ pub host: Option<String>,
4452 /// Path segments after the repository name.
4553 pub segments: Vec<Vec<u8>>,
4654 pub query: PageQuery,
@@ -55,11 +63,16 @@ pub struct Ctx<'a> {
5563 pub repo: &'a Repo,
5664 pub git: Handle,
5765 pub query: &'a PageQuery,
66+ pub host: Option<&'a str>,
5867 if_none_match: Option<&'a HeaderValue>,
68+ heavy: Cell<Option<OwnedSemaphorePermit>>,
5969 }
6070
61−pub fn handle(state: &AppState, req: RepoRequest) -> Response {
71+pub fn handle(state: &AppState, mut req: RepoRequest) -> Response {
72+ let heavy = Cell::new(req.heavy.take());
6273 let ctx = Ctx {
74+ heavy,
75+ host: req.host.as_deref(),
6376 state,
6477 config: &req.config,
6578 repo: &req.repo,
@@ -73,15 +86,24 @@ pub fn handle(state: &AppState, req: RepoRequest) -> Response {
7386 match action.as_slice() {
7487 b"tree" => tree::page(&ctx, rest),
7588 b"blob" => blob::page(&ctx, rest),
89+ b"blame" => blame::page(&ctx, rest),
7690 b"raw" => raw::serve(&ctx, rest),
7791 b"log" => log::page(&ctx, rest),
92+ b"archive" => archive::serve(&ctx, rest),
93+ b"find" => find::page(&ctx, rest),
7894 b"commit" => commit::page(&ctx, rest),
7995 b"refs" if rest.is_empty() => refs::page(&ctx),
96+ b"atom" if rest.is_empty() => feed::serve(&ctx),
8097 _ => ctx.not_found("PAGE NOT FOUND"),
8198 }
8299 }
83100
84101 impl Ctx<'_> {
102+ /// The heavy-request permit, for work that outlives the handler.
103+ pub fn take_heavy_permit(&self) -> Option<OwnedSemaphorePermit> {
104+ self.heavy.take()
105+ }
106+
85107 /// `{base}{repo}` without a trailing slash.
86108 pub fn repo_url(&self) -> String {
87109 format!(
@@ -101,6 +123,21 @@ impl Ctx<'_> {
101123 url
102124 }
103125
126+ /// `scheme://host` for absolute URLs: `server.base_url`, else the
127+ /// request's `Host` (over http).
128+ pub fn origin(&self) -> String {
129+ match (&self.config.server.base_url, self.host) {
130+ (Some(url), _) => url.clone(),
131+ (None, Some(host)) => format!("http://{host}"),
132+ (None, None) => "http://localhost".into(),
133+ }
134+ }
135+
136+ /// The repository's Atom feed (relative URL).
137+ pub fn feed_url(&self) -> String {
138+ format!("{}/atom", self.repo_url())
139+ }
140+
104141 pub fn commit_url(&self, id: &ObjectId) -> String {
105142 format!("{}/commit/{id}", self.repo_url())
106143 }
@@ -203,14 +240,16 @@ impl Ctx<'_> {
203240 current: Option<&Rev>,
204241 picker: impl Fn(&str) -> String,
205242 ) -> RepoNav {
206− let (tree_url, log_url) = match current {
243+ let (tree_url, log_url, find_url) = match current {
207244 Some(rev) => (
208245 self.url("tree", &rev.name, b""),
209246 self.url("log", &rev.name, b""),
247+ self.url("find", &rev.name, b""),
210248 ),
211249 None => (
212250 format!("{}/tree", self.repo_url()),
213251 format!("{}/log", self.repo_url()),
252+ format!("{}/find", self.repo_url()),
214253 ),
215254 };
216255 let picker = current.and_then(|rev| {
@@ -245,6 +284,7 @@ impl Ctx<'_> {
245284 url: self.repo_url(),
246285 tree_url,
247286 log_url,
287+ find_url,
248288 refs_url: format!("{}/refs", self.repo_url()),
249289 active: active.as_str(),
250290 picker,
@@ -281,6 +321,7 @@ pub enum Active {
281321 Tree,
282322 Log,
283323 Refs,
324+ Find,
284325 }
285326
286327 impl Active {
@@ -289,6 +330,7 @@ impl Active {
289330 Active::Summary => "summary",
290331 Active::Tree => "tree",
291332 Active::Log => "log",
333+ Active::Find => "find",
292334 Active::Refs => "refs",
293335 }
294336 }
@@ -299,6 +341,7 @@ pub struct RepoNav {
299341 pub url: String,
300342 pub tree_url: String,
301343 pub log_url: String,
344+ pub find_url: String,
302345 pub refs_url: String,
303346 /// `summary`, `tree`, `log` or `refs`.
304347 pub active: &'static str,
M src/handlers/summary.rs View
@@ -11,6 +11,7 @@ use super::{Layout, render};
1111 use crate::cache::Key;
1212 use crate::git::refs::{RefKind, Rev};
1313 use crate::git::{self, CommitInfo};
14+use crate::index::languages::{self, Stats};
1415 use crate::render::time;
1516
1617 pub struct Stat {
@@ -25,6 +26,72 @@ pub struct CloneUrl {
2526 pub url: String,
2627 }
2728
29+/// The segmented language bar: 40 blocks, the top three languages at
30+/// full, 70% and 45% strength, everything else at 25%.
31+pub struct LangBar {
32+ /// One class per block (`o0` … `o3`).
33+ pub blocks: Vec<&'static str>,
34+ pub legend: Vec<LangEntry>,
35+}
36+
37+pub struct LangEntry {
38+ pub name: String,
39+ pub percent: String,
40+ pub class: &'static str,
41+}
42+
43+const BAR_BLOCKS: u64 = 40;
44+const BAR_CLASSES: [&str; 4] = ["o0", "o1", "o2", "o3"];
45+
46+pub fn language_bar(stats: &Stats) -> Option<LangBar> {
47+ if stats.total == 0 {
48+ return None;
49+ }
50+ let mut segments: Vec<(String, u64, &'static str)> = stats
51+ .languages
52+ .iter()
53+ .take(3)
54+ .enumerate()
55+ .map(|(i, (name, bytes))| (name.to_string(), *bytes, BAR_CLASSES[i]))
56+ .collect();
57+ let rest: u64 = stats.languages.iter().skip(3).map(|(_, n)| n).sum();
58+ if rest > 0 {
59+ segments.push(("Other".into(), rest, BAR_CLASSES[3]));
60+ }
61+ // Largest remainder, so the blocks always add up to 40.
62+ let mut counts: Vec<(u64, u64, usize)> = segments
63+ .iter()
64+ .enumerate()
65+ .map(|(i, (_, bytes, _))| {
66+ let exact = bytes * BAR_BLOCKS;
67+ (exact / stats.total, exact % stats.total, i)
68+ })
69+ .collect();
70+ let mut left = BAR_BLOCKS - counts.iter().map(|c| c.0).sum::<u64>();
71+ let mut order: Vec<usize> = (0..counts.len()).collect();
72+ order.sort_by(|&a, &b| counts[b].1.cmp(&counts[a].1).then(a.cmp(&b)));
73+ for i in order {
74+ if left == 0 {
75+ break;
76+ }
77+ counts[i].0 += 1;
78+ left -= 1;
79+ }
80+ let blocks = counts
81+ .iter()
82+ .flat_map(|(n, _, i)| std::iter::repeat_n(segments[*i].2, *n as usize))
83+ .collect();
84+ let legend = segments
85+ .into_iter()
86+ .map(|(name, bytes, class)| LangEntry {
87+ name,
88+ percent: format!("{:.1}%", bytes as f64 * 100.0 / stats.total as f64),
89+ class,
90+ })
91+ .collect();
92+ Some(LangBar { blocks, legend })
93+}
94+
2895 pub struct Latest {
2996 pub subject: String,
3097 pub author: String,
@@ -48,6 +115,7 @@ pub struct SummaryPage {
48115 pub rows: Option<Vec<TreeRow>>,
49116 pub tree_url: String,
50117 pub readme: Option<Readme>,
118+ pub languages: Option<LangBar>,
51119 }
52120
53121 pub fn page(ctx: &Ctx) -> Response {
@@ -61,12 +129,26 @@ pub fn page(ctx: &Ctx) -> Response {
61129 kind: RefKind::Branch,
62130 commit,
63131 });
64− let etag = ctx.etag(("summary", rev.as_ref().map(|r| r.commit)));
132+ let tree = rev
133+ .as_ref()
134+ .and_then(|r| languages::root_tree(&ctx.git, r.commit).ok());
135+ let lang_stats = tree.and_then(|t| languages::cached(&ctx.state.cache, t));
136+ if tree.is_some() && lang_stats.is_none() {
137+ ctx.state.warmer.wake.notify_one();
138+ }
139+ let etag = ctx.etag((
140+ "summary",
141+ rev.as_ref().map(|r| r.commit),
142+ lang_stats.is_some(),
143+ ));
65144 if let Some(res) = ctx.not_modified(&etag, false) {
66145 return res;
67146 }
68147 let mut page = SummaryPage {
69− layout: Layout::new(ctx.state, ctx.config, Some(&ctx.repo.name)),
148+ layout: Layout {
149+ feed: Some(ctx.feed_url()),
150+ ..Layout::new(ctx.state, ctx.config, Some(&ctx.repo.name))
151+ },
70152 nav: ctx.nav(Active::Summary, rev.as_ref(), |name| {
71153 ctx.url("tree", name, b"")
72154 }),
@@ -78,6 +160,7 @@ pub fn page(ctx: &Ctx) -> Response {
78160 rows: None,
79161 tree_url: String::new(),
80162 readme: None,
163+ languages: lang_stats.as_deref().and_then(language_bar),
81164 };
82165
83166 if let Some(rev) = &rev {
@@ -185,3 +268,44 @@ fn clone_urls(ctx: &Ctx) -> Vec<CloneUrl> {
185268 })
186269 .collect()
187270 }
271+
272+#[cfg(test)]
273+mod tests {
274+ use super::*;
275+
276+ #[test]
277+ fn bar_blocks_add_up() {
278+ let stats = Stats {
279+ languages: vec![
280+ ("Rust", 700),
281+ ("Shell", 200),
282+ ("CSS", 60),
283+ ("HTML", 30),
284+ ("Nix", 10),
285+ ],
286+ total: 1000,
287+ };
288+ let bar = language_bar(&stats).unwrap();
289+ assert_eq!(bar.blocks.len(), 40);
290+ let count = |c| bar.blocks.iter().filter(|b| **b == c).count();
291+ assert_eq!(
292+ (count("o0"), count("o1"), count("o2"), count("o3")),
293+ (28, 8, 2, 2)
294+ );
295+ let legend: Vec<_> = bar
296+ .legend
297+ .iter()
298+ .map(|l| (l.name.as_str(), l.percent.as_str()))
299+ .collect();
300+ assert_eq!(
301+ legend,
302+ [
303+ ("Rust", "70.0%"),
304+ ("Shell", "20.0%"),
305+ ("CSS", "6.0%"),
306+ ("Other", "4.0%")
307+ ]
308+ );
309+ assert!(language_bar(&Stats::default()).is_none());
310+ }
311+}
A src/index/languages.rs View
@@ -0,0 +1,410 @@
1+//! Language statistics, the way GitHub counts them: bytes per language
2+//! over the files of a tree, counting only programming and markup
3+//! languages, and leaving out vendored, generated and documentation paths.
4+//! `linguist-*` attributes in the root `.gitattributes` override all of
5+//! that.
6+//!
7+//! Results are cached by tree id and computed in the background by
8+//! [`warm`], so pages never wait for them.
9+
10+use std::collections::HashMap;
11+use std::sync::{Arc, Mutex, OnceLock};
12+use std::time::Duration;
13+
14+use fancy_regex::Regex;
15+use gix::bstr::BStr;
16+use gix::glob::wildmatch;
17+
18+use super::languages_table::{DOCUMENTATION, EXTENSIONS, FILENAMES, LANGUAGES, VENDOR};
19+use crate::cache::Cache;
20+use crate::git::tree::{self, EntryKind, PathTarget};
21+use crate::git::{self, ObjectId};
22+use crate::state::AppState;
23+
24+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25+pub enum Kind {
26+ Programming,
27+ Markup,
28+ Data,
29+ Prose,
30+}
31+
32+pub struct Language {
33+ pub name: &'static str,
34+ pub kind: Kind,
35+ /// Parent language, for statistics.
36+ pub group: Option<u16>,
37+}
38+
39+/// Bytes per language, largest first.
40+#[derive(Debug, Clone, Default, PartialEq, Eq)]
41+pub struct Stats {
42+ pub languages: Vec<(&'static str, u64)>,
43+ pub total: u64,
44+}
45+
46+impl Stats {
47+ pub fn weight(&self) -> usize {
48+ 64 + self.languages.len() * 32
49+ }
50+
51+ pub fn primary(&self) -> Option<&'static str> {
52+ self.languages.first().map(|(name, _)| *name)
53+ }
54+}
55+
56+/// Language of a path from its file name, then its extensions (longest
57+/// first: `x.d.ts` tries `.d.ts`, then `.ts`).
58+pub fn detect(path: &str) -> Option<&'static Language> {
59+ let name = path.rsplit('/').next().unwrap_or(path);
60+ if let Ok(i) = FILENAMES.binary_search_by(|(f, _)| (*f).cmp(name)) {
61+ return Some(&LANGUAGES[FILENAMES[i].1 as usize]);
62+ }
63+ let lower = name.to_ascii_lowercase();
64+ // A leading dot is a hidden file, not an extension (`.bashrc`).
65+ for (i, _) in lower.match_indices('.').filter(|(i, _)| *i > 0) {
66+ let ext = &lower[i..];
67+ if let Ok(j) = EXTENSIONS.binary_search_by(|(e, _)| (*e).cmp(ext)) {
68+ return Some(&LANGUAGES[EXTENSIONS[j].1 as usize]);
69+ }
70+ }
71+ None
72+}
73+
74+/// Paths of generated files (beyond Linguist's vendor/documentation lists).
75+const GENERATED: &[&str] = &[
76+ r"\.min\.(js|css|mjs)$",
77+ r"-min\.js$",
78+ r"\.(js|css)\.map$",
79+ r"\.pb\.(go|cc|h)$",
80+ r"_pb2(_grpc)?\.py$",
81+ r"\.designer\.(cs|vb)$",
82+ r"(^|/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|composer\.lock|Gemfile\.lock|poetry\.lock|go\.sum)$",
83+];
84+
85+struct Rules {
86+ vendor: Regex,
87+ documentation: Regex,
88+ generated: Regex,
89+}
90+
91+fn alternation(patterns: &[&str]) -> Regex {
92+ let joined = patterns
93+ .iter()
94+ .map(|p| format!("(?:{p})"))
95+ .collect::<Vec<_>>()
96+ .join("|");
97+ Regex::new(&joined).expect("Linguist patterns compile")
98+}
99+
100+fn rules() -> &'static Rules {
101+ static RULES: OnceLock<Rules> = OnceLock::new();
102+ RULES.get_or_init(|| Rules {
103+ vendor: alternation(VENDOR),
104+ documentation: alternation(DOCUMENTATION),
105+ generated: alternation(GENERATED),
106+ })
107+}
108+
109+fn matches(re: &Regex, path: &str) -> bool {
110+ re.is_match(path).unwrap_or(false)
111+}
112+
113+#[derive(Debug, Clone, PartialEq, Eq)]
114+enum Value {
115+ Set,
116+ Unset,
117+ Is(String),
118+}
119+
120+impl Value {
121+ fn truthy(&self) -> bool {
122+ match self {
123+ Value::Set => true,
124+ Value::Unset => false,
125+ Value::Is(v) => !matches!(v.as_str(), "false" | "0" | "no"),
126+ }
127+ }
128+}
129+
130+/// `linguist-*` rules from a `.gitattributes`.
131+#[derive(Debug, Default)]
132+pub struct Attributes {
133+ rules: Vec<(String, Vec<(String, Value)>)>,
134+}
135+
136+impl Attributes {
137+ pub fn parse(text: &str) -> Self {
138+ let mut rules = Vec::new();
139+ for line in text.lines() {
140+ let line = line.trim();
141+ if line.is_empty() || line.starts_with('#') {
142+ continue;
143+ }
144+ let mut parts = line.split_whitespace();
145+ let Some(pattern) = parts.next() else {
146+ continue;
147+ };
148+ let attrs: Vec<(String, Value)> = parts
149+ .filter_map(|a| {
150+ let (name, value) = if let Some(name) = a.strip_prefix('-') {
151+ (name, Value::Unset)
152+ } else if let Some(name) = a.strip_prefix('!') {
153+ (name, Value::Unset)
154+ } else if let Some((name, v)) = a.split_once('=') {
155+ (name, Value::Is(v.to_string()))
156+ } else {
157+ (a, Value::Set)
158+ };
159+ name.starts_with("linguist-")
160+ .then(|| (name.to_string(), value))
161+ })
162+ .collect();
163+ if !attrs.is_empty() {
164+ rules.push((pattern.to_string(), attrs));
165+ }
166+ }
167+ Attributes { rules }
168+ }
169+
170+ fn is_empty(&self) -> bool {
171+ self.rules.is_empty()
172+ }
173+
174+ /// The last matching value of `attr` for `path`.
175+ fn get(&self, path: &str, attr: &str) -> Option<&Value> {
176+ let name = path.rsplit('/').next().unwrap_or(path);
177+ self.rules
178+ .iter()
179+ .rev()
180+ .filter(|(pattern, _)| {
181+ let pattern = pattern.strip_prefix('/').unwrap_or(pattern);
182+ if pattern.contains('/') {
183+ wildmatch(
184+ BStr::new(pattern),
185+ BStr::new(path),
186+ wildmatch::Mode::NO_MATCH_SLASH_LITERAL,
187+ )
188+ } else {
189+ wildmatch(
190+ BStr::new(pattern),
191+ BStr::new(name),
192+ wildmatch::Mode::empty(),
193+ )
194+ }
195+ })
196+ .find_map(|(_, attrs)| {
197+ attrs
198+ .iter()
199+ .rev()
200+ .find(|(name, _)| name == attr)
201+ .map(|(_, v)| v)
202+ })
203+ }
204+
205+ fn flag(&self, path: &str, attr: &str) -> Option<bool> {
206+ self.get(path, attr).map(Value::truthy)
207+ }
208+}
209+
210+/// Whether a file counts, and as which language.
211+fn classify(path: &str, attrs: &Attributes) -> Option<&'static str> {
212+ let rules = rules();
213+ let excluded =
214+ |attr: &str, re: &Regex| attrs.flag(path, attr).unwrap_or_else(|| matches(re, path));
215+ if excluded("linguist-vendored", &rules.vendor)
216+ || excluded("linguist-generated", &rules.generated)
217+ || excluded("linguist-documentation", &rules.documentation)
218+ {
219+ return None;
220+ }
221+ let forced = match attrs.get(path, "linguist-language") {
222+ Some(Value::Is(name)) => LANGUAGES.iter().find(|l| {
223+ l.name.eq_ignore_ascii_case(&name.replace('-', " "))
224+ || l.name.eq_ignore_ascii_case(name)
225+ }),
226+ _ => None,
227+ };
228+ let language = forced.or_else(|| detect(path))?;
229+ let detectable = attrs
230+ .flag(path, "linguist-detectable")
231+ .unwrap_or(matches!(language.kind, Kind::Programming | Kind::Markup));
232+ if !detectable {
233+ return None;
234+ }
235+ let counted = language.group.map_or(language, |g| &LANGUAGES[g as usize]);
236+ Some(counted.name)
237+}
238+
239+/// Language statistics of the tree `tree` (with the root `.gitattributes`
240+/// of that tree).
241+pub fn compute(repo: &git::Handle, tree_id: ObjectId) -> anyhow::Result<Stats> {
242+ let attrs = match tree::list(repo, tree_id)?
243+ .iter()
244+ .find(|e| e.name == ".gitattributes" && e.kind.is_blob())
245+ {
246+ Some(entry) => Attributes::parse(&String::from_utf8_lossy(&tree::blob(repo, entry.oid)?)),
247+ None => Attributes::default(),
248+ };
249+ let rules = rules();
250+ // Directories can be skipped wholesale unless attributes might bring
251+ // something inside back.
252+ let prune = attrs.is_empty();
253+ let mut bytes: HashMap<&'static str, u64> = HashMap::new();
254+ tree::walk(
255+ repo,
256+ tree_id,
257+ &mut |dir| !(prune && (matches(&rules.vendor, dir) || matches(&rules.documentation, dir))),
258+ &mut |path, kind, size| {
259+ if kind == EntryKind::Symlink {
260+ return;
261+ }
262+ if let Some(name) = classify(path, &attrs) {
263+ *bytes.entry(name).or_default() += size;
264+ }
265+ },
266+ )?;
267+ let mut languages: Vec<(&'static str, u64)> =
268+ bytes.into_iter().filter(|(_, n)| *n > 0).collect();
269+ languages.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
270+ let total = languages.iter().map(|(_, n)| n).sum();
271+ Ok(Stats { languages, total })
272+}
273+
274+/// Background state: wake-ups and each repository's main language (for
275+/// the index page).
276+#[derive(Default)]
277+pub struct Warmer {
278+ pub wake: tokio::sync::Notify,
279+ primary: Mutex<HashMap<String, &'static str>>,
280+}
281+
282+impl Warmer {
283+ pub fn primary(&self, repo: &str) -> Option<&'static str> {
284+ self.primary
285+ .lock()
286+ .unwrap_or_else(|e| e.into_inner())
287+ .get(repo)
288+ .copied()
289+ }
290+
291+ fn set_primary(&self, repo: &str, language: Option<&'static str>) {
292+ let mut map = self.primary.lock().unwrap_or_else(|e| e.into_inner());
293+ match language {
294+ Some(l) => {
295+ map.insert(repo.to_string(), l);
296+ }
297+ None => {
298+ map.remove(repo);
299+ }
300+ }
301+ }
302+}
303+
304+/// Root tree of a commit.
305+pub fn root_tree(repo: &git::Handle, commit: ObjectId) -> anyhow::Result<ObjectId> {
306+ match tree::lookup(repo, commit, b"")? {
307+ Some(PathTarget::Tree(id)) => Ok(id),
308+ _ => anyhow::bail!("commit has no tree"),
309+ }
310+}
311+
312+/// Cached statistics of `tree`, without computing them.
313+pub fn cached(cache: &Cache, tree: ObjectId) -> Option<Arc<Stats>> {
314+ cache.peek_languages(tree)
315+}
316+
317+/// How often the warmer looks for repositories without statistics, besides
318+/// being woken by page views that found none.
319+const WARM_INTERVAL: Duration = Duration::from_secs(60);
320+
321+/// Compute missing statistics for every repository's default branch, one
322+/// repository at a time, forever.
323+pub async fn warm(state: Arc<AppState>) {
324+ loop {
325+ let index = state.index.load();
326+ for repo in index.repos.values() {
327+ let Some(head) = repo.meta.head else {
328+ state.warmer.set_primary(&repo.name, None);
329+ continue;
330+ };
331+ let repo = repo.clone();
332+ let result = state
333+ .blocking(move |state| -> anyhow::Result<()> {
334+ let git = git::handle(&repo.gix);
335+ let tree = root_tree(&git, head)?;
336+ let stats = match state.cache.peek_languages(tree) {
337+ Some(stats) => stats,
338+ None => {
339+ let start = std::time::Instant::now();
340+ let stats = state.cache.languages(tree, || compute(&git, tree))?;
341+ tracing::debug!(
342+ "{}: language stats in {:?}",
343+ repo.name,
344+ start.elapsed()
345+ );
346+ stats
347+ }
348+ };
349+ state.warmer.set_primary(&repo.name, stats.primary());
350+ Ok(())
351+ })
352+ .await;
353+ match result {
354+ Ok(Ok(())) => {}
355+ Ok(Err(e)) | Err(e) => tracing::warn!("language stats: {e:#}"),
356+ }
357+ }
358+ tokio::select! {
359+ _ = state.warmer.wake.notified() => {}
360+ _ = tokio::time::sleep(WARM_INTERVAL) => {}
361+ }
362+ }
363+}
364+
365+#[cfg(test)]
366+mod tests {
367+ use super::*;
368+
369+ #[test]
370+ fn detection() {
371+ let name = |p: &str| detect(p).map(|l| l.name);
372+ assert_eq!(name("src/main.rs"), Some("Rust"));
373+ assert_eq!(name("Makefile"), Some("Makefile"));
374+ assert_eq!(name("a/b/Dockerfile"), Some("Dockerfile"));
375+ assert_eq!(name("x.PY"), Some("Python"));
376+ assert_eq!(name("types.d.ts"), Some("TypeScript"));
377+ assert_eq!(name("lib.h"), Some("C"));
378+ assert_eq!(name(".bashrc"), Some("Shell"));
379+ assert_eq!(name("LICENSE"), Some("Text"));
380+ assert_eq!(name("no-extension"), None);
381+ }
382+
383+ #[test]
384+ fn exclusions_and_attributes() {
385+ let none = Attributes::default();
386+ assert_eq!(classify("src/main.rs", &none), Some("Rust"));
387+ assert_eq!(classify("vendor/lib.rs", &none), None);
388+ assert_eq!(classify("node_modules/x/index.js", &none), None);
389+ assert_eq!(classify("docs/conf.py", &none), None);
390+ assert_eq!(classify("app.min.js", &none), None);
391+ assert_eq!(classify("config.json", &none), None, "data");
392+ assert_eq!(classify("README.md", &none), None, "prose");
393+
394+ let attrs = Attributes::parse(
395+ "# comment\n*.json linguist-detectable\nvendor/** -linguist-vendored\n\
396+ gen/*.rs linguist-generated=true\n*.inc linguist-language=C++\n",
397+ );
398+ assert_eq!(classify("config.json", &attrs), Some("JSON"));
399+ assert_eq!(classify("vendor/lib.rs", &attrs), Some("Rust"));
400+ assert_eq!(classify("gen/x.rs", &attrs), None);
401+ assert_eq!(classify("a/b.inc", &attrs), Some("C++"));
402+ }
403+
404+ #[test]
405+ fn all_linguist_patterns_compile() {
406+ let r = rules();
407+ assert!(matches(&r.vendor, "third_party/x.c"));
408+ assert!(matches(&r.documentation, "Documentation/index.rst"));
409+ }
410+}
A src/index/languages_table.rs View

[Diff too large] View file

M src/index/mod.rs View
@@ -4,6 +4,8 @@
44 //! Writers (full scans and single-repo refreshes) are serialized and swap in
55 //! a new snapshot copy-on-write.
66
7+pub mod languages;
8+pub mod languages_table;
79 pub mod meta;
810 pub mod scan;
911 pub mod watch;
A src/server/clone.rs View
@@ -0,0 +1,264 @@
1+//! Read-only smart-HTTP clones through `git http-backend`.
2+//!
3+//! Only `info/refs?service=git-upload-pack` and `POST git-upload-pack` are
4+//! served; anything that would push is refused before git runs. The CGI
5+//! environment is built from the index (never from the raw URL), and both
6+//! bodies are streamed.
7+
8+use std::process::Stdio;
9+use std::sync::Arc;
10+
11+use axum::body::Body;
12+use axum::extract::Request;
13+use axum::http::{HeaderName, HeaderValue, Method, StatusCode, header};
14+use axum::response::{IntoResponse, Response};
15+use futures_util::{StreamExt, TryStreamExt};
16+use tokio::io::AsyncReadExt;
17+use tokio::process::Command;
18+use tokio_util::io::{ReaderStream, StreamReader};
19+
20+use crate::handlers::error;
21+use crate::index::Repo;
22+use crate::state::AppState;
23+
24+/// CGI response headers larger than this are an error.
25+const MAX_HEADER_BYTES: usize = 64 * 1024;
26+
27+/// Whether `rest` (the path after the repository) is a clone endpoint.
28+pub fn is_clone_path(rest: &[Vec<u8>]) -> bool {
29+ matches!(
30+ rest,
31+ [a, b] if a == b"info" && b == b"refs"
32+ ) || matches!(rest, [a] if a == b"git-upload-pack" || a == b"git-receive-pack")
33+}
34+
35+pub async fn serve(
36+ state: Arc<AppState>,
37+ repo: Arc<Repo>,
38+ rest: Vec<Vec<u8>>,
39+ req: Request,
40+) -> Response {
41+ let config = state.config.load_full();
42+ if !config.repos.https_clone {
43+ return error::page(&state, StatusCode::NOT_FOUND, "PAGE NOT FOUND");
44+ }
45+ let query = req.uri().query().unwrap_or_default().to_string();
46+ let service = query
47+ .split('&')
48+ .find_map(|kv| kv.strip_prefix("service="))
49+ .unwrap_or_default();
50+ let endpoint = match (req.method(), rest.last().map(Vec::as_slice)) {
51+ (&Method::GET | &Method::HEAD, Some(b"refs")) => match service {
52+ "git-upload-pack" => "info/refs",
53+ "git-receive-pack" => return forbidden(),
54+ _ => {
55+ return (
56+ StatusCode::FORBIDDEN,
57+ "Only the smart HTTP protocol is supported.\n",
58+ )
59+ .into_response();
60+ }
61+ },
62+ (&Method::POST, Some(b"git-upload-pack")) => "git-upload-pack",
63+ (_, Some(b"git-receive-pack")) => return forbidden(),
64+ _ => return StatusCode::METHOD_NOT_ALLOWED.into_response(),
65+ };
66+
67+ let Ok(permit) = state.heavy_permits.clone().acquire_owned().await else {
68+ return StatusCode::SERVICE_UNAVAILABLE.into_response();
69+ };
70+ let (Some(root), Some(dir)) = (repo.git_dir.parent(), repo.git_dir.file_name()) else {
71+ return StatusCode::NOT_FOUND.into_response();
72+ };
73+ let path_info = format!("/{}/{endpoint}", dir.to_string_lossy());
74+
75+ let headers = req.headers();
76+ let header = |name: HeaderName| {
77+ headers
78+ .get(name)
79+ .and_then(|v| v.to_str().ok())
80+ .map(str::to_string)
81+ };
82+ let mut cmd = Command::new("git");
83+ cmd.arg("http-backend")
84+ .env_clear()
85+ .env("PATH", std::env::var_os("PATH").unwrap_or_default())
86+ .env("GIT_PROJECT_ROOT", root)
87+ .env("PATH_INFO", path_info)
88+ .env("REQUEST_METHOD", req.method().as_str())
89+ .env("QUERY_STRING", &query)
90+ .env("GIT_HTTP_EXPORT_ALL", "1")
91+ // Only the repository's own config, plus ours.
92+ .env("GIT_CONFIG_NOSYSTEM", "1")
93+ .env("GIT_CONFIG_GLOBAL", "/dev/null")
94+ .env("HOME", "/nonexistent")
95+ .env("GIT_CONFIG_COUNT", "2")
96+ .env("GIT_CONFIG_KEY_0", "http.receivepack")
97+ .env("GIT_CONFIG_VALUE_0", "false")
98+ // Visibility is decided by the index; ownership is not our concern.
99+ .env("GIT_CONFIG_KEY_1", "safe.directory")
100+ .env("GIT_CONFIG_VALUE_1", "*")
101+ .stdin(Stdio::piped())
102+ .stdout(Stdio::piped())
103+ .stderr(Stdio::piped())
104+ .kill_on_drop(true);
105+ for (var, name) in [
106+ ("CONTENT_TYPE", header::CONTENT_TYPE),
107+ ("CONTENT_LENGTH", header::CONTENT_LENGTH),
108+ ("HTTP_CONTENT_ENCODING", header::CONTENT_ENCODING),
109+ ] {
110+ if let Some(value) = header(name) {
111+ cmd.env(var, value);
112+ }
113+ }
114+ if let Some(protocol) = header(HeaderName::from_static("git-protocol")) {
115+ cmd.env("HTTP_GIT_PROTOCOL", protocol);
116+ }
117+
118+ let mut child = match cmd.spawn() {
119+ Ok(child) => child,
120+ Err(e) => {
121+ tracing::error!("cannot run git http-backend: {e}");
122+ return StatusCode::INTERNAL_SERVER_ERROR.into_response();
123+ }
124+ };
125+ let (Some(mut stdin), Some(mut stdout), Some(mut stderr)) =
126+ (child.stdin.take(), child.stdout.take(), child.stderr.take())
127+ else {
128+ return StatusCode::INTERNAL_SERVER_ERROR.into_response();
129+ };
130+
131+ // Request body → stdin, then EOF.
132+ let body = req
133+ .into_body()
134+ .into_data_stream()
135+ .map_err(std::io::Error::other);
136+ tokio::spawn(async move {
137+ let mut reader = StreamReader::new(body);
138+ if let Err(e) = tokio::io::copy(&mut reader, &mut stdin).await {
139+ tracing::debug!("clone request body: {e}");
140+ }
141+ });
142+ let name = repo.name.clone();
143+ tokio::spawn(async move {
144+ let mut text = String::new();
145+ let _ = stderr.read_to_string(&mut text).await;
146+ for line in text.lines().filter(|l| !l.trim().is_empty()) {
147+ tracing::warn!("{name}: git http-backend: {line}");
148+ }
149+ });
150+
151+ // CGI headers, then the rest of stdout as the body.
152+ let mut buf = Vec::with_capacity(4096);
153+ let (status, cgi_headers, body_start) = loop {
154+ if let Some(parsed) = parse_cgi_headers(&buf) {
155+ break parsed;
156+ }
157+ if buf.len() > MAX_HEADER_BYTES {
158+ tracing::error!("git http-backend sent oversized headers");
159+ return StatusCode::BAD_GATEWAY.into_response();
160+ }
161+ match stdout.read_buf(&mut buf).await {
162+ Ok(0) | Err(_) => {
163+ tracing::error!("git http-backend ended before its headers");
164+ return StatusCode::BAD_GATEWAY.into_response();
165+ }
166+ Ok(_) => {}
167+ }
168+ };
169+ let head = buf.split_off(body_start);
170+ let stream = futures_util::stream::once(async move { Ok(bytes::Bytes::from(head)) })
171+ .chain(ReaderStream::new(stdout));
172+ // Reap git and release the permit when it exits.
173+ tokio::spawn(async move {
174+ let _permit = permit;
175+ let _ = child.wait().await;
176+ });
177+
178+ let mut res = Body::from_stream(stream).into_response();
179+ *res.status_mut() = status;
180+ for (name, value) in cgi_headers {
181+ if let (Ok(name), Ok(value)) = (
182+ HeaderName::from_bytes(name.as_bytes()),
183+ HeaderValue::from_str(&value),
184+ ) {
185+ res.headers_mut().insert(name, value);
186+ }
187+ }
188+ res
189+}
190+
191+fn forbidden() -> Response {
192+ (StatusCode::FORBIDDEN, "This server is read-only.\n").into_response()
193+}
194+
195+/// A parsed CGI response head: status, headers, and where the body starts.
196+pub type CgiHead = (StatusCode, Vec<(String, String)>, usize);
197+
198+/// Parse a CGI response head, or `None` while the blank line hasn't
199+/// arrived yet.
200+pub fn parse_cgi_headers(buf: &[u8]) -> Option<CgiHead> {
201+ let (end, sep) = [&b"\r\n\r\n"[..], b"\n\n"]
202+ .iter()
203+ .filter_map(|sep| {
204+ buf.windows(sep.len())
205+ .position(|w| w == *sep)
206+ .map(|i| (i, sep.len()))
207+ })
208+ .min()?;
209+ let head = String::from_utf8_lossy(&buf[..end]);
210+ let mut status = StatusCode::OK;
211+ let mut headers = Vec::new();
212+ for line in head.lines() {
213+ let Some((name, value)) = line.split_once(':') else {
214+ continue;
215+ };
216+ let value = value.trim().to_string();
217+ if name.eq_ignore_ascii_case("status") {
218+ if let Some(code) = value
219+ .split_whitespace()
220+ .next()
221+ .and_then(|c| c.parse::<u16>().ok())
222+ .and_then(|c| StatusCode::from_u16(c).ok())
223+ {
224+ status = code;
225+ }
226+ } else {
227+ headers.push((name.trim().to_string(), value));
228+ }
229+ }
230+ Some((status, headers, end + sep))
231+}
232+
233+#[cfg(test)]
234+mod tests {
235+ use super::*;
236+
237+ #[test]
238+ fn cgi_headers() {
239+ let raw = b"Expires: Fri, 01 Jan 1980 00:00:00 GMT\r\nContent-Type: application/x-git-upload-pack-advertisement\r\n\r\n001e# service";
240+ let (status, headers, start) = parse_cgi_headers(raw).unwrap();
241+ assert_eq!(status, StatusCode::OK);
242+ assert_eq!(headers.len(), 2);
243+ assert_eq!(headers[1].1, "application/x-git-upload-pack-advertisement");
244+ assert_eq!(&raw[start..], b"001e# service");
245+
246+ let raw = b"Status: 403 Forbidden\nContent-Type: text/plain\n\nno";
247+ let (status, headers, start) = parse_cgi_headers(raw).unwrap();
248+ assert_eq!(status, StatusCode::FORBIDDEN);
249+ assert_eq!(headers, vec![("Content-Type".into(), "text/plain".into())]);
250+ assert_eq!(&raw[start..], b"no");
251+
252+ assert!(parse_cgi_headers(b"Content-Type: x\r\n").is_none());
253+ }
254+
255+ #[test]
256+ fn clone_paths() {
257+ let p = |s: &[&str]| s.iter().map(|x| x.as_bytes().to_vec()).collect::<Vec<_>>();
258+ assert!(is_clone_path(&p(&["info", "refs"])));
259+ assert!(is_clone_path(&p(&["git-upload-pack"])));
260+ assert!(is_clone_path(&p(&["git-receive-pack"])));
261+ assert!(!is_clone_path(&p(&["info"])));
262+ assert!(!is_clone_path(&p(&["tree", "main"])));
263+ }
264+}
M src/server/dispatch.rs View
@@ -7,9 +7,10 @@
77 use std::sync::Arc;
88
99 use axum::extract::{Query, Request, State};
10−use axum::http::{StatusCode, header};
10+use axum::http::{Method, StatusCode, header};
1111 use axum::response::{IntoResponse, Redirect, Response};
1212
13+use super::clone;
1314 use crate::handlers::error;
1415 use crate::handlers::repo::{PageQuery, RepoRequest, handle};
1516 use crate::index::{Repo, RepoIndex};
@@ -21,9 +22,10 @@ pub enum Match<'a> {
2122 repo: Arc<Repo>,
2223 rest: &'a [Vec<u8>],
2324 },
24− /// `/{repo}.git` → `/{repo}`.
25+ /// `/{repo}.git` → `/{repo}`; `/{repo}.git/…` is only for cloning.
2526 DotGit {
26− name: String,
27+ repo: Arc<Repo>,
28+ rest: &'a [Vec<u8>],
2729 },
2830 None,
2931 }
@@ -63,21 +65,26 @@ pub fn find_repo<'a>(index: &RepoIndex, segments: &'a [Vec<u8>]) -> Match<'a> {
6365 };
6466 }
6567 if let Some(stripped) = name.strip_suffix(".git")
66− && index.repos.contains_key(stripped)
68+ && let Some(repo) = index.repos.get(stripped)
6769 {
68− // `/{repo}.git/…` is the HTTPS clone endpoint (not served yet).
69− return if n == segments.len() {
70− Match::DotGit {
71− name: stripped.to_string(),
72− }
73− } else {
74− Match::None
70+ return Match::DotGit {
71+ repo: repo.clone(),
72+ rest: &segments[n..],
7573 };
7674 }
7775 }
7876 Match::None
7977 }
8078
79+/// Expensive pages share `limits.heavy_parallel` permits.
80+fn is_heavy(rest: &[Vec<u8>], query: &PageQuery) -> bool {
81+ match rest.first().map(Vec::as_slice) {
82+ Some(b"blame" | b"archive") => true,
83+ Some(b"log") => query.q.as_deref().is_some_and(|q| !q.trim().is_empty()),
84+ _ => false,
85+ }
86+}
87+
8188 pub async fn dispatch(State(state): State<Arc<AppState>>, req: Request) -> Response {
8289 let config = state.config.load_full();
8390 let index = state.index.load();
@@ -86,17 +93,42 @@ pub async fn dispatch(State(state): State<Arc<AppState>>, req: Request) -> Respo
8693 };
8794 let (repo, rest) = match find_repo(&index, &segments) {
8895 Match::Repo { repo, rest } => (repo, rest.to_vec()),
89− Match::DotGit { name } => {
90− let url = format!("{}{}", config.server.base_path, encode_path(&name));
96+ Match::DotGit { repo, rest: [] } => {
97+ let url = format!("{}{}", config.server.base_path, encode_path(&repo.name));
9198 return Redirect::permanent(&url).into_response();
9299 }
93− Match::None => return error::page(&state, StatusCode::NOT_FOUND, "PAGE NOT FOUND"),
100+ Match::DotGit { repo, rest } if clone::is_clone_path(rest) => {
101+ return clone::serve(state.clone(), repo, rest.to_vec(), req).await;
102+ }
103+ Match::DotGit { .. } | Match::None => {
104+ return error::page(&state, StatusCode::NOT_FOUND, "PAGE NOT FOUND");
105+ }
94106 };
107+ if clone::is_clone_path(&rest) {
108+ return clone::serve(state.clone(), repo, rest, req).await;
109+ }
110+ if !matches!(*req.method(), Method::GET | Method::HEAD) {
111+ return StatusCode::METHOD_NOT_ALLOWED.into_response();
112+ }
95113 let query = Query::<PageQuery>::try_from_uri(req.uri())
96114 .map(|q| q.0)
97115 .unwrap_or_default();
116+ let heavy = if is_heavy(&rest, &query) {
117+ match state.heavy_permits.clone().acquire_owned().await {
118+ Ok(permit) => Some(permit),
119+ Err(_) => return error::page(&state, StatusCode::SERVICE_UNAVAILABLE, "SHUTTING DOWN"),
120+ }
121+ } else {
122+ None
123+ };
98124 let request = RepoRequest {
99125 repo,
126+ heavy,
127+ host: req
128+ .headers()
129+ .get(header::HOST)
130+ .and_then(|h| h.to_str().ok())
131+ .map(str::to_string),
100132 segments: rest,
101133 query,
102134 if_none_match: req.headers().get(header::IF_NONE_MATCH).cloned(),
M src/server/mod.rs View
@@ -1,6 +1,7 @@
11 //! HTTP server: router, middleware, listeners, signals and shutdown.
22
33 pub mod assets;
4+pub mod clone;
45 pub mod dispatch;
56 pub mod headers;
67
@@ -14,7 +15,7 @@ use axum::extract::{Request, State};
1415 use axum::http::{HeaderValue, StatusCode, Uri, header};
1516 use axum::middleware::{self, Next};
1617 use axum::response::{IntoResponse, Redirect, Response};
17−use axum::routing::get;
18+use axum::routing::{any, get};
1819 use sd_notify::NotifyState;
1920 use tokio::signal::unix::{SignalKind, signal};
2021 use tokio::sync::watch;
@@ -43,7 +44,8 @@ pub fn app(state: Arc<AppState>) -> Router {
4344 .route("/-/health", get(health))
4445 .route("/-/site.css", get(assets::site_css))
4546 .route("/-/static/{hash}/{*path}", get(assets::serve))
46− .route("/{*path}", get(dispatch::dispatch))
47+ .route("/robots.txt", get(robots))
48+ .route("/{*path}", any(dispatch::dispatch))
4749 .fallback(handlers::error::not_found)
4850 .with_state(state.clone());
4951
@@ -72,8 +74,14 @@ pub fn app(state: Arc<AppState>) -> Router {
7274 } else {
7375 compression.no_gzip().no_br()
7476 }
75− // woff2 is already compressed.
76− .compress_when(DefaultPredicate::new().and(NotForContentType::const_new("font/")));
77+ // Fonts, archives and packfiles are already compressed.
78+ .compress_when(
79+ DefaultPredicate::new()
80+ .and(NotForContentType::const_new("font/"))
81+ .and(NotForContentType::const_new("application/zip"))
82+ .and(NotForContentType::const_new("application/gzip"))
83+ .and(NotForContentType::const_new("application/x-git-")),
84+ );
7785
7886 let [csp, nosniff, referrer, frame] = headers::layers();
7987 routes.layer(
@@ -142,6 +150,37 @@ async fn strip_base_path(State(base): State<Arc<str>>, mut req: Request, next: N
142150 }
143151 }
144152
153+/// Keep crawlers off the expensive pages: blame, archives, patches,
154+/// searches, path histories, deep log pages, diff options and clones.
155+async fn robots(State(state): State<Arc<AppState>>) -> impl IntoResponse {
156+ let base = state.config.load().server.base_path.clone();
157+ let mut text = String::from("User-agent: *\n");
158+ for rule in [
159+ "*/blame/",
160+ "*/archive/",
161+ "*.patch$",
162+ "*/find/",
163+ "*/log/*/*",
164+ "*?q=",
165+ "*&q=",
166+ "*?after=",
167+ "*&after=",
168+ "*?context=",
169+ "*?ws=",
170+ "*/info/refs",
171+ "*/git-upload-pack",
172+ ] {
173+ text.push_str(&format!("Disallow: {base}{rule}\n"));
174+ }
175+ (
176+ [(
177+ header::CACHE_CONTROL,
178+ HeaderValue::from_static("public, max-age=86400"),
179+ )],
180+ text,
181+ )
182+}
183+
145184 async fn health() -> impl IntoResponse {
146185 (
147186 [(header::CACHE_CONTROL, HeaderValue::from_static("no-store"))],
@@ -166,6 +205,7 @@ pub async fn run(state: Arc<AppState>) -> anyhow::Result<()> {
166205 let mut seen = Default::default();
167206 index::watch::log_scan(&stats, &state.index.load(), &mut seen, true);
168207 tokio::spawn(index::watch::run(state.clone(), seen));
208+ tokio::spawn(index::languages::warm(state.clone()));
169209 // Parse the embedded grammars now rather than on the first blob view.
170210 tokio::task::spawn_blocking(|| {
171211 let start = std::time::Instant::now();
M src/state.rs View
@@ -10,6 +10,7 @@ use tokio::sync::Semaphore;
1010 use crate::cache::Cache;
1111 use crate::config::{Config, ConfigError, Listen};
1212 use crate::index::Indexer;
13+use crate::index::languages::Warmer;
1314 use crate::server::assets::Assets;
1415
1516 pub struct AppState {
@@ -21,7 +22,12 @@ pub struct AppState {
2122 pub cache: Cache,
2223 /// Bounds concurrent git work (`limits.git_parallel`).
2324 pub git_permits: Semaphore,
25+ /// Bounds expensive requests: blame, archives, clones, log searches
26+ /// (`limits.heavy_parallel`). Held for a whole response stream.
27+ pub heavy_permits: Arc<Semaphore>,
2428 pub index: Indexer,
29+ /// Background language statistics.
30+ pub warmer: Warmer,
2531 /// Wakes the index watcher for an immediate full rescan.
2632 pub rescan: tokio::sync::Notify,
2733 /// Command-line overrides re-applied after every reload.
@@ -49,10 +55,12 @@ impl AppState {
4955 Arc::new(AppState {
5056 cache: Cache::new(config.cache.size_mb),
5157 git_permits: Semaphore::new(config.git_parallel()),
58+ heavy_permits: Arc::new(Semaphore::new(config.limits.heavy_parallel)),
5259 config_generation: AtomicU64::new(0),
5360 config: ArcSwap::from_pointee(config),
5461 assets: Assets::new(),
5562 index: Indexer::new(),
63+ warmer: Warmer::default(),
5664 rescan: tokio::sync::Notify::new(),
5765 overrides,
5866 })
@@ -85,10 +93,16 @@ impl AppState {
8593 new.server.compression = old.server.compression;
8694 new.limits.request_timeout_secs = old.limits.request_timeout_secs;
8795 }
88− if new.cache.size_mb != old.cache.size_mb || new.git_parallel() != old.git_parallel() {
89− tracing::warn!("cache.size_mb / limits.git_parallel changed; restart required");
96+ if new.cache.size_mb != old.cache.size_mb
97+ || new.git_parallel() != old.git_parallel()
98+ || new.limits.heavy_parallel != old.limits.heavy_parallel
99+ {
100+ tracing::warn!(
101+ "cache.size_mb / limits.git_parallel / limits.heavy_parallel changed; restart required"
102+ );
90103 new.cache.size_mb = old.cache.size_mb;
91104 new.limits.git_parallel = old.limits.git_parallel;
105+ new.limits.heavy_parallel = old.limits.heavy_parallel;
92106 }
93107 let new = Arc::new(new);
94108 self.config.store(new.clone());
A templates/atom.xml View
@@ -0,0 +1,26 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<feed xmlns="http://www.w3.org/2005/Atom">
3+ <title>{{ title }}</title>
4+ {%- if let Some(subtitle) = subtitle %}
5+ <subtitle>{{ subtitle }}</subtitle>
6+ {%- endif %}
7+ <id>{{ self_url }}</id>
8+ <link rel="self" type="application/atom+xml" href="{{ self_url }}"/>
9+ <link rel="alternate" type="text/html" href="{{ html_url }}"/>
10+ <updated>{{ updated }}</updated>
11+ <generator version="{{ version }}">Ferrenium</generator>
12+ {%- for entry in entries %}
13+ <entry>
14+ <title>{{ entry.title }}</title>
15+ <id>{{ entry.url }}</id>
16+ <link rel="alternate" type="text/html" href="{{ entry.url }}"/>
17+ <updated>{{ entry.updated }}</updated>
18+ <published>{{ entry.published }}</published>
19+ <author>
20+ <name>{{ entry.author }}</name>
21+ <email>{{ entry.email }}</email>
22+ </author>
23+ <content type="text">{{ entry.message }}</content>
24+ </entry>
25+ {%- endfor %}
26+</feed>
M templates/base.html View
@@ -11,6 +11,9 @@
1111 {%- if let Some(css) = layout.site_css %}
1212 <link rel="stylesheet" href="{{ css }}">
1313 {%- endif %}
14+{%- if let Some(feed) = layout.feed %}
15+<link rel="alternate" type="application/atom+xml" title="Commits" href="{{ feed }}">
16+{%- endif %}
1417 <link rel="icon" href="{{ layout.static_base }}icons/favicon.svg" type="image/svg+xml">
1518 <script src="{{ layout.static_base }}js/theme.js"></script>
1619 <script src="{{ layout.static_base }}js/app.js" defer></script>
A templates/blame.html View
@@ -0,0 +1,34 @@
1+{% extends "base.html" %}
2+{% block content %}
3+{% include "repo_nav.html" %}
4+{% include "crumbs.html" %}
5+<div class="blob-bar">
6+ <p class="label blob-facts">{% for fact in facts %}{% if !loop.first %} · {% endif %}{{ fact }}{% endfor %}</p>
7+ <div class="blob-actions">
8+ <a class="btn-ghost" href="{{ blob_url }}">File</a>
9+ <a class="btn-ghost" href="{{ log_url }}">History</a>
10+ </div>
11+</div>
12+{%- match body %}
13+{%- when Body::Lines with (rows) %}
14+<div class="code-wrap">
15+<table class="code blame" data-lines>
16+<tbody>
17+{%- for row in rows %}
18+{%- if let Some(head) = row.head %}
19+<tr id="L{{ row.n }}" class="hunk-start"><td class="blame-meta {{ head.age_class }}" rowspan="{{ head.rows }}"><a class="sha" href="{{ head.url }}" title="{{ head.subject }}">{{ head.id }}</a> <span class="blame-who">{{ head.author }}</span> <time datetime="{{ head.iso }}" title="{{ head.iso }}">{{ head.age }}</time></td><td class="ln"><a href="#L{{ row.n }}">{{ row.n }}</a></td><td class="lc">{{ row.html|safe }}</td></tr>
20+{%- else %}
21+<tr id="L{{ row.n }}"><td class="ln"><a href="#L{{ row.n }}">{{ row.n }}</a></td><td class="lc">{{ row.html|safe }}</td></tr>
22+{%- endif %}
23+{%- endfor %}
24+</tbody>
25+</table>
26+</div>
27+{%- when Body::Binary %}
28+<p class="status blob-note">[Binary file]</p>
29+{%- when Body::TooLarge %}
30+<p class="status blob-note">[Too large to blame]</p>
31+{%- when Body::Empty %}
32+<p class="status blob-note">[Empty file]</p>
33+{%- endmatch %}
34+{% endblock %}
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="{{ blame_url }}">Blame</a>
1516 <a class="btn-ghost" href="{{ log_url }}">History</a>
1617 <a class="btn-ghost" href="{{ download_url }}">Download</a>
1718 </div>
A templates/find.html View
@@ -0,0 +1,28 @@
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">Path</span>
8+ <input type="search" name="q" value="{{ q }}" autocomplete="off" spellcheck="false" placeholder="File name or path" autofocus>
9+ </label>
10+</form>
11+{%- if !q.is_empty() %}
12+<p class="label dim find-count">{{ matched }} of {{ total }} files{% if matched > hits.len() %} · showing {{ hits.len() }}{% endif %}</p>
13+{%- if hits.is_empty() %}
14+<p class="status log-note">[No matches]</p>
15+{%- else %}
16+<ul class="tree find-list">
17+ {%- for hit in hits %}
18+ <li class="tree-row"><a class="tree-name mono" href="{{ hit.url }}"><span><span class="find-dir">{{ hit.dir }}</span>{{ hit.name }}</span></a></li>
19+ {%- endfor %}
20+</ul>
21+{%- endif %}
22+{%- else %}
23+<p class="label dim find-count">{{ total }} files</p>
24+{%- endif %}
25+{%- if truncated %}
26+<p class="status log-note">[Large tree: only the first {{ total }} paths are searched]</p>
27+{%- endif %}
28+{% endblock %}
M templates/index.html View
@@ -44,6 +44,7 @@
4444 </a>
4545 <span class="repo-meta label">
4646 {%- if repo.fresh %}<span class="dot" title="Updated in the last 24 hours"></span>{% endif -%}
47+ {%- if let Some(language) = repo.language %}<span class="repo-lang">{{ language }}</span>{% endif -%}
4748 {%- if let Some(age) = repo.age %}<time datetime="{{ repo.iso.as_deref().unwrap_or_default() }}" title="{{ repo.iso.as_deref().unwrap_or_default() }}">{{ age }}</time>{% else %}Empty{% endif -%}
4849 </span>
4950 </li>
M templates/ref_table.html View
@@ -7,7 +7,7 @@
77 {%- for row in rows %}
88 <tr>
99 <td class="ref-name"><a href="{{ row.url }}">{{ row.name }}</a>{% if row.is_default %} <span class="chip">Default</span>{% endif %}</td>
10− <td class="ref-subject">{{ row.subject }}</td>
10+ <td class="ref-subject">{{ row.subject }}{% if !row.archives.is_empty() %}<span class="ref-archives">{% for (label, url) in row.archives %}<a class="label" href="{{ url }}" rel="nofollow">{{ label }}</a>{% endfor %}</span>{% endif %}</td>
1111 <td class="num"><time datetime="{{ row.iso }}" title="{{ row.iso }}">{{ row.age }}</time></td>
1212 </tr>
1313 {%- endfor %}
M templates/repo_nav.html View
@@ -4,6 +4,7 @@
44 <a class="label{% if nav.active == "tree" %} active{% endif %}" href="{{ nav.tree_url }}"{% if nav.active == "tree" %} aria-current="page"{% endif %}>Tree</a>
55 <a class="label{% if nav.active == "log" %} active{% endif %}" href="{{ nav.log_url }}"{% if nav.active == "log" %} aria-current="page"{% endif %}>Log</a>
66 <a class="label{% if nav.active == "refs" %} active{% endif %}" href="{{ nav.refs_url }}"{% if nav.active == "refs" %} aria-current="page"{% endif %}>Refs</a>
7+ <a class="label{% if nav.active == "find" %} active{% endif %}" href="{{ nav.find_url }}"{% if nav.active == "find" %} aria-current="page"{% endif %}>Find</a>
78 </nav>
89 {%- if let Some(picker) = nav.picker %}
910 <details class="ref-picker">
M templates/summary.html View
@@ -31,6 +31,19 @@
3131 </div>
3232 {%- endfor %}
3333 </dl>
34+ {%- if let Some(bar) = languages %}
35+ <section class="rail-block">
36+ <h2 class="label">Languages</h2>
37+ <div class="lang-bar" role="img" aria-label="{% for l in bar.legend %}{{ l.name }} {{ l.percent }}{% if !loop.last %}, {% endif %}{% endfor %}">
38+ {%- for b in bar.blocks %}<i class="{{ b }}"></i>{% endfor -%}
39+ </div>
40+ <ul class="lang-legend">
41+ {%- for l in bar.legend %}
42+ <li><i class="{{ l.class }}" aria-hidden="true"></i><span class="label">{{ l.name }}</span><span class="lang-pct">{{ l.percent }}</span></li>
43+ {%- endfor %}
44+ </ul>
45+ </section>
46+ {%- endif %}
3447 {%- if !clone_urls.is_empty() %}
3548 <section class="rail-block" data-clone>
3649 <div class="rail-head">
A tests/extras.rs View
@@ -0,0 +1,445 @@
1+//! Blame, archives, the Atom feed, the file finder, language statistics,
2+//! robots.txt and HTTPS clones, checked against the git CLI.
3+
4+mod common;
5+
6+use std::path::{Path, PathBuf};
7+use std::process::Command;
8+use std::sync::Arc;
9+
10+use axum::body::{Body, to_bytes};
11+use axum::http::{HeaderMap, Method, Request, StatusCode, header};
12+use common::{Work, bare, git};
13+use ferrenium::config::Config;
14+use ferrenium::index::languages;
15+use ferrenium::server;
16+use ferrenium::state::{AppState, Overrides};
17+use tower::ServiceExt;
18+
19+struct Fixture {
20+ bare: PathBuf,
21+ head: String,
22+}
23+
24+/// Several authors editing one file, a rename, vendored/generated/doc
25+/// files, a `.gitattributes` and a tag.
26+fn fixture(root: &Path) -> Fixture {
27+ let bare = bare(root, "ext.git", &[]);
28+ let work = Work::clone_of(&bare);
29+ let w = work.path();
30+ let write = |name: &str, data: &str| {
31+ let path = w.join(name);
32+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
33+ std::fs::write(path, data).unwrap();
34+ };
35+ // A day apart, so blame ages differ.
36+ let day = std::cell::Cell::new(0);
37+ let commit = |message: &str, author: &str| {
38+ git(w, &["add", "-A"]);
39+ day.set(day.get() + 1);
40+ let date = format!("{} +0000", 1_700_000_000 + day.get() * 86_400);
41+ let status = Command::new("git")
42+ .args(["commit", "-q", "-m", message, "--author", author])
43+ .current_dir(w)
44+ .env("GIT_CONFIG_NOSYSTEM", "1")
45+ .env("GIT_CONFIG_GLOBAL", "/dev/null")
46+ .env("GIT_COMMITTER_NAME", "Ada")
47+ .env("GIT_COMMITTER_EMAIL", "ada@example.com")
48+ .env("GIT_AUTHOR_DATE", &date)
49+ .env("GIT_COMMITTER_DATE", &date)
50+ .status()
51+ .unwrap();
52+ assert!(status.success());
53+ };
54+ write("src/old.rs", "fn a() {}\nfn b() {}\nfn c() {}\n");
55+ write("README.md", "# Ext\n");
56+ commit("Start", "Ada <ada@example.com>");
57+ write(
58+ "src/old.rs",
59+ "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n",
60+ );
61+ commit("Edit b, add d", "Bob <bob@example.com>");
62+ git(w, &["mv", "src/old.rs", "src/main.rs"]);
63+ commit("Rename", "Ada <ada@example.com>");
64+ write(
65+ "src/main.rs",
66+ "// top\nfn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n",
67+ );
68+ write("run.sh", "#!/bin/sh\necho run\n");
69+ write("vendor/lib.js", &"var x = 1;\n".repeat(500));
70+ write("docs/conf.py", &"x = 1\n".repeat(500));
71+ write("app.min.js", &"var y=2;".repeat(500));
72+ write("extra/big.c", &"int x;\n".repeat(500));
73+ write(".gitattributes", "extra/** linguist-vendored\n");
74+ commit("Add the rest", "Cy <cy@example.com>");
75+ git(w, &["tag", "v1.0"]);
76+ work.push("main");
77+ git(w, &["push", "-q", "origin", "--tags"]);
78+ let head = git(w, &["rev-parse", "HEAD"]);
79+ Fixture { bare, head }
80+}
81+
82+struct Site {
83+ _root: tempfile::TempDir,
84+ state: Arc<AppState>,
85+ app: axum::Router,
86+ f: Fixture,
87+}
88+
89+/// `repos` is extra lines for the `[repos]` table.
90+fn site(repos: &str) -> Site {
91+ let root = tempfile::tempdir().unwrap();
92+ let f = fixture(root.path());
93+ let toml = format!(
94+ "[repos]\nscan_path = {:?}\n{repos}\n[watch]\nenabled = false\n\
95+ [server]\nbase_url = \"https://git.example.com\"\n",
96+ root.path().to_str().unwrap()
97+ );
98+ let mut config = Config::parse(&toml).expect("parse");
99+ config.finish().expect("valid");
100+ let state = AppState::new(config, Overrides::default());
101+ let config = state.config.load_full();
102+ state.index.full_scan(&config);
103+ Site {
104+ app: server::app(state.clone()),
105+ state,
106+ f,
107+ _root: root,
108+ }
109+}
110+
111+struct Res {
112+ status: StatusCode,
113+ headers: HeaderMap,
114+ body: Vec<u8>,
115+}
116+
117+impl Res {
118+ fn text(&self) -> String {
119+ String::from_utf8_lossy(&self.body).into_owned()
120+ }
121+}
122+
123+async fn request(app: &axum::Router, method: Method, uri: &str) -> Res {
124+ let req = Request::builder()
125+ .method(method)
126+ .uri(uri)
127+ .body(Body::empty())
128+ .unwrap();
129+ let res = app.clone().oneshot(req).await.unwrap();
130+ let status = res.status();
131+ let headers = res.headers().clone();
132+ let body = to_bytes(res.into_body(), usize::MAX)
133+ .await
134+ .unwrap()
135+ .to_vec();
136+ Res {
137+ status,
138+ headers,
139+ body,
140+ }
141+}
142+
143+async fn get(app: &axum::Router, uri: &str) -> Res {
144+ request(app, Method::GET, uri).await
145+}
146+
147+async fn ok(app: &axum::Router, uri: &str) -> String {
148+ let res = get(app, uri).await;
149+ assert_eq!(res.status, StatusCode::OK, "{uri}: {}", res.text());
150+ res.text()
151+}
152+
153+/// Commit of every line, from the page's hunk heads.
154+fn blamed_lines(html: &str) -> Vec<String> {
155+ let mut out = Vec::new();
156+ for part in html.split(r#"class="blame-meta "#).skip(1) {
157+ let rows: usize = part
158+ .split(r#"rowspan=""#)
159+ .nth(1)
160+ .unwrap()
161+ .split('"')
162+ .next()
163+ .unwrap()
164+ .parse()
165+ .unwrap();
166+ let id = part.split("/commit/").nth(1).unwrap()[..40].to_string();
167+ out.extend(std::iter::repeat_n(id, rows));
168+ }
169+ out
170+}
171+
172+#[tokio::test]
173+async fn blame_matches_git() {
174+ let s = site("");
175+ for path in ["src/main.rs", "run.sh", "README.md"] {
176+ let html = ok(&s.app, &format!("/ext/blame/main/{path}")).await;
177+ let porcelain = git(&s.f.bare, &["blame", "--porcelain", "main", "--", path]);
178+ let expected: Vec<String> = porcelain
179+ .lines()
180+ .filter(|l| {
181+ l.len() > 41
182+ && l.as_bytes()[40] == b' '
183+ && l[..40].bytes().all(|b| b.is_ascii_hexdigit())
184+ })
185+ .map(|l| l[..40].to_string())
186+ .collect();
187+ assert_eq!(blamed_lines(&html), expected, "{path}");
188+ }
189+ let html = ok(&s.app, "/ext/blame/main/src/main.rs").await;
190+ // Hunk heads: author, age bar (newest is full strength), links.
191+ assert!(html.contains("blame-meta age-8"), "{html}");
192+ assert!(html.contains("blame-meta age-0"));
193+ assert!(html.contains(">Bob<"));
194+ assert!(html.contains("5 lines · 3 commits"), "{html}");
195+ assert!(html.contains(r#"href="/ext/blob/main/src/main.rs">File<"#));
196+ // The blob page links here; directories redirect; binaries don't blame.
197+ let blob = ok(&s.app, "/ext/blob/main/src/main.rs").await;
198+ assert!(blob.contains(r#"href="/ext/blame/main/src/main.rs">Blame<"#));
199+ let res = get(&s.app, "/ext/blame/main/src").await;
200+ assert_eq!(res.status, StatusCode::TEMPORARY_REDIRECT);
201+ let res = get(&s.app, "/ext/blame/main/nope").await;
202+ assert_eq!(res.status, StatusCode::NOT_FOUND);
203+}
204+
205+fn tar_listing(data: &[u8]) -> String {
206+ let mut child = Command::new("tar")
207+ .args(["tzf", "-"])
208+ .stdin(std::process::Stdio::piped())
209+ .stdout(std::process::Stdio::piped())
210+ .spawn()
211+ .unwrap();
212+ use std::io::Write;
213+ child.stdin.take().unwrap().write_all(data).unwrap();
214+ let out = child.wait_with_output().unwrap();
215+ // git also lists directories; only files matter.
216+ let mut lines: Vec<&str> = std::str::from_utf8(&out.stdout)
217+ .unwrap()
218+ .lines()
219+ .filter(|l| !l.ends_with('/'))
220+ .collect();
221+ lines.sort();
222+ lines.join("\n")
223+}
224+
225+#[tokio::test]
226+async fn archives_match_git_archive() {
227+ let s = site("");
228+ let res = get(&s.app, "/ext/archive/v1.0.tar.gz").await;
229+ assert_eq!(res.status, StatusCode::OK);
230+ assert_eq!(res.headers[header::CONTENT_TYPE], "application/gzip");
231+ assert!(
232+ res.headers[header::CONTENT_DISPOSITION]
233+ .to_str()
234+ .unwrap()
235+ .contains("filename=\"ext-v1.0.tar.gz\"")
236+ );
237+ assert!(res.headers.get(header::CONTENT_ENCODING).is_none());
238+ let ours = tar_listing(&res.body);
239+ let theirs = Command::new("git")
240+ .args(["archive", "--format=tar.gz", "--prefix=ext-v1.0/", "v1.0"])
241+ .current_dir(&s.f.bare)
242+ .output()
243+ .unwrap()
244+ .stdout;
245+ assert_eq!(ours, tar_listing(&theirs));
246+ assert!(ours.contains("ext-v1.0/src/main.rs"));
247+
248+ let res = get(&s.app, &format!("/ext/archive/{}.zip", s.f.head)).await;
249+ assert_eq!(res.status, StatusCode::OK);
250+ assert_eq!(res.headers[header::CONTENT_TYPE], "application/zip");
251+ assert_eq!(res.headers[header::CACHE_CONTROL], "public, max-age=86400");
252+ assert_eq!(&res.body[..2], b"PK");
253+
254+ for uri in [
255+ "/ext/archive/nope.zip",
256+ "/ext/archive/main.rar",
257+ "/ext/archive/main",
258+ ] {
259+ assert_eq!(
260+ get(&s.app, uri).await.status,
261+ StatusCode::NOT_FOUND,
262+ "{uri}"
263+ );
264+ }
265+ let s = site("snapshots = [\"tar.gz\"]");
266+ assert_eq!(
267+ get(&s.app, "/ext/archive/main.zip").await.status,
268+ StatusCode::NOT_FOUND
269+ );
270+ let refs = ok(&s.app, "/ext/refs").await;
271+ assert!(
272+ refs.contains(r#"href="/ext/archive/v1.0.tar.gz""#),
273+ "{refs}"
274+ );
275+ assert!(!refs.contains(".zip"));
276+}
277+
278+#[tokio::test]
279+async fn atom_feed() {
280+ let s = site("");
281+ let res = get(&s.app, "/ext/atom").await;
282+ assert_eq!(res.status, StatusCode::OK);
283+ assert_eq!(
284+ res.headers[header::CONTENT_TYPE],
285+ "application/atom+xml; charset=utf-8"
286+ );
287+ let xml = res.text();
288+ assert!(xml.starts_with("<?xml"));
289+ assert_eq!(xml.matches("<entry>").count(), 4);
290+ assert!(xml.contains("<id>https://git.example.com/ext/atom</id>"));
291+ assert!(xml.contains(&format!(
292+ r#"href="https://git.example.com/ext/commit/{}""#,
293+ s.f.head
294+ )));
295+ assert!(xml.contains("<name>Cy</name>"));
296+ assert_eq!(
297+ get(&s.app, "/ext/atom?h=nope").await.status,
298+ StatusCode::NOT_FOUND
299+ );
300+ // Advertised on the summary.
301+ let html = ok(&s.app, "/ext").await;
302+ assert!(html.contains(
303+ r#"<link rel="alternate" type="application/atom+xml" title="Commits" href="/ext/atom">"#
304+ ));
305+}
306+
307+#[tokio::test]
308+async fn find_files() {
309+ let s = site("");
310+ let html = ok(&s.app, "/ext/find/main?q=MAIN").await;
311+ assert!(
312+ html.contains(r#"href="/ext/blob/main/src/main.rs""#),
313+ "{html}"
314+ );
315+ assert!(html.contains("1 of 8 files"), "{html}");
316+ let html = ok(&s.app, "/ext/find/main?q=zzz").await;
317+ assert!(html.contains("[No matches]"));
318+ let html = ok(&s.app, "/ext/find/main").await;
319+ assert!(html.contains("8 files"));
320+ assert!(html.contains(r#"aria-current="page">Find<"#));
321+}
322+
323+#[tokio::test]
324+async fn language_statistics() {
325+ let s = site("");
326+ let repo = s.state.index.load().repos["ext"].clone();
327+ let handle = ferrenium::git::handle(&repo.gix);
328+ let tree = languages::root_tree(&handle, repo.meta.head.unwrap()).unwrap();
329+ let stats = languages::compute(&handle, tree).unwrap();
330+ let names: Vec<&str> = stats.languages.iter().map(|(n, _)| *n).collect();
331+ // Vendored, documentation, minified and attribute-vendored files, and
332+ // prose (README), don't count.
333+ assert_eq!(names, ["Rust", "Shell"], "{stats:?}");
334+
335+ // The summary shows the bar once the warmer has run.
336+ let html = ok(&s.app, "/ext").await;
337+ assert!(!html.contains("lang-bar"));
338+ s.state
339+ .cache
340+ .languages(tree, || Ok::<_, std::convert::Infallible>(stats))
341+ .unwrap();
342+ let html = ok(&s.app, "/ext").await;
343+ assert!(html.contains(r#"class="lang-bar""#), "{html}");
344+ assert_eq!(
345+ html.matches(r#"<i class="o0"></i>"#).count()
346+ + html.matches(r#"<i class="o1"></i>"#).count(),
347+ 40
348+ );
349+}
350+
351+#[tokio::test]
352+async fn robots_and_methods() {
353+ let s = site("");
354+ let robots = ok(&s.app, "/robots.txt").await;
355+ assert!(robots.contains("Disallow: /*/blame/"));
356+ assert!(robots.contains("Disallow: /*/archive/"));
357+ assert!(robots.contains("Disallow: /*?q="));
358+ assert_eq!(
359+ request(&s.app, Method::POST, "/ext/tree/main").await.status,
360+ StatusCode::METHOD_NOT_ALLOWED
361+ );
362+ assert_eq!(
363+ get(&s.app, "/ext/info/refs?service=git-receive-pack")
364+ .await
365+ .status,
366+ StatusCode::FORBIDDEN
367+ );
368+ assert_eq!(
369+ get(&s.app, "/ext/info/refs").await.status,
370+ StatusCode::FORBIDDEN
371+ );
372+ assert_eq!(
373+ request(&s.app, Method::POST, "/ext.git/git-receive-pack")
374+ .await
375+ .status,
376+ StatusCode::FORBIDDEN
377+ );
378+ assert_eq!(
379+ get(&s.app, "/ext.git/tree/main").await.status,
380+ StatusCode::NOT_FOUND
381+ );
382+ let s = site("https_clone = false");
383+ assert_eq!(
384+ get(&s.app, "/ext/info/refs?service=git-upload-pack")
385+ .await
386+ .status,
387+ StatusCode::NOT_FOUND
388+ );
389+}
390+
391+#[tokio::test(flavor = "multi_thread")]
392+async fn clone_over_https() {
393+ let s = site("");
394+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
395+ let addr = listener.local_addr().unwrap();
396+ let app = s.app.clone();
397+ tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
398+
399+ let dest = tempfile::tempdir().unwrap();
400+ let head = s.f.head.clone();
401+ let out = tokio::task::spawn_blocking(move || {
402+ let mut results = Vec::new();
403+ for (url, dir, protocol) in [
404+ (format!("http://{addr}/ext.git"), "v2", "2"),
405+ (format!("http://{addr}/ext"), "v0", "0"),
406+ ] {
407+ let target = dest.path().join(dir);
408+ let status = Command::new("git")
409+ .args([
410+ "-c",
411+ &format!("protocol.version={protocol}"),
412+ "clone",
413+ "-q",
414+ &url,
415+ ])
416+ .arg(&target)
417+ .env("GIT_CONFIG_NOSYSTEM", "1")
418+ .env("GIT_CONFIG_GLOBAL", "/dev/null")
419+ .env("GIT_TERMINAL_PROMPT", "0")
420+ .status()
421+ .unwrap();
422+ assert!(status.success(), "clone {url}");
423+ results.push(git(&target, &["rev-parse", "HEAD"]));
424+ }
425+ // Pushing is refused.
426+ let work = dest.path().join("v2");
427+ std::fs::write(work.join("new.txt"), "x").unwrap();
428+ git(&work, &["add", "-A"]);
429+ git(&work, &["commit", "-q", "-m", "x"]);
430+ let push = Command::new("git")
431+ .args(["push", "-q", "origin", "main"])
432+ .current_dir(&work)
433+ .env("GIT_CONFIG_NOSYSTEM", "1")
434+ .env("GIT_CONFIG_GLOBAL", "/dev/null")
435+ .env("GIT_TERMINAL_PROMPT", "0")
436+ .output()
437+ .unwrap();
438+ assert!(!push.status.success());
439+ assert!(String::from_utf8_lossy(&push.stderr).contains("403"));
440+ results
441+ })
442+ .await
443+ .unwrap();
444+ assert_eq!(out, vec![head.clone(), head]);
445+}
A tools/gen-languages/gen.py View
@@ -0,0 +1,169 @@
1+#!/usr/bin/env python3
2+"""Generate src/index/languages_table.rs from GitHub Linguist's data.
3+
4+Dev-only; the output is committed. Needs PyYAML.
5+
6+ tools/gen-languages/gen.py [VERSION] # default: the pinned version
7+
8+Downloads languages.yml, vendor.yml and documentation.yml for VERSION and
9+writes the table. Re-run when bumping Linguist.
10+"""
11+
12+import pathlib
13+import sys
14+import urllib.request
15+
16+import yaml
17+
18+VERSION = sys.argv[1] if len(sys.argv) > 1 else "v9.7.0"
19+BASE = f"https://raw.githubusercontent.com/github-linguist/linguist/{VERSION}/lib/linguist/"
20+OUT = pathlib.Path(__file__).resolve().parents[2] / "src/index/languages_table.rs"
21+
22+# Extensions claimed by several languages, settled by hand (Linguist uses
23+# content heuristics we don't run). Everything else: see `pick`.
24+OVERRIDES = {
25+ ".h": "C",
26+ ".m": "Objective-C",
27+ ".pl": "Perl",
28+ ".pm": "Perl",
29+ ".t": "Perl",
30+ ".md": "Markdown",
31+ ".ts": "TypeScript",
32+ ".sql": "SQL",
33+ ".inc": "PHP",
34+ ".v": "Verilog",
35+ ".fs": "F#",
36+ ".r": "R",
37+ ".d": "D",
38+ ".cls": "TeX",
39+ ".pro": "Prolog",
40+ ".s": "Unix Assembly",
41+ ".asm": "Assembly",
42+ ".l": "Lex",
43+ ".ms": "Roff",
44+ ".bf": "Brainfuck",
45+ ".cs": "C#",
46+ ".rs": "Rust",
47+ ".html": "HTML",
48+ ".txt": "Text",
49+ ".json": "JSON",
50+ ".yml": "YAML",
51+ ".xml": "XML",
52+ ".sh": "Shell",
53+ ".mod": "Modula-2",
54+ ".gs": "JavaScript",
55+ ".ex": "Elixir",
56+ ".nl": "NewLisp",
57+ ".lisp": "Common Lisp",
58+ ".cl": "Common Lisp",
59+ ".tst": "Scilab",
60+ ".x": "Logos",
61+ ".b": "Brainfuck",
62+ ".n": "Roff",
63+ ".i": "SWIG",
64+ ".tpl": "Smarty",
65+ ".sc": "Scala",
66+ ".st": "Smalltalk",
67+ ".frm": "VBA",
68+ ".bas": "BASIC",
69+ ".fx": "HLSL",
70+ ".mask": "Unity3D Asset",
71+ ".plt": "Gnuplot",
72+ ".ncl": "NCL",
73+ ".lsp": "Common Lisp",
74+ ".brd": "Eagle",
75+ ".mdx": "MDX",
76+ ".cake": "C#",
77+ ".builds": "XML",
78+ ".ice": "JSON",
79+ ".rpy": "Ren'Py",
80+ ".sol": "Solidity",
81+ ".yaml": "YAML",
82+ ".cfg": "INI",
83+ ".f": "Fortran",
84+ ".es": "JavaScript",
85+}
86+
87+KIND_RANK = {"programming": 0, "markup": 1, "data": 2, "prose": 3}
88+
89+
90+def fetch(name):
91+ with urllib.request.urlopen(BASE + name) as res:
92+ return yaml.safe_load(res.read())
93+
94+
95+def rust_str(s):
96+ return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
97+
98+
99+def main():
100+ languages = fetch("languages.yml")
101+ vendor = fetch("vendor.yml")
102+ documentation = fetch("documentation.yml")
103+
104+ names = sorted(languages)
105+ index = {name: i for i, name in enumerate(names)}
106+
107+ def pick(ext, candidates):
108+ if ext in OVERRIDES and OVERRIDES[ext] in candidates:
109+ return OVERRIDES[ext]
110+ primary = [c for c in candidates if languages[c].get("extensions", [None])[0] == ext]
111+ pool = primary or candidates
112+ return min(pool, key=lambda c: (KIND_RANK.get(languages[c]["type"], 9), c))
113+
114+ by_ext = {}
115+ by_name = {}
116+ for name in names:
117+ lang = languages[name]
118+ for ext in lang.get("extensions", []):
119+ by_ext.setdefault(ext.lower(), []).append(name)
120+ for filename in lang.get("filenames", []):
121+ by_name.setdefault(filename, []).append(name)
122+
123+ extensions = sorted((ext, index[pick(ext, c)]) for ext, c in by_ext.items())
124+ filenames = sorted(
125+ (f, index[min(c, key=lambda n: (KIND_RANK.get(languages[n]["type"], 9), n))])
126+ for f, c in by_name.items()
127+ )
128+
129+ out = []
130+ w = out.append
131+ w(f"// @generated by tools/gen-languages from GitHub Linguist {VERSION}. Do not edit.\n")
132+ w("// Linguist is MIT licensed: https://github.com/github-linguist/linguist\n\n")
133+ w("use super::languages::{Kind, Language};\n\n")
134+ w(f"pub const LINGUIST_VERSION: &str = {rust_str(VERSION)};\n\n")
135+ w("/// Every language; `group` folds a language into its parent for statistics.\n")
136+ w("pub static LANGUAGES: &[Language] = &[\n")
137+ for name in names:
138+ lang = languages[name]
139+ kind = lang["type"].capitalize()
140+ group = lang.get("group")
141+ group = f"Some({index[group]})" if group in index else "None"
142+ w(f" Language {{ name: {rust_str(name)}, kind: Kind::{kind}, group: {group} }},\n")
143+ w("];\n\n")
144+ w("/// Lowercase extensions (with the dot), sorted, to an index into `LANGUAGES`.\n")
145+ w("pub static EXTENSIONS: &[(&str, u16)] = &[\n")
146+ for ext, i in extensions:
147+ w(f" ({rust_str(ext)}, {i}),\n")
148+ w("];\n\n")
149+ w("/// Exact file names, sorted, to an index into `LANGUAGES`.\n")
150+ w("pub static FILENAMES: &[(&str, u16)] = &[\n")
151+ for f, i in filenames:
152+ w(f" ({rust_str(f)}, {i}),\n")
153+ w("];\n\n")
154+ for title, const, patterns in [
155+ ("Paths of vendored code (regular expressions).", "VENDOR", vendor),
156+ ("Paths of documentation (regular expressions).", "DOCUMENTATION", documentation),
157+ ]:
158+ w(f"/// {title}\n")
159+ w(f"pub static {const}: &[&str] = &[\n")
160+ for p in patterns:
161+ w(f" {rust_str(p)},\n")
162+ w("];\n\n")
163+ OUT.write_text("".join(out).rstrip() + "\n")
164+ print(f"wrote {OUT} ({len(names)} languages, {len(extensions)} extensions, "
165+ f"{len(filenames)} file names)")
166+
167+
168+if __name__ == "__main__":
169+ main()