git-ents.gitmain
⌘K
foforge
commit 4e59d1b
refactor: 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

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

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)) } } } }