git-ents.gitmain
⌘K
foforge
commit 56f5fb1
refactor: tighten web/http maintainability and reframe Hooks as Checks

An adversarial maintainability pass over the server crate. Collapse duplicated constants and predicates to a single source of truth, and replace the git-hooks framing of the CI tab with scaffolding for the object-graph model: signed CI run records in the git-metadata fanout keyed by (config_oid, code_oid), config on refs/ents/config, drafts on refs/ents/ci/draft. No runner is wired up yet, so the Checks panels are empty states.

refactor: share MAX_REPO_DEPTH between the gateway and web routing refactor: define git_output in terms of git_output_bytes refactor: derive is_git_path from is_service_request refactor: add RepoMeta::name() for the repository short name fix: report binary blob sizes in human-readable units on the blob page feat: rename the Hooks tab to Checks refactor: drop the .gitents/hooks.toml probe from repository metadata 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

crates/git-ents-server/src/http.rs @@ -178,7 +178,8 @@ } /// Greatest repository nesting depth: `repo`, `org/repo`, or `org/team/repo`. -const MAX_REPO_DEPTH: usize = 3; +/// Shared by the push gateway and the web UI's routing/discovery. +pub(crate) const MAX_REPO_DEPTH: usize = 3; /// Whether a GET should be answered with the HTML web UI rather than handed to /// `git http-backend`. @@ -211,12 +212,7 @@ /// rather than the browser-facing web UI. Anything matching here is delegated /// to `git http-backend`; everything else is rendered as HTML. fn is_git_path(path: &str, query: &str) -> bool { - path.ends_with("/info/refs") - || path.ends_with("/git-upload-pack") - || path.ends_with("/git-receive-pack") - || path.ends_with("/HEAD") - || path.contains("/objects/") - || query.contains("service=") + is_service_request(path, query) || path.ends_with("/HEAD") || path.contains("/objects/") } /// Whether this request is a push: the smart-HTTP receive-pack advertisement
crates/git-ents-server/src/web/git.rs @@ -7,23 +7,14 @@ use tokio::process::Command; -use super::MAX_DEPTH; -use crate::http::is_bare_repo; +use crate::http::{MAX_REPO_DEPTH, is_bare_repo}; -/// Run `git -C <repo> <args>` and return its stdout, or `None` on failure. +/// Run `git -C <repo> <args>` and return its stdout as lossy UTF-8, or `None` on +/// failure. pub(super) async fn git_output(repo: &Path, args: &[&str]) -> Option<String> { - let out = Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .stderr(Stdio::null()) - .output() + git_output_bytes(repo, args) .await - .ok()?; - if !out.status.success() { - return None; - } - Some(String::from_utf8_lossy(&out.stdout).into_owned()) + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) } /// Run `git -C <repo> <args>` and return its raw stdout bytes, or `None` on @@ -91,7 +82,7 @@ /// All bare repositories under `root`, as relative slash paths, sorted. pub(super) fn discover_repos(root: &Path) -> Vec<String> { let mut repos = Vec::new(); - collect_repos(root, root, MAX_DEPTH, &mut repos); + collect_repos(root, root, MAX_REPO_DEPTH, &mut repos); repos.sort(); repos }
crates/git-ents-server/src/web/mod.rs @@ -19,15 +19,12 @@ use maud::{DOCTYPE, Markup, PreEscaped, html}; use crate::AppState; -use crate::http::{is_bare_repo, valid_segment}; +use crate::http::{MAX_REPO_DEPTH, is_bare_repo, valid_segment}; use self::assets::{COPY_SCRIPT, FONTS, STYLE}; use self::git::{discover_repos, git_output, git_output_bytes}; use self::icons::{icon_branch, icon_chevron, icon_folder, icon_logo, icon_repo, icon_search}; -/// Greatest repository nesting depth served: `repo`, `org/repo`, `org/team/repo`. -const MAX_DEPTH: usize = 3; - /// Render the page for `path`: the repository index at the root, a repository /// overview, or one of its browse views (`tree`, `blob`, `commit`). `host` is /// the request's `Host` header, used to build a copy-pasteable clone URL. @@ -37,11 +34,11 @@ return index(state).into_response(); } - // The repository is the shortest valid prefix (up to `MAX_DEPTH` segments) - // that names a bare repo on disk; anything after it selects a browse view. - // Resolving the boundary this way keeps a repo named `tree`/`blob`/`commit` - // distinct from the route markers of the same name. - let depth_limit = segments.len().min(MAX_DEPTH); + // The repository is the shortest valid prefix (up to `MAX_REPO_DEPTH` + // segments) that names a bare repo on disk; anything after it selects a + // browse view. Resolving the boundary this way keeps a repo named + // `tree`/`blob`/`commit` distinct from the route markers of the same name. + let depth_limit = segments.len().min(MAX_REPO_DEPTH); for depth in 1..=depth_limit { let Some(repo_segs) = segments.get(..depth) else { break; @@ -74,7 +71,7 @@ Some((&"blob", sub)) => pages::blob_page(repo, &meta, sub).await, Some((&"commit", &[sha])) => pages::commit_page(repo, &meta, sha).await, Some((&"releases", &[])) => pages::releases_page(repo, &meta).await.into_response(), - Some((&"hooks", &[])) => pages::hooks_page(repo, &meta).await.into_response(), + Some((&"checks", &[])) => pages::checks_page(&meta).into_response(), Some((&"issues", &[])) => pages::issues_page(&meta).into_response(), Some((&"settings", &[])) => pages::settings_page(&meta).into_response(), _ => not_found().into_response(), @@ -87,7 +84,7 @@ Overview, Files, Releases, - Hooks, + Checks, Issues, Settings, } @@ -101,7 +98,13 @@ topics: Vec<String>, releases: usize, issues: usize, - has_hooks: bool, +} + +impl RepoMeta { + /// The repository's short name: the final segment of its path. + fn name(&self) -> &str { + self.rel.rsplit('/').next().unwrap_or(&self.rel) + } } /// Collect the header/tab metadata for the repository at `rel`. @@ -128,10 +131,6 @@ .await .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count()) .unwrap_or(0); - let has_hooks = git_output(repo, &["cat-file", "-t", "HEAD:.gitents/hooks.toml"]) - .await - .as_deref() - == Some("blob\n"); RepoMeta { rel: rel.to_owned(), branch, @@ -139,7 +138,6 @@ topics, releases, issues: 0, - has_hooks, } } @@ -203,10 +201,7 @@ "Releases" @if meta.releases > 0 { span.tab-count { (meta.releases) } } } - a.tab.active[active == Tab::Hooks] href={ "/" (rel) "/hooks" } { - "Hooks" - @if meta.has_hooks { span.tab-dot {} } - } + a.tab.active[active == Tab::Checks] href={ "/" (rel) "/checks" } { "Checks" } a.tab.active[active == Tab::Issues] href={ "/" (rel) "/issues" } { "Issues" @if meta.issues > 0 { span.tab-count { (meta.issues) } }
crates/git-ents-server/src/web/pages.rs @@ -32,7 +32,7 @@ let clone_url = clone_url(host, rel); let langs = languages(repo).await; let latest = latest_release(repo).await; - let name = rel.rsplit('/').next().unwrap_or(rel); + let name = meta.name(); let main = html! { @if is_empty { @@ -282,7 +282,7 @@ }, }; - let name = rel.rsplit('/').next().unwrap_or(rel); + let name = meta.name(); repo_shell( meta, Tab::Files, @@ -377,7 +377,7 @@ if entries.is_empty() && !dir.is_empty() { return not_found().into_response(); } - let name = rel.rsplit('/').next().unwrap_or(rel); + let name = meta.name(); repo_shell( meta, Tab::Files, @@ -421,7 +421,7 @@ }; let name = path.rsplit('/').next().unwrap_or(&path); let body = if is_binary(&bytes) { - html! { div.blob { div.binary { "Binary file (" (bytes.len()) " bytes) not shown." } } } + html! { div.blob { div.binary { "Binary file (" (human_size(bytes.len())) ") not shown." } } } } else { let text = String::from_utf8_lossy(&bytes); match crate::asciidoc::is_asciidoc(name) @@ -576,39 +576,34 @@ ) } -/// The Hooks tab: CI is defined as plain git hooks, configured in -/// `.gitents/hooks.toml`. Run logs need a store that does not exist yet, so the -/// run list is an empty state; the configuration shown is the real file. -pub(super) async fn hooks_page(repo: &Path, meta: &RepoMeta) -> Markup { - let config = git_output_bytes(repo, &["cat-file", "-p", "HEAD:.gitents/hooks.toml"]) - .await - .map(|b| String::from_utf8_lossy(&b).into_owned()); +/// The Checks tab: CI in the object-graph model. A run is a signed record in the +/// git-metadata fanout keyed by `(config_oid, code_oid)`, so identical +/// config/code pairs dedupe and skip re-execution. The privileged config lives +/// on `refs/ents/config` and unprivileged drafts on `refs/ents/ci/draft/<name>`. +/// None of this is wired up yet, so every panel is an empty state. +pub(super) fn checks_page(meta: &RepoMeta) -> Markup { repo_shell( meta, - Tab::Hooks, - "Hooks", + Tab::Checks, + "Checks", html! { - div.page-header { h1.page-title { "Hooks" } } + div.page-header { h1.page-title { "Checks" } } p.shell-note { - "CI runs as plain " - code { "pre-receive" } " / " code { "post-receive" } - " git hooks, configured in " code { ".gitents/hooks.toml" } - ". Runs and logs appear here once hooks execute on a push." + "CI runs are signed records in the git-metadata fanout, keyed by " + code { "(config_oid, code_oid)" } + ". Configuration is read from " code { "refs/ents/config" } + " and draft pipelines from " code { "refs/ents/ci/draft/*" } + ". Runs appear here once the runner records them." } - div.hooks-grid { + div.checks-grid { div.card { div.card-header { "Recent runs" } div.card-row.muted { "No runs recorded yet." } } - @if let Some(config) = &config { - div.card { - div.card-header { (icon_file()) " .gitents/hooks.toml" } - (blob_body("hooks.toml", config)) - } - } @else { - div.card { - div.card-header { "Configuration" } - div.card-row.muted { "No " code { ".gitents/hooks.toml" } " in this repository." } + div.card { + div.card-header { "Configuration" } + div.card-row.muted { + "No signed config on " code { "refs/ents/config" } " yet." } } } @@ -656,7 +651,7 @@ /// exist yet, so the controls reflect the repository's current real values and /// are presented read-only. pub(super) fn settings_page(meta: &RepoMeta) -> Markup { - let name = meta.rel.rsplit('/').next().unwrap_or(&meta.rel); + let name = meta.name(); repo_shell( meta, Tab::Settings, @@ -686,7 +681,7 @@ div.card-header { "Features" } (feature_row("Bug reports", "Track and triage bugs.", meta.issues > 0)) (feature_row("Releases", "Publish tagged releases.", meta.releases > 0)) - (feature_row("Hooks (CI)", "Run git hooks on push.", meta.has_hooks)) + (feature_row("Checks (CI)", "Run signed CI records on push.", false)) (feature_row("Wiki", "A separate documentation space.", false)) }
crates/git-ents-server/src/web/style.css @@ -119,7 +119,6 @@ .tab.active { color: var(--color-text); font-weight: 600; } .tab.active::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--color-accent); } .tab-count { font-family: var(--font-mono); font-size: .68rem; color: var(--color-text-muted); background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: 0 .4rem; } -.tab-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--s-func); } .overview { display: grid; grid-template-columns: 1fr 19rem; gap: 34px; align-items: start; } .aside { position: sticky; top: 78px; display: flex; flex-direction: column; gap: 18px; min-width: 0; } @@ -295,10 +294,10 @@ .card-row.muted { color: var(--color-text-muted); } .card-row.muted code { font-family: var(--font-mono); background: var(--color-code-bg); padding: .1rem .35rem; border-radius: 5px; } -.hooks-grid { display: grid; grid-template-columns: 20rem minmax(0, 1fr); gap: 20px; align-items: start; } -.hooks-grid .card { margin-bottom: 0; } -.hooks-grid .blob { border: 0; border-radius: 0; box-shadow: none; margin: 0; } -@media (max-width: 800px) { .hooks-grid { grid-template-columns: 1fr; } } +.checks-grid { display: grid; grid-template-columns: 20rem minmax(0, 1fr); gap: 20px; align-items: start; } +.checks-grid .card { margin-bottom: 0; } +.checks-grid .blob { border: 0; border-radius: 0; box-shadow: none; margin: 0; } +@media (max-width: 800px) { .checks-grid { grid-template-columns: 1fr; } } .issues-head { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; padding-bottom: 1.25rem; border-bottom: 1px solid var(--color-border); position: relative; } .issues-head::after { content: ""; position: absolute; bottom: -1px; left: 0; width: 3rem; height: 2px; background: var(--color-accent); border-radius: 1px; }