refactor: represent git data with gitoxide types in the web data layer
commit
4e59d1brefactor: represent git data with gitoxide types in the web data layer
The web data layer parsed git output into ad-hoc structs and stringly-typed
hashes. It now decodes that output into gitoxide’s own types, leaving the git
subprocess in place; route inputs in the binary stay plain strings.
feat: add gix-hash, gix-object, gix-actor, and gix-date dependencies
refactor: replace the custom TreeEntry with gix_object::tree::Entry
refactor: type Release dates as gix_date::Time and ids as gix_hash::ObjectId
feat: render relative dates and short ids from gitoxide types in the views
Assisted-by: Claude:claude-opus-4-8
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
Cargo.lock
@@ -8,7 +8,7 @@
source = "git+https://github.com/nlopes/acdc?rev=3452a4a8d745e6279e50e1d465551b2e33160464#3452a4a8d745e6279e50e1d465551b2e33160464"
dependencies = [
"acdc-parser",
- "bitflags",
+ "bitflags 2.13.0",
"thiserror",
"tracing",
]
@@ -30,7 +30,7 @@
version = "0.9.0"
source = "git+https://github.com/nlopes/acdc?rev=3452a4a8d745e6279e50e1d465551b2e33160464#3452a4a8d745e6279e50e1d465551b2e33160464"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"bumpalo",
"csv",
"encoding_rs",
@@ -429,18 +429,50 @@
"tracing",
]
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bstr"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
+dependencies = [
+ "memchr",
+ "regex-automata",
+ "serde",
+]
+
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
[[package]]
name = "bytes"
version = "1.12.0"
@@ -538,6 +570,25 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
[[package]]
name = "csv"
version = "1.4.0"
@@ -559,6 +610,48 @@
"memchr",
]
+[[package]]
+name = "defmt"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f"
+dependencies = [
+ "bitflags 1.3.2",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b"
+dependencies = [
+ "defmt-parser",
+ "proc-macro-error2",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+dependencies = [
+ "thiserror",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
[[package]]
name = "displaydoc"
version = "0.2.6"
@@ -612,6 +705,16 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25929004897f2bbab309121a60400d36992f6d911d09baa6c172f6cc55706601"
+[[package]]
+name = "faster-hex"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73"
+dependencies = [
+ "heapless",
+ "serde",
+]
+
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -684,6 +787,16 @@
"slab",
]
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -710,24 +823,157 @@
"axum",
"clap",
"clap_mangen",
+ "gix-actor",
+ "gix-date",
+ "gix-hash",
+ "gix-object",
"maud",
"rstest",
"tempfile",
"tokio",
]
+[[package]]
+name = "gix-actor"
+version = "0.41.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8bc998b8f746dda8565450d08a63b792ced9165d8c27a1ed3f02799ec6a7820f"
+dependencies = [
+ "bstr",
+ "gix-date",
+ "gix-error",
+]
+
+[[package]]
+name = "gix-date"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3ecab64a98bbac9f8e02990a9ea5e3c974a7d49b95f2bd70ad94ad22fa6b48c"
+dependencies = [
+ "bstr",
+ "gix-error",
+ "itoa",
+ "jiff",
+]
+
+[[package]]
+name = "gix-error"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57831e199be480af90dcd7e459abed8a174c09ec9a6e2cc8f7ca6c54598b06b"
+dependencies = [
+ "bstr",
+]
+
+[[package]]
+name = "gix-features"
+version = "0.48.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1849ae154d38bc403185be14fa871e38e3c93ee606875d94e207fdb9fba52dbc"
+dependencies = [
+ "gix-trace",
+ "libc",
+ "prodash",
+]
+
+[[package]]
+name = "gix-hash"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb0926d3819c837750b4e03c7754901e73f68b8c9b690753a6372a1bed4eedce"
+dependencies = [
+ "faster-hex",
+ "gix-features",
+ "sha1-checked",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-hashtable"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0e30b93eea8718baf7d8153fcb938e2926175bbf18097c09f1c01b6f0be0563"
+dependencies = [
+ "gix-hash",
+ "hashbrown",
+ "parking_lot",
+]
+
+[[package]]
+name = "gix-object"
+version = "0.60.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a38075a95d7cc5df8afd38e72c617026c1456952207a4120a7f55a3fbf93b4d7"
+dependencies = [
+ "bstr",
+ "gix-actor",
+ "gix-date",
+ "gix-features",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-utils",
+ "gix-validate",
+ "itoa",
+ "smallvec",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-trace"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44dc45eae785c0eb14173e0f152e6e224dcf4d45b6a6999a3aed22af541ad678"
+
+[[package]]
+name = "gix-utils"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66c50966184123caf580ffa64e28031a878597f1c7fceb8fe19566c38eb1b771"
+dependencies = [
+ "fastrand",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "gix-validate"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7bc6fc771c4063ba7cd2f47b91fb6076251c6a823b64b7fe7b8874b0fe4afae3"
+dependencies = [
+ "bstr",
+]
+
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+[[package]]
+name = "hash32"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
+dependencies = [
+ "byteorder",
+]
+
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+[[package]]
+name = "heapless"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
+dependencies = [
+ "hash32",
+ "stable_deref_trait",
+]
+
[[package]]
name = "heck"
version = "0.5.0"
@@ -963,6 +1209,48 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+[[package]]
+name = "jiff"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46"
+dependencies = [
+ "defmt",
+ "jiff-static",
+ "jiff-tzdb-platform",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+ "windows-link",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "jiff-tzdb"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076"
+
+[[package]]
+name = "jiff-tzdb-platform"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
+dependencies = [
+ "jiff-tzdb",
+]
+
[[package]]
name = "js-sys"
version = "0.3.102"
@@ -992,6 +1280,15 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
[[package]]
name = "log"
version = "0.4.33"
@@ -1072,6 +1369,29 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
[[package]]
name = "peg"
version = "0.8.6"
@@ -1111,6 +1431,21 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -1129,6 +1464,28 @@
"toml_edit",
]
+[[package]]
+name = "proc-macro-error-attr2"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "proc-macro-error2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
+dependencies = [
+ "proc-macro-error-attr2",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -1150,6 +1507,15 @@
"version_check",
]
+[[package]]
+name = "prodash"
+version = "31.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c"
+dependencies = [
+ "parking_lot",
+]
+
[[package]]
name = "quote"
version = "1.0.45"
@@ -1165,6 +1531,15 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.0",
+]
+
[[package]]
name = "regex"
version = "1.12.4"
@@ -1256,7 +1631,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"errno",
"libc",
"linux-raw-sys",
@@ -1275,6 +1650,12 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
[[package]]
name = "self_cell"
version = "1.2.2"
@@ -1353,6 +1734,27 @@
"serde",
]
+[[package]]
+name = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha1-checked"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423"
+dependencies = [
+ "digest",
+ "sha1",
+]
+
[[package]]
name = "shlex"
version = "2.0.1"
@@ -1480,6 +1882,21 @@
"zerovec",
]
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
[[package]]
name = "tokio"
version = "1.52.3"
@@ -1603,12 +2020,27 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
[[package]]
name = "url"
version = "2.5.8"
Cargo.toml
@@ -36,6 +36,10 @@
clap = { version = "4.5.60", features = ["derive"] }
clap_mangen = "0.2.31"
gix = { version = "0.83", features = ["no-default-features"] }
+gix-hash = { version = "0.25", features = ["sha1"] }
+gix-object = "0.60"
+gix-actor = "0.41"
+gix-date = "0.15"
maud = { version = "0.27", features = ["axum"] }
rstest = "0.26"
tempfile = "3"
crates/git-ents-server/Cargo.toml
@@ -13,6 +13,10 @@
axum = { workspace = true }
clap = { workspace = true }
clap_mangen = { workspace = true }
+gix-actor = { workspace = true }
+gix-date = { workspace = true }
+gix-hash = { workspace = true }
+gix-object = { workspace = true }
maud = { workspace = true }
tokio = { workspace = true }
crates/git-ents-server/src/web/git.rs
@@ -5,6 +5,9 @@
use std::path::Path;
use std::process::Stdio;
+use gix_date::Time;
+use gix_hash::ObjectId;
+use gix_object::tree::{Entry, EntryKind, EntryMode};
use tokio::process::Command;
use crate::http::{MAX_REPO_DEPTH, is_bare_repo};
@@ -34,14 +37,8 @@
Some(out.stdout)
}
-/// A single entry in a git tree.
-pub(super) struct TreeEntry {
- pub(super) name: String,
- pub(super) is_dir: bool,
-}
-
/// The entries of the root tree at `HEAD`, directories first then by name.
-pub(super) async fn root_tree(repo: &Path, has_head: bool) -> Vec<TreeEntry> {
+pub(super) async fn root_tree(repo: &Path, has_head: bool) -> Vec<Entry> {
if !has_head {
return Vec::new();
}
@@ -50,25 +47,46 @@
/// The entries of the tree named by `spec` (a git tree-ish such as `HEAD` or
/// `HEAD:src`), directories first then by name. Empty if `spec` is not a tree.
-pub(super) async fn list_tree(repo: &Path, spec: &str) -> Vec<TreeEntry> {
+pub(super) async fn list_tree(repo: &Path, spec: &str) -> Vec<Entry> {
let Some(out) = git_output(repo, &["ls-tree", spec]).await else {
return Vec::new();
};
- let mut entries: Vec<TreeEntry> = out
- .lines()
- .filter_map(|line| {
- let (meta, name) = line.split_once('\t')?;
- let kind = meta.split(' ').nth(1).unwrap_or_default();
- Some(TreeEntry {
- name: name.to_owned(),
- is_dir: kind == "tree",
- })
- })
- .collect();
- entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
+ let mut entries: Vec<Entry> = out.lines().filter_map(parse_tree_entry).collect();
+ entries.sort_by(|a, b| {
+ b.mode
+ .is_tree()
+ .cmp(&a.mode.is_tree())
+ .then_with(|| a.filename.cmp(&b.filename))
+ });
entries
}
+/// Parse one `git ls-tree` line (`<mode> <type> <oid>\t<name>`) into a tree
+/// entry, or `None` when it is malformed.
+fn parse_tree_entry(line: &str) -> Option<Entry> {
+ let (meta, name) = line.split_once('\t')?;
+ let mut cols = meta.split(' ');
+ let mode = cols.next()?;
+ let oid = cols.nth(1)?;
+ Some(Entry {
+ mode: entry_mode(mode),
+ filename: name.into(),
+ oid: ObjectId::from_hex(oid.as_bytes()).ok()?,
+ })
+}
+
+/// Map a `git ls-tree` mode column to a tree entry mode.
+fn entry_mode(mode: &str) -> EntryMode {
+ match mode {
+ "040000" | "40000" => EntryKind::Tree,
+ "120000" => EntryKind::Link,
+ "160000" => EntryKind::Commit,
+ "100755" => EntryKind::BlobExecutable,
+ _ => EntryKind::Blob,
+ }
+ .into()
+}
+
/// Join the path segments of a browse view, rejecting empty or traversing
/// components. The result is used only as a git tree path (`HEAD:<path>`), never
/// touched on disk, but refusing `..` keeps the rendered links well-formed.
@@ -180,13 +198,18 @@
}
/// A tagged release: its tag, the release name and notes drawn from the tag (or
-/// commit) message, the relative date, and the target commit's short hash.
+/// commit) message, the target commit's date, and that commit's id.
pub(super) struct Release {
pub(super) tag: String,
pub(super) title: String,
pub(super) body: String,
- pub(super) date: String,
- pub(super) short: String,
+ pub(super) date: Time,
+ pub(super) oid: ObjectId,
+}
+
+/// Parse a strict-ISO 8601 git date (`%aI`) into a gitoxide time.
+pub(super) fn parse_iso(input: &str) -> Option<Time> {
+ gix_date::parse(input, None).ok()
}
/// All tags as releases, newest first by creation date.
@@ -201,12 +224,19 @@
.filter(|t| !t.is_empty())
.take(40)
{
- let Some(meta) = git_output(repo, &["log", "-1", "--format=%h%x00%ar", tag]).await else {
+ let Some(meta) = git_output(repo, &["log", "-1", "--format=%H%x00%aI", tag]).await else {
continue;
};
let mut parts = meta.trim().split('\u{0}');
- let short = parts.next().unwrap_or_default().to_owned();
- let date = parts.next().unwrap_or_default().to_owned();
+ let Some(oid) = parts
+ .next()
+ .and_then(|h| ObjectId::from_hex(h.as_bytes()).ok())
+ else {
+ continue;
+ };
+ let Some(date) = parts.next().and_then(parse_iso) else {
+ continue;
+ };
let notes = git_output(
repo,
&[
@@ -226,7 +256,7 @@
title,
body,
date,
- short,
+ oid,
});
}
out
crates/git-ents-server/src/web/pages.rs
@@ -9,10 +9,14 @@
use arborium::{Config, Highlighter, HtmlFormat};
use axum::response::{IntoResponse, Response};
+use gix_date::Time;
+use gix_hash::{ObjectId, Prefix};
+use gix_object::bstr::ByteSlice;
+use gix_object::tree::Entry;
use maud::{Markup, PreEscaped, html};
use super::git::{
- TreeEntry, browse_path, git_output, git_output_bytes, languages, latest_release, list_tree,
+ browse_path, git_output, git_output_bytes, languages, latest_release, list_tree, parse_iso,
releases, root_tree,
};
use super::icons::*;
@@ -49,9 +53,10 @@
div.card {
div.card-header { "Files" }
@for entry in &tree {
- div.card-row.is-dir[entry.is_dir] {
- @if entry.is_dir { (icon_folder()) } @else { (icon_file()) }
- a href=(entry_href(rel, "", entry)) { (entry.name) }
+ @let name = entry.filename.to_str_lossy();
+ div.card-row.is-dir[entry.mode.is_tree()] {
+ @if entry.mode.is_tree() { (icon_folder()) } @else { (icon_file()) }
+ a href=(entry_href(rel, "", entry)) { (name.as_ref()) }
}
}
}
@@ -91,7 +96,7 @@
span.badge-latest { "Latest" }
}
div.aside-row {
- span.muted { (release.title) " · " (release.date) }
+ span.muted { (release.title) " · " (ago(&release.date)) }
}
}
}
@@ -130,18 +135,20 @@
/// The rendered README for the overview: the first AsciiDoc file in the root
/// tree whose stem is `README`, converted to HTML, paired with its filename.
/// `None` when there is no such file or it fails to render.
-async fn readme(repo: &Path, tree: &[TreeEntry]) -> Option<(String, String)> {
+async fn readme(repo: &Path, tree: &[Entry]) -> Option<(String, String)> {
let entry = tree.iter().find(|e| {
- !e.is_dir
- && crate::asciidoc::is_asciidoc(&e.name)
- && e.name
+ let name = e.filename.to_str_lossy();
+ !e.mode.is_tree()
+ && crate::asciidoc::is_asciidoc(&name)
+ && name
.rsplit_once('.')
.is_some_and(|(stem, _)| stem.eq_ignore_ascii_case("readme"))
})?;
- let spec = format!("HEAD:{}", entry.name);
+ let name = entry.filename.to_str_lossy();
+ let spec = format!("HEAD:{name}");
let bytes = git_output_bytes(repo, &["cat-file", "-p", &spec]).await?;
let html = crate::asciidoc::to_html(&String::from_utf8_lossy(&bytes))?;
- Some((entry.name.clone(), html))
+ Some((name.into_owned(), html))
}
/// The clone URL for `rel`, using the request host when known.
@@ -154,9 +161,9 @@
/// The link to a tree entry: a `tree` view for directories, a `blob` view for
/// files. `dir` is the tree's path within the repo (empty at the root).
-fn entry_href(rel: &str, dir: &str, entry: &TreeEntry) -> String {
- let view = if entry.is_dir { "tree" } else { "blob" };
- let name = &entry.name;
+fn entry_href(rel: &str, dir: &str, entry: &Entry) -> String {
+ let view = if entry.mode.is_tree() { "tree" } else { "blob" };
+ let name = entry.filename.to_str_lossy();
if dir.is_empty() {
format!("/{rel}/{view}/{name}")
} else {
@@ -191,19 +198,21 @@
format!("HEAD:{dir}")
};
for entry in list_tree(repo, &spec).await {
+ let name = entry.filename.to_str_lossy();
+ let is_dir = entry.mode.is_tree();
let path = if dir.is_empty() {
- entry.name.clone()
+ name.clone().into_owned()
} else {
- format!("{dir}/{}", entry.name)
+ format!("{dir}/{name}")
};
- let is_expanded = entry.is_dir && expanded.contains(&path);
+ let is_expanded = is_dir && expanded.contains(&path);
out.push(TreeRow {
- name: entry.name.clone(),
+ name: name.into_owned(),
path: path.clone(),
- is_dir: entry.is_dir,
+ is_dir,
depth,
expanded: is_expanded,
- selected: !entry.is_dir && path == selected,
+ selected: !is_dir && path == selected,
});
if is_expanded {
collect_rows(
@@ -362,6 +371,44 @@
}
}
+/// A commit id shortened to seven hex characters for display.
+fn short_oid(oid: &ObjectId) -> String {
+ Prefix::new(oid, 7)
+ .ok()
+ .map_or_else(|| oid.to_string(), |prefix| prefix.to_string())
+}
+
+/// A git date rendered as a relative "time ago" label, measured against the
+/// current time.
+fn ago(time: &Time) -> String {
+ let secs = Time::now_utc().seconds.saturating_sub(time.seconds).max(0);
+ let mins = secs.checked_div(60).unwrap_or(0);
+ let hours = mins.checked_div(60).unwrap_or(0);
+ let days = hours.checked_div(24).unwrap_or(0);
+ if mins == 0 {
+ "just now".to_owned()
+ } else if hours == 0 {
+ plural(mins, "minute")
+ } else if days == 0 {
+ plural(hours, "hour")
+ } else if days < 30 {
+ plural(days, "day")
+ } else if days < 365 {
+ plural(days.checked_div(30).unwrap_or(0), "month")
+ } else {
+ plural(days.checked_div(365).unwrap_or(0), "year")
+ }
+}
+
+/// Format `n` whole `unit`s with an "ago" suffix, pluralizing as needed.
+fn plural(n: i64, unit: &str) -> String {
+ if n == 1 {
+ format!("1 {unit} ago")
+ } else {
+ format!("{n} {unit}s ago")
+ }
+}
+
/// A directory listing at `sub` within the repository.
pub(super) async fn tree_page(repo: &Path, meta: &RepoMeta, sub: &[&str]) -> Response {
let rel = &meta.rel;
@@ -390,9 +437,10 @@
div.card-row { "Empty repository." }
}
@for entry in &entries {
- div.card-row.is-dir[entry.is_dir] {
- @if entry.is_dir { (icon_folder()) } @else { (icon_file()) }
- a href=(entry_href(rel, &dir, entry)) { (entry.name) }
+ @let name = entry.filename.to_str_lossy();
+ div.card-row.is-dir[entry.mode.is_tree()] {
+ @if entry.mode.is_tree() { (icon_folder()) } @else { (icon_file()) }
+ a href=(entry_href(rel, &dir, entry)) { (name.as_ref()) }
}
}
}
@@ -495,19 +543,24 @@
}
let Some(info) = git_output(
repo,
- &["show", "-s", "--format=%H%x00%an%x00%ar%x00%s%x00%b", sha],
+ &["show", "-s", "--format=%H%x00%an%x00%aI%x00%s%x00%b", sha],
)
.await
else {
return not_found().into_response();
};
let mut parts = info.split('\u{0}');
- let hash = parts.next().unwrap_or_default().trim().to_owned();
+ let Some(oid) = parts
+ .next()
+ .and_then(|h| ObjectId::from_hex(h.trim().as_bytes()).ok())
+ else {
+ return not_found().into_response();
+ };
let author = parts.next().unwrap_or_default().to_owned();
- let when = parts.next().unwrap_or_default().to_owned();
+ let when = parts.next().and_then(parse_iso);
let subject = parts.next().unwrap_or_default().to_owned();
let body = parts.next().unwrap_or_default().trim_end().to_owned();
- let short = hash.get(..7).unwrap_or(&hash).to_owned();
+ let short = short_oid(&oid);
let patch = git_output(repo, &["show", "--no-color", "--format=", "--patch", sha])
.await
.unwrap_or_default();
@@ -524,7 +577,7 @@
@if !body.is_empty() {
div.commit-msg { (body) }
}
- div.commit-meta { (author) " · " (when) }
+ div.commit-meta { (author) @if let Some(when) = &when { " · " (ago(when)) } }
}
}
(diff_view(&patch))
@@ -559,13 +612,13 @@
span.release-name { (release.title) }
}
@if i == 0 { span.badge-latest { "Latest" } }
- span.release-date { (release.date) }
+ span.release-date { (ago(&release.date)) }
}
@if !release.body.is_empty() {
div.release-body { p { (release.body) } }
}
div.release-foot {
- span.sha { (icon_commit()) (release.short) }
+ span.sha { (icon_commit()) (short_oid(&release.oid)) }
}
}
}