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.
+9127 −29
38 files changed
- M Cargo.lock +37 −0
- M Cargo.toml +7 −1
- M assets/css/components.css +10 −0
- M assets/css/repo.css +158 −0
- M src/cache.rs +65 −0
- A src/git/archive.rs +97 −0
- A src/git/blame.rs +67 −0
- M src/git/mod.rs +2 −0
- M src/git/tree.rs +63 −0
- A src/handlers/archive.rs +108 −0
- A src/handlers/blame.rs +211 −0
- M src/handlers/blob.rs +2 −0
- A src/handlers/feed.rs +127 −0
- A src/handlers/find.rs +170 −0
- M src/handlers/index.rs +3 −0
- M src/handlers/log.rs +4 −1
- M src/handlers/mod.rs +7 −0
- M src/handlers/refs.rs +31 −1
- M src/handlers/repo.rs +46 −3
- M src/handlers/summary.rs +126 −2
- A src/index/languages.rs +410 −0
- A src/index/languages_table.rs +6282 −0
- M src/index/mod.rs +2 −0
- A src/server/clone.rs +264 −0
- M src/server/dispatch.rs +46 −14
- M src/server/mod.rs +44 −4
- M src/state.rs +16 −2
- A templates/atom.xml +26 −0
- M templates/base.html +3 −0
- A templates/blame.html +34 −0
- M templates/blob.html +1 −0
- A templates/find.html +28 −0
- M templates/index.html +1 −0
- M templates/ref_table.html +1 −1
- M templates/repo_nav.html +1 −0
- M templates/summary.html +13 −0
- A tests/extras.rs +445 −0
- A tools/gen-languages/gen.py +169 −0
| @@ -728,9 +728,13 @@ dependencies = [ | |||
| 728 | 728 | "arc-swap", | |
| 729 | 729 | "askama", | |
| 730 | 730 | "axum", | |
| 731 | + | "bytes", | |
| 731 | 732 | "clap", | |
| 732 | 733 | "comrak", | |
| 734 | + | "fancy-regex", | |
| 735 | + | "futures-util", | |
| 733 | 736 | "gix", | |
| 737 | + | "gix-archive", | |
| 734 | 738 | "jiff", | |
| 735 | 739 | "mime_guess", | |
| 736 | 740 | "moka", | |
| @@ -742,6 +746,7 @@ dependencies = [ | |||
| 742 | 746 | "tempfile", | |
| 743 | 747 | "thiserror", | |
| 744 | 748 | "tokio", | |
| 749 | + | "tokio-util", | |
| 745 | 750 | "toml", | |
| 746 | 751 | "tower", | |
| 747 | 752 | "tower-http", | |
| @@ -912,10 +917,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 912 | 917 | checksum = "cb5fef3fbef9788069016ae1b18e899ac22a508387f9935bb8185c280ef01333" | |
| 913 | 918 | dependencies = [ | |
| 914 | 919 | "bstr", | |
| 920 | + | "flate2", | |
| 915 | 921 | "gix-date", | |
| 916 | 922 | "gix-error", | |
| 917 | 923 | "gix-object", | |
| 924 | + | "gix-path", | |
| 918 | 925 | "gix-worktree-stream", | |
| 926 | + | "rawzip", | |
| 927 | + | "tar", | |
| 919 | 928 | ] | |
| 920 | 929 | ||
| 921 | 930 | [[package]] | |
| @@ -2402,6 +2411,12 @@ version = "6.0.0" | |||
| 2402 | 2411 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2403 | 2412 | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" | |
| 2404 | 2413 | ||
| 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 | + | ||
| 2405 | 2420 | [[package]] | |
| 2406 | 2421 | name = "redox_syscall" | |
| 2407 | 2422 | version = "0.5.18" | |
| @@ -2768,6 +2783,17 @@ version = "0.2.0" | |||
| 2768 | 2783 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2769 | 2784 | checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" | |
| 2770 | 2785 | ||
| 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 | + | ||
| 2771 | 2797 | [[package]] | |
| 2772 | 2798 | name = "tempfile" | |
| 2773 | 2799 | version = "3.27.0" | |
| @@ -2841,6 +2867,7 @@ version = "1.54.1" | |||
| 2841 | 2867 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2842 | 2868 | checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" | |
| 2843 | 2869 | dependencies = [ | |
| 2870 | + | "bytes", | |
| 2844 | 2871 | "libc", | |
| 2845 | 2872 | "mio", | |
| 2846 | 2873 | "pin-project-lite", | |
| @@ -3244,6 +3271,16 @@ version = "0.6.4" | |||
| 3244 | 3271 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3245 | 3272 | checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" | |
| 3246 | 3273 | ||
| 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 | + | ||
| 3247 | 3284 | [[package]] | |
| 3248 | 3285 | name = "yoke" | |
| 3249 | 3286 | version = "0.8.4" | |
| @@ -10,12 +10,17 @@ publish = false | |||
| 10 | 10 | ammonia = "4.2" | |
| 11 | 11 | anyhow = "1.0.104" | |
| 12 | 12 | arc-swap = "1.9.3" | |
| 13 | + | bytes = "1" | |
| 13 | 14 | askama = "0.16.1" | |
| 14 | 15 | axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio", "query"] } | |
| 15 | 16 | clap = { version = "4.6.7", features = ["derive", "env"] } | |
| 16 | 17 | comrak = { version = "0.59", default-features = false } | |
| 18 | + | fancy-regex = "0.16" | |
| 19 | + | futures-util = { version = "0.3", default-features = false, features = ["std"] } | |
| 17 | 20 | # No `extras`/`interrupt`: they install their own signal handlers. | |
| 18 | 21 | 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"] } | |
| 19 | 24 | jiff = "0.2.39" | |
| 20 | 25 | mime_guess = "2.0.5" | |
| 21 | 26 | moka = { version = "0.12", features = ["sync"] } | |
| @@ -25,7 +30,8 @@ sd-notify = "0.5.0" | |||
| 25 | 30 | serde = { version = "1.0.229", features = ["derive"] } | |
| 26 | 31 | syntect = { version = "5.3", default-features = false, features = ["parsing", "html", "regex-fancy"] } | |
| 27 | 32 | 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"] } | |
| 29 | 35 | toml = "1.2.0" | |
| 30 | 36 | tower = { version = "0.5.3", features = ["util"] } | |
| 31 | 37 | tower-http = { version = "0.7.1", features = ["trace", "timeout", "catch-panic", "compression-gzip", "compression-br", "set-header"] } | |
| @@ -380,6 +380,16 @@ html:not(.js) .theme-switch { | |||
| 380 | 380 | white-space: nowrap; | |
| 381 | 381 | } | |
| 382 | 382 | ||
| 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 | + | ||
| 383 | 393 | /* Data-status dot: pushed in the last 24 hours. */ | |
| 384 | 394 | .dot { | |
| 385 | 395 | width: 6px; | |
| @@ -290,6 +290,66 @@ html:not(.js) .copy-status { | |||
| 290 | 290 | margin-top: var(--space-xs); | |
| 291 | 291 | } | |
| 292 | 292 | ||
| 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 | + | ||
| 293 | 353 | .latest-subject { | |
| 294 | 354 | margin-top: var(--space-sm); | |
| 295 | 355 | color: var(--text-primary); | |
| @@ -540,6 +600,20 @@ html:not(.js) .copy-status { | |||
| 540 | 600 | overflow-wrap: anywhere; | |
| 541 | 601 | } | |
| 542 | 602 | ||
| 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 | + | ||
| 543 | 617 | .chip { | |
| 544 | 618 | display: inline-block; | |
| 545 | 619 | margin-left: var(--space-sm); | |
| @@ -867,6 +941,90 @@ html:not(.js) .copy-status { | |||
| 867 | 941 | border-top: 1px solid var(--border); | |
| 868 | 942 | } | |
| 869 | 943 | ||
| 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 | + | ||
| 870 | 1028 | /* ---- Markdown ------------------------------------------------------------ */ | |
| 871 | 1029 | ||
| 872 | 1030 | .markdown { | |
| @@ -6,10 +6,12 @@ | |||
| 6 | 6 | ||
| 7 | 7 | use std::sync::Arc; | |
| 8 | 8 | ||
| 9 | + | use crate::git::blame::BlameHunk; | |
| 9 | 10 | use crate::git::log::{LogPage, LogQuery}; | |
| 10 | 11 | use crate::git::refs::RefList; | |
| 11 | 12 | use crate::git::tree::TreeEntry; | |
| 12 | 13 | use crate::git::{Mailmap, ObjectId}; | |
| 14 | + | use crate::index::languages::Stats as LanguageStats; | |
| 13 | 15 | use crate::render::diff::{CommitDiff, Options as DiffOptions}; | |
| 14 | 16 | ||
| 15 | 17 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] | |
| @@ -39,6 +41,14 @@ pub enum Key { | |||
| 39 | 41 | options: DiffOptions, | |
| 40 | 42 | }, | |
| 41 | 43 | 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), | |
| 42 | 52 | } | |
| 43 | 53 | ||
| 44 | 54 | #[derive(Clone)] | |
| @@ -51,6 +61,9 @@ pub enum Value { | |||
| 51 | 61 | Log(Arc<LogPage>), | |
| 52 | 62 | Diff(Arc<CommitDiff>), | |
| 53 | 63 | Mailmap(Arc<Mailmap>), | |
| 64 | + | Blame(Arc<Vec<BlameHunk>>), | |
| 65 | + | Paths(Arc<(Vec<String>, bool)>), | |
| 66 | + | Languages(Arc<LanguageStats>), | |
| 54 | 67 | } | |
| 55 | 68 | ||
| 56 | 69 | impl Value { | |
| @@ -64,6 +77,12 @@ impl Value { | |||
| 64 | 77 | Value::Log(page) => page.weight(), | |
| 65 | 78 | Value::Diff(diff) => diff.weight(), | |
| 66 | 79 | 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(), | |
| 67 | 86 | } | |
| 68 | 87 | } | |
| 69 | 88 | } | |
| @@ -81,6 +100,7 @@ impl Cache { | |||
| 81 | 100 | Key::Highlight { syntax, .. } => syntax.len(), | |
| 82 | 101 | Key::Markdown { context, .. } => context.len(), | |
| 83 | 102 | Key::Refs { repo, .. } => repo.len(), | |
| 103 | + | Key::Blame { path, .. } => path.len(), | |
| 84 | 104 | Key::Log { query, .. } => { | |
| 85 | 105 | query.path.len() + query.grep.as_ref().map_or(0, String::len) | |
| 86 | 106 | } | |
| @@ -191,6 +211,51 @@ impl Cache { | |||
| 191 | 211 | } | |
| 192 | 212 | } | |
| 193 | 213 | ||
| 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 | + | ||
| 194 | 259 | pub fn entry_count(&self) -> u64 { | |
| 195 | 260 | self.inner.run_pending_tasks(); | |
| 196 | 261 | self.inner.entry_count() | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | //! All repository access goes through this module; nothing else touches gix. | |
| 2 | 2 | ||
| 3 | + | pub mod archive; | |
| 4 | + | pub mod blame; | |
| 3 | 5 | pub mod diff; | |
| 4 | 6 | pub mod log; | |
| 5 | 7 | pub mod refs; | |
| @@ -135,6 +135,69 @@ pub fn list(repo: &gix::Repository, tree: ObjectId) -> anyhow::Result<Vec<TreeEn | |||
| 135 | 135 | Ok(entries) | |
| 136 | 136 | } | |
| 137 | 137 | ||
| 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 | + | ||
| 138 | 201 | /// Full contents of a blob. | |
| 139 | 202 | pub fn blob(repo: &gix::Repository, oid: ObjectId) -> anyhow::Result<Vec<u8>> { | |
| 140 | 203 | let mut blob = repo.find_blob(oid).context("blob not found")?; | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -45,6 +45,7 @@ pub struct BlobPage { | |||
| 45 | 45 | pub facts: Vec<String>, | |
| 46 | 46 | pub raw_url: String, | |
| 47 | 47 | pub log_url: String, | |
| 48 | + | pub blame_url: String, | |
| 48 | 49 | pub download_url: String, | |
| 49 | 50 | pub toggle: Option<ViewToggle>, | |
| 50 | 51 | pub body: Body, | |
| @@ -165,6 +166,7 @@ pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response { | |||
| 165 | 166 | facts, | |
| 166 | 167 | download_url: format!("{raw_url}?download"), | |
| 167 | 168 | log_url: ctx.url("log", &rev.name, &path), | |
| 169 | + | blame_url: ctx.url("blame", &rev.name, &path), | |
| 168 | 170 | raw_url, | |
| 169 | 171 | toggle, | |
| 170 | 172 | body, | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -31,6 +31,8 @@ pub struct Row { | |||
| 31 | 31 | pub age: Option<String>, | |
| 32 | 32 | pub iso: Option<String>, | |
| 33 | 33 | pub fresh: bool, | |
| 34 | + | /// Main language, once the background statistics have it. | |
| 35 | + | pub language: Option<&'static str>, | |
| 34 | 36 | /// Lowercased text the client-side filter matches against. | |
| 35 | 37 | pub search: String, | |
| 36 | 38 | } | |
| @@ -104,6 +106,7 @@ pub async fn index( | |||
| 104 | 106 | .meta | |
| 105 | 107 | .last_activity | |
| 106 | 108 | .is_some_and(|t| now - t < FRESH_SECS), | |
| 109 | + | language: state.warmer.primary(&repo.name), | |
| 107 | 110 | search: search_text(repo), | |
| 108 | 111 | }; | |
| 109 | 112 | ||
| @@ -147,7 +147,10 @@ pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response { | |||
| 147 | 147 | }; | |
| 148 | 148 | let older_url = found.older.map(|id| link(Some(id))); | |
| 149 | 149 | 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 | + | }, | |
| 151 | 154 | nav: ctx.nav(Active::Log, Some(&rev), |name| ctx.url("log", name, &path)), | |
| 152 | 155 | crumbs, | |
| 153 | 156 | here: last, | |
| @@ -1,8 +1,12 @@ | |||
| 1 | 1 | //! Request handlers. Each page is an askama template that embeds a [`Layout`]. | |
| 2 | 2 | ||
| 3 | + | pub mod archive; | |
| 4 | + | pub mod blame; | |
| 3 | 5 | pub mod blob; | |
| 4 | 6 | pub mod commit; | |
| 5 | 7 | pub mod error; | |
| 8 | + | pub mod feed; | |
| 9 | + | pub mod find; | |
| 6 | 10 | pub mod index; | |
| 7 | 11 | pub mod log; | |
| 8 | 12 | pub mod raw; | |
| @@ -37,6 +41,8 @@ pub struct Layout { | |||
| 37 | 41 | pub header: Option<Arc<str>>, | |
| 38 | 42 | pub footer: Option<Arc<str>>, | |
| 39 | 43 | pub version: &'static str, | |
| 44 | + | /// Atom feed advertised in `<head>`. | |
| 45 | + | pub feed: Option<String>, | |
| 40 | 46 | } | |
| 41 | 47 | ||
| 42 | 48 | impl Layout { | |
| @@ -57,6 +63,7 @@ impl Layout { | |||
| 57 | 63 | header: config.custom.header.clone(), | |
| 58 | 64 | footer: config.custom.footer.clone(), | |
| 59 | 65 | version: env!("CARGO_PKG_VERSION"), | |
| 66 | + | feed: None, | |
| 60 | 67 | } | |
| 61 | 68 | } | |
| 62 | 69 | } | |
| @@ -6,7 +6,9 @@ use axum::response::Response; | |||
| 6 | 6 | ||
| 7 | 7 | use super::repo::{Active, Crumb, Ctx, RepoNav, set_cache_headers}; | |
| 8 | 8 | use super::{Layout, render}; | |
| 9 | + | use crate::config::SnapshotFormat; | |
| 9 | 10 | use crate::git::refs::RefEntry; | |
| 11 | + | use crate::render::encode_path; | |
| 10 | 12 | use crate::render::time; | |
| 11 | 13 | ||
| 12 | 14 | pub struct RefRow { | |
| @@ -16,6 +18,8 @@ pub struct RefRow { | |||
| 16 | 18 | pub age: String, | |
| 17 | 19 | pub iso: String, | |
| 18 | 20 | pub is_default: bool, | |
| 21 | + | /// Snapshot downloads (`tar.gz`, `zip`), on tags. | |
| 22 | + | pub archives: Vec<(&'static str, String)>, | |
| 19 | 23 | } | |
| 20 | 24 | ||
| 21 | 25 | #[derive(Template)] | |
| @@ -50,6 +54,7 @@ pub fn page(ctx: &Ctx) -> Response { | |||
| 50 | 54 | age: time::relative(r.info.committer_time, now), | |
| 51 | 55 | iso: time::iso(r.info.committer_time), | |
| 52 | 56 | is_default: false, | |
| 57 | + | archives: Vec::new(), | |
| 53 | 58 | }; | |
| 54 | 59 | let mut branches: Vec<RefRow> = refs.branches.iter().map(row).collect(); | |
| 55 | 60 | // The default branch leads the list. | |
| @@ -75,9 +80,34 @@ pub fn page(ctx: &Ctx) -> Response { | |||
| 75 | 80 | crumbs, | |
| 76 | 81 | here: Some("Refs".into()), | |
| 77 | 82 | 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(), | |
| 79 | 92 | }; | |
| 80 | 93 | let mut res = render(StatusCode::OK, &page); | |
| 81 | 94 | set_cache_headers(&mut res, &etag, false); | |
| 82 | 95 | res | |
| 83 | 96 | } | |
| 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 | + | } | |
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | //! | |
| 4 | 4 | //! Everything here runs on the blocking pool (see [`AppState::blocking`]). | |
| 5 | 5 | ||
| 6 | + | use std::cell::Cell; | |
| 6 | 7 | use std::hash::{Hash, Hasher}; | |
| 7 | 8 | use std::sync::Arc; | |
| 8 | 9 | use std::sync::atomic::Ordering; | |
| @@ -10,8 +11,9 @@ use std::sync::atomic::Ordering; | |||
| 10 | 11 | use axum::http::{HeaderValue, StatusCode, header}; | |
| 11 | 12 | use axum::response::{IntoResponse, Redirect, Response}; | |
| 12 | 13 | use serde::Deserialize; | |
| 14 | + | use tokio::sync::OwnedSemaphorePermit; | |
| 13 | 15 | ||
| 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}; | |
| 15 | 17 | use crate::cache::Key; | |
| 16 | 18 | use crate::config::Config; | |
| 17 | 19 | use crate::git::refs::{RefKind, RefList, Rev}; | |
| @@ -37,10 +39,16 @@ pub struct PageQuery { | |||
| 37 | 39 | pub context: Option<String>, | |
| 38 | 40 | /// Commit: `ignore` compares lines ignoring whitespace. | |
| 39 | 41 | pub ws: Option<String>, | |
| 42 | + | /// Atom: the branch (default branch when absent). | |
| 43 | + | pub h: Option<String>, | |
| 40 | 44 | } | |
| 41 | 45 | ||
| 42 | 46 | pub struct RepoRequest { | |
| 43 | 47 | 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>, | |
| 44 | 52 | /// Path segments after the repository name. | |
| 45 | 53 | pub segments: Vec<Vec<u8>>, | |
| 46 | 54 | pub query: PageQuery, | |
| @@ -55,11 +63,16 @@ pub struct Ctx<'a> { | |||
| 55 | 63 | pub repo: &'a Repo, | |
| 56 | 64 | pub git: Handle, | |
| 57 | 65 | pub query: &'a PageQuery, | |
| 66 | + | pub host: Option<&'a str>, | |
| 58 | 67 | if_none_match: Option<&'a HeaderValue>, | |
| 68 | + | heavy: Cell<Option<OwnedSemaphorePermit>>, | |
| 59 | 69 | } | |
| 60 | 70 | ||
| 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()); | |
| 62 | 73 | let ctx = Ctx { | |
| 74 | + | heavy, | |
| 75 | + | host: req.host.as_deref(), | |
| 63 | 76 | state, | |
| 64 | 77 | config: &req.config, | |
| 65 | 78 | repo: &req.repo, | |
| @@ -73,15 +86,24 @@ pub fn handle(state: &AppState, req: RepoRequest) -> Response { | |||
| 73 | 86 | match action.as_slice() { | |
| 74 | 87 | b"tree" => tree::page(&ctx, rest), | |
| 75 | 88 | b"blob" => blob::page(&ctx, rest), | |
| 89 | + | b"blame" => blame::page(&ctx, rest), | |
| 76 | 90 | b"raw" => raw::serve(&ctx, rest), | |
| 77 | 91 | b"log" => log::page(&ctx, rest), | |
| 92 | + | b"archive" => archive::serve(&ctx, rest), | |
| 93 | + | b"find" => find::page(&ctx, rest), | |
| 78 | 94 | b"commit" => commit::page(&ctx, rest), | |
| 79 | 95 | b"refs" if rest.is_empty() => refs::page(&ctx), | |
| 96 | + | b"atom" if rest.is_empty() => feed::serve(&ctx), | |
| 80 | 97 | _ => ctx.not_found("PAGE NOT FOUND"), | |
| 81 | 98 | } | |
| 82 | 99 | } | |
| 83 | 100 | ||
| 84 | 101 | 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 | + | ||
| 85 | 107 | /// `{base}{repo}` without a trailing slash. | |
| 86 | 108 | pub fn repo_url(&self) -> String { | |
| 87 | 109 | format!( | |
| @@ -101,6 +123,21 @@ impl Ctx<'_> { | |||
| 101 | 123 | url | |
| 102 | 124 | } | |
| 103 | 125 | ||
| 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 | + | ||
| 104 | 141 | pub fn commit_url(&self, id: &ObjectId) -> String { | |
| 105 | 142 | format!("{}/commit/{id}", self.repo_url()) | |
| 106 | 143 | } | |
| @@ -203,14 +240,16 @@ impl Ctx<'_> { | |||
| 203 | 240 | current: Option<&Rev>, | |
| 204 | 241 | picker: impl Fn(&str) -> String, | |
| 205 | 242 | ) -> RepoNav { | |
| 206 | − | let (tree_url, log_url) = match current { | |
| 243 | + | let (tree_url, log_url, find_url) = match current { | |
| 207 | 244 | Some(rev) => ( | |
| 208 | 245 | self.url("tree", &rev.name, b""), | |
| 209 | 246 | self.url("log", &rev.name, b""), | |
| 247 | + | self.url("find", &rev.name, b""), | |
| 210 | 248 | ), | |
| 211 | 249 | None => ( | |
| 212 | 250 | format!("{}/tree", self.repo_url()), | |
| 213 | 251 | format!("{}/log", self.repo_url()), | |
| 252 | + | format!("{}/find", self.repo_url()), | |
| 214 | 253 | ), | |
| 215 | 254 | }; | |
| 216 | 255 | let picker = current.and_then(|rev| { | |
| @@ -245,6 +284,7 @@ impl Ctx<'_> { | |||
| 245 | 284 | url: self.repo_url(), | |
| 246 | 285 | tree_url, | |
| 247 | 286 | log_url, | |
| 287 | + | find_url, | |
| 248 | 288 | refs_url: format!("{}/refs", self.repo_url()), | |
| 249 | 289 | active: active.as_str(), | |
| 250 | 290 | picker, | |
| @@ -281,6 +321,7 @@ pub enum Active { | |||
| 281 | 321 | Tree, | |
| 282 | 322 | Log, | |
| 283 | 323 | Refs, | |
| 324 | + | Find, | |
| 284 | 325 | } | |
| 285 | 326 | ||
| 286 | 327 | impl Active { | |
| @@ -289,6 +330,7 @@ impl Active { | |||
| 289 | 330 | Active::Summary => "summary", | |
| 290 | 331 | Active::Tree => "tree", | |
| 291 | 332 | Active::Log => "log", | |
| 333 | + | Active::Find => "find", | |
| 292 | 334 | Active::Refs => "refs", | |
| 293 | 335 | } | |
| 294 | 336 | } | |
| @@ -299,6 +341,7 @@ pub struct RepoNav { | |||
| 299 | 341 | pub url: String, | |
| 300 | 342 | pub tree_url: String, | |
| 301 | 343 | pub log_url: String, | |
| 344 | + | pub find_url: String, | |
| 302 | 345 | pub refs_url: String, | |
| 303 | 346 | /// `summary`, `tree`, `log` or `refs`. | |
| 304 | 347 | pub active: &'static str, | |
| @@ -11,6 +11,7 @@ use super::{Layout, render}; | |||
| 11 | 11 | use crate::cache::Key; | |
| 12 | 12 | use crate::git::refs::{RefKind, Rev}; | |
| 13 | 13 | use crate::git::{self, CommitInfo}; | |
| 14 | + | use crate::index::languages::{self, Stats}; | |
| 14 | 15 | use crate::render::time; | |
| 15 | 16 | ||
| 16 | 17 | pub struct Stat { | |
| @@ -25,6 +26,72 @@ pub struct CloneUrl { | |||
| 25 | 26 | pub url: String, | |
| 26 | 27 | } | |
| 27 | 28 | ||
| 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 | + | ||
| 28 | 95 | pub struct Latest { | |
| 29 | 96 | pub subject: String, | |
| 30 | 97 | pub author: String, | |
| @@ -48,6 +115,7 @@ pub struct SummaryPage { | |||
| 48 | 115 | pub rows: Option<Vec<TreeRow>>, | |
| 49 | 116 | pub tree_url: String, | |
| 50 | 117 | pub readme: Option<Readme>, | |
| 118 | + | pub languages: Option<LangBar>, | |
| 51 | 119 | } | |
| 52 | 120 | ||
| 53 | 121 | pub fn page(ctx: &Ctx) -> Response { | |
| @@ -61,12 +129,26 @@ pub fn page(ctx: &Ctx) -> Response { | |||
| 61 | 129 | kind: RefKind::Branch, | |
| 62 | 130 | commit, | |
| 63 | 131 | }); | |
| 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 | + | )); | |
| 65 | 144 | if let Some(res) = ctx.not_modified(&etag, false) { | |
| 66 | 145 | return res; | |
| 67 | 146 | } | |
| 68 | 147 | 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 | + | }, | |
| 70 | 152 | nav: ctx.nav(Active::Summary, rev.as_ref(), |name| { | |
| 71 | 153 | ctx.url("tree", name, b"") | |
| 72 | 154 | }), | |
| @@ -78,6 +160,7 @@ pub fn page(ctx: &Ctx) -> Response { | |||
| 78 | 160 | rows: None, | |
| 79 | 161 | tree_url: String::new(), | |
| 80 | 162 | readme: None, | |
| 163 | + | languages: lang_stats.as_deref().and_then(language_bar), | |
| 81 | 164 | }; | |
| 82 | 165 | ||
| 83 | 166 | if let Some(rev) = &rev { | |
| @@ -185,3 +268,44 @@ fn clone_urls(ctx: &Ctx) -> Vec<CloneUrl> { | |||
| 185 | 268 | }) | |
| 186 | 269 | .collect() | |
| 187 | 270 | } | |
| 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 | + | } | |
| @@ -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 | + | } | |
[Diff too large] View file
| @@ -4,6 +4,8 @@ | |||
| 4 | 4 | //! Writers (full scans and single-repo refreshes) are serialized and swap in | |
| 5 | 5 | //! a new snapshot copy-on-write. | |
| 6 | 6 | ||
| 7 | + | pub mod languages; | |
| 8 | + | pub mod languages_table; | |
| 7 | 9 | pub mod meta; | |
| 8 | 10 | pub mod scan; | |
| 9 | 11 | pub mod watch; | |
| @@ -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 | + | } | |
| @@ -7,9 +7,10 @@ | |||
| 7 | 7 | use std::sync::Arc; | |
| 8 | 8 | ||
| 9 | 9 | use axum::extract::{Query, Request, State}; | |
| 10 | − | use axum::http::{StatusCode, header}; | |
| 10 | + | use axum::http::{Method, StatusCode, header}; | |
| 11 | 11 | use axum::response::{IntoResponse, Redirect, Response}; | |
| 12 | 12 | ||
| 13 | + | use super::clone; | |
| 13 | 14 | use crate::handlers::error; | |
| 14 | 15 | use crate::handlers::repo::{PageQuery, RepoRequest, handle}; | |
| 15 | 16 | use crate::index::{Repo, RepoIndex}; | |
| @@ -21,9 +22,10 @@ pub enum Match<'a> { | |||
| 21 | 22 | repo: Arc<Repo>, | |
| 22 | 23 | rest: &'a [Vec<u8>], | |
| 23 | 24 | }, | |
| 24 | − | /// `/{repo}.git` → `/{repo}`. | |
| 25 | + | /// `/{repo}.git` → `/{repo}`; `/{repo}.git/…` is only for cloning. | |
| 25 | 26 | DotGit { | |
| 26 | − | name: String, | |
| 27 | + | repo: Arc<Repo>, | |
| 28 | + | rest: &'a [Vec<u8>], | |
| 27 | 29 | }, | |
| 28 | 30 | None, | |
| 29 | 31 | } | |
| @@ -63,21 +65,26 @@ pub fn find_repo<'a>(index: &RepoIndex, segments: &'a [Vec<u8>]) -> Match<'a> { | |||
| 63 | 65 | }; | |
| 64 | 66 | } | |
| 65 | 67 | if let Some(stripped) = name.strip_suffix(".git") | |
| 66 | − | && index.repos.contains_key(stripped) | |
| 68 | + | && let Some(repo) = index.repos.get(stripped) | |
| 67 | 69 | { | |
| 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..], | |
| 75 | 73 | }; | |
| 76 | 74 | } | |
| 77 | 75 | } | |
| 78 | 76 | Match::None | |
| 79 | 77 | } | |
| 80 | 78 | ||
| 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 | + | ||
| 81 | 88 | pub async fn dispatch(State(state): State<Arc<AppState>>, req: Request) -> Response { | |
| 82 | 89 | let config = state.config.load_full(); | |
| 83 | 90 | let index = state.index.load(); | |
| @@ -86,17 +93,42 @@ pub async fn dispatch(State(state): State<Arc<AppState>>, req: Request) -> Respo | |||
| 86 | 93 | }; | |
| 87 | 94 | let (repo, rest) = match find_repo(&index, &segments) { | |
| 88 | 95 | 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)); | |
| 91 | 98 | return Redirect::permanent(&url).into_response(); | |
| 92 | 99 | } | |
| 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 | + | } | |
| 94 | 106 | }; | |
| 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 | + | } | |
| 95 | 113 | let query = Query::<PageQuery>::try_from_uri(req.uri()) | |
| 96 | 114 | .map(|q| q.0) | |
| 97 | 115 | .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 | + | }; | |
| 98 | 124 | let request = RepoRequest { | |
| 99 | 125 | 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), | |
| 100 | 132 | segments: rest, | |
| 101 | 133 | query, | |
| 102 | 134 | if_none_match: req.headers().get(header::IF_NONE_MATCH).cloned(), | |
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | //! HTTP server: router, middleware, listeners, signals and shutdown. | |
| 2 | 2 | ||
| 3 | 3 | pub mod assets; | |
| 4 | + | pub mod clone; | |
| 4 | 5 | pub mod dispatch; | |
| 5 | 6 | pub mod headers; | |
| 6 | 7 | ||
| @@ -14,7 +15,7 @@ use axum::extract::{Request, State}; | |||
| 14 | 15 | use axum::http::{HeaderValue, StatusCode, Uri, header}; | |
| 15 | 16 | use axum::middleware::{self, Next}; | |
| 16 | 17 | use axum::response::{IntoResponse, Redirect, Response}; | |
| 17 | − | use axum::routing::get; | |
| 18 | + | use axum::routing::{any, get}; | |
| 18 | 19 | use sd_notify::NotifyState; | |
| 19 | 20 | use tokio::signal::unix::{SignalKind, signal}; | |
| 20 | 21 | use tokio::sync::watch; | |
| @@ -43,7 +44,8 @@ pub fn app(state: Arc<AppState>) -> Router { | |||
| 43 | 44 | .route("/-/health", get(health)) | |
| 44 | 45 | .route("/-/site.css", get(assets::site_css)) | |
| 45 | 46 | .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)) | |
| 47 | 49 | .fallback(handlers::error::not_found) | |
| 48 | 50 | .with_state(state.clone()); | |
| 49 | 51 | ||
| @@ -72,8 +74,14 @@ pub fn app(state: Arc<AppState>) -> Router { | |||
| 72 | 74 | } else { | |
| 73 | 75 | compression.no_gzip().no_br() | |
| 74 | 76 | } | |
| 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 | + | ); | |
| 77 | 85 | ||
| 78 | 86 | let [csp, nosniff, referrer, frame] = headers::layers(); | |
| 79 | 87 | routes.layer( | |
| @@ -142,6 +150,37 @@ async fn strip_base_path(State(base): State<Arc<str>>, mut req: Request, next: N | |||
| 142 | 150 | } | |
| 143 | 151 | } | |
| 144 | 152 | ||
| 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 | + | ||
| 145 | 184 | async fn health() -> impl IntoResponse { | |
| 146 | 185 | ( | |
| 147 | 186 | [(header::CACHE_CONTROL, HeaderValue::from_static("no-store"))], | |
| @@ -166,6 +205,7 @@ pub async fn run(state: Arc<AppState>) -> anyhow::Result<()> { | |||
| 166 | 205 | let mut seen = Default::default(); | |
| 167 | 206 | index::watch::log_scan(&stats, &state.index.load(), &mut seen, true); | |
| 168 | 207 | tokio::spawn(index::watch::run(state.clone(), seen)); | |
| 208 | + | tokio::spawn(index::languages::warm(state.clone())); | |
| 169 | 209 | // Parse the embedded grammars now rather than on the first blob view. | |
| 170 | 210 | tokio::task::spawn_blocking(|| { | |
| 171 | 211 | let start = std::time::Instant::now(); | |
| @@ -10,6 +10,7 @@ use tokio::sync::Semaphore; | |||
| 10 | 10 | use crate::cache::Cache; | |
| 11 | 11 | use crate::config::{Config, ConfigError, Listen}; | |
| 12 | 12 | use crate::index::Indexer; | |
| 13 | + | use crate::index::languages::Warmer; | |
| 13 | 14 | use crate::server::assets::Assets; | |
| 14 | 15 | ||
| 15 | 16 | pub struct AppState { | |
| @@ -21,7 +22,12 @@ pub struct AppState { | |||
| 21 | 22 | pub cache: Cache, | |
| 22 | 23 | /// Bounds concurrent git work (`limits.git_parallel`). | |
| 23 | 24 | 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>, | |
| 24 | 28 | pub index: Indexer, | |
| 29 | + | /// Background language statistics. | |
| 30 | + | pub warmer: Warmer, | |
| 25 | 31 | /// Wakes the index watcher for an immediate full rescan. | |
| 26 | 32 | pub rescan: tokio::sync::Notify, | |
| 27 | 33 | /// Command-line overrides re-applied after every reload. | |
| @@ -49,10 +55,12 @@ impl AppState { | |||
| 49 | 55 | Arc::new(AppState { | |
| 50 | 56 | cache: Cache::new(config.cache.size_mb), | |
| 51 | 57 | git_permits: Semaphore::new(config.git_parallel()), | |
| 58 | + | heavy_permits: Arc::new(Semaphore::new(config.limits.heavy_parallel)), | |
| 52 | 59 | config_generation: AtomicU64::new(0), | |
| 53 | 60 | config: ArcSwap::from_pointee(config), | |
| 54 | 61 | assets: Assets::new(), | |
| 55 | 62 | index: Indexer::new(), | |
| 63 | + | warmer: Warmer::default(), | |
| 56 | 64 | rescan: tokio::sync::Notify::new(), | |
| 57 | 65 | overrides, | |
| 58 | 66 | }) | |
| @@ -85,10 +93,16 @@ impl AppState { | |||
| 85 | 93 | new.server.compression = old.server.compression; | |
| 86 | 94 | new.limits.request_timeout_secs = old.limits.request_timeout_secs; | |
| 87 | 95 | } | |
| 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 | + | ); | |
| 90 | 103 | new.cache.size_mb = old.cache.size_mb; | |
| 91 | 104 | new.limits.git_parallel = old.limits.git_parallel; | |
| 105 | + | new.limits.heavy_parallel = old.limits.heavy_parallel; | |
| 92 | 106 | } | |
| 93 | 107 | let new = Arc::new(new); | |
| 94 | 108 | self.config.store(new.clone()); | |
| @@ -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> | |
| @@ -11,6 +11,9 @@ | |||
| 11 | 11 | {%- if let Some(css) = layout.site_css %} | |
| 12 | 12 | <link rel="stylesheet" href="{{ css }}"> | |
| 13 | 13 | {%- endif %} | |
| 14 | + | {%- if let Some(feed) = layout.feed %} | |
| 15 | + | <link rel="alternate" type="application/atom+xml" title="Commits" href="{{ feed }}"> | |
| 16 | + | {%- endif %} | |
| 14 | 17 | <link rel="icon" href="{{ layout.static_base }}icons/favicon.svg" type="image/svg+xml"> | |
| 15 | 18 | <script src="{{ layout.static_base }}js/theme.js"></script> | |
| 16 | 19 | <script src="{{ layout.static_base }}js/app.js" defer></script> | |
| @@ -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 %} | |
| @@ -12,6 +12,7 @@ | |||
| 12 | 12 | </nav> | |
| 13 | 13 | {%- endif %} | |
| 14 | 14 | <a class="btn-ghost" href="{{ raw_url }}">Raw</a> | |
| 15 | + | <a class="btn-ghost" href="{{ blame_url }}">Blame</a> | |
| 15 | 16 | <a class="btn-ghost" href="{{ log_url }}">History</a> | |
| 16 | 17 | <a class="btn-ghost" href="{{ download_url }}">Download</a> | |
| 17 | 18 | </div> | |
| @@ -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 %} | |
| @@ -44,6 +44,7 @@ | |||
| 44 | 44 | </a> | |
| 45 | 45 | <span class="repo-meta label"> | |
| 46 | 46 | {%- 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 -%} | |
| 47 | 48 | {%- 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 -%} | |
| 48 | 49 | </span> | |
| 49 | 50 | </li> | |
| @@ -7,7 +7,7 @@ | |||
| 7 | 7 | {%- for row in rows %} | |
| 8 | 8 | <tr> | |
| 9 | 9 | <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> | |
| 11 | 11 | <td class="num"><time datetime="{{ row.iso }}" title="{{ row.iso }}">{{ row.age }}</time></td> | |
| 12 | 12 | </tr> | |
| 13 | 13 | {%- endfor %} | |
| @@ -4,6 +4,7 @@ | |||
| 4 | 4 | <a class="label{% if nav.active == "tree" %} active{% endif %}" href="{{ nav.tree_url }}"{% if nav.active == "tree" %} aria-current="page"{% endif %}>Tree</a> | |
| 5 | 5 | <a class="label{% if nav.active == "log" %} active{% endif %}" href="{{ nav.log_url }}"{% if nav.active == "log" %} aria-current="page"{% endif %}>Log</a> | |
| 6 | 6 | <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> | |
| 7 | 8 | </nav> | |
| 8 | 9 | {%- if let Some(picker) = nav.picker %} | |
| 9 | 10 | <details class="ref-picker"> | |
| @@ -31,6 +31,19 @@ | |||
| 31 | 31 | </div> | |
| 32 | 32 | {%- endfor %} | |
| 33 | 33 | </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 %} | |
| 34 | 47 | {%- if !clone_urls.is_empty() %} | |
| 35 | 48 | <section class="rail-block" data-clone> | |
| 36 | 49 | <div class="rail-head"> | |
| @@ -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 | + | } | |
| @@ -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() | |