Merge branch 'web-ui-polish'
commit
187a5a3Merge branch 'web-ui-polish'
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
.dockerignore
@@ -1,2 +1,6 @@
-fly.toml
-.git/
+# The Dockerfile only ever COPYs docker/*; ignore everything else so the
+# build context stays a few KB instead of the whole workspace (including
+# target/, which alone can run into the tens of GB).
+*
+!docker/
+!docker/**
.gitattributes
@@ -1,14 +1,6 @@
-crates/git-ents-server/src/web/icons/LICENSE vendor=octicons
-crates/git-ents-server/src/web/icons/check.svg vendor=octicons
-crates/git-ents-server/src/web/icons/chevron-right.svg vendor=octicons
-crates/git-ents-server/src/web/icons/clock.svg vendor=octicons
-crates/git-ents-server/src/web/icons/file-directory-fill.svg vendor=octicons
-crates/git-ents-server/src/web/icons/file.svg vendor=octicons
-crates/git-ents-server/src/web/icons/git-branch.svg vendor=octicons
-crates/git-ents-server/src/web/icons/git-commit.svg vendor=octicons
-crates/git-ents-server/src/web/icons/issue-opened.svg vendor=octicons
-crates/git-ents-server/src/web/icons/north-star.svg vendor=octicons
-crates/git-ents-server/src/web/icons/plus.svg vendor=octicons
-crates/git-ents-server/src/web/icons/repo.svg vendor=octicons
-crates/git-ents-server/src/web/icons/search.svg vendor=octicons
-crates/git-ents-server/src/web/icons/tag.svg vendor=octicons
+crates/cli/ents-web/src/assets/icons/LICENSE vendor=octicons
+crates/cli/ents-web/src/assets/icons/chevron-right.svg vendor=octicons
+crates/cli/ents-web/src/assets/icons/file-directory-fill.svg vendor=octicons
+crates/cli/ents-web/src/assets/icons/file.svg vendor=octicons
+crates/cli/ents-web/src/assets/icons/git-branch.svg vendor=octicons
+crates/cli/ents-web/src/assets/icons/search.svg vendor=octicons
.gitignore
@@ -1,6 +1,5 @@
+.DS_Store
target/
-.claude/
-PROMPT.md
-*.html
-# Askama view templates are source, not generated HTML output.
-!crates/git-ents-server/templates/*.html
+# Materialized from refs/meta/releases/<sha> just before `docker build`;
+# never a normal tracked file.
+docker/bin/
.gitvendors
@@ -2,17 +2,9 @@
url = https://github.com/primer/octicons
base = 6d9dcd901260bf05a1d82bd3bf52346032f1cedb
mode = squash
- pattern = icons/repo-16.svg:crates/git-ents-server/src/web/icons/repo.svg
- pattern = icons/file-directory-fill-16.svg:crates/git-ents-server/src/web/icons/file-directory-fill.svg
- pattern = icons/file-16.svg:crates/git-ents-server/src/web/icons/file.svg
- pattern = icons/plus-16.svg:crates/git-ents-server/src/web/icons/plus.svg
- pattern = icons/issue-opened-16.svg:crates/git-ents-server/src/web/icons/issue-opened.svg
- pattern = icons/check-16.svg:crates/git-ents-server/src/web/icons/check.svg
- pattern = icons/chevron-right-16.svg:crates/git-ents-server/src/web/icons/chevron-right.svg
- pattern = icons/git-branch-16.svg:crates/git-ents-server/src/web/icons/git-branch.svg
- pattern = icons/tag-16.svg:crates/git-ents-server/src/web/icons/tag.svg
- pattern = icons/clock-16.svg:crates/git-ents-server/src/web/icons/clock.svg
- pattern = icons/git-commit-16.svg:crates/git-ents-server/src/web/icons/git-commit.svg
- pattern = icons/north-star-16.svg:crates/git-ents-server/src/web/icons/north-star.svg
- pattern = icons/search-16.svg:crates/git-ents-server/src/web/icons/search.svg
- pattern = LICENSE:crates/git-ents-server/src/web/icons/LICENSE
+ pattern = icons/file-directory-fill-16.svg:crates/cli/ents-web/src/assets/icons/file-directory-fill.svg
+ pattern = icons/file-16.svg:crates/cli/ents-web/src/assets/icons/file.svg
+ pattern = icons/chevron-right-16.svg:crates/cli/ents-web/src/assets/icons/chevron-right.svg
+ pattern = icons/search-16.svg:crates/cli/ents-web/src/assets/icons/search.svg
+ pattern = icons/git-branch-16.svg:crates/cli/ents-web/src/assets/icons/git-branch.svg
+ pattern = LICENSE:crates/cli/ents-web/src/assets/icons/LICENSE
Cargo.toml
@@ -1,6 +1,21 @@
[workspace]
resolver = "3"
-members = []
+members = [
+ "crates/kernel/ents-anchor",
+ "crates/kernel/ents-effect",
+ "crates/kernel/ents-gate",
+ "crates/kernel/ents-model",
+ "crates/kernel/ents-query",
+ "crates/kernel/ents-receive",
+ "crates/kernel/ents-sync",
+ "crates/kernel/ents-testutil",
+ "crates/cli/ents-lens",
+ "crates/cli/ents-web",
+ "crates/cli/git-ents",
+ "crates/forge/ents-forge",
+ "crates/kiln/ents-kiln",
+ "crates/substrate/gix-ref-store",
+]
[workspace.package]
edition = "2024"
@@ -12,6 +27,19 @@
missing_docs = "warn"
[workspace.dependencies]
+ents-anchor = { path = "crates/kernel/ents-anchor" }
+ents-effect = { path = "crates/kernel/ents-effect" }
+ents-forge = { path = "crates/forge/ents-forge" }
+ents-gate = { path = "crates/kernel/ents-gate" }
+ents-kiln = { path = "crates/kiln/ents-kiln" }
+ents-lens = { path = "crates/cli/ents-lens" }
+ents-model = { path = "crates/kernel/ents-model" }
+ents-query = { path = "crates/kernel/ents-query" }
+ents-receive = { path = "crates/kernel/ents-receive" }
+ents-sync = { path = "crates/kernel/ents-sync" }
+ents-testutil = { path = "crates/kernel/ents-testutil" }
+ents-web = { path = "crates/cli/ents-web" }
+gix-ref-store = { path = "crates/substrate/gix-ref-store" }
arborium = { version = "2.18", default-features = false, features = [
"lang-rust",
"lang-toml",
@@ -37,6 +65,7 @@
acdc-converters-terminal = { git = "https://github.com/nlopes/acdc", rev = "6ae19bc2e6f0fa4254a3e9ebd1c3d2f6c7caafde" }
askama = "0.16"
axum = "0.8"
+cargo_metadata = "0.23"
figue = "5.0.0-rc.5"
facet = { version = "0.50.0-rc.0", features = ["reflect"] }
facet-pretty = "0.50.0-rc.5"
@@ -48,19 +77,25 @@
gix-date = "0.15"
gix-features = { version = "0.48", features = ["zlib"] }
gix-hash = { version = "0.25", features = ["sha1"] }
+gix-lock = "23.0"
gix-object = "0.61"
gix-odb = "0.81"
gix-pack = "0.71"
iddqd = "0.4"
+lsp-server = "0.9"
+lsp-types = "0.94"
object_store = { version = "0.14", default-features = false, features = [
"aws",
] }
maud = { version = "0.27", features = ["axum"] }
+proptest = "1"
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
rayon = "1"
rstest = "0.26"
semver = "1"
+serde_json = "1"
spdx = "0.13"
+ssh-key = { version = "0.6", features = ["ed25519"] }
target-lexicon = "0.13"
tempfile = "3"
thiserror = "2"
@@ -75,7 +110,6 @@
tokio-postgres = { version = "0.7", default-features = false, features = [
"runtime",
] }
-uuid = { version = "1", features = ["v4"] }
# These lint configurations were originally pulled from [Evan Schwartz][1].
# [1]: https://emschwartz.me/your-clippy-config-should-be-stricter/
.config/committed.toml
@@ -21,6 +21,7 @@
"docs",
"spec",
"checks",
+ "lens",
"release",
"treewide",
"multi",
.config/fly.toml
@@ -1,20 +1,25 @@
-# fly.toml app configuration file generated for git-ents-server on 2026-06-20T12:17:33-04:00
+# fly.toml app configuration file for git-ents-hosted.
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
+# `git-ents-server` (no `-c` needed, its own separate app) is the legacy
+# pre-redo deployment, left alone. This app is the single-node hosted root
+# (`roots.single-node-hosted`, phase 6), a dedicated app so its shared
+# anycast proxy never round-robins traffic onto legacy's stopped machine.
-app = 'git-ents-server'
+app = 'git-ents-hosted'
primary_region = 'iad'
[build]
- # crates/git-ents-server doesn't exist yet post-redo; path is stale until it's rebuilt
- dockerfile = "../crates/git-ents-server/Dockerfile"
+ # The single-node hosted root (`roots.single-node-hosted`, phase 6):
+ # `git-ents` itself, served behind stock git's smart-HTTP transport.
+ dockerfile = "../Dockerfile"
[env]
PORT = '8080'
[mounts]
- source = 'odb'
+ source = 'git_ents_hosted'
destination = '/data'
[http_service]
.config/nextest.toml
@@ -5,15 +5,12 @@
# backend) race and flake when several test binaries hit the Docker daemon
# at once at default parallelism. Serialize exactly those tests through
# one test group; everything else keeps full parallelism.
+#
+# The `docker` group and its filter are reinstated as the crates that need
+# it (refstore-postgres, effect-dispatcher, git-ents-server, git-effect)
+# land in their own phases; none exist yet in the post-redo workspace, and
+# a filter naming an absent package is a hard nextest config error, not a
+# no-op.
[test-groups]
docker = { max-threads = 1 }
-
-[[profile.default.overrides]]
-filter = '''
-package(refstore-postgres)
-| package(effect-dispatcher)
-| (package(git-ents-server) & binary(hydrate))
-| (package(git-effect) & test(docker_backend_runs_a_trivial_effect))
-'''
-test-group = "docker"
.config/pre-commit.yaml
@@ -1,6 +1,6 @@
# Vendored content (see .gitvendors) is kept byte-for-byte as upstream, so it
# must not be reformatted by the hooks below.
-exclude: ^(.*/)?(CHANGELOG\.md)$|^crates/git-ents-server/src/web/icons/
+exclude: ^(.*/)?(CHANGELOG\.md)$|^crates/cli/ents-web/src/assets/icons/
default_install_hook_types: [pre-commit, commit-msg]
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
docs/abstractions.adoc
@@ -25,7 +25,7 @@
Two namespaces are consequences of the granularity rule plus the gate (5):
* `refs/meta/inbox/*` — entities authored by someone not authorized for a canonical ref, awaiting adoption.
-* `refs/meta/results/~<member>/*` — results produced by a member's own executor rather than a designated worker.
+* `refs/meta/self/<member>/*` — results produced by a member's own executor rather than a designated worker.
Both hold the same typed trees as their canonical counterparts; only the refname rule differs.
@@ -38,9 +38,8 @@
History keeps the old encoding as archive.
Trees stay pure struct representations — no version marker entry.
-Ref-level metadata that is not entity content belongs in commit-message trailers, because the commit is already the storage unit.
-Two trailers are reserved: `Schema-Version:` for explicit encoding detection if it is ever needed, and `Ents-Ref:` for refname binding (see 4).
-Versioning or binding in the tree would pollute both the schema and the merge path.
+Ref-level metadata that is not entity content — who, when, the signature — lives in the commit header, because the commit is already the storage unit; no reserved trailer exists.
+Refname binding needs no stored metadata at all: the refname is a total function of signed content, recomputed at verification (see 4).
*Tip invariant:* the tip of a meta-ref is always readable by the binary that owns the entity type; a non-owning binary treats the tree as opaque and degrades to generic display (`git store show`); history is archival.
(Redacted entries are the one qualification: readers surface a withheld object as a redaction marker, never an error — see Derived.)
@@ -67,7 +66,7 @@
A commit signature proves authorship; it does not prove placement.
The difference is recovered explicitly:
-* *Refname binding* — an `Ents-Ref:` trailer names the ref the commit was authored for, checked at verification; without it, a signed commit could be replayed as the tip of a different meta-ref.
+* *Refname binding* — the refname is recomputed from the commit's own signed content (a genesis oid, a natural-key tree field, a composite of fields and signer) and a mismatch refuses; without this, a signed commit could be replayed as the tip of a different meta-ref.
* *Anti-replay* — meta-refs advance fast-forward-only in the DAG sense (the new tip descends from the old), enforced by atomic CAS on the ref store; the parent hash is the freshness binding, so no nonce is needed.
*Tip invariant:* the tip of a meta-ref is signed by a member authorized for that refname.
@@ -91,7 +90,7 @@
[arabic]
. The new tip is signed by a member authorized for this refname.
-. The `Ents-Ref:` trailer matches the refname.
+. The refname recomputes from the tip's signed content.
. The new tip descends from the old tip.
. The update commits via atomic CAS.
@@ -172,7 +171,7 @@
*Identity discipline:* the runner is a member, not an ambient authority.
A result signature proves who pushed the result, not that the run was faithful; official results are official because canonical results refs are writable only by designated worker keys — a refname rule, not a runtime property.
-Any member may run any effect on their own executor and account; their results land in `refs/meta/results/~<member>/*` or the inbox, adoptable by merge like everything else, with the trust decision explicit in the adoption.
+Any member may run any effect on their own executor and account; their results land in `refs/meta/self/<member>/*` or the inbox, adoptable by merge like everything else, with the trust decision explicit in the adoption.
Anyone can run CI; nobody can impersonate the verdict.
*Where the line holds:* no content predicates beyond `results(…, status)`, no time atoms, no external-event atoms.
@@ -233,6 +232,35 @@
'''''
+== Layering (consequence of the abstractions)
+
+The crate graph is four layers, each depending only on the layer(s) before it: substrate, kernel, package, CLI.
+Dependencies point one way; a lower layer never depends on a higher one, checked mechanically (`layering.rs`, run in CI alongside every other test).
+
+* *Substrate* — `gix-ref-store`: the pluggable `RefStore` seam gitoxide itself does not provide.
+Extends git, not the forge; carries the `gix-` prefix per the Composition section's own rule.
+* *Kernel* — `ents-model`, `ents-anchor`, `ents-gate`, `ents-effect`, `ents-query`, `ents-receive`, `ents-sync`, `ents-testutil`.
+Owns mechanism: typed ref-store access and refs/meta layout primitives, signing and authorship, the entity envelope (typed trees, id assignment), gate execution and composition semantics, effect dispatch, adoption/inbox mechanics, yank, sync/receive plumbing, hooks.
+The kernel is declarative and generic — it defines *how* any entity is stored, verified, and run, never *which* entities exist.
+* *Package* — `ents-forge` (issues, comments, review/release/check types as they land), `ents-kiln` (toolchains).
+Owns types and policy: the domain entity structs, and any package-specific gate or effect definitions.
+A package depends on kernel crates freely, and on other packages never — `ents-forge` and `ents-kiln` do not depend on each other.
+* *CLI* — the leaf layer, two sibling occupants: `git-ents` and `ents-web`, the composition roots and their direct consumers.
+`git-ents` depends on the kernel directly (for kernel-owned commands: `setup`, `members`, `account`, `effect`, `inbox`, `redact`, `hook`) and on every installed package (for `comment`, from `ents-forge`; `toolchain`, from `ents-kiln`).
+Mounts each package's subcommand grammar through one convention (`crate::package::Package`, in `git-ents`): a package owns its own figue action enum, defined in its own crate, and the CLI's top-level subcommand enum references that type directly — a compile-time pairing, not a runtime plugin registry, because the argument grammar is resolved from a `#[derive(Facet)]` shape at compile time, with nothing to register dynamically.
+`ents-web` (phase 7) is a second leaf, not a third layer: it depends on kernel crates and, for v1, directly on `ents-forge` and `ents-kiln` too, hardcoding a custom page per package where one is genuinely needed (a toolchain's recipe provenance, a comment's anchor projection) — legitimate for a leaf consumer exactly as `git-ents`'s own package-specific subcommands are, not a layering violation.
+The one rule that binds inside `ents-web` itself is narrower than the crate-level layering rule: its *generic* rendering path — the schema-driven list/view mechanism driven by `facet::Shape` reflection, the UI analog of the gate executor — must never match on which concrete entity type it was handed.
+A generic-path need to know a concrete type is a missing generic capability to add to that mechanism, or grounds for a legitimate custom page; it is never grounds for a `match` inside the generic renderer.
+`git-ents` depends on `ents-web` (for its `serve` subcommand), the one edge between the two leaves; `ents-web` never depends back on `git-ents`.
+
+The one-way rule is absolute, not just directional-on-average: a kernel crate must not depend on a package crate in any form — not a normal dependency, not a dev-dependency, not a build-dependency, not behind a feature flag.
+`ents-testutil` in particular must not know a package's types; a package needing test fixtures beyond the kernel's generic ones adds them inside its own crate, not by teaching `ents-testutil` its vocabulary.
+
+Ref-namespace segments (`refs/meta/issues/*`, `refs/meta/comments/*`, `refs/meta/toolchains/*`, and so on) stay kernel-reserved regardless of which layer owns the entity stored there: every namespace is minted by one function in `ents_model::namespace`, and a package calls that function rather than inventing its own ref layout.
+This is what keeps `meta-ref.granularity` and `meta-ref.namespace` a single, auditable surface even as entity types move between crates — a package can gain or lose ownership of a type without the ref layout itself ever needing to change.
+
+'''''
+
== Derived, not fundamental
Instances and consequences of the six, listed for orientation:
docs/design.adoc
@@ -27,7 +27,7 @@
Mutations are author-signed commits, not push certificates.
A push cert signs a transition and evaporates at the transport layer; a commit signature replicates with the repo and verifies in every clone, forever.
Verification evidence is repository state, like everything else.
-Refname binding lives in an `Ents-Ref:` trailer; anti-replay is fast-forward-plus-CAS, with the parent hash as the freshness binding.
+Refname binding is recomputed from signed content — every meta-ref's name is a total function of what it holds (a genesis commit's oid, a natural-key tree field, a composite of fields and signer); anti-replay is fast-forward-plus-CAS, with the parent hash as the freshness binding.
Because writing and verifying are separated, local is genuinely offline-first: the local store accepts any write and the gate merely annotates.
You can author while unenrolled, work against an unfetched member list, and accumulate meta-refs the canonical store would reject.
docs/development-plan.adoc
@@ -87,7 +87,8 @@
| sonnet
| Entity structs, namespaces, trailers, taxonomy — declarative, but it is
the vocabulary every crate imports, so spec fidelity and the public API
- review matter more than the code. Absorbs git-metadata's trailer work.
+ review matter more than the code. Trailer parsing is new work, not a
+ port: no `git-metadata` crate exists at `pre-redo`.
| `ents-gate`
| 3
@@ -152,22 +153,42 @@
| Local root wiring (~50 lines) plus the real cost: subcommand surface
and the `denyCurrentBranch=updateInstead` worktree edge. Headless — no
`serve` subcommand yet (arrives Phase 7 with `ents-web`). Doubles as
- the single-node hosted root: loose refs and a real odb on a Fly volume,
- served behind git's own `receive-pack` — the same stock-git transport
- Phase 0 bootstraps, now invoking `receive()` from a hook — with an
- in-memory `EventSink` and a boot-time reconciliation scan, and the
- Sprite executor, deployed at `git.ents.cloud`. Milestone: CLI-complete.
+ the single-node hosted root (`roots.single-node-hosted`): loose refs
+ and a real odb on a Fly volume, served behind git's own `receive-pack`
+ — the same stock-git transport Phase 0 bootstraps, now invoking the
+ gate from a hook — with an in-memory `EventSink` and a boot-time
+ reconciliation scan, and the Sprite executor, deployed at
+ `git.ents.cloud`. Milestone: CLI-complete. Two gaps ship unclosed,
+ neither assigned a phase: no porcelain command or root bootstrap sets
+ `refs/meta/config`'s verification epoch, so the hosted root's Mandatory
+ gate stays archival until one is designed (`gate.epoch-bootstrap`); and
+ `meta-ref.tip-invariant`'s generic escape-hatch display for an
+ unrecognized meta-ref, and anchor resolution, have no command anywhere
+ in this binary, despite `ents-model` (phase 2) forward-referencing them
+ to "the `git-ents` binary" by name.
| `ents-web`
| 7
| Medium
| sonnet
-| Broad surface, low algorithmic depth. Lands `serve` in `git-ents`,
- turning the single-node hosted root into a hosted web UI with
- sessions/CSRF (`roots.web-session`). Signing identity is injected by
- the composition root; the crate assumes nothing about network
+| Broad surface, low algorithmic depth. Lands `serve` in `git-ents`
+ (`roots.local`): the local root's own wiring, plus sessions/CSRF
+ (`roots.web-session`) and signed mutations (`roots.web-signing`)
+ layered on top, the user's own key standing in for whichever identity
+ a composition root injects. Signing identity is injected by the
+ composition root; the crate assumes nothing about network
reachability, so in-process webview embedding stays a supported
- deployment (`roots.web-agnostic`).
+ deployment (`roots.web-agnostic`). Gap this phase does not close, not
+ assigned to any later phase either: no composition root actually wires
+ `ents-web` onto a hosted root — not the single-node hosted root this
+ binary already doubles as, deployed at `git.ents.cloud` since Phase 6,
+ and not `git-ents-server` (phase 8, whose own row below merely reuses
+ this phase's sessions/CSRF code, never mounts the web UI).
+ `roots.web-signing`'s server-key indirection is proven only
+ against fixture identities in this crate's own tests; "turning the
+ single-node hosted root into a hosted web UI," which an earlier
+ version of this row asserted as delivered, describes the crate's
+ capability, not a running deployment.
| `gix-receive`
| 8
@@ -192,6 +213,91 @@
date. Reuses the Sprite executor and sessions/CSRF from `git-ents`;
salvage the store code and gix-receive framing from the scale-out work
at `pre-redo`.
+
+| `ents-forge` conversations
+| 9
+| Medium
+| fable
+| Comments become the universal conversational primitive (`model.comment`
+ broadened, `model.comment-state`, `model.comment-context`,
+ `model.comment-thread`, `model.review`): the Comment struct broadens as
+ a clean break (experimental repo, no on-disk data to preserve, so no
+ back-compat reader), working-tree capture and projection in
+ `ents-anchor` (`anchor.working-tree`), the
+ Review entity plus its retention pin ref (`model.review-pin` —
+ `refs/meta/pins/reviews/<target>/<member>`, an empty-tree signed commit whose
+ parents include the reviewed commit; verify the gate's tip-signed and
+ fast-forward checks accept the merge-shaped pin advance), and
+ porcelain — `comment reply/resolve/reopen`,
+ `comment list --worktree` with a machine-readable form (`lens.parity`),
+ `issue` and `review` actions.
+
+| `ents-lens`
+| 9
+| High
+| opus
+| The LSP frontend (`lens.adoc`, all rules): `git ents lsp` reusing the
+ local root's wiring the way `serve` does, code lenses + hint
+ diagnostics + hover derived per-request from anchor projection onto
+ the working tree, and the editor-file compose flow. No network, no
+ state of its own.
+
+| `ents-zed` (extension)
+| 9
+| Low
+| sonnet
+| Zed extension at `editors/zed`, outside the workspace (wasm target):
+ registers the `ents-lsp` language server running `git ents lsp`.
+ Verify current Zed LSP capability coverage against the real
+ `zed_extension_api` — `lens.diagnostics` exists precisely so a client
+ without code-lens rendering still shows the conversation.
+
+| `ents-web` conversations
+| 9
+| Medium
+| opus
+| Issues index and detail with threads as context queries
+ (`model.comment-context`), review creation and verdict display on
+ commit pages (`model.review`), reply/resolve on the existing comment
+ views. Reuses the registry-driven meta rail and session/CSRF machinery
+ already in place.
+
+| kernel identity binding (`ents-model`, `ents-gate`, `ents-receive`,
+ `ents-sync`, `ents-effect`, `ents-testutil`)
+| 10
+| High
+| fable
+| One atomic migration — the kernel cannot build half-converted. Retire
+ `trailer.rs` (`Advance-ref` and the never-used `Schema-Version`) in
+ favor of `meta-ref.identity-binding`: the gate recomputes each refname
+ from signed content — the parentless-roots walk for hash-identified
+ namespaces (never applied to pins, whose ancestry reaches code
+ history), natural-key tree fields (`Member` and `Effect` gain their
+ name field), composite review segments, and result trees gaining
+ effect + target (`model.result-identity`, closing a result-forgery
+ replay). Strict genesis decode for the gate's own types (a result)
+ plus the pairwise schema-disjointness test across every genesis-borne
+ struct; `gate.owner-mutation`; `propose_*` grows the sign-then-name
+ genesis flow (the ref named from the signed commit's own oid — no
+ circularity, since no commit names its own ref anymore). Two gaps ship
+ unclosed, neither assigned a phase: a comment or an issue's type lives
+ outside the gate's own crate, so strict decode never runs for those
+ namespaces (`gate.non-kernel-strict-decode`); and the binding's
+ redaction-vouching clause has no implementation
+ (`gate.redaction-vouching-undefined`).
+
+| forge and surface identity migration (`ents-forge`, `git-ents`,
+ `ents-web`, `ents-lens`, skills)
+| 10
+| Medium
+| sonnet
+| `uuid` leaves the workspace: comment and issue ids are genesis commit
+ oids, reviews move to the composite `reviews/<target>/<member>` key
+ with re-review's fast-forward advance now a reachable CLI path; the
+ CLI's plain display and the web UI abbreviate ids the way git
+ abbreviates oids, while the `--porcelain` and lens machine-readable
+ forms keep the full id (`lens.parity`); lens and agent skills updated
+ for the id format.
|===
== Phase gates
@@ -211,13 +317,55 @@
* *Phase 6 exit*: the CLI-complete milestone — every porcelain command
green against the single-node hosted root; the boot-time
reconciliation scan regenerates obligations correctly after a
- `kill -9` of the in-memory queue.
+ `kill -9` of the in-memory queue. The two gaps this row's own notes
+ name (`gate.epoch-bootstrap`; `meta-ref.tip-invariant`'s missing
+ reader surface) are known exceptions, not silent ones.
+* *Phase 7 exit*: `git ents serve` (`roots.local`) is loopback-only with
+ no smart-HTTP surface added, drives every state-changing route through
+ a per-session CSRF check (`roots.web-session`), and signs mutations
+ through an identity the composition root injects, never one this
+ crate resolves itself (`roots.web-signing`, `roots.web-agnostic`); the
+ same router is provably reachable with no bound socket, driven
+ in-process via `tower::ServiceExt::oneshot`. This row's own gap is a
+ known exception, not a silent one: no hosted deployment (`git.ents.cloud`
+ or `git-ents-server`) actually mounts this web UI yet, so
+ `roots.web-signing`'s server-key half stays proven only against
+ fixture identities until some later phase's composition root wires it.
* *Phase 8 exit*: the honesty test (`roots.honesty-test`) — the
Postgres/Tigris/durable-queue root, now served by `gix-receive` in
place of git's own `receive-pack`, is wired as a new composition root
with zero library-crate modification; `gix-receive` round-trips a push
from stock git, and a diff of library-crate contents between phase
entry and phase exit MUST be empty.
+* *Phase 9 exit*: the comment loop — a comment composed through the
+ lens's editor-file flow against a dirty working tree
+ (`lens.compose`, `anchor.working-tree`) is listed open by the
+ machine-readable `git ents comment list --worktree` form
+ (`lens.parity`), resolved through the CLI (`model.comment-state`),
+ and gone from the lens's next publish; an issue and a review each
+ round-trip CLI ↔ web with their threads rendered as context queries
+ (`model.comment-context`, `model.review`). This being an experimental
+ repository with no on-disk data to preserve, a struct change is a
+ clean break: entities read only as their current shape, and no
+ back-compat reader is kept — `meta-ref.migration`'s forward rewrite
+ applies whenever data actually needs carrying, not a permanent legacy
+ code path.
+* *Phase 10 exit*: identity is derivation — no minted id and no binding
+ trailer exists anywhere in the workspace. The doppelgänger replay (a
+ signed mutation commit proposed as the genesis of a new entity) and
+ the result replay (a signed `pass` proposed for a different effect or
+ commit) are refused by tests driving `receive`
+ (`gate.identity-binding`, `model.result-identity`); entity structs
+ prove pairwise disjoint under strict decode; a comment created,
+ replied to, and resolved, and a commit reviewed then re-reviewed
+ (the pin advancing fast-forward), round-trip through CLI and web
+ under genesis-oid and composite ids (`model.comment`,
+ `model.review`). Same clean-break rule as phase 9: no legacy reader,
+ ids regenerate with the data. The two gaps this row's own notes name
+ (`gate.non-kernel-strict-decode`; `gate.redaction-vouching-undefined`)
+ are known exceptions, not silent ones: a comment or issue genesis is
+ not yet refused from double-admission across namespaces, and the
+ binding's redaction-vouching clause is unimplemented.
Backend conformance tests are shared suites written once against each
trait (`RefStore`, `EventSink`, `Executor`) and run per implementation —
docs/faq.adoc
@@ -150,7 +150,7 @@
Revocation is a state, never a deletion — deleting the entity would merely make their old signatures unverifiable, which is the opposite of what an audit needs.
*I want to verify that nobody tampered with forge state.*
-Every meta-ref tip must be signed by a member authorized for that refname, bound to the ref by an `Ents-Ref:` trailer, descending from the previous tip.
+Every meta-ref tip must be signed by a member authorized for that refname, bound to the ref by recomputing the refname from the tip's own signed content, descending from the previous tip.
`git ents members check` verifies this from any clone, with no server's cooperation.
*I want to know who did what, and when.*
@@ -178,7 +178,7 @@
Cherry-pick is forbidden as an adoption verb because it would destroy the signature it is meant to honor.
*I want to prove my patch passes CI, without any access.*
-Run the project's real effects on your own executor; results land in your namespace (`refs/meta/results/~you/*`), signed by you, shareable and verifiable.
+Run the project's real effects on your own executor; results land in your namespace (`refs/meta/self/you/*`), signed by you, shareable and verifiable.
A maintainer can adopt them, with the trust decision explicit in the adoption merge.
*I want to know why this feels like the old email-patch workflow.*
docs/spec/anchor.adoc
@@ -72,3 +72,22 @@
An outdated or deleted projection MUST NOT lose the anchor: the original
anchor remains displayable.
--
+
+[role="requirement", id="anchor.working-tree"]
+.The Working Tree Is a Source and a Target
+--
+Capturing an anchor MUST support the working tree as its source: the
+file's current on-disk bytes are written to the object database as a
+blob and embedded per <<anchor.retention>>, so an anchor to uncommitted
+content survives that content being committed, amended, or discarded.
+Such an anchor MUST record `HEAD`'s commit in its commit field — the
+same best-effort, never-load-bearing data field <<anchor.immutable>>
+already makes it.
+Projection MUST support the working tree as its target, diffing the
+anchored blob against the path's current on-disk bytes — or against a
+caller-supplied buffer standing in for them (<<lens.working-tree>>) —
+and reporting the same four outcomes as <<anchor.projection>>.
+A working-tree projection MAY degrade rename following to the context
+fallback (<<anchor.fuzzy-fallback>>): there is no commit on the target
+side to diff trees against.
+--
docs/spec/effect.adoc
@@ -60,7 +60,8 @@
effect's command inside a sandbox.
Sandboxed execution MUST sit behind one `Executor` trait with multiple
backends, selected only at a composition root (<<roots.local>>,
-<<roots.hosted>>), so no execution logic is duplicated per backend.
+<<roots.single-node-hosted>>, <<roots.hosted>>), so no execution logic is
+duplicated per backend.
Host-direct execution, with no sandbox, MUST require an explicit
`--unsandboxed` flag and MUST be available only locally, never on canonical
hosted infrastructure.
@@ -147,7 +148,7 @@
--
Any member MAY run any effect on their own executor and account.
Such a member's results MUST land under the self-run namespace
-`refs/meta/results/~<member>/<effect>/<short-oid>` (<<meta-ref.inbox>>),
+`refs/meta/self/<member>/<effect>/<short-oid>` (<<meta-ref.inbox>>),
never directly on the canonical results ref, and MUST be adoptable onto
the canonical ref only through the same
adoption merge as any other contribution (<<gate.adoption-merge>>,
docs/spec/gate.adoc
@@ -13,17 +13,103 @@
.Tip Signature
--
The new tip of a meta-ref MUST be signed by a member authorized for that
-refname.
-This is checkable after the fact by anyone with a clone, not only by
-whoever ran the server.
+refname, judged against the member entity in force at acceptance time —
+the member ref's tip in the same snapshot the gate reads
+(<<model.member-revocation>>).
+Anyone with a clone can run the same judgment against the same snapshot,
+not only whoever ran the server; reconstructing what a past acceptance
+saw is an audit function over the deployment's out-of-scope op log, not
+a gate path.
--
-[role="requirement", id="gate.refname-binding"]
-.Refname Binding
+[role="requirement", id="gate.identity-binding"]
+.Identity Binding
--
-The commit's `Ents-Ref:` trailer MUST match the refname being updated.
+The refname being updated MUST be recomputable from the proposed tip's
+signed content, per namespace exactly as <<meta-ref.identity-binding>>
+tabulates; a mismatch MUST refuse.
Without this check a signed commit could be replayed as the tip of a
-different meta-ref than the one its author signed for.
+different meta-ref than the one its author created it for — including
+a signed pass replayed as the result of a different effect or commit,
+which is why a result's effect and target are tree fields
+(<<model.result-identity>>).
+The creation of a hash-identified entity whose type the gate crate
+itself owns MUST strictly decode as that type, an unknown tree entry
+refusing; the gate-owned entity structs MUST stay pairwise disjoint
+under this decode, a property held by test rather than by a stored
+marker (<<meta-ref.typed-tree>>).
+A hash-identified entity whose type lives outside the gate's own crate
+(a comment, an issue) binds by genesis oid and the all-roots walk
+alone (<<meta-ref.identity-binding>>): the gate cannot strictly decode
+a type it does not depend on, so the same signed genesis commit is not
+yet refused from being admitted a second time under a different such
+namespace (<<gate.non-kernel-strict-decode>>).
+When an object the binding needs is withheld by redaction, the binding
+MUST be vouched by the admin-signed redaction record instead of
+recomputed, and a redacted object MUST NOT be re-admitted
+(<<receive.redaction-ingest>>, <<model.redaction>>); this vouching has
+no implementation yet (<<gate.redaction-vouching-undefined>>).
+--
+
+[role="requirement", id="gate.owner-mutation"]
+.Ownership Keys Mutation
+--
+Advancing a hash-identified entity's ref MUST be authorized only for
+the member whose signature its genesis carries — ownership intrinsic
+to the id, which is the genesis commit's oid — or for an
+admin-registered member; a review ref MUST advance only under the
+signature of the member its refname names (<<model.review>>).
+Creation stays provenance-keyed exactly as <<model.member-provenance>>
+routes it: a self-attested member creates and mutates through its own
+inbox, adopted onto the canonical ref by merge
+(<<gate.adoption-merge>>), which satisfies the parentless-roots walk
+because the contributor's genesis remains the history's sole root.
+--
+
+=== Identity Binding Gaps
+
+[role="requirement", id="gate.non-kernel-strict-decode"]
+.Non-Kernel Hash-Identified Types Have No Strict-Decode Enforcement (Deferred)
+--
+<<gate.identity-binding>>'s strict-decode refusal only runs for a
+hash-identified entity whose Rust type the gate crate itself owns
+(today, a result); a comment or an issue's type lives in a higher-layer
+crate the gate MUST NOT depend on, so the gate's binding for those
+namespaces checks only the genesis oid and the all-roots walk, never
+the tree's shape.
+The workspace-level pairwise-disjointness test (<<meta-ref.typed-tree>>)
+proves the *types* cannot both decode one tree; it does not by itself
+stop the *gate* from admitting the identical signed genesis commit as
+the tip of two different such namespaces (for example
+`refs/meta/comments/<oid>` and `refs/meta/issues/<oid>`), since neither
+admission path ever calls either type's decoder.
+Closing this gap needs either a decoder a non-kernel crate can register
+with the gate, or moving strict decode to a layer that already depends
+on every entity type (`receive` or above); this is chosen direction,
+not yet designed, the same acknowledged-gap shape as <<gate.bootstrap>>.
+--
+
+[role="requirement", id="gate.redaction-vouching-undefined"]
+.Redaction Vouching for the Binding Has No Implementation (Deferred)
+--
+<<gate.identity-binding>> requires that when an object the binding
+needs has been redacted, the gate vouch by the admin-signed redaction
+record rather than fail trying to re-read withheld bytes.
+No such check exists: the gate treats a redacted (missing) object the
+same as any other unreadable one — for a walk like the all-roots
+reachability check this ends that path early rather than consulting
+`refs/meta/redactions/*`, and for a required tree-entry read it
+surfaces as an evaluation error the mandatory call site MUST block on
+(<<gate.mandatory-hosted>>), not the vouched pass-through the
+requirement describes.
+`receive.redaction-ingest`'s refusal to re-admit already-redacted bytes
+is a different guarantee — it stops refilling a yanked object at
+ingest — and does not give the gate a way to keep verifying later,
+unrelated mutations on a ref whose history happens to reach a redacted
+object.
+Which component consults the redaction list on the gate's behalf, and
+how, is chosen direction, not yet designed, the same acknowledged-gap
+shape as <<gate.bootstrap>>.
--
[role="requirement", id="gate.fast-forward"]
@@ -45,8 +131,12 @@
.Signature as Repository Data
--
A commit signature MUST be treated as a data artifact, not a transport
-artifact: it replicates with the repository and MUST verify offline in
-every clone, so verification evidence is itself repository state.
+artifact: it replicates with the repository and MUST verify
+cryptographically offline in every clone, so the signature evidence is
+itself repository state.
+Whether the signing key was authorized is a property of the snapshot the
+gate reads (<<gate.policy-as-state>>, <<model.member-revocation>>), not
+of the signature bytes.
A push certificate carries no meta-ref semantics; it MUST NOT be consulted
by the gate.
--
@@ -115,10 +205,24 @@
MUST render this reason to the user — for example, "your signing key is
not authorized for `refs/heads/main`" — so a negative verdict is
actionable before a push is ever attempted.
+This guarantee is scoped to `refs/meta/*`: the enumerated requirements
+never fire against `refs/heads/*` (<<gate.principled-split>>), whose own
+authorization mechanism is not yet specified
+(<<gate.branch-acl-undefined>>), so the illustrative refname above is
+aspirational until that mechanism exists.
--
=== Adoption
+The requirements below are consequences the gate enforces by judging only
+the tip plus DAG descent (this file's own introduction: verification is a
+pure function over ref-store reads). The prohibition on cherry-picking as
+an adoption mechanism is not among them: a cherry-picked commit, signed
+by the authorized member placing it, satisfies <<gate.tip-signed>> exactly as
+a hand-authored commit would, so no function over the resulting ref state
+can tell the two apart. That prohibition binds the adoption tooling
+instead — <<sync.adoption-no-cherry-pick>>.
+
[role="requirement", id="gate.adoption-merge"]
.Adoption Is a Merge
--
@@ -130,14 +234,6 @@
commit remains in ancestry with attribution intact.
--
-[role="requirement", id="gate.adoption-no-cherry-pick"]
-.Cherry-Pick Forbidden as Adoption
---
-Cherry-picking a contributor's commit MUST NOT be used as an adoption
-mechanism: it creates a new commit object and destroys the original
-author's signature.
---
-
[role="requirement", id="gate.adoption-no-fast-forward"]
.Direct Fast-Forward Forbidden
--
@@ -168,6 +264,25 @@
invariant.
--
+[role="requirement", id="gate.branch-acl-undefined"]
+.Branch-Ref Transport Authorization Is Unspecified (Deferred)
+--
+<<gate.principled-split>> requires `refs/heads/*` to keep transport-level
+authorization instead of the tip invariant, but no document in this spec
+defines that mechanism: what evidence it consults, where its policy lives,
+or how a verdict is derived from it.
+`receive.proposal-shape`'s connection-level ACL input for `refs/heads/*`
+MUST therefore be threaded through uninterpreted — never substituted for
+the tip invariant on a `refs/meta/*` update, which remains fully specified
+— until a future spec addition defines this policy.
+No advisory call site (<<gate.advisory-local>>, <<sync.pre-flight>>) MAY
+render a `refs/heads/*` verdict in the meantime; the illustrative
+verdict-reason examples in this file and `roots.adoc` describe tip-invariant
+predictions against meta-refs only.
+This direction is chosen but not yet designed, the same acknowledged gap
+as <<gate.bootstrap>>.
+--
+
=== Bootstrap
[role="requirement", id="gate.bootstrap"]
@@ -180,3 +295,21 @@
Closing "first push owns the repo" beyond this bootstrap admission is a
hosted-deployment concern, deferred to <<roots.bootstrap>>.
--
+
+[role="requirement", id="gate.epoch-bootstrap"]
+.Epoch Establishment Has No Assigned Trigger (Deferred)
+--
+<<gate.epoch>> defines what the epoch-setting commit to `refs/meta/config`
+must be, but no composition root or porcelain command is specified to
+produce one.
+A Mandatory-mode root (<<gate.mandatory-hosted>>, <<roots.single-node-hosted>>,
+<<roots.hosted>>) that never receives that commit runs with verification
+permanently off: every `refs/meta/*` push stays archival, indistinguishable
+from pre-epoch history, and the "abort before any ref is updated" guarantee
+never actually engages.
+A root establishing its own epoch as part of first boot — before any
+member exists to author a porcelain command — is the likely shape,
+mirroring this section's own self-admitting first enrollment, but this is
+chosen direction, not yet designed or enforced, the same acknowledged gap
+as <<gate.bootstrap>>.
+--
docs/spec/meta-ref.adoc
@@ -21,6 +21,9 @@
synchronization (fetching or pushing the ref moves exactly that entity),
authorization (refname-keyed rules gate who may advance it), and history
(its commit chain is the audit trail).
+Retention pins under `refs/meta/pins/*` (<<model.review-pin>>) are the
+sole exception: a pin's commits anchor other content's reachability and
+carry the empty tree, never an entity.
--
[role="requirement", id="meta-ref.granularity"]
@@ -28,8 +31,8 @@
--
A meta-ref MUST hold exactly one independently-authored entity:
`refs/meta/member/*`, `refs/meta/issues/*`, `refs/meta/comments/*`,
-`refs/meta/effects/*`, and `refs/meta/results/*` each decompose one ref
-per entity.
+`refs/meta/reviews/*`, `refs/meta/effects/*`, and `refs/meta/results/*`
+each decompose one ref per entity.
Repository-global state with a single writer-of-record MUST instead live
on one fixed ref, such as `refs/meta/account` or `refs/meta/config`.
Entities that different actors write concurrently MUST NOT share a ref.
@@ -41,12 +44,43 @@
[role="requirement", id="meta-ref.inbox"]
.Inbox and Self-Run Namespaces
--
-`refs/meta/inbox/*` MUST hold entities authored by someone not authorized
-for the corresponding canonical ref, awaiting adoption.
-`refs/meta/results/~<member>/<effect>/<short-oid>` MUST hold results a
+`refs/meta/inbox/<member>/<canonical-suffix>` MUST hold one entity
+authored by `<member>`, who is not authorized for the corresponding
+canonical ref, awaiting adoption; `<member>` MUST be the leading segment,
+symmetric with `refs/meta/self/<member>/*` below, so authorization
+(<<gate.tip-signed>>) keys off the refname alone, matching the granularity
+rule's one-entity-per-ref shape (<<meta-ref.granularity>>) instead of
+leaving `<member>` unencoded.
+`<canonical-suffix>` MUST be the corresponding canonical ref's entire path
+below `refs/meta/`, not a bare entity id: `refs/meta/issues/42` routes to
+`refs/meta/inbox/<member>/issues/42`, and a multi-segment canonical ref
+such as `refs/meta/results/<effect>/<short-oid>`
+(<<effect.results-writeback>>) routes to
+`refs/meta/inbox/<member>/results/<effect>/<short-oid>` in full — the
+namespace segment MUST be preserved so two different entity kinds can
+never collide under the same inbox id.
+`<member>` MUST be authorized to create and update refs under only its
+own `refs/meta/inbox/<member>/*` segment; no member, including an
+admin-registered one, MAY write into another member's inbox segment.
+Adoption happens by an authorized member merging the inbox entity onto
+the canonical ref (<<gate.adoption-merge>>, <<sync.adoption-machinery>>),
+never by writing the contributor's inbox ref directly, so admin write access
+to another member's segment is never needed.
+An inbox ref MUST NOT be deleted on adoption or at any other time: it
+remains the contributor's own audit trail, exactly as a member entity is
+never deleted on revocation (<<model.member-revocation>>).
+`refs/meta/self/<member>/<effect>/<short-oid>` MUST hold results a
member produced on their own executor rather than a designated worker,
mirroring the canonical results pattern (<<effect.results-writeback>>)
under the member's own namespace.
+`self` MUST be its own top-level namespace under `refs/meta/*`, a fixed
+segment from the spec's own namespace table (<<meta-ref.namespace>>) rather
+than a marker nested inside `refs/meta/results/*`: an effect name and a
+member id are both otherwise-unconstrained ref-path segments
+(<<effect.definition>>), so a marker sharing their position could collide
+with one; a sibling top-level segment cannot, and it keeps the canonical
+results glob (`refs/meta/results/<effect>/*`, <<effect.official>>) and the
+self-run glob (`refs/meta/self/<member>/*`) disjoint by construction.
Both namespaces MUST hold the same typed trees as their canonical
counterparts (<<meta-ref.typed-tree>>); only the refname rule differs.
--
@@ -64,14 +98,59 @@
a version-marker entry.
--
-[role="requirement", id="meta-ref.trailers"]
-.Reserved Commit Trailers
+[role="requirement", id="meta-ref.identity-binding"]
+.The Refname Is a Function of Signed Content
--
-Ref-level metadata that is not entity content MUST live in the mutation
-commit's trailers, never inside the tree (<<meta-ref.typed-tree>>).
-Two trailers are reserved: `Schema-Version:`, for explicit encoding
-detection if it is ever needed, and `Ents-Ref:`, for binding the commit
-to the refname it was authored for.
+A meta-ref's name MUST be a total function of its signed content, and
+the gate MUST recompute that function and refuse a mismatch
+(<<gate.identity-binding>>); no commit-message trailer or other
+side channel participates in the binding, and no reserved trailer
+exists.
+The function, by namespace:
+
+* Singleton state binds by its fixed name: `refs/meta/config`,
+ `refs/meta/account`.
+* A natural-key entity binds by a designated tree field equal to the
+ refname's final segment: a member's id, an effect's name, a
+ toolchain's name (<<model.member-identity>>,
+ <<model.effect-definition>>, <<model.toolchain>>).
+* A hash-identified entity binds by genesis: the refname's final
+ segment MUST equal the oid of the entity's genesis commit, and every
+ parentless commit reachable from the proposed tip MUST be that
+ genesis — comments and issues (<<model.comment>>, <<model.issue>>).
+ The reachability form, not a creation-time-only check, is what makes
+ replaying a signed mutation commit as the genesis of a doppelgänger
+ entity impossible, and it holds across the merge commits divergence
+ resolution creates (<<gate.same-actor-divergence>>).
+* A composite-keyed entity binds by genesis fields and signer: a
+ review's `reviews/<target>/<member>` segments MUST equal its genesis
+ tree's target field and its genesis signer's member id
+ (<<model.review>>); a result's `results/<effect>/<short-oid>`
+ segments MUST derive from its own tree's effect and target fields
+ (<<model.result-identity>>), and a self-run's member segment MUST
+ additionally equal its signer (<<meta-ref.inbox>>).
+* A retention pin binds by mirroring its entity's segments, with
+ parents that include the retained commit (<<model.review-pin>>).
+ A pin's ancestry deliberately reaches into code history, so the
+ parentless-roots walk above MUST NOT be applied to pins.
+* An inbox ref binds by its owner segment equal to the signer
+ (<<meta-ref.inbox>>), with the canonical suffix bound exactly as its
+ canonical namespace binds.
+
+Who authored a state, and when, MUST come from the commit itself — its
+header and its signature — never from duplicated tree fields: each
+datum has exactly one signed home, and a tree field participates in the
+binding only where the namespace's function above names it.
+Within a meta-ref's history, a commit parent means exactly one thing —
+the prior state of the same entity — with the pin's retained-commit
+parents as the sole exception (<<meta-ref.namespace>>); a cross-entity
+relationship MUST be tree data (a reply's parent field, a comment's
+context field), never a parent edge.
+A genesis commit is frozen by the identity derived from it, so the
+struct of a hash-identified or composite-keyed entity MUST evolve
+additively only — new fields optional, required fields never added,
+renamed, or removed; <<meta-ref.migration>> governs the remaining
+namespaces.
--
=== Tip Invariant and Migration
docs/spec/model.adoc
@@ -31,7 +31,9 @@
[role="requirement", id="model.member-identity"]
.Member Identity and Enrollment
--
-A Member entity MUST carry the member's public key.
+A Member entity MUST carry the member's public key, and its member id —
+the final segment of its refname, which binds to this field
+(<<meta-ref.identity-binding>>).
Enrollment MUST occur as a signed commit written to the member's ref: the
member becomes forge state the moment that commit lands, with no user
database separate from the repository.
@@ -42,10 +44,19 @@
--
Revoking a member MUST record a revoked state on the member entity and
MUST NOT delete the entity.
-A revoked key MUST be explicitly rejected for verifying any signature
-made after revocation, while a signature the key made before revocation
-MUST remain verifiable — deleting the entity instead would make old
-signatures unverifiable, the opposite of what an audit needs.
+Admission MUST consult the member entity currently in force — the tip of
+the member's ref in the same snapshot the gate reads (<<gate.tip-signed>>) —
+so a revoked key's new pushes are refused from the moment the revocation
+lands, regardless of any committer timestamp the pushed commit claims.
+A ref accepted before the revocation landed MUST remain valid: acceptance
+is never re-judged.
+The moment of acceptance is witnessed by the deployment's op log, which is
+out of scope for this specification and for every crate in this
+repository; verifying what a key signed while it was valid is an audit
+function over that witness and the retained entity history, never a gate
+admission path.
+The entity MUST be retained rather than deleted precisely so that audit
+stays possible.
Unrevoking a member MUST be supported, returning the key to authorizing
new signatures without altering the record of the period it was revoked.
--
@@ -77,19 +88,71 @@
=== Comment
[role="requirement", id="model.comment"]
-.Comment Carries an Anchor
+.Comment Is About Something
--
-A Comment entity MUST carry a body and an anchor identifying the exact
-content it was written against (<<anchor.definition>>).
-A comment's identity MUST be derived from a hash of its genesis content
-and MUST NEVER change afterward — edits advance the ref, they do not
-rename it.
+A Comment entity MUST carry a body and MUST identify what it is about:
+an anchor into content (<<anchor.definition>>), a context entity
+(<<model.comment-context>>), a parent comment (<<model.comment-thread>>),
+or any combination — a comment about nothing MUST be refused at creation
+by the writing tool, though never by the gate, which stays
+content-agnostic (<<model.extensibility>>).
+A comment's identity MUST be the oid of its genesis commit — git's own
+hash over the genesis tree, author, timestamp, and signature — and MUST
+NEVER change afterward: edits advance the ref, they do not rename it,
+and the refname binds to this genesis (<<meta-ref.identity-binding>>).
Author and timestamp MUST come from the mutation commit chain, never from
-fields stored in the tree (<<meta-ref.trailers>>).
+fields stored in the tree (<<meta-ref.identity-binding>>).
Anchor resolution, projection onto other revisions, and reachability are
specified in `anchor.adoc` and apply to a Comment's anchor unchanged.
--
+[role="requirement", id="model.comment-state"]
+.Comment State
+--
+A Comment entity MUST carry a state; a new comment's state MUST be
+`open`.
+Resolving a comment MUST record state `resolved` as an ordinary mutation
+commit on the comment's own ref — never a deletion, so the conversation
+stays auditable — and reopening MUST be supported the same way.
+Custom states beyond `open` and `resolved` are schema, not platform
+features, exactly as for issues (<<model.issue>>).
+Who changed a state, and when, MUST come from the mutation commit chain
+(<<meta-ref.identity-binding>>), never from stored fields.
+--
+
+[role="requirement", id="model.comment-provenance"]
+.Comment State Provenance
+--
+A mutation that changes a comment's state MUST, when the mutating tool's signing key matches an enrolled member's stored key, carry a `Key-for-<member-id>` trailer in its commit message.
+The trailer's value MUST be the member ref's tip commit oid at mutation time, pinning the resolver's whole enrolled record — key, state, provenance — into the chain, so which member acted, and under which key, stays answerable after any later key rotation or revocation (<<meta-ref.identity-binding>>).
+A signer with no enrolled member writes no trailer; the mutation itself is still recorded.
+The trailer is the writing tool's duty; the gate stays content-agnostic (<<model.comment-state>>).
+--
+
+[role="requirement", id="model.comment-context"]
+.Context Aggregates, Never Contains
+--
+A Comment MAY name a context: the canonical ref path below `refs/meta/`
+of the entity it belongs to, such as `issues/<id>` or `reviews/<id>`.
+An entity's thread MUST be an aggregation query over comment refs
+matching that context; a context entity MUST NOT store a list of its
+comments (<<meta-ref.granularity>> — decomposed refs, aggregated views),
+so two people commenting on the same issue concurrently never race a
+shared ref.
+--
+
+[role="requirement", id="model.comment-thread"]
+.Replies Form Threads
+--
+A Comment MAY name a parent comment by id, making it a reply; the parent
+MUST exist when the reply is created.
+A reply MUST NOT be required to repeat its parent's anchor or context:
+its aboutness is inherited from its thread root for display and
+projection.
+A thread MUST be an aggregation query over comment refs naming ancestors
+in the thread; no comment stores a list of its replies.
+--
+
=== Issue
[role="requirement", id="model.issue"]
@@ -98,6 +161,14 @@
An Issue entity MUST be a typed tree under its own ref in
`refs/meta/issues/*`, one ref per issue (<<meta-ref.granularity>>),
written, gated, synced, and audited exactly like a comment.
+An issue's identity MUST be the oid of its genesis commit, exactly as a
+comment's (<<model.comment>>, <<meta-ref.identity-binding>>) — no
+sequential counter exists, because a counter is a coordination point
+and issues are created offline and concurrently; the CLI's plain
+(human-facing) display and the web UI abbreviate ids the way git
+abbreviates commit oids, but a machine-readable form — the `--porcelain`
+flag included — MUST carry the full id, since that is what `lens.parity`
+requires to be "sufficient for an agent to enumerate and resolve."
An Issue entity MUST carry a title, a body, a state, assignees, and
labels as struct fields; multiple assignees and custom states are
schema, not platform features.
@@ -105,16 +176,73 @@
typed-tree change, not a platform request (<<model.extensibility>>).
--
+=== Review
+
+[role="requirement", id="model.review"]
+.Review Is a Verdict Plus a Context
+--
+A Review entity MUST be a typed tree under its own ref at
+`refs/meta/reviews/<target>/<member>`, where `<target>` is the oid of
+the first commit the review judged and `<member>` is the reviewer's
+member id — a composite natural key (<<meta-ref.identity-binding>>):
+one review thread per (target, reviewer), all reviews of a commit
+enumerable by ref prefix, no minted id anywhere.
+Every review MUST occupy exactly two refs: the entity ref above, and a
+retention pin at `refs/meta/pins/reviews/<target>/<member>` anchoring
+the reviewed content itself (<<model.review-pin>>).
+A Review MUST carry the oid of the most recently reviewed commit as a
+plain data field — at genesis this equals the refname's `<target>`
+segment and binds it; re-reviewing a descendant advances the field
+while the refname stays keyed by genesis — plus a verdict and a body;
+reading the field MUST NOT require the pin ref: the pin anchors, the
+entity describes.
+A review's verdict MUST be one of `approve`, `request-changes`, or
+`comment` — a hard enum, unlike issue and comment states
+(<<model.issue>>, <<model.comment-state>>): a verdict gates decisions,
+so its vocabulary is platform, not schema.
+A review's discussion MUST be Comment entities naming the review as
+their context (<<model.comment-context>>), anchored into the reviewed
+code where they concern specific lines (<<anchor.definition>>); the
+review itself MUST NOT store a list of its comments.
+Reviewer and timestamp MUST come from the mutation commit chain
+(<<meta-ref.identity-binding>>); the reviewer needs no tree field,
+being both a refname segment and the signer the gate checks
+(<<gate.owner-mutation>>).
+--
+
+[role="requirement", id="model.review-pin"]
+.The Review Pin Anchors the Reviewed Content
+--
+A review's pin ref, `refs/meta/pins/reviews/<target>/<member>` — the
+entity's own canonical suffix prefixed the same way <<meta-ref.inbox>>
+prefixes one —
+MUST keep the reviewed content reachable: its tip is a signed commit
+authored by the reviewer whose parents include the reviewed commit, so
+that commit and its ancestry survive force-push, branch deletion, and gc
+for as long as the review exists.
+This is the commit-level counterpart of <<anchor.retention>>, which can
+embed blobs but has no reachability edge for commits — a gitlink is not
+one.
+Re-reviewing after the target moves MUST advance the pin fast-forward
+with a new signed commit whose parents are the previous pin tip and the
+newly reviewed commit, so every reviewed round stays retained and the
+pin's own history is the audit trail of exactly what was reviewed, when.
+A pin commit carries no entity: its tree MUST be the empty tree, the
+sole deliberate exception to <<meta-ref.namespace>>'s
+tree-is-the-entity shape.
+--
+
=== Effect
[role="requirement", id="model.effect-definition"]
.Effect Definition
--
-An Effect entity MUST carry: a trigger, a `CommitQuery` denoting the set
-of commits the effect fires for; the toolchains its run requires; and a
-run command.
+An Effect entity MUST carry: its own name, the final segment of its
+refname, which binds to this field (<<meta-ref.identity-binding>>); a
+trigger, a `CommitQuery` denoting the set of commits the effect fires
+for; the toolchains its run requires; and a run command.
Its results refname is derived from the effect's own name
-(<<effect.results-writeback>>), never stored as a field.
+(<<effect.results-writeback>>), never stored as a separate field.
It MUST NOT carry executor, sandbox, or retry fields; how an effect runs
is a deployment property (<<effect.deployment-property>>).
--
@@ -129,6 +257,23 @@
run semantics, specified in <<effect.result-taxonomy>>.
--
+[role="requirement", id="model.result-identity"]
+.A Result Names What It Judged
+--
+A Result entity MUST carry the effect's name and the full oid of the
+commit the run judged as tree fields, from which its refname's
+`<effect>` and `<short-oid>` segments derive
+(<<meta-ref.identity-binding>>).
+Without these fields the refname is the only thing tying a signed
+status to a run, and a signed `pass` could be replayed as the result
+of any effect on any commit; a result MUST mean something with the
+refname stripped away.
+The fields are not parent edges: a result ref's parents stay prior
+states of the same result (<<meta-ref.identity-binding>>'s
+one-meaning-per-edge rule), and a result MUST NOT retain the judged
+commit's ancestry the way a pin does (<<model.review-pin>>).
+--
+
=== Toolchain
[role="requirement", id="model.toolchain"]
docs/spec/overview.adoc
@@ -67,17 +67,19 @@
Every meta-ref mutation is an author-signed commit whose signature
replicates with the repository and verifies offline in any clone.
-A signature proves authorship, not placement, so an `Ents-Ref:` trailer
-binds the commit to the ref it was authored for, and adopting someone
-else's commit onto a canonical ref is always a merge, never a
+A signature proves authorship, not placement, so the refname is bound by
+recomputation from the commit's own signed content — a genesis oid, a
+natural-key tree field, a composite of fields and signer — and adopting
+someone else's commit onto a canonical ref is always a merge, never a
cherry-pick, so the author's signature survives intact in ancestry.
=== 5. Gate
Verification is a pure function over ref-store reads — is the new tip
-signed by a member authorized for this refname, does its `Ents-Ref:`
-trailer match, does it descend from the old tip, does the update commit
-via atomic CAS — evaluated identically at three call sites: hosted CAS
+signed by a member authorized for this refname, does the refname
+recompute from its signed content, does it descend from the old tip,
+does the update commit via atomic CAS — evaluated identically at three
+call sites: hosted CAS
(mandatory, failure aborts the write), local UI verdict (advisory), and
push pre-flight (advisory).
@@ -154,12 +156,18 @@
| `ents-web`
| The local and hosted web UI.
-| `ents-receive`, `ents-model`, `ents-anchor`, `ents-query`
+| `ents-receive`, `ents-model`, `ents-anchor`, `ents-query`, `ents-forge`,
+`ents-kiln`
+
+| `ents-lens`
+| The editor surface: comments projected over open buffers as a language
+server.
+| `ents-receive`, `ents-model`, `ents-anchor`, `ents-forge`
| `git-ents` (bin)
| Local composition root.
-| `ents-receive`, `ents-effect`, `ents-web`, `ents-anchor`, `ents-sync`,
-`gix-ref-store`
+| `ents-receive`, `ents-effect`, `ents-web`, `ents-lens`, `ents-anchor`,
+`ents-sync`, `gix-ref-store`
| `git-ents-server` (bin)
| Hosted composition root.
docs/spec/query.adoc
@@ -39,10 +39,20 @@
[role="requirement", id="query.rev"]
.rev() Over Code Refs
--
-`rev(expr)` MUST denote the commit set produced by evaluating `expr` as an
-ordinary Git revspec or ref glob against refs outside `refs/meta/*` —
-`refs/heads/*`, `refs/tags/*`, a range such as `main ^release`, an ancestry
-expression, or a merge-base expression.
+`rev(expr)` MUST denote the commit set produced by evaluating `expr`
+against refs outside `refs/meta/*` using the rev-list-shaped subset of
+gitrevisions(7): a refname, short (resolved through the standard
+gitrevisions lookup order) or full, such as `refs/heads/main`; a ref glob
+in full `refs/...` form, such as `refs/heads/*`; a full hex object id; a
+`^`-negated term excluding its ancestry; and `A..B` two-dot sugar for
+`^A B` — a range such as `main ^release`.
+`~n`/`^n` ancestry suffixes, `A...B` merge-base (symmetric-difference)
+expressions, `@{...}` reflog or upstream syntax, and abbreviated hex MUST
+each be rejected as a malformed query (<<effect.validation>>), never
+silently evaluated to the empty set or to the wrong set. Growing this
+subset to cover more of gitrevisions(7) is a compatible, additive
+extension to this requirement; nothing about the query language depends
+on the subset staying this size.
`refs/meta/*` MUST be outside `rev()`'s domain by definition: an `expr`
naming a `refs/meta/*` pattern MUST be rejected as a malformed query
(<<effect.validation>>), never silently evaluated to the empty set.
@@ -58,6 +68,15 @@
refname encodes the tested commit's oid (<<effect.results-writeback>>),
resolution MUST be a scan of refname patterns under the effect's results
namespace, never a walk of commit history.
+A commit's membership in `results(effect, status)` MUST be decided solely
+by whether a matching results ref exists, never by the commit's
+reachability from `refs/heads/*` or any other ref outside the query's own
+footprint (<<query.footprint>>); a transition on such an outside ref —
+including deleting and recreating it at that same commit — is a non-event
+for this atom's entry set.
+A recorded result is already the computed answer for that commit, which is
+why its results ref, not the commit's standing in unrelated ref history, is
+what membership tracks.
--
[role="requirement", id="query.meta"]
docs/spec/receive.adoc
@@ -15,6 +15,15 @@
Gate evaluation (<<gate.tip-signed>> through <<gate.atomic-cas>>), effect
matching, and enqueue MUST live inside `receive`, above its trait
parameters, never duplicated in a caller.
+This requirement governs origination — proposing new state to a store.
+Replicating refs a trusted remote has already admitted through its own
+`receive` (<<sync.forge-transfer>>) MAY apply them directly: the source's
+gate already judged that state, so re-verification on fetch is an opt-in
+audit rather than an obligation, and the work set stays reconstructible
+by scan regardless (<<receive.reconstructible>>).
+Commits the replicating machinery authors itself — divergence and
+adoption merges (<<gate.same-actor-divergence>>, <<gate.adoption-merge>>)
+— are origination, not replication.
--
[role="requirement", id="receive.proposal-shape"]
@@ -32,6 +41,29 @@
`refs/heads/*` (<<gate.principled-split>>) only; gate evaluation for
meta-ref admission MUST ignore it entirely and MUST NOT consult it in
place of the tip invariant (<<gate.signature-artifact>>).
+No policy for that `refs/heads/*` ACL input is specified yet
+(<<gate.branch-acl-undefined>>); `receive` MUST thread the evidence
+through uninterpreted until one is.
+--
+
+[role="requirement", id="receive.multi-ref-atomicity"]
+.An Entity Declared Across Multiple Refs Writes Them as One Proposal
+--
+Some entities are declared to require more than one ref at once as part
+of their canonical shape — a review and its retention pin are the
+motivating case: two refs that MUST both exist for the review to exist
+at all.
+Creating or updating such an entity MUST carry every one of its refs'
+transitions in a single `Proposal` (<<receive.proposal-shape>>) through
+one call to `receive`, never as two or more independent proposals.
+The ref-store's atomic multi-ref compare-and-swap
+(<<arch.refstore-read-cas-split>>) then admits or refuses the whole
+batch together, so such an entity is never observable with only some of
+its refs written.
+This is distinct from the inbox/canonical relationship
+(<<meta-ref.inbox>>), where a contributor's ref and the canonical ref
+are deliberately written at different times by different actors, not as
+one unit.
--
[role="requirement", id="receive.shared-path"]
@@ -132,8 +164,15 @@
`receive` MUST check every incoming object against the redaction list
recorded under `refs/meta/redactions/*` at ingest time, so a redacted hole
cannot be silently refilled by re-pushing the same bytes.
+This binds new admission only: for an object redacted after it was already
+accepted, `receive` refusing future pushes of the same bytes does not by
+itself withhold the bytes already sitting in the object store.
A redacted object's bytes MUST be withheld from the object store and from
every generated pack; the oid MUST remain in history as evidence.
+Guaranteeing this for bytes already stored before their redaction is not
+`receive`'s job — it belongs to whichever component actually generates
+outgoing packs, which is not yet specified for every root
+(<<roots.redaction-pack-serving>>).
A reader resolving a redacted object MUST receive a redaction marker,
never an error.
--
docs/spec/roots.adoc
@@ -31,6 +31,27 @@
(<<effect.local-run>>).
--
+[role="requirement", id="roots.single-node-hosted"]
+.git-ents Single-Node Hosted Root
+--
+The `git-ents` binary's second composition root (the CLI-complete
+milestone deployed at `git.ents.cloud`) MUST wire: a loose-ref `RefStore`,
+a real on-disk object database, an in-memory `EventSink` scoped to one
+hook invocation and reconciled at boot (<<receive.reconstructible>>), a
+Fly.io Sprite `Executor`, and the mandatory gate (<<gate.mandatory-hosted>>).
+Unlike <<roots.local>>, git's own `receive-pack` is the transport and
+performs the actual object unpack and ref update; this root's
+`pre-receive`/`post-receive` hooks call the gate and reconcile obligations
+around that write, never through this crate's own `RefStore::transaction`,
+to avoid a double-write race against `receive-pack`'s internal ref update.
+This is not <<roots.hosted>>: that root replaces the `RefStore` and object
+store themselves with Postgres and Tigris once scale forces `receive-pack`
+out entirely (<<roots.honesty-test>>); this root keeps a real on-disk
+repository and stock git as the one hosted and local serving transport
+until that phase — "stock git wearing the same gate everything else runs,
+not a bespoke protocol."
+--
+
[role="requirement", id="roots.hosted"]
.git-ents-server Hosted Root
--
@@ -49,8 +70,9 @@
`EventSink` (<<roots.hosted>>) as a new composition root MUST require
zero modification to any library crate.
This is the project's honesty test for the seam design: the proof is an
-actual production migration off the single-node hosted root, not a
-synthetic root built to demonstrate the seams.
+actual production migration off the single-node hosted root
+(<<roots.single-node-hosted>>), not a synthetic root built to demonstrate
+the seams.
--
[role="requirement", id="roots.config-isolation"]
@@ -92,6 +114,11 @@
gate, evaluated against policy as of Joey's last fetch
(<<gate.policy-as-state>>), rendered by the same in-process signing
this section specifies — never a separate git-serving transport.
+The `refs/heads/main` case is aspirational as stated: it is a
+transport-level check, not the tip invariant (<<gate.principled-split>>),
+and this spec does not yet define that mechanism
+(<<gate.branch-acl-undefined>>), so the local UI has no policy to predict
+against for it today.
====
[role="requirement", id="roots.worktree-update"]
@@ -149,6 +176,27 @@
repository creation; this direction is chosen but not yet enforced.
--
+[role="requirement", id="roots.redaction-pack-serving"]
+.Outgoing Pack Serving Ignores Redaction (Deferred)
+--
+Every composition root through the single-node hosted root
+(<<roots.local>>, <<roots.hosted>>) serves fetches and clones via stock
+git's own `upload-pack` acting directly on a real on-disk object
+database, which has no knowledge of `refs/meta/redactions/*` and will
+serve a redacted object's bytes to any fetch if they are already
+reachable in that odb.
+`receive`'s ingest-time check (<<receive.redaction-ingest>>) only
+refuses a redacted hole being refilled by a new push; it cannot evict
+bytes already stored before the redaction record existed, nor bytes a
+fetch already served.
+`gix-receive` (<<roots.honesty-test>>) replaces only incoming pack
+ingestion at the scale-out root; nothing in this design wraps outgoing
+pack generation for any root.
+Making pack generation redaction-aware, or otherwise physically evicting
+redacted bytes from an on-disk repository, is chosen direction but not
+yet designed.
+--
+
[role="requirement", id="roots.embeddable"]
.Embeddable Server
--
docs/spec/sync.adoc
@@ -32,8 +32,9 @@
Any negative advisory verdict against a canonical meta-ref — the local UI
verdict at commit time (<<gate.advisory-local>>), push pre-flight
(<<sync.pre-flight>>), or the canonical store's actual rejection — MUST
-cause sync to offer routing the same commit to the author's
-`refs/meta/inbox/*` ref instead of discarding it.
+cause sync to offer routing the same commit to a new ref under the
+author's own `refs/meta/inbox/<member>/*` segment (<<meta-ref.inbox>>)
+instead of discarding it.
The offer MUST appear the moment the verdict goes negative, not only
after a push is actually attempted and refused.
--
@@ -55,6 +56,23 @@
<<gate.adoption-merge>>, not a separate adoption code path.
--
+[role="requirement", id="sync.adoption-no-cherry-pick"]
+.Cherry-Pick Forbidden as Adoption
+--
+The adoption machinery (<<sync.adoption-machinery>>) MUST NOT cherry-pick a
+contributor's commit as a substitute for merging it: cherry-picking
+creates a new commit object, placed and signed by the adopting member,
+and destroys the original author's signature.
+This binds the tooling, not the gate: a cherry-picked commit, signed by
+the authorized member placing it, satisfies the tip invariant
+(<<gate.tip-signed>> through <<gate.atomic-cas>>) exactly as a hand-authored
+commit would, so nothing in the resulting ref-store state lets a pure
+verifier tell the two apart after the fact (gate.sdoc's Adoption
+section). Preserving attribution is therefore a property this
+requirement demands of the merge machinery itself, not one the gate can
+check.
+--
+
[role="requirement", id="sync.local-advisory"]
.Local Store Accepts Regardless of Verdict
--
.claude/skills/ents-comments/SKILL.md
@@ -1,0 +1,151 @@
+---
+name: ents-comments
+description: Read, address, and leave git-ents comments — the universal conversational primitive anchored into source. Use when asked to "address the comments", "address all comments in the working tree", act on review feedback, or leave a comment/reply on code. Editor-agnostic: the same `git ents comment` CLI an editor, the web UI, and you all drive.
+---
+
+# Addressing and leaving ents comments
+
+A git-ents comment is a body anchored to exact content — a blob, an optional line range, a commit (or the working tree).
+Comments live on `refs/meta/comments/*`, one ref each, and carry a `state` (`open` or `resolved`).
+Editors, the web UI, and the CLI are three frontends of one mechanism, so a comment left in Zed is the same entity you resolve here.
+
+A comment's (and an issue's) `<id>` is the oid of its own genesis commit — never minted, always derivable from the signed content.
+The default (non-`--porcelain`) listing abbreviates it for display exactly as git abbreviates a commit oid; `--porcelain` and refnames always carry the full oid.
+Every `<id>` argument below needs the **full** id — copy it from `--porcelain` output (or the id an `add`/`new`/`reply` command just printed), not the abbreviated form a plain listing shows.
+
+Everything below is `git ents comment …`.
+Run it from inside the repo.
+There is no MCP server and no daemon — just the CLI.
+
+## The loop: "address all comments in the working tree"
+
+This is the primary workflow.
+A human (or an agent) leaves open comments; you fix the code they point at and resolve them.
+
+1. **Enumerate open comments, projected onto the working tree.**
+ Use the porcelain form — it is stable and designed for you to parse:
+
+ ```text
+ git ents comment list --worktree --open --porcelain
+ ```
+
+ Records are blank-line-separated.
+ Each starts with:
+
+ ```text
+ <id> <state> <projection> <location>
+ ```
+
+ - `<projection>` is `current`, `relocated`, `outdated`, `deleted`, or
+
+ `-` (the comment has no anchor).
+ It is the anchor projected onto the **working tree's current bytes**, so `current` means the comment still points at the exact code you are looking at.
+
+ - `<location>` is `path:start-end`, or `path` for a whole-file anchor,
+
+ or `-` when there is no anchor or the file is gone.
+
+ - Optional `context <c>` and `parent <id>` lines follow, then the body
+
+ with **every body line prefixed by one tab**.
+
+2. **Address each open comment.**
+ Read the location, make the change the comment asks for.
+ Treat `relocated` as authoritative about where the code moved; treat `outdated`/`deleted` as a signal the comment may no longer apply — read the body and decide, don't blindly resolve.
+
+3. **Reply if there's something to say**, then **resolve**:
+
+ ```text
+ git ents comment reply <id> --body "Done — extracted the helper as suggested."
+ git ents comment resolve <id>
+ ```
+
+ Resolving is an ordinary signed mutation on the comment's ref, never a deletion — the thread stays auditable.
+ Reopen with `git ents comment reopen <id>` if you resolved too early.
+
+Resolve a comment only once the code actually satisfies it.
+If you can't address one, leave a reply explaining why and leave it `open`.
+
+## Reading one thread
+
+```text
+git ents comment show <id> # projected onto HEAD
+git ents comment show <id> --worktree # projected onto the working tree
+```
+
+Shows state, context, parent, the anchored snippet, and body.
+To read a whole conversation on an entity (an issue or review), filter by context:
+
+```text
+git ents comment list --context issues/42 --porcelain
+```
+
+## Leaving a comment
+
+A comment must be *about* something: an anchor, a context, a parent, or any combination.
+A comment about nothing is refused.
+
+```text
+# Anchor to lines of a file at HEAD:
+git ents comment add src/gate.rs --lines 40:52 --body "This branch never runs when epoch is unset."
+
+# Anchor to uncommitted work — the exact bytes on disk right now:
+git ents comment add src/gate.rs --lines 40:52 --worktree --body "..."
+
+# Whole-file comment (omit --lines); comment on a specific revision with --rev.
+# Reply (inherits the parent's aboutness, no anchor needed):
+git ents comment reply <id> --body "..."
+```
+
+Comments are signed with `user.signingkey` by default; `--key <path>` overrides.
+Anchoring `--worktree` captures the current on-disk bytes, so a remark about uncommitted code stays pinned to exactly what you read even after it's committed or amended.
+
+## Issues and reviews are made of comments
+
+Issues and reviews are their own entities, but their *discussion* is ordinary comments carrying the entity as a `context` — so the same loop above works on them.
+A context is the entity's ref path below `refs/meta/`, e.g. `issues/42` or `reviews/<target>/<member>`.
+
+### Issues
+
+```text
+git ents issue list
+git ents issue show <id>
+git ents issue new --title "Gate rejects a valid signature" --body "..."
+git ents issue new # omit --title to compose in $GIT_EDITOR/$EDITOR
+git ents issue edit <id> --state closed # also --label, --assignee
+git ents comment add --context issues/42 --body "I can repro this."
+git ents comment list --context issues/42 --porcelain # the issue's thread
+```
+
+`issue new` with no `--title` opens an editor on a scratch file: first line is the title, the rest is the body, `#` lines are stripped, an empty title aborts.
+
+### Reviews
+
+A review is a verdict plus a body about a commit, keyed by the composite pair `(target, member)` — no minted id anywhere.
+It occupies **two refs**: the entity at `refs/meta/reviews/<target>/<member>` and a retention pin at `refs/meta/pins/reviews/<target>/<member>` that keeps the reviewed commit reachable.
+`review new` writes both, keyed to the caller's own member id (the enrolled username matching the signing key, or a fingerprint-derived placeholder if unenrolled).
+
+Reviewing a commit an ancestor of which you already reviewed is a **re-review**: it advances the same two refs fast-forward (the composite key stays anchored at the original target) rather than opening a second, unrelated review — so re-reviewing after a branch moves forward is just `review new` again with the new target.
+
+```text
+git ents review new --target HEAD --verdict approve --body "LGTM."
+git ents review new --target <rev> --verdict request-changes --body "..."
+git ents review list [--target <rev>]
+git ents review show <target> <member> # verdict, body, and its comment thread
+# Line-level review notes are just anchored comments in the review's context:
+git ents comment add src/gate.rs --lines 40:52 --context reviews/<target>/<member> --body "..."
+```
+
+Verdicts are free-form strings; `approve` and `request-changes` are conventions, not a fixed set.
+
+## Notes
+
+- Prefer `--porcelain` for anything you parse; the default output is for
+ humans and may change.
+- `--worktree` is what makes this an *iteration* loop: it projects and
+ anchors against your live edits, not just committed history.
+- Author and time come from each ref's commit chain, never from stored
+ fields — don't expect an author field in the entity.
+- One mechanism everywhere: issues, reviews, and line comments are all
+ comments-on-a-context or their own small entities on `refs/meta/*`,
+ the same ones the editor lens and the web UI read and write.
.claude/skills/ents-zed/SKILL.md
@@ -1,0 +1,44 @@
+---
+name: ents-zed
+description: Use git-ents comments inside the Zed editor via the ents-zed extension and the ents-lens language server. Use when working in Zed and asked to see, leave, or resolve inline comments, or to set up the editor for the ents comment loop.
+---
+
+# ents in Zed
+
+The `ents-zed` extension (at `editors/zed/`) registers a language server, `ents-lsp`, that runs `git ents lsp`.
+The server projects the repo's comments (`refs/meta/comments/*`) into whatever buffer you have open and lets you leave new ones — no MCP, no network, just an LSP over stdio reusing the local composition root.
+
+The language server is the same mechanism the CLI and web UI use (`lens.parity`), so a comment left in Zed is readable and resolvable from `git ents comment` and the web, and vice versa.
+If you are acting on comments programmatically rather than through the editor, use the `ents-comments` skill (the CLI) instead — it is the same entities.
+
+## Setup
+
+1. `git ents lsp` must be on `PATH` (build/install the `git-ents` binary).
+ The extension launches `git` with `ents lsp` and speaks LSP over stdio.
+2. Install the extension as a dev extension: Zed → command palette →
+ `zed: install dev extension` → pick `editors/zed`.
+3. **Turn on code lenses.**
+ Zed renders LSP code lenses but they are **off by default**.
+ Enable them, or the inline comment lenses won't show:
+ - setting: `"code_lens": "on"`, or
+ - command palette: `editor: toggle code lens`.
+
+## What renders
+
+- **Code lenses** at each open comment's projected line (once enabled):
+ the id, a summary, and View / Reply / Resolve actions.
+- **Hint diagnostics** for the same comments — these show inline **even without code lenses enabled**, which is why the server publishes them (`lens.diagnostics`).
+ They are hints, never warnings or errors.
+- **Hover** over a commented range shows the full thread.
+- **Code action** on a selection ("leave an ents comment") to compose a
+ new comment; see the crate's compose flow for how saving creates it.
+
+## Composing a comment
+
+Trigger the code action on the lines you want to anchor to.
+It opens a template file (git-commit style: first content is the body, `#` lines are ignored, an empty body aborts).
+Saving a non-empty body creates the comment, anchored to the working-tree bytes you selected.
+
+Because everything is the one mechanism, the iteration loop is: leave comments in Zed, then tell an agent to "address all comments in the working tree" (the `ents-comments` skill) — it reads the same open comments, fixes the code, replies, and resolves them, and your next Zed publish reflects the resolutions.
+
+See `editors/zed/README.adoc` for the authoritative, version-tracked notes on exactly which surfaces the current Zed renders.
Cargo.lock
@@ -1,0 +1,5172 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "acdc-converters-core"
+version = "0.1.0"
+source = "git+https://github.com/nlopes/acdc?rev=6ae19bc2e6f0fa4254a3e9ebd1c3d2f6c7caafde#6ae19bc2e6f0fa4254a3e9ebd1c3d2f6c7caafde"
+dependencies = [
+ "acdc-parser",
+ "bitflags 2.13.0",
+ "thiserror 2.0.18",
+ "tracing",
+]
+
+[[package]]
+name = "acdc-converters-html"
+version = "0.1.0"
+source = "git+https://github.com/nlopes/acdc?rev=6ae19bc2e6f0fa4254a3e9ebd1c3d2f6c7caafde#6ae19bc2e6f0fa4254a3e9ebd1c3d2f6c7caafde"
+dependencies = [
+ "acdc-converters-core",
+ "acdc-converters-terminal",
+ "acdc-parser",
+ "base64",
+ "chrono",
+ "sha2 0.11.0",
+ "thiserror 2.0.18",
+ "tracing",
+]
+
+[[package]]
+name = "acdc-converters-terminal"
+version = "0.1.0"
+source = "git+https://github.com/nlopes/acdc?rev=6ae19bc2e6f0fa4254a3e9ebd1c3d2f6c7caafde#6ae19bc2e6f0fa4254a3e9ebd1c3d2f6c7caafde"
+dependencies = [
+ "acdc-converters-core",
+ "acdc-parser",
+ "asciicast-rs",
+ "comfy-table",
+ "crossterm",
+ "libghostty-vt",
+ "serde",
+ "serde_json",
+ "syntect",
+ "thiserror 2.0.18",
+ "tracing",
+ "unicode-width",
+]
+
+[[package]]
+name = "acdc-parser"
+version = "0.9.0"
+source = "git+https://github.com/nlopes/acdc?rev=6ae19bc2e6f0fa4254a3e9ebd1c3d2f6c7caafde#6ae19bc2e6f0fa4254a3e9ebd1c3d2f6c7caafde"
+dependencies = [
+ "bitflags 2.13.0",
+ "bumpalo",
+ "csv",
+ "encoding_rs",
+ "evalexpr",
+ "peg",
+ "rustc-hash",
+ "self_cell",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "ansi-str"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "060de1453b69f46304b28274f382132f4e72c55637cf362920926a70d090890d"
+dependencies = [
+ "ansitok",
+]
+
+[[package]]
+name = "ansitok"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0a8acea8c2f1c60f0a92a8cd26bf96ca97db56f10bbcab238bbe0cceba659ee"
+dependencies = [
+ "nom",
+ "vte",
+]
+
+[[package]]
+name = "arborium"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abb7a99c146e42da48d5783335814d9bd0f088143aa51fb5ea5b59855df5ca70"
+dependencies = [
+ "arborium-asciidoc",
+ "arborium-bash",
+ "arborium-c",
+ "arborium-cpp",
+ "arborium-css",
+ "arborium-go",
+ "arborium-highlight",
+ "arborium-html",
+ "arborium-javascript",
+ "arborium-json",
+ "arborium-markdown",
+ "arborium-python",
+ "arborium-rust",
+ "arborium-theme",
+ "arborium-toml",
+ "arborium-tree-sitter",
+ "arborium-typescript",
+ "arborium-yaml",
+ "dlmalloc",
+]
+
+[[package]]
+name = "arborium-asciidoc"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "58270e81ade15dc1f1397c2acb827bdaf9de4a3adb94a776a0b7d0d5ce57cfc7"
+dependencies = [
+ "arborium-sysroot",
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "arborium-bash"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5aa5ef1fa9c298d071d0817b068880a22c86f11202508215bdfddc3386aae643"
+dependencies = [
+ "arborium-sysroot",
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "arborium-c"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d44b533ac7c14da8a26dfb8f7b4bfa29da969a7f01015d63a3ed4097af14c9d"
+dependencies = [
+ "arborium-sysroot",
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "arborium-cpp"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f307d299c2089784e11c6a93374b80c0d5f42c46bdc9f10bcd02163a1fd30bb"
+dependencies = [
+ "arborium-c",
+ "arborium-sysroot",
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "arborium-css"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "040085bd355e439915eaeca9ed9db316a8dc3795f36c23948f216a808192376f"
+dependencies = [
+ "arborium-sysroot",
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "arborium-go"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab9a45c7deab55b1dfed13074fe9cbc0bfa8d4a40e4bc509f4295291e53efdec"
+dependencies = [
+ "arborium-sysroot",
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "arborium-highlight"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65635c663883cb887d52abce06650f81e4abeb7bfd8e79a555f6a0d8e8f0a31b"
+dependencies = [
+ "arborium-theme",
+ "arborium-tree-sitter",
+ "streaming-iterator",
+]
+
+[[package]]
+name = "arborium-html"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b109985d29a842c1fe328b567ac48f8d2211bce778dfe12da6297dd58adac2a"
+dependencies = [
+ "arborium-css",
+ "arborium-javascript",
+ "arborium-sysroot",
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "arborium-javascript"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "424c648bf3075e137a5620318a7f865cf320c5f43163d3094764d4cc050e7cc0"
+dependencies = [
+ "arborium-jsdoc",
+ "arborium-sysroot",
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "arborium-jsdoc"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f06a77e65f79ded95575bbc9b48a3f213e82ba1a23e5bc64ee5df77f14166cf4"
+dependencies = [
+ "arborium-sysroot",
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "arborium-json"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bcbd847461cd81d50cd34454870359bc73ac1b96512eb8a33943fe54cba82d9f"
+dependencies = [
+ "arborium-sysroot",
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "arborium-markdown"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5bd7c263aeb25a87a0b9eb0d90d2f559eb4e7ec4bce2a305271893902201a6e1"
+dependencies = [
+ "arborium-html",
+ "arborium-sysroot",
+ "arborium-toml",
+ "arborium-yaml",
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "arborium-python"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ade74ad0c312807b2f4c439c7e416c6de41f5a6a638cdea1d5e93470d87ba91"
+dependencies = [
+ "arborium-sysroot",
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "arborium-rust"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a291f443c124244ff7dea2248a1f2bf60a3b4d6ab0585784ea680dc33fd7cd47"
+dependencies = [
+ "arborium-sysroot",
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "arborium-sysroot"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59d99d80550b726f9dec7ee6d07118c31e08b10e729ac488eabd4c10603dc841"
+dependencies = [
+ "cc",
+ "dlmalloc",
+]
+
+[[package]]
+name = "arborium-theme"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00a8f02b994f454d9703dd6a8b6075ba778320550d699198faff1882594c0f7"
+
+[[package]]
+name = "arborium-toml"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "102a80466c8afd64c0c05c57cae30e5ff5ddbf3f943065fdd8fbe64331b49952"
+dependencies = [
+ "arborium-sysroot",
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "arborium-tree-sitter"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b27f3bd6bb7192e4a19dc177748532b305e9052839b34e55705f53e5eea2d5ca"
+dependencies = [
+ "arborium-sysroot",
+ "cc",
+ "regex",
+ "regex-syntax",
+ "streaming-iterator",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "arborium-typescript"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72a226449c2081a153662af88ac5db08d6ee2654ac9022bdc1bacb8a2769053d"
+dependencies = [
+ "arborium-javascript",
+ "arborium-sysroot",
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "arborium-yaml"
+version = "2.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83a224e870912490905c2cb21fa1d0b055ae9f88a47329f5849662cb9fe656f"
+dependencies = [
+ "arborium-sysroot",
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "arc-swap"
+version = "1.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "ariadne"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8454c8a44ce2cb9cc7e7fae67fc6128465b343b92c6631e94beca3c8d1524ea5"
+dependencies = [
+ "unicode-width",
+ "yansi",
+]
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
+[[package]]
+name = "asciicast-rs"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0ba000776cf478263576bca61217ca5557749c55a8a2d2daf973047188be03"
+dependencies = [
+ "rgb",
+ "ruzstd",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
+[[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 = "block-buffer"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "bstr"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79"
+dependencies = [
+ "memchr",
+ "regex-automata",
+ "serde_core",
+]
+
+[[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.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "bytesize"
+version = "2.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d7c8918969267b2932ffd5655509bbbea0833823058c378876953217f5fc50e"
+
+[[package]]
+name = "camino"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.67"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common 0.1.7",
+ "inout",
+]
+
+[[package]]
+name = "clru"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5"
+dependencies = [
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "comfy-table"
+version = "7.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
+dependencies = [
+ "ansi-str",
+ "console",
+ "crossterm",
+ "unicode-segmentation",
+ "unicode-width",
+]
+
+[[package]]
+name = "console"
+version = "0.16.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
+dependencies = [
+ "encode_unicode",
+ "libc",
+ "unicode-width",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "const-fnv1a-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca"
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "const-oid"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+
+[[package]]
+name = "convert_case"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "coolor"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3"
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+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 = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+
+[[package]]
+name = "crossterm"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
+dependencies = [
+ "bitflags 2.13.0",
+ "crossterm_winapi",
+ "derive_more",
+ "document-features",
+ "mio",
+ "parking_lot",
+ "rustix",
+ "signal-hook 0.3.18",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm_winapi"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "subtle",
+ "zeroize",
+]
+
+[[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 = "crypto-common"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "csv"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
+dependencies = [
+ "csv-core",
+ "itoa",
+ "ryu",
+ "serde_core",
+]
+
+[[package]]
+name = "csv-core"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "curve25519-dalek-derive",
+ "digest 0.10.7",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "dashmap"
+version = "6.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags 1.3.2",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+dependencies = [
+ "defmt-parser",
+ "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 2.0.18",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid 0.9.6",
+ "zeroize",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[package]]
+name = "derive_more"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+dependencies = [
+ "convert_case",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer 0.10.4",
+ "const-oid 0.9.6",
+ "crypto-common 0.1.7",
+ "subtle",
+]
+
+[[package]]
+name = "digest"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
+dependencies = [
+ "block-buffer 0.12.1",
+ "const-oid 0.10.2",
+ "crypto-common 0.2.2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "dlmalloc"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad5208a115eaba24916f7456929832e310a81518c641f93fee4f89aa93aa3675"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der",
+ "digest 0.10.7",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "sha2 0.10.9",
+ "subtle",
+]
+
+[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "digest 0.10.7",
+ "ff",
+ "generic-array",
+ "group",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "ents-anchor"
+version = "0.0.0"
+dependencies = [
+ "facet",
+ "facet-git-tree",
+ "gix",
+ "proptest",
+ "rstest",
+ "tempfile",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "ents-effect"
+version = "0.0.0"
+dependencies = [
+ "ents-gate",
+ "ents-model",
+ "ents-query",
+ "ents-receive",
+ "ents-testutil",
+ "facet",
+ "facet-git-tree",
+ "gix",
+ "gix-hash",
+ "gix-object",
+ "gix-ref-store",
+ "rstest",
+ "tempfile",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "ents-forge"
+version = "0.0.0"
+dependencies = [
+ "ents-anchor",
+ "ents-model",
+ "ents-receive",
+ "ents-testutil",
+ "facet",
+ "facet-git-tree",
+ "figue",
+ "gix",
+ "gix-hash",
+ "gix-object",
+ "gix-ref-store",
+ "proptest",
+ "rstest",
+ "tempfile",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "ents-gate"
+version = "0.0.0"
+dependencies = [
+ "ents-model",
+ "ents-testutil",
+ "facet",
+ "facet-git-tree",
+ "gix",
+ "gix-hash",
+ "gix-object",
+ "gix-ref-store",
+ "rstest",
+ "ssh-key",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "ents-kiln"
+version = "0.0.0"
+dependencies = [
+ "ents-effect",
+ "ents-model",
+ "ents-receive",
+ "ents-testutil",
+ "facet",
+ "facet-git-tree",
+ "figue",
+ "gix",
+ "gix-hash",
+ "gix-object",
+ "gix-ref-store",
+ "rstest",
+ "tempfile",
+]
+
+[[package]]
+name = "ents-lens"
+version = "0.0.0"
+dependencies = [
+ "ents-anchor",
+ "ents-forge",
+ "ents-model",
+ "ents-receive",
+ "ents-testutil",
+ "facet-git-tree",
+ "gix",
+ "gix-hash",
+ "gix-object",
+ "gix-ref-store",
+ "lsp-server",
+ "lsp-types",
+ "rstest",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "ents-model"
+version = "0.0.0"
+dependencies = [
+ "facet",
+ "facet-git-tree",
+ "gix",
+ "gix-hash",
+ "gix-object",
+ "proptest",
+ "rstest",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "ents-query"
+version = "0.0.0"
+dependencies = [
+ "ents-model",
+ "ents-testutil",
+ "facet-git-tree",
+ "gix",
+ "gix-hash",
+ "gix-object",
+ "gix-ref-store",
+ "proptest",
+ "rstest",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "ents-receive"
+version = "0.0.0"
+dependencies = [
+ "ents-gate",
+ "ents-model",
+ "ents-query",
+ "ents-testutil",
+ "facet",
+ "facet-git-tree",
+ "gix",
+ "gix-hash",
+ "gix-object",
+ "gix-ref-store",
+ "rstest",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "ents-sync"
+version = "0.0.0"
+dependencies = [
+ "ents-gate",
+ "ents-model",
+ "ents-testutil",
+ "facet",
+ "facet-git-tree",
+ "gix",
+ "gix-hash",
+ "gix-object",
+ "gix-ref-store",
+ "proptest",
+ "rstest",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "ents-testutil"
+version = "0.0.0"
+dependencies = [
+ "ents-model",
+ "facet",
+ "facet-git-tree",
+ "gix",
+ "gix-hash",
+ "gix-object",
+ "gix-ref-store",
+ "rstest",
+ "ssh-key",
+]
+
+[[package]]
+name = "ents-web"
+version = "0.0.0"
+dependencies = [
+ "acdc-converters-core",
+ "acdc-converters-html",
+ "acdc-parser",
+ "arborium",
+ "axum",
+ "ents-anchor",
+ "ents-effect",
+ "ents-forge",
+ "ents-gate",
+ "ents-kiln",
+ "ents-model",
+ "ents-query",
+ "ents-receive",
+ "ents-testutil",
+ "facet",
+ "facet-git-tree",
+ "facet-reflect",
+ "getrandom 0.4.3",
+ "gix",
+ "gix-hash",
+ "gix-object",
+ "gix-ref-store",
+ "http-body-util",
+ "maud",
+ "pulldown-cmark",
+ "rstest",
+ "serde",
+ "tempfile",
+ "thiserror 2.0.18",
+ "tokio",
+ "tower",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "evalexpr"
+version = "13.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25929004897f2bbab309121a60400d36992f6d911d09baa6c172f6cc55706601"
+
+[[package]]
+name = "facet"
+version = "0.50.0-rc.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd3e58a40571ddd336865ec9643ea43d5cf93c35ae4f5ad5d903dc4e427a2e03"
+dependencies = [
+ "autocfg",
+ "facet-core",
+ "facet-macros",
+ "facet-reflect",
+]
+
+[[package]]
+name = "facet-core"
+version = "0.50.0-rc.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2c2ad271d54e76358f179bc5f75483c4ed025d1009a8599f299ac7bee99dbdd"
+dependencies = [
+ "autocfg",
+ "camino",
+ "const-fnv1a-hash",
+ "iddqd",
+ "impls",
+ "indexmap",
+]
+
+[[package]]
+name = "facet-dessert"
+version = "0.50.0-rc.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39aaece2ca1e5aad3e2bee0f4fea23ae0a11eb5a0b88bb265ec09c39d580aac5"
+dependencies = [
+ "facet-core",
+ "facet-reflect",
+]
+
+[[package]]
+name = "facet-error"
+version = "0.50.0-rc.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175f8aab2cbd354a829214f7f033c5dff59c60b66848fc833116a4dabcc7f7c6"
+dependencies = [
+ "facet",
+]
+
+[[package]]
+name = "facet-format"
+version = "0.50.0-rc.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64c612924dc3d45853f81e9cfafc513f3ec0be5982f943a7945f49f2492effb1"
+dependencies = [
+ "facet-core",
+ "facet-dessert",
+ "facet-path",
+ "facet-reflect",
+ "facet-solver",
+]
+
+[[package]]
+name = "facet-git-tree"
+version = "0.1.0"
+source = "git+https://github.com/git-ents/facet-git-tree#ec7f4a18ff822d148ab9c4c5360564d38f8a4813"
+dependencies = [
+ "facet",
+ "gix-hash",
+ "gix-object",
+ "gix-odb",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "facet-json"
+version = "0.50.0-rc.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82902c758e29af6599f852dd783206697fd33383fa499b07bdcddbd8eb1ef4d0"
+dependencies = [
+ "facet",
+ "facet-core",
+ "facet-format",
+ "facet-reflect",
+ "weavy",
+]
+
+[[package]]
+name = "facet-macro-parse"
+version = "0.50.0-rc.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77a43cd88c3df76b7606194a059973aea078940de8cc442dca7c327f51bbc014"
+dependencies = [
+ "facet-macro-types",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "facet-macro-types"
+version = "0.50.0-rc.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dce57ed093067e4a3a1ab42c8c84eec1a25af7726d43a22e390734b29b947ec"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unsynn",
+]
+
+[[package]]
+name = "facet-macros"
+version = "0.50.0-rc.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ae8b8eb7169a41245f31bda2912a3c7d33e0efa7269c0c8847b54377e31ff68"
+dependencies = [
+ "facet-macros-impl",
+]
+
+[[package]]
+name = "facet-macros-impl"
+version = "0.50.0-rc.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d7d66574a47f07e87058ce6e429a7be58fd77c7686e572cb1f85d8a6a840c6"
+dependencies = [
+ "facet-macro-parse",
+ "facet-macro-types",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "unsynn",
+]
+
+[[package]]
+name = "facet-path"
+version = "0.50.0-rc.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a21c558bfb27bf957ead130a3b7e5ea2dee7ccbfb31a524053f061efa164d6f"
+dependencies = [
+ "facet-core",
+]
+
+[[package]]
+name = "facet-pretty"
+version = "0.50.0-rc.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dbcf41fe256543a1cdc8a75b3ef130100fbe4a7e180b20472d5601e03349d5b"
+dependencies = [
+ "facet-core",
+ "facet-reflect",
+ "owo-colors",
+ "terminal-light",
+]
+
+[[package]]
+name = "facet-reflect"
+version = "0.50.0-rc.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8486d2a82dcb2a1bdca472c2cdc513d0feacda3af2ffd50fb9e04f8dedda8735"
+dependencies = [
+ "facet-core",
+ "facet-path",
+ "hashbrown 0.17.1",
+ "smallvec 2.0.0-alpha.12",
+]
+
+[[package]]
+name = "facet-solver"
+version = "0.50.0-rc.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5c800eb08ef5e1f18ffafcddf40da5d9ea3c86b8f0486f6ee077b0a3d497812"
+dependencies = [
+ "facet-core",
+ "facet-reflect",
+ "strsim",
+]
+
+[[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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
+[[package]]
+name = "figue"
+version = "5.0.0-rc.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d683110c41f61f23e580c2f11887cfd2667a75388dbbc551b0fec025d3f6aaa8"
+dependencies = [
+ "ariadne",
+ "camino",
+ "facet",
+ "facet-core",
+ "facet-error",
+ "facet-format",
+ "facet-json",
+ "facet-pretty",
+ "facet-reflect",
+ "figue-attrs",
+ "heck",
+ "indexmap",
+ "owo-colors",
+ "strip-ansi-escapes",
+ "strsim",
+ "supports-color 3.0.2",
+ "tracing",
+ "unicode-width",
+]
+
+[[package]]
+name = "figue-attrs"
+version = "5.0.0-rc.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfb26d9b503638fe61b04ef7d76d0d53f39993549948c0267e05094185b6b808"
+dependencies = [
+ "facet",
+]
+
+[[package]]
+name = "filetime"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-timer"
+version = "3.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-macro",
+ "futures-task",
+ "pin-project-lite",
+ "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",
+ "zeroize",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+]
+
+[[package]]
+name = "git-ents"
+version = "0.0.0"
+dependencies = [
+ "axum",
+ "cargo_metadata",
+ "ents-anchor",
+ "ents-effect",
+ "ents-forge",
+ "ents-gate",
+ "ents-kiln",
+ "ents-lens",
+ "ents-model",
+ "ents-query",
+ "ents-receive",
+ "ents-sync",
+ "ents-testutil",
+ "ents-web",
+ "facet",
+ "facet-git-tree",
+ "facet-pretty",
+ "figue",
+ "gix",
+ "gix-hash",
+ "gix-object",
+ "gix-odb",
+ "gix-ref-store",
+ "rand_core 0.6.4",
+ "rstest",
+ "ssh-key",
+ "tempfile",
+ "thiserror 2.0.18",
+ "tokio",
+ "tower",
+]
+
+[[package]]
+name = "gix"
+version = "0.84.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae54ae0ebd1a5a3c3f8d95dd3b5ca6e63f4fed9bfd585e13801a97d7bde8f9ce"
+dependencies = [
+ "gix-actor",
+ "gix-archive",
+ "gix-attributes",
+ "gix-blame",
+ "gix-command",
+ "gix-commitgraph",
+ "gix-config",
+ "gix-credentials",
+ "gix-date",
+ "gix-diff",
+ "gix-dir",
+ "gix-discover",
+ "gix-error",
+ "gix-features",
+ "gix-filter",
+ "gix-fs",
+ "gix-glob",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-ignore",
+ "gix-index",
+ "gix-lock",
+ "gix-mailmap",
+ "gix-negotiate",
+ "gix-object",
+ "gix-odb",
+ "gix-pack",
+ "gix-path",
+ "gix-pathspec",
+ "gix-prompt",
+ "gix-protocol",
+ "gix-ref",
+ "gix-refspec",
+ "gix-revision",
+ "gix-revwalk",
+ "gix-sec",
+ "gix-shallow",
+ "gix-status",
+ "gix-submodule",
+ "gix-tempfile",
+ "gix-trace",
+ "gix-traverse",
+ "gix-url",
+ "gix-utils",
+ "gix-validate",
+ "gix-worktree",
+ "gix-worktree-state",
+ "gix-worktree-stream",
+ "nonempty",
+ "parking_lot",
+ "regex",
+ "signal-hook 0.4.4",
+ "smallvec 1.15.2",
+ "thiserror 2.0.18",
+]
+
+[[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-archive"
+version = "0.33.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16909cacc78936ab96f6c3be08379d0a2e88bfa3a7527972d2ed75c7517ef31e"
+dependencies = [
+ "bstr",
+ "gix-date",
+ "gix-error",
+ "gix-object",
+ "gix-worktree-stream",
+]
+
+[[package]]
+name = "gix-attributes"
+version = "0.33.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39b40888d0ed415c0744a6cdc61eebf0304c9d26ab726725b718443c322e5ba4"
+dependencies = [
+ "bstr",
+ "gix-glob",
+ "gix-path",
+ "gix-quote",
+ "gix-trace",
+ "kstring",
+ "smallvec 1.15.2",
+ "thiserror 2.0.18",
+ "unicode-bom",
+]
+
+[[package]]
+name = "gix-bitmap"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ebef0c26ad305747649e727bbcd56a7b7910754eb7cea88f6dff6f93c51283"
+dependencies = [
+ "gix-error",
+]
+
+[[package]]
+name = "gix-blame"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d39a0c14af94c2edaa5eefe06d5ef2cdea55316ae9a9321314288e3f55fa4c0"
+dependencies = [
+ "gix-commitgraph",
+ "gix-date",
+ "gix-diff",
+ "gix-error",
+ "gix-hash",
+ "gix-object",
+ "gix-revwalk",
+ "gix-trace",
+ "gix-traverse",
+ "gix-worktree",
+ "smallvec 1.15.2",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-chunk"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9faee47943b638e58ddd5e275a4906ad3e4b6c8584f1d41bd18ab9032ec52afb"
+dependencies = [
+ "gix-error",
+]
+
+[[package]]
+name = "gix-command"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00706d4fef135ef4b01680d5218c6ee40cda8baf697b864296cbc887d19118f6"
+dependencies = [
+ "bstr",
+ "gix-path",
+ "gix-quote",
+ "gix-trace",
+ "shell-words",
+]
+
+[[package]]
+name = "gix-commitgraph"
+version = "0.37.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f675d0df484a7f6a47e64bd6f311af489d947c0323b0564f36d14f3d7762abb"
+dependencies = [
+ "bstr",
+ "gix-chunk",
+ "gix-error",
+ "gix-hash",
+ "memmap2",
+ "nonempty",
+]
+
+[[package]]
+name = "gix-config"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f2372d4b49ca28431e7d150cab9d25edc1890f0184bd57eb0e917c7799e63de"
+dependencies = [
+ "bstr",
+ "gix-config-value",
+ "gix-features",
+ "gix-glob",
+ "gix-path",
+ "gix-ref",
+ "gix-sec",
+ "smallvec 1.15.2",
+ "thiserror 2.0.18",
+ "unicode-bom",
+]
+
+[[package]]
+name = "gix-config-value"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed42168329552f6c2e5df09665c104199d45d84bedb53683738a49b57fe1baab"
+dependencies = [
+ "bitflags 2.13.0",
+ "bstr",
+ "gix-path",
+ "libc",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-credentials"
+version = "0.38.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40cd22f0dd71988be12d6e78b1709de2370e1957c5f107ff31e56caeba3745d"
+dependencies = [
+ "bstr",
+ "gix-command",
+ "gix-config-value",
+ "gix-date",
+ "gix-path",
+ "gix-prompt",
+ "gix-sec",
+ "gix-trace",
+ "gix-url",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-date"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d63f9e28b59ddeb1a1eb9e5cf986a9222b5d484947445edbc20473939cc7fd0"
+dependencies = [
+ "bstr",
+ "gix-error",
+ "itoa",
+ "jiff",
+]
+
+[[package]]
+name = "gix-diff"
+version = "0.64.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b6d9528f32d94cef2edf39a1ac01fe5a0fc44ddbb18d9e44099936047c3302b"
+dependencies = [
+ "bstr",
+ "gix-attributes",
+ "gix-command",
+ "gix-filter",
+ "gix-fs",
+ "gix-hash",
+ "gix-imara-diff",
+ "gix-index",
+ "gix-object",
+ "gix-path",
+ "gix-pathspec",
+ "gix-tempfile",
+ "gix-trace",
+ "gix-traverse",
+ "gix-worktree",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-dir"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21bb2a53a6fd917ec499ed0bfb5b6887de7a15bd79197dcea7c987938749a9f1"
+dependencies = [
+ "bstr",
+ "gix-discover",
+ "gix-fs",
+ "gix-ignore",
+ "gix-index",
+ "gix-object",
+ "gix-path",
+ "gix-pathspec",
+ "gix-trace",
+ "gix-utils",
+ "gix-worktree",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-discover"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77bacdd12b7879d2178a80c58c2f319995e4654e1a7a23e3181e5c8a12b824f7"
+dependencies = [
+ "bstr",
+ "dunce",
+ "gix-fs",
+ "gix-path",
+ "gix-ref",
+ "gix-sec",
+ "thiserror 2.0.18",
+]
+
+[[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 = [
+ "bytes",
+ "bytesize",
+ "crc32fast",
+ "crossbeam-channel",
+ "gix-path",
+ "gix-trace",
+ "gix-utils",
+ "libc",
+ "once_cell",
+ "parking_lot",
+ "prodash",
+ "thiserror 2.0.18",
+ "walkdir",
+ "zlib-rs",
+]
+
+[[package]]
+name = "gix-filter"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecf74b7d16f6694ce4a3049074c41be0c7987105743674f1671807bd6dce09fa"
+dependencies = [
+ "bstr",
+ "encoding_rs",
+ "gix-attributes",
+ "gix-command",
+ "gix-hash",
+ "gix-object",
+ "gix-packetline",
+ "gix-path",
+ "gix-quote",
+ "gix-trace",
+ "gix-utils",
+ "smallvec 1.15.2",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-fs"
+version = "0.21.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6cdff46db8798e47e2f727d84b9379aac5add3dd3d9d0b07bb4d7d5d640771fe"
+dependencies = [
+ "bstr",
+ "fastrand",
+ "gix-features",
+ "gix-path",
+ "gix-utils",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-glob"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1fcb8ef5b16bcf874abe9b68d8abb3c0493c876d367ab824151f30a0f3f3756"
+dependencies = [
+ "bitflags 2.13.0",
+ "bstr",
+ "gix-features",
+ "gix-path",
+]
+
+[[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 2.0.18",
+]
+
+[[package]]
+name = "gix-hashtable"
+version = "0.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e261d54091f0d1c729bc83f54548c071bdec60a697de1e58e88bdfd7a99d24e"
+dependencies = [
+ "gix-hash",
+ "hashbrown 0.17.1",
+ "parking_lot",
+]
+
+[[package]]
+name = "gix-ignore"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d491bab9bf2c9f341dc754f425c31d5d3f63aca615312167b82e1deeaca97d8d"
+dependencies = [
+ "bstr",
+ "gix-glob",
+ "gix-path",
+ "gix-trace",
+ "unicode-bom",
+]
+
+[[package]]
+name = "gix-imara-diff"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b305d85504de270ad3525d726a6b69cc59ee7b2269b014387651107ab9f0755b"
+dependencies = [
+ "bstr",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "gix-index"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e6b28cc592dc753adb58302bb14a64e412ee591a3bec77aa4df87bff74fa80d"
+dependencies = [
+ "bitflags 2.13.0",
+ "bstr",
+ "filetime",
+ "fnv",
+ "gix-bitmap",
+ "gix-features",
+ "gix-fs",
+ "gix-hash",
+ "gix-lock",
+ "gix-object",
+ "gix-traverse",
+ "gix-utils",
+ "gix-validate",
+ "hashbrown 0.17.1",
+ "itoa",
+ "libc",
+ "memmap2",
+ "rustix",
+ "smallvec 1.15.2",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-lock"
+version = "23.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c9dedd9e90b0d47624d2ed241d394e09294118364e87b9b7e5f1fe755f3c2c"
+dependencies = [
+ "gix-tempfile",
+ "gix-utils",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-mailmap"
+version = "0.33.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "195fd20808055824531be2fd0d34136d900e5fbca3ffb0a3c07e8beeefb9c828"
+dependencies = [
+ "bstr",
+ "gix-actor",
+ "gix-date",
+ "gix-error",
+]
+
+[[package]]
+name = "gix-negotiate"
+version = "0.32.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "890c936a215bae25818c076cb881cb2e54d2c66ba947ba58b8dd47cff921bf55"
+dependencies = [
+ "bitflags 2.13.0",
+ "gix-commitgraph",
+ "gix-date",
+ "gix-hash",
+ "gix-object",
+ "gix-revwalk",
+]
+
+[[package]]
+name = "gix-object"
+version = "0.61.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5cd857e29429c7213bdef3f5aef83f8cc124774fe8ae0d27b1607d218d6d525"
+dependencies = [
+ "bstr",
+ "gix-actor",
+ "gix-date",
+ "gix-features",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-utils",
+ "gix-validate",
+ "itoa",
+ "smallvec 1.15.2",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-odb"
+version = "0.81.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d004c32858b1556f2d7874405edb3c97dc78fc09beaa87d57bb077ee2858a7d"
+dependencies = [
+ "arc-swap",
+ "gix-features",
+ "gix-fs",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-object",
+ "gix-pack",
+ "gix-path",
+ "gix-quote",
+ "memmap2",
+ "parking_lot",
+ "tempfile",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-pack"
+version = "0.71.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e43626f2a27d1033674ec1a196b845614231e6bbd949d5e21c133045ff56b174"
+dependencies = [
+ "clru",
+ "gix-chunk",
+ "gix-error",
+ "gix-features",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-object",
+ "gix-path",
+ "memmap2",
+ "smallvec 1.15.2",
+ "thiserror 2.0.18",
+ "uluru",
+]
+
+[[package]]
+name = "gix-packetline"
+version = "0.21.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b217dd0ee0c4021ecf169a4a519b1b4f80d15e3f3765f3dc466223dc0ac891d7"
+dependencies = [
+ "bstr",
+ "faster-hex",
+ "gix-trace",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-path"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "afa6ac14cd14939ea94a496ce7460daa6511c09f5b84757e9cfc6f9c8d0f93a6"
+dependencies = [
+ "bstr",
+ "gix-trace",
+ "gix-validate",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-pathspec"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3050783b41ee11511e1e8fb35623df81806194f4030395f14f48ea37c2798c9f"
+dependencies = [
+ "bitflags 2.13.0",
+ "bstr",
+ "gix-attributes",
+ "gix-config-value",
+ "gix-glob",
+ "gix-path",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-prompt"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ee604d7746080ae7e1023bf47204bcc2c5f307bfbe2306a3c90b1bfd1a2c6d8"
+dependencies = [
+ "gix-command",
+ "gix-config-value",
+ "parking_lot",
+ "rustix",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-protocol"
+version = "0.62.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51dea3acb390707ab868f1f9584f18449eb95d869deffae96768e47d303595ee"
+dependencies = [
+ "bstr",
+ "gix-date",
+ "gix-features",
+ "gix-hash",
+ "gix-ref",
+ "gix-shallow",
+ "gix-transport",
+ "gix-utils",
+ "maybe-async",
+ "nonempty",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-quote"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef"
+dependencies = [
+ "bstr",
+ "gix-error",
+ "gix-utils",
+]
+
+[[package]]
+name = "gix-ref"
+version = "0.64.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c04f64c37eb7e6feb73c7060f8dc6f381cc5de5d53249bfd450bc48a86b2e8b"
+dependencies = [
+ "gix-actor",
+ "gix-features",
+ "gix-fs",
+ "gix-hash",
+ "gix-lock",
+ "gix-object",
+ "gix-path",
+ "gix-tempfile",
+ "gix-utils",
+ "gix-validate",
+ "memmap2",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-ref-store"
+version = "0.0.0"
+dependencies = [
+ "gix",
+ "gix-hash",
+ "gix-lock",
+ "rstest",
+ "tempfile",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-refspec"
+version = "0.42.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b216ae06ec74b5f24ad0142026a997fb0a935b7410eaf9c1616fc3f0e6c5a6d3"
+dependencies = [
+ "bstr",
+ "gix-error",
+ "gix-glob",
+ "gix-hash",
+ "gix-revision",
+ "gix-validate",
+ "smallvec 1.15.2",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-revision"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b47c88884dd3c1a19a39da19d10211fcdea2809aadc86869b6e824a1774340f"
+dependencies = [
+ "bitflags 2.13.0",
+ "bstr",
+ "gix-commitgraph",
+ "gix-date",
+ "gix-error",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-object",
+ "gix-revwalk",
+ "gix-trace",
+ "nonempty",
+]
+
+[[package]]
+name = "gix-revwalk"
+version = "0.32.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85f5756abffe0917827aac683b13684ed99875bc398fa1f9b8f479b0681ef9e6"
+dependencies = [
+ "gix-commitgraph",
+ "gix-date",
+ "gix-error",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-object",
+ "smallvec 1.15.2",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-sec"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab8519976e4c7e486270740a5400369f37940779b80bd1377d94cfa1125d01b3"
+dependencies = [
+ "bitflags 2.13.0",
+ "gix-path",
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "gix-shallow"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a292fc2fe548c5dfa575479d16b445b0ddf1dd2f56f1fec6aed386f82553cd97"
+dependencies = [
+ "bstr",
+ "gix-hash",
+ "gix-lock",
+ "nonempty",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-status"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22042e385d28a34275e029d98f4970285045be14b9073658ca897923f2ed8700"
+dependencies = [
+ "bstr",
+ "filetime",
+ "gix-diff",
+ "gix-dir",
+ "gix-features",
+ "gix-filter",
+ "gix-fs",
+ "gix-hash",
+ "gix-index",
+ "gix-object",
+ "gix-path",
+ "gix-pathspec",
+ "gix-worktree",
+ "portable-atomic",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-submodule"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3059890ef054066c22a94bfc6a3eaba0d806aedcd630a0bc9e5783fd88884781"
+dependencies = [
+ "bstr",
+ "gix-config",
+ "gix-path",
+ "gix-pathspec",
+ "gix-refspec",
+ "gix-url",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-tempfile"
+version = "23.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ef60812443484e67bf84e444cc71b4c78ae62deb822221774a4fa0c57fdb17f"
+dependencies = [
+ "dashmap",
+ "gix-fs",
+ "libc",
+ "parking_lot",
+ "signal-hook 0.4.4",
+ "signal-hook-registry",
+ "tempfile",
+]
+
+[[package]]
+name = "gix-trace"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44dc45eae785c0eb14173e0f152e6e224dcf4d45b6a6999a3aed22af541ad678"
+
+[[package]]
+name = "gix-transport"
+version = "0.57.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "186874f7ad1fb2f9a2f2aa9c2dabc7f9dd087bef74c1a0eee2b4a9cf0248fcb3"
+dependencies = [
+ "bstr",
+ "gix-command",
+ "gix-features",
+ "gix-packetline",
+ "gix-quote",
+ "gix-sec",
+ "gix-url",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-traverse"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8de590ecc86a3b2870665f2288324fa9f7f8672c7fc2d4e020fdd81cd1f7aed"
+dependencies = [
+ "bitflags 2.13.0",
+ "gix-commitgraph",
+ "gix-date",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-object",
+ "gix-revwalk",
+ "smallvec 1.15.2",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-url"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65bb01ec69d55e82ccb7a19e264501ead4e6aac38463a8cebfdd81e22bb67ab2"
+dependencies = [
+ "bstr",
+ "gix-path",
+ "percent-encoding",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-utils"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66c50966184123caf580ffa64e28031a878597f1c7fceb8fe19566c38eb1b771"
+dependencies = [
+ "bstr",
+ "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 = "gix-worktree"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cef414ed275e8407cd5d53d301e83be19700b0dd3f859d2434417b58f454a2d1"
+dependencies = [
+ "bstr",
+ "gix-attributes",
+ "gix-fs",
+ "gix-glob",
+ "gix-hash",
+ "gix-ignore",
+ "gix-index",
+ "gix-object",
+ "gix-path",
+ "gix-validate",
+]
+
+[[package]]
+name = "gix-worktree-state"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bffae8b3ca258fdd50370cd51f06deb4c76a3b43db3868bc28dde45ffa77d69"
+dependencies = [
+ "bstr",
+ "gix-features",
+ "gix-filter",
+ "gix-fs",
+ "gix-index",
+ "gix-object",
+ "gix-path",
+ "gix-worktree",
+ "io-close",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "gix-worktree-stream"
+version = "0.33.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d25e9ed30100c63f7590bc581c225e53f731a53e06aa79a245739c07f7dcc557"
+dependencies = [
+ "gix-attributes",
+ "gix-error",
+ "gix-features",
+ "gix-filter",
+ "gix-fs",
+ "gix-hash",
+ "gix-object",
+ "gix-path",
+ "gix-traverse",
+ "parking_lot",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
+[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[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.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "human_format"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eaec953f16e5bcf6b8a3cb3aa959b17e5577dbd2693e94554c462c08be22624b"
+
+[[package]]
+name = "hybrid-array"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "hyper"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec 1.15.2",
+ "tokio",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "bytes",
+ "http",
+ "http-body",
+ "hyper",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec 1.15.2",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "iddqd"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7cf7e72dd082126bd727c1f8bd2d5229c2780258c0b3c7bc33eaf0f4ea5f0fa"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec 1.15.2",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "impls"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a46645bbd70538861a90d0f26c31537cdf1e44aae99a794fb75a664b70951bc"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "int-enum"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e366a1634cccc76b4cfd3e7580de9b605e4d93f1edac48d786c1f867c0def495"
+dependencies = [
+ "proc-macro2",
+ "proc-macro2-diagnostics",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "io-close"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "is-terminal"
+version = "0.4.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "is_ci"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jiff"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e"
+dependencies = [
+ "defmt",
+ "jiff-static",
+ "jiff-tzdb-platform",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+ "windows-link",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "jiff-tzdb"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
+
+[[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.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kstring"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1"
+dependencies = [
+ "static_assertions",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+dependencies = [
+ "spin",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libghostty-vt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04c77176dd05b2b5718046d807e786473f463ea6d478d56f8c0105e2fe6f71f5"
+dependencies = [
+ "bitflags 2.13.0",
+ "int-enum",
+ "libghostty-vt-sys",
+]
+
+[[package]]
+name = "libghostty-vt-sys"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "404671567c0ac43389a69ae424eefc89390a25d3a5386a54249cdaf13e01b411"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "linked-hash-map"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
+[[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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "lsp-server"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62e55c013e58520c3471c904b6a4b95367acb73f20e718e1a0026d4297c9fc8e"
+dependencies = [
+ "crossbeam-channel",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+]
+
+[[package]]
+name = "lsp-types"
+version = "0.94.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1"
+dependencies = [
+ "bitflags 1.3.2",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "url",
+]
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
+[[package]]
+name = "maud"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e"
+dependencies = [
+ "axum-core",
+ "http",
+ "itoa",
+ "maud_macros",
+]
+
+[[package]]
+name = "maud_macros"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca"
+dependencies = [
+ "proc-macro2",
+ "proc-macro2-diagnostics",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "maybe-async"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memmap2"
+version = "0.9.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "mutants"
+version = "0.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126"
+
+[[package]]
+name = "nix"
+version = "0.31.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
+dependencies = [
+ "bitflags 2.13.0",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "nonempty"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6"
+
+[[package]]
+name = "num-bigint-dig"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
+dependencies = [
+ "lazy_static",
+ "libm",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "rand 0.8.6",
+ "smallvec 1.15.2",
+ "zeroize",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "onig"
+version = "6.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
+dependencies = [
+ "bitflags 2.13.0",
+ "libc",
+ "once_cell",
+ "onig_sys",
+]
+
+[[package]]
+name = "onig_sys"
+version = "69.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
+[[package]]
+name = "owo-colors"
+version = "4.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
+dependencies = [
+ "supports-color 2.1.0",
+ "supports-color 3.0.2",
+]
+
+[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "p384"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "p521"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
+dependencies = [
+ "base16ct",
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "rand_core 0.6.4",
+ "sha2 0.10.9",
+]
+
+[[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 1.15.2",
+ "windows-link",
+]
+
+[[package]]
+name = "peg"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0aad070be5b63aa72103f2fcdd70a83adbd5e90112ce5b574171ff1c65501773"
+dependencies = [
+ "peg-macros",
+ "peg-runtime",
+]
+
+[[package]]
+name = "peg-macros"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd8ef6825cae95355031ae26a99b616a2a21f22ba2de0197c43dfb05acbe7ee"
+dependencies = [
+ "peg-runtime",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "peg-runtime"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7011d97b484a5ebdc4b1fdb3b12d5e4bbbea56e9d22b688f2e79e04b65a7d8a6"
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkcs1"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+dependencies = [
+ "der",
+ "pkcs8",
+ "spki",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plist"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
+dependencies = [
+ "base64",
+ "indexmap",
+ "quick-xml",
+ "serde",
+ "time",
+]
+
+[[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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "proc-macro2-diagnostics"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "version_check",
+]
+
+[[package]]
+name = "prodash"
+version = "31.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c"
+dependencies = [
+ "bytesize",
+ "human_format",
+ "parking_lot",
+]
+
+[[package]]
+name = "proptest"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
+dependencies = [
+ "bit-set",
+ "bit-vec",
+ "bitflags 2.13.0",
+ "num-traits",
+ "rand 0.9.4",
+ "rand_chacha 0.9.0",
+ "rand_xorshift",
+ "regex-syntax",
+ "rusty-fork",
+ "tempfile",
+ "unarray",
+]
+
+[[package]]
+name = "pulldown-cmark"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
+dependencies = [
+ "bitflags 2.13.0",
+ "memchr",
+ "pulldown-cmark-escape",
+ "unicase",
+]
+
+[[package]]
+name = "pulldown-cmark-escape"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
+
+[[package]]
+name = "quick-error"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
+
+[[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "rand_xorshift"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+dependencies = [
+ "rand_core 0.9.5",
+]
+
+[[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.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "relative-path"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
+
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
+]
+
+[[package]]
+name = "rgb"
+version = "0.8.53"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
+
+[[package]]
+name = "rsa"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
+dependencies = [
+ "const-oid 0.9.6",
+ "digest 0.10.7",
+ "num-bigint-dig",
+ "num-integer",
+ "num-traits",
+ "pkcs1",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "sha2 0.10.9",
+ "signature",
+ "spki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rstest"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49"
+dependencies = [
+ "futures-timer",
+ "futures-util",
+ "rstest_macros",
+]
+
+[[package]]
+name = "rstest_macros"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0"
+dependencies = [
+ "cfg-if",
+ "glob",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "relative-path",
+ "rustc_version",
+ "syn",
+ "unicode-ident",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.13.0",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "rusty-fork"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
+dependencies = [
+ "fnv",
+ "quick-error",
+ "tempfile",
+ "wait-timeout",
+]
+
+[[package]]
+name = "ruzstd"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "pkcs8",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "self_cell"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "sha1-checked"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423"
+dependencies = [
+ "digest 0.10.7",
+ "sha1",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "sha2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "shell-words"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-mio"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
+dependencies = [
+ "libc",
+ "mio",
+ "signal-hook 0.3.18",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest 0.10.7",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "smallvec"
+version = "2.0.0-alpha.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef784004ca8777809dcdad6ac37629f0a97caee4c685fcea805278d81dd8b857"
+
+[[package]]
+name = "socket2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "ssh-cipher"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
+dependencies = [
+ "cipher",
+ "ssh-encoding",
+]
+
+[[package]]
+name = "ssh-encoding"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
+dependencies = [
+ "base64ct",
+ "pem-rfc7468",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "ssh-key"
+version = "0.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
+dependencies = [
+ "ed25519-dalek",
+ "p256",
+ "p384",
+ "p521",
+ "rand_core 0.6.4",
+ "rsa",
+ "sec1",
+ "sha2 0.10.9",
+ "signature",
+ "ssh-cipher",
+ "ssh-encoding",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
+
+[[package]]
+name = "strip-ansi-escapes"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
+dependencies = [
+ "vte",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "supports-color"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89"
+dependencies = [
+ "is-terminal",
+ "is_ci",
+]
+
+[[package]]
+name = "supports-color"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
+dependencies = [
+ "is_ci",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "syntect"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
+dependencies = [
+ "bincode",
+ "flate2",
+ "fnv",
+ "once_cell",
+ "onig",
+ "plist",
+ "regex-syntax",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "thiserror 2.0.18",
+ "walkdir",
+ "yaml-rust",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "terminal-light"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79307346b35bf24ed47c895c71f24653fab51c37576c7df7b5efdb8d519aee6d"
+dependencies = [
+ "coolor",
+ "crossterm",
+ "thiserror 1.0.69",
+ "xterm-query",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl 2.0.18",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "time"
+version = "0.3.53"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "tree-sitter-language"
+version = "0.1.7"
+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 = "uluru"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da"
+dependencies = [
+ "arrayvec",
+]
+
+[[package]]
+name = "unarray"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
+
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
+[[package]]
+name = "unicode-bom"
+version = "2.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217"
+
+[[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 = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[package]]
+name = "unsynn"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "501a7adf1a4bd9951501e5c66621e972ef8874d787628b7f90e64f936ef7ec0a"
+dependencies = [
+ "mutants",
+ "proc-macro2",
+ "rustc-hash",
+]
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "vte"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
+dependencies = [
+ "arrayvec",
+ "memchr",
+]
+
+[[package]]
+name = "wait-timeout"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "weavy"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30261027ea4225cf7c8287aa70bd103c1e78508606c77caf24bb468326ce7b5f"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winnow"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "xterm-query"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e468ba3129b3d5acd2655410cf12adccd818746e2b8d7fd4b2d22a6e3515ec98"
+dependencies = [
+ "nix",
+ "thiserror 1.0.69",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "yaml-rust"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
+dependencies = [
+ "linked-hash-map",
+]
+
+[[package]]
+name = "yansi"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zlib-rs"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5"
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Dockerfile
@@ -1,0 +1,34 @@
+# The single-node hosted root (`roots.single-node-hosted`,
+# docs/development-plan.adoc phase 6): the `git-ents` binary itself, wired
+# so stock git's own smart-HTTP transport (`git http-backend`, via
+# nginx+fcgiwrap) invokes its `pre-receive`/`post-receive` hooks. No
+# Postgres/Tigris/gix-receive here — that is `git-ents-server`, phase 8.
+#
+# No Rust toolchain, no cargo build, in this image: `docker/bin/git-ents` is
+# a musl static binary cross-compiled on the host (`cargo zigbuild --target
+# x86_64-unknown-linux-musl`) and materialized here from the on-disk blob
+# recorded at `refs/meta/releases/<source-commit-sha>` — never a normal
+# tracked file on `refs/heads` (see `.gitignore`).
+FROM debian:bookworm-slim AS runtime
+WORKDIR /app
+# git: the bare repo + git-http-backend CGI itself.
+# nginx+fcgiwrap+spawn-fcgi: the smart-HTTP transport (Phase 0's bootstrap,
+# still the transport Phase 6 rides per docs/development-plan.adoc).
+# curl: installs the sprite CLI the post-receive hook shells out to.
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ git ca-certificates curl nginx fcgiwrap spawn-fcgi \
+ && rm -rf /var/lib/apt/lists/*
+# The sprite CLI runs post-receive's checks in a Sprite; it reads
+# SPRITES_TOKEN from the env. The installer drops the binary in
+# $HOME/.local/bin and never touches PATH, so point it at /usr/local/bin
+# (already on PATH) where the hosted root can spawn it.
+RUN curl -fsSL https://sprites.dev/install.sh \
+ | env SPRITE_INSTALL_PREFERRED_DIRS=/usr/local/bin \
+ SPRITE_INSTALL_DEFAULT_BIN_DIR=/usr/local/bin bash
+COPY docker/bin/git-ents /usr/local/bin/git-ents
+RUN chmod +x /usr/local/bin/git-ents
+COPY docker/nginx.conf /etc/git-ents/nginx.conf
+COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
+RUN chmod +x /usr/local/bin/entrypoint.sh
+ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
crates/cli/ents-lens/Cargo.toml
@@ -1,0 +1,30 @@
+[package]
+name = "ents-lens"
+version = "0.0.0"
+edition.workspace = true
+publish.workspace = true
+license.workspace = true
+
+[dependencies]
+ents-anchor = { workspace = true }
+ents-forge = { workspace = true }
+ents-model = { workspace = true }
+ents-receive = { workspace = true }
+facet-git-tree = { workspace = true }
+gix = { workspace = true }
+gix-hash = { workspace = true }
+gix-object = { workspace = true }
+gix-ref-store = { workspace = true }
+lsp-server = { workspace = true }
+lsp-types = { workspace = true }
+serde = "1"
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+
+[dev-dependencies]
+ents-testutil = { workspace = true }
+rstest = { workspace = true }
+tempfile = { workspace = true }
+
+[lints]
+workspace = true
crates/cli/ents-lens/src/compose.rs
@@ -1,0 +1,138 @@
+//! The editor-file compose flow (`lens.compose`): building the template
+//! git-style commit-message file the editor opens, and parsing it back
+//! once the user saves.
+//!
+//! This module is pure — string in, string out, no IO and no git — so the
+//! exact template grammar and the "an empty body aborts, `#` lines are
+//! ignored" rule are unit-testable on their own, and [`crate::Lens`] owns
+//! only the filesystem and mutation halves.
+//!
+//! # The mechanism, precisely
+//!
+//! `lens.compose` requires composing to work through a file "the way git
+//! itself takes a commit message", using no client-specific extension. The
+//! flow the lens drives, using only standard LSP a plain client provides
+//! (`workspace/executeCommand`, `window/showDocument`, and
+//! `textDocument/didSave`):
+//!
+//! 1. A `textDocument/codeAction` on the selection returns the
+//! `ents.compose` command.
+//! 2. The client runs it via `workspace/executeCommand`; the lens writes
+//! [`template_text`] to `.git/ENTS_COMMENT_EDITMSG` and asks the client
+//! to open it with `window/showDocument`.
+//! 3. The user edits the body and saves. The lens's `textDocument/didSave`
+//! handler reads the file, [`parse`]s it, and — if the body is
+//! non-empty — creates the comment through `ents_forge::comment::add`
+//! (the same call the CLI makes, `lens.parity`), anchoring to the
+//! working tree (`lens.working-tree`). An empty body aborts.
+//!
+//! The template is self-describing: the anchor target (path, lines,
+//! working-tree flag, and an optional reply parent) rides in `#`-prefixed
+//! metadata lines, so [`parse`] recovers it from the saved file alone and
+//! the lens keeps no per-compose state of its own ("owning no state of its
+//! own", the lens's whole premise). Because those lines start with `#`
+//! they are ignored for the body exactly as any other comment line is, so
+//! the metadata can never leak into the comment text.
+
+/// The prefix every machine-readable metadata line in the template carries,
+/// after the `#` comment marker: `# ents-compose-<key>: <value>`.
+const META_PREFIX: &str = "# ents-compose-";
+
+/// What a compose targets: an anchor (a path, optional `<start>:<end>`
+/// lines) captured against the working tree, and/or a reply parent.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct Target {
+ /// Repository-relative path to anchor to, or `None` for a reply that
+ /// inherits its aboutness from its parent.
+ pub path: Option<String>,
+ /// Lines to anchor, as `<start>[:<end>]`.
+ pub lines: Option<String>,
+ /// Id of the comment being replied to, when this compose is a reply.
+ pub parent: Option<String>,
+}
+
+/// A parsed, saved template: the body (with `#` lines and surrounding
+/// blank lines stripped) and the anchor [`Target`] its metadata named.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Composed {
+ /// The comment body the user typed. Empty (after trimming) means the
+ /// compose was aborted (`lens.compose`).
+ pub body: String,
+ /// Where the comment anchors / who it replies to.
+ pub target: Target,
+}
+
+impl Composed {
+ /// Whether the saved template aborts the compose: an empty body once
+ /// `#` lines and surrounding whitespace are stripped (`lens.compose`).
+ #[must_use]
+ pub fn is_abort(&self) -> bool {
+ self.body.trim().is_empty()
+ }
+}
+
+/// The initial template text for a new anchored comment on `path`/`lines`
+/// against the working tree, or a reply when `target.parent` is set — a
+/// blank body followed by git-style `#` guidance and the machine-readable
+/// metadata [`parse`] reads back.
+#[must_use]
+pub fn template_text(target: &Target) -> String {
+ let mut out = String::new();
+ // One blank line for the body; the user types above the guidance.
+ out.push('\n');
+ out.push_str("# Leave an ents comment. Lines starting with '#' are ignored;\n");
+ out.push_str("# an empty message aborts. Save this file to create the comment.\n");
+ out.push_str("#\n");
+ match (&target.parent, &target.path) {
+ (Some(parent), _) => {
+ out.push_str(&format!("# Replying to comment {parent}.\n"));
+ }
+ (None, Some(path)) => match &target.lines {
+ Some(lines) => out.push_str(&format!("# On: {path} lines {lines} (working tree).\n")),
+ None => out.push_str(&format!("# On: {path} (working tree).\n")),
+ },
+ (None, None) => {}
+ }
+ // Machine-readable metadata: one value per line, so a path containing
+ // spaces round-trips without any escaping.
+ if let Some(path) = &target.path {
+ out.push_str(&format!("{META_PREFIX}path: {path}\n"));
+ }
+ if let Some(lines) = &target.lines {
+ out.push_str(&format!("{META_PREFIX}lines: {lines}\n"));
+ }
+ if let Some(parent) = &target.parent {
+ out.push_str(&format!("{META_PREFIX}parent: {parent}\n"));
+ }
+ out
+}
+
+/// Parse a saved template back into its body and [`Target`]
+/// (`lens.compose`): every line starting with `#` is dropped from the body,
+/// and the `# ents-compose-<key>: <value>` metadata lines reconstruct the
+/// anchor target the compose was started with.
+#[must_use]
+pub fn parse(content: &str) -> Composed {
+ let mut target = Target::default();
+ let mut body_lines: Vec<&str> = Vec::new();
+ for line in content.lines() {
+ if let Some(rest) = line.strip_prefix(META_PREFIX) {
+ if let Some((key, value)) = rest.split_once(':') {
+ let value = value.trim().to_owned();
+ match key {
+ "path" => target.path = Some(value),
+ "lines" => target.lines = Some(value),
+ "parent" => target.parent = Some(value),
+ _ => {}
+ }
+ }
+ continue;
+ }
+ if line.starts_with('#') {
+ continue;
+ }
+ body_lines.push(line);
+ }
+ let body = body_lines.join("\n").trim().to_owned();
+ Composed { body, target }
+}
crates/cli/ents-lens/src/document.rs
@@ -1,0 +1,92 @@
+//! The lens's view of the client's open buffers, and the file-URI ↔
+//! repository-path arithmetic that ties an LSP document to the anchor
+//! paths `refs/meta/comments/*` records.
+//!
+//! The lens caches nothing derived — no projection, no lens, no diagnostic
+//! survives a comment-ref mutation (`lens.lenses`) — but it must remember
+//! the *buffer text* the client has sent, because the client owns the only
+//! copy of a document's unsaved content: `textDocument/didChange` ships an
+//! edit, never the file, and the disk still holds the old bytes. Holding
+//! the latest buffer per open URI is what lets projection target the bytes
+//! the user is actually looking at (`lens.working-tree`), and it is dropped
+//! the moment the client closes the document.
+
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+
+use lsp_types::Url;
+
+/// The client's open text documents, keyed by URI, each holding the latest
+/// full text the client has sent (`textDocumentSync` is full-text, so every
+/// change replaces the whole buffer).
+#[derive(Debug, Default)]
+pub struct Documents {
+ open: HashMap<Url, String>,
+}
+
+impl Documents {
+ /// Record (or replace) the full text of the document at `uri` — the
+ /// `didOpen`/`didChange` handler's whole job.
+ pub fn set(&mut self, uri: Url, text: String) {
+ self.open.insert(uri, text);
+ }
+
+ /// Forget the document at `uri` — `didClose`; projection falls back to
+ /// the on-disk bytes afterward.
+ pub fn remove(&mut self, uri: &Url) {
+ self.open.remove(uri);
+ }
+
+ /// The latest buffer text for `uri`, if the client has it open.
+ #[must_use]
+ pub fn text(&self, uri: &Url) -> Option<&str> {
+ self.open.get(uri).map(String::as_str)
+ }
+
+ /// Every open document's URI — the server republishes diagnostics for
+ /// these after a comment-ref mutation.
+ #[must_use]
+ pub fn open_uris(&self) -> Vec<Url> {
+ self.open.keys().cloned().collect()
+ }
+}
+
+/// The repository-relative, forward-slashed path a file URI names inside
+/// the working tree at `workdir`, or `None` when the URI is not a file
+/// under it — the key that matches an [`ents_anchor::Anchor`]'s own
+/// recorded path.
+///
+/// Both sides are canonicalized when possible (the working tree always
+/// exists; an open document usually does), so a symlinked temp directory
+/// like macOS's `/var` → `/private/var` does not defeat the prefix match;
+/// when the document has no on-disk form yet, the raw paths are compared.
+#[must_use]
+pub fn relative_path(workdir: &Path, uri: &Url) -> Option<String> {
+ let file = uri.to_file_path().ok()?;
+ let file_canon = canonical(&file);
+ let workdir_canon = canonical(workdir);
+ let rel = file_canon.strip_prefix(&workdir_canon).ok()?;
+ Some(rel.to_string_lossy().replace('\\', "/"))
+}
+
+/// Resolve `path` through symlinks even when its leaf does not exist yet,
+/// by canonicalizing the deepest ancestor that does and re-appending the
+/// rest — so a not-yet-saved document under a symlinked temp directory
+/// (macOS's `/var` → `/private/var`) still shares a prefix with the
+/// canonicalized working tree.
+fn canonical(path: &Path) -> PathBuf {
+ if let Ok(resolved) = path.canonicalize() {
+ return resolved;
+ }
+ match (path.parent(), path.file_name()) {
+ (Some(parent), Some(name)) => canonical(parent).join(name),
+ _ => path.to_owned(),
+ }
+}
+
+/// The absolute file URI for `path` — used to open the compose template
+/// with `window/showDocument`.
+#[must_use]
+pub fn file_uri(path: &Path) -> Option<Url> {
+ Url::from_file_path(path).ok()
+}
crates/cli/ents-lens/src/error.rs
@@ -1,0 +1,45 @@
+//! The lens's error type: every failure a request handler can hit while
+//! reading `refs/meta/*`, projecting an anchor, or writing a new comment.
+
+/// A lens operation's result.
+pub type Result<T> = std::result::Result<T, Error>;
+
+/// Everything that can go wrong deriving a lens response or composing a
+/// comment through it.
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ /// A comment read, listing, projection, or mutation failed in the
+ /// shared `ents-forge` library the lens calls (`lens.parity`). The
+ /// caller should surface the message; it is never a protocol-level
+ /// fault.
+ #[error(transparent)]
+ Forge(#[from] ents_forge::Error),
+
+ /// The repository could not be opened at the injected path — the lens
+ /// was wired against a directory that is not a git working tree.
+ #[error("open repository: {0}")]
+ Repo(String),
+
+ /// A filesystem operation on the compose template under `.git/`
+ /// (`lens.compose`) failed. The caller should report it; the comment
+ /// was not created.
+ #[error("template {path}: {source}")]
+ Template {
+ /// The template path the operation targeted.
+ path: std::path::PathBuf,
+ /// The underlying IO error.
+ source: std::io::Error,
+ },
+
+ /// An `executeCommand` request named a command the lens exposes but
+ /// carried the wrong arguments (a missing or non-string comment id, for
+ /// instance). The caller sent a malformed request.
+ #[error("bad command arguments: {0}")]
+ BadArguments(String),
+}
+
+impl From<gix::open::Error> for Error {
+ fn from(source: gix::open::Error) -> Self {
+ Self::Repo(source.to_string())
+ }
+}
crates/cli/ents-lens/src/lens.rs
@@ -1,0 +1,527 @@
+//! The lens core: a read-time view over `refs/meta/comments/*` projected
+//! into whatever buffer the client has open, plus the compose flow that
+//! writes new comments back through the shared library.
+//!
+//! Every response is derived per request from an anchor projection onto the
+//! working tree — never cached across a comment-ref mutation (`lens.lenses`)
+//! — and every listing, projection, and write is the same
+//! `ents_forge::comment` call the CLI porcelain makes (`lens.parity`), so a
+//! comment is one entity across the editor, the CLI, and the web.
+
+use std::path::PathBuf;
+
+use ents_forge::comment::{self, ListFilter, Listed, NewComment};
+use ents_receive::{EventSink, Identity, Mode};
+use gix_object::{CommitRef, Find, Write};
+use gix_ref_store::RefStore;
+use lsp_types::{
+ CodeAction, CodeActionKind, CodeActionOrCommand, CodeLens, Command, Diagnostic, Hover,
+ HoverContents, Position, Range, Url,
+};
+use serde_json::{Value, json};
+
+use crate::compose::{self, Target};
+use crate::document::{self, Documents};
+use crate::error::{Error, Result};
+use crate::render;
+use crate::signing::Signing;
+
+/// What an `executeCommand` or a `didSave` produced, in protocol-neutral
+/// terms the server layer turns into LSP messages: an optional command
+/// result value, an optional file to open with `window/showDocument`, and
+/// whether the open documents' diagnostics should be republished
+/// (a comment-ref mutation invalidates every derived view, `lens.lenses`).
+#[derive(Debug, Default)]
+pub struct Outcome {
+ /// The `workspace/executeCommand` result value (the thread markup, for
+ /// View); `None` for a command whose effect is a side effect only.
+ pub response: Option<Value>,
+ /// A file the client should open (`window/showDocument`) — the compose
+ /// template, for Compose and Reply (`lens.compose`).
+ pub show_document: Option<PathBuf>,
+ /// Whether every open document's diagnostics should be recomputed and
+ /// republished, because a comment ref just changed.
+ pub refresh: bool,
+}
+
+/// The editor surface over one repository's comments. Holds the four
+/// composition-root seams it needs (the ref store, the object store, the
+/// event sink, and the signing identity — all injected, `lens.serve`), the
+/// gate mode, the working-tree path, and the client's open buffers; owns no
+/// derived state.
+///
+/// Generic over only the object store `O`, exactly as
+/// `ents_web::state::AppState` is and for the same reason: `refs` and
+/// `events` are already trait objects everywhere in this codebase, while
+/// every mutation primitive takes the object store as `&(impl Find +
+/// Write)`.
+pub struct Lens<O> {
+ refs: Box<dyn RefStore>,
+ objects: O,
+ events: Box<dyn EventSink>,
+ mode: Mode,
+ signing: Signing,
+ path: PathBuf,
+ documents: Documents,
+}
+
+impl<O: Find + Write> Lens<O> {
+ /// Wire a lens from already-resolved seams — the one constructor a
+ /// composition root calls (`git ents lsp`); the lens never opens a
+ /// store or resolves a key itself.
+ pub fn new(
+ refs: Box<dyn RefStore>,
+ objects: O,
+ events: Box<dyn EventSink>,
+ mode: Mode,
+ signing: Signing,
+ path: PathBuf,
+ ) -> Self {
+ Self {
+ refs,
+ objects,
+ events,
+ mode,
+ signing,
+ path,
+ documents: Documents::default(),
+ }
+ }
+
+ /// Record a document the client opened, with its full text
+ /// (`textDocument/didOpen`) — projection targets this buffer afterward
+ /// (`lens.working-tree`).
+ pub fn did_open(&mut self, uri: Url, text: String) {
+ self.documents.set(uri, text);
+ }
+
+ /// Replace an open document's text on a full-sync change
+ /// (`textDocument/didChange`), so ranges re-project against the unsaved
+ /// edit (`lens.working-tree`).
+ pub fn did_change(&mut self, uri: Url, text: String) {
+ self.documents.set(uri, text);
+ }
+
+ /// Forget a closed document (`textDocument/didClose`); projection falls
+ /// back to on-disk bytes.
+ pub fn did_close(&mut self, uri: &Url) {
+ self.documents.remove(uri);
+ }
+
+ /// The open comments (`model.comment-state`) anchored to `uri`'s
+ /// document, each projected onto its live buffer when open, on-disk
+ /// bytes otherwise — the one derivation every read response is built
+ /// from, recomputed here on every call and never cached (`lens.lenses`,
+ /// `lens.working-tree`, `lens.parity`).
+ ///
+ /// # Errors
+ ///
+ /// Propagates a ref-store, object, repository, or projection failure.
+ // @relation(lens.lenses, lens.working-tree, lens.parity, scope=function)
+ fn document_comments(&self, uri: &Url) -> Result<Vec<Listed>> {
+ let Some(rel) = document::relative_path(&self.path, uri) else {
+ return Ok(Vec::new());
+ };
+ let buffer = self.documents.text(uri).map(str::as_bytes);
+ let filter = ListFilter {
+ state: Some("open".to_owned()),
+ context: None,
+ };
+ let (rows, unreadable) = comment::list_for_document(
+ self.refs.as_ref(),
+ &self.objects,
+ &self.path,
+ &rel,
+ buffer,
+ &filter,
+ )?;
+ // An unreadable comment ref names no document, so it has no lens,
+ // diagnostic, or hover to appear in -- the web listing's
+ // disclosure and `git ents comment list`'s trailing note are the
+ // surfaces that report it; here it is dropped deliberately, not
+ // silently (the library returns it either way, `lens.parity`).
+ let _ = unreadable;
+ Ok(rows)
+ }
+
+ /// The code lenses for `uri` (`lens.lenses`): three per open comment
+ /// that projects onto the document — a summary lens plus Reply and
+ /// Resolve — omitting a comment whose anchor no longer lands there.
+ ///
+ /// # Errors
+ ///
+ /// Propagates a ref-store, object, repository, or projection failure.
+ // @relation(lens.lenses, scope=function)
+ pub fn code_lenses(&self, uri: &Url) -> Result<Vec<CodeLens>> {
+ let mut out = Vec::new();
+ for row in self.document_comments(uri)? {
+ if let Some((range, outdated)) = landed(&row) {
+ out.extend(render::code_lenses(&row.id, &row.comment, range, outdated));
+ }
+ }
+ Ok(out)
+ }
+
+ /// The hint-severity diagnostics for `uri` (`lens.diagnostics`): the
+ /// same projected comments as the lenses, one hint each, for clients
+ /// that do not render lenses. Never a warning or error.
+ ///
+ /// # Errors
+ ///
+ /// Propagates a ref-store, object, repository, or projection failure.
+ // @relation(lens.diagnostics, scope=function)
+ pub fn diagnostics(&self, uri: &Url) -> Result<Vec<Diagnostic>> {
+ let mut out = Vec::new();
+ for row in self.document_comments(uri)? {
+ if let Some((range, outdated)) = landed(&row) {
+ out.push(render::diagnostic(&row.id, &row.comment, range, outdated));
+ }
+ }
+ Ok(out)
+ }
+
+ /// The hover for a position in `uri` (`lens.hover`): if it falls on a
+ /// projected comment's range, the whole thread rendered as Markdown —
+ /// bodies, states, and authorship read from each ref's commit chain.
+ ///
+ /// # Errors
+ ///
+ /// Propagates a ref-store, object, repository, or projection failure.
+ // @relation(lens.hover, scope=function)
+ pub fn hover(&self, uri: &Url, position: Position) -> Result<Option<Hover>> {
+ for row in self.document_comments(uri)? {
+ let Some((range, _outdated)) = landed(&row) else {
+ continue;
+ };
+ if position_in(position, range) {
+ let markup = self.thread_markup(&row.id)?;
+ return Ok(Some(Hover {
+ contents: HoverContents::Markup(markup),
+ range: Some(range),
+ }));
+ }
+ }
+ Ok(None)
+ }
+
+ /// The code actions for a selection in `uri` (`lens.compose`): the
+ /// "Leave an ents comment" action, whose command opens the compose
+ /// template anchored to exactly the selected lines against the working
+ /// tree. Empty when the URI is not a file in the working tree.
+ ///
+ /// # Errors
+ ///
+ /// Never fails today; returns [`Result`] for symmetry with the other
+ /// request handlers.
+ // @relation(lens.compose, scope=function)
+ pub fn code_actions(&self, uri: &Url, range: Range) -> Result<Vec<CodeActionOrCommand>> {
+ let Some(rel) = document::relative_path(&self.path, uri) else {
+ return Ok(Vec::new());
+ };
+ let lines = selection_lines(range);
+ let command = Command {
+ title: "Leave an ents comment".to_owned(),
+ command: render::CMD_COMPOSE.to_owned(),
+ arguments: Some(vec![json!({ "path": rel, "lines": lines })]),
+ };
+ Ok(vec![CodeActionOrCommand::CodeAction(CodeAction {
+ title: "Leave an ents comment".to_owned(),
+ kind: Some(CodeActionKind::EMPTY),
+ diagnostics: None,
+ edit: None,
+ command: Some(command),
+ is_preferred: None,
+ disabled: None,
+ data: None,
+ })])
+ }
+
+ /// Run a `workspace/executeCommand` the lens registered
+ /// (`lens.lenses`, `lens.compose`): View returns the thread, Resolve
+ /// records the state mutation through the shared library call, and
+ /// Reply/Compose open the compose template.
+ ///
+ /// # Errors
+ ///
+ /// [`Error::BadArguments`] for a missing or malformed argument;
+ /// otherwise propagates the underlying comment library or template
+ /// failure.
+ // @relation(lens.lenses, lens.compose, lens.parity, scope=function)
+ pub fn execute_command(&self, command: &str, arguments: &[Value]) -> Result<Outcome> {
+ match command {
+ render::CMD_VIEW => {
+ let id = arg_id(arguments)?;
+ let markup = self.thread_markup(&id)?;
+ Ok(Outcome {
+ response: Some(json!(markup.value)),
+ ..Outcome::default()
+ })
+ }
+ render::CMD_RESOLVE => {
+ let id = arg_id(arguments)?;
+ let signer = &self.signing;
+ let sign = |payload: &[u8]| signer.sign(payload);
+ let identity = Identity {
+ actor: signer.actor(),
+ sign: &sign,
+ };
+ comment::resolve(
+ self.refs.as_ref(),
+ &self.objects,
+ self.events.as_ref(),
+ &id,
+ &identity,
+ self.mode,
+ Some(signer.public_openssh()),
+ )?;
+ Ok(Outcome {
+ refresh: true,
+ ..Outcome::default()
+ })
+ }
+ render::CMD_REPLY => {
+ let id = arg_id(arguments)?;
+ let target = Target {
+ parent: Some(id),
+ ..Target::default()
+ };
+ let template = self.write_template(&target)?;
+ Ok(Outcome {
+ show_document: Some(template),
+ ..Outcome::default()
+ })
+ }
+ render::CMD_COMPOSE => {
+ let target = compose_target(arguments)?;
+ let template = self.write_template(&target)?;
+ Ok(Outcome {
+ show_document: Some(template),
+ ..Outcome::default()
+ })
+ }
+ other => Err(Error::BadArguments(format!("unknown command {other}"))),
+ }
+ }
+
+ /// Handle a `textDocument/didSave`: if the saved file is the compose
+ /// template, finalize the comment (`lens.compose`); otherwise recompute
+ /// diagnostics, since the saved buffer now matches disk.
+ ///
+ /// # Errors
+ ///
+ /// Propagates a template read or comment-creation failure.
+ // @relation(lens.compose, scope=function)
+ pub fn did_save(&self, uri: &Url) -> Result<Outcome> {
+ if self.is_template(uri) {
+ return self.finalize_compose();
+ }
+ Ok(Outcome {
+ refresh: true,
+ ..Outcome::default()
+ })
+ }
+
+ /// Every open document's URI — the server republishes diagnostics for
+ /// these after a mutation ([`Outcome::refresh`]).
+ #[must_use]
+ pub fn open_documents(&self) -> Vec<Url> {
+ self.documents.open_uris()
+ }
+
+ /// Diagnostics for `uri` even when the document is not open — the server
+ /// uses this to clear or refresh a specific document.
+ ///
+ /// # Errors
+ ///
+ /// See [`Lens::diagnostics`].
+ pub fn diagnostics_for(&self, uri: &Url) -> Result<Vec<Diagnostic>> {
+ self.diagnostics(uri)
+ }
+
+ /// The compose template's absolute path, `<git-dir>/ENTS_COMMENT_EDITMSG`
+ /// (`lens.compose`).
+ fn template_path(&self) -> Result<PathBuf> {
+ let repo = gix::open(&self.path)?;
+ Ok(repo.git_dir().join("ENTS_COMMENT_EDITMSG"))
+ }
+
+ /// Whether `uri` names the compose template (a saved-template event).
+ fn is_template(&self, uri: &Url) -> bool {
+ let Ok(template) = self.template_path() else {
+ return false;
+ };
+ let Ok(saved) = uri.to_file_path() else {
+ return false;
+ };
+ let template = template.canonicalize().unwrap_or(template);
+ let saved = saved.canonicalize().unwrap_or(saved);
+ saved == template
+ }
+
+ /// Write the compose template for `target` and return its path
+ /// (`lens.compose`).
+ fn write_template(&self, target: &Target) -> Result<PathBuf> {
+ let template = self.template_path()?;
+ let text = compose::template_text(target);
+ std::fs::write(&template, text).map_err(|source| Error::Template {
+ path: template.clone(),
+ source,
+ })?;
+ Ok(template)
+ }
+
+ /// Read the saved template and create the comment it describes through
+ /// the shared library call (`lens.parity`), anchoring to the working
+ /// tree (`lens.working-tree`); an empty body aborts (`lens.compose`).
+ /// The template is always removed afterward so a stale one is never
+ /// reused.
+ fn finalize_compose(&self) -> Result<Outcome> {
+ let template = self.template_path()?;
+ let content = std::fs::read_to_string(&template).map_err(|source| Error::Template {
+ path: template.clone(),
+ source,
+ })?;
+ let composed = compose::parse(&content);
+ // Best effort: a leftover template is harmless — the next compose
+ // overwrites it — so a removal failure never aborts a comment that
+ // was otherwise created successfully.
+ if let Err(_error) = std::fs::remove_file(&template) {}
+ if composed.is_abort() {
+ return Ok(Outcome::default());
+ }
+
+ let signer = &self.signing;
+ let sign = |payload: &[u8]| signer.sign(payload);
+ let identity = Identity {
+ actor: signer.actor(),
+ sign: &sign,
+ };
+ if let Some(parent) = composed.target.parent {
+ comment::reply(
+ self.refs.as_ref(),
+ &self.objects,
+ self.events.as_ref(),
+ &parent,
+ composed.body,
+ &identity,
+ self.mode,
+ )?;
+ } else {
+ let new = NewComment {
+ body: composed.body,
+ path: composed.target.path,
+ lines: composed.target.lines,
+ rev: "HEAD".to_owned(),
+ worktree: true,
+ context: None,
+ parent: None,
+ };
+ comment::add(
+ self.refs.as_ref(),
+ &self.objects,
+ self.events.as_ref(),
+ &self.path,
+ new,
+ &identity,
+ self.mode,
+ )?;
+ }
+ Ok(Outcome {
+ refresh: true,
+ ..Outcome::default()
+ })
+ }
+
+ /// The whole thread rooted at `root_id` as hover Markdown (`lens.hover`)
+ /// — root plus replies (`thread_of`), each stamped with the author and
+ /// time read from its ref's tip mutation commit (`meta-ref.identity-binding`).
+ fn thread_markup(&self, root_id: &str) -> Result<lsp_types::MarkupContent> {
+ let rows = comment::thread_of(self.refs.as_ref(), &self.objects, root_id)?;
+ let mut with_authors = Vec::with_capacity(rows.len());
+ for (id, comment) in rows {
+ let (author, when) = self
+ .authorship(&id)
+ .unwrap_or_else(|| ("unknown".to_owned(), String::new()));
+ with_authors.push((id, comment, author, when));
+ }
+ Ok(render::hover_markup(&with_authors))
+ }
+
+ /// The author display name and a short date for the comment at `id`,
+ /// read from its ref's tip mutation commit (`model.comment`: authorship
+ /// lives in the commit chain, never a stored field). `None` when the
+ /// ref or its commit cannot be read.
+ fn authorship(&self, id: &str) -> Option<(String, String)> {
+ let ref_name = ents_model::namespace::comment_ref(id).ok()?;
+ let tip = self.refs.get(ref_name.as_ref()).ok().flatten()?;
+ let mut buf = Vec::new();
+ let data = Find::try_find(&self.objects, &tip, &mut buf).ok()??;
+ let commit = CommitRef::from_bytes(data.data, tip.kind()).ok()?;
+ let author = commit.author().ok()?;
+ let name = author.name.to_string();
+ let when = author
+ .time()
+ .ok()
+ .and_then(|time| time.format(gix::date::time::format::SHORT).ok())
+ .unwrap_or_default();
+ Some((name, when))
+ }
+}
+
+/// The `(range, outdated)` a listed comment lands at, or `None` when it does
+/// not project onto the document (deleted, or unanchored).
+fn landed(row: &Listed) -> Option<(Range, bool)> {
+ let anchor = row.anchor.as_ref()?;
+ let projection = row.projection.as_ref()?;
+ render::landed_range(projection, anchor)
+}
+
+/// Whether `position`'s line falls within `range` — hovering anywhere on an
+/// anchored line reveals its thread.
+fn position_in(position: Position, range: Range) -> bool {
+ position.line >= range.start.line && position.line <= range.end.line
+}
+
+/// The 1-based inclusive `<start>:<end>` line span a selection covers,
+/// collapsing a trailing full-line boundary (`end` at column 0 of the next
+/// line) back onto the last selected line.
+fn selection_lines(range: Range) -> String {
+ let start = range.start.line.saturating_add(1);
+ let end = if range.end.character == 0 && range.end.line > range.start.line {
+ range.end.line
+ } else {
+ range.end.line.saturating_add(1)
+ };
+ format!("{start}:{end}")
+}
+
+/// Extract a single comment-id string argument.
+fn arg_id(arguments: &[Value]) -> Result<String> {
+ arguments
+ .first()
+ .and_then(Value::as_str)
+ .map(str::to_owned)
+ .ok_or_else(|| Error::BadArguments("expected a comment id argument".to_owned()))
+}
+
+/// Extract a [`Target`] from the `ents.compose` command's `{path,
+/// lines}` object argument.
+fn compose_target(arguments: &[Value]) -> Result<Target> {
+ let object = arguments
+ .first()
+ .ok_or_else(|| Error::BadArguments("compose needs a target".to_owned()))?;
+ Ok(Target {
+ path: object
+ .get("path")
+ .and_then(Value::as_str)
+ .map(str::to_owned),
+ lines: object
+ .get("lines")
+ .and_then(Value::as_str)
+ .map(str::to_owned),
+ parent: object
+ .get("parent")
+ .and_then(Value::as_str)
+ .map(str::to_owned),
+ })
+}
crates/cli/ents-lens/src/lib.rs
@@ -1,0 +1,102 @@
+//! The lens: an editor-facing Language Server Protocol surface over
+//! `refs/meta/comments/*`, projecting the repository's anchored comments
+//! into whatever buffer the user is reading and writing new ones back
+//! through the same signed mutation path every other frontend uses.
+//!
+//! # One responsibility
+//!
+//! This crate is the third place a git-ents conversation surfaces, after
+//! the CLI and the web UI, and it earns no third mechanism (`docs/spec/lens.adoc`):
+//! it is a read-time *view* over `refs/meta/*`, owning no state of its own,
+//! so a comment left in an editor, on the web, or by an agent at the CLI is
+//! one and the same entity everywhere. Every listing, projection, and write
+//! is the exact `ents_forge::comment` library call the `git ents comment`
+//! porcelain makes (`lens.parity`); the lens never shells out and never
+//! reimplements listing or projection. It is a frontend of the local root
+//! and receives its signing identity by injection (`lens.serve`,
+//! `roots.web-agnostic`), exactly as `ents-web` does.
+//!
+//! # Spec coverage (`docs/spec/lens.adoc`)
+//!
+//! - `lens.serve` — [`serve_stdio`], stdio only, no socket, no git
+//! transport; the signing identity is the injected [`Signing`].
+//! - `lens.lenses` — [`Lens::code_lenses`]: one View/Reply/Resolve lens set
+//! per open comment projecting onto the document, derived per request.
+//! - `lens.diagnostics` — [`Lens::diagnostics`]: the same comments as
+//! hint-severity diagnostics, never warnings or errors.
+//! - `lens.hover` — [`Lens::hover`]: the full thread as Markdown, authorship
+//! read from each ref's commit chain.
+//! - `lens.compose` — [`Lens::code_actions`] plus the compose flow in
+//! [`compose`]: a code action opens a git-style template file, saving a
+//! non-empty body creates the comment.
+//! - `lens.working-tree` — projection targets the working tree, the open
+//! buffer standing in for disk, re-projected on every change.
+//! - `lens.parity` — every operation is an `ents_forge::comment` call.
+//!
+//! # The compose-on-save mechanism
+//!
+//! Composing works entirely through standard LSP a plain client provides —
+//! `workspace/executeCommand`, `window/showDocument`, and
+//! `textDocument/didSave` — with no client-specific extension. The
+//! `ents.compose` command writes a git-commit-style template under
+//! `.git/ENTS_COMMENT_EDITMSG` and asks the client to open it; when the user
+//! saves it, the `didSave` handler creates the comment (or aborts on an
+//! empty body). See [`compose`] for the exact grammar and rationale.
+//!
+//! # Worked example
+//!
+//! Wire a lens against a fresh repository (as `git ents lsp`'s composition
+//! root does) and ask it for the code lenses on a document — none yet, since
+//! no comment has been written:
+//!
+//! ```
+//! use ents_lens::{Lens, Signing};
+//! use ents_receive::{Mode, NullEventSink};
+//! use ents_testutil::{MemRefStore, ObjectStore};
+//!
+//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
+//! let dir = tempfile::tempdir()?;
+//! gix::init(dir.path())?;
+//!
+//! // The composition root injects the signing identity (`lens.serve`); a
+//! // fixed fixture stands in for the user's own resolved key here.
+//! let signing = Signing::new(
+//! gix::actor::Signature {
+//! name: "jdc".into(),
+//! email: "jdc@ents.test".into(),
+//! time: gix::date::Time { seconds: 0, offset: 0 },
+//! },
+//! Box::new(|_payload| "-----BEGIN SSH SIGNATURE-----\n-----END SSH SIGNATURE-----\n".to_owned()),
+//! "ssh-ed25519 AAAA jdc".to_owned(),
+//! );
+//!
+//! let lens = Lens::new(
+//! Box::new(MemRefStore::default()),
+//! ObjectStore::default(),
+//! Box::new(NullEventSink),
+//! Mode::Advisory,
+//! signing,
+//! dir.path().to_owned(),
+//! );
+//!
+//! let uri = lsp_types::Url::from_file_path(dir.path().join("src/lib.rs")).unwrap();
+//! assert!(lens.code_lenses(&uri)?.is_empty());
+//! assert!(lens.diagnostics(&uri)?.is_empty());
+//! # Ok(())
+//! # }
+//! ```
+
+pub mod compose;
+mod document;
+mod error;
+mod lens;
+mod render;
+mod server;
+mod signing;
+
+pub use compose::{Composed, Target};
+pub use error::{Error, Result};
+pub use lens::{Lens, Outcome};
+pub use render::{CMD_COMPOSE, CMD_REPLY, CMD_RESOLVE, CMD_VIEW};
+pub use server::{capabilities, serve_stdio};
+pub use signing::Signing;
crates/cli/ents-lens/src/render.rs
@@ -1,0 +1,294 @@
+//! Turning projected comments and threads into the LSP values the lens
+//! publishes: code lenses (`lens.lenses`), hint diagnostics
+//! (`lens.diagnostics`), and hover markup (`lens.hover`).
+//!
+//! Pure rendering only — every input is already-derived data (a `Listed`
+//! row, a thread), so the mapping from a comment to its on-screen shape is
+//! unit-testable without a repository, and [`crate::Lens`] owns the
+//! per-request derivation that feeds it.
+
+use ents_anchor::{Anchor, LineRange, Projection};
+use ents_forge::comment::Comment;
+use lsp_types::{
+ CodeLens, Command, Diagnostic, DiagnosticSeverity, MarkupContent, MarkupKind, Position, Range,
+};
+use serde_json::json;
+
+/// The `workspace/executeCommand` command that opens the thread
+/// (`lens.lenses`: the view operation).
+pub const CMD_VIEW: &str = "ents.view";
+/// The command that starts a reply compose (`lens.lenses`, `lens.compose`).
+pub const CMD_REPLY: &str = "ents.reply";
+/// The command that resolves a comment (`lens.lenses`,
+/// `model.comment-state`).
+pub const CMD_RESOLVE: &str = "ents.resolve";
+/// The command that opens the compose template for a new comment
+/// (`lens.compose`).
+pub const CMD_COMPOSE: &str = "ents.compose";
+
+/// The diagnostic/lens source label the lens stamps every item with, so a
+/// client can suppress just the conversation (`lens.diagnostics`).
+pub const SOURCE: &str = "ents";
+
+/// Where a projected comment lands on the open document, and whether its
+/// anchored lines were edited out from under it (`Projection::Outdated`) —
+/// `None` when the comment does not project onto the document at all
+/// (`Projection::Deleted`, or no anchor), so the caller omits it
+/// (`lens.lenses`).
+#[must_use]
+pub fn landed_range(projection: &Projection, anchor: &Anchor) -> Option<(Range, bool)> {
+ match projection {
+ Projection::Current => Some((line_range(anchor.lines), false)),
+ Projection::Relocated { lines, .. } => Some((line_range(*lines), false)),
+ Projection::Outdated { .. } => Some((line_range(anchor.lines), true)),
+ Projection::Deleted => None,
+ }
+}
+
+/// The half-open LSP [`Range`] covering a 1-based inclusive line range, or
+/// the document's first line for a whole-file anchor (`lines` is `None`).
+fn line_range(lines: Option<LineRange>) -> Range {
+ let (start, end) = match lines {
+ Some(range) => (
+ to_u32(range.start.saturating_sub(1)),
+ to_u32(range.end.saturating_sub(1)),
+ ),
+ None => (0, 0),
+ };
+ Range {
+ start: Position {
+ line: start,
+ character: 0,
+ },
+ // Extend to the end of the last line so a diagnostic underlines the
+ // whole anchored region; clients clamp the character to line length.
+ end: Position {
+ line: end,
+ character: u32::MAX,
+ },
+ }
+}
+
+fn to_u32(value: u64) -> u32 {
+ u32::try_from(value).unwrap_or(u32::MAX)
+}
+
+/// A one-line summary of a comment body for a lens title or diagnostic
+/// message: the first non-empty line, trimmed and capped so it fits inline.
+#[must_use]
+pub fn summary(body: &str) -> String {
+ const CAP: usize = 60;
+ let first = body
+ .lines()
+ .find(|line| !line.trim().is_empty())
+ .unwrap_or("")
+ .trim();
+ let mut chars = first.chars();
+ let capped: String = chars.by_ref().take(CAP).collect();
+ if chars.next().is_some() {
+ format!("{capped}…")
+ } else {
+ capped
+ }
+}
+
+/// The code lenses for one open root comment at `range` (`lens.lenses`):
+/// a primary lens identifying the comment and summarizing its body, then a
+/// Reply and a Resolve lens — the thread's operations offered as commands
+/// that call the same library operations the CLI exposes (`lens.parity`).
+#[must_use]
+pub fn code_lenses(id: &str, comment: &Comment, range: Range, outdated: bool) -> Vec<CodeLens> {
+ let mut title = format!("💬 {}: {}", short(id), summary(&comment.body));
+ if outdated {
+ title.push_str(" (outdated)");
+ }
+ let arg = vec![json!(id)];
+ vec![
+ CodeLens {
+ range,
+ command: Some(Command {
+ title,
+ command: CMD_VIEW.to_owned(),
+ arguments: Some(arg.clone()),
+ }),
+ data: None,
+ },
+ CodeLens {
+ range,
+ command: Some(Command {
+ title: "Reply".to_owned(),
+ command: CMD_REPLY.to_owned(),
+ arguments: Some(arg.clone()),
+ }),
+ data: None,
+ },
+ CodeLens {
+ range,
+ command: Some(Command {
+ title: "Resolve".to_owned(),
+ command: CMD_RESOLVE.to_owned(),
+ arguments: Some(arg),
+ }),
+ data: None,
+ },
+ ]
+}
+
+/// The hint-severity diagnostic mirroring one open comment at `range`
+/// (`lens.diagnostics`): the same conversation the code lens carries, for
+/// clients that do not render lenses. Never a warning or an error.
+#[must_use]
+pub fn diagnostic(id: &str, comment: &Comment, range: Range, outdated: bool) -> Diagnostic {
+ let mut message = format!("{}: {}", short(id), summary(&comment.body));
+ if outdated {
+ message.push_str(" (outdated — the anchored lines changed)");
+ }
+ Diagnostic {
+ range,
+ // `lens.diagnostics` is binding: conversation carries no judgment,
+ // so this is always a hint, never a warning or error.
+ severity: Some(DiagnosticSeverity::HINT),
+ code: None,
+ code_description: None,
+ source: Some(SOURCE.to_owned()),
+ message,
+ related_information: None,
+ tags: None,
+ data: None,
+ }
+}
+
+/// The hover markup for a thread (`lens.hover`): every comment in it —
+/// bodies, states, and authorship — rendered as Markdown so the whole
+/// conversation is readable in the buffer. `rows` are `(id, comment,
+/// author, when)` in thread order; `author`/`when` come from each ref's
+/// mutation commit chain (`meta-ref.identity-binding`), read by the caller.
+#[must_use]
+pub fn hover_markup(rows: &[(String, Comment, String, String)]) -> MarkupContent {
+ let mut value = String::new();
+ for (index, (id, comment, author, when)) in rows.iter().enumerate() {
+ if index > 0 {
+ value.push_str("\n---\n\n");
+ }
+ let reply = if comment.parent.is_some() { "↳ " } else { "" };
+ value.push_str(&format!(
+ "**{reply}{author}** · `{}` · _{}_ · {when}\n\n",
+ comment.state,
+ short(id)
+ ));
+ value.push_str(comment.body.trim());
+ value.push('\n');
+ }
+ MarkupContent {
+ kind: MarkupKind::Markdown,
+ value,
+ }
+}
+
+/// A comment id shortened for display — the first seven characters, the
+/// same length git uses for a short object id.
+fn short(id: &str) -> &str {
+ id.get(..7).unwrap_or(id)
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::unwrap_used, reason = "unit test")]
+
+ use super::*;
+
+ fn comment(body: &str, state: &str) -> Comment {
+ Comment {
+ body: body.to_owned(),
+ state: state.to_owned(),
+ anchor: None,
+ context: None,
+ parent: None,
+ }
+ }
+
+ #[test]
+ // @relation(lens.diagnostics, scope=function, role=Verifies)
+ fn a_diagnostic_is_always_a_hint() {
+ let diag = diagnostic(
+ "abc1234def",
+ &comment("hi", "open"),
+ line_range(None),
+ false,
+ );
+ assert_eq!(diag.severity, Some(DiagnosticSeverity::HINT));
+ assert_eq!(diag.source.as_deref(), Some(SOURCE));
+ }
+
+ #[test]
+ // @relation(lens.lenses, scope=function, role=Verifies)
+ fn lenses_offer_view_reply_resolve() {
+ let lenses = code_lenses(
+ "abc1234def",
+ &comment("body text", "open"),
+ line_range(None),
+ false,
+ );
+ let commands: Vec<&str> = lenses
+ .iter()
+ .filter_map(|lens| lens.command.as_ref().map(|c| c.command.as_str()))
+ .collect();
+ assert_eq!(commands, vec![CMD_VIEW, CMD_REPLY, CMD_RESOLVE]);
+ let primary = lenses.first().unwrap().command.as_ref().unwrap();
+ assert!(primary.title.contains("body text"));
+ }
+
+ #[test]
+ fn summary_caps_and_takes_the_first_nonempty_line() {
+ assert_eq!(summary("\n\nfirst real line\nsecond"), "first real line");
+ let long = "x".repeat(80);
+ assert!(summary(&long).ends_with('…'));
+ }
+
+ #[test]
+ // @relation(anchor.projection, scope=function, role=Verifies)
+ fn deleted_projection_does_not_land() {
+ let anchor_lines = None;
+ let range = line_range(anchor_lines);
+ // Current lands; Deleted does not.
+ assert!(landed_range(&Projection::Current, &fake_anchor()).is_some());
+ assert!(landed_range(&Projection::Deleted, &fake_anchor()).is_none());
+ let _ = range;
+ }
+
+ fn fake_anchor() -> Anchor {
+ // A whole-file anchor is enough for `landed_range`, which only reads
+ // `anchor.lines`.
+ let dir = tempfile::tempdir().unwrap();
+ std::process::Command::new("git")
+ .arg("init")
+ .arg("-q")
+ .arg(dir.path())
+ .status()
+ .unwrap();
+ std::fs::write(dir.path().join("f.txt"), "a\n").unwrap();
+ std::process::Command::new("git")
+ .arg("-C")
+ .arg(dir.path())
+ .args(["add", "-A"])
+ .status()
+ .unwrap();
+ std::process::Command::new("git")
+ .arg("-C")
+ .arg(dir.path())
+ .args([
+ "-c",
+ "user.name=t",
+ "-c",
+ "user.email=t@e.com",
+ "commit",
+ "-q",
+ "-m",
+ "x",
+ ])
+ .status()
+ .unwrap();
+ let repo = gix::open(dir.path()).unwrap();
+ ents_anchor::capture(&repo, "HEAD", "f.txt", None).unwrap()
+ }
+}
crates/cli/ents-lens/src/server.rs
@@ -1,0 +1,353 @@
+//! The stdio Language Server Protocol adapter (`lens.serve`): a thin,
+//! synchronous dispatch loop over `lsp-server` that forwards each request to
+//! a [`Lens`] method and turns the [`Outcome`] back into LSP messages.
+//!
+//! All derivation lives in [`Lens`]; this module only frames JSON-RPC,
+//! declares capabilities, and routes. It binds no socket and adds no git
+//! transport (`lens.serve`) — the only IO is stdin/stdout.
+//!
+//! `lsp-server` (rust-analyzer's own scaffold) is synchronous, which suits
+//! the lens exactly: every operation it performs — reading `refs/meta/*`,
+//! diffing against the working tree, writing a signed commit — is blocking
+//! git and filesystem work, so an async runtime would only wrap blocking
+//! calls in `spawn_blocking` for no gain. The framing and pack of message
+//! types come from the crate; nothing here hand-rolls JSON-RPC.
+#![expect(
+ clippy::let_underscore_must_use,
+ reason = "sending on the LSP transport is best-effort: a closed pipe ends the loop on the \
+ next receive, so a failed send needs no separate handling"
+)]
+
+use gix_object::{Find, Write};
+use lsp_server::{Connection, ExtractError, Message, Request, RequestId, Response};
+use lsp_types::notification::{
+ DidChangeTextDocument, DidCloseTextDocument, DidOpenTextDocument, DidSaveTextDocument,
+ Notification as _, PublishDiagnostics,
+};
+use lsp_types::request::{
+ CodeActionRequest, CodeLensRequest, ExecuteCommand, HoverRequest, Request as _, ShowDocument,
+};
+use lsp_types::{
+ CodeActionProviderCapability, CodeLensOptions, DidChangeTextDocumentParams,
+ DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams,
+ ExecuteCommandOptions, HoverProviderCapability, PublishDiagnosticsParams, ServerCapabilities,
+ ShowDocumentParams, TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions,
+ TextDocumentSyncSaveOptions, Url,
+};
+
+use crate::lens::{Lens, Outcome};
+use crate::render;
+
+/// The capabilities the lens advertises (`lens.serve`): full-text document
+/// sync with save notifications (the compose flow needs the save,
+/// `lens.compose`), code lenses (`lens.lenses`), hover (`lens.hover`), code
+/// actions (`lens.compose`), and the four executable commands
+/// (`lens.lenses`, `lens.compose`). No workspace, symbol, or completion
+/// surface — the lens is a conversation view, not a language analyzer.
+#[must_use]
+pub fn capabilities() -> ServerCapabilities {
+ ServerCapabilities {
+ text_document_sync: Some(TextDocumentSyncCapability::Options(
+ TextDocumentSyncOptions {
+ open_close: Some(true),
+ change: Some(TextDocumentSyncKind::FULL),
+ save: Some(TextDocumentSyncSaveOptions::Supported(true)),
+ ..TextDocumentSyncOptions::default()
+ },
+ )),
+ code_lens_provider: Some(CodeLensOptions {
+ resolve_provider: Some(false),
+ }),
+ hover_provider: Some(HoverProviderCapability::Simple(true)),
+ code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
+ execute_command_provider: Some(ExecuteCommandOptions {
+ commands: vec![
+ render::CMD_VIEW.to_owned(),
+ render::CMD_REPLY.to_owned(),
+ render::CMD_RESOLVE.to_owned(),
+ render::CMD_COMPOSE.to_owned(),
+ ],
+ work_done_progress_options: lsp_types::WorkDoneProgressOptions::default(),
+ }),
+ ..ServerCapabilities::default()
+ }
+}
+
+/// Serve the lens over stdio until the client shuts it down (`lens.serve`).
+///
+/// Performs the LSP initialize handshake advertising [`capabilities`], then
+/// runs the dispatch loop. Binds no socket and speaks only stdin/stdout.
+///
+/// # Errors
+///
+/// [`std::io::Error`] if the JSON-RPC transport fails (a broken pipe, a
+/// malformed frame) or the initialize handshake does not complete.
+pub fn serve_stdio<O: Find + Write>(lens: Lens<O>) -> std::io::Result<()> {
+ let (connection, io_threads) = Connection::stdio();
+ let capabilities = serde_json::to_value(capabilities())
+ .map_err(|source| std::io::Error::other(source.to_string()))?;
+ let _init_params = connection
+ .initialize(capabilities)
+ .map_err(|source| std::io::Error::other(source.to_string()))?;
+ let mut server = ServerLoop { connection, lens };
+ server.run()?;
+ // Drop the connection (and with it the writer's channel sender) before
+ // joining: the IO writer thread only terminates once its sender is gone,
+ // so joining while `server` still holds the connection would hang here
+ // forever.
+ drop(server);
+ io_threads.join()?;
+ Ok(())
+}
+
+/// The running dispatch loop: owns the connection and the lens, and a
+/// counter for the ids of the server-initiated requests it sends (only
+/// `window/showDocument`).
+struct ServerLoop<O> {
+ connection: Connection,
+ lens: Lens<O>,
+}
+
+impl<O: Find + Write> ServerLoop<O> {
+ fn run(&mut self) -> std::io::Result<()> {
+ // `iter()` yields until the client closes the connection; a
+ // `shutdown` request breaks the loop through `handle_shutdown`.
+ while let Ok(message) = self.connection.receiver.recv() {
+ match message {
+ Message::Request(request) => {
+ if self
+ .connection
+ .handle_shutdown(&request)
+ .map_err(|source| std::io::Error::other(source.to_string()))?
+ {
+ break;
+ }
+ self.on_request(request);
+ }
+ Message::Notification(notification) => self.on_notification(notification),
+ // Responses to our own `window/showDocument` requests carry
+ // nothing the lens needs to act on.
+ Message::Response(_) => {}
+ }
+ }
+ Ok(())
+ }
+
+ /// Route one request to a [`Lens`] read handler, replying with its
+ /// result or an error response.
+ fn on_request(&mut self, request: Request) {
+ let request = match self.request::<CodeLensRequest, _>(request, |lens, params| {
+ lens.code_lenses(¶ms.text_document.uri).map(Some)
+ }) {
+ Ok(()) => return,
+ Err(request) => request,
+ };
+ let request = match self.request::<HoverRequest, _>(request, |lens, params| {
+ let position = params.text_document_position_params;
+ lens.hover(&position.text_document.uri, position.position)
+ }) {
+ Ok(()) => return,
+ Err(request) => request,
+ };
+ let request = match self.request::<CodeActionRequest, _>(request, |lens, params| {
+ lens.code_actions(¶ms.text_document.uri, params.range)
+ .map(Some)
+ }) {
+ Ok(()) => return,
+ Err(request) => request,
+ };
+ // `executeCommand` is the one request with side effects, handled on
+ // its own so its `Outcome` can drive `showDocument` and refreshes.
+ let request = match self.on_execute_command(request) {
+ Ok(()) => return,
+ Err(request) => request,
+ };
+ // Any other request: an empty success, so a client probing an
+ // unsupported method gets a well-formed (null) reply, never a hang.
+ self.respond(Response::new_ok(request.id, serde_json::Value::Null));
+ }
+
+ /// Extract and answer a plain read request `R`, returning `Err(request)`
+ /// unchanged when it is a different method so the caller can try the
+ /// next.
+ fn request<R, F>(&mut self, request: Request, handle: F) -> Result<(), Request>
+ where
+ R: lsp_types::request::Request,
+ F: FnOnce(&Lens<O>, R::Params) -> crate::error::Result<R::Result>,
+ R::Result: serde::Serialize,
+ {
+ match request.extract::<R::Params>(R::METHOD) {
+ Ok((id, params)) => {
+ let response = match handle(&self.lens, params) {
+ Ok(result) => Response::new_ok(id, result),
+ Err(error) => error_response(id, &error),
+ };
+ self.respond(response);
+ Ok(())
+ }
+ Err(ExtractError::MethodMismatch(request)) => Err(request),
+ Err(ExtractError::JsonError { .. }) => {
+ // A malformed params payload for a method we do own: there is
+ // no id to reply against cleanly here, so drop it — the
+ // client will observe the missing response.
+ Ok(())
+ }
+ }
+ }
+
+ /// Handle `workspace/executeCommand`, applying the [`Outcome`]: reply
+ /// with its result value, open the compose template if it named one, and
+ /// republish diagnostics when a mutation invalidated them.
+ fn on_execute_command(&mut self, request: Request) -> Result<(), Request> {
+ match request.extract::<<ExecuteCommand as lsp_types::request::Request>::Params>(
+ ExecuteCommand::METHOD,
+ ) {
+ Ok((id, params)) => {
+ match self
+ .lens
+ .execute_command(¶ms.command, ¶ms.arguments)
+ {
+ Ok(outcome) => {
+ let value = outcome.response.clone().unwrap_or(serde_json::Value::Null);
+ self.respond(Response::new_ok(id, value));
+ self.apply(outcome);
+ }
+ Err(error) => self.respond(error_response(id, &error)),
+ }
+ Ok(())
+ }
+ Err(ExtractError::MethodMismatch(request)) => Err(request),
+ Err(ExtractError::JsonError { .. }) => Ok(()),
+ }
+ }
+
+ /// Route one notification to the matching [`Lens`] document or save
+ /// handler, republishing diagnostics as the sync events demand.
+ fn on_notification(&mut self, notification: lsp_server::Notification) {
+ match notification.method.as_str() {
+ DidOpenTextDocument::METHOD => {
+ if let Ok(params) = extract_notification::<DidOpenTextDocumentParams>(notification)
+ {
+ let uri = params.text_document.uri.clone();
+ self.lens.did_open(uri.clone(), params.text_document.text);
+ self.publish(&uri);
+ }
+ }
+ DidChangeTextDocument::METHOD => {
+ if let Ok(params) =
+ extract_notification::<DidChangeTextDocumentParams>(notification)
+ {
+ let uri = params.text_document.uri.clone();
+ // Full sync: the last change carries the whole buffer.
+ if let Some(change) = params.content_changes.into_iter().next_back() {
+ self.lens.did_change(uri.clone(), change.text);
+ }
+ self.publish(&uri);
+ }
+ }
+ DidCloseTextDocument::METHOD => {
+ if let Ok(params) = extract_notification::<DidCloseTextDocumentParams>(notification)
+ {
+ self.lens.did_close(¶ms.text_document.uri);
+ // Clear this document's diagnostics on close.
+ self.publish_list(¶ms.text_document.uri, Vec::new());
+ }
+ }
+ DidSaveTextDocument::METHOD => {
+ if let Ok(params) = extract_notification::<DidSaveTextDocumentParams>(notification)
+ {
+ match self.lens.did_save(¶ms.text_document.uri) {
+ Ok(outcome) => self.apply(outcome),
+ Err(error) => log(&format!("didSave: {error}")),
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+
+ /// Apply an [`Outcome`]'s side effects: open the compose template and/or
+ /// republish diagnostics for every open document.
+ fn apply(&mut self, outcome: Outcome) {
+ if let Some(path) = outcome.show_document
+ && let Some(uri) = crate::document::file_uri(&path)
+ {
+ self.show_document(uri);
+ }
+ if outcome.refresh {
+ for uri in self.lens.open_documents() {
+ self.publish(&uri);
+ }
+ }
+ }
+
+ /// Compute and publish diagnostics for one document (`lens.diagnostics`).
+ fn publish(&self, uri: &Url) {
+ match self.lens.diagnostics_for(uri) {
+ Ok(diagnostics) => self.publish_list(uri, diagnostics),
+ Err(error) => log(&format!("diagnostics for {uri}: {error}")),
+ }
+ }
+
+ /// Send a `textDocument/publishDiagnostics` notification.
+ fn publish_list(&self, uri: &Url, diagnostics: Vec<lsp_types::Diagnostic>) {
+ let params = PublishDiagnosticsParams {
+ uri: uri.clone(),
+ diagnostics,
+ version: None,
+ };
+ let notification =
+ lsp_server::Notification::new(PublishDiagnostics::METHOD.to_owned(), params);
+ let _ = self
+ .connection
+ .sender
+ .send(Message::Notification(notification));
+ }
+
+ /// Ask the client to open `uri` (`window/showDocument`) — the compose
+ /// template (`lens.compose`).
+ fn show_document(&self, uri: Url) {
+ let params = ShowDocumentParams {
+ uri,
+ external: Some(false),
+ take_focus: Some(true),
+ selection: None,
+ };
+ let request = Request {
+ // A fixed id: the lens never correlates showDocument responses,
+ // and only one is ever in flight per user action.
+ id: RequestId::from("ents-show-document".to_owned()),
+ method: ShowDocument::METHOD.to_owned(),
+ params: serde_json::to_value(params).unwrap_or(serde_json::Value::Null),
+ };
+ let _ = self.connection.sender.send(Message::Request(request));
+ }
+
+ fn respond(&self, response: Response) {
+ let _ = self.connection.sender.send(Message::Response(response));
+ }
+}
+
+/// Build an LSP error response from a lens error, so a failing request gets
+/// a well-formed fault rather than a dropped reply.
+fn error_response(id: RequestId, error: &crate::error::Error) -> Response {
+ Response::new_err(
+ id,
+ lsp_server::ErrorCode::RequestFailed as i32,
+ error.to_string(),
+ )
+}
+
+/// Deserialize a notification's params, returning `Err` on a mismatch or a
+/// malformed payload.
+fn extract_notification<P: serde::de::DeserializeOwned>(
+ notification: lsp_server::Notification,
+) -> Result<P, ()> {
+ serde_json::from_value(notification.params).map_err(|_error| ())
+}
+
+/// Emit a diagnostic line on stderr — the lens's own log channel, since
+/// stdout carries the LSP framing.
+fn log(message: &str) {
+ eprintln!("ents-lens: {message}");
+}
crates/cli/ents-lens/src/signing.rs
@@ -1,0 +1,82 @@
+//! The signing identity the composition root injects into the lens
+//! (`lens.serve`, `roots.web-agnostic`).
+//!
+//! The lens writes new comments through the same signed mutation path
+//! every other frontend uses (`lens.parity`), so it must be handed an
+//! identity to sign with — but, exactly like `ents-web`, it resolves no
+//! key itself and assumes nothing about which editor (if any) is attached.
+//! [`Signing`] is a plain owned value the root builds once and moves in;
+//! there is no second implementation to abstract over the way `ents-web`'s
+//! hosted/local split needs, because a lens only ever serves the local
+//! root (`lens.serve`), so a concrete carrier is enough and no trait is
+//! introduced.
+
+/// A closure that signs a commit's to-be-signed bytes, producing an armored
+/// SSHSIG PEM block — the injected half of [`Signing`].
+pub type SignFn = Box<dyn Fn(&[u8]) -> String>;
+
+/// An owned signing identity: the commit author signature and a closure
+/// that produces an SSHSIG armored block for a commit's bytes, plus the
+/// public key that identifies the acting member.
+///
+/// Built by the composition root from the user's own key (the same
+/// resolution `git ents comment` and `git ents serve` perform) and moved
+/// into the [`crate::Lens`]; the lens never resolves a key path or reads
+/// `user.signingkey` itself.
+///
+/// # Examples
+///
+/// ```
+/// use ents_lens::Signing;
+///
+/// let signing = Signing::new(
+/// gix::actor::Signature {
+/// name: "jdc".into(),
+/// email: "jdc@ents.test".into(),
+/// time: gix::date::Time { seconds: 0, offset: 0 },
+/// },
+/// Box::new(|_payload| "-----BEGIN SSH SIGNATURE-----\n-----END SSH SIGNATURE-----\n".to_owned()),
+/// "ssh-ed25519 AAAA... jdc".to_owned(),
+/// );
+/// assert_eq!(signing.actor().name, "jdc");
+/// ```
+pub struct Signing {
+ actor: gix::actor::Signature,
+ sign: SignFn,
+ public_openssh: String,
+}
+
+impl Signing {
+ /// Build a signing identity from an already-resolved key: the commit
+ /// `actor` signature, a `sign` closure over the key, and the key's
+ /// `public_openssh` single-line form.
+ #[must_use]
+ pub fn new(actor: gix::actor::Signature, sign: SignFn, public_openssh: String) -> Self {
+ Self {
+ actor,
+ sign,
+ public_openssh,
+ }
+ }
+
+ /// The commit author/committer signature every comment mutation this
+ /// identity signs will carry.
+ #[must_use]
+ pub fn actor(&self) -> gix::actor::Signature {
+ self.actor.clone()
+ }
+
+ /// The public half of this identity's key, in OpenSSH single-line
+ /// form — which enrolled member is acting.
+ #[must_use]
+ pub fn public_openssh(&self) -> &str {
+ &self.public_openssh
+ }
+
+ /// Sign `payload` (a commit's to-be-signed bytes), returning the
+ /// armored SSHSIG PEM block for the commit's `gpgsig` header.
+ #[must_use]
+ pub fn sign(&self, payload: &[u8]) -> String {
+ (self.sign)(payload)
+ }
+}
crates/cli/ents-lens/tests/lens.rs
@@ -1,0 +1,408 @@
+//! Integration coverage for `docs/spec/lens.adoc`, driving the [`Lens`]
+//! request handlers directly against a fixture repository — the strategy
+//! the engineering conventions select for a protocol surface: construct the
+//! server in-process with a real working tree and a comment anchored into
+//! it, then assert each handler's derived LSP value, rather than spawning a
+//! stdio process and parsing frames. The JSON-RPC framing is `lsp-server`'s
+//! own tested concern; what this crate owns is the derivation, so that is
+//! what these tests exercise.
+//!
+//! The seams are `ents-testutil`'s in-memory `MemRefStore`/`ObjectStore`
+//! (the same pair every library crate's tests use) paired with a real
+//! on-disk repository for the working tree the anchors project onto —
+//! `ents_forge::comment::add` embeds the anchored bytes into the object
+//! store, so the two stay consistent even though only one is on disk.
+
+#![allow(
+ clippy::expect_used,
+ clippy::unwrap_used,
+ clippy::indexing_slicing,
+ clippy::panic,
+ reason = "integration test"
+)]
+
+use std::path::Path;
+use std::process::Command;
+
+use ents_forge::comment::{self, NewComment};
+use ents_lens::{CMD_COMPOSE, CMD_RESOLVE, CMD_VIEW, Lens, Signing};
+use ents_receive::{Identity, Mode, NullEventSink};
+use ents_testutil::{Keypair, MemRefStore, ObjectStore};
+use lsp_types::{DiagnosticSeverity, HoverContents, Position, Range, Url};
+use serde_json::json;
+
+/// A fixture repository, its in-memory seams, and a deterministic signing
+/// key — everything a [`Lens`] needs to be wired the way `git ents lsp`
+/// wires it.
+struct Fixture {
+ dir: tempfile::TempDir,
+ refs: MemRefStore,
+ objects: ObjectStore,
+ key: Keypair,
+}
+
+impl Fixture {
+ /// A repository holding `file.txt` with ten numbered lines, committed.
+ fn new() -> Self {
+ let dir = tempfile::tempdir().expect("tempdir");
+ gix::init(dir.path()).expect("init");
+ let contents: String = (1..=10).map(|n| format!("line {n}\n")).collect();
+ commit_file(dir.path(), "file.txt", &contents);
+ Self {
+ dir,
+ refs: MemRefStore::default(),
+ objects: ObjectStore::default(),
+ key: Keypair::from_seed(1),
+ }
+ }
+
+ fn uri(&self, rel: &str) -> Url {
+ Url::from_file_path(self.dir.path().join(rel)).expect("file uri")
+ }
+
+ fn actor(&self) -> gix::actor::Signature {
+ gix::actor::Signature {
+ name: "jdc".into(),
+ email: "jdc@ents.test".into(),
+ time: gix::date::Time {
+ seconds: 1_000,
+ offset: 0,
+ },
+ }
+ }
+
+ /// Add a comment through the same library call the CLI makes
+ /// (`lens.parity`), anchored to `lines` of `file.txt` against the
+ /// working tree.
+ fn add_comment(&self, body: &str, lines: Option<&str>) -> String {
+ let new = NewComment {
+ body: body.to_owned(),
+ path: Some("file.txt".to_owned()),
+ lines: lines.map(str::to_owned),
+ rev: "HEAD".to_owned(),
+ worktree: true,
+ context: None,
+ parent: None,
+ };
+ let key = &self.key;
+ let sign = |payload: &[u8]| key.sign(payload);
+ let identity = Identity {
+ actor: self.actor(),
+ sign: &sign,
+ };
+ let (id, _outcome) = comment::add(
+ &self.refs,
+ &self.objects,
+ &NullEventSink,
+ self.dir.path(),
+ new,
+ &identity,
+ Mode::Advisory,
+ )
+ .expect("adds a comment");
+ id
+ }
+
+ /// Consume the fixture into a wired [`Lens`] (the seams move in, exactly
+ /// as `git ents lsp`'s composition root moves `LocalRoot`'s seams in).
+ fn into_lens(self) -> (Lens<ObjectStore>, tempfile::TempDir) {
+ let key = Keypair::from_seed(1);
+ let signing = Signing::new(
+ self.actor(),
+ Box::new(move |payload| key.sign(payload)),
+ self.key.public_openssh(),
+ );
+ let lens = Lens::new(
+ Box::new(self.refs),
+ self.objects,
+ Box::new(NullEventSink),
+ Mode::Advisory,
+ signing,
+ self.dir.path().to_owned(),
+ );
+ (lens, self.dir)
+ }
+}
+
+fn commit_file(dir: &Path, path: &str, contents: &str) {
+ std::fs::write(dir.join(path), contents).expect("write");
+ run_git(dir, &["add", "-A"]);
+ run_git(
+ dir,
+ &[
+ "-c",
+ "user.name=test",
+ "-c",
+ "user.email=test@example.com",
+ "commit",
+ "-q",
+ "-m",
+ "seed",
+ ],
+ );
+}
+
+fn run_git(dir: &Path, args: &[&str]) {
+ let status = Command::new("git")
+ .arg("-C")
+ .arg(dir)
+ .args(args)
+ .status()
+ .expect("git runs");
+ assert!(status.success(), "git {args:?} failed");
+}
+
+/// `lens.lenses`: an open comment whose anchor projects onto the document
+/// surfaces as code lenses at its projected line, identifying the comment
+/// and offering View/Reply/Resolve as commands. `lens.diagnostics`: the
+/// same comment is also a hint-severity diagnostic at the same range.
+#[test]
+// @relation(lens.lenses, lens.diagnostics, scope=function, role=Verifies)
+fn code_lenses_and_hint_diagnostics_surface_an_open_comment() {
+ let fixture = Fixture::new();
+ fixture.add_comment("this looks off by one", Some("5:5"));
+ let uri = fixture.uri("file.txt");
+ let (lens, _dir) = fixture.into_lens();
+
+ let lenses = lens.code_lenses(&uri).expect("code lenses");
+ assert_eq!(lenses.len(), 3, "one View/Reply/Resolve set");
+ // Line 5 is 0-based line 4.
+ assert_eq!(lenses[0].range.start.line, 4);
+ let commands: Vec<&str> = lenses
+ .iter()
+ .filter_map(|lens| lens.command.as_ref().map(|c| c.command.as_str()))
+ .collect();
+ assert!(commands.contains(&CMD_VIEW));
+ assert!(commands.contains(&"ents.reply"));
+ assert!(commands.contains(&CMD_RESOLVE));
+ assert!(
+ lenses[0]
+ .command
+ .as_ref()
+ .unwrap()
+ .title
+ .contains("off by one")
+ );
+
+ let diagnostics = lens.diagnostics(&uri).expect("diagnostics");
+ assert_eq!(diagnostics.len(), 1);
+ // `lens.diagnostics` is binding: hint severity, never a warning/error.
+ assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::HINT));
+ assert_eq!(diagnostics[0].range.start.line, 4);
+}
+
+/// `lens.hover`: hovering the anchored range returns the whole thread —
+/// the root comment and its reply, bodies and authorship — as markup.
+#[test]
+// @relation(lens.hover, scope=function, role=Verifies)
+fn hover_returns_the_full_thread() {
+ let fixture = Fixture::new();
+ let root = fixture.add_comment("root remark", Some("5:5"));
+ // A reply, created through the same library the lens uses.
+ let key = Keypair::from_seed(1);
+ let sign = |payload: &[u8]| key.sign(payload);
+ let identity = Identity {
+ actor: fixture.actor(),
+ sign: &sign,
+ };
+ comment::reply(
+ &fixture.refs,
+ &fixture.objects,
+ &NullEventSink,
+ &root,
+ "a reply body".to_owned(),
+ &identity,
+ Mode::Advisory,
+ )
+ .expect("replies");
+ let uri = fixture.uri("file.txt");
+ let (lens, _dir) = fixture.into_lens();
+
+ let hover = lens
+ .hover(
+ &uri,
+ Position {
+ line: 4,
+ character: 0,
+ },
+ )
+ .expect("hover")
+ .expect("a comment is anchored at line 5");
+ let HoverContents::Markup(markup) = hover.contents else {
+ panic!("hover must be markup");
+ };
+ assert!(markup.value.contains("root remark"));
+ assert!(markup.value.contains("a reply body"));
+ assert!(
+ markup.value.contains("jdc"),
+ "authorship from the commit chain"
+ );
+
+ // Hovering an unrelated line yields nothing.
+ assert!(
+ lens.hover(
+ &uri,
+ Position {
+ line: 0,
+ character: 0
+ }
+ )
+ .expect("hover")
+ .is_none()
+ );
+}
+
+/// `lens.compose`: a code action on a selection offers "Leave an ents
+/// comment", whose command opens the template; running it writes the
+/// template under `.git/` and asks the client to open that file.
+#[test]
+// @relation(lens.compose, scope=function, role=Verifies)
+fn code_action_and_compose_open_the_template() {
+ let fixture = Fixture::new();
+ let uri = fixture.uri("file.txt");
+ let (lens, dir) = fixture.into_lens();
+
+ let range = Range {
+ start: Position {
+ line: 1,
+ character: 0,
+ },
+ end: Position {
+ line: 2,
+ character: 0,
+ },
+ };
+ let actions = lens.code_actions(&uri, range).expect("code actions");
+ assert_eq!(actions.len(), 1);
+ let lsp_types::CodeActionOrCommand::CodeAction(action) = &actions[0] else {
+ panic!("expected a code action");
+ };
+ assert_eq!(action.title, "Leave an ents comment");
+ let command = action.command.as_ref().expect("carries a command");
+ assert_eq!(command.command, CMD_COMPOSE);
+
+ // Running the command writes the template and asks to open it.
+ let outcome = lens
+ .execute_command(
+ CMD_COMPOSE,
+ &[json!({ "path": "file.txt", "lines": "2:2" })],
+ )
+ .expect("compose");
+ let template = outcome.show_document.expect("opens the template");
+ assert_eq!(
+ template,
+ dir.path().join(".git").join("ENTS_COMMENT_EDITMSG")
+ );
+ let written = std::fs::read_to_string(&template).expect("template written");
+ assert!(written.contains("ents-compose-path: file.txt"));
+ assert!(written.contains("Lines starting with '#' are ignored"));
+}
+
+/// `lens.compose` end to end: saving the template with a non-empty body
+/// creates the comment (anchored to the working tree, `lens.working-tree`),
+/// and it then surfaces as a code lens; an empty body aborts.
+#[test]
+// @relation(lens.compose, lens.working-tree, lens.parity, scope=function, role=Verifies)
+fn saving_a_nonempty_body_creates_the_comment_and_empty_aborts() {
+ let fixture = Fixture::new();
+ let uri = fixture.uri("file.txt");
+ let (lens, dir) = fixture.into_lens();
+ let template = dir.path().join(".git").join("ENTS_COMMENT_EDITMSG");
+ let template_uri = Url::from_file_path(&template).unwrap();
+
+ // Start a compose targeting line 3.
+ lens.execute_command(
+ CMD_COMPOSE,
+ &[json!({ "path": "file.txt", "lines": "3:3" })],
+ )
+ .expect("compose");
+
+ // An empty save aborts: no comment, template removed.
+ std::fs::write(
+ &template,
+ "\n# only comments here\n# ents-compose-path: file.txt\n# ents-compose-lines: 3:3\n",
+ )
+ .unwrap();
+ lens.did_save(&template_uri).expect("save");
+ assert!(lens.code_lenses(&uri).expect("lenses").is_empty());
+ assert!(!template.exists(), "aborted compose removes the template");
+
+ // Re-start and save a real body: the comment is created and surfaces.
+ lens.execute_command(
+ CMD_COMPOSE,
+ &[json!({ "path": "file.txt", "lines": "3:3" })],
+ )
+ .expect("compose");
+ std::fs::write(
+ &template,
+ "the third line is wrong\n# ignored\n# ents-compose-path: file.txt\n# ents-compose-lines: 3:3\n",
+ )
+ .unwrap();
+ lens.did_save(&template_uri).expect("save");
+
+ let lenses = lens.code_lenses(&uri).expect("lenses");
+ assert_eq!(lenses.len(), 3, "the composed comment now surfaces");
+ assert_eq!(lenses[0].range.start.line, 2, "anchored at line 3");
+ assert!(
+ lenses[0]
+ .command
+ .as_ref()
+ .unwrap()
+ .title
+ .contains("third line is wrong")
+ );
+}
+
+/// `lens.parity` + `model.comment-state`: View returns the thread, and
+/// Resolve — the same library call the CLI runs — drops the comment from
+/// the next publish, since only open comments surface (`lens.lenses`).
+#[test]
+// @relation(lens.parity, lens.lenses, scope=function, role=Verifies)
+fn view_returns_the_thread_and_resolve_hides_it() {
+ let fixture = Fixture::new();
+ let id = fixture.add_comment("please fix", Some("5:5"));
+ let uri = fixture.uri("file.txt");
+ let (lens, _dir) = fixture.into_lens();
+
+ let view = lens
+ .execute_command(CMD_VIEW, &[json!(id)])
+ .expect("view")
+ .response
+ .expect("view returns the thread");
+ assert!(view.as_str().unwrap().contains("please fix"));
+
+ // Resolve, then the open-only publish no longer shows it.
+ let outcome = lens
+ .execute_command(CMD_RESOLVE, &[json!(id)])
+ .expect("resolve");
+ assert!(outcome.refresh, "a mutation asks for a diagnostics refresh");
+ assert!(lens.code_lenses(&uri).expect("lenses").is_empty());
+ assert!(lens.diagnostics(&uri).expect("diags").is_empty());
+}
+
+/// `lens.working-tree`: the open buffer stands in for disk, so a comment's
+/// range tracks unsaved edits — prepending two lines in the buffer shifts
+/// the projected lens down by two.
+#[test]
+// @relation(lens.working-tree, scope=function, role=Verifies)
+fn the_buffer_overrides_disk_so_ranges_track_unsaved_edits() {
+ let fixture = Fixture::new();
+ fixture.add_comment("watch this line", Some("5:5"));
+ let uri = fixture.uri("file.txt");
+ let (mut lens, _dir) = fixture.into_lens();
+
+ // On disk the anchor is line 5 (0-based 4).
+ let on_disk = lens.code_lenses(&uri).expect("lenses");
+ assert_eq!(on_disk[0].range.start.line, 4);
+
+ // The client sends a buffer with two extra lines prepended, unsaved.
+ let buffer: String = std::iter::once("added a".to_owned())
+ .chain(std::iter::once("added b".to_owned()))
+ .chain((1..=10).map(|n| format!("line {n}")))
+ .collect::<Vec<_>>()
+ .join("\n");
+ lens.did_open(uri.clone(), format!("{buffer}\n"));
+
+ let shifted = lens.code_lenses(&uri).expect("lenses");
+ assert_eq!(shifted[0].range.start.line, 6, "line 5 shifted to line 7");
+}
crates/cli/ents-web/Cargo.toml
@@ -1,0 +1,44 @@
+[package]
+name = "ents-web"
+version = "0.0.0"
+edition.workspace = true
+publish.workspace = true
+license.workspace = true
+
+[dependencies]
+acdc-converters-core = { workspace = true }
+acdc-converters-html = { workspace = true }
+acdc-parser = { workspace = true }
+arborium = { workspace = true }
+ents-anchor = { workspace = true }
+ents-effect = { workspace = true }
+ents-forge = { workspace = true }
+ents-gate = { workspace = true }
+ents-kiln = { workspace = true }
+ents-model = { workspace = true }
+ents-query = { workspace = true }
+ents-receive = { workspace = true }
+facet = { workspace = true }
+facet-git-tree = { workspace = true }
+facet-reflect = "0.50.0-rc.5"
+getrandom = { workspace = true }
+gix = { workspace = true }
+gix-hash = { workspace = true }
+gix-object = { workspace = true }
+gix-ref-store = { workspace = true }
+axum = { workspace = true }
+maud = { workspace = true }
+pulldown-cmark = { workspace = true }
+serde = { version = "1", features = ["derive"] }
+thiserror = { workspace = true }
+tokio = { workspace = true }
+
+[dev-dependencies]
+ents-testutil = { workspace = true }
+http-body-util = "0.1"
+rstest = { workspace = true }
+tempfile = { workspace = true }
+tower = { version = "0.5", default-features = false, features = ["util"] }
+
+[lints]
+workspace = true
crates/cli/ents-web/src/asciidoc.rs
@@ -1,0 +1,206 @@
+//! AsciiDoc rendering via the [`acdc`](https://github.com/nlopes/acdc) library.
+//!
+//! AsciiDoc gets the same treatment Markdown does (`crate::markdown`): an
+//! `.adoc`/`.asciidoc` blob in [`crate::pages::files`] renders as a
+//! formatted document rather than a plain-text listing. Output is the
+//! *embedded* fragment (no `<!DOCTYPE>`/`<html>` frame) so it can drop
+//! straight into a `.doc-body`-styled card
+//! (`crate::assets::OVERRIDES`).
+//!
+//! `acdc-converters-core` and `acdc-converters-html` are not on crates.io
+//! yet, so they are pinned as git dependencies on the same revision
+//! `pre-redo:Cargo.toml` pinned (see this crate's own `Cargo.toml`).
+
+use acdc_converters_core::{Converter, Options as ConvertOptions, inlines_to_string};
+use acdc_converters_html::{Processor, RenderOptions};
+use acdc_parser::Options as ParseOptions;
+use maud::{Markup, PreEscaped, html};
+
+use crate::error::{Error, Result};
+
+/// File extensions that name an AsciiDoc document.
+const EXTENSIONS: [&str; 4] = ["adoc", "asciidoc", "asc", "adc"];
+
+/// Whether `name` looks like an AsciiDoc file by its extension.
+#[must_use]
+pub(crate) fn is_asciidoc(name: &str) -> bool {
+ name.rsplit_once('.')
+ .is_some_and(|(_, ext)| EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e)))
+}
+
+/// Render AsciiDoc `source` to an embedded HTML fragment. The fragment
+/// carries no document frame, so callers place it inside their own
+/// container (`.doc-body`).
+///
+/// `acdc`'s embedded render mode omits both the document frame *and* the
+/// visible doctitle/subtitle, so this reconstructs them from the parsed
+/// header and prepends them to the embedded body -- carried over from
+/// `pre-redo:crates/git-ents-server/src/asciidoc.rs`'s own `to_html`,
+/// which hit the same gap: without this, a README's own `= Title` line
+/// would silently vanish from the rendered page.
+///
+/// The header's own attribute entries (`:name: value`) surface above the
+/// document as a key-value properties table
+/// ([`crate::render::properties_table`], the same component
+/// [`crate::markdown`] renders frontmatter through), read by
+/// [`header_attribute_entries`]'s own line scan of the source header --
+/// deliberately not `acdc`'s parsed `doc.attributes`, which folds the
+/// explicitly-written entries in with its ~80 defaults and exposes no
+/// explicit-only view to read back.
+///
+/// No sanitization is applied beyond what `acdc`'s HTML converter itself
+/// guarantees -- the pre-redo version did the same, emitting the
+/// converter's output unescaped via `maud::PreEscaped`.
+///
+/// # Errors
+///
+/// [`Error::Asciidoc`] if `source` cannot be parsed or converted.
+pub(crate) fn to_html(source: &str) -> Result<Markup> {
+ let parsed = acdc_parser::parse(source, &ParseOptions::default())
+ .map_err(|err| Error::Asciidoc(err.to_string()))?;
+ let doc = parsed.document();
+
+ let attributes = header_attribute_entries(source);
+ let heading = doc
+ .header
+ .as_ref()
+ .filter(|h| !h.title.is_empty())
+ .map(|h| {
+ let title = inlines_to_string(&h.title);
+ let subtitle = h.subtitle.as_ref().map(|s| inlines_to_string(s));
+ html! {
+ h1 { (title) }
+ @if let Some(subtitle) = subtitle {
+ p.doc-subtitle { (subtitle) }
+ }
+ }
+ });
+
+ let processor = Processor::new(ConvertOptions::default(), doc.attributes.clone());
+ let options = RenderOptions {
+ embedded: true,
+ ..RenderOptions::default()
+ };
+ let body = processor
+ .convert_to_string(doc, &options)
+ .map_err(|err| Error::Asciidoc(err.to_string()))?;
+ Ok(html! {
+ (crate::render::properties_table(&attributes))
+ @if let Some(heading) = heading { (heading) }
+ (PreEscaped(body))
+ })
+}
+
+/// The attribute entries (`:name: value`, or a bare/unset `:name:` /
+/// `:!name:`) written in `source`'s own document header -- the lines from
+/// the top of the document to its first blank line, where AsciiDoc allows
+/// attribute entries at all. A minimal line scan, for the reason
+/// [`to_html`]'s own doc gives: `acdc`'s parsed attribute map cannot say
+/// which entries the document actually wrote. Non-attribute header lines
+/// (the title, an author line, a `//` comment) are skipped; a bare
+/// `:name:` renders with an empty value and an unsetting `:name!:` (or
+/// `:!name:`) keeps its `!`, both verbatim -- this is a display of what
+/// the header says, not an evaluation of it.
+pub(crate) fn header_attribute_entries(source: &str) -> Vec<(String, String)> {
+ let mut entries = Vec::new();
+ for line in source.lines() {
+ let trimmed = line.trim_end();
+ if trimmed.trim().is_empty() {
+ break;
+ }
+ let Some(rest) = trimmed.strip_prefix(':') else {
+ continue;
+ };
+ let Some((name, value)) = rest.split_once(':') else {
+ continue;
+ };
+ let bare = name.trim_matches('!');
+ let is_name = !bare.is_empty()
+ && bare
+ .chars()
+ .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-'));
+ if is_name {
+ entries.push((name.to_owned(), value.trim().to_owned()));
+ }
+ }
+ entries
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ #[case::adoc("readme.adoc", true)]
+ #[case::asciidoc("readme.asciidoc", true)]
+ #[case::asc("notes.asc", true)]
+ #[case::upper("README.ADOC", true)]
+ #[case::md("readme.md", false)]
+ #[case::no_ext("readme", false)]
+ fn is_asciidoc_matches_by_extension(#[case] name: &str, #[case] expected: bool) {
+ assert_eq!(is_asciidoc(name), expected);
+ }
+
+ #[test]
+ fn to_html_reconstructs_the_doctitle_and_renders_a_paragraph() {
+ let rendered = to_html("= Title\n\nA paragraph.\n")
+ .expect("valid asciidoc")
+ .into_string();
+ assert!(rendered.contains("<h1>Title</h1>"));
+ assert!(rendered.contains("A paragraph."));
+ }
+
+ #[test]
+ fn to_html_reconstructs_a_subtitle() {
+ let rendered = to_html("= Title: Subtitle\n\nBody.\n")
+ .expect("valid asciidoc")
+ .into_string();
+ assert!(rendered.contains("<h1>Title</h1>"));
+ assert!(rendered.contains(r#"class="doc-subtitle""#));
+ assert!(rendered.contains("Subtitle"));
+ }
+
+ #[test]
+ fn to_html_surfaces_header_attributes_without_regressing_the_doctitle() {
+ let rendered = to_html("= Title\n:toc: left\n:experimental:\n\nBody.\n")
+ .expect("valid asciidoc")
+ .into_string();
+ assert!(
+ rendered.contains("doc-props"),
+ "the properties table renders"
+ );
+ assert!(rendered.contains("toc"));
+ assert!(rendered.contains("left"));
+ assert!(
+ rendered.contains("<h1>Title</h1>"),
+ "the reconstructed doctitle stays: {rendered}"
+ );
+ }
+
+ #[test]
+ fn header_attribute_entries_reads_only_the_header_block() {
+ let entries = header_attribute_entries(
+ "= Title\nAn Author <author@ents.test>\n:toc: left\n:experimental:\n:sectnums!:\n\n:not-header: too late\n",
+ );
+ assert_eq!(
+ entries,
+ vec![
+ ("toc".to_owned(), "left".to_owned()),
+ ("experimental".to_owned(), String::new()),
+ ("sectnums!".to_owned(), String::new()),
+ ]
+ );
+ }
+
+ #[rstest]
+ #[case::no_header_at_all("Just a paragraph.\n")]
+ #[case::title_only("= Title\n\nBody.\n")]
+ #[case::prose_colon_line("= Title\nnote: this is an author line, not an attribute\n\nBody.\n")]
+ fn header_attribute_entries_finds_none_where_none_are_written(#[case] source: &str) {
+ assert!(header_attribute_entries(source).is_empty());
+ }
+}
crates/cli/ents-web/src/assets.rs
@@ -1,0 +1,89 @@
+//! Static assets embedded at compile time so the built binary stays
+//! self-contained -- no runtime fetch, no separate asset bundle to ship
+//! alongside `git-ents`. `ents.css` is the hand-rolled pre-redo stylesheet
+//! (`pre-redo:crates/git-ents-server/src/web/style.css`), ported rather
+//! than vendored -- including its type stack, which this crate's own
+//! system-font fallback carries rather than the pre-redo Google Fonts load
+//! (see `ents.css`'s own header comment). `ents.js` is new to this crate
+//! (pre-redo had no client-side script at all): a vanilla,
+//! dependency-free progressive enhancement over `crate::pages::files`'s
+//! raw-source blob view -- click-to-select a line or range and an inline
+//! comment composer -- served alongside `ents.css` the same way, via
+//! `crate::router`'s own `GET /ents.js` route.
+//!
+//! The icon functions below are vendored Octicons (`.gitvendors`, MIT; see
+//! `assets/icons/LICENSE`), re-homed here from
+//! `pre-redo:crates/git-ents-server/src/web/icons/` for
+//! [`crate::pages::files`]'s directory listing and breadcrumbs -- the same
+//! `include_str!`-and-tag pattern
+//! `pre-redo:crates/git-ents-server/src/web/icons.rs` used. The workbench
+//! shell's own chrome (the `.rail` page-family icons, the `.palette`
+//! search glass, the `.branch` pill) draws from [`sprite`] instead: one
+//! hand-rolled `<symbol>` sprite embedded per page by
+//! `crate::pages::layout`, each use site a tiny [`icon_use`] reference
+//! rather than a repeated inline SVG. Every sprite symbol -- the `i-ed-*`
+//! editor marks `crate::pages`'s `editor_open` uses included -- is an
+//! original drawing in the sprite's own stroke style, never a vendored
+//! asset, so no third-party icon license applies to the sprite. The
+//! editor marks are simplified glyphs *evoking* each product's logo
+//! (shown nominatively, naming the editor `$ENTS_EDITOR`/`$EDITOR`
+//! already configured), not copies of the trademarked logo files; the
+//! Octicons under `assets/icons/` remain this module's only third-party
+//! assets.
+
+use std::sync::LazyLock;
+
+use maud::{Markup, PreEscaped};
+
+pub(crate) const OVERRIDES: &str = include_str!("assets/ents.css");
+
+/// The client-side line-selection/comment-composer script
+/// [`crate::router`]'s `GET /ents.js` serves -- see this module's own doc.
+pub(crate) const SCRIPT: &str = include_str!("assets/ents.js");
+
+/// Adapt a vendored Octicon to this UI: tag it with the `.icon` class the
+/// stylesheet targets and mark it decorative for assistive tech. Every
+/// vendored file opens with a bare `<svg …>` element, so a single prefix
+/// swap suffices (mirrors
+/// `pre-redo:crates/git-ents-server/src/web/icons.rs`'s own `inline`).
+fn inline(svg: &str) -> String {
+ svg.replacen("<svg ", "<svg class=\"icon\" aria-hidden=\"true\" ", 1)
+}
+
+/// Define an icon accessor per vendored Octicon file. Each prepares its
+/// inline markup once and hands out a cheap clone on use.
+macro_rules! icons {
+ ($($name:ident => $file:literal),* $(,)?) => {
+ $(
+ pub(crate) fn $name() -> Markup {
+ static HTML: LazyLock<String> =
+ LazyLock::new(|| inline(include_str!(concat!("assets/icons/", $file, ".svg"))));
+ PreEscaped(HTML.clone())
+ }
+ )*
+ };
+}
+
+icons! {
+ icon_folder => "file-directory-fill",
+ icon_file => "file",
+ icon_chevron => "chevron-right",
+}
+
+/// The workbench shell's inline `<symbol>` sprite (see this module's own
+/// doc) -- embedded once per page, right after `<body>`, so every
+/// [`icon_use`] reference on the page resolves against it.
+pub(crate) fn sprite() -> Markup {
+ PreEscaped(include_str!("assets/sprite.svg").to_owned())
+}
+
+/// An `.icon`-classed, decorative `<use>` reference into [`sprite`] --
+/// `id` names one of its `<symbol>`s (`i-home`, `i-files`, ...). Sized
+/// entirely by the use site's own CSS rule (`.rail a .icon`,
+/// `.palette .icon`, `.branch .icon`), since a symbol carries only a
+/// viewBox.
+pub(crate) fn icon_use(id: &str) -> Markup {
+ PreEscaped(format!(
+ "<svg class=\"icon\" aria-hidden=\"true\"><use href=\"#{id}\"/></svg>"
+ ))
+}
crates/cli/ents-web/src/assets/ents.css
@@ -1,0 +1,529 @@
+/* Hand-rolled stylesheet, ported from the pre-redo web UI
+ * (`pre-redo:crates/git-ents-server/src/web/style.css`) and reverified
+ * against the markup `crate::pages::layout` and `crate::render` actually
+ * emit. The `--s-*` syntax-token variables and their `.code .keyword`-family
+ * consumers, and the `--diff-add`/`--diff-del` variables and `.diff`
+ * rules, are ported alongside `crate::pages::files`'s `arborium` highlighter
+ * and `crate::pages::commits`'s unified-diff view. Still dropped: the
+ * clone-URL copy button (`.copy-btn`, `.clone`), live-check output
+ * (`.checks-grid`, `.status-*`, `.terminal-*`), and every chrome class for a
+ * page family this crate does not have (releases, issues, settings,
+ * account-strip sign-in). The pre-redo brand type stack (DM Sans, IBM Plex
+ * Mono, Lora, loaded from Google Fonts) is dropped in favor of system font
+ * stacks, so this sheet never depends on a network fetch. Added beyond the
+ * pre-redo sheet: the `.wb`/`.rail`/`.wb-bar`/`.palette` workbench shell
+ * (`crate::pages::layout_shell`, adapted from the design project's
+ * `proposal-c.css`); `.meta-layout`/`.meta-rail` for the `meta` group's
+ * page-family rail (`crate::pages::META_SECTIONS`) and `.id-chip` for the
+ * bar's signing-identity link; `.comment-meta`/`.outdated` for the file-anchored
+ * comment cards `crate::pages::comments::comments_section` renders below a
+ * blob view, which otherwise reuse `.card`/`.doc-body` as-is; and
+ * `.blob-header`/`.blob-actions`/`.entry-size` for
+ * `crate::pages::files::blob_header`/`dir_listing`'s own metadata, plus
+ * `tr.sel`/`.blob-add`/`.composer-*` for `assets/ents.js`'s client-side
+ * line selection and inline comment composer -- pre-redo had neither a
+ * blob header bar nor any client-side script at all.
+ */
+:root {
+ --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
+ --font-serif: ui-serif, Georgia, serif;
+ --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ --max-width: 78rem;
+ --content-narrow: 58rem;
+ /* The brighter "Proposal C" pass (the workbench mocks' proposal-c.css
+ * token overrides, merged in as THE tokens rather than layered over the
+ * old, dimmer pre-redo values). */
+ --color-bg: #fdfcf8;
+ --color-surface: #ffffff;
+ --color-text: #2a2518;
+ --color-text-muted: #8a7e6a;
+ --color-link: #c08a12;
+ --color-link-hover: #a8760c;
+ --color-border: #e9e4d6;
+ --color-code-bg: #f6f4ec;
+ --color-accent: #c08a12;
+ --color-accent-subtle: #c08a120f;
+ --shadow-sm: 0 1px 3px #0000000d;
+ --shadow-md: 0 4px 16px #0000000f;
+ --radius-sm: 10px;
+ --radius-pill: 100px;
+ --s-comment: #9c8f74;
+ --s-keyword: #9d0006;
+ --s-func: #427b58;
+ --s-type: #b57614;
+ --s-string: #79740e;
+ --s-const: #8f3f71;
+ --s-op: #7c6f57;
+ --s-prop: #076678;
+ --diff-add: #4e9a0622;
+ --diff-del: #cc241d22;
+ --status-pass: #4e9a06;
+ --status-fail: #cc241d;
+ --status-error: #b57614;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --color-bg: #1d1b15;
+ --color-surface: #282419;
+ --color-text: #f3eedd;
+ --color-text-muted: #a89e88;
+ --color-link: #e8b23c;
+ --color-link-hover: #f2c55c;
+ --color-border: #453e2a;
+ --color-code-bg: #322d1e;
+ --color-accent: #e8b23c;
+ --color-accent-subtle: #e8b23c12;
+ --shadow-sm: 0 1px 3px #00000040;
+ --shadow-md: 0 4px 16px #0000004d;
+ --s-comment: #928374;
+ --s-keyword: #fb4934;
+ --s-func: #8ec07c;
+ --s-type: #fabd2f;
+ --s-string: #b8bb26;
+ --s-const: #d3869b;
+ --s-op: #a89984;
+ --s-prop: #83a598;
+ --diff-add: #b8bb2620;
+ --diff-del: #fb493420;
+ --status-pass: #b8bb26;
+ --status-fail: #fb4934;
+ --status-error: #fabd2f;
+ }
+}
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+html { font-size: 17px; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
+body {
+ font-family: var(--font-sans);
+ background: var(--color-bg);
+ color: var(--color-text);
+ line-height: 1.7;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ background-image: radial-gradient(58rem 30rem at 50% -10rem, var(--color-accent-subtle), transparent 72%);
+ background-attachment: fixed;
+}
+h1, h2, h3, h4, h5, h6 { font-family: var(--font-serif); }
+a { color: var(--color-link); text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--color-link) 25%, transparent); text-underline-offset: 2px; transition: color .15s, text-decoration-color .15s; }
+a:hover { color: var(--color-link-hover); text-decoration-color: currentColor; }
+a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; border-radius: 2px; }
+.icon { flex-shrink: 0; fill: currentColor; vertical-align: -0.125em; }
+
+/* The workbench shell (`crate::pages::layout_shell`, the "Proposal C"
+ * chrome): a `.wb` grid pairing the sticky icon `.rail` with a `.wb-main`
+ * column whose sticky `.wb-bar` top bar carries the repo name, branch
+ * pill, `.palette` search, and `.id-chip` identity link. */
+.wb { display: grid; grid-template-columns: 56px minmax(0, 1fr); min-height: 100vh; width: 100%; }
+.rail { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; align-items: center; gap: .3rem; padding: .8rem 0; border-right: 1px solid var(--color-border); background: var(--color-surface); }
+.rail .nav-mark { font-size: 1.35rem; margin-bottom: .6rem; }
+.nav-mark { color: var(--color-accent); }
+.rail a { display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: 10px; color: var(--color-text-muted); }
+.rail a:hover { background: var(--color-code-bg); color: var(--color-text); }
+.rail a.active { background: var(--color-accent-subtle); color: var(--color-accent); }
+.rail a .icon { width: 17px; height: 17px; }
+.rail .spacer { flex: 1; }
+.wb-main { min-width: 0; display: flex; flex-direction: column; }
+.wb-bar { position: sticky; top: 0; z-index: 50; display: flex; align-items: center; gap: 1rem; height: 52px; padding: 0 1.25rem; border-bottom: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-bg) 90%, transparent); backdrop-filter: blur(10px); }
+.repo-path { font-family: var(--font-mono); font-size: .98rem; display: flex; align-items: center; gap: .5rem; white-space: nowrap; min-width: 0; }
+.repo-path .here { color: var(--color-accent); font-weight: 600; }
+.branch { font-family: var(--font-mono); font-size: .72rem; font-weight: 600; color: var(--color-accent); background: var(--color-accent-subtle); border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent); border-radius: var(--radius-pill); padding: .1rem .6rem; display: inline-flex; align-items: center; gap: .3rem; }
+.branch .icon { width: 13px; height: 13px; color: var(--color-accent); }
+/* `.palette` is a `form`, so it must undo the stacked-field form rule
+ * below (column direction, row gap, bottom margin). */
+.palette { flex: 0 1 26rem; margin-left: auto; position: relative; display: flex; flex-direction: row; align-items: center; gap: 0; margin-bottom: 0; }
+.palette .icon { position: absolute; left: .65rem; width: 14px; height: 14px; color: var(--color-text-muted); pointer-events: none; }
+.palette input { width: 100%; font-family: var(--font-sans); font-size: .82rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .42rem 3rem .42rem 2rem; transition: border-color .15s; }
+.palette input:focus { border-color: var(--color-accent); }
+.palette kbd { position: absolute; right: .55rem; font-family: var(--font-mono); font-size: .66rem; color: var(--color-text-muted); border: 1px solid var(--color-border); border-radius: 5px; padding: 0 .35rem; background: var(--color-code-bg); }
+.id-chip { font-family: var(--font-mono); font-size: .78rem; font-weight: 600; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: .25rem .75rem; white-space: nowrap; text-decoration: none; transition: border-color .15s, color .15s; }
+.id-chip:hover { color: var(--color-accent); border-color: var(--color-accent); text-decoration: none; }
+
+/* The `meta` tab's page-family rail (`crate::pages::META_SECTIONS`),
+ * rendered beside every meta-namespace page's own content
+ * (`crate::pages::layout_meta`). */
+.meta-layout { display: grid; grid-template-columns: 13rem minmax(0, 1fr); gap: 2rem; align-items: start; }
+.meta-rail { display: flex; flex-direction: column; gap: .1rem; position: sticky; top: 64px; }
+.meta-rail a { font-family: var(--font-mono); font-size: .85rem; color: var(--color-text-muted); text-decoration: none; padding: .4rem .6rem; border-radius: var(--radius-sm); }
+.meta-rail a:hover { color: var(--color-text); background: var(--color-code-bg); }
+.meta-rail a.active { color: var(--color-accent); font-weight: 600; background: var(--color-accent-subtle); }
+
+@media (max-width: 900px) {
+ .meta-layout { grid-template-columns: minmax(0, 1fr); }
+ .meta-rail { position: static; flex-direction: row; flex-wrap: wrap; }
+}
+
+.content { max-width: var(--max-width); width: 100%; margin: 0 auto; padding: 2.25rem 1.5rem 3rem; flex: 1; }
+/* Master-detail split (`crate::pages::layout_split`): a sticky `.tree`
+ * sidebar beside a padded `.pane` -- the pane, not the shell, owns the
+ * content padding here, so blob and diff content never sits on the
+ * viewport edge. */
+.split { display: grid; grid-template-columns: 16rem minmax(0, 1fr); flex: 1; align-items: start; }
+.tree { border-right: 1px solid var(--color-border); background: var(--color-surface); padding: .9rem .6rem; font-family: var(--font-mono); font-size: .8rem; position: sticky; top: 52px; height: calc(100vh - 52px); overflow-y: auto; }
+.tree a { display: block; padding: .16rem .5rem; border-radius: 6px; color: var(--color-text); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.tree a:hover { background: var(--color-code-bg); }
+.tree a.active { background: var(--color-accent-subtle); color: var(--color-accent); font-weight: 600; }
+.tree a span { display: block; overflow: hidden; text-overflow: ellipsis; }
+.tree .dir { color: var(--color-text-muted); font-weight: 600; }
+.tree .i1 { padding-left: 1.3rem; }
+.tree .i2 { padding-left: 2.5rem; }
+.tree .i3 { padding-left: 3.7rem; }
+.tree-note { display: block; padding: .16rem .5rem; color: var(--color-text-muted); }
+.tree .where { font-size: .68rem; font-weight: 400; color: var(--color-text-muted); }
+.pane { padding: 1.4rem 1.75rem 2.5rem; min-width: 0; }
+@media (max-width: 900px) {
+ .split { grid-template-columns: minmax(0, 1fr); }
+ .tree { position: static; height: auto; max-height: 14rem; border-right: none; border-bottom: 1px solid var(--color-border); }
+}
+/* Single-column reading content (an entity's cards, a discussion thread,
+ * a form) caps at `--content-narrow` so it never smears across a wide
+ * viewport; wide surfaces (the commits table, file lists, diffs) stay at
+ * the shell's full `--max-width`. */
+.readable { max-width: var(--content-narrow); }
+.page-header { margin-bottom: 1.75rem; padding-bottom: 1.25rem; border-bottom: 1px solid var(--color-border); position: relative; display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
+.page-header::after { content: ""; position: absolute; bottom: -1px; left: 0; width: 3rem; height: 2px; background: var(--color-accent); border-radius: 1px; }
+.page-title { font-family: var(--font-serif); font-size: 1.5rem; font-weight: 700; letter-spacing: -.01em; line-height: 1.3; }
+
+/* Cards: the container every generic (reflection-driven) view and every
+ * bare list/definition list a custom page renders lands inside. */
+.card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); margin-bottom: 1.5rem; overflow: hidden; }
+.card-header { display: flex; align-items: center; gap: .5rem; font-family: var(--font-mono); font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--color-text-muted); background: var(--color-code-bg); padding: .55rem 1.1rem; border-bottom: 1px solid var(--color-border); }
+.card-row { display: flex; align-items: center; gap: .65rem; padding: .7rem 1.1rem; font-family: var(--font-mono); font-size: .9rem; }
+.card-row + .card-row { border-top: 1px solid var(--color-border); }
+.card-row .icon { color: var(--color-text-muted); }
+.card-row.is-dir .icon { color: var(--color-accent); }
+.card-row a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); flex: 1; min-width: 0; word-break: break-all; }
+.card-row a:hover { color: var(--color-accent); }
+.card-row a.row-link { display: flex; align-items: center; gap: .65rem; text-decoration: none; }
+.card-row:has(.row-link):hover { background: var(--color-code-bg); }
+
+/* A directory listing's per-entry byte size (`crate::pages::files::dir_listing`,
+ * `human_size`) -- muted, mono, right-aligned, absent for a directory
+ * entry. */
+.entry-size { margin-left: auto; font-family: var(--font-mono); font-size: .82rem; color: var(--color-text-muted); white-space: nowrap; }
+
+/* The workbench dashboard (`GET /`, `crate::pages::dashboard`): three
+ * cards on a `.desk` grid (working tree, needs attention, issues), then
+ * a full-width `.desk-wide` History card. `.content` already pads the
+ * column, so the desk itself only spaces its cards. */
+.desk { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.25rem; align-items: start; margin-bottom: 1.25rem; }
+.desk .card, .desk-wide .card { margin-bottom: 0; }
+.card-header .btn-ghost { margin-left: auto; }
+.muted { color: var(--color-text-muted); }
+.btn { display: inline-block; font-size: .78rem; font-weight: 600; color: var(--color-bg); background: var(--color-accent); border: none; border-radius: 8px; padding: .3rem .8rem; cursor: pointer; text-decoration: none; }
+.btn-ghost { color: var(--color-accent); background: transparent; border: 1px solid var(--color-border); text-transform: none; letter-spacing: 0; }
+.btn-ghost:hover { border-color: var(--color-accent); color: var(--color-accent); }
+/* Needs-attention / issue rows: a block link pairing a `.what` line with
+ * a muted, mono `.where` locator. */
+.attention-row { display: block; padding: .7rem 1.1rem; color: inherit; text-decoration: none; }
+.attention-row:hover { background: var(--color-code-bg); text-decoration: none; }
+.attention-row + .attention-row { border-top: 1px solid var(--color-border); }
+.attention-row .what { display: block; font-size: .88rem; }
+.attention-row .where { display: block; font-family: var(--font-mono); font-size: .74rem; color: var(--color-text-muted); margin-top: .15rem; }
+/* Scoped-Commits scope chips (`crate::pages::dashboard::scope_class`):
+ * `.scope-c{0..5}` maps a stable hash of the scope name onto the six
+ * `--s-*` syntax-token colors. */
+.scope { font-family: var(--font-mono); font-size: .68rem; font-weight: 600; border-radius: var(--radius-pill); padding: 0 .55rem; white-space: nowrap; }
+.scope-c0 { color: var(--s-prop); background: color-mix(in srgb, var(--s-prop) 12%, transparent); }
+.scope-c1 { color: var(--s-func); background: color-mix(in srgb, var(--s-func) 12%, transparent); }
+.scope-c2 { color: var(--s-const); background: color-mix(in srgb, var(--s-const) 12%, transparent); }
+.scope-c3 { color: var(--s-type); background: color-mix(in srgb, var(--s-type) 14%, transparent); }
+.scope-c4 { color: var(--s-keyword); background: color-mix(in srgb, var(--s-keyword) 12%, transparent); }
+.scope-c5 { color: var(--s-string); background: color-mix(in srgb, var(--s-string) 14%, transparent); }
+/* History rows: the oid link stays its own width; only the subject cell
+ * flexes and ellipsizes. */
+.history .card-row { flex-wrap: nowrap; }
+.history .card-row a { flex: none; text-decoration: none; }
+.history .card-row a:hover { text-decoration: underline; }
+.desk-subject { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-sans); font-size: .88rem; }
+.blankslate { text-align: center; padding: 3rem 1.5rem; }
+.blankslate h2 { font-family: var(--font-serif); font-size: 1.3rem; font-weight: 700; margin-bottom: .5rem; }
+.blankslate p { color: var(--color-text-muted); }
+.blankslate code { font-family: var(--font-mono); background: var(--color-code-bg); padding: .15rem .45rem; border-radius: 5px; font-size: .85rem; }
+
+@media (max-width: 1100px) {
+ .desk { grid-template-columns: minmax(0, 1fr); }
+}
+
+/* The generic reflection-driven views (`crate::render`): a definition list
+ * for one entity, a table for a list of entities, a plain list for bare
+ * name lists (toolchains, inbox). */
+.entity-view { font-family: var(--font-mono); font-size: .9rem; }
+.entity-view dt { padding: .7rem 1.1rem .1rem; font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--color-text-muted); }
+.entity-view dd { padding: 0 1.1rem .7rem; word-break: break-all; }
+.entity-view dt + dt { padding-top: .1rem; }
+
+.entity-list { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: .85rem; }
+.entity-list th, .entity-list td { padding: .6rem 1.1rem; text-align: left; }
+.entity-list th { white-space: nowrap; }
+/* `render::list_table` marks a cell `.long-token` when one whitespace-free
+ * token (an ssh key's base64 body, an unbroken hash) is too long to wrap
+ * at word boundaries; only those cells may break mid-token, so a short
+ * value like a variant name never shreds. */
+.entity-list td.long-token { word-break: break-all; }
+.entity-list thead { background: var(--color-code-bg); font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--color-text-muted); }
+.entity-list tbody tr + tr { border-top: 1px solid var(--color-border); }
+.entity-list td a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
+.entity-list td a:hover { color: var(--color-accent); }
+
+/* The commits list (`crate::pages::commits::list`) reuses `.entity-list`,
+ * but its oid/author/when columns are short tokens that should stay on
+ * one line however narrow the table gets; only the subject column --
+ * ordinary prose -- wraps at word boundaries. */
+.commits-table th:nth-child(1), .commits-table td:nth-child(1),
+.commits-table th:nth-child(3), .commits-table td:nth-child(3),
+.commits-table th:nth-child(4), .commits-table td:nth-child(4) {
+ white-space: nowrap;
+}
+
+.string-list { list-style: none; font-family: var(--font-mono); font-size: .9rem; }
+.string-list li { display: flex; align-items: center; gap: .65rem; padding: .7rem 1.1rem; }
+.string-list li + li { border-top: 1px solid var(--color-border); }
+.string-list a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
+.string-list a:hover { color: var(--color-accent); }
+
+/* A per-entity read failure (an older or unrelated schema's tree shape)
+ * on a show page's own card (`crate::render::unreadable`) -- graceful
+ * degradation, never a 500. */
+.card-row.unreadable span {
+ color: var(--color-text-muted);
+ font-style: italic;
+}
+.unreadable-detail {
+ display: block;
+ margin-top: .2rem;
+ color: var(--color-text-muted);
+ font-style: normal;
+ font-family: var(--font-mono);
+ font-size: .78rem;
+ word-break: break-all;
+}
+
+/* The subtle unreadable-entities disclosure a list page renders when refs
+ * under its prefix failed to read back (`crate::render::unreadable_disclosure`):
+ * a muted, badge-shaped `<details>` summary that must never dominate the
+ * page, expanding -- the element's own no-JS toggle -- to a small card of
+ * refname/error pairs. */
+.unreadable-note { margin-bottom: 1.25rem; }
+.unreadable-note summary { display: inline-flex; align-items: center; gap: .35rem; font-family: var(--font-mono); font-size: .72rem; font-weight: 600; color: var(--color-text-muted); background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: .1rem .6rem; cursor: pointer; list-style: none; user-select: none; -webkit-user-select: none; }
+.unreadable-note summary::-webkit-details-marker { display: none; }
+.unreadable-note summary:hover { color: var(--color-text); }
+.unreadable-note[open] summary { margin-bottom: .6rem; }
+.unreadable-note .card { margin-bottom: 0; }
+.unreadable-note dd { color: var(--color-text-muted); font-size: .78rem; }
+
+/* Bare `dl`/`ul`/`form` markup a custom page (toolchains, comments) emits
+ * directly, styled the same as the generic views above so a page needs no
+ * per-page CSS hook to look consistent. */
+dl { font-family: var(--font-mono); font-size: .9rem; margin-bottom: 1.5rem; }
+dl dt { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--color-text-muted); margin-top: .6rem; }
+dl dd { word-break: break-all; }
+ul:not(.string-list) { list-style: none; font-family: var(--font-mono); font-size: .9rem; margin-bottom: 1.5rem; }
+ul:not(.string-list) li { padding: .35rem 0; }
+
+.badge { 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 .5rem; margin-left: .5rem; }
+
+/* Member identity cards (`crate::pages::members::member_card`): username
+ * prominent, key type as an accent badge, key material truncated through
+ * the middle with the full line behind a details toggle. */
+.member-head { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; padding: .8rem 1.1rem; }
+.member-head .badge { margin-left: 0; }
+.member-name { font-family: var(--font-mono); font-weight: 700; font-size: 1.05rem; color: var(--color-text); }
+a.member-name { text-decoration: none; }
+a.member-name:hover { color: var(--color-accent); }
+.key-badge { font-family: var(--font-mono); font-size: .68rem; font-weight: 600; color: var(--color-accent); background: var(--color-accent-subtle); border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent); border-radius: var(--radius-pill); padding: 0 .55rem; white-space: nowrap; }
+.member-key { padding: 0 1.1rem .8rem; font-family: var(--font-mono); font-size: .82rem; }
+.member-key code { color: var(--color-text-muted); background: var(--color-code-bg); border-radius: 5px; padding: .1rem .45rem; }
+.member-key details { margin-top: .4rem; }
+.member-key summary { font-size: .72rem; color: var(--color-text-muted); cursor: pointer; user-select: none; -webkit-user-select: none; }
+.member-key pre { margin-top: .3rem; padding: .5rem .7rem; background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: var(--radius-sm); white-space: pre-wrap; word-break: break-all; font-size: .74rem; }
+
+/* Forms (`account`, `comments`, `issues`, the review form): each labeled
+ * field is a compact two-column grid row -- the label in a fixed left
+ * column, its control on the right -- stacking back to one column under
+ * the mobile breakpoint. Controls size to their content: the short
+ * token-shaped fields (`rev`, `lines`, `state`, `verdict`) stop at
+ * `12rem` instead of stretching, while `title`/`path` inputs and `body`
+ * textareas keep the full control column. */
+form { display: flex; flex-direction: column; gap: .6rem; margin-bottom: 1.5rem; }
+form label { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: .25rem .9rem; align-items: center; font-family: var(--font-sans); font-size: .82rem; font-weight: 600; color: var(--color-text-muted); }
+form label:has(textarea) { align-items: start; }
+form input, form textarea { font-family: var(--font-mono); font-size: .85rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .42rem .7rem; }
+form input:focus, form textarea:focus { border-color: var(--color-accent); }
+form input[name="rev"], form input[name="lines"], form input[name="state"], form input[name="verdict"] { width: 12rem; justify-self: start; }
+form textarea { resize: vertical; min-height: 9rem; }
+form button { align-self: flex-start; font-size: .88rem; font-weight: 600; color: var(--color-bg); background: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: .45rem 1rem; cursor: pointer; transition: background .15s; }
+form button:hover { background: var(--color-link-hover); }
+
+/* Breadcrumbs (pure path navigation) and the file browser
+ * (`crate::pages::files`). */
+.crumbs { font-family: var(--font-mono); font-size: .92rem; margin-bottom: 1.25rem; display: flex; flex-wrap: wrap; align-items: center; gap: .3rem; word-break: break-all; }
+.crumbs a { text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
+.crumbs .sep { color: var(--color-text-muted); opacity: .55; }
+.crumbs .here { color: var(--color-text-muted); }
+
+/* The blob header bar (`crate::pages::files::blob_header`): a file's name
+ * and metadata on the left, the history/comment actions that used to
+ * trail `.crumbs` on the right -- rendered above every blob view (raw
+ * source, a rendered document, or a binary placeholder), matching the
+ * `.card-header` family look it sits beside. */
+.blob-header { display: flex; align-items: center; flex-wrap: wrap; gap: .5rem .9rem; padding: .55rem 1.1rem; border-bottom: 1px solid var(--color-border); background: var(--color-code-bg); border-radius: var(--radius-sm) var(--radius-sm) 0 0; }
+.blob-title { font-family: var(--font-mono); font-weight: 600; }
+.blob-meta { font-size: .78rem; color: var(--color-text-muted); }
+.blob-actions { margin-left: auto; display: flex; align-items: center; gap: .9rem; font-size: .82rem; color: var(--color-text-muted); }
+.blob-actions a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
+.blob-actions a:hover { color: var(--color-accent); }
+
+/* Commit history and view (`crate::pages::commits`). */
+.commit-subject { font-weight: 600; font-size: 1.05rem; margin-bottom: .3rem; }
+.commit-msg { font-family: var(--font-mono); font-size: .9rem; white-space: pre-wrap; word-break: break-word; margin-bottom: .3rem; }
+.commit-meta { color: var(--color-text-muted); font-size: .85rem; margin-top: .2rem; }
+.commit-meta a { text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
+
+/* The line-per-row source view (`crate::pages::files::source_view`): a
+ * `<table>` inside one `overflow-x: auto` wrapper so the whole blob scrolls
+ * together, its `.blob-nums` gutter cell pinned via `position: sticky` so
+ * line numbers stay put while wide code scrolls under them -- the same
+ * frozen-gutter behavior the previous two-`<pre>`-column layout got for
+ * free, now reconstructed for a per-line table so a comment card
+ * (`tr.blob-comment-row`) can interleave as a full-width row between any
+ * two line rows. */
+.blob { overflow-x: auto; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); margin-bottom: 1.5rem; }
+.blob table { border-collapse: collapse; width: 100%; font-family: var(--font-mono); font-size: .82rem; line-height: 1.55; }
+/* Breathing room above the first row and below the last, without a
+ * margin/padding on the table itself -- that would leave a gap in the
+ * sticky gutter's own continuous background column. */
+.blob tr:first-child td { padding-top: .45rem; }
+.blob tr:last-child td { padding-bottom: .45rem; }
+.blob td.blob-nums { position: sticky; left: 0; text-align: right; color: var(--color-text-muted); background: var(--color-code-bg); border-right: 1px solid var(--color-border); padding: 0 1ch; user-select: none; -webkit-user-select: none; white-space: nowrap; vertical-align: top; }
+.blob-nums a { display: block; color: inherit; text-decoration: none; padding: 0 .4ch; border-radius: 4px; outline: none; cursor: pointer; }
+.blob-nums a:hover { color: var(--color-accent); }
+.blob-nums a:target { color: var(--color-accent); font-weight: 700; }
+.blob-nums a:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -2px; }
+.blob td.blob-code { padding: 0 1.25rem; white-space: pre; color: var(--color-text); vertical-align: top; }
+.blob-code code { font-family: inherit; }
+.blob tr.blob-comment-row td { padding: 0; background: var(--color-surface); }
+.blob tr.blob-comment-row .card { margin: .5rem 1rem; }
+.binary { padding: 2.5rem; text-align: center; font-family: var(--font-mono); font-size: .85rem; color: var(--color-text-muted); }
+
+/* Client-side line selection (`assets/ents.js`): `.blob-nums a:target` above
+ * is the no-JS fallback for a single anchored line; `tr.sel` is the
+ * script's own richer selection, spanning a whole clicked/shift-clicked
+ * range. */
+.blob tr.sel td { background: var(--color-accent-subtle); }
+.blob tr.sel td.blob-nums { color: var(--color-accent); font-weight: 700; }
+
+/* The gutter's "+" comment affordance (`assets/ents.js`): injected once per
+ * line row, absolutely positioned inside the already-positioned (sticky)
+ * `.blob-nums` cell so it costs no layout of its own and never shifts line
+ * height, hidden until that row is hovered or the button itself is
+ * focused. */
+.blob-add { position: absolute; top: 50%; left: .2rem; transform: translateY(-50%); width: 15px; height: 15px; line-height: 14px; padding: 0; text-align: center; font-size: .78rem; font-weight: 700; color: var(--color-bg); background: var(--color-accent); border: none; border-radius: 4px; opacity: 0; cursor: pointer; transition: opacity .1s; }
+.blob tr:hover .blob-add, .blob-add:focus-visible { opacity: 1; }
+
+/* The inline comment composer (`crate::pages::files::composer_template`,
+ * cloned and shown by `assets/ents.js`): mirrors `tr.blob-comment-row
+ * .card`'s own margin so it lands flush with the comment cards it
+ * follows. */
+.blob tr.blob-composer td { padding: 0; background: var(--color-surface); }
+.composer-form { margin: .5rem 1rem; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); padding: .75rem 1rem; }
+.composer-form textarea { min-height: 6rem; }
+.composer-buttons { display: flex; flex-direction: row; gap: .5rem; }
+.composer-buttons button { margin: 0; }
+.composer-cancel { align-self: flex-start; font-size: .88rem; font-weight: 600; color: var(--color-text); background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .45rem 1rem; cursor: pointer; transition: border-color .15s, color .15s; }
+.composer-cancel:hover { color: var(--color-accent); border-color: var(--color-accent); }
+
+/* File-anchored comment cards (`crate::pages::comments::comment_card`),
+ * whether interleaved into a blob's own table (`tr.blob-comment-row`,
+ * above) or listed below it (`comments_section`/`outdated_comments_section`)
+ * -- each comment is its own `.card`, its body reusing `.doc-body`'s prose
+ * styling (a comment body renders as AsciiDoc, same as a rendered document
+ * blob). */
+.comment-meta { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; padding: .7rem 1.1rem; font-size: .82rem; color: var(--color-text-muted); border-bottom: 1px solid var(--color-border); }
+.comment-meta .author { color: var(--color-text); font-weight: 600; }
+.outdated { color: var(--color-text-muted); font-style: italic; }
+/* State/verdict pills on comment, issue, and review cards
+ * (`crate::pages::{comments,issues,commits}`). */
+.comment-state, .verdict { display: inline-block; padding: .05rem .5rem; border: 1px solid var(--color-border); border-radius: var(--radius-sm); font-size: .78rem; font-weight: 600; text-transform: lowercase; color: var(--color-text-muted); }
+.verdict { color: var(--color-text); }
+/* Open-in-editor affordances (`crate::pages::editor_open`): a small,
+ * muted icon deep-linking a code location into the serving user's own
+ * editor ($ENTS_EDITOR, then $EDITOR). */
+.editor-open { display: inline-flex; align-items: center; vertical-align: -2px; color: var(--color-text-muted); }
+.editor-open:hover { color: var(--color-accent); }
+.editor-open .icon { width: 14px; height: 14px; }
+/* Check-status chips (`crate::pages::commits::checks_section`): one color
+ * per value of the closed pass/fail/error result taxonomy. */
+.status { display: inline-block; font-family: var(--font-mono); font-size: .72rem; font-weight: 700; border-radius: var(--radius-pill); padding: .05rem .6rem; text-transform: lowercase; }
+.status-pass { color: var(--status-pass); background: color-mix(in srgb, var(--status-pass) 12%, transparent); }
+.status-fail { color: var(--status-fail); background: color-mix(in srgb, var(--status-fail) 12%, transparent); }
+.status-error { color: var(--status-error); background: color-mix(in srgb, var(--status-error) 14%, transparent); }
+/* The reply/resolve/reopen and comment composer forms on a thread card. */
+.comment-actions { display: flex; flex-wrap: wrap; align-items: flex-start; gap: .6rem; padding: .7rem 1.1rem; border-top: 1px solid var(--color-border); }
+
+/* Syntax-highlight token classes (`crate::pages::files::highlight`, `arborium`'s `HtmlFormat::ClassNames`). */
+.code .keyword, .code .macro, .code .tag { color: var(--s-keyword); }
+.code .function, .code .constructor { color: var(--s-func); }
+.code .type { color: var(--s-type); }
+.code .string { color: var(--s-string); }
+.code .number, .code .constant, .code .label { color: var(--s-const); }
+.code .comment { color: var(--s-comment); font-style: italic; }
+.code .operator, .code .punctuation { color: var(--s-op); }
+.code .property, .code .attribute { color: var(--s-prop); }
+.code .title { color: var(--s-keyword); font-weight: 700; }
+.code .strong { font-weight: 700; }
+.code .emphasis { font-style: italic; }
+.code .link, .code .url, .code .reference { color: var(--s-prop); text-decoration: underline; }
+.code .markup { color: var(--s-func); }
+
+/* Unified diffs (`crate::pages::commits::diff_view`). */
+.diff { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); overflow-x: auto; margin-bottom: 1.5rem; font-family: var(--font-mono); font-size: .82rem; line-height: 1.55; padding: .6rem 0; }
+.diff .ln { display: block; padding: 0 1rem; white-space: pre; }
+.diff .add { background: var(--diff-add); }
+.diff .del { background: var(--diff-del); }
+.diff .hunk { color: var(--s-prop); background: var(--color-code-bg); }
+.diff .meta { color: var(--color-text-muted); }
+.diff .file { color: var(--color-text); font-weight: 600; background: var(--color-code-bg); padding-top: .3rem; padding-bottom: .3rem; }
+
+/* A rendered document's own metadata -- Markdown frontmatter and AsciiDoc
+ * header attributes (`crate::render::properties_table`) -- as a bordered
+ * definition list above the document body, reusing `.entity-view`'s own
+ * dt/dd rhythm. `pre-wrap` on the value cell keeps an unparsed nested
+ * structure's raw text readable line by line. */
+.doc-props { border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-code-bg); padding: .35rem 0; margin: 0 0 1.5rem; }
+.doc-props dd { white-space: pre-wrap; }
+
+/* Rendered Markdown/AsciiDoc documents (`crate::markdown`, `crate::asciidoc`). */
+.doc-body { padding: 40px 48px 52px; max-width: 44rem; overflow-wrap: break-word; }
+.doc-body > :first-child { margin-top: 0; }
+.doc-body h1, .doc-body h2, .doc-body h3, .doc-body h4 { font-family: var(--font-serif); font-weight: 700; letter-spacing: -.01em; line-height: 1.25; margin: 1.8rem 0 .9rem; }
+.doc-body h1 { font-size: 2.4rem; letter-spacing: -.02em; margin-top: 0; }
+.doc-body .doc-subtitle { font-family: var(--font-serif); font-style: italic; font-size: 1.18rem; color: var(--color-text-muted); margin: -.4rem 0 1.2rem; }
+.doc-body h2 { font-size: 1.4rem; font-weight: 600; position: relative; padding-bottom: .55rem; }
+.doc-body h2::after { content: ""; position: absolute; left: 0; bottom: 0; width: 3rem; height: 2px; background: var(--color-accent); border-radius: 1px; }
+.doc-body h3 { font-size: 1.15rem; font-weight: 600; }
+.doc-body p, .doc-body ul, .doc-body ol { margin: 0 0 1rem; }
+.doc-body ul, .doc-body ol { padding-left: 1.4rem; list-style: revert; font-family: inherit; }
+.doc-body li { margin: .25rem 0; padding: 0; }
+.doc-body a { font-weight: 500; }
+.doc-body code, .doc-body .literal { font-family: var(--font-mono); font-size: .86em; background: var(--color-code-bg); padding: .1rem .35rem; border-radius: 5px; }
+.doc-body pre { font-family: var(--font-mono); font-size: .82rem; line-height: 1.55; background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: 1rem 1.2rem; overflow-x: auto; margin: 0 0 1rem; }
+.doc-body pre code { background: none; padding: 0; font-size: inherit; }
+.doc-body blockquote { border-left: 3px solid var(--color-accent); padding: .2rem 0 .2rem 1.1rem; margin: 0 0 1rem; color: var(--color-text-muted); }
+.doc-body table { border-collapse: collapse; margin: 0 0 1rem; font-size: .92rem; }
+.doc-body th, .doc-body td { border: 1px solid var(--color-border); padding: .4rem .7rem; text-align: left; }
+.doc-body th { background: var(--color-code-bg); font-weight: 600; }
+.doc-body img { max-width: 100%; height: auto; }
+.doc-body hr { border: none; border-top: 1px solid var(--color-border); margin: 1.8rem 0; }
+
+@media (max-width: 640px) {
+ html { font-size: 16px; }
+ .content { padding: 1.5rem 1.25rem 2.5rem; }
+ form label { grid-template-columns: minmax(0, 1fr); }
+ .wb-bar { gap: .6rem; padding: 0 .9rem; }
+ .palette { flex-basis: 14rem; }
+ .palette kbd { display: none; }
+ .page-title { font-size: 1.3rem; }
+ .doc-body { padding: 1.75rem 1.5rem 2rem; }
+ .doc-body h1 { font-size: 1.9rem; }
+ .doc-body h2 { font-size: 1.25rem; }
+}
crates/cli/ents-web/src/assets/ents.js
@@ -1,0 +1,222 @@
+/*
+ * The shell's `⌘K` shortcut: focus the top bar's `.palette` search input
+ * (the `kbd` hint every page renders beside it -- `crate::pages`'s
+ * `layout_shell`). Cmd+K on macOS, Ctrl+K elsewhere; Escape hands focus
+ * back. Enhancement only: the form is a plain GET to `/search` with or
+ * without this handler.
+ */
+(function () {
+ "use strict";
+
+ var input = document.querySelector('.palette input[name="q"]');
+ if (!input) {
+ return;
+ }
+ document.addEventListener("keydown", function (event) {
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
+ event.preventDefault();
+ input.focus();
+ input.select();
+ } else if (event.key === "Escape" && document.activeElement === input) {
+ input.blur();
+ }
+ });
+})();
+
+/*
+ * Progressive enhancement for `crate::pages::files`'s raw-source blob view
+ * (`div.blob[data-path][data-rev]`): click a gutter line number to select
+ * it, shift-click to extend the selection to a range, and open an inline
+ * comment composer cloned from the server-rendered
+ * `<template id="composer-template">`. Every behavior here layers on top
+ * of markup that already works with no script at all -- the plain `#L<n>`
+ * anchors and the header's "comment on this file" link -- so a disabled or
+ * failed script load never breaks the page, only the shortcut.
+ *
+ * Vanilla, dependency-free: no fetch/AJAX anywhere in this file. The
+ * composer's own submit is left as an ordinary form POST; only its Cancel
+ * button and the gutter's "+" affordance are wired up here.
+ */
+(function () {
+ "use strict";
+
+ var blob = document.querySelector("div.blob[data-path][data-rev]");
+ if (!blob) {
+ return;
+ }
+ var table = blob.querySelector("table");
+ if (!table) {
+ return;
+ }
+
+ var rows = Array.prototype.slice.call(table.querySelectorAll("tbody > tr"));
+
+ function lineNumber(tr) {
+ var a = tr.querySelector("td.blob-nums a");
+ return a ? parseInt(a.textContent, 10) : null;
+ }
+
+ var lineRows = rows.filter(function (tr) {
+ return lineNumber(tr) !== null;
+ });
+ var byNumber = {};
+ lineRows.forEach(function (tr) {
+ byNumber[lineNumber(tr)] = tr;
+ });
+
+ var anchorLine = null;
+
+ // The blob header's open-in-editor deep link follows the selection:
+ // `data-editor-base` carries the line-less URL, the selected line is
+ // appended in the editors' shared `:{line}` suffix shape.
+ var editorLink = blob.querySelector("a.editor-open[data-editor-base]");
+ function retargetEditor(line) {
+ if (editorLink) {
+ editorLink.href = editorLink.getAttribute("data-editor-base") + ":" + line;
+ }
+ }
+
+ function selectRange(start, end) {
+ retargetEditor(Math.min(start, end));
+ var lo = Math.min(start, end);
+ var hi = Math.max(start, end);
+ lineRows.forEach(function (tr) {
+ tr.classList.remove("sel");
+ });
+ for (var n = lo; n <= hi; n += 1) {
+ if (byNumber[n]) {
+ byNumber[n].classList.add("sel");
+ }
+ }
+ }
+
+ function setHash(start, end) {
+ var hash = start === end ? "#L" + start : "#L" + start + "-L" + end;
+ history.replaceState(null, "", hash);
+ }
+
+ function applyHash(hash, scroll) {
+ var match = /^#L(\d+)(?:-L(\d+))?$/.exec(hash);
+ if (!match) {
+ return;
+ }
+ var start = parseInt(match[1], 10);
+ var end = match[2] ? parseInt(match[2], 10) : start;
+ anchorLine = start;
+ selectRange(start, end);
+ if (scroll && byNumber[start] && byNumber[start].scrollIntoView) {
+ byNumber[start].scrollIntoView({ block: "center" });
+ }
+ }
+
+ if (location.hash) {
+ applyHash(location.hash, true);
+ }
+
+ table.querySelectorAll("td.blob-nums a").forEach(function (a) {
+ a.addEventListener("click", function (event) {
+ event.preventDefault();
+ var n = lineNumber(a.closest("tr"));
+ if (n === null) {
+ return;
+ }
+ if (event.shiftKey && anchorLine !== null) {
+ selectRange(anchorLine, n);
+ setHash(Math.min(anchorLine, n), Math.max(anchorLine, n));
+ } else {
+ anchorLine = n;
+ selectRange(n, n);
+ setHash(n, n);
+ }
+ });
+ });
+
+ function selectedRange() {
+ var numbers = lineRows
+ .filter(function (tr) {
+ return tr.classList.contains("sel");
+ })
+ .map(lineNumber);
+ if (numbers.length === 0) {
+ return null;
+ }
+ return [Math.min.apply(null, numbers), Math.max.apply(null, numbers)];
+ }
+
+ function openComposer() {
+ var range = selectedRange();
+ var template = document.getElementById("composer-template");
+ if (!range || !template) {
+ return;
+ }
+ var existing = table.querySelector("tr.blob-composer");
+ if (existing) {
+ existing.remove();
+ }
+
+ // Land below the last selected line's own row, and below any comment
+ // cards the server already interleaved after it.
+ var afterRow = byNumber[range[1]];
+ if (!afterRow) {
+ return;
+ }
+ while (
+ afterRow.nextElementSibling &&
+ afterRow.nextElementSibling.classList.contains("blob-comment-row")
+ ) {
+ afterRow = afterRow.nextElementSibling;
+ }
+
+ var tr = document.createElement("tr");
+ tr.className = "blob-composer";
+ var td = document.createElement("td");
+ td.colSpan = 2;
+
+ var fragment = template.content.cloneNode(true);
+ var form = fragment.querySelector("form");
+ var linesInput = form && form.querySelector('input[name="lines"]');
+ if (linesInput) {
+ linesInput.value =
+ range[0] === range[1] ? String(range[0]) : range[0] + ":" + range[1];
+ }
+ var cancel = fragment.querySelector(".composer-cancel");
+ if (cancel) {
+ cancel.addEventListener("click", function () {
+ tr.remove();
+ });
+ }
+
+ td.appendChild(fragment);
+ tr.appendChild(td);
+ afterRow.parentNode.insertBefore(tr, afterRow.nextElementSibling);
+ }
+
+ // One "+" affordance per line row, injected once -- CSS reveals it on
+ // row hover (`.blob tr:hover .blob-add`), so there is nothing to
+ // rebuild on each click.
+ lineRows.forEach(function (tr) {
+ var cell = tr.querySelector("td.blob-nums");
+ if (!cell) {
+ return;
+ }
+ var button = document.createElement("button");
+ button.type = "button";
+ button.className = "blob-add";
+ button.setAttribute("aria-label", "Comment on this line");
+ button.textContent = "+";
+ button.addEventListener("click", function (event) {
+ event.preventDefault();
+ var n = lineNumber(tr);
+ if (n === null) {
+ return;
+ }
+ if (anchorLine === null || !tr.classList.contains("sel")) {
+ anchorLine = n;
+ selectRange(n, n);
+ setHash(n, n);
+ }
+ openComposer();
+ });
+ cell.appendChild(button);
+ });
+})();
crates/cli/ents-web/src/assets/icons/LICENSE
@@ -1,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 GitHub Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
crates/cli/ents-web/src/assets/icons/chevron-right.svg
@@ -1,0 +1,1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z"/></svg>
crates/cli/ents-web/src/assets/icons/file-directory-fill.svg
@@ -1,0 +1,1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/></svg>
crates/cli/ents-web/src/assets/icons/file.svg
@@ -1,0 +1,1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></svg>
crates/cli/ents-web/src/assets/icons/git-branch.svg
@@ -1,0 +1,1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg>
crates/cli/ents-web/src/assets/icons/search.svg
@@ -1,0 +1,1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
crates/cli/ents-web/src/assets/sprite.svg
@@ -1,0 +1,15 @@
+<svg style="display:none" xmlns="http://www.w3.org/2000/svg">
+ <symbol id="i-home" viewBox="0 0 16 16"><path d="M2 7.5 8 2l6 5.5V14H9.5v-3.5h-3V14H2z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol>
+ <symbol id="i-files" viewBox="0 0 16 16"><path d="M1.5 3.5h4l1.5 2h7.5v7h-13z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol>
+ <symbol id="i-commit" viewBox="0 0 16 16"><circle cx="8" cy="8" r="2.6" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M.5 8h4.9M10.6 8h4.9" stroke="currentColor" stroke-width="1.5"/></symbol>
+ <symbol id="i-issue" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="8" cy="8" r="1.6" fill="currentColor"/></symbol>
+ <symbol id="i-comment" viewBox="0 0 16 16"><path d="M2 2.5h12v8H8l-3.5 3v-3H2z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol>
+ <symbol id="i-meta" viewBox="0 0 16 16"><circle cx="8" cy="8" r="2.2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8 1.2v2.2M8 12.6v2.2M1.2 8h2.2M12.6 8h2.2M3.5 3.5l1.5 1.5M11 11l1.5 1.5M12.5 3.5 11 5M5 11l-1.5 1.5" stroke="currentColor" stroke-width="1.5"/></symbol>
+ <symbol id="i-people" viewBox="0 0 16 16"><circle cx="5.5" cy="5.5" r="2.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M1.5 14a4 4 0 0 1 8 0" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="11.5" cy="6.5" r="2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M11 14a3.5 3.5 0 0 1 3.8-3.2" fill="none" stroke="currentColor" stroke-width="1.5"/></symbol>
+ <symbol id="i-person" viewBox="0 0 16 16"><circle cx="8" cy="5" r="2.6" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M2.5 14a5.5 5.5 0 0 1 11 0" fill="none" stroke="currentColor" stroke-width="1.5"/></symbol>
+ <symbol id="i-search" viewBox="0 0 16 16"><circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M10.5 10.5 14 14" stroke="currentColor" stroke-width="1.5"/></symbol>
+ <symbol id="i-menu" viewBox="0 0 16 16"><path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" stroke-width="1.5"/></symbol>
+ <symbol id="i-ed-zed" viewBox="0 0 16 16"><path d="M2.5 3.5h11l-11 9h11" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/><path d="M6.5 8h3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
+ <symbol id="i-ed-code" viewBox="0 0 16 16"><path d="M11.6.9 5.2 6.7 2.4 4.5l-1.3.7L4.3 8l-3.2 2.8 1.3.7 2.8-2.2 6.4 5.8 3.5-1.7v-11zM11.4 4.6v6.8L7.7 8z" fill="currentColor"/></symbol>
+ <symbol id="i-ed-nvim" viewBox="0 0 16 16"><path d="M2.8 4.6 5.8 1.4v13.2l-3-3.2zM13.2 11.4l-3 3.2V1.4l3 3.2z" fill="currentColor"/><path d="M5.8 3.8l4.4 8.4" stroke="currentColor" stroke-width="1.6"/></symbol>
+</svg>
crates/cli/ents-web/src/editor.rs
@@ -1,0 +1,143 @@
+//! Which editor the serving user works in, and deep links into it.
+//!
+//! The web surface is an escalation from the editor, never a destination
+//! of its own (`docs/web-workbench-plan.adoc`), so every code location a
+//! page renders carries an "open in editor" affordance pointing back at
+//! the desk the reader came from (`crate::pages`'s `editor_open`). The
+//! editor is resolved from `ENTS_EDITOR`, then `EDITOR` -- the same
+//! override-then-general order git applies to `GIT_EDITOR`/`EDITOR` --
+//! once per process ([`detected`]); an absent or unrecognized value
+//! renders no affordance at all rather than a dead link.
+//!
+//! Deep links use each editor's own URL scheme (`zed://file/...`,
+//! `vscode://file/...`). Neovim has no scheme of its own, so its links
+//! use the community `nvim://file/...` shape -- they work only where the
+//! reader has registered a handler for it, which is stated here rather
+//! than hidden: the icon still names the editor the user configured.
+
+use std::path::Path;
+use std::sync::LazyLock;
+
+/// The editors this crate can deep-link into, resolved by [`detected`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Editor {
+ /// `zed://file/<path>:<line>` (Zed's own scheme).
+ Zed,
+ /// `vscode://file/<path>:<line>` (VS Code's own scheme; Codium
+ /// installs it too).
+ VsCode,
+ /// `nvim://file/<path>:<line>` -- no official scheme exists, so this
+ /// is the community handler shape (see this module's own doc).
+ Neovim,
+}
+
+impl Editor {
+ /// The editor's display name, the affordance's `title` text.
+ pub(crate) fn label(self) -> &'static str {
+ match self {
+ Self::Zed => "Zed",
+ Self::VsCode => "VS Code",
+ Self::Neovim => "Neovim",
+ }
+ }
+
+ /// The editor's `crate::assets::sprite` symbol id.
+ pub(crate) fn icon(self) -> &'static str {
+ match self {
+ Self::Zed => "i-ed-zed",
+ Self::VsCode => "i-ed-code",
+ Self::Neovim => "i-ed-nvim",
+ }
+ }
+
+ /// The URL-scheme prefix up to and including `file` -- the deep link
+ /// is `<scheme>/<absolute path>[:<line>]`.
+ fn scheme(self) -> &'static str {
+ match self {
+ Self::Zed => "zed://file",
+ Self::VsCode => "vscode://file",
+ Self::Neovim => "nvim://file",
+ }
+ }
+
+ /// The deep link opening `abs` (an absolute path) in this editor,
+ /// at `line` when given.
+ pub(crate) fn deep_link(self, abs: &Path, line: Option<u64>) -> String {
+ let scheme = self.scheme();
+ let path = abs.display();
+ match line {
+ Some(line) => format!("{scheme}{path}:{line}"),
+ None => format!("{scheme}{path}"),
+ }
+ }
+}
+
+/// Parse one editor-variable value: the command's first token's basename,
+/// matched against the launchers each recognized editor ships. `None` for
+/// anything else -- an unknown editor gets no affordance, never a dead
+/// link.
+fn parse(value: &str) -> Option<Editor> {
+ let command = value.split_whitespace().next()?;
+ let name = Path::new(command)
+ .file_name()?
+ .to_string_lossy()
+ .to_lowercase();
+ match name.as_str() {
+ "zed" | "zeditor" => Some(Editor::Zed),
+ "code" | "code-insiders" | "codium" | "vscodium" => Some(Editor::VsCode),
+ "nvim" | "neovim" | "neovide" | "vim" | "gvim" => Some(Editor::Neovim),
+ _ => None,
+ }
+}
+
+/// The serving user's editor: the first of `ENTS_EDITOR`, `EDITOR` that
+/// names one this crate recognizes ([`parse`]), read once per process --
+/// `git ents serve` runs in the user's own environment, so the variables
+/// are the same ones their shell hands every other tool.
+pub(crate) fn detected() -> Option<Editor> {
+ static DETECTED: LazyLock<Option<Editor>> = LazyLock::new(|| {
+ ["ENTS_EDITOR", "EDITOR"]
+ .iter()
+ .filter_map(|name| std::env::var(name).ok())
+ .find_map(|value| parse(&value))
+ });
+ *DETECTED
+}
+
+#[cfg(test)]
+mod tests {
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ #[case::bare_zed("zed", Some(Editor::Zed))]
+ #[case::zed_with_flags("zed --wait", Some(Editor::Zed))]
+ #[case::absolute_code("/usr/local/bin/code -g", Some(Editor::VsCode))]
+ #[case::codium("codium", Some(Editor::VsCode))]
+ #[case::nvim("nvim", Some(Editor::Neovim))]
+ #[case::vim_maps_to_the_neovim_icon("vim", Some(Editor::Neovim))]
+ #[case::neovide("neovide", Some(Editor::Neovim))]
+ #[case::unknown("ed", None)]
+ #[case::empty("", None)]
+ fn parse_matches_the_command_basename(#[case] value: &str, #[case] expected: Option<Editor>) {
+ assert_eq!(parse(value), expected);
+ }
+
+ #[rstest]
+ fn deep_link_carries_scheme_path_and_line() {
+ let abs = Path::new("/repo/src/main.rs");
+ assert_eq!(
+ Editor::Zed.deep_link(abs, Some(21)),
+ "zed://file/repo/src/main.rs:21"
+ );
+ assert_eq!(
+ Editor::VsCode.deep_link(abs, None),
+ "vscode://file/repo/src/main.rs"
+ );
+ assert_eq!(
+ Editor::Neovim.deep_link(abs, Some(3)),
+ "nvim://file/repo/src/main.rs:3"
+ );
+ }
+}
crates/cli/ents-web/src/error.rs
@@ -1,0 +1,175 @@
+//! `ents-web`'s error type: every failure a page handler can hit, rendered
+//! as an HTTP response by rendered per-page (via the `IntoResponse` impl below) rather than at the
+//! type itself — a web frontend renders failures as pages/status codes, not
+//! terminal text, so this module stays data-only (mirrors `git-ents`'s own
+//! `error.rs` shape, one variant per failure source).
+
+/// Every way a page handler in this crate can fail.
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ /// The named entity does not exist.
+ #[error("not found: {what}")]
+ NotFound {
+ /// What was being looked up.
+ what: String,
+ },
+
+ /// A malformed request: a bad line-range, an unparsable object id, a
+ /// missing required form field.
+ #[error("invalid request: {0}")]
+ InvalidArgument(String),
+
+ /// The gate refused the proposed mutation (`gate.verdict-reason`).
+ #[error("rejected: {0}")]
+ Refused(String),
+
+ /// `receive` rejected the batch as a stale compare-and-swap.
+ #[error("rejected: {name} changed concurrently, retry")]
+ Stale {
+ /// The ref whose precondition was stale.
+ name: String,
+ },
+
+ /// A previously redacted object would have been refilled by this
+ /// mutation (`receive.redaction-ingest`).
+ #[error("refused: object {oid} was redacted and cannot be refilled")]
+ Redacted {
+ /// The redacted object id.
+ oid: gix_hash::ObjectId,
+ },
+
+ /// The request's CSRF token was missing or did not match the session's
+ /// (`roots.web-session`).
+ #[error("invalid or missing CSRF token")]
+ BadCsrf,
+
+ /// No session cookie was presented, or it named a session this server
+ /// no longer holds in memory (`roots.web-session`): the process
+ /// restarted, or the cookie is forged.
+ #[error("no valid session")]
+ NoSession,
+
+ /// A `gix-ref-store` failure: reading or writing a ref.
+ #[error(transparent)]
+ Refs(#[from] gix_ref_store::Error),
+
+ /// An `ents-model` failure: building or validating a refname or typed
+ /// tree.
+ #[error(transparent)]
+ Model(#[from] ents_model::Error),
+
+ /// A `facet-git-tree` (de)serialization failure.
+ #[error(transparent)]
+ Tree(#[from] facet_git_tree::Error),
+
+ /// An `ents-anchor` failure: capturing or projecting a code anchor.
+ #[error(transparent)]
+ Anchor(#[from] ents_anchor::Error),
+
+ /// An `ents-forge` failure: anchoring, serializing, or proposing a
+ /// comment mutation. Boxed: `ents_forge::Error` is large enough on its
+ /// own to trip `clippy::result_large_err` if stored inline (mirrors
+ /// `git-ents::error::Error::Forge`'s identical boxing).
+ #[error(transparent)]
+ Forge(Box<ents_forge::Error>),
+
+ /// An `ents-effect` failure: toolchain resolution or import (the error
+ /// type `ents-kiln`'s own toolchain module reuses as-is, per that
+ /// crate's own doc). Boxed; see [`Error::Forge`]'s own doc.
+ #[error(transparent)]
+ Effect(Box<ents_effect::Error>),
+
+ /// An `ents-receive` failure: `receive` itself could not reach an
+ /// outcome. Boxed; see [`Error::Forge`]'s own doc.
+ #[error(transparent)]
+ Receive(Box<ents_receive::Error>),
+
+ /// `crate::asciidoc::to_html` could not parse or convert an AsciiDoc
+ /// blob (`acdc` reported no more specific error than "could not
+ /// convert").
+ #[error("could not render asciidoc: {0}")]
+ Asciidoc(String),
+
+ /// `crate::pages::files` could not open the served repository or read
+ /// its `HEAD` tree/a tree or blob within it (`gix::open`, a tree
+ /// lookup, or a blob read).
+ #[error("could not read repository: {0}")]
+ Repo(String),
+}
+
+impl From<ents_forge::Error> for Error {
+ fn from(source: ents_forge::Error) -> Self {
+ Self::Forge(Box::new(source))
+ }
+}
+
+impl From<ents_effect::Error> for Error {
+ fn from(source: ents_effect::Error) -> Self {
+ Self::Effect(Box::new(source))
+ }
+}
+
+impl From<ents_receive::Error> for Error {
+ fn from(source: ents_receive::Error) -> Self {
+ Self::Receive(Box::new(source))
+ }
+}
+
+/// Translate a reached [`ents_receive::Outcome`] into `Ok(())` on success or
+/// an [`Error`] otherwise — this crate's counterpart to
+/// `git_ents::mutate::outcome_to_result`, kept as a free function here for
+/// exactly the same reason: every page that proposes a mutation renders a
+/// refusal identically.
+///
+/// # Errors
+///
+/// [`Error::Refused`], [`Error::Stale`], or [`Error::Redacted`]; see
+/// `git_ents::mutate::outcome_to_result` for the identical mapping this
+/// mirrors.
+pub fn outcome_to_result(outcome: ents_receive::Outcome) -> Result<()> {
+ match outcome.result {
+ ents_receive::TxResult::Applied => Ok(()),
+ ents_receive::TxResult::Refused => {
+ let reasons = outcome
+ .verdicts
+ .iter()
+ .filter_map(|(_, verdict)| match verdict {
+ ents_gate::Verdict::Fail(refusal) => Some(refusal.to_string()),
+ ents_gate::Verdict::Pass(_) => None,
+ })
+ .collect::<Vec<_>>()
+ .join("; ");
+ Err(Error::Refused(reasons))
+ }
+ ents_receive::TxResult::Rejected { name } => Err(Error::Stale {
+ name: name.as_bstr().to_string(),
+ }),
+ ents_receive::TxResult::Redacted { oid } => Err(Error::Redacted { oid }),
+ }
+}
+
+/// This crate's `Result` alias.
+pub type Result<T> = std::result::Result<T, Error>;
+
+impl axum::response::IntoResponse for Error {
+ fn into_response(self) -> axum::response::Response {
+ use axum::http::StatusCode;
+
+ let status = match &self {
+ Error::NotFound { .. } => StatusCode::NOT_FOUND,
+ // A forge entity with no ref at all is as much a 404 as this
+ // crate's own NotFound -- the box exists for variant-size
+ // hygiene, not to demote the status to a 500.
+ Error::Forge(inner) if matches!(inner.as_ref(), ents_forge::Error::NotFound { .. }) => {
+ StatusCode::NOT_FOUND
+ }
+ Error::InvalidArgument(_) | Error::BadCsrf => StatusCode::BAD_REQUEST,
+ Error::NoSession => StatusCode::UNAUTHORIZED,
+ Error::Refused(_) | Error::Stale { .. } | Error::Redacted { .. } => {
+ StatusCode::CONFLICT
+ }
+ _ => StatusCode::INTERNAL_SERVER_ERROR,
+ };
+ (status, self.to_string()).into_response()
+ }
+}
crates/cli/ents-web/src/identity.rs
@@ -1,0 +1,107 @@
+//! The signing-identity seam (`roots.web-agnostic`, `roots.web-signing`):
+//! the one new trait this crate introduces, because gitoxide and the
+//! kernel are both silent on "who signs a web-originated commit" -- exactly
+//! the carve-out `arch.no-object-store-trait` reserves for "the pluggable
+//! ref store, server-side receive framing, and reachability artifacts...
+//! and new seams where upstream is silent."
+//!
+//! Every page that proposes a mutation is handed a
+//! `&dyn SigningIdentity` through [`crate::state::AppState`]; nothing in
+//! [`crate::pages`] ever loads a key, resolves `user.signingkey`, or knows
+//! whether the identity behind it belongs to the local operator or a
+//! hosted server's own worker account. That is the whole point
+//! (`roots.web-signing`): a hosted deployment's composition root wires an
+//! identity backed by the server's own enrolled member key, a local
+//! deployment's wires one backed by the user's own key
+//! (`git_ents::sign::Signer`, injected by `git-ents`'s own `serve`
+//! command) -- both satisfy this same trait, and no branch anywhere in
+//! this crate asks which one it was handed.
+
+/// Everything a page handler needs to sign a mutation commit on behalf of
+/// the current request, injected by the composition root
+/// (`roots.web-agnostic`).
+///
+/// # Examples
+///
+/// A fixture identity, standing in for either a local user's key or a
+/// hosted server's worker key -- [`crate::pages`] cannot tell which from
+/// this trait alone, which is exactly `roots.web-signing`'s requirement.
+///
+/// ```
+/// use ents_web::identity::SigningIdentity;
+///
+/// struct Fixed;
+/// impl SigningIdentity for Fixed {
+/// fn actor(&self) -> gix::actor::Signature {
+/// gix::actor::Signature {
+/// name: "fixture".into(),
+/// email: "fixture@ents.test".into(),
+/// time: gix::date::Time { seconds: 0, offset: 0 },
+/// }
+/// }
+/// fn sign(&self, _payload: &[u8]) -> String {
+/// "-----BEGIN SSH SIGNATURE-----\n-----END SSH SIGNATURE-----\n".to_owned()
+/// }
+/// fn public_openssh(&self) -> String {
+/// "ssh-ed25519 AAAA... fixture".to_owned()
+/// }
+/// }
+///
+/// let identity: Box<dyn SigningIdentity> = Box::new(Fixed);
+/// assert_eq!(identity.actor().name, "fixture");
+/// // `label` defaults to `actor().name` when a composition root has no
+/// // better identifier (a resolved member's own username, for instance).
+/// assert_eq!(identity.label(), "fixture");
+/// ```
+// @relation(roots.web-signing, roots.web-agnostic, scope=file)
+pub trait SigningIdentity: Send + Sync {
+ /// The commit author/committer signature every mutation this identity
+ /// signs carries.
+ fn actor(&self) -> gix::actor::Signature;
+
+ /// Sign `payload` (a commit's to-be-signed bytes), returning the
+ /// armored SSHSIG PEM block for the commit's `gpgsig` header.
+ fn sign(&self, payload: &[u8]) -> String;
+
+ /// The public half of this identity's key, in OpenSSH single-line
+ /// format -- used to resolve which enrolled [`ents_model::Member`] is
+ /// acting, exactly as `git ents account create` resolves its own
+ /// signer's member when `--member` is omitted.
+ fn public_openssh(&self) -> String;
+
+ /// This identity's display label for `crate::pages::layout`'s
+ /// `.id-chip` (`roots.web-signing`) -- the one place this crate names
+ /// "who is acting" for a human reader, as opposed to [`Self::actor`]'s
+ /// commit-authorship signature.
+ ///
+ /// Defaults to [`Self::actor`]'s own author name: good enough when a
+ /// composition root has nothing better to show. `git-ents`'s own
+ /// `LocalIdentity` overrides this with the enrolled member's username
+ /// resolved from the signer's public key (falling back to a short key
+ /// fingerprint when no member matches), since `actor().name` there is
+ /// a fixed wordmark ("git-ents"), not a signer identity -- showing it
+ /// in the chip would just duplicate the site logo next to it.
+ fn label(&self) -> String {
+ self.actor().name.to_string()
+ }
+}
+
+/// Build the [`ents_receive::Identity`] every mutation page hands to
+/// `propose_entity`/`propose_delete`.
+///
+/// This is a macro, not a function, deliberately: `ents_receive::Identity`
+/// borrows its `sign` closure (`sign: &'a dyn Fn(&[u8]) -> String`), so the
+/// closure literal must live in the caller's own stack frame -- a helper
+/// function that built and returned an `Identity` would return a
+/// reference to a temporary dropped at that function's end. Every page in
+/// [`crate::pages`] expands this at its own call site instead, exactly the
+/// shape `git_ents::commands::comment::add` and its siblings already use.
+#[macro_export]
+macro_rules! receive_identity {
+ ($identity:expr) => {
+ ents_receive::Identity {
+ actor: $identity.actor(),
+ sign: &|payload| $identity.sign(payload),
+ }
+ };
+}
crates/cli/ents-web/src/lib.rs
@@ -1,0 +1,122 @@
+//! `ents-web`: the web UI (`docs/development-plan.adoc`, phase 7) --
+//! a second leaf, sibling to `git-ents`, in the layering
+//! `docs/abstractions.adoc` states (`substrate -> kernel -> {forge, kiln}
+//! -> {git-ents, ents-web}`).
+//!
+//! This crate's one responsibility is rendering the kernel's and every
+//! installed package's own state as HTML, and accepting signed,
+//! CSRF-checked mutations back -- never a second copy of forge or kiln
+//! business logic. Every page is a thin caller into `ents-model`,
+//! `ents-anchor`, `ents-query`, `ents-receive`, `ents-forge`, or
+//! `ents-kiln`, exactly as `git-ents`'s own `commands` modules are thin
+//! callers into the same crates ([`crate::pages`]'s own module doc draws
+//! the line between the generic, reflection-driven pages and the
+//! legitimate custom ones).
+//!
+//! # Deployment-agnostic by construction (`roots.web-agnostic`)
+//!
+//! Nothing in this crate binds a socket except [`serve_on`], and nothing
+//! upstream of it assumes one exists: `router()` alone builds a complete,
+//! in-process `tower::Service` a caller can drive via
+//! `tower::ServiceExt::oneshot` with no network transport at all -- the
+//! same shape an in-process webview embedding would drive a request
+//! through. See [`identity::SigningIdentity`]'s own doc for the other half
+//! of this requirement: the signing identity a mutation is signed with is
+//! always injected by the composition root, never resolved by this crate
+//! itself.
+//!
+//! # What this crate does not expose (`roots.local`)
+//!
+//! There is no `/info/refs`, no `git-upload-pack`/`git-receive-pack`
+//! route, and no code path that shells to `git` as a smart-HTTP backend
+//! anywhere in `router()`'s route table. `git ents serve`'s own doc
+//! (`git-ents`'s `commands::serve`) states why: the local root's existing
+//! wiring already serves git's own transport for the test-harness case
+//! (`roots.worktree-update`); this crate adds only the web UI on top of
+//! it, on loopback, never a second git-serving surface.
+//!
+//! # Spec coverage
+//!
+//! From `docs/spec/roots.adoc`:
+//!
+//! - `roots.local` -- this crate's route table carries no git
+//! smart-HTTP surface; `git-ents`'s own `serve` command reuses
+//! `LocalRoot`'s existing seams and binds loopback only (see that
+//! crate's `commands::serve` module).
+//! - `roots.web-signing`, `roots.web-agnostic` -- [`identity::SigningIdentity`].
+//! - `roots.web-session` -- [`session::SessionStore`], and
+//! `pages::require_csrf` on every state-changing route.
+//!
+//! `roots.path-validation` and `roots.fetch-auth` are out of scope for
+//! this crate: both describe `git-ents-server`'s multi-repository hosted
+//! root (phase 8) -- "reject a path that would escape the data
+//! directory, nest inside an existing repository, or collide with a
+//! non-repository namespace directory" and "private-repository access...
+//! out of scope for v1" both presuppose a data directory holding more
+//! than one repository, which does not exist until that phase. This
+//! crate's composition root always already has exactly one, already-open
+//! repository.
+//!
+//! # Examples
+//!
+//! Driving a full request through this crate with no socket bound at all
+//! (`roots.web-agnostic`'s in-process case) -- see `tests/router.rs` for
+//! the full-fixture version of this same shape, wired against a real
+//! signed member.
+//!
+//! ```
+//! use std::sync::Arc;
+//!
+//! use ents_web::identity::SigningIdentity;
+//! use ents_web::state::AppState;
+//! use ents_receive::{Mode, NullEventSink};
+//! use ents_testutil::ObjectStore;
+//! use gix_ref_store::LooseRefStore;
+//! use http_body_util::BodyExt as _;
+//! use tower::ServiceExt as _;
+//!
+//! struct Fixture;
+//! impl SigningIdentity for Fixture {
+//! fn actor(&self) -> gix::actor::Signature {
+//! gix::actor::Signature {
+//! name: "fixture".into(), email: "fixture@ents.test".into(),
+//! time: gix::date::Time { seconds: 0, offset: 0 },
+//! }
+//! }
+//! fn sign(&self, _payload: &[u8]) -> String { String::new() }
+//! fn public_openssh(&self) -> String { "ssh-ed25519 AAAA... fixture".to_owned() }
+//! }
+//!
+//! # let runtime = tokio::runtime::Runtime::new().expect("runtime");
+//! # runtime.block_on(async {
+//! let dir = tempfile::tempdir().expect("tempdir");
+//! gix::init(dir.path()).expect("init");
+//! let refs = LooseRefStore::open(dir.path()).expect("opens");
+//! let objects = ObjectStore::default();
+//! let state = Arc::new(AppState::new(
+//! Box::new(refs), objects, Box::new(NullEventSink), Mode::Advisory,
+//! Box::new(Fixture), dir.path().to_owned(),
+//! ));
+//! let router = ents_web::router(state);
+//! let response = router
+//! .oneshot(axum::http::Request::get("/").body(axum::body::Body::empty()).expect("request"))
+//! .await
+//! .expect("in-process call");
+//! assert_eq!(response.status(), axum::http::StatusCode::OK);
+//! # });
+//! ```
+
+pub(crate) mod asciidoc;
+pub(crate) mod assets;
+pub(crate) mod editor;
+pub mod error;
+pub mod identity;
+pub(crate) mod markdown;
+pub mod pages;
+pub mod render;
+pub mod router;
+pub mod session;
+pub mod state;
+
+pub use error::{Error, Result};
+pub use router::{bind, router, serve_on};
crates/cli/ents-web/src/markdown.rs
@@ -1,0 +1,287 @@
+//! Markdown rendering via [`pulldown_cmark`].
+//!
+//! Markdown gets the same treatment AsciiDoc does (`crate::asciidoc`): a
+//! `.md` blob in [`crate::pages::files`] renders as a formatted document
+//! rather than a plain-text listing. Output is an embedded fragment (no
+//! document frame) styled by [`crate::assets::OVERRIDES`]'s own
+//! `.doc-body` rules.
+//!
+//! A document opening with YAML (`---`) or TOML (`+++`) frontmatter has
+//! it stripped from the rendered body ([`split_frontmatter`]) and
+//! rendered above the document as a key-value properties table instead
+//! ([`crate::render::properties_table`]) -- the same table
+//! [`crate::asciidoc`] renders a header's attribute entries through. The
+//! parse is deliberately a minimal, line-based one over top-level scalar
+//! keys (see [`split_frontmatter`]'s own doc): no YAML/TOML dependency
+//! carries its weight for a display-only table that renders anything
+//! nested as raw text anyway.
+
+use maud::{Markup, PreEscaped, html as maud_html};
+use pulldown_cmark::{Options, Parser, html};
+
+/// File extensions that name a Markdown document.
+const EXTENSIONS: [&str; 4] = ["md", "markdown", "mdown", "mkd"];
+
+/// Whether `name` looks like a Markdown file by its extension.
+#[must_use]
+pub(crate) fn is_markdown(name: &str) -> bool {
+ name.rsplit_once('.')
+ .is_some_and(|(_, ext)| EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e)))
+}
+
+/// Render Markdown `source` to an embedded HTML fragment, with the tables,
+/// footnotes, strikethrough, and task-list extensions people expect from
+/// forge-flavored Markdown. Leading YAML/TOML frontmatter is stripped from
+/// the body and rendered above it as a properties table (this module's own
+/// doc; [`split_frontmatter`]).
+///
+/// No additional sanitization is applied beyond what `pulldown_cmark`
+/// itself guarantees (well-formed HTML output from the parsed Markdown
+/// tree, not sanitized against embedded raw HTML in the source) --
+/// `pre-redo:crates/git-ents-server/src/markdown.rs`'s own `to_html` did
+/// the same: it emitted `pulldown_cmark::html::push_html`'s output
+/// unescaped, straight into the page.
+#[must_use]
+pub(crate) fn to_html(source: &str) -> Markup {
+ let (frontmatter, body) = split_frontmatter(source);
+ let options = Options::ENABLE_TABLES
+ | Options::ENABLE_FOOTNOTES
+ | Options::ENABLE_STRIKETHROUGH
+ | Options::ENABLE_TASKLISTS;
+ let mut out = String::new();
+ html::push_html(&mut out, Parser::new_ext(body, options));
+ maud_html! {
+ (crate::render::properties_table(&frontmatter))
+ (PreEscaped(out))
+ }
+}
+
+/// Split leading frontmatter off `source`: `(entries, body)`, where
+/// `entries` is empty when `source` carries no frontmatter at all and
+/// `body` is the document with the frontmatter block (fences included)
+/// stripped.
+///
+/// Frontmatter is recognized only in the one shape static-site tooling
+/// actually writes: the document's very first line is exactly a `---`
+/// (YAML) or `+++` (TOML) fence, closed by a later line that is exactly
+/// the same fence. A `---` further down the document is a thematic break,
+/// never frontmatter, and an unclosed fence is not frontmatter either --
+/// both render as ordinary Markdown, untouched.
+///
+/// The parse between the fences is deliberately minimal and line-based
+/// (this module's own doc): an unindented `key: value` (YAML) or
+/// `key = value` (TOML) line becomes one entry, with one level of
+/// matching surrounding quotes stripped from the value. Anything deeper
+/// -- an indented nested block, a list continuation, a TOML `[table]`
+/// header and everything after it -- is not parsed: it is appended
+/// verbatim, raw text, to the entry it follows
+/// ([`crate::render::properties_table`] renders it as-is). A nested block
+/// with no preceding entry at all opens one keyed by its own raw first
+/// line, so no frontmatter line is ever silently dropped.
+pub(crate) fn split_frontmatter(source: &str) -> (Vec<(String, String)>, &str) {
+ let (fence, separator) = if source.starts_with("---\n") || source.starts_with("---\r\n") {
+ ("---", ':')
+ } else if source.starts_with("+++\n") || source.starts_with("+++\r\n") {
+ ("+++", '=')
+ } else {
+ return (Vec::new(), source);
+ };
+
+ // Walk physical lines by byte offset so the body can be returned as a
+ // slice of `source` rather than a rebuilt copy.
+ let mut offset: usize = 0;
+ let mut lines = Vec::new();
+ let mut close = None;
+ for line in source.split_inclusive('\n') {
+ let text = line.trim_end_matches(['\n', '\r']);
+ if offset > 0 && text == fence {
+ close = Some(offset.saturating_add(line.len()));
+ break;
+ }
+ if offset > 0 {
+ lines.push(text);
+ }
+ offset = offset.saturating_add(line.len());
+ }
+ let Some(body_start) = close else {
+ return (Vec::new(), source);
+ };
+
+ let mut entries: Vec<(String, String)> = Vec::new();
+ let mut raw_only = false;
+ for line in lines {
+ let top_level = !raw_only
+ && !line.starts_with([' ', '\t'])
+ && line
+ .split_once(separator)
+ .is_some_and(|(key, _)| is_bare_key(key));
+ if top_level && let Some((key, value)) = line.split_once(separator) {
+ entries.push((key.trim().to_owned(), unquote(value.trim()).to_owned()));
+ continue;
+ }
+ if line.trim().is_empty() {
+ continue;
+ }
+ if separator == '=' && line.trim_start().starts_with('[') {
+ // A TOML `[table]` header: nothing after it is top-level, so
+ // the rest of the block stays raw under this one entry.
+ raw_only = true;
+ entries.push((line.trim().to_owned(), String::new()));
+ continue;
+ }
+ match entries.last_mut() {
+ Some((_, value)) => {
+ if !value.is_empty() {
+ value.push('\n');
+ }
+ value.push_str(line);
+ }
+ None => entries.push((line.trim().to_owned(), String::new())),
+ }
+ }
+ (entries, source.get(body_start..).unwrap_or(""))
+}
+
+/// Whether `key` looks like a bare frontmatter key: non-empty, no
+/// whitespace or quoting inside it -- what keeps [`split_frontmatter`]
+/// from misreading prose containing a `:` (or a quoted value containing
+/// `=`) as an entry.
+fn is_bare_key(key: &str) -> bool {
+ let key = key.trim_end();
+ !key.is_empty()
+ && key
+ .chars()
+ .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
+}
+
+/// Strip one level of matching surrounding single or double quotes from a
+/// scalar frontmatter value.
+fn unquote(value: &str) -> &str {
+ let stripped = value
+ .strip_prefix('"')
+ .and_then(|rest| rest.strip_suffix('"'))
+ .or_else(|| {
+ value
+ .strip_prefix('\'')
+ .and_then(|rest| rest.strip_suffix('\''))
+ });
+ stripped.unwrap_or(value)
+}
+
+#[cfg(test)]
+mod tests {
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ #[case::md("readme.md", true)]
+ #[case::markdown("readme.markdown", true)]
+ #[case::upper("README.MD", true)]
+ #[case::adoc("readme.adoc", false)]
+ #[case::no_ext("readme", false)]
+ fn is_markdown_matches_by_extension(#[case] name: &str, #[case] expected: bool) {
+ assert_eq!(is_markdown(name), expected);
+ }
+
+ #[test]
+ fn to_html_renders_a_heading_and_a_table() {
+ let rendered = to_html("# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n").into_string();
+ assert!(rendered.contains("<h1>Title</h1>"));
+ assert!(rendered.contains("<table>"));
+ }
+
+ #[test]
+ fn to_html_strips_frontmatter_from_the_body_and_renders_it_as_properties() {
+ let rendered = to_html("---\ntitle: Design Notes\n---\n# Title\n\nBody.\n").into_string();
+ assert!(
+ rendered.contains("doc-props"),
+ "the properties table renders"
+ );
+ assert!(rendered.contains("Design Notes"));
+ assert!(rendered.contains("<h1>Title</h1>"));
+ assert!(
+ !rendered.contains("<hr"),
+ "the fences are stripped, not rendered as thematic breaks: {rendered}"
+ );
+ }
+
+ #[test]
+ fn split_frontmatter_reads_yaml_scalars_and_strips_the_block() {
+ let (entries, body) = split_frontmatter("---\ntitle: \"Hello\"\ndraft: true\n---\n# Doc\n");
+ assert_eq!(
+ entries,
+ vec![
+ ("title".to_owned(), "Hello".to_owned()),
+ ("draft".to_owned(), "true".to_owned()),
+ ]
+ );
+ assert_eq!(body, "# Doc\n");
+ }
+
+ #[test]
+ fn split_frontmatter_reads_toml_scalars_behind_plus_fences() {
+ let (entries, body) = split_frontmatter("+++\ntitle = 'Hi'\nweight = 3\n+++\nBody.\n");
+ assert_eq!(
+ entries,
+ vec![
+ ("title".to_owned(), "Hi".to_owned()),
+ ("weight".to_owned(), "3".to_owned()),
+ ]
+ );
+ assert_eq!(body, "Body.\n");
+ }
+
+ #[test]
+ fn split_frontmatter_keeps_a_nested_yaml_block_as_raw_text_under_its_key() {
+ let (entries, body) = split_frontmatter("---\ntags:\n - a\n - b\nname: x\n---\nBody.\n");
+ assert_eq!(
+ entries,
+ vec![
+ ("tags".to_owned(), " - a\n - b".to_owned()),
+ ("name".to_owned(), "x".to_owned()),
+ ]
+ );
+ assert_eq!(body, "Body.\n");
+ }
+
+ #[test]
+ fn split_frontmatter_keeps_a_toml_table_and_everything_after_it_raw() {
+ let (entries, _body) =
+ split_frontmatter("+++\ntitle = 'Hi'\n[params]\nx = 1\n+++\nBody.\n");
+ assert_eq!(
+ entries,
+ vec![
+ ("title".to_owned(), "Hi".to_owned()),
+ ("[params]".to_owned(), "x = 1".to_owned()),
+ ]
+ );
+ }
+
+ #[rstest]
+ #[case::no_fence_at_all("# Just a doc\n")]
+ #[case::fence_not_first("\n---\nkey: value\n---\n")]
+ #[case::unclosed_fence("---\nkey: value\n# Doc\n")]
+ #[case::thematic_break_later("# Doc\n\n---\n\nMore.\n")]
+ fn split_frontmatter_leaves_a_document_without_frontmatter_untouched(#[case] source: &str) {
+ let (entries, body) = split_frontmatter(source);
+ assert!(entries.is_empty());
+ assert_eq!(body, source);
+ }
+
+ #[test]
+ fn split_frontmatter_keeps_a_prose_colon_line_raw_rather_than_splitting_it() {
+ let (entries, _body) =
+ split_frontmatter("---\nnote this: is prose\nreal-key: yes\n---\nBody.\n");
+ assert_eq!(
+ entries,
+ vec![
+ // Not a bare key ("note this" holds a space), so the line
+ // stays raw -- and with no entry before it, it opens one
+ // keyed by its own text rather than being dropped.
+ ("note this: is prose".to_owned(), String::new()),
+ ("real-key".to_owned(), "yes".to_owned()),
+ ]
+ );
+ }
+}
crates/cli/ents-web/src/pages/account.rs
@@ -1,0 +1,235 @@
+//! `GET /account`, `POST /account`: who the current session is (the
+//! serving identity's enrolled member, `roots.web-signing` -- there is no
+//! login flow, the signing key *is* the identity), followed by the
+//! generic *view* of [`ents_model::Account`] (`crate::render::view`,
+//! reflection-driven, the same mechanism [`super::members`] and
+//! [`super::redactions`] use), paired with this crate's one demonstrated
+//! generic-edit write flow (`roots.web-session`'s signed, CSRF-checked
+//! mutation path).
+//!
+//! Account is the write-flow demo rather than every entity because it is
+//! the simplest possible case -- two string-shaped fields, one fixed ref,
+//! no anchor or recipe machinery to special-case -- so the CSRF/session/
+//! signing plumbing this page exercises is visible without also chasing a
+//! more complex entity's own domain logic. Every other write flow this
+//! crate ships ([`super::comments::add`]) is a legitimate custom page for
+//! exactly the reason `ents-forge`'s own comment command is: anchoring
+//! needs a repository checkout and a projection, not a bare form.
+
+use std::sync::Arc;
+
+use axum::Form;
+use axum::extract::State;
+use axum::response::{IntoResponse, Redirect};
+use ents_model::{Account, Member, MemberId, namespace};
+use ents_receive::propose_entity;
+use gix_object::{Find, Write};
+use maud::html;
+use serde::Deserialize;
+
+use crate::error::{Error, Result};
+use crate::session::Session;
+use crate::state::AppState;
+
+/// `GET /account`: who the current session is, first -- the enrolled
+/// member whose key the serving identity signs with, as the same identity
+/// card `crate::pages::members` renders, or the unenrolled key itself --
+/// then the recorded [`Account`] (the hosted login mapping) with its edit
+/// form. There is no login flow to land on: a local root's identity *is*
+/// the signing key `git ents serve` resolved at startup
+/// (`roots.web-signing`), so this page states that rather than asking for
+/// credentials.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let pubkey = state.identity.public_openssh();
+ let enrolled = resolve_member_by_key(&state, &pubkey).ok();
+ let current = read(&state)?;
+ let (member_value, login_value) = match ¤t {
+ Some(account) => (account.member.as_str().to_owned(), account.login.clone()),
+ None => (String::new(), String::new()),
+ };
+ let view = current
+ .as_ref()
+ .map(crate::render::view)
+ .unwrap_or_else(|| html! { p.muted { "No login mapping recorded." } });
+
+ Ok(super::layout(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ super::Tab::Account,
+ "Account",
+ html! {
+ div.readable {
+ @match &enrolled {
+ Some((username, member)) => {
+ p {
+ "Signed in as the member below. Every web edit is a "
+ "mutation commit signed with this key, exactly as "
+ code { "git ents" }
+ " itself would sign it -- a local root has no separate login."
+ }
+ (super::members::member_card(username.as_str(), member, true))
+ }
+ None => {
+ div.card {
+ p {
+ "This signing key is not enrolled as a "
+ a href="/members" { "member" }
+ " of this repository. Edits still sign with it; enroll "
+ "the key to have them attributed to a username."
+ }
+ pre { (pubkey) }
+ }
+ }
+ }
+ h2 { "Hosted login" }
+ p.muted {
+ "A hosted deployment maps an external login to an enrolled "
+ "member so its pushes can be attributed. A local root never "
+ "needs one -- the key above is the identity."
+ }
+ (view)
+ details {
+ summary { "Edit" }
+ form method="post" action="/account" {
+ (super::csrf_input(&session))
+ label { "member" input type="text" name="member" value=(member_value) list="members"; }
+ label { "login" input type="text" name="login" value=(login_value); }
+ button type="submit" { "Save" }
+ }
+ (super::members_datalist(&state))
+ }
+ }
+ },
+ ))
+}
+
+/// The form fields `POST /account` accepts.
+#[derive(Debug, Deserialize)]
+pub struct AccountForm {
+ /// The member this account belongs to; if blank, resolved from the
+ /// signing identity's own enrolled key (mirrors
+ /// `git_ents::commands::account::create`'s identical default).
+ #[serde(default)]
+ member: String,
+ /// The login identity to record.
+ login: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+/// `POST /account`: create or update the account, signed
+/// (`roots.web-signing`) on behalf of the current session
+/// (`roots.web-session`).
+///
+/// # Errors
+///
+/// [`Error::BadCsrf`] if `form.csrf` does not match the session's own
+/// token; [`Error::NotFound`] if `member` is blank and the signing
+/// identity's key is not an enrolled member; otherwise propagates a
+/// serialization or `receive` failure.
+// @relation(roots.web-signing, roots.web-session, scope=function)
+pub async fn update<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Form(form): Form<AccountForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+
+ let member = if form.member.trim().is_empty() {
+ resolve_member_by_key(&state, &state.identity.public_openssh())?.0
+ } else {
+ MemberId::new(form.member.trim())
+ };
+ let account = Account {
+ member,
+ login: form.login,
+ };
+
+ #[expect(
+ clippy::expect_used,
+ reason = "ACCOUNT_REF is a fixed, compile-time-known-valid refname literal, mirroring \
+ git_ents::commands::account's identical unguarded conversion"
+ )]
+ let name: gix::refs::FullName = namespace::ACCOUNT_REF
+ .try_into()
+ .expect("fixed, valid refname");
+
+ let identity = state.identity.as_ref();
+ let outcome = propose_entity(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ name,
+ &account,
+ &crate::receive_identity!(identity),
+ "Create account (web)",
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(Redirect::to("/account"))
+}
+
+fn read<O: Find>(state: &AppState<O>) -> Result<Option<Account>> {
+ #[expect(
+ clippy::expect_used,
+ clippy::unwrap_in_result,
+ reason = "ACCOUNT_REF is a fixed, compile-time-known-valid refname literal"
+ )]
+ let name: gix::refs::FullName = namespace::ACCOUNT_REF
+ .try_into()
+ .expect("fixed, valid refname");
+ let Some(tip) = state.refs.get(name.as_ref())? else {
+ return Ok(None);
+ };
+ let tree = super::commit_tree(&*state.objects(), tip)?;
+ Ok(Some(facet_git_tree::deserialize::<Account>(
+ &tree,
+ &*state.objects(),
+ )?))
+}
+
+/// Resolve `pubkey` to the enrolled member whose stored key matches it
+/// (its id and full [`Member`] record), or [`Error::NotFound`] when none
+/// does — shared with `crate::pages::commits::review`, which needs the
+/// same "which member is this session" lookup to key a review's composite
+/// `refs/meta/reviews/<target>/<member>` ref (`model.review`), and with
+/// [`show`]'s own signed-in-as card.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if no enrolled member's key matches `pubkey`;
+/// otherwise propagates a ref-store or object read failure.
+pub(crate) fn resolve_member_by_key<O: Find>(
+ state: &AppState<O>,
+ pubkey: &str,
+) -> Result<(MemberId, Member)> {
+ for entry in state.refs.iter_prefix("refs/meta/member/")? {
+ let (name, tip) = entry?;
+ let path = name.as_bstr().to_string();
+ let Some(username) = path.strip_prefix("refs/meta/member/") else {
+ continue;
+ };
+ let tree = super::commit_tree(&*state.objects(), tip)?;
+ if let Ok(member) = facet_git_tree::deserialize::<Member>(&tree, &*state.objects())
+ && member.key == pubkey
+ {
+ return Ok((MemberId::new(username), member));
+ }
+ }
+ Err(Error::NotFound {
+ what: "member for the current signing identity".to_owned(),
+ })
+}
crates/cli/ents-web/src/pages/comments.rs
@@ -1,0 +1,875 @@
+//! `GET /comments`, `GET /comments/{id}`, `POST /comments`: a custom (not
+//! generic) page family, per this crate's own top-level doc -- a
+//! comment's anchor needs projection against a live working tree
+//! (`anchor.projection`) to render meaningfully, which is exactly the
+//! kind of domain-specific view `ents-forge`'s own `comment::show`
+//! already returns structured data for, rather than a bare reflected
+//! field list.
+//!
+//! `for_path`/`comment_card`/`comments_section` are this module's
+//! second entry point: `crate::pages::files`'s blob view calls them to
+//! render the comments anchored to the file it is showing -- inline,
+//! interleaved at the anchored line, or in a below-the-blob section for
+//! one with no current line to interleave at -- rather than duplicating
+//! this module's own read-project-render pattern or its card markup.
+//! `for_commit` is a third: `crate::pages::commits::show`'s own
+//! "conversation" section, listing every comment whose anchor was captured
+//! against that exact commit (`Anchor::commit`, not a projection onto any
+//! revision -- a commit page shows what was written about that commit,
+//! not merely reachable from it).
+
+use std::sync::Arc;
+
+use axum::Form;
+use axum::extract::{Path, Query as PathQuery, State};
+use axum::response::{IntoResponse, Redirect};
+use ents_anchor::{Anchor, LineRange, Projection};
+use ents_forge::comment;
+use gix_hash::ObjectId;
+use gix_object::{Find, Write};
+use maud::{Markup, html};
+use serde::Deserialize;
+
+use crate::error::Result;
+use crate::session::Session;
+use crate::state::AppState;
+
+/// The query parameters `GET /comments` accepts: `file`/`lines`/`rev`
+/// prefill the add-comment form (e.g. a link from `crate::pages::files`'s
+/// "comment on this file", or `crate::pages::commits::show`'s "comment on
+/// this commit"), rather than changing what the page lists. All three
+/// default to empty except `rev`, which defaults to `HEAD` exactly as the
+/// add form always has -- an absent or nonsensical `file`/`lines` value
+/// (neither is ever parsed here, only echoed back into the form) is
+/// exactly as inert as an absent one.
+#[derive(Debug, Deserialize)]
+pub struct ListQuery {
+ /// Pre-fills the add form's `path` field.
+ #[serde(default)]
+ file: String,
+ /// Pre-fills the add form's `lines` field.
+ #[serde(default)]
+ lines: String,
+ /// Pre-fills the add form's `rev` field; defaults to `HEAD`.
+ #[serde(default = "default_rev_field")]
+ rev: String,
+}
+
+impl Default for ListQuery {
+ fn default() -> Self {
+ Self {
+ file: String::new(),
+ lines: String::new(),
+ rev: default_rev_field(),
+ }
+ }
+}
+
+/// `GET /comments?file=<path>&lines=<range>&rev=<rev>`.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+pub async fn list<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ PathQuery(query): PathQuery<ListQuery>,
+) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let (rows, unreadable) = comment::list_all(state.refs.as_ref(), &*state.objects())?;
+ let failures: Vec<(String, String)> = unreadable
+ .into_iter()
+ .map(|entry| (entry.refname, entry.error))
+ .collect();
+ Ok(super::layout(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ super::Tab::Comments,
+ "Comments",
+ html! {
+ div.readable {
+ (crate::render::unreadable_disclosure(&failures))
+ @if rows.is_empty() {
+ (super::blankslate(
+ "No comments yet",
+ html! { "Anchor one to a file with the form below." },
+ ))
+ } @else {
+ @for (id, comment) in &rows {
+ (listing_card(&state, id, comment))
+ }
+ }
+ h2 { "Add a Comment" }
+ (add_form(&query.rev, &session, &query.file, &query.lines))
+ }
+ },
+ ))
+}
+
+/// The query parameters `GET /comments/{id}` accepts: which revision to
+/// project the anchor onto (defaults to `HEAD`).
+#[derive(Debug, Deserialize)]
+pub struct ShowQuery {
+ /// The revision to project onto; defaults to `HEAD`.
+ #[serde(default = "default_rev_field")]
+ rev: String,
+}
+
+fn default_rev_field() -> String {
+ "HEAD".to_owned()
+}
+
+/// `GET /comments/{id}?rev=...`: the comment's body, its anchor, and the
+/// projection of that anchor onto `rev` (`anchor.projection`). Its state
+/// (`model.comment-state`) and the reply/resolve/reopen actions
+/// (`action_forms`, `model.comment-thread`, `model.comment-state`) render
+/// alongside, so a comment is a conversation from its own page and not only
+/// from an issue's or a review's.
+///
+/// # Errors
+///
+/// [`crate::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`]) if
+/// `id` has no comment ref at all; a comment ref whose stored tree this
+/// build cannot read back degrades to [`crate::render::unreadable`]'s
+/// marker card instead of erroring.
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path(id): Path<String>,
+ PathQuery(query): PathQuery<ShowQuery>,
+) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let (comment, projected) = match comment::show(
+ state.refs.as_ref(),
+ &*state.objects(),
+ &state.path,
+ &id,
+ &query.rev,
+ false,
+ ) {
+ Ok(read) => read,
+ // No ref at all stays a real 404; any other failure (a tree this
+ // build's shape cannot read back, written by an older schema) is
+ // an existing entity this page degrades to the plain unreadable
+ // card for, never a 404 or a 500.
+ Err(source @ ents_forge::Error::NotFound { .. }) => return Err(source.into()),
+ Err(source) => {
+ return Ok(super::layout(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ super::Tab::Comments,
+ &format!("Comment {}", ents_forge::abbreviate_id(&id)),
+ html! {
+ (super::child_crumbs("comments", "/comments", ents_forge::abbreviate_id(&id)))
+ div.readable { (crate::render::unreadable(&source.to_string())) }
+ },
+ ));
+ }
+ };
+ let resolved = comment.state == "resolved";
+ let return_to = format!("/comments/{id}");
+ let body =
+ crate::asciidoc::to_html(&comment.body).unwrap_or_else(|_| html! { p { (comment.body) } });
+ Ok(super::layout(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ super::Tab::Comments,
+ &format!("Comment {}", ents_forge::abbreviate_id(&id)),
+ html! {
+ (super::child_crumbs("comments", "/comments", ents_forge::abbreviate_id(&id)))
+ div.readable {
+ div.card {
+ div.comment-meta {
+ span.comment-state { (comment.state) }
+ @if let Some(context) = &comment.context {
+ (context_link(context))
+ }
+ @if let Some(parent) = &comment.parent {
+ a href={ "/comments/" (parent) } {
+ "in reply to " (ents_forge::abbreviate_id(parent))
+ }
+ }
+ @if let Some((anchor, _)) = &projected {
+ a href={ "/files/" (anchor.path) (line_fragment(anchor.lines)) } {
+ (anchor.path) (line_label(anchor.lines))
+ }
+ (super::editor_open(&state, &anchor.path, anchor.lines.map(|range| range.start)))
+ }
+ }
+ @if let Some((_, projection)) = &projected {
+ div.comment-meta {
+ span { "at " (query.rev) ": " (projection_label(projection)) }
+ }
+ }
+ div.doc-body { (body) }
+ }
+ (action_forms(&session, &id, resolved, &return_to))
+ }
+ },
+ ))
+}
+
+/// The `#L<start>[-L<end>]` fragment a files link carries for an anchored
+/// range, or nothing for a whole-file anchor -- the same fragment shape
+/// `crate::pages::files`'s gutter anchors and `ents.js`'s hash handling
+/// use.
+fn line_fragment(lines: Option<LineRange>) -> String {
+ match lines {
+ Some(range) if range.start == range.end => format!("#L{}", range.start),
+ Some(range) => format!("#L{}-L{}", range.start, range.end),
+ None => String::new(),
+ }
+}
+
+/// The `:21` / `:21-23` suffix a path locator shows for an anchored range,
+/// or nothing for a whole-file anchor.
+fn line_label(lines: Option<LineRange>) -> String {
+ match lines {
+ Some(range) if range.start == range.end => format!(":{}", range.start),
+ Some(range) => format!(":{}-{}", range.start, range.end),
+ None => String::new(),
+ }
+}
+
+/// One human sentence for a projection result (`anchor.projection`) --
+/// never the enum's `Debug` form.
+fn projection_label(projection: &Projection) -> String {
+ match projection {
+ Projection::Current => "anchored lines unchanged".to_owned(),
+ Projection::Relocated { path, lines } => {
+ format!("moved to {path}{}", line_label(*lines))
+ }
+ Projection::Outdated { path } => {
+ format!("outdated \u{2014} the anchored lines in {path} have been edited")
+ }
+ Projection::Deleted => "the anchored file no longer exists".to_owned(),
+ }
+}
+
+/// A context's own page link: `issues/<id>` and `reviews/<target>/<member>`
+/// land on the issue and commit pages that render those threads
+/// (`model.comment-context`); any other context renders as plain text.
+fn context_link(context: &str) -> Markup {
+ if let Some(id) = context.strip_prefix("issues/") {
+ html! { a href={ "/issues/" (id) } { "on issue " (ents_forge::abbreviate_id(id)) } }
+ } else if let Some(rest) = context.strip_prefix("reviews/") {
+ let target = rest.split('/').next().unwrap_or(rest);
+ html! { a href={ "/commit/" (target) } { "on a review of " (ents_forge::abbreviate_id(target)) } }
+ } else {
+ html! { span { (context) } }
+ }
+}
+
+/// One `GET /comments` row: the comment's own card -- an abbreviated-id
+/// link to its page, author and age off its ref's tip commit (best
+/// effort, like [`thread_comment_card`]'s), its state badge, its context
+/// or anchor locator, and its body rendered as AsciiDoc like every other
+/// comment card in this crate.
+fn listing_card<O: Find + Write>(
+ state: &AppState<O>,
+ id: &str,
+ comment: &comment::Comment,
+) -> Markup {
+ let authorship = ents_model::namespace::comment_ref(id)
+ .ok()
+ .and_then(|ref_name| state.refs.get(ref_name.as_ref()).ok().flatten())
+ .and_then(|tip| super::commit_authorship(&*state.objects(), tip).ok());
+ let anchor = comment
+ .anchor
+ .as_ref()
+ .and_then(|raw| facet_git_tree::deserialize::<Anchor>(&raw.oid(), &*state.objects()).ok());
+ let body =
+ crate::asciidoc::to_html(&comment.body).unwrap_or_else(|_| html! { p { (comment.body) } });
+ html! {
+ div.card {
+ div.comment-meta {
+ a href={ "/comments/" (id) } { (ents_forge::abbreviate_id(id)) }
+ @if let Some((author, seconds)) = &authorship {
+ span.author { (author) }
+ span { (super::ago(*seconds)) }
+ }
+ span.comment-state { (comment.state) }
+ @if let Some(context) = &comment.context {
+ (context_link(context))
+ }
+ @if let Some(anchor) = &anchor {
+ a href={ "/files/" (anchor.path) (line_fragment(anchor.lines)) } {
+ (anchor.path) (line_label(anchor.lines))
+ }
+ (super::editor_open(state, &anchor.path, anchor.lines.map(|range| range.start)))
+ }
+ }
+ div.doc-body { (body) }
+ }
+ }
+}
+
+/// The form fields the reply route accepts.
+#[derive(Debug, Deserialize)]
+pub struct ReplyForm {
+ /// The reply's body text.
+ body: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+ /// Where to send the browser back to after the reply lands
+ /// ([`redirect_back`]) -- the issue, review, or comment page the reply
+ /// was composed on.
+ #[serde(default)]
+ return_to: String,
+}
+
+/// The form fields the resolve and reopen routes accept: a CSRF token and a
+/// return path, no body.
+#[derive(Debug, Deserialize)]
+pub struct ActionForm {
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+ /// Where to send the browser back to ([`redirect_back`]).
+ #[serde(default)]
+ return_to: String,
+}
+
+/// `POST /comments/{id}/reply`: a reply to `id` (`model.comment-thread`),
+/// signed (`roots.web-signing`) on behalf of the current session
+/// (`roots.web-session`) -- a caller of [`ents_forge::comment::reply`],
+/// never a second thread-building path.
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
+/// propagates [`ents_forge::comment::reply`]'s own failures (including
+/// [`ents_forge::Error::NotFound`] when `id` names no comment).
+// @relation(model.comment-thread, roots.web-signing, roots.web-session, scope=function)
+pub async fn reply<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path(id): Path<String>,
+ Form(form): Form<ReplyForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let identity = state.identity.as_ref();
+ let (_reply_id, outcome) = comment::reply(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ form.body,
+ &crate::receive_identity!(identity),
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(redirect_back(&form.return_to, &id))
+}
+
+/// `POST /comments/{id}/resolve`: record state `resolved` on `id`
+/// (`model.comment-state`), signed on behalf of the current session.
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
+/// propagates [`ents_forge::comment::resolve`]'s own failures.
+// @relation(model.comment-state, roots.web-signing, roots.web-session, scope=function)
+pub async fn resolve<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path(id): Path<String>,
+ Form(form): Form<ActionForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let identity = state.identity.as_ref();
+ let outcome = comment::resolve(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ &crate::receive_identity!(identity),
+ state.mode,
+ Some(&identity.public_openssh()),
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(redirect_back(&form.return_to, &id))
+}
+
+/// `POST /comments/{id}/reopen`: record state `open` on `id` again
+/// (`model.comment-state`), the way [`resolve`] records `resolved`.
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
+/// propagates [`ents_forge::comment::reopen`]'s own failures.
+// @relation(model.comment-state, roots.web-signing, roots.web-session, scope=function)
+pub async fn reopen<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path(id): Path<String>,
+ Form(form): Form<ActionForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let identity = state.identity.as_ref();
+ let outcome = comment::reopen(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ &crate::receive_identity!(identity),
+ state.mode,
+ Some(&identity.public_openssh()),
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(redirect_back(&form.return_to, &id))
+}
+
+/// Where a reply/resolve/reopen sends the browser after the mutation lands:
+/// back to `return_to` when it is a same-origin path (the issue, review, or
+/// comment page the action was taken on), or the comment's own page as a
+/// safe fallback. Only a value beginning with `/` is honored, so a crafted
+/// `return_to` can never redirect off-site.
+fn redirect_back(return_to: &str, id: &str) -> Redirect {
+ if return_to.starts_with('/') {
+ Redirect::to(return_to)
+ } else {
+ Redirect::to(&format!("/comments/{id}"))
+ }
+}
+
+/// The reply and resolve/reopen action forms every comment carries, on its
+/// own page and in an issue's or review's thread alike: a reply composer
+/// (`model.comment-thread`) and a single state toggle showing `resolve`
+/// when open or `reopen` when resolved (`model.comment-state`). `return_to`
+/// is echoed into a hidden field so [`redirect_back`] can return to
+/// whichever page rendered these forms.
+pub(crate) fn action_forms(
+ session: &Session,
+ id: &str,
+ resolved: bool,
+ return_to: &str,
+) -> maud::Markup {
+ html! {
+ div.comment-actions {
+ form method="post" action=(format!("/comments/{id}/reply")) {
+ (super::csrf_input(session))
+ input type="hidden" name="return_to" value=(return_to);
+ label { "reply" textarea name="body" {} }
+ button type="submit" { "Reply" }
+ }
+ @if resolved {
+ form method="post" action=(format!("/comments/{id}/reopen")) {
+ (super::csrf_input(session))
+ input type="hidden" name="return_to" value=(return_to);
+ button type="submit" { "Reopen" }
+ }
+ } @else {
+ form method="post" action=(format!("/comments/{id}/resolve")) {
+ (super::csrf_input(session))
+ input type="hidden" name="return_to" value=(return_to);
+ button type="submit" { "Resolve" }
+ }
+ }
+ }
+ }
+}
+
+/// The form fields `POST /comments` accepts.
+#[derive(Debug, Deserialize)]
+pub struct AddForm {
+ /// The repository-relative path to anchor to.
+ path: String,
+ /// The comment's text.
+ body: String,
+ /// An optional `<start>[:<end>]` line range.
+ #[serde(default)]
+ lines: String,
+ /// The revision to anchor against.
+ rev: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+/// `POST /comments`: anchor `body` to `path` at `rev`, signed
+/// (`roots.web-signing`) on behalf of the current session
+/// (`roots.web-session`).
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
+/// propagates [`ents_forge::comment::add`]'s own failures.
+// @relation(roots.web-signing, roots.web-session, scope=function)
+pub async fn add<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Form(form): Form<AddForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let lines = (!form.lines.trim().is_empty()).then(|| form.lines.trim().to_owned());
+
+ let identity = state.identity.as_ref();
+ let new = ents_forge::comment::NewComment {
+ body: form.body,
+ path: Some(form.path),
+ lines,
+ rev: form.rev,
+ worktree: false,
+ context: None,
+ parent: None,
+ };
+ let (id, outcome) = comment::add(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &state.path,
+ new,
+ &crate::receive_identity!(identity),
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(Redirect::to(&format!("/comments/{id}")))
+}
+
+/// The add-comment form, its `path`/`lines` fields pre-filled from
+/// [`ListQuery`] when `list` was reached with `?file=`/`?lines=` (e.g.
+/// `crate::pages::files`'s "comment on this file" link) -- maud escapes
+/// both into the `value` attribute the same as any other interpolation,
+/// so neither can break out of the form markup, and an empty prefill
+/// renders exactly as the unfilled field always did.
+fn add_form(
+ default_rev: &str,
+ session: &Session,
+ prefill_path: &str,
+ prefill_lines: &str,
+) -> maud::Markup {
+ html! {
+ form method="post" action="/comments" {
+ (super::csrf_input(session))
+ label { "path" input type="text" name="path" value=(prefill_path); }
+ label { "rev" input type="text" name="rev" value=(default_rev); }
+ label { "lines" input type="text" name="lines" value=(prefill_lines); }
+ label { "body" textarea name="body" {} }
+ button type="submit" { "Comment" }
+ }
+ }
+}
+
+/// One comment as `crate::pages::files`'s blob view shows it: who wrote it
+/// and when ([`super::ago`]), where its anchor lands (a path plus a line
+/// range, when it has one to interleave at -- [`comment_card`]'s own doc),
+/// and its body rendered as AsciiDoc ([`crate::asciidoc`], this crate's
+/// default prose treatment for text with no filename of its own to infer a
+/// MIME type from). Mirrors `pre-redo:crates/git-ents-server/src/web/pages.rs`'s
+/// own `FileComment`, salvaged per this crate's PORT-and-reverify policy:
+/// author/timestamp there came from `git_comment::provenance`'s shell-out,
+/// here from [`super::commit_authorship`] reading the comment ref's own tip
+/// commit through `gix_object::Find`.
+pub(crate) struct FileComment {
+ /// The comment ref's own tip commit's author display name
+ /// (`model.comment`: a comment stores no author field of its own).
+ pub(crate) author: String,
+ /// [`super::ago`] renders this against the current time.
+ pub(crate) seconds: i64,
+ /// The repository-relative path this comment's anchor lands on: the
+ /// file [`for_path`] was called for (it filters to exactly that path),
+ /// or the anchor's own recorded path for [`for_commit`] (a commit's
+ /// conversation spans every file the commit touched, so there is no
+ /// single implied path the way a blob view has one).
+ pub(crate) path: String,
+ /// The anchored range as it lands on the displayed file at `HEAD`, or
+ /// `None` for a whole-file anchor or an outdated projection -- either
+ /// way, nothing for [`crate::pages::files`]'s blob view to interleave
+ /// the card after, so it renders in a below-the-blob section instead.
+ /// [`for_commit`] always uses the anchor's own recorded range as-is
+ /// (never projected), since a commit's conversation is about that
+ /// commit specifically, not about `HEAD`.
+ pub(crate) lines: Option<LineRange>,
+ /// Set when [`ents_anchor::project`] reports
+ /// [`Projection::Outdated`]: the anchored lines themselves were
+ /// edited, so no line link is shown, only the marker -- the comment
+ /// itself is never dropped from the page. Always `false` for
+ /// [`for_commit`]'s own rows: "outdated" is a projection-onto-`HEAD`
+ /// concept, and a commit page shows the anchor exactly as captured.
+ pub(crate) outdated: bool,
+ /// The body, rendered as AsciiDoc ([`crate::asciidoc::to_html`]),
+ /// falling back to escaped plain text on a render failure -- a file
+ /// view degrades, it never 500s over one unparsable comment.
+ pub(crate) body: Markup,
+ /// The pre-rendered open-in-editor affordance for this comment's
+ /// landing spot ([`crate::pages::editor_open`]; empty when no editor
+ /// is recognized) -- built where `state` is at hand so
+ /// [`comment_card`] stays a pure markup function.
+ pub(crate) editor: Markup,
+}
+
+/// Every comment whose anchor projects onto `path` at `HEAD` in `repo` --
+/// [`crate::pages::files`]'s own read of this domain, built on the same
+/// [`comment::list`] read [`list`] itself uses and the same
+/// [`ents_anchor::project`] call [`show`] itself uses, rather than a third
+/// way to read a comment. Best effort throughout: a comment whose anchor
+/// or body fails to read, parse, or project is skipped from this file's
+/// own view only -- it still shows up on `GET /comments` and its own `GET
+/// /comments/{id}` page -- and a projection landing anywhere other than
+/// `path` (moved elsewhere, or deleted) is likewise not this file's
+/// comment to show. A projection that still lands at `path` but comes
+/// back [`Projection::Outdated`] is the one case this function keeps and
+/// flags (`outdated: true`) rather than skips: the anchored lines
+/// changed, not the comment's relevance to this file.
+pub(crate) fn for_path<O: Find + Write>(
+ state: &AppState<O>,
+ repo: &gix::Repository,
+ path: &str,
+) -> Vec<FileComment> {
+ let Ok(rows) = comment::list(state.refs.as_ref(), &*state.objects()) else {
+ return Vec::new();
+ };
+ let mut out = Vec::new();
+ for (id, comment) in rows {
+ let Some(raw) = &comment.anchor else {
+ // An unanchored comment (context or reply aboutness only) has
+ // no line in any file to land on.
+ continue;
+ };
+ let Ok(anchor) = facet_git_tree::deserialize::<Anchor>(&raw.oid(), &*state.objects())
+ else {
+ continue;
+ };
+ let Ok(projection) = ents_anchor::project(repo, &anchor, "HEAD") else {
+ continue;
+ };
+ let (landed, lines, outdated) = match projection {
+ Projection::Current => (anchor.path.clone(), anchor.lines, false),
+ Projection::Relocated { path, lines } => (path, lines, false),
+ Projection::Outdated { path } => (path, None, true),
+ Projection::Deleted => continue,
+ };
+ if landed != path {
+ continue;
+ }
+ let Ok(ref_name) = ents_model::namespace::comment_ref(&id) else {
+ continue;
+ };
+ let Some(tip) = state.refs.get(ref_name.as_ref()).ok().flatten() else {
+ continue;
+ };
+ let Ok((author, seconds)) = super::commit_authorship(&*state.objects(), tip) else {
+ continue;
+ };
+ let body = crate::asciidoc::to_html(&comment.body)
+ .unwrap_or_else(|_| html! { p { (comment.body) } });
+ let editor = super::editor_open(state, &landed, lines.map(|range| range.start));
+ out.push(FileComment {
+ author,
+ seconds,
+ path: landed,
+ lines,
+ outdated,
+ body,
+ editor,
+ });
+ }
+ out
+}
+
+/// Every comment whose anchor was captured against `commit_id` exactly --
+/// `crate::pages::commits::show`'s own "conversation" section. Filtered by
+/// [`Anchor::commit`] (the resolved commit oid `ents_anchor::capture`
+/// records at write time), not by projecting onto any revision the way
+/// [`for_path`] does: a commit page shows what was written about that
+/// commit specifically, so an anchor is read here exactly as captured,
+/// never re-projected (`lines`/`path` mirror [`Anchor::lines`]/
+/// [`Anchor::path`] verbatim, `outdated` is always `false`). Best effort
+/// throughout, mirroring [`for_path`]'s own stance: a comment whose anchor
+/// or body fails to read or parse is skipped from this commit's own view
+/// only.
+pub(crate) fn for_commit<O: Find + Write>(
+ state: &AppState<O>,
+ commit_id: ObjectId,
+) -> Vec<FileComment> {
+ let Ok(rows) = comment::list(state.refs.as_ref(), &*state.objects()) else {
+ return Vec::new();
+ };
+ let mut out = Vec::new();
+ for (id, comment) in rows {
+ let Some(raw) = &comment.anchor else {
+ continue;
+ };
+ let Ok(anchor) = facet_git_tree::deserialize::<Anchor>(&raw.oid(), &*state.objects())
+ else {
+ continue;
+ };
+ if anchor.commit() != commit_id {
+ continue;
+ }
+ let Ok(ref_name) = ents_model::namespace::comment_ref(&id) else {
+ continue;
+ };
+ let Some(tip) = state.refs.get(ref_name.as_ref()).ok().flatten() else {
+ continue;
+ };
+ let Ok((author, seconds)) = super::commit_authorship(&*state.objects(), tip) else {
+ continue;
+ };
+ let body = crate::asciidoc::to_html(&comment.body)
+ .unwrap_or_else(|_| html! { p { (comment.body) } });
+ let editor = super::editor_open(state, &anchor.path, anchor.lines.map(|range| range.start));
+ out.push(FileComment {
+ author,
+ seconds,
+ path: anchor.path.clone(),
+ lines: anchor.lines,
+ outdated: false,
+ body,
+ editor,
+ });
+ }
+ out
+}
+
+/// Where a [`FileComment`]'s line-range link points -- [`comment_card`]'s
+/// own mode switch between the pages that render one.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(crate) enum LinkMode {
+ /// The comment renders on the same page as the file it anchors to
+ /// (`crate::pages::files`'s blob view, whether interleaved at its own
+ /// line or in the below-the-blob section): the link is an in-page
+ /// fragment (`#L<n>`), labeled just the line range -- the path is
+ /// implied by the page itself.
+ SameFile,
+ /// The comment renders on a page about something else
+ /// (`crate::pages::commits::show`'s "conversation" section, which can
+ /// span several files): the link crosses into the file browser
+ /// (`/files/<path>#L<n>`), labeled with the path so the reader knows
+ /// where it lands.
+ CrossFile,
+}
+
+/// One comment's card: author, [`super::ago`] time, its line-range link
+/// (per `link`'s [`LinkMode`]) or the muted `outdated` marker, and its body
+/// -- the single rendering every comment-showing page in this crate shares
+/// ([`comments_section`]'s below-the-blob list, `crate::pages::files`'s own
+/// inline-interleaved rows, `crate::pages::commits::show`'s "conversation"
+/// section), so a comment's markup is defined in exactly one place. `index`
+/// names this card's `id="comment-<index>"` anchor, stable within
+/// whichever page rendered it (not a global id): `crate::pages::files`'s
+/// crumbs "N comments" jump link targets `comment-0`, the first comment in
+/// display order, regardless of whether it landed inline or below the
+/// blob.
+pub(crate) fn comment_card(index: usize, comment: &FileComment, link: LinkMode) -> Markup {
+ html! {
+ div.card id={ "comment-" (index) } {
+ div.comment-meta {
+ span.author { (comment.author) }
+ span { (super::ago(comment.seconds)) }
+ @if let Some(range) = comment.lines {
+ @match link {
+ LinkMode::SameFile => {
+ a href={ "#L" (range.start) } {
+ @if range.start == range.end { "line " (range.start) }
+ @else { "lines " (range.start) "-" (range.end) }
+ }
+ }
+ LinkMode::CrossFile => {
+ a href={ "/files/" (comment.path) "#L" (range.start) } {
+ (comment.path) "#L" (range.start)
+ @if range.start != range.end { "-" (range.end) }
+ }
+ }
+ }
+ }
+ (comment.editor)
+ @if comment.outdated {
+ span.outdated { "outdated" }
+ }
+ }
+ div.doc-body { (comment.body) }
+ }
+ }
+}
+
+/// One comment in an entity's discussion thread -- an issue's
+/// (`crate::pages::issues::show`) or a review's
+/// (`crate::pages::commits::show`) -- rendered from an aggregation query
+/// (`comment::thread`, `model.comment-context`), never a list any entity
+/// stores. Author and time come from the comment ref's own tip commit
+/// (`super::commit_authorship`, `model.comment`: no stored author field),
+/// its state (`model.comment-state`) shows as a badge, its body renders as
+/// AsciiDoc, and it carries the same [`action_forms`] every comment does,
+/// with `return_to` pointing back at the entity page rendering it so a
+/// reply or resolve returns there. Best effort: a comment whose tip commit
+/// cannot be read still renders, only without an author line.
+pub(crate) fn thread_comment_card<O: Find + Write>(
+ state: &AppState<O>,
+ session: &Session,
+ id: &str,
+ comment: &ents_forge::comment::Comment,
+ return_to: &str,
+) -> Markup {
+ let authorship = ents_model::namespace::comment_ref(id)
+ .ok()
+ .and_then(|ref_name| state.refs.get(ref_name.as_ref()).ok().flatten())
+ .and_then(|tip| super::commit_authorship(&*state.objects(), tip).ok());
+ let body =
+ crate::asciidoc::to_html(&comment.body).unwrap_or_else(|_| html! { p { (comment.body) } });
+ html! {
+ div.card id={ "thread-" (id) } {
+ div.comment-meta {
+ @if let Some((author, seconds)) = &authorship {
+ span.author { (author) }
+ span { (super::ago(*seconds)) }
+ }
+ @if comment.parent.is_some() {
+ span { "reply" }
+ }
+ span.comment-state { (comment.state) }
+ }
+ div.doc-body { (body) }
+ (action_forms(session, id, comment.state == "resolved", return_to))
+ }
+ }
+}
+
+/// An entity's whole discussion thread as a stack of [`thread_comment_card`]s
+/// (`model.comment-context`, `model.comment-thread`) -- what
+/// `crate::pages::issues::show` and `crate::pages::commits::show` render a
+/// `comment::thread` result through. Renders nothing when the thread is
+/// empty.
+pub(crate) fn thread_section<O: Find + Write>(
+ state: &AppState<O>,
+ session: &Session,
+ thread: &[(String, ents_forge::comment::Comment)],
+ return_to: &str,
+) -> Markup {
+ html! {
+ @for (id, comment) in thread {
+ (thread_comment_card(state, session, id, comment, return_to))
+ }
+ }
+}
+
+/// The comment cards under a blob view (a rendered document, a binary
+/// placeholder, or -- for a raw-source view -- the ones with no current
+/// line range to interleave at; see `crate::pages::files::source_view`),
+/// one [`comment_card`] per entry (in [`LinkMode::SameFile`]). Renders
+/// nothing at all -- not even an empty container -- when `comments` is
+/// empty, so a file with no comments carries no extra markup
+/// (`crate::pages::files`'s own blob view calls this unconditionally
+/// rather than checking first).
+pub(crate) fn comments_section(comments: &[FileComment]) -> Markup {
+ html! {
+ @for (index, comment) in comments.iter().enumerate() {
+ (comment_card(index, comment, LinkMode::SameFile))
+ }
+ }
+}
crates/cli/ents-web/src/pages/commits.rs
@@ -1,0 +1,947 @@
+//! `GET /commits`, `GET /commit/{oid}`: a read-only commit history and
+//! per-commit unified diff over `HEAD` -- a tab of its own (both routes
+//! render with `super::Tab::Commits` active; see [`super`]'s own doc),
+//! also reached from [`super::files`]'s "history" link.
+//!
+//! Reads go through `gix`'s high-level `Repository`/`Commit`/`Tree` types,
+//! opened fresh per request from `state.path`, exactly as
+//! [`super::files`]/[`super::dashboard`] browse `HEAD` -- `facet-git-tree`'s
+//! typed-tree convention is for meta-ref entities, not browsing arbitrary
+//! repository history. The unified diff itself is built directly on top of
+//! `gix::diff::blob` (`gix_diff`'s own re-export through the `gix`
+//! facade): [`gix::diff::blob::InternedInput`] interns each side's lines,
+//! [`gix::diff::blob::diff_with_slider_heuristics`] computes the hunks, and
+//! [`gix::diff::blob::unified_diff::ConsumeBinaryHunk`] renders them as the
+//! same textual unified-diff format `git diff` itself produces, which
+//! `diff_class` then colorizes line by line -- no new dependency, since
+//! `gix`'s default features already enable `blob-diff`.
+//!
+//! `GET /commit/{oid}` also lists a "conversation": every comment whose
+//! anchor was captured against that exact commit
+//! (`crate::pages::comments::for_commit`), rendered below the diff via the
+//! same `crate::pages::comments::comment_card` a blob view uses, each
+//! naming its `path#lines` and linking into `crate::pages::files`'s own
+//! `#L<n>` gutter. A "comment on this commit" link beside the parents list
+//! reaches `crate::pages::comments::list`'s add form with `rev` prefilled
+//! to this commit's own oid.
+
+use std::sync::Arc;
+
+use axum::Form;
+use axum::extract::{Path, Query, State};
+use axum::response::{IntoResponse, Redirect};
+use gix::bstr::ByteSlice as _;
+use gix::diff::blob::unified_diff::{ConsumeBinaryHunk, ContextSize};
+use gix::diff::blob::{Algorithm, InternedInput, UnifiedDiff, diff_with_slider_heuristics};
+use gix::object::tree::diff::Change;
+use gix_hash::ObjectId;
+use gix_object::{Find, Write};
+use maud::{Markup, html};
+use serde::Deserialize;
+
+use crate::error::{Error, Result};
+use crate::session::Session;
+use crate::state::AppState;
+
+/// One page of `GET /commits`.
+const PAGE_SIZE: usize = 50;
+
+/// The largest total diff rendered in full on `GET /commit/{oid}` -- past
+/// it, reading every changed blob into memory and diffing it would be
+/// unbounded, so the page shows a truncation notice instead (mirrors
+/// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own
+/// `MAX_RENDER_BYTES`, at this page's own, smaller budget).
+const MAX_DIFF_BYTES: usize = 1024 * 1024;
+
+/// The query parameters `GET /commits` accepts.
+#[derive(Debug, Deserialize)]
+pub struct ListQuery {
+ /// Continue the walk just past this previously shown commit (the
+ /// "older" link) -- omitted for the first page.
+ from: Option<String>,
+}
+
+/// One row of `GET /commits` -- also what [`super::dashboard`]'s History
+/// card renders, at its own smaller limit, so the two pages share one
+/// history read.
+pub(crate) struct CommitRow {
+ /// The full commit id, the `/commit/{oid}` link target.
+ pub(crate) oid: ObjectId,
+ /// [`super::short_oid`] of `oid`, the row's displayed, mono id.
+ pub(crate) short: String,
+ /// The commit message's title line.
+ pub(crate) subject: String,
+ /// The commit author's display name.
+ pub(crate) author: String,
+ /// [`super::ago`] of the commit author's time.
+ pub(crate) ago: String,
+}
+
+/// `GET /commits`: the repository's commit history, newest first, 50 per
+/// page.
+///
+/// # Errors
+///
+/// Never fails on an unopenable repository or an unborn `HEAD` -- both
+/// degrade to a blankslate.
+pub async fn list<O>(
+ State(state): State<Arc<AppState<O>>>,
+ Query(params): Query<ListQuery>,
+) -> Result<Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let (rows, older) = commit_rows(&state, params.from.as_deref(), PAGE_SIZE);
+ Ok(super::layout(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ super::Tab::Commits,
+ "Commits",
+ html! {
+ @if rows.is_empty() {
+ (blankslate())
+ } @else {
+ div.card {
+ div.card-header { "commits" }
+ table.entity-list.commits-table {
+ thead {
+ tr { th { "commit" } th { "subject" } th { "author" } th { "when" } }
+ }
+ tbody {
+ @for row in &rows {
+ tr {
+ td { a href={ "/commit/" (row.oid) } { code { (row.short) } } }
+ td { (row.subject) }
+ td { (row.author) }
+ td { (row.ago) }
+ }
+ }
+ }
+ }
+ }
+ @if let Some(from) = older {
+ nav.crumbs {
+ a href={ "/commits?from=" (from) } { "older" }
+ }
+ }
+ }
+ },
+ ))
+}
+
+/// Up to `limit` rows starting at `from` (or `HEAD` when `from` is
+/// `None`), newest first, plus the oid to continue from for an "older"
+/// link when more commits remain -- [`list`] passes [`PAGE_SIZE`],
+/// [`super::dashboard`]'s History card its own smaller cap. Best-effort:
+/// an unopenable repository, an unborn `HEAD`, or an
+/// unparsable/unresolvable `from` all degrade to an empty page rather
+/// than an error.
+pub(crate) fn commit_rows<O>(
+ state: &AppState<O>,
+ from: Option<&str>,
+ limit: usize,
+) -> (Vec<CommitRow>, Option<String>) {
+ let Ok(repo) = gix::open(&state.path) else {
+ return (Vec::new(), None);
+ };
+ let continuing = from.and_then(|hex| ObjectId::from_hex(hex.as_bytes()).ok());
+ let tip = match continuing {
+ Some(oid) => oid,
+ None => {
+ let Ok(head) = repo.head_id() else {
+ return (Vec::new(), None);
+ };
+ head.detach()
+ }
+ };
+ let Ok(walk) = repo
+ .rev_walk([tip])
+ .sorting(gix::revision::walk::Sorting::ByCommitTime(
+ gix::traverse::commit::simple::CommitTimeOrder::NewestFirst,
+ ))
+ .all()
+ else {
+ return (Vec::new(), None);
+ };
+
+ let skip = if continuing.is_some() { 1 } else { 0 };
+ let mut rows: Vec<CommitRow> = Vec::new();
+ let mut has_more = false;
+ for info in walk.skip(skip) {
+ let Ok(info) = info else { break };
+ if rows.len() == limit {
+ has_more = true;
+ break;
+ }
+ let Ok(commit) = info.object() else { continue };
+ let Ok(message) = commit.message() else {
+ continue;
+ };
+ let Ok(author) = commit.author() else {
+ continue;
+ };
+ let seconds = author.time().map(|time| time.seconds).unwrap_or(0);
+ let oid = info.id().detach();
+ rows.push(CommitRow {
+ oid,
+ short: super::short_oid(&oid),
+ subject: message.title.to_str_lossy().into_owned(),
+ author: author.name.to_str_lossy().into_owned(),
+ ago: super::ago(seconds),
+ });
+ }
+ let older = has_more
+ .then(|| rows.last().map(|row| row.oid.to_string()))
+ .flatten();
+ (rows, older)
+}
+
+/// The empty-history placeholder ([`super::blankslate`]): an unborn
+/// `HEAD`, or a repository this page could not open at all.
+fn blankslate() -> Markup {
+ super::blankslate(
+ "No commits yet",
+ html! { "This repository has no history to show." },
+ )
+}
+
+/// `GET /commit/{oid}`: a single commit's full message, metadata, and a
+/// unified diff against its first parent (or the empty tree, for a root
+/// commit).
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `oid` is not a well-formed object id or does not
+/// name a commit in the served repository.
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path(oid): Path<String>,
+) -> Result<Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let object_id = parse_oid(&oid)?;
+ let repo = gix::open(&state.path).map_err(|source| Error::Repo(source.to_string()))?;
+ let commit = repo
+ .find_commit(object_id)
+ .ok()
+ .ok_or_else(|| Error::NotFound { what: oid.clone() })?;
+ let message = commit
+ .message()
+ .map_err(|source| Error::Repo(source.to_string()))?;
+ let subject = message.title.to_str_lossy().into_owned();
+ let body = message
+ .body
+ .map(|body| body.to_str_lossy().into_owned())
+ .filter(|body| !body.is_empty());
+ let author = commit
+ .author()
+ .map_err(|source| Error::Repo(source.to_string()))?;
+ let author_name = author.name.to_str_lossy().into_owned();
+ let ago = author.time().map(|time| super::ago(time.seconds)).ok();
+ let parents: Vec<ObjectId> = commit.parent_ids().map(|id| id.detach()).collect();
+ let new_tree = commit
+ .tree()
+ .map_err(|source| Error::Repo(source.to_string()))?;
+
+ let old_tree = match parents.first() {
+ Some(parent) => Some(
+ repo.find_commit(*parent)
+ .map_err(|source| Error::Repo(source.to_string()))?
+ .tree()
+ .map_err(|source| Error::Repo(source.to_string()))?,
+ ),
+ None => None,
+ };
+ let empty_tree = repo.empty_tree();
+ let old_tree_ref = old_tree.as_ref().unwrap_or(&empty_tree);
+ let (diff, truncated) = diff_sections(&repo, old_tree_ref, &new_tree);
+ let comments = super::comments::for_commit(&state, object_id);
+ let checks = checks_section(&state, object_id);
+ let reviews = reviews_section(&state, &session, object_id, &oid);
+ let (sidebar_rows, _older) = commit_rows(&state, None, PAGE_SIZE);
+
+ Ok(super::layout_split(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ super::Tab::Commits,
+ &subject,
+ commits_sidebar(&sidebar_rows, object_id),
+ html! {
+ (super::child_crumbs("commits", "/commits", &super::short_oid(&object_id)))
+ // The commit card, its reviews, and the conversation are
+ // single-column reading content, capped at `.readable`'s
+ // narrow width; only the diff sections between them keep the
+ // shell's full width (see `ents.css`'s own `.readable` note).
+ div.readable {
+ div.card {
+ div.card-header { "commit " code { (super::short_oid(&object_id)) } }
+ div.commit {
+ div.commit-subject { (subject) }
+ @if let Some(body) = &body {
+ div.commit-msg { (body) }
+ }
+ div.commit-meta {
+ (author_name)
+ @if let Some(ago) = &ago { " \u{b7} " (ago) }
+ }
+ div.commit-meta {
+ "tree " a href={ "/files" } { "browse at HEAD" }
+ @if !parents.is_empty() {
+ " \u{b7} parents: "
+ @for (index, parent) in parents.iter().enumerate() {
+ @if index > 0 { ", " }
+ a href={ "/commit/" (parent) } { code { (super::short_oid(parent)) } }
+ }
+ } @else {
+ " \u{b7} root commit"
+ }
+ " \u{b7} "
+ a href={ "/comments?rev=" (object_id) } { "comment on this commit" }
+ }
+ }
+ }
+ (checks)
+ (reviews)
+ }
+ (diff)
+ @if truncated {
+ div.card { div.binary { "Diff truncated (over " (MAX_DIFF_BYTES / (1024 * 1024)) " MiB)." } }
+ }
+ @if !comments.is_empty() {
+ div.readable {
+ h2 { "Conversation" }
+ @for (index, comment) in comments.iter().enumerate() {
+ (super::comments::comment_card(index, comment, super::comments::LinkMode::CrossFile))
+ }
+ }
+ }
+ },
+ ))
+}
+
+/// The Review split's `.tree` sidebar (`crate::pages::layout_split`): the
+/// most recent commits, the viewed one active, each row its short oid and
+/// subject on one ellipsized line, closed by a link into the full pager.
+/// A commit older than the newest [`PAGE_SIZE`] simply highlights nothing
+/// -- the sidebar is a recency lane, not a second pager.
+fn commits_sidebar(rows: &[CommitRow], current: ObjectId) -> Markup {
+ html! {
+ @if rows.is_empty() {
+ span.tree-note { "No history to show." }
+ }
+ @for row in rows {
+ a.active[row.oid == current] href={ "/commit/" (row.oid) } {
+ (row.short) " " (row.subject)
+ }
+ }
+ a href="/commits" { "all commits \u{2192}" }
+ }
+}
+
+/// One row of the commit page's "Checks" card: a recorded result targeting
+/// the shown commit.
+struct CheckRow {
+ /// The recording effect's name ([`ents_model::ResultRecord`]'s own
+ /// `effect` field), the row's `/effects/{name}` link.
+ effect: String,
+ /// The run's outcome, one of the closed taxonomy's three values.
+ status: ents_model::Status,
+ /// The self-run mirror's `<member>` segment when the result lives
+ /// there rather than the canonical namespace (`effect.self-run`).
+ self_run: Option<String>,
+ /// The result ref tip's author time, for [`super::ago`].
+ seconds: Option<i64>,
+}
+
+/// A [`ents_model::Status`]'s display word, doubling as its
+/// `.status-<word>` chip class -- the closed pass/fail/error taxonomy
+/// (`model.result-taxonomy`), spelled out here rather than through
+/// `Debug`.
+fn status_label(status: ents_model::Status) -> &'static str {
+ match status {
+ ents_model::Status::Pass => "pass",
+ ents_model::Status::Fail => "fail",
+ ents_model::Status::Error => "error",
+ }
+}
+
+/// The "Checks" card on `GET /commit/{oid}`: every recorded result
+/// (`model.result-identity`) whose stored `target` field names this
+/// commit -- the canonical `refs/meta/results/<effect>/<short-oid>`
+/// namespace and every member's self-run mirror
+/// (`refs/meta/self/<member>/...`), matched on the tree's own `target`
+/// field (the same binding the gate verifies), never the refname's
+/// short-oid segment. Renders nothing at all when no result targets the
+/// commit: a result is only ever written by a run
+/// (`effect.result-taxonomy`), so "no checks" is the ordinary state of
+/// most commits, not a pending one. Best effort: a result ref whose tree
+/// cannot be read back is skipped from this card (it still lists on
+/// `git ents effect log`).
+// @relation(model.result-identity, model.result-taxonomy, scope=function)
+fn checks_section<O: Find + Write>(state: &AppState<O>, commit_id: ObjectId) -> Markup {
+ let mut rows: Vec<CheckRow> = Vec::new();
+ for prefix in ["refs/meta/results/", "refs/meta/self/"] {
+ let Ok(iter) = state.refs.iter_prefix(prefix) else {
+ continue;
+ };
+ for entry in iter {
+ let Ok((name, tip)) = entry else { continue };
+ // One `state.objects()` lock per read -- the same
+ // non-reentrant-`Mutex` care `crate::pages::effects::read_all`
+ // documents.
+ let record = {
+ let objects = state.objects();
+ super::commit_tree(&*objects, tip).ok().and_then(|tree| {
+ facet_git_tree::deserialize::<ents_model::ResultRecord>(&tree, &*objects).ok()
+ })
+ };
+ let Some(record) = record else { continue };
+ if record.target() != commit_id {
+ continue;
+ }
+ let path = name.as_bstr().to_string();
+ let self_run = path
+ .strip_prefix("refs/meta/self/")
+ .and_then(|rest| rest.split('/').next())
+ .map(str::to_owned);
+ let seconds = super::commit_authorship(&*state.objects(), tip)
+ .ok()
+ .map(|(_author, seconds)| seconds);
+ rows.push(CheckRow {
+ effect: record.effect,
+ status: record.status,
+ self_run,
+ seconds,
+ });
+ }
+ }
+ if rows.is_empty() {
+ return html! {};
+ }
+ rows.sort_by(|a, b| (&a.effect, &a.self_run).cmp(&(&b.effect, &b.self_run)));
+ html! {
+ div.card {
+ div.card-header { "Checks" }
+ @for row in &rows {
+ div.card-row {
+ span class={ "status status-" (status_label(row.status)) } {
+ (status_label(row.status))
+ }
+ " "
+ a href={ "/effects/" (row.effect) } { (row.effect) }
+ @if let Some(member) = &row.self_run {
+ span.muted { " \u{b7} self-run by " (member) }
+ }
+ @if let Some(seconds) = row.seconds {
+ span.entry-size { (super::ago(seconds)) }
+ }
+ }
+ }
+ }
+ }
+}
+
+/// Every review targeting `commit_id` (`ents_forge::review::list` filtered
+/// to this commit, `model.review`), each rendering its verdict prominently,
+/// its body as AsciiDoc, and its reviewer (from the review ref's own tip
+/// commit chain, `meta-ref.identity-binding` -- a review stores no author
+/// field, only its composite `(target, member)` key), followed by its
+/// discussion: the comments naming `reviews/<target>/<member>` as their
+/// context (`ents_forge::comment::thread`, `model.comment-context`),
+/// rendered through the same shared `super::comments::thread_section` an
+/// issue's thread uses. A "start a review" form closes the section
+/// (`POST /commit/{oid}/review`). Best effort: a review whose ref cannot be
+/// listed degrades to just the start form rather than failing the page.
+fn reviews_section<O: Find + Write>(
+ state: &AppState<O>,
+ session: &Session,
+ commit_id: ObjectId,
+ oid: &str,
+) -> Markup {
+ let reviews = ents_forge::review::list(
+ state.refs.as_ref(),
+ &*state.objects(),
+ &state.path,
+ Some(&commit_id.to_string()),
+ )
+ .unwrap_or_default();
+ let return_to = format!("/commit/{oid}");
+ html! {
+ h2 { "Reviews" }
+ @if reviews.is_empty() {
+ p.muted { "No reviews of this commit yet \u{2014} record a verdict below." }
+ }
+ @for ((target, member), review) in &reviews {
+ div.card {
+ div.comment-meta {
+ span.verdict { (review.verdict) }
+ span.author { (member) }
+ @let reviewer = ents_model::namespace::review_ref(target, member)
+ .ok()
+ .and_then(|ref_name| state.refs.get(ref_name.as_ref()).ok().flatten())
+ .and_then(|tip| super::commit_authorship(&*state.objects(), tip).ok());
+ @if let Some((_author, seconds)) = &reviewer {
+ span { (super::ago(*seconds)) }
+ }
+ }
+ div.doc-body {
+ (crate::asciidoc::to_html(&review.body).unwrap_or_else(|_| html! { p { (review.body) } }))
+ }
+ @let thread = ents_forge::comment::thread(
+ state.refs.as_ref(),
+ &*state.objects(),
+ &format!("reviews/{target}/{member}"),
+ ).unwrap_or_default();
+ (super::comments::thread_section(state, session, &thread, &return_to))
+ (review_comment_form(session, target, member, &return_to))
+ }
+ }
+ (start_review_form(session, oid))
+ }
+}
+
+/// The comment-on-this-review form (`POST /reviews/{target}/{member}/comment`):
+/// a contextual comment naming `reviews/<target>/<member>`
+/// (`model.comment-context`), so a review's discussion can start from the
+/// web and not only the CLI or lens.
+fn review_comment_form(
+ session: &Session,
+ target: &str,
+ member: &ents_model::MemberId,
+ return_to: &str,
+) -> Markup {
+ html! {
+ form method="post" action=(format!("/reviews/{target}/{member}/comment")) {
+ (super::csrf_input(session))
+ input type="hidden" name="return_to" value=(return_to);
+ label { "comment on this review" textarea name="body" {} }
+ button type="submit" { "Comment" }
+ }
+ }
+}
+
+/// The form fields `POST /reviews/{target}/{member}/comment` accepts.
+#[derive(Debug, Deserialize)]
+pub struct ReviewCommentForm {
+ /// The comment's body text.
+ body: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+ /// Where to send the browser back to -- the commit page rendering the
+ /// review; honored only when it is a same-origin path.
+ #[serde(default)]
+ return_to: String,
+}
+
+/// `POST /reviews/{target}/{member}/comment`: a comment naming
+/// `reviews/<target>/<member>` as its context (`model.comment-context`) --
+/// an ordinary [`ents_forge::comment::add`], contextual and unanchored,
+/// joining the review's discussion thread the moment it lands.
+///
+/// # Errors
+///
+/// [`Error::BadCsrf`] if `form.csrf` does not match; otherwise propagates
+/// [`ents_forge::comment::add`]'s own failures.
+// @relation(model.comment-context, roots.web-signing, roots.web-session, scope=function)
+pub async fn review_comment<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path((target, member)): Path<(String, String)>,
+ Form(form): Form<ReviewCommentForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let identity = state.identity.as_ref();
+ let new = ents_forge::comment::NewComment {
+ body: form.body,
+ path: None,
+ lines: None,
+ rev: "HEAD".to_owned(),
+ worktree: false,
+ context: Some(format!("reviews/{target}/{member}")),
+ parent: None,
+ };
+ let (_comment_id, outcome) = ents_forge::comment::add(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &state.path,
+ new,
+ &crate::receive_identity!(identity),
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ let target = if form.return_to.starts_with('/') {
+ form.return_to
+ } else {
+ "/commits".to_owned()
+ };
+ Ok(Redirect::to(&target))
+}
+
+/// The start-a-review form (`POST /commit/{oid}/review`): a verdict and
+/// a body. The verdict is a closed `select` over
+/// [`ents_forge::review::Verdict`]'s three variants -- `model.review`
+/// makes it a hard enum, unlike issue and comment states.
+fn start_review_form(session: &Session, oid: &str) -> Markup {
+ html! {
+ form method="post" action=(format!("/commit/{oid}/review")) {
+ (super::csrf_input(session))
+ label {
+ "verdict"
+ select name="verdict" {
+ option value="approve" { "approve" }
+ option value="request-changes" { "request-changes" }
+ option value="comment" { "comment" }
+ }
+ }
+ label { "body" textarea name="body" {} }
+ button type="submit" { "Start a Review" }
+ }
+ }
+}
+
+/// The form fields `POST /commit/{oid}/review` accepts.
+#[derive(Debug, Deserialize)]
+pub struct ReviewForm {
+ /// The review's verdict.
+ verdict: String,
+ /// The review's body text.
+ #[serde(default)]
+ body: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+/// `POST /commit/{oid}/review`: review the commit at `oid`
+/// (`ents_forge::review::new`), which writes both the review's entity ref
+/// and its retention pin (`model.review`, `model.review-pin`) -- the web is
+/// another caller of that one library func, never a second review or
+/// pin-writing path. Signed (`roots.web-signing`) on behalf of the current
+/// session (`roots.web-session`).
+///
+/// # Errors
+///
+/// [`Error::BadCsrf`] if `form.csrf` does not match; otherwise propagates
+/// [`ents_forge::review::new`]'s own failures (including an unresolvable
+/// target commit).
+// @relation(model.review, model.review-pin, roots.web-signing, roots.web-session, scope=function)
+pub async fn review<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path(oid): Path<String>,
+ Form(form): Form<ReviewForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let member = reviewer_member_id(&state);
+ let identity = state.identity.as_ref();
+ let new = ents_forge::review::NewReview {
+ target: oid.clone(),
+ verdict: form.verdict.parse().map_err(|_unknown| {
+ Error::InvalidArgument(format!("unknown verdict: {}", form.verdict))
+ })?,
+ body: form.body,
+ };
+ let (_target, outcome) = ents_forge::review::new(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &state.path,
+ new,
+ &member,
+ &crate::receive_identity!(identity),
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(Redirect::to(&format!("/commit/{oid}")))
+}
+
+/// The acting session's member id -- the composite review key's
+/// `<member>` segment -- resolved the same way
+/// [`super::account::resolve_member_by_key`] does, falling back to a short
+/// hash of the public key when no enrolled member matches: mirrors
+/// `git_ents::commands::serve::build_state`'s identical fallback
+/// (`roots.web-signing`: an unenrolled local identity may still review,
+/// exactly as it may still browse and comment).
+fn reviewer_member_id<O: Find>(state: &AppState<O>) -> ents_model::MemberId {
+ let pubkey = state.identity.public_openssh();
+ super::account::resolve_member_by_key(state, &pubkey)
+ .map(|(id, _member)| id)
+ .unwrap_or_else(|_source| ents_model::MemberId::new(short_key_fingerprint(&pubkey)))
+}
+
+/// The first twelve characters of `pubkey`'s key-material token -- mirrors
+/// `git_ents::commands::short_fingerprint`'s identical fallback label.
+fn short_key_fingerprint(pubkey: &str) -> String {
+ let hex: String = pubkey
+ .split_whitespace()
+ .nth(1)
+ .unwrap_or(pubkey)
+ .chars()
+ .take(12)
+ .collect();
+ if hex.is_empty() {
+ "member".to_owned()
+ } else {
+ hex
+ }
+}
+
+/// Validate `text` as a full, well-formed object id -- hex characters only,
+/// at the exact length the served repository's hash kind expects (this
+/// page does not resolve abbreviated prefixes; [`super::commits::list`]'s
+/// own links always carry the full id).
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `text` is empty, not hex, or the wrong length.
+fn parse_oid(text: &str) -> Result<ObjectId> {
+ if text.is_empty() || text.len() > 64 || !text.bytes().all(|b| b.is_ascii_hexdigit()) {
+ return Err(Error::NotFound {
+ what: text.to_owned(),
+ });
+ }
+ ObjectId::from_hex(text.as_bytes())
+ .ok()
+ .ok_or_else(|| Error::NotFound {
+ what: text.to_owned(),
+ })
+}
+
+/// One `.diff` section per changed file between `old_tree` and `new_tree`,
+/// plus whether the total rendered bytes exceeded [`MAX_DIFF_BYTES`] (in
+/// which case the caller shows a truncation notice). Best-effort: a change
+/// this function cannot read renders as a bare file header with no hunks
+/// rather than failing the whole page.
+fn diff_sections(
+ repo: &gix::Repository,
+ old_tree: &gix::Tree<'_>,
+ new_tree: &gix::Tree<'_>,
+) -> (Markup, bool) {
+ let Ok(mut platform) = old_tree.changes() else {
+ return (html! {}, false);
+ };
+ let mut sections = Vec::new();
+ let mut total: usize = 0;
+ let mut truncated = false;
+ let _outcome = platform.for_each_to_obtain_tree(new_tree, |change| {
+ if truncated {
+ return Ok::<_, std::convert::Infallible>(std::ops::ControlFlow::Break(()));
+ }
+ // The walk yields every intermediate directory as its own tree
+ // change; only blob (and link) entries are files a reader can
+ // diff, so a tree entry renders nothing rather than a bare
+ // header per subdirectory.
+ if is_tree_change(&change) {
+ return Ok(std::ops::ControlFlow::Continue(()));
+ }
+ let (section, bytes) = render_change(repo, &change);
+ total = total.saturating_add(bytes);
+ sections.push(section);
+ if total > MAX_DIFF_BYTES {
+ truncated = true;
+ }
+ Ok(std::ops::ControlFlow::Continue(()))
+ });
+ (html! { @for section in §ions { (section) } }, truncated)
+}
+
+/// Whether `change` is a tree (directory) entry rather than a blob or
+/// link -- [`diff_sections`] skips these, since the walk names every
+/// intermediate directory on the way to a changed file.
+fn is_tree_change(change: &Change<'_, '_, '_>) -> bool {
+ match *change {
+ Change::Addition { entry_mode, .. }
+ | Change::Deletion { entry_mode, .. }
+ | Change::Modification { entry_mode, .. }
+ | Change::Rewrite { entry_mode, .. } => entry_mode.is_tree(),
+ }
+}
+
+/// One changed file's `.diff` section: a `.file`-classed header naming the
+/// path (and, on a rename, the old path it moved from), followed by either
+/// a `.meta`-classed "binary file changed" notice or the colorized unified
+/// diff between its old and new blob content. Returns the section's
+/// rendered byte cost, so [`diff_sections`] can track the page's overall
+/// budget.
+fn render_change(repo: &gix::Repository, change: &Change<'_, '_, '_>) -> (Markup, usize) {
+ let (old_id, new_id, path, rename_from) = match *change {
+ Change::Addition { location, id, .. } => (
+ None,
+ Some(id.detach()),
+ location.to_str_lossy().into_owned(),
+ None,
+ ),
+ Change::Deletion { location, id, .. } => (
+ Some(id.detach()),
+ None,
+ location.to_str_lossy().into_owned(),
+ None,
+ ),
+ Change::Modification {
+ location,
+ previous_id,
+ id,
+ ..
+ } => (
+ Some(previous_id.detach()),
+ Some(id.detach()),
+ location.to_str_lossy().into_owned(),
+ None,
+ ),
+ Change::Rewrite {
+ location,
+ source_location,
+ source_id,
+ id,
+ ..
+ } => (
+ Some(source_id.detach()),
+ Some(id.detach()),
+ location.to_str_lossy().into_owned(),
+ Some(source_location.to_str_lossy().into_owned()),
+ ),
+ };
+
+ let old_bytes = old_id.and_then(|id| blob_bytes(repo, id));
+ let new_bytes = new_id.and_then(|id| blob_bytes(repo, id));
+ let cost = old_bytes
+ .as_ref()
+ .map_or(0, Vec::len)
+ .saturating_add(new_bytes.as_ref().map_or(0, Vec::len));
+ let binary =
+ old_bytes.as_deref().is_some_and(is_binary) || new_bytes.as_deref().is_some_and(is_binary);
+
+ let header = html! {
+ span.ln.file {
+ @if let Some(from) = &rename_from { (from) " \u{2192} " }
+ (path)
+ "\n"
+ }
+ };
+ let body = if binary {
+ html! { span.ln.meta { "Binary file changed.\n" } }
+ } else {
+ let old_text = old_bytes.as_deref().map_or_else(String::new, |bytes| {
+ String::from_utf8_lossy(bytes).into_owned()
+ });
+ let new_text = new_bytes.as_deref().map_or_else(String::new, |bytes| {
+ String::from_utf8_lossy(bytes).into_owned()
+ });
+ unified_diff(&old_text, &new_text)
+ };
+ (html! { div.diff { (header) (body) } }, cost)
+}
+
+/// `id`'s blob content, or `None` when it cannot be read as a blob (a
+/// submodule/gitlink entry, or a read failure) -- best-effort, mirroring
+/// [`diff_sections`]'s own degrade-don't-fail stance.
+fn blob_bytes(repo: &gix::Repository, id: ObjectId) -> Option<Vec<u8>> {
+ Some(
+ repo.find_object(id)
+ .ok()?
+ .try_into_blob()
+ .ok()?
+ .data
+ .clone(),
+ )
+}
+
+/// Whether `bytes` looks like binary content (a NUL byte in the leading
+/// chunk, the same heuristic [`super::files::is_binary`] and pre-redo's own
+/// `is_binary` use).
+fn is_binary(bytes: &[u8]) -> bool {
+ bytes.iter().take(8000).any(|b| *b == 0)
+}
+
+/// `old_text` and `new_text` rendered as a colorized unified diff: each
+/// hunk built via [`gix::diff::blob::InternedInput`] and
+/// [`diff_with_slider_heuristics`], then rendered to the textual unified
+/// diff format through [`ConsumeBinaryHunk`] and colorized line by line via
+/// [`diff_class`] -- mirrors `pre-redo:.../pages.rs`'s own `diff_view`,
+/// its `git diff`-shelled-out patch text replaced with this page's own
+/// `gix`-computed one.
+fn unified_diff(old_text: &str, new_text: &str) -> Markup {
+ if old_text == new_text {
+ return html! {};
+ }
+ let input = InternedInput::new(old_text, new_text);
+ let diff = diff_with_slider_heuristics(Algorithm::Histogram, &input);
+ let Ok(patch) = UnifiedDiff::new(
+ &diff,
+ &input,
+ ConsumeBinaryHunk::new(String::new(), "\n"),
+ ContextSize::symmetrical(3),
+ )
+ .consume() else {
+ return html! {};
+ };
+ html! {
+ @for line in patch.lines() {
+ span class={ "ln " (diff_class(line)) } { (line) "\n" }
+ }
+ }
+}
+
+/// The CSS class for a unified-diff line, chosen from its leading marker
+/// (mirrors `pre-redo:.../pages.rs`'s own `diff_class`).
+fn diff_class(line: &str) -> &'static str {
+ if line.starts_with("@@") {
+ "hunk"
+ } else if line.starts_with('+') {
+ "add"
+ } else if line.starts_with('-') {
+ "del"
+ } else {
+ "ctx"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ #[case::empty("", false)]
+ #[case::not_hex("zzzzzzz", false)]
+ #[case::too_long(
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ false
+ )]
+ #[case::sha1("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true)]
+ fn parse_oid_accepts_only_well_formed_hex_ids(#[case] text: &str, #[case] valid: bool) {
+ assert_eq!(parse_oid(text).is_ok(), valid);
+ }
+
+ #[test]
+ fn diff_class_colors_hunk_add_del_and_context_lines() {
+ assert_eq!(diff_class("@@ -1,2 +1,2 @@"), "hunk");
+ assert_eq!(diff_class("+added"), "add");
+ assert_eq!(diff_class("-removed"), "del");
+ assert_eq!(diff_class(" context"), "ctx");
+ }
+
+ #[test]
+ fn unified_diff_renders_colored_added_and_removed_lines() {
+ let rendered = unified_diff("a\nb\n", "a\nc\n").into_string();
+ assert!(rendered.contains("class=\"ln del\""));
+ assert!(rendered.contains("class=\"ln add\""));
+ }
+
+ #[test]
+ fn unified_diff_of_identical_text_renders_nothing() {
+ let rendered = unified_diff("same\n", "same\n").into_string();
+ assert!(rendered.is_empty());
+ }
+}
crates/cli/ents-web/src/pages/dashboard.rs
@@ -1,0 +1,351 @@
+//! `GET /`: the workbench dashboard -- `git status` for review and
+//! issue tracking (`docs/web-workbench-plan.adoc`'s Phase C home page). Four
+//! sections on a `.desk` grid: the working tree's changed files (a live
+//! `gix` status of the repository at `state.path`), a needs-attention
+//! feed of open comment threads, the open issues, and a full-width
+//! History card of recent commits with their Scoped-Commits scope chips.
+//! The `README` this page used to render moved to `crate::pages::files`'s
+//! root listing -- the dashboard is a work surface, not a document viewer.
+//!
+//! The status and history reads browse the repository through `gix`'s
+//! high-level `Repository` types, opened fresh per request from
+//! `state.path`, exactly as [`crate::pages::files`]/[`crate::pages::commits`]
+//! do (and for the same reason: browsing arbitrary repository content is
+//! not the `facet-git-tree` meta-ref convention the generic pages use).
+//! Every repository read here is best-effort: an unopenable repository or
+//! a failed status walk degrades to an in-card note, never an error.
+
+use std::sync::Arc;
+
+use axum::extract::State;
+use gix::bstr::ByteSlice as _;
+use gix_object::{Find, Write};
+use maud::{Markup, html};
+
+use crate::error::Result;
+use crate::state::AppState;
+
+/// How many commits the History card shows -- a dashboard lane, not the
+/// full pager `crate::pages::commits::list` already is.
+const HISTORY_LIMIT: usize = 8;
+
+/// How many characters of a comment's or issue's first line a `.what`
+/// row shows before ellipsizing.
+const WHAT_LIMIT: usize = 90;
+
+/// `GET /`.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure on the comment and issue
+/// listings; every repository read degrades in-card instead (see this
+/// module's own doc).
+pub async fn show<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let changes = worktree_changes(&state);
+ let (comments, _unreadable) =
+ ents_forge::comment::list_all(state.refs.as_ref(), &*state.objects())?;
+ let open_comments: Vec<(String, ents_forge::comment::Comment)> = comments
+ .into_iter()
+ .filter(|(_, comment)| comment.state == "open")
+ .collect();
+ let (issues, _unreadable) =
+ ents_forge::issue::list_all(state.refs.as_ref(), &*state.objects())?;
+ let open_issues: Vec<(String, ents_forge::Issue)> = issues
+ .into_iter()
+ .filter(|(_, issue)| issue.state == "open")
+ .collect();
+ let (history, _older) = super::commits::commit_rows(&state, None, HISTORY_LIMIT);
+
+ let repo = super::RepoHeader::from_state(&state);
+ let history_title = repo.branch.as_ref().map_or_else(
+ || "History".to_owned(),
+ |branch| format!("History \u{2014} {branch}"),
+ );
+
+ let attention = attention_card(&state, &open_comments, open_issues.len());
+ Ok(super::layout(
+ &repo,
+ &super::identity_label(&state),
+ super::Tab::Overview,
+ "Dashboard",
+ html! {
+ div.desk {
+ (working_tree_card(&state, changes.as_deref()))
+ (attention)
+ (issues_card(&open_issues))
+ }
+ div.desk-wide {
+ (history_card(&history_title, &history))
+ }
+ },
+ ))
+}
+
+/// The "Working tree" card: every changed file [`worktree_changes`] found,
+/// each linking into the Files browser with its open-in-editor affordance
+/// ([`super::editor_open`]) beside it and its change kind right-aligned.
+/// `None` (the status walk itself failed) renders a note row; an empty
+/// list renders a "clean" row -- either way the card itself always
+/// renders, so the desk's shape is stable.
+fn working_tree_card<O: Find>(
+ state: &AppState<O>,
+ changes: Option<&[(String, &'static str)]>,
+) -> Markup {
+ html! {
+ section.card {
+ div.card-header { "Working tree" }
+ @match changes {
+ None => { div.card-row.muted { "Working-tree status unavailable." } },
+ Some([]) => { div.card-row.muted { "Clean \u{2014} no uncommitted changes." } },
+ Some(changes) => {
+ @for (path, kind) in changes {
+ div.card-row {
+ a href={ "/files/" (path) } { (path) }
+ (super::editor_open(state, path, None))
+ span.entry-size { (kind) }
+ }
+ }
+ },
+ }
+ }
+ }
+}
+
+/// The "Needs attention" card: every open comment thread, each linking to
+/// its own page and naming where its anchor lands ([`comment_where`]),
+/// closed by an open-issues count line when any issues are open.
+fn attention_card<O: Find>(
+ state: &AppState<O>,
+ open_comments: &[(String, ents_forge::comment::Comment)],
+ open_issue_count: usize,
+) -> Markup {
+ html! {
+ section.card {
+ div.card-header { "Needs attention" }
+ @if open_comments.is_empty() && open_issue_count == 0 {
+ div.card-row.muted { "Nothing waiting on you." }
+ }
+ @for (id, comment) in open_comments {
+ a.attention-row href={ "/comments/" (id) } {
+ span.what { "open thread \u{2014} \u{201c}" (what_line(&comment.body)) "\u{201d}" }
+ span class="where" { (comment_where(state, comment)) }
+ }
+ }
+ @if open_issue_count > 0 {
+ a.attention-row href="/issues" {
+ span.what {
+ (open_issue_count)
+ @if open_issue_count == 1 { " open issue" } @else { " open issues" }
+ }
+ }
+ }
+ }
+ }
+}
+
+/// The "Issues" card: every open issue linking to its own page, with a
+/// ghost "New" button into the Issues page's own composer.
+fn issues_card(open_issues: &[(String, ents_forge::Issue)]) -> Markup {
+ html! {
+ section.card {
+ div.card-header {
+ "Issues"
+ a.btn.btn-ghost href="/issues" { "New" }
+ }
+ @if open_issues.is_empty() {
+ div.card-row.muted { "No open issues." }
+ }
+ @for (id, issue) in open_issues {
+ a.attention-row href={ "/issues/" (id) } {
+ span.what { (what_line(&issue.title)) }
+ span class="where" { "#" (ents_forge::abbreviate_id(id)) " \u{b7} " (issue.state) }
+ }
+ }
+ }
+ }
+}
+
+/// The full-width "History" card: the most recent commits, each with its
+/// Scoped-Commits scope chip ([`split_scope`], [`scope_class`]) when its
+/// subject carries one.
+fn history_card(title: &str, rows: &[super::commits::CommitRow]) -> Markup {
+ html! {
+ section.card.history {
+ div.card-header { (title) }
+ @if rows.is_empty() {
+ div.card-row.muted { "No commits yet." }
+ }
+ @for row in rows {
+ div.card-row {
+ a href={ "/commit/" (row.oid) } { code { (row.short) } }
+ @match split_scope(&row.subject) {
+ Some((scope, rest)) => {
+ span class={ "scope " (scope_class(scope)) } { (scope) }
+ span.desk-subject { (rest) }
+ },
+ None => { span.desk-subject { (row.subject) } },
+ }
+ span.entry-size { (row.ago) }
+ }
+ }
+ }
+ }
+}
+
+/// A body's first line, ellipsized past [`WHAT_LIMIT`] characters -- what
+/// a `.what` row shows of a comment or issue.
+fn what_line(text: &str) -> String {
+ let line = text.lines().next().unwrap_or("");
+ let mut shown: String = line.chars().take(WHAT_LIMIT).collect();
+ if shown.len() < line.len() {
+ shown.push('\u{2026}');
+ }
+ shown
+}
+
+/// Where an open comment lives, for its `.where` line: its anchor's
+/// `path:line` when it carries one this build can read back, else the
+/// context entity it names, else a bare "unanchored".
+fn comment_where<O: Find>(state: &AppState<O>, comment: &ents_forge::comment::Comment) -> String {
+ if let Some(raw) = &comment.anchor {
+ let objects = state.objects();
+ if let Ok(anchor) =
+ facet_git_tree::deserialize::<ents_anchor::Anchor>(&raw.oid(), &*objects)
+ {
+ return match anchor.lines {
+ Some(range) => format!("{}:{}", anchor.path, range.start),
+ None => anchor.path,
+ };
+ }
+ }
+ comment
+ .context
+ .clone()
+ .unwrap_or_else(|| "unanchored".to_owned())
+}
+
+/// Split a Scoped-Commits subject (`<scope>: <description>`,
+/// scopedcommits.com) into its scope and description -- `None` when the
+/// subject carries no `^[a-z-]+:` prefix, in which case the whole subject
+/// renders unchipped.
+fn split_scope(subject: &str) -> Option<(&str, &str)> {
+ let (scope, rest) = subject.split_once(':')?;
+ if scope.is_empty() || !scope.chars().all(|c| c.is_ascii_lowercase() || c == '-') {
+ return None;
+ }
+ Some((scope, rest.trim_start()))
+}
+
+/// The `.scope-c{n}` color class for `scope`: a stable hash of the scope
+/// name onto the stylesheet's six `--s-*` syntax-token colors, so the same
+/// scope always chips the same color across pages and requests.
+fn scope_class(scope: &str) -> String {
+ let hash = scope.bytes().fold(0u32, |acc, byte| {
+ acc.wrapping_mul(31).wrapping_add(u32::from(byte))
+ });
+ format!("scope-c{}", hash.checked_rem(6).unwrap_or(0))
+}
+
+/// Every changed path in the working tree against `HEAD` and the index --
+/// `gix`'s own status walk (`gix::Repository::status`), deduplicated by
+/// path (a file both staged and modified appears in the head-to-index and
+/// index-to-worktree halves; the first classification wins) and sorted for
+/// a stable render. `None` when the repository cannot be opened or the
+/// walk cannot start at all -- [`working_tree_card`] renders a note row
+/// then, never an error.
+fn worktree_changes<O>(state: &AppState<O>) -> Option<Vec<(String, &'static str)>> {
+ let repo = gix::open(&state.path).ok()?;
+ let iter = repo
+ .status(gix::progress::Discard)
+ .ok()?
+ .into_iter(None)
+ .ok()?;
+ let mut by_path: std::collections::BTreeMap<String, &'static str> =
+ std::collections::BTreeMap::new();
+ for item in iter.flatten() {
+ let Some(kind) = change_kind(&item) else {
+ continue;
+ };
+ by_path
+ .entry(item.location().to_str_lossy().into_owned())
+ .or_insert(kind);
+ }
+ Some(by_path.into_iter().collect())
+}
+
+/// A status item's display kind, or `None` for one that is not a change a
+/// reader acts on (a stat-only refresh, an ignored entry).
+fn change_kind(item: &gix::status::Item) -> Option<&'static str> {
+ use gix::status::plumbing::index_as_worktree::{Change, EntryStatus};
+ match item {
+ gix::status::Item::TreeIndex(change) => Some(match change {
+ gix::diff::index::ChangeRef::Addition { .. } => "added",
+ gix::diff::index::ChangeRef::Deletion { .. } => "deleted",
+ gix::diff::index::ChangeRef::Modification { .. } => "modified",
+ gix::diff::index::ChangeRef::Rewrite { .. } => "renamed",
+ }),
+ gix::status::Item::IndexWorktree(change) => match change {
+ gix::status::index_worktree::Item::Modification { status, .. } => match status {
+ EntryStatus::Conflict { .. } => Some("conflict"),
+ EntryStatus::Change(change) => Some(match change {
+ Change::Removed => "deleted",
+ Change::Type { .. } => "type changed",
+ Change::Modification { .. } | Change::SubmoduleModification(_) => "modified",
+ }),
+ EntryStatus::NeedsUpdate(_) => None,
+ EntryStatus::IntentToAdd => Some("added"),
+ },
+ gix::status::index_worktree::Item::DirectoryContents { entry, .. } => {
+ matches!(entry.status, gix::dir::entry::Status::Untracked).then_some("untracked")
+ }
+ gix::status::index_worktree::Item::Rewrite { .. } => Some("renamed"),
+ },
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ #[case::scoped("model: fix stale rustdoc", Some(("model", "fix stale rustdoc")))]
+ #[case::hyphenated("web-ui: polish", Some(("web-ui", "polish")))]
+ #[case::unscoped("Fix stale rustdoc", None)]
+ #[case::uppercase_prefix("Model: fix", None)]
+ #[case::no_colon("just a subject", None)]
+ #[case::empty_scope(": odd", None)]
+ fn split_scope_takes_only_a_lowercase_scope_prefix(
+ #[case] subject: &str,
+ #[case] expected: Option<(&str, &str)>,
+ ) {
+ assert_eq!(split_scope(subject), expected);
+ }
+
+ #[test]
+ fn scope_class_is_stable_and_within_the_token_palette() {
+ let class = scope_class("model");
+ assert_eq!(class, scope_class("model"), "same scope, same color");
+ let index: usize = class
+ .strip_prefix("scope-c")
+ .expect("prefixed class")
+ .parse()
+ .expect("numeric suffix");
+ assert!(index < 6, "always one of the six --s-* token colors");
+ }
+
+ #[test]
+ fn what_line_takes_the_first_line_and_ellipsizes_long_ones() {
+ assert_eq!(what_line("short\nrest"), "short");
+ let long = "x".repeat(200);
+ let shown = what_line(&long);
+ assert!(shown.chars().count() <= WHAT_LIMIT.saturating_add(1));
+ assert!(shown.ends_with('\u{2026}'));
+ }
+}
crates/cli/ents-web/src/pages/effects.rs
@@ -1,0 +1,233 @@
+//! `GET /effects`, `GET /effects/{name}`: the generic list/view pair for
+//! [`ents_model::Effect`], plus a light, genuine use of `ents-query`
+//! (`overview.adoc`'s crate-graph row for this crate names it as a
+//! dependency): the show page re-parses the effect's own trigger text as a
+//! [`ents_query::Query`] and reports whether it still parses, exactly the
+//! tolerance check `git_ents::hook::read_effect` already performs on the
+//! hosted root before running an effect.
+
+use std::sync::Arc;
+
+use axum::Form;
+use axum::extract::{Path, State};
+use axum::response::{IntoResponse, Redirect};
+use ents_model::{Effect, namespace};
+use ents_query::Query;
+use gix_object::{Find, Write};
+use maud::html;
+use serde::Deserialize;
+
+use crate::error::{Error, Result};
+use crate::session::Session;
+use crate::state::AppState;
+
+/// `GET /effects`.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+pub async fn list<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let mut rows = Vec::new();
+ let mut failures = Vec::new();
+ for (name, effect) in read_all(&state)? {
+ match effect {
+ Ok(effect) => rows.push((name, effect)),
+ Err(error) => failures.push((format!("refs/meta/effects/{name}"), error)),
+ }
+ }
+ let table = if rows.is_empty() {
+ super::blankslate(
+ "No effects yet",
+ html! { "Define one with the form below." },
+ )
+ } else {
+ crate::render::list_table(&rows, "name", |id| format!("/effects/{id}"))
+ };
+ Ok(super::layout_meta(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ "/effects",
+ "Effects",
+ html! {
+ (crate::render::unreadable_disclosure(&failures))
+ (table)
+ h2 { "Define an Effect" }
+ (add_form(&session))
+ },
+ ))
+}
+
+/// The define-effect form (`POST /effects`) -- `git ents effect add`'s
+/// own arguments as form fields.
+fn add_form(session: &Session) -> maud::Markup {
+ html! {
+ form method="post" action="/effects" {
+ (super::csrf_input(session))
+ label { "name" input type="text" name="name"; }
+ label {
+ "trigger"
+ input type="text" name="trigger" placeholder="query.grammar trigger";
+ }
+ label { "run" input type="text" name="run" placeholder="command to run"; }
+ label {
+ "toolchains"
+ input type="text" name="toolchains" placeholder="rust, node";
+ }
+ button type="submit" { "Define Effect" }
+ }
+ }
+}
+
+/// The form fields `POST /effects` accepts.
+#[derive(Debug, Deserialize)]
+pub struct AddForm {
+ /// Name to record the effect under (`refs/meta/effects/<name>`).
+ name: String,
+ /// The query the effect triggers on (`query.grammar`).
+ trigger: String,
+ /// The command the effect runs.
+ run: String,
+ /// Comma- or whitespace-separated toolchain names.
+ #[serde(default)]
+ toolchains: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+/// `POST /effects`: define (or replace) an effect as a signed mutation on
+/// `refs/meta/effects/<name>` -- the web counterpart of
+/// `git ents effect add`, sharing its pre-write rule that the trigger must
+/// parse (`ents_receive::reconcile`'s tolerance rule would otherwise
+/// silently skip a malformed one on every future scan).
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match;
+/// [`Error::InvalidArgument`] on an unparsable trigger or an empty name;
+/// otherwise propagates the `receive` proposal's own failures.
+// @relation(roots.web-signing, roots.web-session, scope=function)
+pub async fn create<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Form(form): Form<AddForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let _: Query = form.trigger.parse().map_err(|_source| {
+ Error::InvalidArgument(format!("unparsable trigger: {}", form.trigger))
+ })?;
+ let name = form.name.trim();
+ let ref_name = namespace::effect_ref(name)
+ .map_err(|_invalid| Error::InvalidArgument(format!("invalid effect name: {name}")))?;
+ let effect = Effect {
+ name: name.to_owned(),
+ trigger: form.trigger,
+ toolchains: form
+ .toolchains
+ .split([',', ' '])
+ .map(str::trim)
+ .filter(|part| !part.is_empty())
+ .map(str::to_owned)
+ .collect(),
+ run: form.run,
+ };
+ let identity = state.identity.as_ref();
+ let outcome = ents_receive::propose_entity(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ ref_name,
+ &effect,
+ &crate::receive_identity!(identity),
+ &format!("Define effect {name}"),
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(Redirect::to(&format!("/effects/{name}")))
+}
+
+/// `GET /effects/{name}`.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `name` has no effect ref at all -- an effect ref
+/// that exists but whose stored tree does not match this build's
+/// [`Effect`] shape degrades to [`crate::render::unreadable`] instead
+/// (`roots.web-agnostic`'s graceful-degradation stance); the trigger-query
+/// parse check is skipped in that case, since there is no [`Effect`] to
+/// check.
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ Path(name): Path<String>,
+) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let (_, effect) = read_all(&state)?
+ .into_iter()
+ .find(|(id, _)| *id == name)
+ .ok_or_else(|| Error::NotFound {
+ what: format!("effect {name}"),
+ })?;
+ let body = match effect {
+ Ok(effect) => {
+ let query_status = match effect.trigger.parse::<Query>() {
+ Ok(_) => "parses".to_owned(),
+ Err(error) => format!("does not parse: {error}"),
+ };
+ html! {
+ (crate::render::view(&effect))
+ p { "trigger query: " (query_status) }
+ }
+ }
+ Err(detail) => crate::render::unreadable(&detail),
+ };
+ Ok(super::layout_meta(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ "/effects",
+ &name,
+ html! {
+ (super::child_crumbs("effects", "/effects", &name))
+ (body)
+ },
+ ))
+}
+
+/// Every `refs/meta/effects/*` ref, with its tip's tree deserialized as an
+/// [`Effect`] -- `Err(detail)` for a ref this build's `#[derive(Facet)]`
+/// shape could not read back, kept in the listing rather than dropped (see
+/// `crate::pages::members::read_all`'s identical rationale).
+fn read_all<O: Find>(
+ state: &AppState<O>,
+) -> Result<Vec<(String, std::result::Result<Effect, String>)>> {
+ let mut out = Vec::new();
+ for entry in state.refs.iter_prefix("refs/meta/effects/")? {
+ let (name, tip) = entry?;
+ let path = name.as_bstr().to_string();
+ let Some(id) = path.strip_prefix("refs/meta/effects/") else {
+ continue;
+ };
+ // One `state.objects()` lock per iteration, reused for both reads
+ // -- see `crate::pages::members::read_all`'s identical comment for
+ // why a second `state.objects()` within the same statement would
+ // self-deadlock on this non-reentrant `Mutex`.
+ let objects = state.objects();
+ let effect = super::commit_tree(&*objects, tip)
+ .map_err(|error| error.to_string())
+ .and_then(|tree| {
+ facet_git_tree::deserialize::<Effect>(&tree, &*objects)
+ .map_err(|error| error.to_string())
+ });
+ out.push((id.to_owned(), effect));
+ }
+ Ok(out)
+}
crates/cli/ents-web/src/pages/files.rs
@@ -1,0 +1,1358 @@
+//! `GET /files`, `GET /files/{*path}`: a read-only directory listing and
+//! blob viewer over the `HEAD` tree of the repository `git ents serve` is
+//! serving. A `.md` blob renders via `crate::markdown`, a
+//! `.adoc`/`.asciidoc`/`.asc`/`.adc` blob via `crate::asciidoc`, and
+//! everything else as a line-numbered source view, syntax-highlighted via
+//! [`arborium`] when its filename maps to a known grammar (ported from
+//! `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own `highlight`;
+//! see `highlight`'s own doc), escaped plain text otherwise.
+//!
+//! Tree/blob reads go through `gix`'s high-level `Repository`/`Tree`/`Blob`
+//! types (`repo.head_tree()`, `Tree::lookup_entry_by_path`,
+//! `Entry::object`), opened fresh per request from `state.path` -- the
+//! same `gix::open(repo_path)` pattern `ents_forge::comment::add`/`show`
+//! already use to browse a live working tree, not the
+//! `facet-git-tree`/`gix_object::Find` convention the rest of this crate's
+//! pages use to read typed meta-ref entities (`facet-git-tree` is for
+//! structured meta-ref data; browsing arbitrary repository content is not
+//! that).
+//!
+//! `crumbs` renders only the path trail now -- every action that used to
+//! live at its trailing edge (jump into history, jump to the first
+//! comment, add a comment) moved into `blob_header`'s own right-aligned
+//! action group, rendered above every blob view regardless of how it
+//! renders (raw source, a rendered document, or a binary placeholder); a
+//! directory listing carries neither the actions nor a header, since a
+//! comment anchors to a file, never a tree.
+//!
+//! A blob view also loads and renders the comments anchored to it
+//! (`crate::pages::comments::for_path`). A raw-source view (not a
+//! rendered document or a binary placeholder) interleaves each comment's
+//! card directly after the row naming its anchored range's last line,
+//! full width across the blob's line-number and code columns
+//! (`source_view`); a comment with no current line range (a whole-file
+//! anchor, or `ents_anchor::Projection::Outdated`) has nowhere to
+//! interleave, and renders in a below-the-blob "outdated comments"
+//! section instead (`outdated_comments_section`). Doc-rendered and
+//! binary views keep every comment below the blob, unconditionally
+//! (`crate::pages::comments::comments_section`), since there is no source
+//! line to interleave at.
+//!
+//! A raw-source view additionally carries the client-side hooks
+//! `crate::assets`'s `ents.js` progressively enhances: `div.blob` names its
+//! own `path`/`rev` (`data-path`/`data-rev`, the latter the resolved `HEAD`
+//! commit oid, not the string `"HEAD"`, so a captured selection names the
+//! exact commit being viewed) so a click on a gutter line number can select
+//! a line or a shift-extended range and open an inline comment composer
+//! cloned from a server-rendered `<template id="composer-template">`
+//! (`composer_template`) -- with JS disabled the page stays fully usable
+//! via `blob_header`'s "comment on this file" link and the plain `#L<n>`
+//! anchors `crate::pages::comments::comment_card` already emits.
+
+use std::sync::Arc;
+
+use arborium::{Config, Highlighter, HtmlFormat};
+use axum::extract::{Path, State};
+use gix::bstr::ByteSlice as _;
+use gix_object::{Find, Write};
+use maud::{Markup, PreEscaped, html};
+
+use crate::assets;
+use crate::error::{Error, Result};
+use crate::session::Session;
+use crate::state::AppState;
+
+/// `GET /files`: the repository root directory listing.
+///
+/// # Errors
+///
+/// Propagates a `gix::open`/tree-read failure.
+pub async fn root<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+) -> Result<Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ at(&state, "", &session)
+}
+
+/// `GET /files/{*path}`: a directory listing or blob view at `path`.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `path` does not name a tree or blob entry (or
+/// contains a `.`/`..` component); otherwise propagates a
+/// `gix::open`/tree-read failure.
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path(path): Path<String>,
+) -> Result<Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ at(&state, &path, &session)
+}
+
+/// The shared implementation behind [`root`] and [`show`]: resolve `path`
+/// against `HEAD`'s tree and render whichever of a directory listing or a
+/// blob view it names. `session` is only ever needed on a blob view, to
+/// render [`composer_template`]'s csrf input -- threaded down from the
+/// route handler rather than reached for a second time here.
+fn at<O>(state: &AppState<O>, path: &str, session: &Session) -> Result<Markup>
+where
+ O: Find + Write,
+{
+ if !is_safe_path(path) {
+ return Err(Error::NotFound {
+ what: path.to_owned(),
+ });
+ }
+
+ let repo = gix::open(&state.path).map_err(|source| Error::Repo(source.to_string()))?;
+ let head_tree = match repo.head_tree() {
+ Ok(tree) => tree,
+ // An unborn HEAD (a freshly initialized, still-empty repository)
+ // reads as an empty root directory, not a failure -- mirrors
+ // `pre-redo:crates/git-ents-server/src/web/git.rs`'s `root_tree`,
+ // which returned an empty entry list rather than erroring when the
+ // repository had no `HEAD` yet.
+ Err(_) if path.is_empty() => {
+ return Ok(super::layout(
+ &super::RepoHeader::from_state(state),
+ &super::identity_label(state),
+ super::Tab::Files,
+ "Files",
+ html! {
+ (dir_listing(path, Vec::new()))
+ },
+ ));
+ }
+ Err(_) => {
+ return Err(Error::NotFound {
+ what: path.to_owned(),
+ });
+ }
+ };
+
+ if path.is_empty() {
+ let entries = tree_entries(&head_tree)?;
+ return Ok(super::layout_split(
+ &super::RepoHeader::from_state(state),
+ &super::identity_label(state),
+ super::Tab::Files,
+ "Files",
+ tree_sidebar(&head_tree, "", ""),
+ html! {
+ (dir_listing(path, entries))
+ (readme_card(&head_tree))
+ },
+ ));
+ }
+
+ let entry = head_tree
+ .lookup_entry_by_path(path)
+ .map_err(|source| Error::Repo(source.to_string()))?
+ .ok_or_else(|| Error::NotFound {
+ what: path.to_owned(),
+ })?;
+
+ if entry.mode().is_tree() {
+ let subtree = entry
+ .object()
+ .map_err(|source| Error::Repo(source.to_string()))?
+ .try_into_tree()
+ .map_err(|source| Error::Repo(source.to_string()))?;
+ let entries = tree_entries(&subtree)?;
+ Ok(super::layout_split(
+ &super::RepoHeader::from_state(state),
+ &super::identity_label(state),
+ super::Tab::Files,
+ path,
+ tree_sidebar(&head_tree, path, path),
+ html! {
+ (crumbs(path))
+ (dir_listing(path, entries))
+ },
+ ))
+ } else if entry.mode().is_blob() {
+ let blob = entry
+ .object()
+ .map_err(|source| Error::Repo(source.to_string()))?
+ .try_into_blob()
+ .map_err(|source| Error::Repo(source.to_string()))?;
+ let name = path.rsplit('/').next().unwrap_or(path);
+ let comments = super::comments::for_path(state, &repo, path);
+ let head_oid = repo
+ .head_id()
+ .map_err(|source| Error::Repo(source.to_string()))?
+ .to_string();
+ let editor = super::editor_open(state, path, None);
+ let (body, below) = blob_view(
+ path, name, &head_oid, session, &blob.data, &comments, editor,
+ )?;
+ let parent = path.rsplit_once('/').map_or("", |(dir, _)| dir);
+ Ok(super::layout_split(
+ &super::RepoHeader::from_state(state),
+ &super::identity_label(state),
+ super::Tab::Files,
+ path,
+ tree_sidebar(&head_tree, parent, path),
+ html! {
+ (crumbs(path))
+ (body)
+ (below)
+ },
+ ))
+ } else {
+ // A symlink or a submodule (gitlink) -- neither is a tree or a
+ // blob this browser can render.
+ Err(Error::NotFound {
+ what: path.to_owned(),
+ })
+ }
+}
+
+/// Whether `path` is safe to resolve against a tree: no empty, `.`, or
+/// `..` component. The empty root path is itself safe.
+fn is_safe_path(path: &str) -> bool {
+ path.is_empty()
+ || path
+ .split('/')
+ .all(|s| !s.is_empty() && s != "." && s != "..")
+}
+
+/// One `(name, is_directory, size)` triple per direct child of `tree`, in
+/// tree order (not yet sorted -- [`dir_listing`] sorts for display). `size`
+/// is a blob entry's byte length, read from its odb header
+/// ([`gix::Repository::find_header`], a header-only lookup -- never a
+/// full blob read just to size it) and best-effort (`None`
+/// on a header-read failure); always
+/// `None` for a directory entry, which [`dir_listing`] renders with no
+/// size cell at all.
+fn tree_entries(tree: &gix::Tree<'_>) -> Result<Vec<(String, bool, Option<u64>)>> {
+ tree.iter()
+ .map(|entry| {
+ let entry = entry.map_err(|source| Error::Repo(source.to_string()))?;
+ let is_dir = entry.mode().is_tree();
+ let size = (!is_dir)
+ .then(|| tree.repo.find_header(entry.oid()).ok())
+ .flatten()
+ .map(|header| header.size());
+ Ok((entry.filename().to_str_lossy().into_owned(), is_dir, size))
+ })
+ .collect()
+}
+
+/// The Code split's `.tree` sidebar (`crate::pages::layout_split`): a
+/// crumb trail back to the repository root, then the entries of the
+/// directory at `dir` (the viewed directory itself, or a viewed blob's
+/// parent), directories first -- not a full recursive tree, just enough
+/// context to move one level in any direction. `active` names the full
+/// path of the entry (or trailing crumb) being viewed. Best-effort: a
+/// subtree that fails to read renders an empty entry list rather than
+/// failing the page around it.
+fn tree_sidebar(head_tree: &gix::Tree<'_>, dir: &str, active: &str) -> Markup {
+ let mut entries = if dir.is_empty() {
+ tree_entries(head_tree).unwrap_or_default()
+ } else {
+ head_tree
+ .lookup_entry_by_path(dir)
+ .ok()
+ .flatten()
+ .and_then(|entry| entry.object().ok())
+ .and_then(|object| object.try_into_tree().ok())
+ .map(|subtree| tree_entries(&subtree).unwrap_or_default())
+ .unwrap_or_default()
+ };
+ entries.sort_by(|(a_name, a_is_dir, _), (b_name, b_is_dir, _)| {
+ b_is_dir.cmp(a_is_dir).then_with(|| a_name.cmp(b_name))
+ });
+
+ let crumb_parts: Vec<&str> = dir.split('/').filter(|s| !s.is_empty()).collect();
+ let mut crumb_trail: Vec<(String, String)> = Vec::new();
+ let mut acc = String::new();
+ for part in &crumb_parts {
+ if !acc.is_empty() {
+ acc.push('/');
+ }
+ acc.push_str(part);
+ crumb_trail.push(((*part).to_owned(), acc.clone()));
+ }
+ let entry_depth = crumb_parts.len().saturating_add(1);
+
+ html! {
+ a class=(tree_class(true, 0, active.is_empty())) href="/files" { "/" }
+ @for (index, (label, crumb_path)) in crumb_trail.iter().enumerate() {
+ a class=(tree_class(true, index.saturating_add(1), crumb_path == active))
+ href={ "/files/" (crumb_path) } { (label) "/" }
+ }
+ @for (name, is_dir, _) in &entries {
+ @let full = if dir.is_empty() { name.clone() } else { format!("{dir}/{name}") };
+ a class=(tree_class(*is_dir, entry_depth, full == active))
+ href=(child_href(dir, name)) {
+ (name) @if *is_dir { "/" }
+ }
+ }
+ }
+}
+
+/// The class list for one [`tree_sidebar`] link: `.dir` for a directory,
+/// an `.i{1..3}` indent per crumb depth (capped -- the sidebar shows one
+/// directory's entries, not an unbounded tree), `.active` for the viewed
+/// entry.
+fn tree_class(is_dir: bool, depth: usize, active: bool) -> String {
+ let mut classes = Vec::new();
+ if is_dir {
+ classes.push("dir");
+ }
+ match depth {
+ 0 => {}
+ 1 => classes.push("i1"),
+ 2 => classes.push("i2"),
+ _ => classes.push("i3"),
+ }
+ if active {
+ classes.push("active");
+ }
+ classes.join(" ")
+}
+
+/// The link to a child of the directory at `dir` (empty at the root).
+fn child_href(dir: &str, name: &str) -> String {
+ if dir.is_empty() {
+ format!("/files/{name}")
+ } else {
+ format!("/files/{dir}/{name}")
+ }
+}
+
+/// A directory listing at `dir`: entries sorted directories-first then
+/// alphabetically, each an icon and a link one level deeper, plus a
+/// right-aligned muted size for a blob entry (`span.entry-size`,
+/// [`human_size`]) -- a directory entry carries no size cell, since a
+/// tree's own byte length is not a meaningful measure of it.
+fn dir_listing(dir: &str, mut entries: Vec<(String, bool, Option<u64>)>) -> Markup {
+ entries.sort_by(|(a_name, a_is_dir, _), (b_name, b_is_dir, _)| {
+ b_is_dir.cmp(a_is_dir).then_with(|| a_name.cmp(b_name))
+ });
+ html! {
+ div.card {
+ div.card-header { "files" }
+ @if entries.is_empty() {
+ div.card-row.muted { "Empty directory." }
+ }
+ @for (name, is_dir, size) in &entries {
+ div.card-row.is-dir[*is_dir] {
+ a.row-link href=(child_href(dir, name)) {
+ @if *is_dir { (assets::icon_folder()) } @else { (assets::icon_file()) }
+ (name)
+ }
+ @if let Some(size) = size {
+ span.entry-size { (human_size(*size)) }
+ }
+ }
+ }
+ }
+ }
+}
+
+/// The rendered `README` card below the root listing -- re-homed here
+/// from the old overview dashboard (`crate::pages::dashboard` is a work
+/// surface now; the Code root is where the repository introduces itself).
+/// Renders nothing at all when the root holds no renderable `README`.
+fn readme_card(tree: &gix::Tree<'_>) -> Markup {
+ let Some((name, rendered)) = readme(tree) else {
+ return html! {};
+ };
+ html! {
+ div.card {
+ div.card-header { (assets::icon_file()) (name) }
+ div.doc-body { (rendered) }
+ }
+ }
+}
+
+/// The first root-tree blob whose stem is `README` and whose extension
+/// this crate renders (Markdown or AsciiDoc), converted to HTML and paired
+/// with its filename; `None` when there is none or it fails to render
+/// (mirrors `pre-redo:.../pages.rs`'s `readme`).
+fn readme(tree: &gix::Tree<'_>) -> Option<(String, Markup)> {
+ let name = root_readme_name(tree)?;
+ let entry = tree.lookup_entry_by_path(&name).ok()??;
+ let blob = entry.object().ok()?.try_into_blob().ok()?;
+ let text = String::from_utf8_lossy(&blob.data);
+ render_doc(&name, &text).map(|rendered| (name, rendered))
+}
+
+/// The filename of the root's `README`, if it has a renderable one.
+fn root_readme_name(tree: &gix::Tree<'_>) -> Option<String> {
+ for entry in tree.iter() {
+ let Ok(entry) = entry else { continue };
+ if !entry.mode().is_blob() {
+ continue;
+ }
+ let name = entry.filename().to_str_lossy();
+ let is_readme = name
+ .rsplit_once('.')
+ .is_some_and(|(stem, _)| stem.eq_ignore_ascii_case("readme"));
+ if is_readme && (crate::markdown::is_markdown(&name) || crate::asciidoc::is_asciidoc(&name))
+ {
+ return Some(name.into_owned());
+ }
+ }
+ None
+}
+
+/// `text` rendered as its prose format (Markdown or AsciiDoc), or `None`
+/// when it is neither or AsciiDoc rendering fails.
+fn render_doc(name: &str, text: &str) -> Option<Markup> {
+ if crate::markdown::is_markdown(name) {
+ Some(crate::markdown::to_html(text))
+ } else if crate::asciidoc::is_asciidoc(name) {
+ crate::asciidoc::to_html(text).ok()
+ } else {
+ None
+ }
+}
+
+/// Breadcrumb navigation from the repository's files root down through
+/// `path`, `chevron-right` icons separating segments -- pure navigation,
+/// no trailing actions. The history/comment links that used to trail this
+/// nav on a blob view now live in [`blob_header`]'s own action group
+/// instead (see this module's own top-level doc for why). The files root
+/// itself renders no crumbs at all: a lone self-referencing "files" crumb
+/// under the page's own "Files" title (and above the listing card's own
+/// "files" header) named the same place three times.
+fn crumbs(path: &str) -> Markup {
+ let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
+ let mut acc = String::new();
+ let mut trail: Vec<(String, Option<String>)> =
+ vec![("files".to_owned(), Some("/files".to_owned()))];
+ for (index, part) in parts.iter().enumerate() {
+ if !acc.is_empty() {
+ acc.push('/');
+ }
+ acc.push_str(part);
+ let is_last = index.saturating_add(1) == parts.len();
+ let href = (!is_last).then(|| format!("/files/{acc}"));
+ trail.push(((*part).to_owned(), href));
+ }
+ html! {
+ nav.crumbs {
+ @for (index, (label, href)) in trail.iter().enumerate() {
+ @if index > 0 { span.sep { (assets::icon_chevron()) } }
+ @match href {
+ Some(href) => a href=(href) { (label) },
+ None => span.here { (label) },
+ }
+ }
+ }
+ }
+}
+
+/// Format a byte count the way [`blob_header`] and [`dir_listing`] both
+/// show a file's size: whole bytes under 1 KB, otherwise one decimal place
+/// of KB or MB -- integer-only throughout (`checked_div`/`checked_rem`/
+/// `saturating_mul`, this crate's own arithmetic idiom) rather than a
+/// float division, so there is no rounding-mode or precision question to
+/// answer.
+fn human_size(bytes: u64) -> String {
+ const KB: u64 = 1024;
+ const MB: u64 = 1024 * 1024;
+ if bytes < KB {
+ return format!("{bytes} B");
+ }
+ let (scale, unit) = if bytes < MB { (KB, "KB") } else { (MB, "MB") };
+ let whole = bytes.checked_div(scale).unwrap_or(0);
+ let remainder = bytes.checked_rem(scale).unwrap_or(0);
+ let tenths = remainder.saturating_mul(10).checked_div(scale).unwrap_or(0);
+ format!("{whole}.{tenths} {unit}")
+}
+
+/// Whether `bytes` looks like binary content (a NUL byte in the leading
+/// chunk -- the same heuristic git itself uses, carried over from
+/// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own `is_binary`).
+fn is_binary(bytes: &[u8]) -> bool {
+ bytes.iter().take(8000).any(|b| *b == 0)
+}
+
+/// A single blob's contents, plus whatever comments belong below it: a
+/// Markdown/AsciiDoc document rendered as such via
+/// [`crate::markdown`]/[`crate::asciidoc`] or a binary-content placeholder
+/// -- either way every `comment` renders below, unconditionally
+/// ([`crate::pages::comments::comments_section`]), since there is no
+/// source line to interleave a card at -- or [`source_view`]'s
+/// line-per-row rendering, which interleaves a comment with a current line
+/// range directly into the blob and returns the rest (no current line
+/// range: a whole-file anchor, or `ents_anchor::Projection::Outdated`) as
+/// a separate below-the-blob section ([`outdated_comments_section`]).
+/// Every case renders a [`blob_header`] first -- above `div.card`/
+/// `div.binary` for a doc-rendered or binary view, as `div.blob`'s own
+/// first child for a raw-source view (see [`source_view`]'s own doc).
+///
+/// # Errors
+///
+/// Propagates [`crate::asciidoc::to_html`]'s own [`Error::Asciidoc`].
+fn blob_view(
+ path: &str,
+ name: &str,
+ head_oid: &str,
+ session: &Session,
+ bytes: &[u8],
+ comments: &[super::comments::FileComment],
+ editor: Markup,
+) -> Result<(Markup, Markup)> {
+ let size = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
+ let comment_count = comments.len();
+ let no_line_count_header = || {
+ blob_header(&BlobHeaderMeta {
+ name,
+ path,
+ size,
+ line_count: None,
+ language: None,
+ comments: comment_count,
+ editor: editor.clone(),
+ })
+ };
+ if is_binary(bytes) {
+ return Ok((
+ html! {
+ (no_line_count_header())
+ div.binary { "Binary file (" (bytes.len()) " bytes) not shown." }
+ },
+ super::comments::comments_section(comments),
+ ));
+ }
+ let Ok(text) = std::str::from_utf8(bytes) else {
+ return Ok((
+ html! {
+ (no_line_count_header())
+ div.binary { "Binary file (" (bytes.len()) " bytes) not shown." }
+ },
+ super::comments::comments_section(comments),
+ ));
+ };
+ if crate::markdown::is_markdown(name) {
+ return Ok((
+ html! {
+ (no_line_count_header())
+ div.card { div.doc-body { (crate::markdown::to_html(text)) } }
+ },
+ super::comments::comments_section(comments),
+ ));
+ }
+ if crate::asciidoc::is_asciidoc(name) {
+ return Ok((
+ html! {
+ (no_line_count_header())
+ div.card { div.doc-body { (crate::asciidoc::to_html(text)?) } }
+ },
+ super::comments::comments_section(comments),
+ ));
+ }
+ let language = arborium::detect_language(name);
+ let highlighted = highlight(name, text);
+ let line_count = text.lines().count().max(1);
+ let header = blob_header(&BlobHeaderMeta {
+ name,
+ path,
+ size,
+ line_count: Some(line_count),
+ language,
+ comments: comment_count,
+ editor,
+ });
+ let composer = composer_template(path, head_oid, session);
+ let below: Vec<(usize, &super::comments::FileComment)> = comments
+ .iter()
+ .enumerate()
+ .filter(|(_, comment)| comment.lines.is_none())
+ .collect();
+ Ok((
+ source_view(
+ path,
+ head_oid,
+ header,
+ composer,
+ text,
+ highlighted,
+ comments,
+ ),
+ outdated_comments_section(&below),
+ ))
+}
+
+/// The metadata [`blob_header`] shows beside a blob's name -- gathered by
+/// [`blob_view`], one per view kind (see that function's own doc for which
+/// fields each kind fills in).
+struct BlobHeaderMeta<'a> {
+ /// The file's own name (the last path segment), shown as the title.
+ name: &'a str,
+ /// The full repository-relative path -- only used to build the
+ /// "comment on this file" link's `?file=` query.
+ path: &'a str,
+ /// The blob's byte length, always known ([`human_size`]).
+ size: u64,
+ /// The raw-source view's line count; `None` for a doc-rendered or
+ /// binary view, which has no source line to count.
+ line_count: Option<usize>,
+ /// [`arborium::detect_language`]'s own identifier, shown as-is when it
+ /// recognized `name`'s grammar; `None` otherwise, or for a
+ /// doc-rendered/binary view (a rendered document is not "highlighted
+ /// as" a language, and a binary blob was never linted for one at all).
+ language: Option<&'static str>,
+ /// How many comments [`super::comments::for_path`] found for this
+ /// blob -- the "N comments" jump link renders only when this is above
+ /// zero (mirrors [`crumbs`]'s own former stance, now moved here).
+ comments: usize,
+ /// The pre-rendered open-in-editor affordance ([`super::editor_open`];
+ /// empty when no editor is recognized), leading the actions so the
+ /// jump back to the desk sits first.
+ editor: Markup,
+}
+
+/// The header bar above every blob view -- the file's name and metadata on
+/// the left (`span.blob-title`/`span.blob-meta`), the actions that used to
+/// trail [`crumbs`] on the right (`span.blob-actions`): a jump into
+/// `crate::pages::commits`'s `GET /commits` history (the file browser's
+/// one entry point into commit history, since history is a view of the
+/// code, not a tab of its own -- `crate::pages::mod`'s own doc), a jump
+/// straight to the first comment card (`#comment-0`, in display order --
+/// see [`super::comments::comment_card`]'s own doc) when at least one
+/// comment is already anchored here, and `crate::pages::comments`'s own
+/// add form for this file ("comment on this file" -- the no-JS fallback
+/// entry point into the composer [`composer_template`] otherwise opens
+/// inline). Renders identically whether the view below it is raw source, a
+/// rendered document, or a binary placeholder -- only [`BlobHeaderMeta`]'s
+/// fields differ per kind.
+fn blob_header(meta: &BlobHeaderMeta<'_>) -> Markup {
+ html! {
+ div.blob-header {
+ span.blob-title { (meta.name) }
+ span.blob-meta {
+ @if let Some(lines) = meta.line_count {
+ (lines) @if lines == 1 { " line" } @else { " lines" } " \u{b7} " (human_size(meta.size))
+ } @else {
+ (human_size(meta.size))
+ }
+ @if let Some(language) = meta.language {
+ " \u{b7} " (language)
+ }
+ }
+ span.blob-actions {
+ (meta.editor)
+ a href="/commits" { "history" }
+ @if meta.comments > 0 {
+ a href="#comment-0" {
+ (meta.comments) @if meta.comments == 1 { " comment" } @else { " comments" }
+ }
+ }
+ a href={ "/comments?file=" (meta.path) } { "comment on this file" }
+ }
+ }
+ }
+}
+
+/// The composer's server-rendered `<template>`, cloned by `ents.js` when a
+/// reader clicks the gutter's `+` affordance on a raw-source view (see this
+/// module's own top-level doc). Its `form` posts to
+/// `crate::pages::comments`'s own `POST /comments` handler
+/// ([`super::comments::AddForm`]), pre-filled with this file's own
+/// `path`/`rev` (`head_oid`, the resolved `HEAD` commit, exactly what
+/// `div.blob`'s own `data-rev` names -- see [`source_view`]'s own doc) so
+/// the only field `ents.js` ever needs to fill in before submit is the
+/// hidden `lines` input, left empty here. With JS disabled this template
+/// never becomes visible at all (a `<template>` element's contents are
+/// inert, never rendered by a browser on their own), which is exactly why
+/// [`blob_header`]'s "comment on this file" link remains the no-JS path to
+/// the same form.
+// @relation(roots.web-session, scope=function)
+fn composer_template(path: &str, head_oid: &str, session: &Session) -> Markup {
+ html! {
+ template id="composer-template" {
+ form.composer-form method="post" action="/comments" {
+ (super::csrf_input(session))
+ input type="hidden" name="path" value=(path);
+ input type="hidden" name="rev" value=(head_oid);
+ input type="hidden" name="lines" value="";
+ textarea name="body" placeholder="Leave a comment (AsciiDoc)" {}
+ div.composer-buttons {
+ button type="submit" { "Comment" }
+ button.composer-cancel type="button" { "Cancel" }
+ }
+ }
+ }
+ }
+}
+
+/// The raw-source view: `header` and `composer` (see [`blob_header`]/
+/// [`composer_template`]'s own docs) around one table row per line (a
+/// `<tr>` pairing a `.blob-nums` line-number cell carrying the row's
+/// `#L{n}` anchor with a `.blob-code` cell, no wrapper beyond those two
+/// cells -- lean enough that thousands of lines stay cheap), highlighted
+/// via [`highlight`] when `highlighted` is `Some` and falling back to
+/// plain (still per-line, still auto-escaped by `maud`'s own
+/// interpolation) text otherwise. Each
+/// [`FileComment`](super::comments::FileComment) in `comments` whose
+/// [`ents_anchor::LineRange`] is `Some` renders its card
+/// ([`super::comments::comment_card`]) immediately after the row naming
+/// its range's last line, full width across both columns
+/// (`tr.blob-comment-row`, `colspan="2"`) -- multiple comments ending on
+/// the same line stack in `comments`' own order (`comment::list`'s ref
+/// order). A comment with no current line range is [`blob_view`]'s own
+/// concern, not this function's: it never appears here.
+///
+/// `div.blob` itself carries `data-path=(path)`/`data-rev=(head_oid)` --
+/// `ents.js`'s own activation check and the values it writes into
+/// [`composer_template`]'s clone -- so a click on a gutter line number
+/// selects it (and a shift-click extends the selection) with no further
+/// server round trip needed until the reader actually submits a comment.
+fn source_view(
+ path: &str,
+ head_oid: &str,
+ header: Markup,
+ composer: Markup,
+ text: &str,
+ highlighted: Option<String>,
+ comments: &[super::comments::FileComment],
+) -> Markup {
+ let physical_lines: Vec<&str> = text.lines().collect();
+ let line_count = physical_lines.len().max(1);
+
+ let mut code_lines: Vec<Markup> = match &highlighted {
+ Some(html) => split_highlighted_lines(html, line_count)
+ .into_iter()
+ .map(|fragment| html! { (PreEscaped(fragment)) })
+ .collect(),
+ None => physical_lines
+ .iter()
+ .map(|line| html! { (*line) })
+ .collect(),
+ };
+ // Exactly `line_count` rows either way: arborium trims trailing
+ // newlines before highlighting (see `split_highlighted_lines`'s own
+ // doc), so a file ending in blank lines can highlight to fewer
+ // embedded newlines than `text.lines().count()` -- padding (never
+ // truncating in practice, since `split_highlighted_lines` never
+ // returns fewer than one fragment) keeps every gutter number paired
+ // with a code cell, with no per-row fallback indexing needed below.
+ code_lines.resize_with(line_count, Markup::default);
+
+ let mut by_end_line: std::collections::BTreeMap<u64, Vec<usize>> =
+ std::collections::BTreeMap::new();
+ for (index, comment) in comments.iter().enumerate() {
+ if let Some(range) = comment.lines {
+ by_end_line.entry(range.end).or_default().push(index);
+ }
+ }
+
+ html! {
+ div.blob data-path=(path) data-rev=(head_oid) {
+ (header)
+ table {
+ tbody {
+ @for (index, code) in code_lines.into_iter().enumerate() {
+ @let n = index.saturating_add(1);
+ tr {
+ td.blob-nums { a id={ "L" (n) } href={ "#L" (n) } { (n) } }
+ @if highlighted.is_some() {
+ td.blob-code { code.code { (code) } }
+ } @else {
+ td.blob-code { code { (code) } }
+ }
+ }
+ @if let Some(indices) = by_end_line.get(&u64::try_from(n).unwrap_or(u64::MAX)) {
+ @for &comment_index in indices {
+ @if let Some(comment) = comments.get(comment_index) {
+ tr.blob-comment-row {
+ td colspan="2" {
+ (super::comments::comment_card(
+ comment_index,
+ comment,
+ super::comments::LinkMode::SameFile,
+ ))
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ (composer)
+ }
+ }
+}
+
+/// The below-the-blob section for comments with no current line range to
+/// interleave at (a whole-file anchor, or
+/// `ents_anchor::Projection::Outdated`) -- titled to distinguish it from
+/// the inline cards [`source_view`] interleaves directly into the blob,
+/// since every comment reaching here either predates line-level anchoring
+/// or has literally gone stale. Renders nothing at all when `comments` is
+/// empty (mirrors [`super::comments::comments_section`]'s identical
+/// stance).
+fn outdated_comments_section(comments: &[(usize, &super::comments::FileComment)]) -> Markup {
+ if comments.is_empty() {
+ return html! {};
+ }
+ html! {
+ h2 { "Outdated Comments" }
+ @for &(index, comment) in comments {
+ (super::comments::comment_card(index, comment, super::comments::LinkMode::SameFile))
+ }
+ }
+}
+
+/// Split [`highlight`]'s single HTML string into one HTML fragment per
+/// source line (`line_count` of them, padding with an empty string past
+/// whatever [`tokenize`] actually produced -- arborium trims trailing
+/// newlines from its input before highlighting, so a file ending in
+/// several blank lines can highlight to fewer embedded newlines than
+/// `text.lines().count()`; [`source_view`]'s own row loop indexes
+/// defensively for the same reason).
+///
+/// The hard part: a highlight span **can** cross a newline (a multiline
+/// block comment, a triple-quoted string), so it is not enough to split on
+/// `\n` -- a span open at a line boundary must be closed before the split
+/// and reopened after it, or the two resulting fragments are not
+/// independently well-formed HTML. This walks [`tokenize`]'s token stream
+/// with an explicit stack of open span classes: a `Text` token's embedded
+/// newlines close every open span, end the current line, and reopen them
+/// (in the same order) at the start of the next.
+fn split_highlighted_lines(html: &str, line_count: usize) -> Vec<String> {
+ let mut lines: Vec<String> = Vec::with_capacity(line_count.max(1));
+ let mut current = String::new();
+ let mut open: Vec<&str> = Vec::new();
+
+ for token in tokenize(html) {
+ match token {
+ Token::Open(class) => {
+ current.push_str("<span class=\"");
+ current.push_str(class);
+ current.push_str("\">");
+ open.push(class);
+ }
+ Token::Close => {
+ current.push_str("</span>");
+ open.pop();
+ }
+ Token::Text(text) => {
+ let mut parts = text.split('\n');
+ if let Some(first) = parts.next() {
+ current.push_str(first);
+ }
+ for rest in parts {
+ for _ in &open {
+ current.push_str("</span>");
+ }
+ lines.push(std::mem::take(&mut current));
+ for class in &open {
+ current.push_str("<span class=\"");
+ current.push_str(class);
+ current.push_str("\">");
+ }
+ current.push_str(rest);
+ }
+ }
+ }
+ }
+ lines.push(current);
+ lines
+}
+
+/// One tokenized fragment of arborium's `HtmlFormat::ClassNames` output
+/// (`arborium_highlight::render::spans_to_html`'s own doc): an opening
+/// `<span class="...">`, its matching `</span>`, or a run of
+/// already-escaped text between tags. That renderer never emits any tag
+/// but these two, and every text run it emits is already HTML-escaped
+/// (`<`, `&`, ...) -- [`split_highlighted_lines`] never re-escapes
+/// or splits an entity, since [`Token::Text`] is only ever split on
+/// literal `\n` bytes, never re-parsed.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Token<'a> {
+ /// `<span class="{0}">`.
+ Open(&'a str),
+ /// `</span>`.
+ Close,
+ /// Already-escaped text between tags.
+ Text(&'a str),
+}
+
+/// Tokenize `html` into a stream of [`Token`]s -- see [`Token`]'s own doc
+/// for why a simple `<span class="...">`/`</span>` scan is sufficient
+/// (arborium's own HTML renderer emits no other tag, and every text run is
+/// already escaped so it never contains a literal `<`). Malformed input
+/// (which arborium's own renderer never produces) degrades to treating the
+/// unrecognized byte as plain text rather than panicking or looping
+/// forever.
+fn tokenize(html: &str) -> Vec<Token<'_>> {
+ const OPEN_PREFIX: &str = "<span class=\"";
+ const CLOSE_TAG: &str = "</span>";
+
+ let mut tokens = Vec::new();
+ let mut rest = html;
+ while !rest.is_empty() {
+ // `.get(..)`/`.get(n..)` rather than direct indexing throughout:
+ // every offset here comes from `find`/`strip_prefix`, always a
+ // valid char boundary, but this function still never indexes a
+ // `str` directly (`clippy::string_slice`) or performs raw
+ // arithmetic on an offset (`clippy::arithmetic_side_effects`) --
+ // `.get(end..)` then `strip_prefix('"')` finds "just past the
+ // quote" without ever computing `end + 1`.
+ if let Some(after_prefix) = rest.strip_prefix(OPEN_PREFIX)
+ && let Some(end) = after_prefix.find('"')
+ && let Some(class) = after_prefix.get(..end)
+ && let Some(after_quote) = after_prefix.get(end..).and_then(|s| s.strip_prefix('"'))
+ && let Some(after_gt) = after_quote.strip_prefix('>')
+ {
+ tokens.push(Token::Open(class));
+ rest = after_gt;
+ continue;
+ }
+ if let Some(after) = rest.strip_prefix(CLOSE_TAG) {
+ tokens.push(Token::Close);
+ rest = after;
+ continue;
+ }
+ let next_tag = [rest.find(OPEN_PREFIX), rest.find(CLOSE_TAG)]
+ .into_iter()
+ .flatten()
+ .min();
+ match next_tag {
+ Some(0) | None => {
+ // No recognized tag anywhere ahead (or, defensively, right
+ // at the cursor despite the checks above not matching it
+ // -- malformed input arborium never actually produces):
+ // take the rest as one text run rather than looping.
+ tokens.push(Token::Text(rest));
+ rest = "";
+ }
+ Some(idx) => {
+ let text = rest.get(..idx).unwrap_or(rest);
+ rest = rest.get(idx..).unwrap_or_default();
+ tokens.push(Token::Text(text));
+ }
+ }
+ }
+ tokens
+}
+
+/// Highlighted HTML for `source`, or `None` when `name`'s extension names
+/// no grammar [`arborium::detect_language`] recognizes -- [`blob_view`]
+/// then falls back to escaped plain text. Ported from
+/// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own `highlight`,
+/// its `HtmlFormat::ClassNames` output matched by
+/// `crate::assets::OVERRIDES`'s `.code .keyword`-family rules.
+///
+/// The [`Highlighter`] is built and used entirely within this synchronous
+/// call -- its grammar store is not `Send`, so it must never be held
+/// across an `.await` (this function itself is never `async`, and neither
+/// is any caller between it and the request handler).
+fn highlight(name: &str, source: &str) -> Option<String> {
+ let language = arborium::detect_language(name)?;
+ let config = Config {
+ html_format: HtmlFormat::ClassNames,
+ ..Default::default()
+ };
+ Highlighter::with_config(config)
+ .highlight(language, source)
+ .ok()
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use ents_anchor::LineRange;
+ use rstest::rstest;
+
+ use super::*;
+ use crate::pages::comments::FileComment;
+
+ /// A minimal [`FileComment`] fixture -- the `body`/`author`/`seconds`
+ /// values never matter to a rendering-position assertion, only
+ /// `lines`.
+ fn comment(lines: Option<LineRange>) -> FileComment {
+ FileComment {
+ author: "commenter".to_owned(),
+ seconds: 0,
+ path: "src/main.rs".to_owned(),
+ lines,
+ outdated: false,
+ body: html! { p { "worth a look" } },
+ editor: html! {},
+ }
+ }
+
+ /// A minimal [`Session`] fixture -- [`blob_view`]'s own tests only ever
+ /// need a csrf token to render into [`composer_template`]'s hidden
+ /// input, never a real [`crate::session::SessionStore`]-minted one.
+ fn session() -> Session {
+ Session {
+ csrf: "test-csrf-token".to_owned(),
+ }
+ }
+
+ #[rstest]
+ #[case::empty("", true)]
+ #[case::simple("src/main.rs", true)]
+ #[case::nested("a/b/c", true)]
+ #[case::dot(".", false)]
+ #[case::dotdot("..", false)]
+ #[case::traversal("a/../b", false)]
+ #[case::trailing_slash("a/", false)]
+ #[case::double_slash("a//b", false)]
+ fn is_safe_path_rejects_dot_components_and_empty_segments(
+ #[case] path: &str,
+ #[case] expected: bool,
+ ) {
+ assert_eq!(is_safe_path(path), expected);
+ }
+
+ #[test]
+ fn dir_listing_sorts_directories_first_then_alphabetically() {
+ let entries = vec![
+ ("zeta.txt".to_owned(), false, Some(10)),
+ ("alpha".to_owned(), true, None),
+ ("beta.txt".to_owned(), false, Some(2048)),
+ ("gamma".to_owned(), true, None),
+ ];
+ let rendered = dir_listing("", entries).into_string();
+ let alpha = rendered.find("alpha").expect("alpha listed");
+ let gamma = rendered.find("gamma").expect("gamma listed");
+ let beta = rendered.find("beta.txt").expect("beta listed");
+ let zeta = rendered.find("zeta.txt").expect("zeta listed");
+ assert!(alpha < gamma, "directories sort among themselves");
+ assert!(gamma < beta, "every directory sorts before every file");
+ assert!(beta < zeta, "files sort among themselves");
+ }
+
+ #[test]
+ fn dir_listing_shows_a_size_for_a_file_and_none_for_a_directory() {
+ let entries = vec![
+ ("src".to_owned(), true, None),
+ ("main.rs".to_owned(), false, Some(2048)),
+ ];
+ let rendered = dir_listing("", entries).into_string();
+ let dir_index = rendered.find("src").expect("directory entry renders");
+ let file_index = rendered.find("main.rs").expect("file entry renders");
+ assert!(dir_index < file_index, "directories sort before files");
+ assert!(
+ !rendered
+ .get(..file_index)
+ .expect("slice up to the file entry")
+ .contains("entry-size"),
+ "the directory row carries no size cell"
+ );
+ assert!(
+ rendered.contains("entry-size"),
+ "the file row carries a size span"
+ );
+ assert!(rendered.contains("2.0 KB"), "the size is human-formatted");
+ }
+
+ #[rstest]
+ #[case::bytes(0, "0 B")]
+ #[case::bytes_under_a_kb(1023, "1023 B")]
+ #[case::exactly_one_kb(1024, "1.0 KB")]
+ #[case::fractional_kb(1536, "1.5 KB")]
+ #[case::just_under_a_mb(1_048_575, "1023.9 KB")]
+ #[case::exactly_one_mb(1_048_576, "1.0 MB")]
+ #[case::fractional_mb(1_572_864, "1.5 MB")]
+ fn human_size_formats_bytes_kb_and_mb(#[case] bytes: u64, #[case] expected: &str) {
+ assert_eq!(human_size(bytes), expected);
+ }
+
+ #[test]
+ fn blob_view_renders_markdown_as_a_heading_not_raw_markup() {
+ let (body, _below) = blob_view(
+ "readme.md",
+ "readme.md",
+ "deadbeef",
+ &session(),
+ b"# Title\n",
+ &[],
+ maud::html! {},
+ )
+ .expect("markdown renders");
+ assert!(body.into_string().contains("<h1>Title</h1>"));
+ }
+
+ #[test]
+ fn blob_view_renders_asciidoc_as_a_heading_not_raw_markup() {
+ let (body, _below) = blob_view(
+ "readme.adoc",
+ "readme.adoc",
+ "deadbeef",
+ &session(),
+ b"= Title\n\nBody.\n",
+ &[],
+ maud::html! {},
+ )
+ .expect("asciidoc renders");
+ assert!(body.into_string().contains("<h1>Title</h1>"));
+ }
+
+ #[test]
+ fn blob_view_escapes_plain_text_into_a_line_numbered_code_block() {
+ let (body, _below) = blob_view(
+ "notes.txt",
+ "notes.txt",
+ "deadbeef",
+ &session(),
+ b"1 < 2 and true",
+ &[],
+ maud::html! {},
+ )
+ .expect("plain text renders");
+ let rendered = body.into_string();
+ assert!(rendered.contains("blob-nums"));
+ assert!(rendered.contains("<td class=\"blob-code\"><code>"));
+ assert!(rendered.contains("1 < 2"));
+ }
+
+ #[test]
+ fn blob_view_highlights_a_recognized_language_with_syntax_token_classes() {
+ let (body, _below) = blob_view(
+ "src/main.rs",
+ "main.rs",
+ "deadbeef",
+ &session(),
+ b"fn main() { let x = 1; }",
+ &[],
+ maud::html! {},
+ )
+ .expect("rust renders");
+ let rendered = body.into_string();
+ assert!(rendered.contains("blob-nums"));
+ assert!(rendered.contains("class=\"code\""));
+ assert!(rendered.contains("class=\"keyword\""));
+ }
+
+ #[test]
+ fn blob_view_shows_a_placeholder_for_binary_content() {
+ let (body, _below) = blob_view(
+ "data.bin",
+ "data.bin",
+ "deadbeef",
+ &session(),
+ b"\0\x01\x02binary",
+ &[],
+ maud::html! {},
+ )
+ .expect("binary placeholder renders");
+ assert!(body.into_string().contains("Binary file"));
+ }
+
+ #[test]
+ fn blob_view_routes_a_doc_comment_below_the_blob_never_inline() {
+ let comments = vec![comment(Some(LineRange { start: 1, end: 1 }))];
+ let (_body, below) = blob_view(
+ "readme.md",
+ "readme.md",
+ "deadbeef",
+ &session(),
+ b"# Title\n",
+ &comments,
+ maud::html! {},
+ )
+ .expect("markdown renders");
+ // A doc view has no source line to interleave at: every comment,
+ // even one with a current line range, renders in the below
+ // section -- `comments_section`'s plain, untitled list, not
+ // `outdated_comments_section`'s titled one.
+ assert!(below.into_string().contains("worth a look"));
+ }
+
+ #[test]
+ fn blob_view_shows_the_header_with_line_count_size_and_language() {
+ let (body, _below) = blob_view(
+ "src/main.rs",
+ "main.rs",
+ "deadbeef",
+ &session(),
+ b"fn main() {}\n",
+ &[],
+ maud::html! {},
+ )
+ .expect("rust renders");
+ let rendered = body.into_string();
+ assert!(rendered.contains("blob-header"));
+ assert!(rendered.contains("1 line"));
+ assert!(rendered.contains("13 B"));
+ assert!(rendered.contains("rust"));
+ assert!(rendered.contains("comment on this file"));
+ }
+
+ #[test]
+ fn blob_view_carries_the_composer_hooks_only_on_a_raw_source_view() {
+ let (body, _below) = blob_view(
+ "src/main.rs",
+ "main.rs",
+ "cafef00dcafef00dcafef00dcafef00dcafef00d",
+ &session(),
+ b"fn main() {}\n",
+ &[],
+ maud::html! {},
+ )
+ .expect("rust renders");
+ let rendered = body.into_string();
+ assert!(rendered.contains("data-path=\"src/main.rs\""));
+ assert!(rendered.contains("data-rev=\"cafef00dcafef00dcafef00dcafef00dcafef00d\""));
+ assert!(rendered.contains("id=\"composer-template\""));
+ assert!(rendered.contains("name=\"csrf\""));
+ assert!(rendered.contains("test-csrf-token"));
+ assert!(rendered.contains("name=\"path\" value=\"src/main.rs\""));
+ assert!(
+ rendered.contains("name=\"rev\" value=\"cafef00dcafef00dcafef00dcafef00dcafef00d\"")
+ );
+
+ let (doc_body, _below) = blob_view(
+ "readme.md",
+ "readme.md",
+ "deadbeef",
+ &session(),
+ b"# Title\n",
+ &[],
+ maud::html! {},
+ )
+ .expect("markdown renders");
+ assert!(
+ !doc_body.into_string().contains("composer-template"),
+ "a doc-rendered view has no source line to anchor a composer to"
+ );
+ }
+
+ #[test]
+ fn source_view_interleaves_a_comment_directly_after_its_last_line() {
+ let comments = vec![comment(Some(LineRange { start: 1, end: 2 }))];
+ let rendered = source_view(
+ "src/main.rs",
+ "deadbeef",
+ Markup::default(),
+ Markup::default(),
+ "line 1\nline 2\nline 3\n",
+ None,
+ &comments,
+ )
+ .into_string();
+ let line2 = rendered.find("id=\"L2\"").expect("line 2 renders");
+ let card = rendered.find("comment-meta").expect("card renders");
+ let line3 = rendered.find("id=\"L3\"").expect("line 3 renders");
+ assert!(
+ line2 < card && card < line3,
+ "the card lands strictly between line 2 and line 3: {rendered}"
+ );
+ }
+
+ #[test]
+ fn source_view_stacks_multiple_comments_ending_on_the_same_line_in_order() {
+ let comments = vec![
+ {
+ let mut c = comment(Some(LineRange { start: 1, end: 1 }));
+ c.body = html! { p { "first" } };
+ c
+ },
+ {
+ let mut c = comment(Some(LineRange { start: 1, end: 1 }));
+ c.body = html! { p { "second" } };
+ c
+ },
+ ];
+ let rendered = source_view(
+ "src/main.rs",
+ "deadbeef",
+ Markup::default(),
+ Markup::default(),
+ "line 1\nline 2\n",
+ None,
+ &comments,
+ )
+ .into_string();
+ let first = rendered.find("first").expect("first comment renders");
+ let second = rendered.find("second").expect("second comment renders");
+ assert!(first < second, "stacked comments keep ref order");
+ }
+
+ #[test]
+ fn source_view_omits_a_comment_with_no_current_line_range() {
+ let comments = vec![comment(None)];
+ let rendered = source_view(
+ "src/main.rs",
+ "deadbeef",
+ Markup::default(),
+ Markup::default(),
+ "line 1\nline 2\n",
+ None,
+ &comments,
+ )
+ .into_string();
+ assert!(
+ !rendered.contains("worth a look"),
+ "a comment with no lines has nowhere to interleave -- blob_view routes it below instead"
+ );
+ }
+
+ #[test]
+ fn child_href_nests_under_the_current_directory() {
+ assert_eq!(child_href("", "src"), "/files/src");
+ assert_eq!(child_href("src", "main.rs"), "/files/src/main.rs");
+ }
+
+ #[test]
+ fn tokenize_splits_spans_and_text_without_touching_entities() {
+ let tokens = tokenize("<span class=\"keyword\">fn</span> 1 < 2");
+ assert_eq!(
+ tokens,
+ vec![
+ Token::Open("keyword"),
+ Token::Text("fn"),
+ Token::Close,
+ Token::Text(" 1 < 2"),
+ ]
+ );
+ }
+
+ #[test]
+ fn split_highlighted_lines_reopens_a_span_that_crosses_a_newline() {
+ // A three-line block comment as one span, per arborium's own
+ // `spans_to_html` shape (see that function's own tests): one
+ // `<span>` whose text contains embedded newlines, followed by an
+ // unrelated keyword span on the line after.
+ let html = "<span class=\"comment\">/*\nfoo\nbar*/</span>\n<span class=\"keyword\">fn</span> main() {}";
+ let lines = split_highlighted_lines(html, 4);
+ assert_eq!(
+ lines,
+ vec![
+ "<span class=\"comment\">/*</span>".to_owned(),
+ "<span class=\"comment\">foo</span>".to_owned(),
+ "<span class=\"comment\">bar*/</span>".to_owned(),
+ "<span class=\"keyword\">fn</span> main() {}".to_owned(),
+ ],
+ "each fragment is independently well-formed and still classed"
+ );
+ }
+
+ #[test]
+ fn split_highlighted_lines_never_re_escapes_or_splits_an_entity() {
+ let html = "<span class=\"operator\"><</span>\nnext";
+ let lines = split_highlighted_lines(html, 2);
+ assert_eq!(
+ lines,
+ vec![
+ "<span class=\"operator\"><</span>".to_owned(),
+ "next".to_owned(),
+ ]
+ );
+ }
+
+ #[test]
+ fn split_highlighted_lines_handles_plain_unhighlighted_text() {
+ let lines = split_highlighted_lines("a\nb\nc", 3);
+ assert_eq!(lines, vec!["a".to_owned(), "b".to_owned(), "c".to_owned()]);
+ }
+}
crates/cli/ents-web/src/pages/inbox.rs
@@ -1,0 +1,47 @@
+//! `GET /inbox`: every `refs/meta/inbox/<member>/<id>` entry awaiting
+//! adoption -- read-only in this phase (`sync.adoption-machinery`'s merge
+//! itself stays a `git ents inbox adopt` operation; this crate has no
+//! write path for it, since adoption needs a working-tree-aware three-way
+//! merge, not a signed-commit form).
+
+use std::sync::Arc;
+
+use axum::extract::State;
+use gix_object::{Find, Write};
+
+use crate::error::Result;
+use crate::state::AppState;
+
+/// `GET /inbox`.
+///
+/// # Errors
+///
+/// Propagates a ref-store read failure.
+pub async fn list<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let mut rows = Vec::new();
+ for entry in state.refs.iter_prefix("refs/meta/inbox/")? {
+ let (name, _) = entry?;
+ let path = name.as_bstr().to_string();
+ if let Some(rest) = path.strip_prefix("refs/meta/inbox/") {
+ rows.push(rest.to_owned());
+ }
+ }
+ let body = if rows.is_empty() {
+ super::blankslate(
+ "Inbox is empty",
+ maud::html! { "Entries awaiting adoption appear here." },
+ )
+ } else {
+ crate::render::string_list(&rows, |_| "/inbox".to_owned())
+ };
+ Ok(super::layout_meta(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ "/inbox",
+ "Inbox",
+ body,
+ ))
+}
crates/cli/ents-web/src/pages/issues.rs
@@ -1,0 +1,449 @@
+//! `GET /issues`, `GET /issues/{id}`, `POST /issues`,
+//! `POST /issues/{id}`, `POST /issues/{id}/comment`: the issue surface
+//! (`model.issue`), a top-level tab of its own (`crate::pages::Tab::Issues`;
+//! see [`super`]'s own doc) rather than an entry in the `meta` tab's
+//! registry -- issues are a working surface like comments, not repository
+//! metadata.
+//!
+//! Every read is `ents_forge::issue::{list,show}` and every mutation is
+//! `ents_forge::issue::{new,edit}` or `ents_forge::comment::add` -- the web
+//! is another caller of the same library funcs (`lens.parity`), never a
+//! second issue or thread implementation. An issue's discussion is its
+//! thread: the comments naming `issues/<id>` as their context
+//! (`model.comment-context`), aggregated by `ents_forge::comment::thread`
+//! and rendered through `crate::pages::comments::thread_section`, never a
+//! list the issue stores.
+
+use std::sync::Arc;
+
+use axum::Form;
+use axum::extract::{Path, State};
+use axum::response::{IntoResponse, Redirect};
+use ents_forge::issue::{self, EditIssue, NewIssue};
+use ents_model::MemberId;
+use gix_object::{Find, Write};
+use maud::{Markup, html};
+use serde::Deserialize;
+
+use crate::error::Result;
+use crate::session::Session;
+use crate::state::AppState;
+
+/// `GET /issues`: the Issues split (`crate::pages::layout_split`) --
+/// every issue recorded in this repository (`ents_forge::issue::list_all`)
+/// as the sidebar, its state/assignees/labels on each row's own locator
+/// line, beside the new-issue composer in the pane.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+// @relation(model.issue, scope=function)
+pub async fn list<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+) -> Result<Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let (rows, unreadable) = issue::list_all(state.refs.as_ref(), &*state.objects())?;
+ let failures: Vec<(String, String)> = unreadable
+ .into_iter()
+ .map(|entry| (entry.refname, entry.error))
+ .collect();
+ Ok(super::layout_split(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ super::Tab::Issues,
+ "Issues",
+ issues_sidebar(&rows, None),
+ html! {
+ div.readable {
+ (crate::render::unreadable_disclosure(&failures))
+ @if rows.is_empty() {
+ (super::blankslate(
+ "No issues yet",
+ html! { "Open one with the form below." },
+ ))
+ }
+ h2 { "Open an Issue" }
+ (new_form(&session))
+ (super::members_datalist(&state))
+ }
+ },
+ ))
+}
+
+/// The Issues split's `.tree` sidebar: every issue as a two-line row --
+/// its title, then a muted locator of its state, assignees, and labels --
+/// linking to its own page, `active` naming the viewed issue's id.
+fn issues_sidebar(rows: &[(String, ents_forge::Issue)], active: Option<&str>) -> Markup {
+ html! {
+ @if rows.is_empty() {
+ span.tree-note { "No issues yet." }
+ }
+ @for (id, issue) in rows {
+ a.active[active == Some(id.as_str())] href={ "/issues/" (id) } {
+ span { (issue.title) }
+ span class="where" {
+ (issue.state)
+ @if !issue.assignees.is_empty() { " \u{b7} " (join_members(&issue.assignees)) }
+ @if !issue.labels.is_empty() { " \u{b7} " (issue.labels.join(", ")) }
+ }
+ }
+ }
+ }
+}
+
+/// `GET /issues/{id}`: one issue (`ents_forge::issue::show`), an edit form
+/// for its state/assignees/labels, and its discussion thread -- the
+/// comments naming `issues/<id>` as their context
+/// (`ents_forge::comment::thread`, `model.comment-context`), rendered like
+/// every other conversation in this crate.
+///
+/// # Errors
+///
+/// [`crate::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`]) if
+/// `id` has no issue ref at all; an issue ref whose stored tree this
+/// build cannot read back degrades to [`crate::render::unreadable`]'s
+/// marker card instead of erroring. Otherwise propagates a ref-store or
+/// object read failure.
+// @relation(model.issue, model.comment-context, scope=function)
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path(id): Path<String>,
+) -> Result<Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let issue = match issue::show(state.refs.as_ref(), &*state.objects(), &id) {
+ Ok(issue) => issue,
+ // No ref at all stays a real not-found; any other failure (a tree
+ // this build's shape cannot read back) is an existing entity this
+ // page degrades to the plain unreadable card for.
+ Err(source @ ents_forge::Error::NotFound { .. }) => return Err(source.into()),
+ Err(source) => {
+ return Ok(super::layout(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ super::Tab::Issues,
+ &format!("Issue {}", ents_forge::abbreviate_id(&id)),
+ html! {
+ (super::child_crumbs("issues", "/issues", ents_forge::abbreviate_id(&id)))
+ div.readable { (crate::render::unreadable(&source.to_string())) }
+ },
+ ));
+ }
+ };
+ let context = format!("issues/{id}");
+ let thread = ents_forge::comment::thread(state.refs.as_ref(), &*state.objects(), &context)?;
+ let body =
+ crate::asciidoc::to_html(&issue.body).unwrap_or_else(|_| html! { p { (issue.body) } });
+ let return_to = format!("/issues/{id}");
+ // Best-effort: the sidebar listing every issue beside this one is
+ // navigation chrome, never a reason to fail the issue's own page.
+ let (rows, _unreadable) =
+ issue::list_all(state.refs.as_ref(), &*state.objects()).unwrap_or_default();
+ Ok(super::layout_split(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ super::Tab::Issues,
+ &issue.title,
+ issues_sidebar(&rows, Some(&id)),
+ html! {
+ (super::child_crumbs("issues", "/issues", ents_forge::abbreviate_id(&id)))
+ div.readable {
+ div.card {
+ dl {
+ dt { "state" } dd { span.comment-state { (issue.state) } }
+ dt { "assignees" } dd { (join_members(&issue.assignees)) }
+ dt { "labels" } dd { (issue.labels.join(", ")) }
+ }
+ div.doc-body { (body) }
+ }
+ details {
+ summary { "Edit" }
+ (edit_form(&session, &issue))
+ (super::members_datalist(&state))
+ }
+ h2 { "Discussion" }
+ (crate::pages::comments::thread_section(&state, &session, &thread, &return_to))
+ h2 { "Add a Comment" }
+ (comment_form(&session, &id))
+ }
+ },
+ ))
+}
+
+/// The form fields `POST /issues` accepts.
+#[derive(Debug, Deserialize)]
+pub struct NewForm {
+ /// The issue's title.
+ title: String,
+ /// The issue's body.
+ #[serde(default)]
+ body: String,
+ /// The issue's initial state; defaults to `open` (`model.issue`: the
+ /// platform has no default of its own, so the frontend chooses one).
+ #[serde(default = "default_state")]
+ state: String,
+ /// Comma- or whitespace-separated assignee usernames.
+ #[serde(default)]
+ assignees: String,
+ /// Comma- or whitespace-separated labels.
+ #[serde(default)]
+ labels: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+fn default_state() -> String {
+ "open".to_owned()
+}
+
+/// `POST /issues`: open an issue at a freshly generated
+/// `refs/meta/issues/<id>` (`ents_forge::issue::new`), signed
+/// (`roots.web-signing`) on behalf of the current session
+/// (`roots.web-session`).
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
+/// propagates [`ents_forge::issue::new`]'s own failures.
+// @relation(model.issue, roots.web-signing, roots.web-session, scope=function)
+pub async fn create<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Form(form): Form<NewForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let identity = state.identity.as_ref();
+ let new = NewIssue {
+ title: form.title,
+ body: form.body,
+ state: form.state,
+ assignees: parse_members(&form.assignees),
+ labels: parse_labels(&form.labels),
+ };
+ let (id, outcome) = issue::new(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ new,
+ &crate::receive_identity!(identity),
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(Redirect::to(&format!("/issues/{id}")))
+}
+
+/// The form fields `POST /issues/{id}` accepts. Each field replaces its
+/// counterpart on the issue; an empty `assignees`/`labels` leaves that set
+/// unchanged (matching `git ents issue edit`'s own semantics), while `state`
+/// is always applied.
+#[derive(Debug, Deserialize)]
+pub struct EditForm {
+ /// Replace the issue's state.
+ state: String,
+ /// Comma- or whitespace-separated assignees; empty leaves them.
+ #[serde(default)]
+ assignees: String,
+ /// Comma- or whitespace-separated labels; empty leaves them.
+ #[serde(default)]
+ labels: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+/// `POST /issues/{id}`: mutate `id`'s state, assignees, and/or labels
+/// (`ents_forge::issue::edit`) as a signed mutation on the issue's own ref.
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
+/// propagates [`ents_forge::issue::edit`]'s own failures (including
+/// [`ents_forge::Error::NotFound`] when `id` names no issue).
+// @relation(model.issue, roots.web-signing, roots.web-session, scope=function)
+pub async fn edit<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path(id): Path<String>,
+ Form(form): Form<EditForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let identity = state.identity.as_ref();
+ let assignees = parse_members(&form.assignees);
+ let labels = parse_labels(&form.labels);
+ let edit = EditIssue {
+ state: Some(form.state),
+ assignees: (!assignees.is_empty()).then_some(assignees),
+ labels: (!labels.is_empty()).then_some(labels),
+ };
+ let outcome = issue::edit(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ edit,
+ &crate::receive_identity!(identity),
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(Redirect::to(&format!("/issues/{id}")))
+}
+
+/// The form fields `POST /issues/{id}/comment` accepts.
+#[derive(Debug, Deserialize)]
+pub struct CommentForm {
+ /// The comment's body text.
+ body: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+/// `POST /issues/{id}/comment`: a comment naming `issues/<id>` as its
+/// context (`model.comment-context`) -- an ordinary
+/// [`ents_forge::comment::add`], contextual and unanchored, so it joins the
+/// issue's thread the moment it lands.
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
+/// propagates [`ents_forge::comment::add`]'s own failures.
+// @relation(model.comment-context, roots.web-signing, roots.web-session, scope=function)
+pub async fn comment<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path(id): Path<String>,
+ Form(form): Form<CommentForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let identity = state.identity.as_ref();
+ let new = ents_forge::comment::NewComment {
+ body: form.body,
+ path: None,
+ lines: None,
+ rev: "HEAD".to_owned(),
+ worktree: false,
+ context: Some(format!("issues/{id}")),
+ parent: None,
+ };
+ let (_comment_id, outcome) = ents_forge::comment::add(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &state.path,
+ new,
+ &crate::receive_identity!(identity),
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(Redirect::to(&format!("/issues/{id}")))
+}
+
+/// The open-an-issue form (`POST /issues`). The `state` field is a free
+/// text input with a [`state_datalist`] of the conventional values, never
+/// a closed `select` -- `model.issue` keeps states an open vocabulary
+/// ("custom states are schema, not platform features"; see
+/// [`ents_forge::Issue`]'s own doc).
+fn new_form(session: &Session) -> Markup {
+ html! {
+ form method="post" action="/issues" {
+ (super::csrf_input(session))
+ label { "title" input type="text" name="title"; }
+ label {
+ "state"
+ input type="text" name="state" value="open" list="issue-states";
+ }
+ (state_datalist())
+ label { "assignees" input type="text" name="assignees" placeholder="alice, bob" list="members"; }
+ label { "labels" input type="text" name="labels" placeholder="bug, gate"; }
+ label { "body" textarea name="body" {} }
+ button type="submit" { "Open Issue" }
+ }
+ }
+}
+
+/// The edit-issue form (`POST /issues/{id}`), its fields pre-filled from
+/// the current issue. Its `state` field carries the same [`state_datalist`]
+/// as [`new_form`]'s, for the same open-vocabulary reason.
+fn edit_form(session: &Session, issue: &ents_forge::Issue) -> Markup {
+ html! {
+ form method="post" action="" {
+ (super::csrf_input(session))
+ label {
+ "state"
+ input type="text" name="state" value=(issue.state) list="issue-states";
+ }
+ (state_datalist())
+ label {
+ "assignees"
+ input type="text" name="assignees" value=(join_members(&issue.assignees)) list="members";
+ }
+ label { "labels" input type="text" name="labels" value=(issue.labels.join(", ")); }
+ button type="submit" { "Save" }
+ }
+ }
+}
+
+/// The `datalist` of conventional issue states both forms above attach to
+/// their `state` input -- suggestions only, since `model.issue`'s state is
+/// an open string vocabulary, not an enum a `select` could close over.
+/// Rendered once per form; the two forms never share a page, so the id
+/// never collides.
+fn state_datalist() -> Markup {
+ html! {
+ datalist id="issue-states" {
+ option value="open" {}
+ option value="closed" {}
+ }
+ }
+}
+
+/// The comment-on-this-issue form (`POST /issues/{id}/comment`).
+fn comment_form(session: &Session, id: &str) -> Markup {
+ html! {
+ form method="post" action=(format!("/issues/{id}/comment")) {
+ (super::csrf_input(session))
+ label { "body" textarea name="body" {} }
+ button type="submit" { "Comment" }
+ }
+ }
+}
+
+/// Render a member set for display, comma-joined (`join_members(&[])` is the
+/// empty string, so an unassigned issue shows a blank cell rather than a
+/// stray separator).
+fn join_members(members: &[MemberId]) -> String {
+ members
+ .iter()
+ .map(MemberId::as_str)
+ .collect::<Vec<_>>()
+ .join(", ")
+}
+
+/// Parse a comma- or whitespace-separated list into members, dropping empty
+/// segments so a trailing comma or extra spacing does not enroll a blank
+/// assignee.
+fn parse_members(text: &str) -> Vec<MemberId> {
+ parse_labels(text).into_iter().map(MemberId::new).collect()
+}
+
+/// Parse a comma- or whitespace-separated list into labels, dropping empty
+/// segments.
+fn parse_labels(text: &str) -> Vec<String> {
+ text.split([',', ' ', '\t', '\n'])
+ .map(str::trim)
+ .filter(|segment| !segment.is_empty())
+ .map(str::to_owned)
+ .collect()
+}
crates/cli/ents-web/src/pages/members.rs
@@ -1,0 +1,250 @@
+//! `GET /members`, `GET /members/{username}`: the member surface -- an
+//! identity card per enrolled key rather than [`crate::render`]'s generic
+//! table (an SSH public key's base64 body defeats a table cell; the card
+//! shows the key type as a badge and the material truncated through the
+//! middle, with the full line behind a `<details>` toggle). Read-only in
+//! this phase (enrollment stays a `git ents members add` operation; see
+//! this crate's own top-level doc for why write flows are demonstrated on
+//! [`super::account`] rather than duplicated per entity).
+
+use std::sync::Arc;
+
+use axum::extract::{Path, State};
+use ents_model::{Member, MemberState, Provenance};
+use gix_object::{Find, Write};
+use maud::{Markup, html};
+
+use crate::error::{Error, Result};
+use crate::state::AppState;
+
+/// `GET /members`.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+pub async fn list<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let mut rows = Vec::new();
+ let mut failures = Vec::new();
+ for (username, member) in read_all(&state)? {
+ match member {
+ Ok(member) => rows.push((username, member)),
+ Err(error) => failures.push((format!("refs/meta/member/{username}"), error)),
+ }
+ }
+ let body = if rows.is_empty() {
+ super::blankslate(
+ "No members yet",
+ maud::html! { "Enroll one with " code { "git ents members add" } "." },
+ )
+ } else {
+ html! {
+ @for (username, member) in &rows {
+ (member_card(username, member, true))
+ }
+ }
+ };
+ Ok(super::layout_meta(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ "/members",
+ "Members",
+ maud::html! {
+ (crate::render::unreadable_disclosure(&failures))
+ (body)
+ },
+ ))
+}
+
+/// `GET /members/{username}`.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `username` has no member ref at all -- a member
+/// ref that exists but whose stored tree does not match this build's
+/// [`Member`] shape degrades to [`crate::render::unreadable`] instead
+/// (`roots.web-agnostic`'s graceful-degradation stance).
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ Path(username): Path<String>,
+) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let (_, member) = read_all(&state)?
+ .into_iter()
+ .find(|(name, _)| *name == username)
+ .ok_or_else(|| Error::NotFound {
+ what: format!("member {username}"),
+ })?;
+ let body = match member {
+ Ok(member) => member_card(&username, &member, false),
+ Err(detail) => crate::render::unreadable(&detail),
+ };
+ Ok(super::layout_meta(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ "/members",
+ &username,
+ maud::html! {
+ (super::child_crumbs("members", "/members", &username))
+ (body)
+ },
+ ))
+}
+
+/// One member's identity card: the username prominent (a link on the list
+/// page, plain on the member's own page), the key type as a badge, the
+/// state and provenance as muted badges, and the key material truncated
+/// through the middle ([`truncate_middle`]) with the full key line behind
+/// a `<details>` toggle -- no digest dependency, so no fingerprint; the
+/// truncated material plus the expandable full line is the identity a
+/// reader compares. Also `crate::pages::account`'s signed-in-as card, so
+/// "you" and "a member" render identically.
+pub(crate) fn member_card(username: &str, member: &Member, link: bool) -> Markup {
+ let (key_type, material) = split_key(&member.key);
+ html! {
+ div.card.member-card {
+ div.member-head {
+ @if link {
+ a.member-name href={ "/members/" (username) } { (username) }
+ } @else {
+ span.member-name { (username) }
+ }
+ @if let Some(key_type) = key_type {
+ span.key-badge { (key_type) }
+ }
+ span.badge { (state_label(member.state)) }
+ span.badge { (provenance_label(member.provenance)) }
+ }
+ div.member-key {
+ code { (truncate_middle(material)) }
+ details {
+ summary { "full key" }
+ pre { (member.key) }
+ }
+ }
+ }
+ }
+}
+
+/// A member's key line split into its type token (`ssh-ed25519`, ...) and
+/// key material -- `(None, whole line)` when the line has no second token
+/// to badge (`ents-model` treats the key as opaque text, so this only ever
+/// assumes the OpenSSH `type material [comment]` shape when it actually
+/// sees one).
+fn split_key(key: &str) -> (Option<&str>, &str) {
+ let mut parts = key.split_whitespace();
+ let first = parts.next().unwrap_or("");
+ match parts.next() {
+ Some(material) => (Some(first), material),
+ None => (None, first),
+ }
+}
+
+/// Key material truncated through the middle (`AAAA…zM7f`), leaving the
+/// start and end a reader actually compares -- the full line stays one
+/// `<details>` toggle away.
+fn truncate_middle(material: &str) -> String {
+ const HEAD: usize = 12;
+ const TAIL: usize = 8;
+ let count = material.chars().count();
+ if count <= HEAD.saturating_add(TAIL).saturating_add(1) {
+ return material.to_owned();
+ }
+ let head: String = material.chars().take(HEAD).collect();
+ let tail: String = material.chars().skip(count.saturating_sub(TAIL)).collect();
+ format!("{head}\u{2026}{tail}")
+}
+
+/// [`MemberState`] as its badge text.
+fn state_label(state: MemberState) -> &'static str {
+ match state {
+ MemberState::Active => "active",
+ MemberState::Revoked => "revoked",
+ }
+}
+
+/// [`Provenance`] as its badge text.
+fn provenance_label(provenance: Provenance) -> &'static str {
+ match provenance {
+ Provenance::AdminRegistered => "admin-registered",
+ Provenance::SelfAttested => "self-attested",
+ }
+}
+
+/// Every `refs/meta/member/*` ref, with its tip's tree deserialized as a
+/// [`Member`] -- `Err(detail)` for a ref this build's `#[derive(Facet)]`
+/// shape could not read back, kept in the listing (not dropped) so
+/// [`list`] can surface it through
+/// [`crate::render::unreadable_disclosure`] and [`show`] as
+/// [`crate::render::unreadable`]'s marker card, rather than silently
+/// omitting it (`roots.web-agnostic`: a reader surfaces a marker, never an
+/// error or a silent gap, for one entity written by a schema this build no
+/// longer speaks).
+fn read_all<O: Find>(
+ state: &AppState<O>,
+) -> Result<Vec<(String, std::result::Result<Member, String>)>> {
+ let mut out = Vec::new();
+ for entry in state.refs.iter_prefix("refs/meta/member/")? {
+ let (name, tip) = entry?;
+ let path = name.as_bstr().to_string();
+ let Some(username) = path.strip_prefix("refs/meta/member/") else {
+ continue;
+ };
+ // One `state.objects()` lock per iteration, reused for both reads:
+ // `state.objects()` a second time *within the same statement*
+ // would try to lock this non-reentrant `Mutex` while the first
+ // guard is still alive (a `let`'s temporaries live to its own
+ // `;`), self-deadlocking forever rather than erroring.
+ let objects = state.objects();
+ let member = super::commit_tree(&*objects, tip)
+ .map_err(|error| error.to_string())
+ .and_then(|tree| {
+ facet_git_tree::deserialize::<Member>(&tree, &*objects)
+ .map_err(|error| error.to_string())
+ });
+ out.push((username.to_owned(), member));
+ }
+ Ok(out)
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ #[case::openssh(
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJq4 jdc@host",
+ Some("ssh-ed25519"),
+ "AAAAC3NzaC1lZDI1NTE5AAAAIJq4"
+ )]
+ #[case::bare_token("opaquekeymaterial", None, "opaquekeymaterial")]
+ fn split_key_badges_only_a_typed_key_line(
+ #[case] key: &str,
+ #[case] key_type: Option<&str>,
+ #[case] material: &str,
+ ) {
+ assert_eq!(split_key(key), (key_type, material));
+ }
+
+ #[test]
+ fn truncate_middle_keeps_the_start_and_end_of_a_long_key() {
+ let material = "AAAAC3NzaC1lZDI1NTE5AAAAIJq4rB5zM7f";
+ let shown = truncate_middle(material);
+ assert!(shown.starts_with("AAAAC3NzaC1l"));
+ assert!(shown.ends_with("rB5zM7f"));
+ assert!(shown.contains('\u{2026}'));
+ assert_eq!(
+ truncate_middle("short"),
+ "short",
+ "a short token is left whole"
+ );
+ }
+}
crates/cli/ents-web/src/pages/meta.rs
@@ -1,0 +1,37 @@
+//! `GET /meta`: the landing page for the `meta` tab -- a card listing
+//! every page family in `super::META_SECTIONS` with its blurb, so the
+//! tab resolves to something other than an arbitrary pick of its five
+//! children. The rail those children render beside their own content
+//! (`super::layout_meta`) is this same table; this page is its index.
+
+use std::sync::Arc;
+
+use axum::extract::State;
+use gix_object::{Find, Write};
+use maud::html;
+
+use crate::state::AppState;
+
+/// `GET /meta`.
+pub async fn show<O>(State(state): State<Arc<AppState<O>>>) -> maud::Markup
+where
+ O: Find + Write + Send + 'static,
+{
+ super::layout(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ super::Tab::Meta,
+ "Meta",
+ html! {
+ div.card {
+ div.card-header { "meta" }
+ @for section in super::META_SECTIONS {
+ div.card-row {
+ a href=(section.href) { (section.name) }
+ span { (section.blurb) }
+ }
+ }
+ }
+ },
+ )
+}
crates/cli/ents-web/src/pages/mod.rs
@@ -1,0 +1,557 @@
+//! One module per page family -- `crate::router`'s handlers given a
+//! body, mirroring `git_ents::commands`'s "one module per subcommand
+//! family" convention on the web side.
+//!
+//! [`account`], [`effects`], [`redactions`],
+//! and [`inbox`] are the generic pages: they read a kernel entity and
+//! render it through [`crate::render`]'s reflection-driven mechanism,
+//! never matching on which entity type they were handed. [`dashboard`],
+//! [`toolchains`], [`comments`], [`issues`], and [`members`] are
+//! legitimate custom pages
+//! (`ents-kiln`'s recipe provenance, `ents-forge`'s anchor projection
+//! and issue threads, and a member's SSH-key identity card all need
+//! domain-specific rendering no generic
+//! reflection walk should grow special cases for). [`members`],
+//! [`effects`], [`toolchains`], [`redactions`], and [`inbox`] additionally
+//! share one `meta` rail item and `META_SECTIONS` rail rather than each
+//! carrying its own top-level entry (see `Tab`'s own doc); [`meta`] is that
+//! group's `GET /meta` landing page. [`commits`] and [`issues`] are rail
+//! items of their own -- `Tab::Commits` (Review) and `Tab::Issues`
+//! (Issues) in [`layout`]'s icon rail, alongside the dashboard, code,
+//! threads, and meta items. [`search`] renders with no rail item active at
+//! all; it is reached from the `.wb-bar`'s own `.palette` search form
+//! rather than any rail item.
+
+pub mod account;
+pub mod comments;
+pub mod commits;
+pub mod dashboard;
+pub mod effects;
+pub mod files;
+pub mod inbox;
+pub mod issues;
+pub mod members;
+pub mod meta;
+pub mod redactions;
+pub mod search;
+pub mod toolchains;
+
+use gix::bstr::ByteSlice as _;
+use gix_hash::ObjectId;
+use gix_object::{CommitRef, Find, Kind};
+use maud::{Markup, html};
+
+use crate::error::{Error, Result};
+use crate::session::{CSRF_FIELD, Session};
+use crate::state::AppState;
+
+/// The tree of the commit at `oid` -- every page that reads back a typed
+/// entity needs this; mirrors `git_ents::commands::commit_tree` and
+/// `ents_forge::comment::command`'s own identical, independently
+/// duplicated helper (that module's own doc names this the accepted
+/// pattern in this codebase).
+pub(crate) fn commit_tree(objects: &impl Find, oid: ObjectId) -> Result<ObjectId> {
+ let mut buf = Vec::new();
+ let data = objects
+ .try_find(&oid, &mut buf)
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?
+ .ok_or_else(|| Error::NotFound {
+ what: oid.to_string(),
+ })?;
+ if data.kind != Kind::Commit {
+ return Err(Error::NotFound {
+ what: oid.to_string(),
+ });
+ }
+ let commit = CommitRef::from_bytes(data.data, oid.kind())
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?;
+ Ok(commit.tree())
+}
+
+/// The commit author's display name and commit time (epoch seconds) for
+/// the commit at `oid` -- the meta-ref counterpart to
+/// `crate::pages::commits`'s identical read of an ordinary history
+/// commit, shared by any page that needs to know who mutated a meta-ref
+/// entity and when rather than a stored field (`model.comment`'s own rule
+/// that authorship lives in the commit chain, not the entity: see
+/// `ents_forge::comment::Comment`'s own doc).
+///
+/// A second, independent fetch-and-parse from [`commit_tree`]'s own
+/// (same file, same pattern) rather than a shared parse step: `CommitRef`
+/// borrows from a caller-owned buffer, so factoring the parse out would
+/// need either an owned copy or a callback -- this module's own doc on
+/// [`commit_tree`] already names three such near-identical copies as the
+/// accepted pattern here.
+pub(crate) fn commit_authorship(objects: &impl Find, oid: ObjectId) -> Result<(String, i64)> {
+ let mut buf = Vec::new();
+ let data = objects
+ .try_find(&oid, &mut buf)
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?
+ .ok_or_else(|| Error::NotFound {
+ what: oid.to_string(),
+ })?;
+ if data.kind != Kind::Commit {
+ return Err(Error::NotFound {
+ what: oid.to_string(),
+ });
+ }
+ let commit = CommitRef::from_bytes(data.data, oid.kind())
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?;
+ let author = commit
+ .author()
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?;
+ let seconds = author.time().map(|time| time.seconds).unwrap_or(0);
+ Ok((author.name.to_str_lossy().into_owned(), seconds))
+}
+
+/// The rail-nav page families this crate exposes -- one variant per icon
+/// in [`layout`]'s `.rail`, so a handler can name which rail item it
+/// renders behind without `layout` re-deriving it from the request path
+/// (the pre-redo `Tab` enum, carried through the workbench restructure:
+/// the horizontal tab strip became the vertical icon rail, but the
+/// "handler names its own section" contract is unchanged). The rail reads,
+/// top to bottom: Dashboard (`Overview`), Code (`Files`), Review
+/// (`Commits`), Issues, Threads (`Comments`); then, past the
+/// spacer, Repo & governance (`Meta`) and Account. `Meta` covers five page
+/// families ([`super::members`], [`super::effects`], [`super::toolchains`],
+/// [`super::redactions`], [`super::inbox`]) behind one rail item and the
+/// [`META_SECTIONS`] rail (see [`layout_meta`]) rather than an item each --
+/// nine equal entries did not scale as page families grew. `None`
+/// highlights nothing at all, for a page that is not part of any rail
+/// item's own section ([`super::search`]'s results page).
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Tab {
+ Overview,
+ Files,
+ Commits,
+ Issues,
+ Comments,
+ Meta,
+ Account,
+ None,
+}
+
+/// One entry in the `meta` tab's registry: a page family reachable from
+/// both [`meta::show`]'s index card and the `.meta-rail` every page in
+/// that family renders beside its own content (see [`layout_meta`]). This
+/// table is the entire registry -- growing the `meta` group means adding
+/// one entry here, never touching [`layout`], [`crate::router`]'s route
+/// table beyond the new route itself, or a per-page CSS hook.
+pub(crate) struct MetaSection {
+ /// The section's name, shown as both the rail link text and the
+ /// `/meta` index card's link text.
+ pub(crate) name: &'static str,
+ /// The section's own list-page URL. A `/{id}` child page (e.g.
+ /// `/members/{username}`) highlights this same entry rather than
+ /// failing to match anything (see [`layout_meta`]'s own doc).
+ pub(crate) href: &'static str,
+ /// One line describing the section, shown only on the `/meta` index
+ /// card.
+ pub(crate) blurb: &'static str,
+}
+
+/// The `meta` tab's registry (see [`MetaSection`]'s own doc).
+pub(crate) const META_SECTIONS: &[MetaSection] = &[
+ MetaSection {
+ name: "members",
+ href: "/members",
+ blurb: "Enrolled members and their signing keys.",
+ },
+ MetaSection {
+ name: "effects",
+ href: "/effects",
+ blurb: "Registered effects and their trigger queries.",
+ },
+ MetaSection {
+ name: "toolchains",
+ href: "/toolchains",
+ blurb: "Recorded toolchain recipes and their import provenance.",
+ },
+ MetaSection {
+ name: "redactions",
+ href: "/redactions",
+ blurb: "Recorded redactions.",
+ },
+ MetaSection {
+ name: "inbox",
+ href: "/inbox",
+ blurb: "Entries awaiting adoption.",
+ },
+];
+
+/// The served repository's identity for the shell's `.wb-bar` top bar:
+/// its directory name and, when `HEAD` resolves to a
+/// branch, that branch's short name (mirrors
+/// `pre-redo:crates/git-ents-server/src/web/mod.rs`'s `RepoMeta`, trimmed
+/// to the two fields this single-repo crate actually has a data surface
+/// for -- no owner/name split, description, or topics).
+pub(crate) struct RepoHeader {
+ /// The served repository's directory name, shown as the sole
+ /// breadcrumb crumb (this crate serves exactly one repository).
+ pub(crate) name: String,
+ /// The short name of `HEAD`'s branch, or `None` when `HEAD` is
+ /// detached, unborn, or the repository cannot be opened -- the
+ /// `.branch` pill is omitted in that case rather than guessed at.
+ pub(crate) branch: Option<String>,
+}
+
+impl RepoHeader {
+ /// Read the served repository's name and current branch off `state`
+ /// once, so [`layout`]'s call sites stay one-liners and the
+ /// `gix::open`/`HEAD` logic lives in exactly this one place (the same
+ /// `gix::open(&state.path)` pattern [`crate::pages::files`] browses the
+ /// `HEAD` tree with). Never panics: an unopenable repository or a
+ /// detached/unborn `HEAD` degrades to no branch pill.
+ pub(crate) fn from_state<O>(state: &AppState<O>) -> Self {
+ let name = std::fs::canonicalize(&state.path)
+ .ok()
+ .as_deref()
+ .and_then(std::path::Path::file_name)
+ .map(|name| name.to_string_lossy().into_owned())
+ .unwrap_or_else(|| "repository".to_owned());
+ let branch = gix::open(&state.path).ok().and_then(|repo| {
+ repo.head_name()
+ .ok()
+ .flatten()
+ .map(|full| full.shorten().to_str_lossy().into_owned())
+ });
+ Self { name, branch }
+ }
+}
+
+/// Wrap `title` and `body` in the one page shell every route renders
+/// through -- the workbench chrome (see [`layout_shell`]) around a
+/// `main.content` column carrying the page's own `.page-header` title and
+/// `body`. `active` names which rail item is current and `repo` the served
+/// repository the top bar names. `identity` is the signing identity's
+/// display label (see [`identity_label`]), rendered as the bar's
+/// right-aligned `.id-chip` link to `/account` -- the same place the
+/// rail's own account icon leads.
+pub(crate) fn layout(
+ repo: &RepoHeader,
+ identity: &str,
+ active: Tab,
+ title: &str,
+ body: Markup,
+) -> Markup {
+ layout_shell(
+ repo,
+ identity,
+ active,
+ title,
+ html! {
+ main.content {
+ div.page-header { h1.page-title { (title) } }
+ (body)
+ }
+ },
+ )
+}
+
+/// One `.rail` item: an icon-only link into a page family, `title`-tipped
+/// (the rail carries no text labels at all), highlighted when `tab` is the
+/// page's own `active` section.
+fn rail_link(active: Tab, tab: Tab, href: &str, title: &str, icon: &str) -> Markup {
+ html! {
+ a.active[active == tab] href=(href) title=(title) { (crate::assets::icon_use(icon)) }
+ }
+}
+
+/// The workbench shell itself (the "Proposal C" chrome,
+/// `docs/web-workbench-plan.adoc`): a `.wb` grid pairing the sticky icon
+/// `.rail` (Dashboard / Code / Review / Issues / Threads, then governance
+/// and account past the spacer -- see [`Tab`]'s own doc) with a `.wb-main`
+/// column whose sticky `.wb-bar` top bar names the served repository and
+/// its branch pill, carries the `.palette` search form (a plain GET to
+/// `/search` for now -- the `⌘K` kbd is a hint at the palette phase, not
+/// yet wired), and ends in the `.id-chip` identity link. `content` renders
+/// below the bar as-is: [`layout`] passes the ordinary padded
+/// `main.content` column, while a master-detail page passes its own
+/// full-bleed `.split` instead.
+pub(crate) fn layout_shell(
+ repo: &RepoHeader,
+ identity: &str,
+ active: Tab,
+ title: &str,
+ content: Markup,
+) -> Markup {
+ html! {
+ (maud::DOCTYPE)
+ html lang="en" {
+ head {
+ meta charset="utf-8";
+ meta name="viewport" content="width=device-width, initial-scale=1";
+ meta name="color-scheme" content="light dark";
+ title { "git ents: " (title) }
+ link rel="stylesheet" href="/style.css";
+ script src="/ents.js" defer {}
+ }
+ body {
+ (crate::assets::sprite())
+ div.wb {
+ aside.rail {
+ span.nav-mark { "✳" }
+ (rail_link(active, Tab::Overview, "/", "Dashboard", "i-home"))
+ (rail_link(active, Tab::Files, "/files", "Code", "i-files"))
+ (rail_link(active, Tab::Commits, "/commits", "Review", "i-commit"))
+ (rail_link(active, Tab::Issues, "/issues", "Issues", "i-issue"))
+ (rail_link(active, Tab::Comments, "/comments", "Threads", "i-comment"))
+ span.spacer {}
+ (rail_link(active, Tab::Meta, "/meta", "Repo & governance", "i-meta"))
+ (rail_link(active, Tab::Account, "/account", "Account", "i-person"))
+ }
+ div.wb-main {
+ div.wb-bar {
+ span.repo-path {
+ span.here { (repo.name) }
+ @if let Some(branch) = &repo.branch {
+ span.branch { (crate::assets::icon_use("i-commit")) (branch) }
+ }
+ }
+ form.palette method="get" action="/search" {
+ (crate::assets::icon_use("i-search"))
+ input type="search" name="q" placeholder="Jump to file, commit, issue, member…" aria-label="Search";
+ kbd { "⌘K" }
+ }
+ a.id-chip href="/account" { (identity) }
+ }
+ (content)
+ }
+ }
+ }
+ }
+ }
+}
+
+/// Wrap `body` in the [`META_SECTIONS`] rail, then in [`layout`] itself
+/// with `Meta` active -- the thin wrapper every meta-namespace page
+/// ([`super::members`], [`super::effects`], [`super::toolchains`],
+/// [`super::redactions`], [`super::inbox`]) calls instead of [`layout`]
+/// directly, so the rail markup lives in exactly one place. `active_href`
+/// names which [`META_SECTIONS`] entry to highlight -- a page family's own
+/// `href`, not the request's actual path, so a `/{id}` child page (e.g.
+/// `/members/{username}`) highlights the same rail entry as its list page.
+pub(crate) fn layout_meta(
+ repo: &RepoHeader,
+ identity: &str,
+ active_href: &str,
+ title: &str,
+ body: Markup,
+) -> Markup {
+ layout(
+ repo,
+ identity,
+ Tab::Meta,
+ title,
+ html! {
+ div.meta-layout {
+ nav.meta-rail {
+ @for section in META_SECTIONS {
+ a.active[section.href == active_href] href=(section.href) { (section.name) }
+ }
+ }
+ div { (body) }
+ }
+ },
+ )
+}
+
+/// Wrap `title`, `sidebar`, and `pane` in the master-detail split every
+/// selection-heavy page family renders through ([`super::files`]'s tree
+/// beside a blob, [`super::commits`]'s compact history beside a diff,
+/// [`super::issues`]'s issue list beside an issue): the workbench chrome
+/// ([`layout_shell`]) around a full-bleed `.split` grid -- a sticky
+/// `nav.tree` sidebar on the left, a padded `main.pane` (carrying the
+/// page's own `.page-header` title and `pane` body) on the right. Every
+/// selection in the sidebar is a real URL and the sidebar always renders,
+/// so the split stays SSR-friendly (`docs/web-workbench-plan.adoc`).
+pub(crate) fn layout_split(
+ repo: &RepoHeader,
+ identity: &str,
+ active: Tab,
+ title: &str,
+ sidebar: Markup,
+ pane: Markup,
+) -> Markup {
+ layout_shell(
+ repo,
+ identity,
+ active,
+ title,
+ html! {
+ div.split {
+ nav.tree { (sidebar) }
+ main.pane {
+ div.page-header { h1.page-title { (title) } }
+ (pane)
+ }
+ }
+ },
+ )
+}
+
+/// The "open in editor" affordance rendered beside a code location: a
+/// deep link into the serving user's own editor
+/// ([`crate::editor::detected`]: `$ENTS_EDITOR`, then `$EDITOR`), its
+/// icon naming which one. Renders nothing at all when no recognized
+/// editor is configured -- the affordance is the escalation back to the
+/// desk the reader came from (`docs/web-workbench-plan.adoc`), never a
+/// dead link. The line-less deep link rides along as `data-editor-base`
+/// so `ents.js` can retarget the blob header's affordance at the
+/// currently selected line without rebuilding the URL client-side.
+pub(crate) fn editor_open<O>(state: &AppState<O>, path: &str, line: Option<u64>) -> Markup {
+ let Some(editor) = crate::editor::detected() else {
+ return html! {};
+ };
+ let root = std::fs::canonicalize(&state.path).unwrap_or_else(|_io| state.path.clone());
+ let abs = root.join(path);
+ html! {
+ a.editor-open
+ href=(editor.deep_link(&abs, line))
+ data-editor-base=(editor.deep_link(&abs, None))
+ title={ "Open in " (editor.label()) }
+ {
+ (crate::assets::icon_use(editor.icon()))
+ }
+ }
+}
+
+/// The signing identity's display label for [`layout`]'s `.id-chip`
+/// (`roots.web-signing`) -- [`crate::identity::SigningIdentity::label`].
+/// Every page reads this off `state` itself rather than `layout` reaching
+/// into [`AppState`], so `layout` stays a pure function of the shell's own
+/// chrome inputs (the same reason a [`Session`] is never threaded into it).
+pub(crate) fn identity_label<O>(state: &AppState<O>) -> String {
+ state.identity.label()
+}
+
+/// A hidden CSRF input every form this crate renders carries
+/// (`roots.web-session`): the one place that field is spelled, so a form
+/// can never omit it by a typo.
+pub(crate) fn csrf_input(session: &Session) -> Markup {
+ html! {
+ input type="hidden" name=(CSRF_FIELD) value=(session.csrf);
+ }
+}
+
+/// Verify `submitted` matches `session`'s own CSRF token
+/// (`roots.web-session`): every state-changing handler calls this before
+/// acting on a form body.
+///
+/// # Errors
+///
+/// [`Error::BadCsrf`] if `submitted` does not match.
+// @relation(roots.web-session, scope=function)
+pub(crate) fn require_csrf(session: &Session, submitted: &str) -> Result<()> {
+ if submitted == session.csrf {
+ Ok(())
+ } else {
+ Err(Error::BadCsrf)
+ }
+}
+
+/// A unix timestamp rendered as a relative "time ago" label, measured
+/// against the current time -- hand-rolled from epoch seconds rather than
+/// pulling in a date-formatting dependency, mirroring
+/// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own `ago`/
+/// `ago_seconds`. Shared by [`super::dashboard`]'s freshness strip and
+/// [`super::commits`]'s list/show pages, the only places this crate names
+/// a commit's age.
+pub(crate) fn ago(then_seconds: i64) -> String {
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
+ .unwrap_or(0);
+ let secs = now.saturating_sub(then_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 {
+ ago_plural(mins, "minute")
+ } else if days == 0 {
+ ago_plural(hours, "hour")
+ } else if days < 30 {
+ ago_plural(days, "day")
+ } else if days < 365 {
+ ago_plural(days.checked_div(30).unwrap_or(0), "month")
+ } else {
+ ago_plural(days.checked_div(365).unwrap_or(0), "year")
+ }
+}
+
+/// Format `n` whole `unit`s with an "ago" suffix, pluralizing as needed --
+/// [`ago`]'s own helper.
+fn ago_plural(n: i64, unit: &str) -> String {
+ if n == 1 {
+ format!("1 {unit} ago")
+ } else {
+ format!("{n} {unit}s ago")
+ }
+}
+
+/// The shared empty-state card (`ents.css`'s `.blankslate`): a short
+/// title and one explanatory line, rendered instead of a bare list or a
+/// header-only table when a page family has nothing to show yet. `line`
+/// is markup, not text, so a page can point at its own create form or
+/// link a next step ([`super::dashboard`]'s README pointer does the
+/// same).
+pub(crate) fn blankslate(title: &str, line: Markup) -> Markup {
+ html! {
+ div.card {
+ div.blankslate {
+ h2 { (title) }
+ p { (line) }
+ }
+ }
+ }
+}
+
+/// A `<datalist id="members">` of every enrolled username
+/// (`refs/meta/member/*`), for forms whose text field names a member --
+/// an issue's assignees completes by id in place; richer matching (by
+/// key, fuzzy) stays with the palette. Best-effort: a ref-store read
+/// failure renders an empty datalist rather than failing the page the
+/// form sits on.
+pub(crate) fn members_datalist<O>(state: &AppState<O>) -> Markup {
+ let mut names = Vec::new();
+ if let Ok(entries) = state.refs.iter_prefix("refs/meta/member/") {
+ for (name, _tip) in entries.flatten() {
+ let path = name.as_bstr().to_string();
+ if let Some(username) = path.strip_prefix("refs/meta/member/") {
+ names.push(username.to_owned());
+ }
+ }
+ }
+ html! {
+ datalist id="members" {
+ @for name in &names { option value=(name) {} }
+ }
+ }
+}
+
+/// The one-level breadcrumb trail every `/{id}` child page renders above
+/// its own content -- "parent \u{203a} here", reusing the `.crumbs` markup
+/// pattern [`super::files`]'s own multi-level path trail already renders
+/// (same `nav.crumbs`/`span.sep`/`span.here` classes, so the stylesheet
+/// needs no second breadcrumb rule). `parent` links back to the family's
+/// list page at `parent_href`; `here` is the child's own display name, a
+/// plain non-link "you are here" crumb.
+pub(crate) fn child_crumbs(parent: &str, parent_href: &str, here: &str) -> Markup {
+ html! {
+ nav.crumbs {
+ a href=(parent_href) { (parent) }
+ span.sep { (crate::assets::icon_chevron()) }
+ span.here { (here) }
+ }
+ }
+}
+
+/// A commit id shortened to seven hex characters for display -- mirrors
+/// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own `short_oid`.
+/// Falls back to the full id on the (practically unreachable) case that a
+/// 7-character prefix is invalid for `oid`'s hash kind.
+pub(crate) fn short_oid(oid: &ObjectId) -> String {
+ gix_hash::Prefix::new(oid, 7).map_or_else(|_| oid.to_string(), |prefix| prefix.to_string())
+}
crates/cli/ents-web/src/pages/redactions.rs
@@ -1,0 +1,118 @@
+//! `GET /redactions`, `GET /redactions/{id}`: the generic list/view pair
+//! for [`ents_model::Redaction`] -- read-only in this phase (recording a
+//! redaction stays a `git ents redact add` operation, admin-only per the
+//! gate's default namespace-authorization arm).
+
+use std::sync::Arc;
+
+use axum::extract::{Path, State};
+use ents_model::Redaction;
+use gix_object::{Find, Write};
+
+use crate::error::{Error, Result};
+use crate::state::AppState;
+
+/// `GET /redactions`.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+pub async fn list<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let mut rows = Vec::new();
+ let mut failures = Vec::new();
+ for (id, redaction) in read_all(&state)? {
+ match redaction {
+ Ok(redaction) => rows.push((id, redaction)),
+ Err(error) => failures.push((format!("refs/meta/redactions/{id}"), error)),
+ }
+ }
+ let table = if rows.is_empty() {
+ super::blankslate(
+ "No redactions yet",
+ maud::html! { "Record one with " code { "git ents redact add" } "." },
+ )
+ } else {
+ crate::render::list_table(&rows, "id", |id| format!("/redactions/{id}"))
+ };
+ Ok(super::layout_meta(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ "/redactions",
+ "Redactions",
+ maud::html! {
+ (crate::render::unreadable_disclosure(&failures))
+ (table)
+ },
+ ))
+}
+
+/// `GET /redactions/{id}`.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `id` has no redaction ref at all -- a redaction
+/// ref that exists but whose stored tree does not match this build's
+/// [`Redaction`] shape degrades to [`crate::render::unreadable`] instead
+/// (`roots.web-agnostic`'s graceful-degradation stance).
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ Path(id): Path<String>,
+) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let (_, redaction) = read_all(&state)?
+ .into_iter()
+ .find(|(rid, _)| *rid == id)
+ .ok_or_else(|| Error::NotFound {
+ what: format!("redaction {id}"),
+ })?;
+ let body = match redaction {
+ Ok(redaction) => crate::render::view(&redaction),
+ Err(detail) => crate::render::unreadable(&detail),
+ };
+ Ok(super::layout_meta(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ "/redactions",
+ &id,
+ maud::html! {
+ (super::child_crumbs("redactions", "/redactions", &id))
+ (body)
+ },
+ ))
+}
+
+/// Every `refs/meta/redactions/*` ref, with its tip's tree deserialized as
+/// a [`Redaction`] -- `Err(detail)` for a ref this build's
+/// `#[derive(Facet)]` shape could not read back, kept in the listing
+/// rather than dropped (see `crate::pages::members::read_all`'s identical
+/// rationale).
+fn read_all<O: Find>(
+ state: &AppState<O>,
+) -> Result<Vec<(String, std::result::Result<Redaction, String>)>> {
+ let mut out = Vec::new();
+ for entry in state.refs.iter_prefix("refs/meta/redactions/")? {
+ let (name, tip) = entry?;
+ let path = name.as_bstr().to_string();
+ let Some(id) = path.strip_prefix("refs/meta/redactions/") else {
+ continue;
+ };
+ // One `state.objects()` lock per iteration, reused for both reads
+ // -- see `crate::pages::members::read_all`'s identical comment for
+ // why a second `state.objects()` within the same statement would
+ // self-deadlock on this non-reentrant `Mutex`.
+ let objects = state.objects();
+ let redaction = super::commit_tree(&*objects, tip)
+ .map_err(|error| error.to_string())
+ .and_then(|tree| {
+ facet_git_tree::deserialize::<Redaction>(&tree, &*objects)
+ .map_err(|error| error.to_string())
+ });
+ out.push((id.to_owned(), redaction));
+ }
+ Ok(out)
+}
crates/cli/ents-web/src/pages/search.rs
@@ -1,0 +1,271 @@
+//! `GET /search`: `super::layout`'s nav search form's target -- a plain
+//! request-time substring scan over the served repository, deliberately
+//! with no index and no new state (a design decision this crate settled
+//! on rather than relitigated here): every request re-walks the `HEAD`
+//! tree and the meta-ref listings the pages that already own them use.
+//! Renders with no tab active at all (`super::Tab::None`), like
+//! [`super::account`], since it is reached from the nav search form
+//! rather than any tab.
+
+use std::sync::Arc;
+
+use axum::extract::{Query, State};
+use ents_kiln::toolchain;
+use gix::bstr::ByteSlice as _;
+use gix_object::{Find, Write};
+use maud::{Markup, html};
+use serde::Deserialize;
+
+use crate::error::Result;
+use crate::state::AppState;
+
+/// The largest number of matches shown per result group -- past it, a
+/// "more matches not shown" note replaces the rest rather than rendering
+/// an unbounded page.
+const MAX_RESULTS: usize = 100;
+
+/// The query parameters `GET /search` accepts.
+#[derive(Debug, Deserialize)]
+pub struct SearchQuery {
+ /// The search term. Empty (the default, and what a bare `GET /search`
+ /// carries) renders a "type to search" blankslate rather than a
+ /// no-matches one -- nothing was searched yet, so nothing "failed to
+ /// match" (see [`blankslate`]'s own doc).
+ #[serde(default)]
+ q: String,
+}
+
+/// `GET /search`: grouped, case-insensitive substring matches -- file
+/// paths from the `HEAD` tree (linking into `crate::pages::files`) and
+/// meta entity names (member usernames, effect names, toolchain names,
+/// linking into their own show pages) -- or a blankslate on an empty
+/// query or no matches.
+///
+/// # Errors
+///
+/// Propagates a ref-store read failure.
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ Query(params): Query<SearchQuery>,
+) -> Result<Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let query = params.q.trim().to_owned();
+ let (files, files_more) = search_files(&state, &query);
+ let (members, members_more) = meta_names(&state, "refs/meta/member/", &query)?;
+ let (effects, effects_more) = meta_names(&state, "refs/meta/effects/", &query)?;
+ let (toolchains, toolchains_more) = search_toolchains(&state, &query)?;
+
+ let any_matches =
+ !files.is_empty() || !members.is_empty() || !effects.is_empty() || !toolchains.is_empty();
+
+ Ok(super::layout(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ super::Tab::None,
+ "Search",
+ html! {
+ @if !any_matches {
+ (blankslate(&query))
+ } @else {
+ (result_group("files", &files, files_more, |path| format!("/files/{path}")))
+ (result_group("members", &members, members_more, |id| format!("/members/{id}")))
+ (result_group("effects", &effects, effects_more, |id| format!("/effects/{id}")))
+ (result_group(
+ "toolchains",
+ &toolchains,
+ toolchains_more,
+ |id| format!("/toolchains/{id}"),
+ ))
+ }
+ },
+ ))
+}
+
+/// Truncate `items` to [`MAX_RESULTS`], reporting whether anything was cut.
+fn cap(mut items: Vec<String>) -> (Vec<String>, bool) {
+ if items.len() > MAX_RESULTS {
+ items.truncate(MAX_RESULTS);
+ (items, true)
+ } else {
+ (items, false)
+ }
+}
+
+/// File paths under the `HEAD` tree whose path contains `query`
+/// (case-insensitive), capped via [`cap`]. Empty on an empty `query` --
+/// no walk is attempted at all, matching every other group. Best-effort:
+/// an unopenable repository or unborn `HEAD` degrade to no file matches
+/// rather than an error, exactly as `crate::pages::files`/`crate::pages::dashboard`
+/// degrade the same reads.
+fn search_files<O>(state: &AppState<O>, query: &str) -> (Vec<String>, bool) {
+ if query.is_empty() {
+ return (Vec::new(), false);
+ }
+ let Ok(repo) = gix::open(&state.path) else {
+ return (Vec::new(), false);
+ };
+ let Ok(tree) = repo.head_tree() else {
+ return (Vec::new(), false);
+ };
+ let mut paths = Vec::new();
+ collect_paths(&repo, &tree, "", &mut paths);
+ let needle = query.to_lowercase();
+ cap(paths
+ .into_iter()
+ .filter(|path| path.to_lowercase().contains(&needle))
+ .collect())
+}
+
+/// Recurse `tree`, pushing every blob's full slash-joined path (relative
+/// to the `HEAD` root) onto `out` -- the same walk
+/// `crate::pages::dashboard::collect_blobs` performs for the language
+/// breakdown, here collecting paths instead of `(name, oid)` pairs.
+/// Subtree reads that fail are skipped rather than propagated, matching
+/// that same best-effort stance.
+fn collect_paths(
+ repo: &gix::Repository,
+ tree: &gix::Tree<'_>,
+ prefix: &str,
+ out: &mut Vec<String>,
+) {
+ for entry in tree.iter() {
+ let Ok(entry) = entry else { continue };
+ let name = entry.filename().to_str_lossy();
+ let path = if prefix.is_empty() {
+ name.into_owned()
+ } else {
+ format!("{prefix}/{name}")
+ };
+ if entry.mode().is_tree() {
+ if let Ok(object) = repo.find_object(entry.oid().to_owned())
+ && let Ok(subtree) = object.try_into_tree()
+ {
+ collect_paths(repo, &subtree, &path, out);
+ }
+ } else if entry.mode().is_blob() {
+ out.push(path);
+ }
+ }
+}
+
+/// The ids of every ref directly under `prefix` (a meta-ref namespace,
+/// e.g. `refs/meta/member/`) whose id contains `query` (case-insensitive),
+/// capped via [`cap`] -- the same `state.refs.iter_prefix` listing
+/// `crate::pages::members`/`crate::pages::effects` read their own rows
+/// from, here matched against `query` instead of fully deserialized.
+///
+/// # Errors
+///
+/// Propagates a ref-store read failure.
+fn meta_names<O>(state: &AppState<O>, prefix: &str, query: &str) -> Result<(Vec<String>, bool)> {
+ if query.is_empty() {
+ return Ok((Vec::new(), false));
+ }
+ let needle = query.to_lowercase();
+ let mut names = Vec::new();
+ for entry in state.refs.iter_prefix(prefix)? {
+ let (name, _) = entry?;
+ let path = name.as_bstr().to_string();
+ if let Some(id) = path.strip_prefix(prefix)
+ && id.to_lowercase().contains(&needle)
+ {
+ names.push(id.to_owned());
+ }
+ }
+ Ok(cap(names))
+}
+
+/// Toolchain names containing `query` (case-insensitive), capped via
+/// [`cap`] -- reads through the same [`toolchain::list`]
+/// `crate::pages::toolchains::list` itself calls.
+///
+/// # Errors
+///
+/// Propagates a ref-store read failure.
+fn search_toolchains<O>(state: &AppState<O>, query: &str) -> Result<(Vec<String>, bool)> {
+ if query.is_empty() {
+ return Ok((Vec::new(), false));
+ }
+ let needle = query.to_lowercase();
+ let names = toolchain::list(state.refs.as_ref())?
+ .into_iter()
+ .filter(|name| name.to_lowercase().contains(&needle))
+ .collect();
+ Ok(cap(names))
+}
+
+/// One result group's card: `label` as its header, `rows` linked via
+/// `href_for`, and a trailing "more matches not shown" row when `rows`
+/// was capped. Renders nothing at all when `rows` is empty, so an
+/// unmatched group leaves no empty card behind.
+fn result_group(
+ label: &str,
+ rows: &[String],
+ truncated: bool,
+ href_for: impl Fn(&str) -> String,
+) -> Markup {
+ if rows.is_empty() {
+ return html! {};
+ }
+ html! {
+ div.card {
+ div.card-header { (label) }
+ ul.string-list {
+ @for row in rows {
+ li { a href=(href_for(row)) { (row) } }
+ }
+ }
+ @if truncated {
+ div.card-row.muted { "More matches not shown." }
+ }
+ }
+ }
+}
+
+/// The empty-results placeholder ([`super::blankslate`]): a "type to
+/// search" prompt before any query has been typed at all (naming the
+/// header's own "Jump to file or symbol" search input, this page's only
+/// entry point), or a "no matches" note for a non-empty query that found
+/// nothing.
+fn blankslate(query: &str) -> Markup {
+ if query.is_empty() {
+ super::blankslate(
+ "Type to search",
+ html! {
+ "Use the header's \u{201c}Jump to file or symbol\u{201d} search "
+ "to look through files and meta entities."
+ },
+ )
+ } else {
+ super::blankslate(
+ "No matches",
+ html! { "Nothing matched " code { (query) } "." },
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use super::*;
+
+ #[test]
+ fn cap_truncates_and_reports_when_it_cut_something() {
+ let (kept, truncated) = cap((0..150).map(|n| n.to_string()).collect());
+ assert_eq!(kept.len(), MAX_RESULTS);
+ assert!(truncated);
+
+ let (kept, truncated) = cap(vec!["a".to_owned(), "b".to_owned()]);
+ assert_eq!(kept.len(), 2);
+ assert!(!truncated);
+ }
+
+ #[test]
+ fn result_group_renders_nothing_for_an_empty_group() {
+ let rendered = result_group("files", &[], false, |row| row.to_owned()).into_string();
+ assert!(rendered.is_empty());
+ }
+}
crates/cli/ents-web/src/pages/toolchains.rs
@@ -1,0 +1,224 @@
+//! `GET /toolchains`, `GET /toolchains/{name}`: a custom (not generic)
+//! page family, per this crate's own top-level doc -- a toolchain's
+//! [`ents_kiln::Recipe`] needs domain-specific rendering (`Embedded` vs
+//! `Downloaded`, each with its own provenance shape) that would otherwise
+//! push a `match Recipe::Embedded { .. } => ...` into the generic
+//! reflection walk [`crate::render`] exists to keep type-agnostic.
+//! Directory import stays a `git ents toolchain import` operation (it
+//! takes a local directory path, not form data a browser can supply);
+//! what `POST /toolchains` wires instead is [`toolchain::register`],
+//! taking a recipe as text ([`ents_kiln::Recipe::parse`]'s own format)
+//! -- an `embedded <tree-oid>` line or a `downloaded` component list is
+//! exactly form data.
+
+use std::sync::Arc;
+
+use axum::Form;
+use axum::extract::{Path, State};
+use axum::response::{IntoResponse, Redirect};
+use ents_kiln::toolchain;
+use gix_object::{Find, Write};
+use maud::html;
+use serde::Deserialize;
+
+use crate::error::{Error, Result};
+use crate::session::Session;
+use crate::state::AppState;
+
+/// `GET /toolchains`.
+///
+/// Every name resolves its own recipe (`toolchain::view`) so a name whose
+/// stored tree does not match this build's [`ents_kiln::Toolchain`]/
+/// [`ents_kiln::Recipe`] shape (written by an older schema) surfaces in
+/// the same [`crate::render::unreadable_disclosure`] every other entity
+/// family's list page renders, while its name stays linked in the listing
+/// (its show page renders the unreadable marker card) -- hand-rolled here
+/// since [`toolchain::list`] itself only enumerates ref names, with no
+/// reflected entity for [`crate::render`]'s generic machinery to walk
+/// (this page family's own top-level doc).
+///
+/// # Errors
+///
+/// Propagates a ref-store read failure.
+pub async fn list<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let names = toolchain::list(state.refs.as_ref())?;
+ let mut failures = Vec::new();
+ for name in &names {
+ if let Err(error) = toolchain::view(state.refs.as_ref(), &*state.objects(), name) {
+ failures.push((format!("refs/meta/toolchains/{name}"), error.to_string()));
+ }
+ }
+ let listing = if names.is_empty() {
+ super::blankslate(
+ "No toolchains yet",
+ html! { "Import one with " code { "git ents toolchain import" } "." },
+ )
+ } else {
+ html! {
+ div.card {
+ ul.string-list {
+ @for name in &names {
+ li {
+ a href=(format!("/toolchains/{name}")) { (name) }
+ }
+ }
+ }
+ }
+ }
+ };
+ Ok(super::layout_meta(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ "/toolchains",
+ "Toolchains",
+ html! {
+ (crate::render::unreadable_disclosure(&failures))
+ (listing)
+ h2 { "Import a Toolchain" }
+ (import_form(&session))
+ },
+ ))
+}
+
+/// The import-toolchain form (`POST /toolchains`): a name and a recipe in
+/// [`ents_kiln::Recipe::parse`]'s own text format.
+fn import_form(session: &Session) -> maud::Markup {
+ html! {
+ form method="post" action="/toolchains" {
+ (super::csrf_input(session))
+ label { "name" input type="text" name="name"; }
+ label {
+ "recipe"
+ textarea name="recipe"
+ placeholder="embedded <tree-oid>\nor:\ndownloaded\n<url> <sha256> <strip> [dest]" {}
+ }
+ button type="submit" { "Import Toolchain" }
+ }
+ }
+}
+
+/// The form fields `POST /toolchains` accepts.
+#[derive(Debug, Deserialize)]
+pub struct ImportForm {
+ /// Name to record the toolchain under (`refs/meta/toolchains/<name>`).
+ name: String,
+ /// The recipe text ([`ents_kiln::Recipe::parse`]).
+ recipe: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+/// `POST /toolchains`: record a toolchain from a recipe given as text
+/// ([`toolchain::register`]) as a signed mutation on
+/// `refs/meta/toolchains/<name>` -- the recipe-flow counterpart of
+/// `git ents toolchain import`, whose directory walk cannot arrive as
+/// form data (this module's own top-level doc).
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match;
+/// [`Error::InvalidArgument`] on a recipe that does not parse or a name
+/// that cannot form a ref; otherwise propagates the `receive` proposal's
+/// own failures.
+// @relation(roots.web-signing, roots.web-session, scope=function)
+pub async fn register<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Form(form): Form<ImportForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let recipe = ents_kiln::Recipe::parse(&form.recipe)
+ .map_err(|source| Error::InvalidArgument(format!("invalid recipe: {source}")))?;
+ let name = form.name.trim();
+ let identity = state.identity.as_ref();
+ let outcome = toolchain::register(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ name,
+ &recipe,
+ &crate::receive_identity!(identity),
+ state.mode,
+ )
+ .map_err(|source| match source {
+ ents_effect::Error::InvalidToolchainName(bad) => {
+ Error::InvalidArgument(format!("invalid toolchain name: {bad}"))
+ }
+ other => Error::from(other),
+ })?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(Redirect::to(&format!("/toolchains/{name}")))
+}
+
+/// `GET /toolchains/{name}`: the toolchain's recorded recipe and import
+/// log.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `name` has no toolchain ref at all
+/// ([`ents_effect::Error::UnknownToolchain`]) -- a toolchain ref that
+/// exists but whose stored tree does not match this build's
+/// [`ents_kiln::Toolchain`]/[`ents_kiln::Recipe`] shape degrades to
+/// [`crate::render::unreadable`] instead (`roots.web-agnostic`'s
+/// graceful-degradation stance). The import log is best-effort once the
+/// recipe itself reads back: a log entry this build cannot decode renders
+/// as an empty log rather than failing the whole page, since the recipe is
+/// this page's primary content.
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ Path(name): Path<String>,
+) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ // One `state.objects()` lock, reused for both `view` and `log`: a
+ // `match` scrutinee's own temporaries live for the whole match (arms
+ // included), so a second `state.objects()` inside the `Ok` arm below
+ // would try to lock this non-reentrant `Mutex` while the scrutinee's
+ // own guard is still held, self-deadlocking forever rather than
+ // erroring (see `crate::pages::members::read_all`'s identical
+ // rationale).
+ let objects = state.objects();
+ let body = match toolchain::view(state.refs.as_ref(), &*objects, &name) {
+ Ok((toolchain, recipe)) => {
+ let log = toolchain::log(state.refs.as_ref(), &*objects, &name).unwrap_or_default();
+ html! {
+ dl {
+ dt { "name" } dd { (toolchain.name) }
+ dt { "recipe" } dd { (format!("{recipe:?}")) }
+ }
+ h2 { "Import Log" }
+ ul {
+ @for oid in &log {
+ li { (oid.to_string()) }
+ }
+ }
+ }
+ }
+ Err(ents_effect::Error::UnknownToolchain(_)) => {
+ return Err(Error::NotFound {
+ what: format!("toolchain {name}"),
+ });
+ }
+ Err(error) => crate::render::unreadable(&error.to_string()),
+ };
+ Ok(super::layout_meta(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ "/toolchains",
+ &name,
+ html! {
+ (super::child_crumbs("toolchains", "/toolchains", &name))
+ (body)
+ },
+ ))
+}
crates/cli/ents-web/src/render.rs
@@ -1,0 +1,446 @@
+//! The generic, schema-driven list/view rendering mechanism -- the UI
+//! analog of the gate executor named in this crate's development-plan
+//! row: one reflection walk over any `#[derive(Facet)]` entity's
+//! [`facet::Shape`], reused for every kernel entity this crate lists or
+//! shows, rather than one hand-written renderer per entity type.
+//!
+//! The binding rule this module exists to uphold: nothing here ever
+//! matches on *which* concrete type it was handed. [`fields`] walks
+//! whatever [`facet::Shape`] the type reflects, by field name and
+//! position, exactly the same way for [`ents_model::Member`],
+//! [`ents_model::Effect`], [`ents_model::Redaction`], or
+//! [`ents_model::Account`]. A page that genuinely needs to know it is
+//! showing a comment (to render an anchor's projected diff) or a
+//! toolchain (to render a recipe's provenance) is not a gap in this
+//! module -- it is [`crate::pages::comments`] or [`crate::pages::toolchains`]
+//! choosing a legitimate custom view instead of this generic one, exactly
+//! as this crate's development-plan row anticipates.
+
+use facet::Facet;
+use maud::{Markup, html};
+
+/// One field's name and rendered value, in declaration order.
+pub type FieldRow = (&'static str, String);
+
+/// Reflect over `value`'s [`facet::Shape`] and return one `(name, value)`
+/// pair per field, in declaration order.
+///
+/// A field's value renders via its own `Display` impl when it has one
+/// (plain text, no `Type::Foo(...)` wrapper -- what a `String` or a
+/// `MemberId` newtype gives), and falls back to `Debug` otherwise (every
+/// entity struct in `ents-model`/`ents-forge`/`ents-kiln` derives `Debug`,
+/// so an enum field like [`ents_model::MemberState`] still renders its
+/// variant name rather than an opaque placeholder). A field this crate
+/// cannot even walk as a struct (called on a non-struct `T`) renders as an
+/// empty list, not a panic -- reflection is a UI convenience, never a
+/// correctness path.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::{Member, Provenance};
+///
+/// let member = Member::new("jdc", "ssh-ed25519 AAAA... jdc", Provenance::AdminRegistered);
+/// let rows = ents_web::render::fields(&member);
+/// assert_eq!(rows[0].0, "id");
+/// assert_eq!(rows[1].0, "key");
+/// assert!(rows[1].1.contains("ssh-ed25519"));
+/// assert_eq!(rows[2].0, "state");
+/// assert_eq!(rows[2].1, "Active");
+/// ```
+#[must_use]
+pub fn fields<T: Facet<'static>>(value: &T) -> Vec<FieldRow> {
+ let peek = facet_reflect::Peek::new(value);
+ let Ok(structure) = peek.into_struct() else {
+ return Vec::new();
+ };
+ structure
+ .ty()
+ .fields
+ .iter()
+ .enumerate()
+ .map(|(index, field)| {
+ let rendered = structure
+ .field(index)
+ .map(render_scalar)
+ .unwrap_or_default();
+ (field.name, rendered)
+ })
+ .collect()
+}
+
+/// Render one field's [`facet_reflect::Peek`] as plain text: its own
+/// `Display` if it has one, else `Debug`, so an enum still shows a variant
+/// name instead of this crate's opaque `⟨TypeName⟩` placeholder.
+fn render_scalar(peek: facet_reflect::Peek<'_, '_>) -> String {
+ if let Some(s) = peek.as_str() {
+ return s.to_owned();
+ }
+ let displayed = format!("{peek}");
+ if displayed.starts_with('⟨') {
+ format!("{peek:?}")
+ } else {
+ displayed
+ }
+}
+
+/// A definition-list view of one entity's fields -- the generic "show"
+/// page every kernel entity this crate exposes uses.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::{Account, MemberId};
+///
+/// let account = Account { member: MemberId::new("jdc"), login: "jdc@ents.test".to_owned() };
+/// let markup = ents_web::render::view(&account);
+/// assert!(markup.into_string().contains("login"));
+/// ```
+#[must_use]
+pub fn view<T: Facet<'static>>(value: &T) -> Markup {
+ let rows = fields(value);
+ html! {
+ div.card {
+ dl.entity-view {
+ @for (name, rendered) in &rows {
+ dt { (name) }
+ dd { (rendered) }
+ }
+ }
+ }
+ }
+}
+
+/// A table listing `rows`, one row per `(id, entity)` pair, columns taken
+/// from the first entity's own reflected field names -- the generic
+/// "list" page every kernel entity this crate exposes uses.
+///
+/// `id_header` names the leading column holding each entry's key (a
+/// username, an effect name, a redaction id -- whatever names the ref this
+/// listing was read from, which is never itself a field on the entity).
+///
+/// Rows are the readable entities only: a ref whose stored tree this
+/// build's `#[derive(Facet)]` shape could not read back is not this
+/// table's row to render -- the page surfaces it through
+/// [`unreadable_disclosure`] beside this table instead (the one place
+/// unreadable entities render, for every family alike), and its own show
+/// page still renders [`unreadable`]'s marker card.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::{Member, Provenance};
+///
+/// let rows = vec![
+/// ("jdc".to_owned(), Member::new("jdc", "key-a", Provenance::AdminRegistered)),
+/// ];
+/// let rendered = ents_web::render::list_table(&rows, "username", |id| format!("/members/{id}")).into_string();
+/// assert!(rendered.contains("jdc"));
+/// assert!(rendered.contains("key-a"));
+/// ```
+#[must_use]
+pub fn list_table<T: Facet<'static>>(
+ rows: &[(String, T)],
+ id_header: &str,
+ href_for: impl Fn(&str) -> String,
+) -> Markup {
+ let field_names: Vec<&'static str> = rows
+ .first()
+ .map(|(_, entity)| fields(entity).into_iter().map(|(name, _)| name).collect())
+ .unwrap_or_default();
+ html! {
+ div.card {
+ table.entity-list {
+ thead {
+ tr {
+ th { (id_header) }
+ @for name in &field_names {
+ th { (name) }
+ }
+ }
+ }
+ tbody {
+ @for (id, entity) in rows {
+ tr {
+ td { a href=(href_for(id)) { (id) } }
+ @for (_, rendered) in fields(entity) {
+ td.long-token[has_long_token(&rendered)] { (rendered) }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/// Whether `value` holds a token no wrap opportunity ever splits -- an ssh
+/// key's base64 body, an unbroken hash -- long enough (over 40 characters)
+/// that its cell must be allowed to break mid-token (`.long-token`'s
+/// `break-all`) or it starves every other column of the table's width.
+/// Ordinary short values keep word-boundary wrapping so a variant name
+/// like `AdminRegistered` never shreds.
+fn has_long_token(value: &str) -> bool {
+ value.split_whitespace().any(|token| token.len() > 40)
+}
+
+/// A muted marker card for one entity this crate could not reflect -- the
+/// `GET /{family}/{id}` show-page counterpart to [`list_table`]'s per-row
+/// marker: the same "unreadable" note, plus `detail` (the underlying
+/// deserialization error) rendered verbatim in muted monospace, so an
+/// operator can diagnose the schema mismatch without leaving the browser.
+/// Never a 500 -- reading an older or unrelated schema's tree degrades to
+/// this card, exactly as [`list_table`] degrades one row of a listing.
+///
+/// # Examples
+///
+/// ```
+/// let rendered = ents_web::render::unreadable("object ... is not a blob").into_string();
+/// assert!(rendered.contains("unreadable"));
+/// assert!(rendered.contains("is not a blob"));
+/// ```
+#[must_use]
+pub fn unreadable(detail: &str) -> Markup {
+ html! {
+ div.card {
+ div.card-row.unreadable {
+ span { "unreadable \u{2014} written by an older schema" }
+ }
+ div.card-row {
+ code.unreadable-detail { (detail) }
+ }
+ }
+ }
+}
+
+/// The subtle "this page has unreadable entities" disclosure a list page
+/// renders when one or more refs under its prefix failed to read back as
+/// this build's entity shape: a muted `<details>` badge ("N unreadable",
+/// warning glyph) that expands -- no JS, just the element's own toggle --
+/// to a small card listing each failed refname and its error text. One
+/// component for every entity family (members, effects, redactions,
+/// toolchains, comments, issues), so unreadable entities are surfaced the
+/// same way everywhere instead of a per-page mix of inline rows and
+/// silent gaps. Renders nothing at all when `items` is empty, so a
+/// healthy page carries no extra markup.
+///
+/// # Examples
+///
+/// ```
+/// let items = vec![(
+/// "refs/meta/comments/legacy".to_owned(),
+/// "object ... is not a blob".to_owned(),
+/// )];
+/// let rendered = ents_web::render::unreadable_disclosure(&items).into_string();
+/// assert!(rendered.contains("<details"));
+/// assert!(rendered.contains("1 unreadable"));
+/// assert!(rendered.contains("refs/meta/comments/legacy"));
+/// assert!(ents_web::render::unreadable_disclosure(&[]).into_string().is_empty());
+/// ```
+#[must_use]
+pub fn unreadable_disclosure(items: &[(String, String)]) -> Markup {
+ if items.is_empty() {
+ return html! {};
+ }
+ html! {
+ details.unreadable-note {
+ summary {
+ "\u{26a0} " (items.len()) " unreadable"
+ }
+ div.card {
+ dl.entity-view {
+ @for (refname, error) in items {
+ dt { (refname) }
+ dd { (error) }
+ }
+ }
+ }
+ }
+ }
+}
+
+/// A key-value properties table for a rendered document's own metadata --
+/// Markdown frontmatter ([`crate::markdown`]) and an AsciiDoc header's
+/// attribute entries ([`crate::asciidoc`]) both render through this one
+/// component, above the document body, styled by `ents.css`'s
+/// `.doc-props` rules on top of the same `.entity-view` definition-list
+/// look every generic entity view already has. Values are plain text
+/// (maud-escaped as any interpolation is); a nested structure the caller
+/// chose not to parse arrives here as its raw text and renders verbatim
+/// (`.doc-props dd` preserves its line breaks). Renders nothing at all
+/// when `entries` is empty, so a document with no metadata carries no
+/// empty table.
+///
+/// # Examples
+///
+/// ```
+/// let entries = vec![("title".to_owned(), "Design Notes".to_owned())];
+/// let rendered = ents_web::render::properties_table(&entries).into_string();
+/// assert!(rendered.contains("doc-props"));
+/// assert!(rendered.contains("Design Notes"));
+/// assert!(ents_web::render::properties_table(&[]).into_string().is_empty());
+/// ```
+#[must_use]
+pub fn properties_table(entries: &[(String, String)]) -> Markup {
+ if entries.is_empty() {
+ return html! {};
+ }
+ html! {
+ dl.entity-view.doc-props {
+ @for (key, value) in entries {
+ dt { (key) }
+ dd { (value) }
+ }
+ }
+ }
+}
+
+/// A list of plain strings with no reflected entity behind them (inbox
+/// entries, toolchain names) -- deliberately not the [`fields`] mechanism,
+/// since there is no struct to reflect over, only a bare list of ids.
+#[must_use]
+pub fn string_list(rows: &[String], href_for: impl Fn(&str) -> String) -> Markup {
+ html! {
+ div.card {
+ ul.string-list {
+ @for row in rows {
+ li { a href=(href_for(row)) { (row) } }
+ }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use ents_model::{Account, Effect, Member, MemberId, MemberState, Provenance, Redaction};
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ // @relation(roots.web-agnostic, scope=function, role=Verifies)
+ fn fields_walks_every_declared_field_in_order_for_any_kernel_entity() {
+ let member = Member::new(
+ "jdc",
+ "ssh-ed25519 AAAA... jdc",
+ Provenance::AdminRegistered,
+ );
+ let rows = fields(&member);
+ assert_eq!(
+ rows.iter().map(|(name, _)| *name).collect::<Vec<_>>(),
+ vec!["id", "key", "state", "provenance"]
+ );
+ }
+
+ #[rstest]
+ // @relation(roots.web-agnostic, scope=function, role=Verifies)
+ fn an_enum_field_renders_its_variant_name_not_a_placeholder() {
+ let member = Member::new("jdc", "key", Provenance::AdminRegistered);
+ let rows = fields(&member);
+ let (_, state) = rows
+ .iter()
+ .find(|(name, _)| *name == "state")
+ .expect("state field");
+ assert_eq!(state, "Active");
+ assert_eq!(member.state, MemberState::Active);
+ }
+
+ #[rstest]
+ #[case::member(Member::new("jdc", "k", Provenance::AdminRegistered))]
+ // @relation(roots.web-agnostic, scope=function, role=Verifies)
+ fn the_same_generic_view_renders_every_entity_type(#[case] member: Member) {
+ // Same call, no type-specific branch -- this is the whole point of
+ // the generic mechanism this module exists to prove. Each call's
+ // markup is asserted non-empty and containing a field name real to
+ // that entity, so this is a render check, not a discarded call.
+ assert!(view(&member).into_string().contains("provenance"));
+ assert!(
+ view(&Effect {
+ name: "unit".to_owned(),
+ trigger: "rev(refs/heads/main)".to_owned(),
+ toolchains: vec![],
+ run: "true".to_owned(),
+ })
+ .into_string()
+ .contains("trigger")
+ );
+ assert!(
+ view(&Redaction::new(
+ gix_hash::ObjectId::null(gix_hash::Kind::Sha1),
+ "why"
+ ))
+ .into_string()
+ .contains("reason")
+ );
+ assert!(
+ view(&Account {
+ member: MemberId::new("jdc"),
+ login: "jdc@ents.test".to_owned(),
+ })
+ .into_string()
+ .contains("login")
+ );
+ }
+
+ #[rstest]
+ // @relation(roots.web-agnostic, scope=function, role=Verifies)
+ fn list_table_derives_its_columns_from_the_first_rows_own_shape() {
+ let rows = vec![(
+ "jdc".to_owned(),
+ Member::new("jdc", "key", Provenance::AdminRegistered),
+ )];
+ let markup = list_table(&rows, "username", |id| format!("/members/{id}")).into_string();
+ assert!(markup.contains("username"));
+ assert!(markup.contains("key"));
+ assert!(markup.contains("jdc"));
+ }
+
+ #[rstest]
+ // @relation(roots.web-agnostic, scope=function, role=Verifies)
+ fn list_table_breaks_only_the_cell_holding_a_long_unbroken_token() {
+ let key = format!("ssh-ed25519 {} jdc@host", "A".repeat(68));
+ let rows = vec![(
+ "jdc".to_owned(),
+ Member::new("jdc", key, Provenance::AdminRegistered),
+ )];
+ let markup = list_table(&rows, "username", |id| format!("/members/{id}")).into_string();
+ // Exactly one cell carries the class: the key's; `AdminRegistered`
+ // and the short id stay word-boundary-wrapped.
+ assert_eq!(markup.matches("class=\"long-token\"").count(), 1);
+ }
+
+ #[rstest]
+ // @relation(roots.web-agnostic, scope=function, role=Verifies)
+ fn unreadable_disclosure_lists_each_failed_ref_behind_a_details_toggle() {
+ let items = vec![
+ (
+ "refs/meta/member/legacy".to_owned(),
+ "object ... is not a blob".to_owned(),
+ ),
+ (
+ "refs/meta/member/older".to_owned(),
+ "missing field".to_owned(),
+ ),
+ ];
+ let markup = unreadable_disclosure(&items).into_string();
+ assert!(markup.contains("<details"));
+ assert!(markup.contains("2 unreadable"));
+ assert!(markup.contains("refs/meta/member/legacy"));
+ assert!(markup.contains("missing field"));
+ assert!(
+ unreadable_disclosure(&[]).into_string().is_empty(),
+ "a healthy page carries no disclosure at all"
+ );
+ }
+
+ #[rstest]
+ // @relation(roots.web-agnostic, scope=function, role=Verifies)
+ fn unreadable_card_shows_the_underlying_error() {
+ let markup = unreadable("object deadbeef is not a blob").into_string();
+ assert!(markup.contains("unreadable"));
+ assert!(markup.contains("object deadbeef is not a blob"));
+ }
+}
crates/cli/ents-web/src/router.rs
@@ -1,0 +1,197 @@
+//! Wiring every page into one [`axum::Router`], plus the session/CSRF
+//! middleware every state-changing route runs behind
+//! (`roots.web-session`).
+//!
+//! [`router`] builds the [`axum::Router`] alone, with no socket ever
+//! bound -- this is what lets [`crate`]'s own tests (and, per
+//! `roots.web-agnostic`, an in-process webview embedding) drive a request
+//! through this crate's full stack via `tower::ServiceExt::oneshot`
+//! without any network transport existing at all. [`bind`]/[`serve_on`]
+//! split socket binding from serving so a caller (`git-ents`'s own `serve`
+//! command) can read back the bound port before the server starts
+//! blocking -- necessary for `--port 0` ("pick any free port") to be
+//! useful at all.
+
+use std::net::SocketAddr;
+use std::sync::Arc;
+
+use axum::Router;
+use axum::extract::{Request, State};
+use axum::http::{HeaderValue, header};
+use axum::middleware::{self, Next};
+use axum::response::{IntoResponse, Response};
+use axum::routing::{get, post};
+use gix_object::{Find, Write};
+
+use crate::assets;
+use crate::pages;
+use crate::session;
+use crate::state::AppState;
+
+/// Build the full route table, wrapped in the session middleware
+/// (`roots.web-session`).
+///
+/// Nothing here binds a socket: this `Router` is a plain, in-process
+/// `tower::Service` (`roots.web-agnostic`) -- see this module's own doc.
+// @relation(roots.web-agnostic, roots.local, scope=function)
+pub fn router<O>(state: Arc<AppState<O>>) -> Router
+where
+ O: Find + Write + Send + 'static,
+{
+ Router::new()
+ .route("/", get(pages::dashboard::show::<O>))
+ .route("/members", get(pages::members::list::<O>))
+ .route("/members/{username}", get(pages::members::show::<O>))
+ .route(
+ "/account",
+ get(pages::account::show::<O>).post(pages::account::update::<O>),
+ )
+ .route(
+ "/effects",
+ get(pages::effects::list::<O>).post(pages::effects::create::<O>),
+ )
+ .route("/effects/{name}", get(pages::effects::show::<O>))
+ .route("/commits", get(pages::commits::list::<O>))
+ .route("/commit/{oid}", get(pages::commits::show::<O>))
+ .route("/commit/{oid}/review", post(pages::commits::review::<O>))
+ .route(
+ "/reviews/{target}/{member}/comment",
+ post(pages::commits::review_comment::<O>),
+ )
+ .route("/files", get(pages::files::root::<O>))
+ .route("/files/{*path}", get(pages::files::show::<O>))
+ .route("/meta", get(pages::meta::show::<O>))
+ .route("/redactions", get(pages::redactions::list::<O>))
+ .route("/redactions/{id}", get(pages::redactions::show::<O>))
+ .route("/search", get(pages::search::show::<O>))
+ .route(
+ "/toolchains",
+ get(pages::toolchains::list::<O>).post(pages::toolchains::register::<O>),
+ )
+ .route("/toolchains/{name}", get(pages::toolchains::show::<O>))
+ .route(
+ "/comments",
+ get(pages::comments::list::<O>).post(pages::comments::add::<O>),
+ )
+ .route("/comments/{id}", get(pages::comments::show::<O>))
+ .route("/comments/{id}/reply", post(pages::comments::reply::<O>))
+ .route(
+ "/comments/{id}/resolve",
+ post(pages::comments::resolve::<O>),
+ )
+ .route("/comments/{id}/reopen", post(pages::comments::reopen::<O>))
+ .route(
+ "/issues",
+ get(pages::issues::list::<O>).post(pages::issues::create::<O>),
+ )
+ .route(
+ "/issues/{id}",
+ get(pages::issues::show::<O>).post(pages::issues::edit::<O>),
+ )
+ .route("/issues/{id}/comment", post(pages::issues::comment::<O>))
+ .route("/inbox", get(pages::inbox::list::<O>))
+ .route("/style.css", get(style))
+ .route("/ents.js", get(script))
+ .layer(middleware::from_fn_with_state(
+ Arc::clone(&state),
+ session_middleware::<O>,
+ ))
+ .with_state(state)
+}
+
+/// The session middleware (`roots.web-session`): recognize an existing
+/// session cookie, or mint a fresh one and set it on the response. Every
+/// handler reads the resolved [`session::Session`] via `Extension`.
+// @relation(roots.web-session, scope=function)
+async fn session_middleware<O>(
+ State(state): State<Arc<AppState<O>>>,
+ mut request: Request,
+ next: Next,
+) -> Response
+where
+ O: Find + Write + Send + 'static,
+{
+ let cookie_header = request
+ .headers()
+ .get(header::COOKIE)
+ .and_then(|value| value.to_str().ok())
+ .map(str::to_owned);
+ let existing = cookie_header
+ .as_deref()
+ .and_then(session::session_id_from_cookie_header)
+ .and_then(|id| {
+ state
+ .sessions
+ .get(id)
+ .map(|session| (id.to_owned(), session))
+ });
+
+ let (id, session, is_new) = match existing {
+ Some((id, session)) => (id, session, false),
+ None => {
+ let (id, session) = state.sessions.create();
+ (id, session, true)
+ }
+ };
+ request.extensions_mut().insert(session);
+
+ let mut response = next.run(request).await;
+ if is_new && let Ok(value) = HeaderValue::from_str(&session::set_cookie_header(&id)) {
+ response.headers_mut().append(header::SET_COOKIE, value);
+ }
+ response
+}
+
+/// `GET /style.css`: the one stylesheet [`pages::layout`]'s `head` links --
+/// the hand-rolled, ported pre-redo sheet (`crate::assets::OVERRIDES`). No
+/// session or CSRF gating applies here (the session middleware only ever
+/// attaches a session, never rejects a request), and it must not: every
+/// page, including one reached before a session exists, needs this to
+/// render styled.
+async fn style() -> impl IntoResponse {
+ (
+ [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
+ assets::OVERRIDES,
+ )
+}
+
+/// `GET /ents.js`: the progressive-enhancement script
+/// [`pages::layout`]'s `head` loads with `defer` (`crate::assets::SCRIPT`)
+/// -- see [`crate::assets`]'s own doc for what it does. Served the same
+/// way, and under the same no-session-gating rule, as [`style`].
+async fn script() -> impl IntoResponse {
+ (
+ [(header::CONTENT_TYPE, "text/javascript; charset=utf-8")],
+ assets::SCRIPT,
+ )
+}
+
+/// Bind a loopback-or-otherwise socket for [`serve_on`], returning the
+/// listener before any request is served so a caller can read back
+/// [`std::net::TcpListener::local_addr`] (necessary for `addr`'s port `0`,
+/// "pick any free port," to be useful to a caller that must print or open
+/// the resulting URL).
+///
+/// # Errors
+///
+/// Any [`std::io::Error`] binding the socket.
+pub async fn bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
+ tokio::net::TcpListener::bind(addr).await
+}
+
+/// Serve `state`'s router on an already-bound `listener` until the process
+/// is killed -- this crate has no shutdown signal of its own; a caller
+/// that wants graceful shutdown wraps this future with one.
+///
+/// # Errors
+///
+/// Any [`std::io::Error`] the underlying accept loop hits.
+pub async fn serve_on<O>(
+ listener: tokio::net::TcpListener,
+ state: Arc<AppState<O>>,
+) -> std::io::Result<()>
+where
+ O: Find + Write + Send + 'static,
+{
+ axum::serve(listener, router(state)).await
+}
crates/cli/ents-web/src/session.rs
@@ -1,0 +1,161 @@
+//! Hosted web sessions (`roots.web-session`): held only in this server's
+//! own process memory, never a session database or token table -- the
+//! same ban `model.account` states for authentication state generally.
+//!
+//! [`SessionStore`] is a plain `Mutex<HashMap<..>>`; there is no on-disk or
+//! external-database code path anywhere in this module for a session to
+//! reach, so "memory only" is a structural property of the type, not a
+//! configuration choice. A restarted process starts a new, empty
+//! [`SessionStore`], which is exactly why every state-changing request
+//! must additionally carry a per-session CSRF token: a stale cookie from a
+//! previous process names a session this one has never heard of, and is
+//! rejected as [`crate::Error::NoSession`] rather than silently trusted.
+
+use std::collections::HashMap;
+use std::sync::Mutex;
+
+/// The cookie name a browser carries a session id in.
+pub const COOKIE_NAME: &str = "ents_session";
+
+/// The form field (or header, for a JSON-style client) a state-changing
+/// request carries its CSRF token in.
+pub const CSRF_FIELD: &str = "csrf";
+
+/// One held session: nothing but the CSRF token it was issued.
+/// `roots.web-session` requires no more than this -- there is no login
+/// step in this phase (see `ents-web`'s crate doc for the scoping this
+/// leaves for a future account/login system), so a session's only job is
+/// letting this server recognize "the same browser that fetched the form
+/// is the one submitting it," which a bare CSRF token already proves.
+// @relation(roots.web-session, scope=file)
+#[derive(Debug, Clone)]
+pub struct Session {
+ /// The token a state-changing request must echo back.
+ pub csrf: String,
+}
+
+/// Server-memory-only session storage (`roots.web-session`).
+///
+/// # Examples
+///
+/// ```
+/// use ents_web::session::SessionStore;
+///
+/// let store = SessionStore::default();
+/// let (id, session) = store.create();
+/// assert_eq!(store.get(&id).expect("just created").csrf, session.csrf);
+/// assert!(store.get("no-such-id").is_none());
+/// ```
+// @relation(roots.web-session, scope=file)
+#[derive(Default)]
+pub struct SessionStore {
+ sessions: Mutex<HashMap<String, Session>>,
+}
+
+impl SessionStore {
+ /// Mint a new session with a fresh random id and CSRF token, and hold
+ /// it in memory.
+ ///
+ /// # Panics
+ ///
+ /// Never in practice: [`getrandom::fill`] only fails if the platform's
+ /// randomness source itself is unavailable, which every supported
+ /// target has.
+ #[must_use]
+ pub fn create(&self) -> (String, Session) {
+ let id = random_token();
+ let session = Session {
+ csrf: random_token(),
+ };
+ #[expect(
+ clippy::unwrap_used,
+ reason = "a poisoned mutex means an earlier panic already unwound this process; \
+ there is no meaningful recovery for a session store, only a fresh restart"
+ )]
+ self.sessions
+ .lock()
+ .unwrap()
+ .insert(id.clone(), session.clone());
+ (id, session)
+ }
+
+ /// Look up a held session by id.
+ #[must_use]
+ pub fn get(&self, id: &str) -> Option<Session> {
+ #[expect(clippy::unwrap_used, reason = "see Self::create's identical reasoning")]
+ self.sessions.lock().unwrap().get(id).cloned()
+ }
+}
+
+/// A random, URL-safe token: 32 hex characters from 16 random bytes.
+fn random_token() -> String {
+ let mut bytes = [0u8; 16];
+ #[expect(
+ clippy::expect_used,
+ reason = "getrandom only fails when the platform has no randomness source at all, which \
+ every target this crate ships to provides"
+ )]
+ getrandom::fill(&mut bytes).expect("platform randomness source is available");
+ bytes.iter().map(|b| format!("{b:02x}")).collect()
+}
+
+/// Parse `Cookie:` header bytes for [`COOKIE_NAME`]'s value.
+#[must_use]
+pub fn session_id_from_cookie_header(header: &str) -> Option<&str> {
+ header.split(';').find_map(|pair| {
+ let (name, value) = pair.trim().split_once('=')?;
+ (name == COOKIE_NAME).then_some(value)
+ })
+}
+
+/// Render a `Set-Cookie` header value for `id` -- `HttpOnly` and
+/// `SameSite=Strict` since this cookie is never read by page script and
+/// only ever needs to accompany same-site requests (`roots.web-session`'s
+/// CSRF requirement is the belt to this cookie's suspenders, not a
+/// replacement for it: `SameSite=Strict` alone would already block a
+/// cross-site POST, but a network intermediary or a future relaxation of
+/// that attribute must not silently remove the protection).
+#[must_use]
+pub fn set_cookie_header(id: &str) -> String {
+ format!("{COOKIE_NAME}={id}; Path=/; HttpOnly; SameSite=Strict")
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ // @relation(roots.web-session, scope=function, role=Verifies)
+ fn a_fresh_store_never_recognizes_a_foreign_session_id() {
+ let a = SessionStore::default();
+ let b = SessionStore::default();
+ let (id, _) = a.create();
+ assert!(
+ b.get(&id).is_none(),
+ "a session minted by one store must not be recognized by another -- there is no \
+ shared backing store for either to consult"
+ );
+ }
+
+ #[rstest]
+ // @relation(roots.web-session, scope=function, role=Verifies)
+ fn cookie_header_round_trips_the_session_id() {
+ let header = set_cookie_header("abc123");
+ assert!(header.contains("HttpOnly"));
+ let raw_cookie = header.split(';').next().expect("at least one segment");
+ assert_eq!(session_id_from_cookie_header(raw_cookie), Some("abc123"));
+ }
+
+ #[rstest]
+ // @relation(roots.web-session, scope=function, role=Verifies)
+ fn two_sessions_never_share_a_csrf_token() {
+ let store = SessionStore::default();
+ let (_, first) = store.create();
+ let (_, second) = store.create();
+ assert_ne!(first.csrf, second.csrf);
+ }
+}
crates/cli/ents-web/src/state.rs
@@ -1,0 +1,107 @@
+//! The web frontend's own handle onto the four composition-root seams
+//! (`roots.composition`), generic over only the object store: `refs` and
+//! `events` are already used as trait objects everywhere in this codebase
+//! (`git_ents::root::LocalRoot` passes `&root.refs` where `&dyn RefStore`
+//! is expected; `ents_forge::comment::add` takes `events: &dyn
+//! ents_receive::EventSink` directly), so [`AppState`] holds them boxed
+//! rather than introducing a type parameter this crate has no other use
+//! for. The object store stays a type parameter `O` because every mutation
+//! path (`ents_receive::propose_entity`) takes it as `&(impl
+//! gix_object::Find + gix_object::Write)`, generic, never `dyn` --
+//! matching that established shape rather than inventing a private
+//! object-store trait (`arch.no-object-store-trait`).
+//!
+//! `objects` is held behind a [`std::sync::Mutex`] rather than bare `O`:
+//! axum requires its `State` to be `Sync` (so it can be shared across
+//! however many worker tasks accept connections), but neither this
+//! crate's real composition-root object store nor its test fixture
+//! (`ents_testutil::ObjectStore`, used by every test in this crate) is
+//! `Sync` on its own -- the fixture's internal `RefCell` makes that
+//! concrete, but the same caution applies to any future object-store
+//! implementation this crate is handed, since nothing about
+//! `gix_object::Find`/`Write` requires an implementation to be safe for
+//! concurrent access. Serializing access behind one mutex is the right
+//! choice for this crate regardless: a web admin UI's request volume is
+//! not a throughput target `roots.adoc` names anywhere.
+
+use std::path::PathBuf;
+use std::sync::Mutex;
+
+use ents_receive::{EventSink, Mode};
+use gix_ref_store::RefStore;
+
+use crate::identity::SigningIdentity;
+use crate::session::SessionStore;
+
+/// Everything a page handler needs: the four composition-root seams, the
+/// gate policy in force, the repository's working-tree path (comment
+/// anchoring resolves paths against it), and the in-memory session store
+/// (`roots.web-session`).
+///
+/// Built once per `ents_web::serve`/`ents_web::router` call, by whichever
+/// composition root is wiring this crate in -- never constructed inside a
+/// page handler itself (`roots.config-isolation`'s spirit: every seam
+/// arrives already chosen).
+pub struct AppState<O> {
+ /// The ref store, as the same trait-object shape every mutation
+ /// primitive in this codebase already takes it.
+ pub refs: Box<dyn RefStore>,
+ /// The object store, mutex-serialized (see this module's own doc for
+ /// why). A type parameter, not `dyn`, so every existing
+ /// `propose_entity`/`comment::add`/`toolchain::import` call compiles
+ /// unchanged against a lock guard's deref.
+ objects: Mutex<O>,
+ /// The event sink obligations are enqueued to on a push
+ /// (`receive.event-sink`) -- a local deployment injects a null sink
+ /// (`roots.local`), matching `git ents`'s own CLI commands.
+ pub events: Box<dyn EventSink>,
+ /// The gate policy this deployment runs under (`roots.local`:
+ /// advisory; a future hosted `ents-web` wiring: mandatory).
+ pub mode: Mode,
+ /// The signing identity every mutation page signs on behalf of
+ /// (`roots.web-signing`, `roots.web-agnostic`).
+ pub identity: Box<dyn SigningIdentity>,
+ /// The repository's own path, for anchoring operations
+ /// (`ents_forge::comment`) that need to open the working tree
+ /// directly.
+ pub path: PathBuf,
+ /// In-memory web sessions (`roots.web-session`).
+ pub sessions: SessionStore,
+}
+
+impl<O> AppState<O> {
+ /// Build a state from already-wired seams -- the one constructor every
+ /// composition root calls, and the only place a fresh
+ /// [`SessionStore`] is created.
+ pub fn new(
+ refs: Box<dyn RefStore>,
+ objects: O,
+ events: Box<dyn EventSink>,
+ mode: Mode,
+ identity: Box<dyn SigningIdentity>,
+ path: PathBuf,
+ ) -> Self {
+ Self {
+ refs,
+ objects: Mutex::new(objects),
+ events,
+ mode,
+ identity,
+ path,
+ sessions: SessionStore::default(),
+ }
+ }
+
+ /// Lock the object store for the duration of one request.
+ ///
+ /// Poisoning recovers rather than propagating (mirrors
+ /// `SessionStore`'s identical reasoning): an earlier request
+ /// panicking mid-write already unwound that request's own response;
+ /// refusing every subsequent request forever would be strictly worse
+ /// than reusing the store as-is.
+ pub fn objects(&self) -> std::sync::MutexGuard<'_, O> {
+ self.objects
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ }
+}
crates/cli/ents-web/tests/router.rs
@@ -1,0 +1,2832 @@
+//! Integration coverage for `docs/spec/roots.adoc`'s web-frontend
+//! requirements, driven entirely through [`tower::ServiceExt::oneshot`]
+//! against [`ents_web::router`] -- no socket is ever bound anywhere in
+//! this file, which is itself part of the proof for `roots.web-agnostic`:
+//! every one of these requests is exercised the same way an in-process
+//! webview embedding would drive them.
+#![allow(clippy::expect_used, reason = "integration test")]
+#![allow(clippy::unwrap_used, reason = "integration test")]
+
+use std::sync::Arc;
+
+use axum::body::Body;
+use axum::http::{Request, StatusCode, header};
+use ents_kiln::Toolchain;
+use ents_model::{Account, Effect, MemberId, Provenance, Redaction, ResultRecord, Status};
+use ents_receive::{Mode, NullEventSink};
+use ents_testutil::{
+ CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, record_result, write_commit,
+ write_meta_entity,
+};
+use ents_web::identity::SigningIdentity;
+use ents_web::state::AppState;
+use gix::bstr::ByteSlice as _;
+use gix_object::tree::{Entry, EntryKind};
+use gix_object::{Kind, Tree, Write as _};
+use http_body_util::BodyExt as _;
+use tower::ServiceExt as _;
+
+/// A fixture [`SigningIdentity`] wrapping a deterministic test key, named
+/// so a test can tell two different injected identities apart by their
+/// commit author name alone.
+struct FixtureIdentity {
+ name: &'static str,
+ key: Keypair,
+}
+
+impl SigningIdentity for FixtureIdentity {
+ fn actor(&self) -> gix::actor::Signature {
+ gix::actor::Signature {
+ name: self.name.into(),
+ email: format!("{}@ents.test", self.name).into(),
+ time: gix::date::Time {
+ seconds: 1_000,
+ offset: 0,
+ },
+ }
+ }
+
+ fn sign(&self, payload: &[u8]) -> String {
+ self.key.sign(payload)
+ }
+
+ fn public_openssh(&self) -> String {
+ self.key.public_openssh()
+ }
+}
+
+fn build_state(identity: FixtureIdentity) -> Arc<AppState<ObjectStore>> {
+ Arc::new(AppState::new(
+ Box::new(MemRefStore::default()),
+ ObjectStore::default(),
+ Box::new(NullEventSink),
+ Mode::Advisory,
+ Box::new(identity),
+ std::env::temp_dir(),
+ ))
+}
+
+/// Like [`build_state`], but `path` names a real, on-disk repository
+/// rather than the shared system temp directory -- `crate::pages::files`
+/// opens `state.path` directly with `gix::open`, so its tests need an
+/// actual `HEAD` to browse, not just the in-memory ref/object store every
+/// other test in this file exercises.
+fn build_state_at(
+ identity: FixtureIdentity,
+ path: std::path::PathBuf,
+) -> Arc<AppState<ObjectStore>> {
+ Arc::new(AppState::new(
+ Box::new(MemRefStore::default()),
+ ObjectStore::default(),
+ Box::new(NullEventSink),
+ Mode::Advisory,
+ Box::new(identity),
+ path,
+ ))
+}
+
+/// Like [`build_state`], but `refs`/`objects` are already populated --
+/// what the toolchain-marker tests below use to seed a ref store directly
+/// with plain `gix_object` writes (a wrong-shape tree no `ents-kiln`
+/// helper would ever produce), rather than through a signed write path.
+fn build_state_with(
+ identity: FixtureIdentity,
+ refs: MemRefStore,
+ objects: ObjectStore,
+) -> Arc<AppState<ObjectStore>> {
+ Arc::new(AppState::new(
+ Box::new(refs),
+ objects,
+ Box::new(NullEventSink),
+ Mode::Advisory,
+ Box::new(identity),
+ std::env::temp_dir(),
+ ))
+}
+
+/// Land a `refs/meta/toolchains/<name>` ref pointing at a tree shaped like
+/// the pre-redo `git_toolchain::Bin` schema this repository's own
+/// `refs/meta/toolchains/{rust,sccache,zig}` still carry: a `recipe` entry
+/// that is itself a tree, not the blob today's `ents_kiln::Toolchain::recipe:
+/// String` expects -- `facet_git_tree::deserialize` reads `recipe` as a
+/// scalar (a blob) and fails with `NotABlob` on exactly this shape, the
+/// same failure `git ents serve` hits reading this repository's own real
+/// legacy toolchain refs (piece 1's bug report). Built from plain
+/// `gix_object` writes, not `ents_kiln::toolchain::import` (which only ever
+/// writes today's shape) or `write_meta_entity` (which only ever writes a
+/// value that already round-trips through `facet_git_tree`).
+fn write_legacy_toolchain(refs: &MemRefStore, objects: &ObjectStore, name: &str) {
+ let name_blob = objects
+ .write_buf(Kind::Blob, name.as_bytes())
+ .expect("write");
+ let recipe_tree = objects
+ .write(&Tree {
+ entries: Vec::new(),
+ })
+ .expect("write");
+ let mut entries = vec![
+ Entry {
+ mode: EntryKind::Blob.into(),
+ filename: "name".into(),
+ oid: name_blob,
+ },
+ Entry {
+ mode: EntryKind::Tree.into(),
+ filename: "recipe".into(),
+ oid: recipe_tree,
+ },
+ ];
+ entries.sort();
+ let tree = objects.write(&Tree { entries }).expect("write");
+ let tip = write_commit(
+ objects,
+ &CommitSpec {
+ tree,
+ parents: Vec::new(),
+ message: format!("legacy toolchain {name}"),
+ seconds: 100,
+ },
+ None,
+ );
+ let refname: gix::refs::FullName = format!("refs/meta/toolchains/{name}")
+ .try_into()
+ .expect("valid refname");
+ refs.set(refname.as_ref(), tip);
+}
+
+/// Initialize a real git repository at a fresh tempdir, seed it with
+/// `files` (path, contents), and commit them on `HEAD` -- what
+/// `crate::pages::files`'s tests below browse.
+fn seed_repo(files: &[(&str, &str)]) -> tempfile::TempDir {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let git = |args: &[&str]| {
+ let status = std::process::Command::new("git")
+ .arg("-C")
+ .arg(dir.path())
+ .args(args)
+ .status()
+ .expect("git runs");
+ assert!(status.success(), "git {args:?} failed");
+ };
+ git(&["init", "-q"]);
+ for (name, contents) in files {
+ let path = dir.path().join(name);
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).expect("mkdir -p");
+ }
+ std::fs::write(&path, contents).expect("write fixture file");
+ }
+ git(&["add", "-A"]);
+ git(&[
+ "-c",
+ "user.name=t",
+ "-c",
+ "user.email=t@example.com",
+ "commit",
+ "-q",
+ "-m",
+ "seed",
+ ]);
+ dir
+}
+
+/// The full hex object id of `dir`'s current `HEAD` commit, read via `git
+/// rev-parse` -- what `crate::pages::commits`'s tests below build
+/// `/commit/{oid}` request paths from.
+fn head_oid(dir: &std::path::Path) -> String {
+ let output = std::process::Command::new("git")
+ .arg("-C")
+ .arg(dir)
+ .args(["rev-parse", "HEAD"])
+ .output()
+ .expect("git runs");
+ assert!(output.status.success(), "git rev-parse HEAD failed");
+ String::from_utf8(output.stdout)
+ .expect("utf8 oid")
+ .trim()
+ .to_owned()
+}
+
+/// Commit a further change to a file already tracked in `dir` -- what the
+/// comment tests below use to move `HEAD` past a comment's own anchor
+/// commit, so its projection has something to react to.
+fn commit_change(dir: &std::path::Path, path: &str, contents: &str, message: &str) {
+ std::fs::write(dir.join(path), contents).expect("write fixture file");
+ let git = |args: &[&str]| {
+ let status = std::process::Command::new("git")
+ .arg("-C")
+ .arg(dir)
+ .args(args)
+ .status()
+ .expect("git runs");
+ assert!(status.success(), "git {args:?} failed");
+ };
+ git(&["add", "-A"]);
+ git(&[
+ "-c",
+ "user.name=t",
+ "-c",
+ "user.email=t@example.com",
+ "commit",
+ "-q",
+ "-m",
+ message,
+ ]);
+}
+
+/// `GET path` and return its body decoded as UTF-8, asserting a 200 --
+/// the read-back half of the many "mutate, then observe" tests below, so
+/// each does not re-spell the collect/decode dance inline.
+async fn get_body(router: &axum::Router, path: &str) -> String {
+ let response = router
+ .clone()
+ .oneshot(Request::get(path).body(Body::empty()).expect("request"))
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK, "GET {path}");
+ let bytes = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ String::from_utf8(bytes.to_vec()).expect("utf8 html")
+}
+
+/// Establish a session against `router` via a `GET` to `path`, returning
+/// its cookie header and CSRF token -- the same extraction
+/// `csrf_is_required_and_checked_on_every_state_changing_request` performs
+/// inline, factored out here since every comment test below needs one.
+async fn session_cookie_and_csrf(
+ router: &axum::Router,
+ state: &AppState<ObjectStore>,
+ path: &str,
+) -> (String, String) {
+ let response = router
+ .clone()
+ .oneshot(Request::get(path).body(Body::empty()).expect("request"))
+ .await
+ .expect("in-process call");
+ let cookie = response
+ .headers()
+ .get(header::SET_COOKIE)
+ .expect("a fresh GET always mints a session cookie")
+ .to_str()
+ .expect("ascii")
+ .to_owned();
+ let session_id = cookie
+ .split(';')
+ .next()
+ .expect("at least one segment")
+ .split_once('=')
+ .expect("name=value")
+ .1
+ .to_owned();
+ let csrf = state
+ .sessions
+ .get(&session_id)
+ .expect("the session this cookie names is held in this server's own memory")
+ .csrf;
+ (cookie, csrf)
+}
+
+/// `POST /comments`, anchoring `body` to `path` (`lines`, `<start>:<end>`)
+/// at `rev` -- what the comment tests below seed a real comment through,
+/// exercising the actual signed-write path (`ents_forge::comment::add`)
+/// rather than poking the ref store directly. Asserts the write succeeded
+/// (a redirect to the new comment's own page) and returns the new comment's
+/// id, read from that redirect's `Location` (`/comments/<id>`) -- what the
+/// thread-action tests below drive reply/resolve/reopen against.
+async fn seed_comment(
+ router: &axum::Router,
+ state: &AppState<ObjectStore>,
+ path: &str,
+ body: &str,
+ lines: &str,
+ rev: &str,
+) -> String {
+ let (cookie, csrf) = session_cookie_and_csrf(router, state, "/comments").await;
+ let form = format!(
+ "path={path}&body={}&lines={lines}&rev={rev}&csrf={csrf}",
+ body.replace(' ', "+")
+ );
+ let response = router
+ .clone()
+ .oneshot(
+ Request::post("/comments")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(form))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ response.status().is_redirection(),
+ "comment write did not succeed: {:?}",
+ response.status()
+ );
+ response
+ .headers()
+ .get(header::LOCATION)
+ .expect("a successful comment write redirects to the new comment")
+ .to_str()
+ .expect("ascii")
+ .strip_prefix("/comments/")
+ .expect("redirect targets /comments/<id>")
+ .to_owned()
+}
+
+/// `roots.local`: this crate's route table never exposes git's own
+/// smart-HTTP transport -- a request that would name it (`info/refs` with
+/// a `service` query, exactly the URL stock `git clone`/`git fetch` sends
+/// a dumb or smart HTTP backend) falls through to axum's ordinary 404,
+/// not a git wire-protocol response.
+#[tokio::test]
+// @relation(roots.local, scope=function, role=Verifies)
+async fn smart_http_transport_is_never_exposed() {
+ let state = build_state(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/info/refs?service=git-upload-pack")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+}
+
+/// `GET /style.css` is reachable with no session at all (every other route
+/// runs behind the session middleware, but that middleware only ever
+/// attaches a session -- it never gates), and serves this crate's own
+/// hand-rolled stylesheet.
+#[tokio::test]
+async fn style_css_is_served_with_no_session_required() {
+ let state = build_state(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/style.css")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(
+ response
+ .headers()
+ .get(header::CONTENT_TYPE)
+ .expect("content-type")
+ .to_str()
+ .expect("ascii"),
+ "text/css; charset=utf-8"
+ );
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 css");
+ assert!(!body.is_empty());
+ assert!(body.contains("--color-bg"));
+}
+
+/// `roots.web-agnostic`: the dashboard actually renders real content
+/// in-process, with no socket bound anywhere in this test -- reading the
+/// body back (rather than only checking the status) is what makes this
+/// test more than a routing smoke check.
+#[tokio::test]
+// @relation(roots.web-agnostic, scope=function, role=Verifies)
+async fn dashboard_renders_in_process_with_no_socket_bound() {
+ let state = build_state(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(Request::get("/").body(Body::empty()).expect("request"))
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains("Working tree"));
+ assert!(body.contains("Issues"));
+ // The shell chrome renders on every page: the icon rail, the sticky
+ // top bar, and the bar's palette search form.
+ assert!(body.contains("class=\"rail\""));
+ assert!(body.contains("class=\"wb-bar\""));
+ assert!(body.contains("class=\"palette\""));
+ assert!(body.contains("Jump to file, commit, issue, member"));
+}
+
+/// `roots.web-agnostic`: the shell's `.wb-bar` top bar names the served
+/// repository (its directory name) and, when `HEAD` resolves to a branch,
+/// renders that branch in the `.branch` pill -- both read once off
+/// `AppState.path`, so every page's chrome reflects the actual repository
+/// being served rather than a placeholder.
+#[tokio::test]
+async fn the_top_bar_names_the_served_repo_and_its_head_branch() {
+ let dir = seed_repo(&[("README.md", "# hi\n")]);
+ // `git init` picks the default branch name (which varies by host git
+ // config); rename it so the pill's text is deterministic to assert.
+ let status = std::process::Command::new("git")
+ .arg("-C")
+ .arg(dir.path())
+ .args(["branch", "-m", "trunk"])
+ .status()
+ .expect("git runs");
+ assert!(status.success(), "git branch -m failed");
+ let repo_name = dir
+ .path()
+ .file_name()
+ .expect("tempdir has a name")
+ .to_string_lossy()
+ .into_owned();
+
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(Request::get("/").body(Body::empty()).expect("request"))
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains("class=\"wb-bar\""));
+ assert!(
+ body.contains(&repo_name),
+ "the served repo's directory name {repo_name:?} must appear in the top bar"
+ );
+ assert!(
+ body.contains("class=\"branch\""),
+ "a resolvable HEAD must render the branch pill"
+ );
+ assert!(
+ body.contains("trunk"),
+ "the pill carries the short branch name"
+ );
+}
+
+/// `roots.web-agnostic`: the workbench dashboard (`GET /`) renders its
+/// four sections -- Working tree, Needs attention, Issues, History --
+/// against a real repository, with real content in each: the dirty file
+/// shows up as a working-tree row, the seeded open comment as a
+/// needs-attention row (naming its anchored path), the seeded open issue
+/// as an issue, and the `HEAD` commit in the History card with its
+/// Scoped-Commits scope chip.
+#[tokio::test]
+async fn dashboard_renders_the_four_sections_with_real_content() {
+ let dir = seed_repo(&[("src/main.rs", "line 1\nline 2\nline 3\n")]);
+ // Commit a scoped subject so the History card has a chip to parse.
+ commit_change(
+ dir.path(),
+ "src/main.rs",
+ "line 1\nline 2\nline 3\nline 4\n",
+ "model: grow main by a line",
+ );
+ let oid = head_oid(dir.path());
+ // Dirty the working tree after the commit, for the Working tree lane.
+ std::fs::write(dir.path().join("src/main.rs"), "changed\n").expect("dirty the tree");
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state.clone());
+ let comment_id =
+ seed_comment(&router, &state, "src/main.rs", "worth a look", "2:2", &oid).await;
+ seed_issue(&router, &state, "Ship the desk", "open", "", "").await;
+
+ let body = get_body(&router, "/").await;
+ for header in ["Working tree", "Needs attention", "Issues", "History"] {
+ assert!(body.contains(header), "the {header} section renders");
+ }
+ assert!(
+ body.contains("href=\"/files/src/main.rs\"") && body.contains("modified"),
+ "the dirty file lists as a working-tree change"
+ );
+ assert!(
+ body.contains(&format!("/comments/{comment_id}")) && body.contains("src/main.rs:2"),
+ "the open comment links out and names its anchored path"
+ );
+ assert!(
+ body.contains("Ship the desk"),
+ "the open issue lists on the Issues card"
+ );
+ assert!(
+ body.contains(&format!("/commit/{oid}")),
+ "the History card links the HEAD commit"
+ );
+ assert!(
+ body.contains("class=\"scope scope-c") && body.contains(">model</span>"),
+ "the scoped subject chips its scope"
+ );
+}
+
+/// `GET /` on an unborn `HEAD` (a freshly initialized, still-empty
+/// repository) still renders all four sections, each degrading to its own
+/// empty-state row rather than a placeholder commit or a 500.
+#[tokio::test]
+async fn dashboard_degrades_every_section_on_an_unborn_head() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let status = std::process::Command::new("git")
+ .arg("-C")
+ .arg(dir.path())
+ .args(["init", "-q"])
+ .status()
+ .expect("git runs");
+ assert!(status.success(), "git init failed");
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let body = get_body(&router, "/").await;
+ for header in ["Working tree", "Needs attention", "Issues", "History"] {
+ assert!(body.contains(header), "the {header} section renders");
+ }
+ assert!(!body.contains("/commit/"), "no placeholder commit links");
+ assert!(body.contains("No commits yet."));
+}
+
+/// `GET /account` states who the session is (`roots.web-signing`): with
+/// the serving identity's key enrolled, the page renders that member's
+/// own identity card (never a login or signup form -- the signing key is
+/// the identity); with no matching member, it shows the unenrolled key
+/// itself.
+#[tokio::test]
+// @relation(roots.web-signing, scope=function, role=Verifies)
+async fn account_page_names_the_signed_in_member() {
+ let key = Keypair::from_seed(1);
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ enroll_member(
+ &refs,
+ &objects,
+ "joey",
+ &key,
+ Provenance::AdminRegistered,
+ 100,
+ );
+ let state = build_state_with(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ refs,
+ objects,
+ );
+ let body = get_body(&ents_web::router(state), "/account").await;
+ assert!(
+ body.contains("Signed in as the member below"),
+ "the page states the session's identity"
+ );
+ assert!(
+ body.contains(">joey</a>"),
+ "the enrolled member's card renders"
+ );
+
+ let stranger = build_state(FixtureIdentity {
+ name: "stranger",
+ key: Keypair::from_seed(2),
+ });
+ let body = get_body(&ents_web::router(stranger), "/account").await;
+ assert!(
+ body.contains("not enrolled as a"),
+ "an unenrolled key is stated, not hidden behind a signup form"
+ );
+}
+
+/// `roots.web-session`: a state-changing request with no CSRF token at
+/// all is a bad request (axum's own `Form` rejection); one with the wrong
+/// token is refused by this crate's own check; the session cookie a `GET`
+/// mints is required to learn the right one at all.
+#[tokio::test]
+// @relation(roots.web-session, scope=function, role=Verifies)
+async fn csrf_is_required_and_checked_on_every_state_changing_request() {
+ let state = build_state(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(Arc::clone(&state));
+
+ // No CSRF field at all in the POST body.
+ let response = router
+ .clone()
+ .oneshot(
+ Request::post("/account")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .body(Body::from("member=jdc&login=jdc@ents.test"))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ !response.status().is_success(),
+ "a POST with no csrf field at all must not succeed"
+ );
+
+ // A GET establishes a session; extract its id from Set-Cookie, then
+ // read the matching CSRF token directly out of the (in-memory-only)
+ // session store this test built.
+ let get_response = router
+ .clone()
+ .oneshot(
+ Request::get("/account")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ let cookie = get_response
+ .headers()
+ .get(header::SET_COOKIE)
+ .expect("a fresh GET always mints a session cookie")
+ .to_str()
+ .expect("ascii")
+ .to_owned();
+ let session_id = cookie
+ .split(';')
+ .next()
+ .expect("at least one segment")
+ .split_once('=')
+ .expect("name=value")
+ .1
+ .to_owned();
+ let csrf = state
+ .sessions
+ .get(&session_id)
+ .expect("the session this cookie names is held in this server's own memory")
+ .csrf;
+
+ // The wrong token is refused.
+ let response = router
+ .clone()
+ .oneshot(
+ Request::post("/account")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie.clone())
+ .body(Body::from(
+ "member=jdc&login=jdc@ents.test&csrf=not-the-token",
+ ))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+
+ // The right token, carried by the same session cookie, succeeds.
+ let response = router
+ .clone()
+ .oneshot(
+ Request::post("/account")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(format!(
+ "member=jdc&login=jdc@ents.test&csrf={csrf}"
+ )))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ response.status().is_redirection(),
+ "{:?}",
+ response.status()
+ );
+}
+
+/// `roots.web-session`: a session is recognized across requests that
+/// carry its cookie (no fresh `Set-Cookie` reissued), and is held only in
+/// this server's own process memory -- a second, independently built
+/// state/router pair (standing in for a second process) never recognizes
+/// a cookie the first one minted.
+#[tokio::test]
+// @relation(roots.web-session, scope=function, role=Verifies)
+async fn a_session_is_recognized_across_requests_but_never_across_servers() {
+ let state_a = build_state(FixtureIdentity {
+ name: "a",
+ key: Keypair::from_seed(1),
+ });
+ let router_a = ents_web::router(Arc::clone(&state_a));
+
+ let first = router_a
+ .clone()
+ .oneshot(Request::get("/").body(Body::empty()).expect("request"))
+ .await
+ .expect("in-process call");
+ let cookie = first
+ .headers()
+ .get(header::SET_COOKIE)
+ .expect("first request mints a session")
+ .clone();
+
+ let second = router_a
+ .clone()
+ .oneshot(
+ Request::get("/")
+ .header(header::COOKIE, cookie.clone())
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ second.headers().get(header::SET_COOKIE).is_none(),
+ "a recognized session must not be re-minted"
+ );
+
+ // A second server (fresh in-memory session store) never recognizes
+ // the first server's cookie.
+ let state_b = build_state(FixtureIdentity {
+ name: "b",
+ key: Keypair::from_seed(2),
+ });
+ let router_b = ents_web::router(state_b);
+ let third = router_b
+ .oneshot(
+ Request::get("/")
+ .header(header::COOKIE, cookie)
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ third.headers().get(header::SET_COOKIE).is_some(),
+ "a foreign session id must be treated as absent, minting a fresh one"
+ );
+}
+
+/// `roots.web-signing`, `roots.web-agnostic`: the identical page handler,
+/// reached through the identical route, signs each request's mutation
+/// commit with whichever [`SigningIdentity`] its own composition root
+/// injected -- never a fixed or shared one. This is the crate-level proof
+/// the development plan assigns this phase; wiring an actual hosted
+/// server-key identity behind `git-ents-server` is phase 8's job (see
+/// this crate's own top-level doc).
+#[tokio::test]
+// @relation(roots.web-signing, roots.web-agnostic, scope=function, role=Verifies)
+async fn each_request_is_signed_by_its_own_injected_identity_never_a_shared_one() {
+ for (name, seed) in [("local-style", 11u8), ("hosted-style", 22u8)] {
+ let state = build_state(FixtureIdentity {
+ name,
+ key: Keypair::from_seed(seed),
+ });
+ let router = ents_web::router(Arc::clone(&state));
+
+ let get_response = router
+ .clone()
+ .oneshot(
+ Request::get("/account")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ let cookie = get_response
+ .headers()
+ .get(header::SET_COOKIE)
+ .expect("session")
+ .to_str()
+ .expect("ascii")
+ .to_owned();
+ let session_id = cookie
+ .split(';')
+ .next()
+ .expect("segment")
+ .split_once('=')
+ .expect("name=value")
+ .1
+ .to_owned();
+ let csrf = state.sessions.get(&session_id).expect("session").csrf;
+
+ let response = router
+ .oneshot(
+ Request::post("/account")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(format!(
+ "member=jdc&login=jdc@ents.test&csrf={csrf}"
+ )))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(response.status().is_redirection());
+
+ // Read the commit this request wrote back directly (this test's
+ // own retained `state` handle, not a second connection) and
+ // confirm its author is exactly this iteration's own identity.
+ let name_ref: gix::refs::FullName = ents_model::namespace::ACCOUNT_REF
+ .try_into()
+ .expect("valid");
+ let tip = state
+ .refs
+ .get(name_ref.as_ref())
+ .expect("readable")
+ .expect("account was just written");
+ let mut buf = Vec::new();
+ let objects = state.objects();
+ let data = gix_object::Find::try_find(&*objects, &tip, &mut buf)
+ .expect("read")
+ .expect("present");
+ let commit = gix_object::CommitRef::from_bytes(data.data, tip.kind()).expect("commit");
+ assert!(
+ commit.author.to_str_lossy().contains(name),
+ "commit author {:?} must carry this iteration's own identity name {name:?}",
+ commit.author.to_str_lossy()
+ );
+
+ let tree = commit.tree();
+ let account: Account = facet_git_tree::deserialize(&tree, &*objects).expect("typed tree");
+ assert_eq!(account.member, MemberId::new("jdc"));
+ }
+}
+
+/// `GET /files` lists the served repository's root directory: every
+/// top-level entry, directory or file, appears as a link.
+#[tokio::test]
+async fn files_root_lists_the_repository_root() {
+ let dir = seed_repo(&[
+ ("README.adoc", "= Welcome\n\nHello.\n"),
+ ("docs/x.md", "# Doc Title\n\nSome text.\n"),
+ ("src/main.rs", "fn main() {\n let ok = 1 < 2;\n}\n"),
+ ]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(Request::get("/files").body(Body::empty()).expect("request"))
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains("README.adoc"));
+ assert!(body.contains("docs"));
+ assert!(body.contains("src"));
+}
+
+/// `GET /files` renders the root `README` as a document card below the
+/// listing -- re-homed from the old overview dashboard, so the repository
+/// still introduces itself somewhere.
+#[tokio::test]
+async fn files_root_renders_the_readme_below_the_listing() {
+ let dir = seed_repo(&[
+ ("README.md", "# Welcome\n\nThe project overview.\n"),
+ ("src/main.rs", "fn main() {}\n"),
+ ]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let body = get_body(&router, "/files").await;
+ assert!(
+ body.contains("<h1>Welcome</h1>"),
+ "the README renders as HTML, not raw markdown"
+ );
+ let listing = body.find("href=\"/files/src\"").expect("listing renders");
+ let readme = body.find("<h1>Welcome</h1>").expect("README renders");
+ assert!(listing < readme, "the README card sits below the listing");
+}
+
+/// The master-detail splits (`crate::pages::layout_split`): a blob view
+/// renders a `.tree` sidebar with its own entry active and its siblings
+/// listed; a commit page renders the compact history sidebar with the
+/// viewed commit active; the issues page renders its list beside the
+/// composer.
+#[tokio::test]
+async fn split_pages_render_a_sidebar_with_the_current_selection_active() {
+ let dir = seed_repo(&[
+ ("src/main.rs", "fn main() {}\n"),
+ ("src/lib.rs", "pub fn f() {}\n"),
+ ("README.md", "# hi\n"),
+ ]);
+ let oid = head_oid(dir.path());
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state.clone());
+ let issue_id = seed_issue(&router, &state, "Split the panes", "open", "", "").await;
+
+ let blob = get_body(&router, "/files/src/main.rs").await;
+ assert!(blob.contains("class=\"tree\""), "the blob page splits");
+ assert!(
+ blob.contains(">main.rs</a>") && blob.contains("active"),
+ "the viewed blob's own entry renders in the sidebar"
+ );
+ assert!(
+ blob.contains("href=\"/files/src/lib.rs\""),
+ "its sibling entries render beside it"
+ );
+ assert!(
+ blob.contains("class=\"pane\""),
+ "the content sits in a pane"
+ );
+
+ let commit = get_body(&router, &format!("/commit/{oid}")).await;
+ assert!(commit.contains("class=\"tree\""), "the commit page splits");
+ assert!(
+ commit.contains(&format!("class=\"active\" href=\"/commit/{oid}\"")),
+ "the viewed commit highlights in the history sidebar"
+ );
+
+ let issues = get_body(&router, "/issues").await;
+ assert!(issues.contains("class=\"tree\""), "the issues page splits");
+ assert!(
+ issues.contains("Split the panes") && issues.contains("Open an Issue"),
+ "the list and the composer render side by side"
+ );
+ let detail = get_body(&router, &format!("/issues/{issue_id}")).await;
+ assert!(
+ detail.contains(&format!("class=\"active\" href=\"/issues/{issue_id}\"")),
+ "the viewed issue highlights in the sidebar"
+ );
+}
+
+/// `GET /files/<path>` on a plain-text blob with no recognized grammar
+/// renders a line-numbered, escaped `pre.blob-code` source view -- no
+/// syntax highlighting, and no unescaped source.
+#[tokio::test]
+async fn files_blob_view_renders_a_plain_text_file() {
+ let dir = seed_repo(&[("notes.txt", "true and 1 < 2\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/files/notes.txt")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains("blob-nums"));
+ assert!(body.contains("<td class=\"blob-code\"><code>"));
+ assert!(body.contains("1 < 2"));
+}
+
+/// `GET /files/<path>` on a `.rs` blob renders syntax-highlighted source:
+/// `arborium`'s `HtmlFormat::ClassNames` spans, matched by
+/// `crate::assets::OVERRIDES`'s `.code .keyword`-family rules.
+#[tokio::test]
+async fn files_blob_view_syntax_highlights_a_rust_file() {
+ let dir = seed_repo(&[("src/main.rs", "fn main() {\n let ok = 1 < 2;\n}\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/files/src/main.rs")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains("blob-nums"));
+ assert!(body.contains("class=\"code\""));
+ assert!(body.contains("class=\"keyword\""));
+}
+
+/// The icon rail (`crate::pages::layout_shell`) names every top-level page
+/// family truthfully: Dashboard, Code, Review, Issues, Threads, then the
+/// meta and account items -- and the issues family renders as its own rail
+/// item, never behind the `META_SECTIONS` rail (see `crate::pages::mod`'s
+/// own doc).
+#[tokio::test]
+async fn the_rail_carries_every_page_family_and_issues_left_the_meta_rail() {
+ let state = build_state(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(state);
+
+ let overview = get_body(&router, "/").await;
+ for href in [
+ "/",
+ "/files",
+ "/commits",
+ "/issues",
+ "/comments",
+ "/meta",
+ "/account",
+ ] {
+ assert!(
+ overview.contains(&format!("href=\"{href}\"")),
+ "the rail links {href}"
+ );
+ }
+ for label in ["Dashboard", "Code", "Review", "Issues", "Threads"] {
+ assert!(
+ overview.contains(&format!("title=\"{label}\"")),
+ "the rail tooltips {label}"
+ );
+ }
+
+ let issues = get_body(&router, "/issues").await;
+ assert!(
+ !issues.contains("class=\"meta-rail\""),
+ "issues renders as its own rail item, not behind the meta rail"
+ );
+
+ // The meta rail renders a bare (classless when inactive) link per
+ // section; the icon rail's own issues link always carries a `title`
+ // attribute, so this exact form only ever comes from the meta rail.
+ let members = get_body(&router, "/members").await;
+ assert!(
+ !members.contains("<a href=\"/issues\">issues</a>"),
+ "the meta rail no longer lists issues"
+ );
+}
+
+/// The rail highlights exactly the item whose page family is being viewed
+/// (`crate::pages::rail_link`'s `active` toggle): on `GET /files` the Code
+/// item carries `class="active"` and the others do not.
+#[tokio::test]
+async fn the_rail_marks_the_active_item() {
+ // A real on-disk repository: `GET /files` opens `state.path` itself.
+ let dir = seed_repo(&[("README.md", "# hi\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let files = get_body(&router, "/files").await;
+ assert!(
+ files.contains("class=\"active\" href=\"/files\""),
+ "the Code item highlights on a files page"
+ );
+ assert!(
+ files.contains("class=\"\" href=\"/commits\""),
+ "the Review item stays unhighlighted there"
+ );
+
+ let comments = get_body(&router, "/comments").await;
+ assert!(
+ comments.contains("class=\"active\" href=\"/comments\""),
+ "the Threads item highlights on the comments page"
+ );
+ assert!(
+ comments.contains("class=\"\" href=\"/files\""),
+ "the Code item stays unhighlighted there"
+ );
+}
+
+/// The `meta` group (`crate::pages::mod`'s own doc): `GET /meta` is
+/// reachable as the group's index page, and `GET /members` -- one of the
+/// five page families that group shares -- renders with the
+/// `META_SECTIONS` rail visible and the icon rail's meta item (not a
+/// per-family item) highlighted.
+#[tokio::test]
+async fn meta_index_and_a_meta_group_page_render_with_the_rail() {
+ let state = build_state(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(state);
+
+ let meta_response = router
+ .clone()
+ .oneshot(Request::get("/meta").body(Body::empty()).expect("request"))
+ .await
+ .expect("in-process call");
+ assert_eq!(meta_response.status(), StatusCode::OK);
+
+ let members_response = router
+ .oneshot(
+ Request::get("/members")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(members_response.status(), StatusCode::OK);
+ let body = members_response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(
+ body.contains("class=\"meta-rail\""),
+ "a meta-group page renders the section rail"
+ );
+ assert!(
+ body.contains("class=\"active\" href=\"/meta\""),
+ "the rail's meta item itself highlights, not a per-family item"
+ );
+}
+
+/// `GET /members` and `GET /members/{username}` render an identity card
+/// per member -- username prominent, the key type as a badge, the key
+/// material truncated through the middle with the full line behind a
+/// details toggle -- never the generic entity table an SSH key's base64
+/// body used to shred.
+#[tokio::test]
+async fn members_pages_render_an_identity_card_per_member() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let key = Keypair::from_seed(1);
+ let full_key = key.public_openssh();
+ enroll_member(
+ &refs,
+ &objects,
+ "jdc",
+ &key,
+ Provenance::AdminRegistered,
+ 100,
+ );
+ let state = build_state_with(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(2),
+ },
+ refs,
+ objects,
+ );
+ let router = ents_web::router(state);
+
+ let list = get_body(&router, "/members").await;
+ assert!(list.contains("member-card"), "the identity card renders");
+ assert!(
+ list.contains("class=\"key-badge\"") && list.contains("ssh-ed25519"),
+ "the key type badges"
+ );
+ assert!(
+ list.contains('\u{2026}'),
+ "the key material truncates through the middle"
+ );
+ assert!(
+ list.contains("full key") && list.contains(&full_key),
+ "the full key line stays one details toggle away"
+ );
+ assert!(
+ !list.contains("entity-list"),
+ "the generic table no longer renders here"
+ );
+
+ let show = get_body(&router, "/members/jdc").await;
+ assert!(show.contains("member-card"));
+ assert!(show.contains("class=\"key-badge\""));
+}
+
+/// `GET /files/<path>` renders a `.md` blob as Markdown and a `.adoc` blob
+/// as AsciiDoc -- both a real rendered heading, not the raw source markup.
+#[tokio::test]
+async fn files_blob_view_renders_markdown_and_asciidoc_as_documents() {
+ let dir = seed_repo(&[
+ ("README.adoc", "= Welcome\n\nHello.\n"),
+ ("docs/x.md", "# Doc Title\n\nSome text.\n"),
+ ]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let adoc_response = router
+ .clone()
+ .oneshot(
+ Request::get("/files/README.adoc")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(adoc_response.status(), StatusCode::OK);
+ let adoc_body = adoc_response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let adoc_body = String::from_utf8(adoc_body.to_vec()).expect("utf8 html");
+ assert!(adoc_body.contains("<h1>Welcome</h1>"));
+ assert!(!adoc_body.contains("= Welcome"));
+
+ let md_response = router
+ .oneshot(
+ Request::get("/files/docs/x.md")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(md_response.status(), StatusCode::OK);
+ let md_body = md_response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let md_body = String::from_utf8(md_body.to_vec()).expect("utf8 html");
+ assert!(md_body.contains("<h1>Doc Title</h1>"));
+}
+
+/// `GET /files/<path>` on a blob with no comments carries no comment-card
+/// markup at all -- not even an empty section (`crate::pages::comments::comments_section`'s
+/// own no-drop-but-no-empty-section contract).
+#[tokio::test]
+async fn files_blob_view_with_no_comments_has_no_comment_card_markup() {
+ let dir = seed_repo(&[("src/main.rs", "line 1\nline 2\nline 3\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/files/src/main.rs")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(!body.contains("file-comments"));
+ assert!(!body.contains("comment-meta"));
+}
+
+/// A blob view shows every comment anchored to it: author, body (rendered
+/// as AsciiDoc), and its projected line range linking into the blob's own
+/// `#L<n>` gutter.
+#[tokio::test]
+async fn files_blob_view_shows_a_seeded_comments_body_and_author() {
+ let dir = seed_repo(&[("src/main.rs", "line 1\nline 2\nline 3\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "commenter",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state.clone());
+ seed_comment(
+ &router,
+ &state,
+ "src/main.rs",
+ "worth a look here",
+ "2:2",
+ "HEAD",
+ )
+ .await;
+
+ let response = router
+ .oneshot(
+ Request::get("/files/src/main.rs")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains("id=\"comment-0\""));
+ assert!(body.contains("worth a look here"));
+ assert!(body.contains("commenter"));
+ assert!(body.contains("href=\"#L2\""));
+ assert!(!body.contains("class=\"outdated\""));
+ // Interleaved directly into the blob, after line 2's row and before
+ // line 3's -- not below the whole table.
+ let line2 = body.find("id=\"L2\"").expect("line 2 renders");
+ let card = body.find("comment-meta").expect("card renders");
+ let line3 = body.find("id=\"L3\"").expect("line 3 renders");
+ assert!(
+ line2 < card && card < line3,
+ "the card must land between line 2 and line 3, in document order"
+ );
+}
+
+/// A comment whose anchored lines were since edited still renders (never
+/// dropped), flagged with the muted `outdated` marker instead of a line
+/// link (`ents_anchor::Projection::Outdated`).
+#[tokio::test]
+async fn files_blob_view_marks_an_outdated_comment() {
+ let dir = seed_repo(&[("src/main.rs", "line 1\nline 2\nline 3\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "commenter",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state.clone());
+ seed_comment(
+ &router,
+ &state,
+ "src/main.rs",
+ "line two looks off",
+ "2:2",
+ "HEAD",
+ )
+ .await;
+ // Edit exactly the anchored line, so the projection can no longer map
+ // it -- `ents_anchor::project`'s own `Outdated` case.
+ commit_change(
+ dir.path(),
+ "src/main.rs",
+ "line 1\nsomething else entirely\nline 3\n",
+ "edit line two",
+ );
+
+ let response = router
+ .oneshot(
+ Request::get("/files/src/main.rs")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(
+ body.contains("line two looks off"),
+ "comment is never dropped"
+ );
+ assert!(body.contains("class=\"outdated\""));
+}
+
+/// `model.comment-state`, `roots.web-session`: a comment resolves and
+/// reopens through CSRF-checked, signed `POST`s -- each an
+/// `ents_forge::comment::{resolve,reopen}` call -- and its `GET
+/// /comments/{id}` page reflects the new state and offers the opposite
+/// action each time. The wrong CSRF token is refused, exactly as every
+/// other state-changing route in this crate refuses one.
+#[tokio::test]
+// @relation(model.comment-state, roots.web-signing, roots.web-session, scope=function, role=Verifies)
+async fn a_comment_resolves_and_reopens_through_csrf_checked_posts() {
+ let dir = seed_repo(&[("src/main.rs", "line 1\nline 2\nline 3\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "commenter",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state.clone());
+ let id = seed_comment(&router, &state, "src/main.rs", "look here", "2:2", "HEAD").await;
+ let page = format!("/comments/{id}");
+ let (cookie, csrf) = session_cookie_and_csrf(&router, &state, &page).await;
+
+ // A fresh comment lists open and offers "resolve".
+ let body = get_body(&router, &page).await;
+ assert!(body.contains("resolve"), "an open comment offers resolve");
+
+ // The wrong token is refused.
+ let refused = router
+ .clone()
+ .oneshot(
+ Request::post(format!("/comments/{id}/resolve"))
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie.clone())
+ .body(Body::from("csrf=not-the-token"))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(refused.status(), StatusCode::BAD_REQUEST);
+
+ // The right token resolves it.
+ let resolved = router
+ .clone()
+ .oneshot(
+ Request::post(format!("/comments/{id}/resolve"))
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie.clone())
+ .body(Body::from(format!("csrf={csrf}")))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(resolved.status().is_redirection());
+ let body = get_body(&router, &page).await;
+ assert!(body.contains("resolved"), "the comment now reads resolved");
+ assert!(body.contains("reopen"), "a resolved comment offers reopen");
+
+ // Reopen returns it to open.
+ let reopened = router
+ .clone()
+ .oneshot(
+ Request::post(format!("/comments/{id}/reopen"))
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(format!("csrf={csrf}")))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(reopened.status().is_redirection());
+ let body = get_body(&router, &page).await;
+ assert!(
+ body.contains(">open<") || body.contains("resolve"),
+ "the comment offers resolve again once reopened"
+ );
+}
+
+/// `model.comment-thread`: a reply through `POST /comments/{id}/reply` is a
+/// second comment (`ents_forge::comment::reply`) -- after it lands, the
+/// comment index lists two comments where the seed left one.
+#[tokio::test]
+// @relation(model.comment-thread, roots.web-signing, roots.web-session, scope=function, role=Verifies)
+async fn a_reply_creates_a_threaded_comment_through_a_signed_post() {
+ let dir = seed_repo(&[("src/main.rs", "line 1\nline 2\nline 3\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "commenter",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state.clone());
+ let id = seed_comment(&router, &state, "src/main.rs", "the parent", "2:2", "HEAD").await;
+ let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/comments").await;
+
+ let reply = router
+ .clone()
+ .oneshot(
+ Request::post(format!("/comments/{id}/reply"))
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(format!("body=a+reply+here&csrf={csrf}")))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(reply.status().is_redirection(), "{:?}", reply.status());
+
+ let body = get_body(&router, "/comments").await;
+ let count = body.matches("/comments/").count();
+ assert!(
+ count >= 2,
+ "the index lists the parent and its reply, got {count} links"
+ );
+ assert!(body.contains("a reply here"), "the reply's body renders");
+}
+
+/// A raw-source blob view carries the client-side hooks `assets/ents.js`
+/// needs: `div.blob`'s own `data-path`/`data-rev` (the latter a full
+/// 40-hex `HEAD` commit oid, not the string `"HEAD"`), and a
+/// `<template id="composer-template">` whose form carries a csrf input and
+/// hidden `path`/`rev` inputs pre-filled with this exact file and commit.
+#[tokio::test]
+async fn files_blob_view_carries_data_path_data_rev_and_the_composer_template() {
+ let dir = seed_repo(&[("src/main.rs", "fn main() {}\n")]);
+ let oid = head_oid(dir.path());
+ assert_eq!(oid.len(), 40, "a full sha1 hex oid is 40 characters");
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/files/src/main.rs")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains("data-path=\"src/main.rs\""));
+ assert!(body.contains(&format!("data-rev=\"{oid}\"")));
+ let template_start = body
+ .find("id=\"composer-template\"")
+ .expect("composer template renders");
+ let template = body.get(template_start..).expect("template slice");
+ assert!(
+ template.contains("name=\"csrf\""),
+ "the composer's own form carries a csrf input"
+ );
+ assert!(template.contains(r#"name="path" value="src/main.rs""#));
+ assert!(template.contains(&format!(r#"name="rev" value="{oid}""#)));
+}
+
+/// `GET /ents.js` serves the client-side script `crate::pages::layout`
+/// loads with `defer` -- no session required (mirrors `GET /style.css`'s
+/// own stance), a JS content type, and a non-empty body.
+#[tokio::test]
+async fn ents_js_is_served_with_a_javascript_content_type() {
+ let state = build_state(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/ents.js")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let content_type = response
+ .headers()
+ .get(header::CONTENT_TYPE)
+ .expect("content-type")
+ .to_str()
+ .expect("ascii")
+ .to_owned();
+ assert!(content_type.contains("javascript"));
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ assert!(!body.is_empty());
+}
+
+/// The blob header bar (`crate::pages::files::blob_header`) shows a raw
+/// source file's line count, human-formatted size, and detected language,
+/// plus the "comment on this file" no-JS fallback link -- moved here from
+/// `crumbs`'s own trailing edge.
+#[tokio::test]
+async fn files_blob_header_shows_line_count_size_language_and_the_comment_link() {
+ let dir = seed_repo(&[("src/main.rs", "fn main() {\n let ok = 1;\n}\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/files/src/main.rs")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains("blob-header"));
+ assert!(body.contains("3 lines"));
+ assert!(body.contains("rust"));
+ assert!(body.contains("comment on this file"));
+}
+
+/// `GET /files` (a directory listing): a file entry carries a
+/// human-formatted size (`span.entry-size`), rendered after the
+/// directory-first entries, which carry none.
+#[tokio::test]
+async fn files_root_listing_shows_a_size_for_a_file_but_not_a_directory() {
+ let dir = seed_repo(&[("README.md", "# hi\n"), ("src/main.rs", "fn main() {}\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(Request::get("/files").body(Body::empty()).expect("request"))
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ let src_index = body
+ .find("/files/src\"")
+ .expect("the src directory links in");
+ let size_index = body.find("entry-size").expect("a size span renders");
+ assert!(
+ src_index < size_index,
+ "the directory row (sorted first, no size cell) renders before the file row's own size"
+ );
+}
+
+/// A doc-rendered (Markdown) blob view carries no composer template at
+/// all: there is no source line row for `assets/ents.js` to anchor an
+/// inline composer after, so `ents_web::pages::files::blob_view` never
+/// renders one for this view kind.
+#[tokio::test]
+async fn files_markdown_blob_view_has_no_composer_template() {
+ let dir = seed_repo(&[("docs/x.md", "# Doc Title\n\nSome text.\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/files/docs/x.md")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(
+ !body.contains("composer-template"),
+ "a doc-rendered view has no source line to anchor a composer to"
+ );
+}
+
+/// `GET /comments?file=<path>&lines=<range>` pre-fills the add-comment
+/// form's `path`/`lines` fields -- the entry point `crate::pages::files`'s
+/// own "comment on this file" link uses.
+#[tokio::test]
+async fn comments_list_prefills_the_add_form_from_query_params() {
+ let state = build_state(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/comments?file=src/main.rs&lines=1-2")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains(r#"name="path" value="src/main.rs""#));
+ assert!(body.contains(r#"name="lines" value="1-2""#));
+ assert!(
+ body.contains(r#"name="rev" value="HEAD""#),
+ "rev defaults to HEAD when absent, exactly as before"
+ );
+}
+
+/// `GET /comments?rev=<oid>` (the link `crate::pages::commits::show`'s
+/// "comment on this commit" renders) carries the given rev through into
+/// the add form, rather than defaulting to `HEAD`.
+#[tokio::test]
+async fn comments_list_prefills_rev_from_the_query_param() {
+ let state = build_state(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/comments?rev=deadbeef")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains(r#"name="rev" value="deadbeef""#));
+}
+
+/// `GET /commits` lists the repository's commit history: the seeded
+/// commit's own short id appears, linking into `/commit/{oid}`.
+#[tokio::test]
+async fn commits_list_shows_a_fixture_commit() {
+ let dir = seed_repo(&[("README.md", "# hi\n")]);
+ let oid = head_oid(dir.path());
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/commits")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains(&format!("/commit/{oid}")));
+ assert!(body.contains("seed"), "the seeded commit's subject renders");
+}
+
+/// `GET /commit/{oid}` shows the commit's subject, its author, and a
+/// colorized diff line for the file it introduced.
+#[tokio::test]
+async fn commit_show_renders_the_subject_and_a_diff_line() {
+ let dir = seed_repo(&[("README.md", "# hi\n")]);
+ let oid = head_oid(dir.path());
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get(format!("/commit/{oid}"))
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains("seed"), "the commit's subject renders");
+ assert!(
+ body.contains("class=\"ln add\""),
+ "the root commit's diff renders its added lines"
+ );
+}
+
+/// `GET /commit/{oid}` renders one `.file` header per changed blob and
+/// none for the intermediate directories the tree walk also names --
+/// each subdirectory used to appear as its own bare file section.
+#[tokio::test]
+async fn commit_diff_lists_files_not_intermediate_directories() {
+ let dir = seed_repo(&[("crates/foo/src/lib.rs", "pub fn f() {}\n")]);
+ let oid = head_oid(dir.path());
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get(format!("/commit/{oid}"))
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert_eq!(
+ body.matches("class=\"ln file\"").count(),
+ 1,
+ "one changed blob means exactly one file header"
+ );
+}
+
+/// `GET /commit/{oid}` lists, under a "conversation" heading, every
+/// comment whose anchor was captured against that exact commit -- and
+/// none captured against a different one, even a later commit on the same
+/// branch. The "comment on this commit" link prefills `rev` to the shown
+/// commit's own oid.
+#[tokio::test]
+async fn commit_show_lists_comments_captured_against_that_exact_commit() {
+ let dir = seed_repo(&[("src/main.rs", "line 1\nline 2\nline 3\n")]);
+ let first_oid = head_oid(dir.path());
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "commenter",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state.clone());
+ seed_comment(
+ &router,
+ &state,
+ "src/main.rs",
+ "left at the first commit",
+ "2:2",
+ &first_oid,
+ )
+ .await;
+
+ commit_change(
+ dir.path(),
+ "src/main.rs",
+ "line 1\nline two\nline 3\n",
+ "second commit",
+ );
+ let second_oid = head_oid(dir.path());
+ assert_ne!(first_oid, second_oid);
+
+ let first_response = router
+ .clone()
+ .oneshot(
+ Request::get(format!("/commit/{first_oid}"))
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(first_response.status(), StatusCode::OK);
+ let first_body = String::from_utf8(
+ first_response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes()
+ .to_vec(),
+ )
+ .expect("utf8 html");
+ assert!(first_body.contains("Conversation"));
+ assert!(first_body.contains("left at the first commit"));
+ assert!(first_body.contains("commenter"));
+ assert!(
+ first_body.contains("href=\"/files/src/main.rs#L2\""),
+ "the conversation card links path#lines into the file browser: {first_body}"
+ );
+ assert!(
+ first_body.contains(&format!("href=\"/comments?rev={first_oid}\"")),
+ "the comment-on-this-commit link prefills this commit's own oid: {first_body}"
+ );
+
+ let second_response = router
+ .oneshot(
+ Request::get(format!("/commit/{second_oid}"))
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(second_response.status(), StatusCode::OK);
+ let second_body = String::from_utf8(
+ second_response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes()
+ .to_vec(),
+ )
+ .expect("utf8 html");
+ assert!(
+ !second_body.contains("left at the first commit"),
+ "a comment captured against the first commit must not appear on the second: {second_body}"
+ );
+}
+
+/// `model.review`, `model.comment-context`: starting a review on a commit
+/// page (`POST /commit/{oid}/review`, `ents_forge::review::new`) makes its
+/// verdict, body, and reviewer render on that commit's page, and a comment
+/// on the review (`POST /reviews/{id}/comment`) joins the review's own
+/// discussion thread -- every step a CSRF-checked signed POST through the
+/// injected identity.
+#[tokio::test]
+// @relation(model.review, model.review-pin, model.comment-context, roots.web-signing, roots.web-session, scope=function, role=Verifies)
+async fn commit_page_shows_a_seeded_review_verdict_and_a_review_comment() {
+ let dir = seed_repo(&[("src/main.rs", "fn main() {}\n")]);
+ let oid = head_oid(dir.path());
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "reviewer",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state.clone());
+
+ // Start a review of this commit through the commit page's own form.
+ let (cookie, csrf) = session_cookie_and_csrf(&router, &state, &format!("/commit/{oid}")).await;
+ let started = router
+ .clone()
+ .oneshot(
+ Request::post(format!("/commit/{oid}/review"))
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie.clone())
+ .body(Body::from(format!(
+ "verdict=request-changes&body=needs+a+test&csrf={csrf}"
+ )))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(started.status().is_redirection(), "{:?}", started.status());
+
+ // The commit page renders the review's verdict, body, and reviewer.
+ let page = get_body(&router, &format!("/commit/{oid}")).await;
+ assert!(page.contains("Reviews"), "the reviews section renders");
+ assert!(
+ page.contains("class=\"verdict\""),
+ "the verdict renders prominently"
+ );
+ assert!(page.contains("request-changes"), "the verdict text renders");
+ assert!(page.contains("needs a test"), "the review body renders");
+ assert!(
+ page.contains("reviewer"),
+ "the reviewer's own identity (from the commit chain) renders"
+ );
+
+ // Recover the review id from its comment form's action, then comment on
+ // the review; the comment joins the review's thread on the same page.
+ let review_id = page
+ .split_once("/reviews/")
+ .and_then(|(_, rest)| rest.split_once("/comment"))
+ .map(|(id, _)| id)
+ .expect("a review comment form links in");
+
+ let commented = router
+ .clone()
+ .oneshot(
+ Request::post(format!("/reviews/{review_id}/comment"))
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(format!(
+ "body=agreed+on+the+test&return_to=/commit/{oid}&csrf={csrf}"
+ )))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ commented.status().is_redirection(),
+ "{:?}",
+ commented.status()
+ );
+ let page = get_body(&router, &format!("/commit/{oid}")).await;
+ assert!(
+ page.contains("agreed on the test"),
+ "the review comment renders in the review's thread: {page}"
+ );
+}
+
+/// The commit page's Checks card (`model.result-identity`,
+/// `model.result-taxonomy`): a recorded result whose stored target names
+/// the shown commit renders as a status chip and its effect's link --
+/// one row per taxonomy value here -- a self-run mirror row additionally
+/// names its member, and a result targeting a different commit stays off
+/// the page (the tree's own `target` field is what is matched, not the
+/// refname).
+#[tokio::test]
+async fn commit_page_lists_recorded_results_as_checks() {
+ let dir = seed_repo(&[("src/main.rs", "fn main() {}\n")]);
+ let oid = head_oid(dir.path());
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ record_result(&refs, &objects, "unit", &oid, Status::Pass, None, 1_000);
+ record_result(&refs, &objects, "lint", &oid, Status::Fail, None, 1_001);
+ record_result(&refs, &objects, "deploy", &oid, Status::Error, None, 1_002);
+ // Same effect, different target commit: filtered out by the stored
+ // target field.
+ record_result(
+ &refs,
+ &objects,
+ "unit",
+ "aaaaaaaa",
+ Status::Pass,
+ None,
+ 1_003,
+ );
+ // A self-run mirror row names the member that ran it.
+ let member = MemberId::new("joey");
+ let target = gix::ObjectId::from_hex(oid.as_bytes()).expect("head oid is hex");
+ let self_ref = ents_model::namespace::self_result_ref(&member, "unit", &oid).expect("valid");
+ let mirror = ResultRecord::new("unit", target, Status::Pass);
+ write_meta_entity(&refs, &objects, self_ref, &mirror, None, 1_004);
+
+ let state = Arc::new(AppState::new(
+ Box::new(refs),
+ objects,
+ Box::new(NullEventSink),
+ Mode::Advisory,
+ Box::new(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ }),
+ dir.path().to_owned(),
+ ));
+ let router = ents_web::router(state);
+
+ let body = get_body(&router, &format!("/commit/{oid}")).await;
+ assert!(body.contains("Checks"), "the Checks card renders");
+ for (chip, effect) in [
+ ("status-pass", "unit"),
+ ("status-fail", "lint"),
+ ("status-error", "deploy"),
+ ] {
+ assert!(
+ body.contains(chip),
+ "the {effect} row carries its {chip} chip"
+ );
+ assert!(
+ body.contains(&format!("/effects/{effect}")),
+ "the {effect} row links to its effect page"
+ );
+ }
+ assert!(
+ body.contains("self-run by joey"),
+ "the mirror row names its member"
+ );
+ // The chip class appears once in the canonical unit row and once in
+ // the self-run mirror row -- never for the other commit's result.
+ assert_eq!(
+ body.matches("status-pass").count(),
+ 2,
+ "the other commit's result stays off the page"
+ );
+}
+
+/// `GET /commit/{oid}` on a malformed id is a 404, never a panic or 500.
+#[tokio::test]
+async fn commit_show_on_an_invalid_oid_is_not_found_not_a_crash() {
+ let state = build_state(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/commit/zzz")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+}
+
+/// `POST /issues` through the real signed-write path
+/// (`ents_forge::issue::new`), returning the new issue's id from the
+/// redirect's `Location` (`/issues/<id>`). Asserts the write succeeded.
+async fn seed_issue(
+ router: &axum::Router,
+ state: &AppState<ObjectStore>,
+ title: &str,
+ issue_state: &str,
+ assignees: &str,
+ labels: &str,
+) -> String {
+ let (cookie, csrf) = session_cookie_and_csrf(router, state, "/issues").await;
+ let form = format!(
+ "title={}&state={issue_state}&assignees={assignees}&labels={labels}&body=the+full+body&csrf={csrf}",
+ title.replace(' ', "+")
+ );
+ let response = router
+ .clone()
+ .oneshot(
+ Request::post("/issues")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(form))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ response.status().is_redirection(),
+ "issue create did not succeed: {:?}",
+ response.status()
+ );
+ response
+ .headers()
+ .get(header::LOCATION)
+ .expect("a successful issue create redirects to the new issue")
+ .to_str()
+ .expect("ascii")
+ .strip_prefix("/issues/")
+ .expect("redirect targets /issues/<id>")
+ .to_owned()
+}
+
+/// `model.issue`, `model.comment-context`: the issues index lists a seeded
+/// issue with its state, assignees, and labels; the detail page shows the
+/// issue and its discussion thread; a comment naming `issues/<id>` as its
+/// context joins that thread; and an edit changes the issue's state -- every
+/// mutation a CSRF-checked signed POST calling the same `ents_forge` funcs
+/// the CLI and lens do.
+#[tokio::test]
+// @relation(model.issue, model.comment-context, roots.web-signing, roots.web-session, scope=function, role=Verifies)
+async fn issues_index_and_detail_render_a_seeded_issue_and_its_context_comment() {
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(state.clone());
+ let id = seed_issue(
+ &router,
+ &state,
+ "gate rejects a valid signature",
+ "triaged",
+ "jdc",
+ "bug",
+ )
+ .await;
+
+ // The index lists the issue with its state, assignees, and labels, and
+ // links into its detail page.
+ let index = get_body(&router, "/issues").await;
+ assert!(index.contains("gate rejects a valid signature"));
+ assert!(index.contains("triaged"));
+ assert!(index.contains("jdc"));
+ assert!(index.contains("bug"));
+ assert!(index.contains(&format!("/issues/{id}")));
+
+ // The detail page shows the issue and an (initially empty) discussion.
+ let detail = get_body(&router, &format!("/issues/{id}")).await;
+ assert!(detail.contains("gate rejects a valid signature"));
+ assert!(detail.contains("the full body"));
+ assert!(detail.contains("Discussion"));
+
+ // A comment naming the issue as its context joins the thread.
+ let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/issues").await;
+ let comment = router
+ .clone()
+ .oneshot(
+ Request::post(format!("/issues/{id}/comment"))
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie.clone())
+ .body(Body::from(format!("body=cannot+reproduce+yet&csrf={csrf}")))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(comment.status().is_redirection(), "{:?}", comment.status());
+ let detail = get_body(&router, &format!("/issues/{id}")).await;
+ assert!(
+ detail.contains("cannot reproduce yet"),
+ "the context comment renders in the issue's thread: {detail}"
+ );
+
+ // Editing the issue's state lands and reads back.
+ let edited = router
+ .clone()
+ .oneshot(
+ Request::post(format!("/issues/{id}"))
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(format!("state=closed&csrf={csrf}")))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(edited.status().is_redirection());
+ let detail = get_body(&router, &format!("/issues/{id}")).await;
+ assert!(detail.contains("closed"), "the edited state reads back");
+}
+
+/// `roots.web-session`: opening an issue is a state-changing route, so a
+/// `POST /issues` with no CSRF field at all is rejected, and one with the
+/// wrong token is a bad request -- the same gate every mutation in this
+/// crate runs behind.
+#[tokio::test]
+// @relation(model.issue, roots.web-session, scope=function, role=Verifies)
+async fn issue_create_is_rejected_without_a_valid_csrf_token() {
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(Arc::clone(&state));
+
+ // No CSRF field at all.
+ let no_csrf = router
+ .clone()
+ .oneshot(
+ Request::post("/issues")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .body(Body::from("title=sneaky&state=open"))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ !no_csrf.status().is_success() && !no_csrf.status().is_redirection(),
+ "a POST with no csrf field must not open an issue"
+ );
+
+ // A session's cookie, but the wrong token.
+ let (cookie, _csrf) = session_cookie_and_csrf(&router, &state, "/issues").await;
+ let wrong = router
+ .oneshot(
+ Request::post("/issues")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from("title=sneaky&state=open&csrf=not-the-token"))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(wrong.status(), StatusCode::BAD_REQUEST);
+}
+
+/// `GET /search?q=` finds a known fixture file path, linking into its
+/// `/files/...` blob view.
+#[tokio::test]
+async fn search_finds_a_known_fixture_file_path() {
+ let dir = seed_repo(&[("src/needle.rs", "fn main() {}\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/search?q=needle")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains("/files/src/needle.rs"));
+}
+
+/// `GET /search` with no query renders a "type to search" blankslate
+/// naming the header's own search input -- not a "no matches" one, since
+/// nothing was searched yet -- rather than an empty or error page.
+#[tokio::test]
+async fn search_with_no_query_renders_a_blankslate() {
+ let state = build_state(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/search")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains("Type to search"));
+ assert!(
+ body.contains("Jump to file or symbol"),
+ "the prompt names the header's own search input"
+ );
+}
+
+/// `GET /comments` surfaces a comment ref written by an older schema
+/// through the shared unreadable disclosure instead of silently dropping
+/// it, and its own `GET /comments/{id}` page renders the plain unreadable
+/// marker card rather than erroring.
+#[tokio::test]
+async fn comments_surface_an_unreadable_ref_in_the_list_and_on_its_own_page() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let tip = write_commit(
+ &objects,
+ &CommitSpec {
+ tree: ents_testutil::empty_tree(&objects),
+ parents: Vec::new(),
+ message: "legacy comment".to_owned(),
+ seconds: 100,
+ },
+ None,
+ );
+ let refname: gix::refs::FullName = "refs/meta/comments/legacy"
+ .try_into()
+ .expect("valid refname");
+ refs.set(refname.as_ref(), tip);
+
+ let state = build_state_with(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ refs,
+ objects,
+ );
+ let router = ents_web::router(state);
+
+ let list = get_body(&router, "/comments").await;
+ assert!(
+ list.contains("unreadable-note") && list.contains("1 unreadable"),
+ "the list page carries the subtle disclosure: {list}"
+ );
+ assert!(
+ list.contains("refs/meta/comments/legacy"),
+ "the disclosure names the failed ref"
+ );
+
+ let detail = get_body(&router, "/comments/legacy").await;
+ assert!(
+ detail.contains("unreadable"),
+ "the detail page shows the error state plainly instead of erroring: {detail}"
+ );
+}
+
+/// `POST /effects` defines an effect as a signed mutation on
+/// `refs/meta/effects/<name>` and redirects to its show page; the list
+/// page then names it -- the web counterpart of `git ents effect add`.
+#[tokio::test]
+async fn effect_form_defines_an_effect() {
+ let dir = seed_repo(&[("README.md", "# hi\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state.clone());
+ let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/effects").await;
+
+ let form = format!(
+ "name=unit&trigger=rev(refs/heads/main)&run=cargo+nextest+run&toolchains=rust&csrf={csrf}"
+ );
+ let response = router
+ .clone()
+ .oneshot(
+ Request::post("/effects")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(form))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ response.status().is_redirection(),
+ "effect write did not succeed: {:?}",
+ response.status()
+ );
+
+ let list = get_body(&router, "/effects").await;
+ assert!(list.contains("unit"), "the new effect lists: {list}");
+ let show = get_body(&router, "/effects/unit").await;
+ assert!(
+ show.contains("rev(refs/heads/main)") && show.contains("parses"),
+ "the show page renders the trigger and its parse check: {show}"
+ );
+}
+
+/// `POST /toolchains` records a toolchain from a recipe given as text
+/// (`ents_kiln::toolchain::register`) and redirects to its show page --
+/// the recipe-flow counterpart of `git ents toolchain import`.
+#[tokio::test]
+async fn toolchain_form_registers_a_recipe() {
+ let dir = seed_repo(&[("README.md", "# hi\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state.clone());
+ let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/toolchains").await;
+
+ let form =
+ format!("name=empty&recipe=embedded+4b825dc642cb6eb9a060e54bf8d69288fbee4904&csrf={csrf}");
+ let response = router
+ .clone()
+ .oneshot(
+ Request::post("/toolchains")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(form))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ response.status().is_redirection(),
+ "toolchain write did not succeed: {:?}",
+ response.status()
+ );
+
+ let list = get_body(&router, "/toolchains").await;
+ assert!(list.contains("empty"), "the new toolchain lists: {list}");
+ let show = get_body(&router, "/toolchains/empty").await;
+ assert!(
+ show.contains("Embedded"),
+ "the show page renders the recorded recipe: {show}"
+ );
+}
+
+/// The issues page carries a `datalist#members` of enrolled usernames so
+/// the assignees field completes by member id in place.
+#[tokio::test]
+async fn issue_forms_carry_a_members_datalist() {
+ let dir = seed_repo(&[("README.md", "# hi\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+ let body = get_body(&router, "/issues").await;
+ assert!(
+ body.contains("datalist id=\"members\""),
+ "the assignees field has a members datalist to complete from: {body}"
+ );
+}
+
+/// A show page for an id with no ref at all is a real 404, not a 500 --
+/// `ents_forge::Error::NotFound` keeps its status through the `Forge`
+/// box (the box exists for variant-size hygiene only).
+#[tokio::test]
+async fn missing_forge_entity_is_a_404_not_a_500() {
+ let dir = seed_repo(&[("README.md", "# hi\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state);
+ for path in ["/issues/nope", "/comments/nope"] {
+ let response = router
+ .clone()
+ .oneshot(Request::get(path).body(Body::empty()).expect("request"))
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::NOT_FOUND, "GET {path}");
+ }
+}
+
+/// `GET /toolchains` surfaces a toolchain written by an older schema
+/// (piece 1's bug: this repository's own
+/// `refs/meta/toolchains/{rust,sccache,zig}` still carry it) through the
+/// shared unreadable disclosure, never a 500 -- and a good toolchain
+/// alongside it still lists and links normally.
+#[tokio::test]
+async fn toolchains_list_marks_a_legacy_entry_but_still_lists_a_good_one() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let name: gix::refs::FullName = "refs/meta/toolchains/good".try_into().expect("valid");
+ write_meta_entity(
+ &refs,
+ &objects,
+ name,
+ &Toolchain {
+ name: "good".to_owned(),
+ recipe: "embedded 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n".to_owned(),
+ },
+ None,
+ 100,
+ );
+ write_legacy_toolchain(&refs, &objects, "legacy");
+
+ let state = build_state_with(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ refs,
+ objects,
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/toolchains")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains(r#"href="/toolchains/good""#));
+ assert!(body.contains(r#"href="/toolchains/legacy""#));
+ assert!(
+ body.contains("unreadable-note") && body.contains("1 unreadable"),
+ "the legacy entry surfaces through the shared disclosure: {body}"
+ );
+ assert!(
+ body.contains("refs/meta/toolchains/legacy"),
+ "the disclosure names the failed ref"
+ );
+}
+
+/// `GET /toolchains/{name}` on a legacy-schema entry renders the marker
+/// card (with the underlying error) rather than a 500.
+#[tokio::test]
+async fn toolchain_show_on_a_legacy_entry_renders_a_marker_not_a_500() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ write_legacy_toolchain(&refs, &objects, "legacy");
+
+ let state = build_state_with(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ refs,
+ objects,
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/toolchains/legacy")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains("unreadable"));
+ assert!(
+ body.contains("is not a blob"),
+ "the underlying facet-git-tree error renders verbatim: {body}"
+ );
+}
+
+/// A real, readable entity (not merely an empty ref store) exercised on
+/// every list/show page pair `read_all`'s `state.objects()` double-lock
+/// regression could hit: each page must complete rather than hang forever
+/// (a non-reentrant `Mutex` self-deadlock, previously reachable whenever a
+/// row's tree actually read back cleanly -- see the fix commit's own
+/// message). `#[tokio::test]`'s single-threaded runtime means a real
+/// deadlock here hangs the whole test binary rather than merely failing
+/// it, so this is worth pinning down explicitly rather than trusting the
+/// list/show pages' other tests to happen to seed data.
+#[tokio::test]
+async fn members_effects_redactions_and_toolchains_list_and_show_a_real_entity_without_hanging() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ enroll_member(
+ &refs,
+ &objects,
+ "jdc",
+ &Keypair::from_seed(1),
+ Provenance::AdminRegistered,
+ 100,
+ );
+ let effect_name: gix::refs::FullName = "refs/meta/effects/ci".try_into().expect("valid");
+ write_meta_entity(
+ &refs,
+ &objects,
+ effect_name,
+ &Effect {
+ name: "ci".to_owned(),
+ trigger: "rev(refs/heads/main)".to_owned(),
+ toolchains: vec![],
+ run: "true".to_owned(),
+ },
+ None,
+ 100,
+ );
+ let redaction_name: gix::refs::FullName = "refs/meta/redactions/1".try_into().expect("valid");
+ write_meta_entity(
+ &refs,
+ &objects,
+ redaction_name,
+ &Redaction::new(gix_hash::ObjectId::null(gix_hash::Kind::Sha1), "leaked"),
+ None,
+ 100,
+ );
+ let toolchain_name: gix::refs::FullName =
+ "refs/meta/toolchains/good".try_into().expect("valid");
+ write_meta_entity(
+ &refs,
+ &objects,
+ toolchain_name,
+ &Toolchain {
+ name: "good".to_owned(),
+ recipe: "embedded 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n".to_owned(),
+ },
+ None,
+ 100,
+ );
+
+ let state = build_state_with(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(2),
+ },
+ refs,
+ objects,
+ );
+ let router = ents_web::router(state);
+
+ for path in [
+ "/members",
+ "/members/jdc",
+ "/effects",
+ "/effects/ci",
+ "/redactions",
+ "/redactions/1",
+ "/toolchains",
+ "/toolchains/good",
+ ] {
+ let response = router
+ .clone()
+ .oneshot(Request::get(path).body(Body::empty()).expect("request"))
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK, "GET {path}");
+ }
+}
crates/cli/git-ents/Cargo.toml
@@ -1,0 +1,47 @@
+[package]
+name = "git-ents"
+version = "0.0.0"
+edition.workspace = true
+publish.workspace = true
+license.workspace = true
+
+[[bin]]
+name = "git-ents"
+path = "src/main.rs"
+
+[dependencies]
+ents-anchor = { workspace = true }
+ents-effect = { workspace = true, features = ["docker", "sprite"] }
+ents-forge = { workspace = true }
+ents-gate = { workspace = true }
+ents-kiln = { workspace = true }
+ents-lens = { workspace = true }
+ents-model = { workspace = true }
+ents-query = { workspace = true }
+ents-receive = { workspace = true }
+ents-sync = { workspace = true }
+ents-web = { workspace = true }
+facet = { workspace = true }
+facet-git-tree = { workspace = true }
+facet-pretty = { workspace = true }
+figue = { workspace = true }
+gix = { workspace = true }
+gix-hash = { workspace = true }
+gix-object = { workspace = true }
+gix-ref-store = { workspace = true }
+gix-odb = { workspace = true }
+rand_core = { version = "0.6", features = ["getrandom"] }
+ssh-key = { version = "0.6", features = ["ed25519"] }
+tempfile = { workspace = true }
+thiserror = { workspace = true }
+tokio = { workspace = true }
+
+[dev-dependencies]
+axum = { workspace = true }
+cargo_metadata = { workspace = true }
+ents-testutil = { workspace = true }
+rstest = { workspace = true }
+tower = { version = "0.5", default-features = false, features = ["util"] }
+
+[lints]
+workspace = true
crates/cli/git-ents/src/cli.rs
@@ -1,0 +1,365 @@
+//! `git ents`'s argument grammar — `figue` derive definitions only.
+//!
+//! Per this project's engineering conventions, this module carries no
+//! logic: every doc comment here becomes `--help` text, and
+//! [`crate::exe`] is the only place a [`Top`] variant is interpreted.
+
+use std::path::PathBuf;
+
+use facet::Facet;
+use figue::{self as args, FigueBuiltins};
+
+pub use ents_forge::comment::CommentAction;
+pub use ents_forge::issue::IssueAction;
+pub use ents_forge::review::ReviewAction;
+pub use ents_kiln::toolchain::ToolchainAction;
+
+/// Local root wiring, subcommand surface, and the single-node hosted
+/// root's git-hook plumbing (`docs/development-plan.adoc`, phase 6).
+#[derive(Facet)]
+pub struct Cli {
+ /// The subcommand to run.
+ #[facet(args::subcommand)]
+ pub command: Top,
+ /// `--help`/`--version`/`--completions` wiring `figue` provides for
+ /// every CLI built on it.
+ #[facet(flatten)]
+ pub builtins: FigueBuiltins,
+}
+
+/// Every top-level `git ents` subcommand.
+// @relation(roots.local, roots.worktree-update, roots.single-node-hosted, lens.serve, scope=file)
+#[derive(Facet)]
+#[repr(u8)]
+pub enum Top {
+ /// Configure this repository for signed local writes: resolve or
+ /// generate a signing key, record it as `user.signingkey` with
+ /// `gpg.format=ssh`, and set `receive.denyCurrentBranch=updateInstead`
+ /// so the integration-test harness can push into this repository's
+ /// checked-out branch (`roots.worktree-update`).
+ ///
+ /// With `--hosted`, configures the single-node hosted root instead
+ /// (`roots.single-node-hosted`): a signing key for the hosted worker,
+ /// and this binary's own `pre-receive`/`post-receive` hooks installed
+ /// into a bare repository's `hooks/` directory. Without these hooks
+ /// installed, a hosted bare repository accepts every push ungated —
+ /// stock git's `receive-pack` has no gate of its own.
+ Setup {
+ /// Key to sign with; defaults to `user.signingkey`, else a new
+ /// `~/.ssh/id_ed25519` is generated.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ /// Configure the single-node hosted root instead of the local
+ /// one: install this binary's `hook pre-receive`/`hook
+ /// post-receive` into a bare repository's own hooks, and a
+ /// signing key for the hosted worker.
+ #[facet(args::named, default)]
+ hosted: bool,
+ /// The bare repository to configure with `--hosted`; defaults to
+ /// the current directory. Ignored without `--hosted`.
+ #[facet(args::positional, default)]
+ path: Option<PathBuf>,
+ },
+ /// Manage the repository members at `refs/meta/member/<username>`.
+ Members {
+ /// The member action to run.
+ #[facet(args::subcommand)]
+ action: MembersAction,
+ },
+ /// Manage this repository's account identity at `refs/meta/account`.
+ Account {
+ /// The account action to run.
+ #[facet(args::subcommand)]
+ action: AccountAction,
+ },
+ /// Manage the configured effects at `refs/meta/effects/<name>` and run
+ /// them locally.
+ Effect {
+ /// The effect action to run.
+ #[facet(args::subcommand)]
+ action: EffectAction,
+ },
+ /// Manage the toolchains stored as git trees at
+ /// `refs/meta/toolchains/<name>`.
+ Toolchain {
+ /// The toolchain action to run.
+ #[facet(args::subcommand)]
+ action: ToolchainAction,
+ },
+ /// Comment on code: one comment per ref at `refs/meta/comments/<id>`,
+ /// anchored to a blob (and optionally lines) at a commit.
+ Comment {
+ /// The comment action to run.
+ #[facet(args::subcommand)]
+ action: CommentAction,
+ },
+ /// Manage issues at `refs/meta/issues/<id>`.
+ Issue {
+ /// The issue action to run.
+ #[facet(args::subcommand)]
+ action: IssueAction,
+ },
+ /// Review a commit: a verdict plus a body at
+ /// `refs/meta/reviews/<target>/<member>`, with a retention pin at
+ /// `refs/meta/pins/reviews/<target>/<member>` keeping the reviewed
+ /// commit reachable.
+ Review {
+ /// The review action to run.
+ #[facet(args::subcommand)]
+ action: ReviewAction,
+ },
+ /// Work with entities awaiting adoption at
+ /// `refs/meta/inbox/<member>/<id>`.
+ Inbox {
+ /// The inbox action to run.
+ #[facet(args::subcommand)]
+ action: InboxAction,
+ },
+ /// Manage redactions recorded at `refs/meta/redactions/<id>`.
+ Redact {
+ /// The redaction action to run.
+ #[facet(args::subcommand)]
+ action: RedactAction,
+ },
+ /// Plumbing invoked by git's own hooks on the single-node hosted root
+ /// (`git.ents.cloud`) — not part of the porcelain surface a developer
+ /// runs directly.
+ Hook {
+ /// Which hook is running.
+ #[facet(args::subcommand)]
+ action: HookAction,
+ },
+ /// Start the local web UI (`roots.local`): reuses this repository's
+ /// existing local composition root (the same loose-ref `RefStore`,
+ /// odb, null `EventSink`, and advisory gate `git ents members`,
+ /// `git ents comment`, and every other porcelain command already use)
+ /// and adds only the `ents-web` HTTP frontend, bound to loopback —
+ /// never git's own smart-HTTP transport, which this command does not
+ /// expose in any form.
+ Serve {
+ /// Port to bind on loopback (`127.0.0.1`); `0` picks any free
+ /// port. Defaults to 4880.
+ #[facet(args::named)]
+ port: Option<u16>,
+ /// Key to sign web edits with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Serve the editor lens (`lens.serve`): a Language Server Protocol
+ /// server over stdin/stdout that projects this repository's comments
+ /// (`refs/meta/comments/*`) into whatever buffer an editor has open,
+ /// and composes new ones through the same signed path `git ents
+ /// comment` uses (`lens.parity`).
+ ///
+ /// Speaks LSP over stdio only: it binds no network socket and adds no
+ /// git-serving transport. It reuses the very same local composition
+ /// root `git ents serve` and every other porcelain command use (the
+ /// same loose-ref `RefStore`, odb, null `EventSink`, and advisory
+ /// gate), adding only the LSP frontend and signing with the user's own
+ /// key. Meant to be launched by an editor extension (e.g. `ents-zed`),
+ /// not run interactively.
+ Lsp {
+ /// Key to sign composed comments with; defaults to
+ /// `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+}
+
+/// `git ents members` actions.
+#[derive(Facet)]
+#[repr(u8)]
+pub enum MembersAction {
+ /// List the members recorded in this repository.
+ List,
+ /// Enroll a new member, or update an existing one's key.
+ Add {
+ /// The member's username (`refs/meta/member/<username>`).
+ #[facet(args::positional)]
+ username: String,
+ /// The public key to enroll (an OpenSSH single-line public key);
+ /// defaults to the signer's own public key.
+ #[facet(args::named)]
+ pubkey: Option<String>,
+ /// Key to sign the enrollment with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Remove a member, deleting its ref.
+ Remove {
+ /// The member (username) to remove.
+ #[facet(args::positional)]
+ username: String,
+ /// Key to sign the removal with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Revoke a member's key (`model.member-revocation`): the record
+ /// stays, but the key no longer authorizes new signatures.
+ Revoke {
+ /// The member (username) to revoke.
+ #[facet(args::positional)]
+ username: String,
+ /// Key to sign the revocation with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Lift a revocation, restoring a member's key to active.
+ Unrevoke {
+ /// The member (username) to unrevoke.
+ #[facet(args::positional)]
+ username: String,
+ /// Key to sign the unrevocation with; defaults to
+ /// `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Report whether a key is an active member.
+ Check {
+ /// Key to look for; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+}
+
+/// `git ents account` actions.
+#[derive(Facet)]
+#[repr(u8)]
+pub enum AccountAction {
+ /// Show this repository's account identity.
+ Show,
+ /// Create or update this repository's account identity.
+ Create {
+ /// The member this account belongs to; defaults to the signer's
+ /// own member (resolved by public key).
+ #[facet(args::named)]
+ member: Option<String>,
+ /// The login identity the member authenticates as.
+ #[facet(args::named)]
+ login: String,
+ /// Key to sign with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+}
+
+/// `git ents effect` actions.
+#[derive(Facet)]
+#[repr(u8)]
+pub enum EffectAction {
+ /// List the effects configured in this repository.
+ List,
+ /// Show one effect's definition and, when a commit is given, its
+ /// result.
+ Show {
+ /// The effect's name.
+ #[facet(args::positional)]
+ name: String,
+ /// Commit to show the result for.
+ #[facet(args::named)]
+ at: Option<String>,
+ },
+ /// Define (or replace) an effect and push the update.
+ Add {
+ /// Name to record the effect under (`refs/meta/effects/<name>`).
+ #[facet(args::positional)]
+ name: String,
+ /// The query this effect triggers on (`query.grammar`).
+ #[facet(args::named)]
+ on: String,
+ /// The command the effect runs.
+ #[facet(args::positional)]
+ run: String,
+ /// Toolchain (`refs/meta/toolchains/<name>`) to activate before
+ /// the command runs (repeatable).
+ #[facet(args::named, args::label = "TOOLCHAIN", default)]
+ toolchain: Vec<String>,
+ /// Key to sign with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Run this repository's effects locally against every commit still
+ /// owed a result, or a single one with `--at`
+ /// (`effect.local-run`): identical toolchain materialization and
+ /// sandbox path to a hosted worker, the queue skipped entirely.
+ Run {
+ /// The effect's name.
+ #[facet(args::positional)]
+ name: String,
+ /// Commit to run against; omit to run every outstanding commit
+ /// (`query.workset`).
+ #[facet(args::named)]
+ at: Option<String>,
+ /// Key to sign the result with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+ /// Show recorded results for an effect, newest first.
+ Log {
+ /// The effect's name.
+ #[facet(args::positional)]
+ name: String,
+ },
+}
+
+/// `git ents inbox` actions.
+#[derive(Facet)]
+#[repr(u8)]
+pub enum InboxAction {
+ /// List entities awaiting adoption.
+ List,
+ /// Adopt an inbox entity onto its canonical ref
+ /// (`sync.adoption-machinery`): a merge that keeps the author's
+ /// original signed commit in ancestry
+ /// (`sync.adoption-no-cherry-pick`).
+ Adopt {
+ /// The inbox entry to adopt, as `<member>/<id>`.
+ #[facet(args::positional)]
+ entry: String,
+ /// Key to sign the adoption merge with; defaults to
+ /// `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+}
+
+/// `git ents redact` actions.
+#[derive(Facet)]
+#[repr(u8)]
+pub enum RedactAction {
+ /// List the redactions recorded in this repository.
+ List,
+ /// Record that `oid` was redacted (`refs/meta/redactions/<id>`),
+ /// refusing any future push that would refill it
+ /// (`receive.redaction-ingest`). Admin-only: the gate's default
+ /// namespace-authorization arm requires admin-registered provenance
+ /// for `refs/meta/redactions/*`.
+ Add {
+ /// The object id to redact.
+ #[facet(args::positional)]
+ oid: String,
+ /// A human-readable reason recorded alongside the redaction.
+ #[facet(args::named)]
+ reason: String,
+ /// Key to sign with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+}
+
+/// Plumbing subcommands the single-node hosted root's git hooks invoke;
+/// see `crate::hook`'s own doc for what each does and why.
+#[derive(Facet)]
+#[repr(u8)]
+pub enum HookAction {
+ /// Run as git's own `pre-receive` hook: evaluate the gate against
+ /// every proposed transition read from stdin, refusing the whole
+ /// push under the mandatory gate if any fails.
+ PreReceive,
+ /// Run as git's own `post-receive` hook: reconcile outstanding effect
+ /// obligations (`receive.reconstructible`) and run them.
+ PostReceive,
+ /// Reconcile outstanding effect obligations without running anything
+ /// — the boot-time scan on its own, for operational use and testing.
+ Reconcile,
+}
crates/cli/git-ents/src/commands/account.rs
@@ -1,0 +1,93 @@
+//! `git ents account create`: link a member to a login identity at the
+//! fixed `refs/meta/account` ref (`model.account`).
+
+use ents_model::{Account, MemberId, namespace};
+use ents_receive::{Identity, propose_entity};
+use gix_ref_store::RefStoreRead;
+
+use super::{actor, signer};
+use crate::error::{Error, Result};
+use crate::mutate::outcome_to_result;
+use crate::root::LocalRoot;
+
+/// `git ents account show`: this repository's account identity.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if no account has been created yet
+/// (`git ents account create` first).
+pub fn show(root: &LocalRoot) -> Result<Account> {
+ #[expect(
+ clippy::expect_used,
+ clippy::unwrap_in_result,
+ reason = "ACCOUNT_REF is a fixed, compile-time-known-valid refname literal"
+ )]
+ let name: gix::refs::FullName = namespace::ACCOUNT_REF
+ .try_into()
+ .expect("fixed, valid refname");
+ let Some(tip) = root.refs.get(name.as_ref())? else {
+ return Err(Error::NotFound {
+ what: "account".to_owned(),
+ });
+ };
+ let tree = super::commit_tree(&root.objects, tip)?;
+ Ok(facet_git_tree::deserialize::<Account>(
+ &tree,
+ &root.objects,
+ )?)
+}
+
+/// Run `git ents account create`.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `member` is given but no such member exists (or,
+/// when omitted, the signer's own key is not enrolled yet — enroll it with
+/// `git ents members add` first); otherwise see
+/// [`crate::mutate::outcome_to_result`].
+pub fn create(
+ root: &LocalRoot,
+ member: Option<String>,
+ login: String,
+ key: Option<std::path::PathBuf>,
+) -> Result<()> {
+ let signer = signer(root, key.clone())?;
+ let member_id = match member {
+ Some(username) => MemberId::new(username),
+ None => {
+ let (username, _) =
+ super::members::check(root, key)?.ok_or_else(|| Error::NotFound {
+ what: "member for the current signing key".to_owned(),
+ })?;
+ MemberId::new(username)
+ }
+ };
+ let account = Account {
+ member: member_id,
+ login,
+ };
+ #[expect(
+ clippy::expect_used,
+ clippy::unwrap_in_result,
+ reason = "ACCOUNT_REF is a fixed, compile-time-known-valid refname literal"
+ )]
+ let name: gix::refs::FullName = namespace::ACCOUNT_REF
+ .try_into()
+ .expect("fixed, valid refname");
+ let identity = Identity {
+ actor: actor(&signer),
+ sign: &|payload| signer.sign(payload),
+ };
+ let outcome = propose_entity(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ name,
+ &account,
+ &identity,
+ "Create account",
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(())
+}
crates/cli/git-ents/src/commands/comment.rs
@@ -1,0 +1,241 @@
+//! `git ents comment`: a thin wrapper around `ents_forge::comment`'s
+//! business logic — this module only resolves the signer/actor identity
+//! against [`LocalRoot`], translates a reached `Outcome` into a CLI-facing
+//! [`Result`] (`crate::mutate::outcome_to_result`), and renders the
+//! machine-readable listing, exactly as every other mutation command does.
+//! Every operation is the library call itself (`lens.parity`); nothing
+//! here re-implements one.
+
+use ents_forge::comment;
+use ents_forge::comment::{Comment, ListFilter, Listed, NewComment};
+use ents_receive::Identity;
+
+use super::{actor, signer};
+use crate::error::Result;
+use crate::mutate::outcome_to_result;
+use crate::root::LocalRoot;
+
+/// `git ents comment list`: every comment recorded in this repository.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+pub fn list(root: &LocalRoot) -> Result<Vec<(String, Comment)>> {
+ Ok(comment::list(&root.refs, &root.objects)?)
+}
+
+/// `git ents comment list [--worktree] [--state ...] [--context ...]`:
+/// matching comments with each anchor projected onto the working tree
+/// (with `worktree`) or `HEAD`, plus the refs whose stored tree this
+/// build could not read back (reported after the listing, never
+/// silently dropped).
+///
+/// # Errors
+///
+/// Propagates a ref-store, object read, or projection failure.
+pub fn list_projected(
+ root: &LocalRoot,
+ worktree: bool,
+ filter: &ListFilter,
+) -> Result<(Vec<Listed>, Vec<ents_forge::Unreadable>)> {
+ Ok(comment::list_projected(
+ &root.refs,
+ &root.objects,
+ &root.path,
+ worktree,
+ filter,
+ )?)
+}
+
+/// `git ents comment add`: create a comment about something.
+///
+/// # Errors
+///
+/// [`crate::error::Error::Forge`] if the comment is about nothing, its
+/// arguments do not parse, or anchoring, serialization, or `receive`
+/// itself fails; see [`crate::mutate::outcome_to_result`] for how a
+/// reached refusal renders.
+pub fn add(root: &LocalRoot, new: NewComment, key: Option<std::path::PathBuf>) -> Result<String> {
+ let signer = signer(root, key)?;
+ let identity = Identity {
+ actor: actor(&signer),
+ sign: &|payload| signer.sign(payload),
+ };
+ let (id, outcome) = comment::add(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ &root.path,
+ new,
+ &identity,
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(id)
+}
+
+/// `git ents comment reply`: a comment whose parent is `parent_id`.
+///
+/// # Errors
+///
+/// See [`add`]; additionally [`ents_forge::Error::NotFound`] (wrapped)
+/// when `parent_id` names no comment.
+pub fn reply(
+ root: &LocalRoot,
+ parent_id: &str,
+ body: String,
+ key: Option<std::path::PathBuf>,
+) -> Result<String> {
+ let signer = signer(root, key)?;
+ let identity = Identity {
+ actor: actor(&signer),
+ sign: &|payload| signer.sign(payload),
+ };
+ let (id, outcome) = comment::reply(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ parent_id,
+ body,
+ &identity,
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(id)
+}
+
+/// `git ents comment resolve` / `reopen`: record the state mutation on the
+/// comment's own ref.
+///
+/// # Errors
+///
+/// See [`add`].
+pub fn set_state(
+ root: &LocalRoot,
+ id: &str,
+ resolve: bool,
+ key: Option<std::path::PathBuf>,
+) -> Result<()> {
+ let signer = signer(root, key)?;
+ let identity = Identity {
+ actor: actor(&signer),
+ sign: &|payload| signer.sign(payload),
+ };
+ let outcome = if resolve {
+ comment::resolve(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ id,
+ &identity,
+ root.mode(),
+ Some(&signer.public_openssh()),
+ )?
+ } else {
+ comment::reopen(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ id,
+ &identity,
+ root.mode(),
+ Some(&signer.public_openssh()),
+ )?
+ };
+ outcome_to_result(outcome, None)?;
+ Ok(())
+}
+
+/// `git ents comment show`: `id`'s comment and, when anchored, its anchor
+/// projected onto `rev` or the working tree.
+///
+/// # Errors
+///
+/// [`crate::error::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`])
+/// if `id` has no comment ref.
+pub fn show(
+ root: &LocalRoot,
+ id: &str,
+ rev: &str,
+ worktree: bool,
+) -> Result<(
+ Comment,
+ Option<(ents_anchor::Anchor, ents_anchor::Projection)>,
+)> {
+ Ok(comment::show(
+ &root.refs,
+ &root.objects,
+ &root.path,
+ id,
+ rev,
+ worktree,
+ )?)
+}
+
+/// One record of `git ents comment list --porcelain`'s stable
+/// machine-readable form (`lens.parity`: id, state, projected location,
+/// and body, sufficient for an agent to enumerate and resolve every open
+/// comment with no editor attached):
+///
+/// ```text
+/// <id> <state> <projection> <location>
+/// context <c> (only when the comment names one)
+/// parent <id> (only when the comment is a reply)
+/// \t<body line> (every body line, tab-prefixed)
+/// ```
+///
+/// `projection` is `current`, `relocated`, `outdated`, or `deleted`, and
+/// `-` for a comment with no anchor; `location` is `path:start-end`
+/// (`path` alone for a whole-file anchor) and `-` when there is no anchor
+/// or the file is gone. Records are separated by one blank line — a blank
+/// body line renders as a lone tab, so it can never terminate a record.
+#[must_use]
+pub fn porcelain(rows: &[Listed]) -> String {
+ let mut out = String::new();
+ for (index, row) in rows.iter().enumerate() {
+ if index > 0 {
+ out.push('\n');
+ }
+ let (projection, location) = match (&row.projection, &row.anchor) {
+ (Some(projection), Some(anchor)) => porcelain_projection(projection, anchor),
+ _ => ("-".to_owned(), "-".to_owned()),
+ };
+ out.push_str(&format!(
+ "{} {} {} {}\n",
+ row.id, row.comment.state, projection, location
+ ));
+ if let Some(context) = &row.comment.context {
+ out.push_str(&format!("context {context}\n"));
+ }
+ if let Some(parent) = &row.comment.parent {
+ out.push_str(&format!("parent {parent}\n"));
+ }
+ for line in row.comment.body.lines() {
+ out.push('\t');
+ out.push_str(line);
+ out.push('\n');
+ }
+ }
+ out
+}
+
+/// The `(projection, location)` columns of one porcelain record.
+fn porcelain_projection(
+ projection: &ents_anchor::Projection,
+ anchor: &ents_anchor::Anchor,
+) -> (String, String) {
+ use ents_anchor::Projection;
+ match projection {
+ Projection::Current => ("current".to_owned(), location(&anchor.path, anchor.lines)),
+ Projection::Relocated { path, lines } => ("relocated".to_owned(), location(path, *lines)),
+ Projection::Outdated { path } => ("outdated".to_owned(), location(path, None)),
+ Projection::Deleted => ("deleted".to_owned(), "-".to_owned()),
+ }
+}
+
+fn location(path: &str, lines: Option<ents_anchor::LineRange>) -> String {
+ match lines {
+ Some(range) => format!("{path}:{}-{}", range.start, range.end),
+ None => path.to_owned(),
+ }
+}
crates/cli/git-ents/src/commands/effect.rs
@@ -1,0 +1,235 @@
+//! `git ents effect`: define, list, show, run, and log effects
+//! (`model.effect-definition`, `effect.local-run`).
+
+use ents_effect::run::{run_effect, short_oid};
+use ents_model::{Effect, ResultRecord, Status, namespace};
+use ents_receive::{Identity, propose_entity};
+use gix_ref_store::RefStoreRead;
+
+use super::{actor, signer};
+use crate::error::{Error, Result};
+use crate::mutate::outcome_to_result;
+use crate::root::LocalRoot;
+
+/// `git ents effect list`: every effect currently defined.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+pub fn list(root: &LocalRoot) -> Result<Vec<(String, Effect)>> {
+ let mut out = Vec::new();
+ for entry in root.refs.iter_prefix("refs/meta/effects/")? {
+ let (name, tip) = entry?;
+ let path = name.as_bstr().to_string();
+ let Some(short) = path.strip_prefix("refs/meta/effects/") else {
+ continue;
+ };
+ if short.is_empty() || short.contains('/') {
+ continue;
+ }
+ let tree = super::commit_tree(&root.objects, tip)?;
+ if let Ok(effect) = facet_git_tree::deserialize::<Effect>(&tree, &root.objects) {
+ out.push((short.to_owned(), effect));
+ }
+ }
+ Ok(out)
+}
+
+/// `git ents effect add`: define (or replace) `name`.
+///
+/// # Errors
+///
+/// See [`crate::mutate::outcome_to_result`].
+pub fn add(
+ root: &LocalRoot,
+ name: &str,
+ on: String,
+ run: String,
+ toolchains: Vec<String>,
+ key: Option<std::path::PathBuf>,
+) -> Result<()> {
+ // Validate the trigger parses before it is ever written — a malformed
+ // trigger would otherwise be silently skipped by every future
+ // reconciliation scan (`ents_receive::reconcile`'s own tolerance rule).
+ let _: ents_query::Query = on
+ .parse()
+ .map_err(|_source| Error::InvalidArgument(format!("unparsable trigger: {on}")))?;
+
+ let signer = signer(root, key)?;
+ let effect = Effect {
+ name: name.to_owned(),
+ trigger: on,
+ toolchains,
+ run,
+ };
+ let ref_name = namespace::effect_ref(name)?;
+ let identity = Identity {
+ actor: actor(&signer),
+ sign: &|payload| signer.sign(payload),
+ };
+ let outcome = propose_entity(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ ref_name,
+ &effect,
+ &identity,
+ &format!("Define effect {name}"),
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(())
+}
+
+/// `git ents effect show`: the definition, plus its result at `at` when
+/// given.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `name` has no effect definition.
+pub fn show(root: &LocalRoot, name: &str, at: Option<String>) -> Result<(Effect, Option<Status>)> {
+ let ref_name = namespace::effect_ref(name)?;
+ let Some(tip) = root.refs.get(ref_name.as_ref())? else {
+ return Err(Error::NotFound {
+ what: format!("effect {name}"),
+ });
+ };
+ let tree = super::commit_tree(&root.objects, tip)?;
+ let effect = facet_git_tree::deserialize::<Effect>(&tree, &root.objects)?;
+
+ let status = match at {
+ None => None,
+ Some(commit) => {
+ let oid = resolve_commit(root, &commit)?;
+ let results_ref = namespace::result_ref(name, &short_oid(oid))?;
+ match root.refs.get(results_ref.as_ref())? {
+ None => None,
+ Some(result_tip) => {
+ let tree = super::commit_tree(&root.objects, result_tip)?;
+ facet_git_tree::deserialize::<ResultRecord>(&tree, &root.objects)
+ .ok()
+ .map(|record| record.status)
+ }
+ }
+ }
+ };
+ Ok((effect, status))
+}
+
+/// `git ents effect run`: run `name` locally against every outstanding
+/// commit, or a single `at` — no queue, identical materialization and
+/// sandbox path to a hosted worker (`effect.local-run`).
+///
+/// # Errors
+///
+/// Propagates any failure `ents_effect::run::run_effect` reports.
+#[expect(
+ clippy::result_large_err,
+ reason = "the closure passed to run_effect below is typed against ents_effect::Error, that \
+ crate's own Result shape, not this crate's to box"
+)]
+pub fn run(
+ root: &LocalRoot,
+ name: &str,
+ at: Option<String>,
+ key: Option<std::path::PathBuf>,
+ executor: &dyn ents_effect::Executor,
+) -> Result<Vec<(gix_hash::ObjectId, ents_receive::Outcome)>> {
+ let ref_name = namespace::effect_ref(name)?;
+ let Some(tip) = root.refs.get(ref_name.as_ref())? else {
+ return Err(Error::NotFound {
+ what: format!("effect {name}"),
+ });
+ };
+ let tree = super::commit_tree(&root.objects, tip)?;
+ let effect = facet_git_tree::deserialize::<Effect>(&tree, &root.objects)?;
+
+ let signer = signer(root, key)?;
+ let at_oid = at.map(|rev| resolve_commit(root, &rev)).transpose()?;
+
+ let scratch = tempfile::tempdir().map_err(|source| Error::Io {
+ path: root.path.clone(),
+ source,
+ })?;
+ let cache = tempfile::tempdir().map_err(|source| Error::Io {
+ path: root.path.clone(),
+ source,
+ })?;
+
+ // `run_effect` no longer resolves toolchain names itself: resolve and
+ // materialize each of the effect's declared toolchains here, before
+ // handing the run loop an already-materialized slice.
+ let mut toolchains = Vec::with_capacity(effect.toolchains.len());
+ for toolchain_name in &effect.toolchains {
+ let (_, recipe) = ents_kiln::toolchain::resolve(&root.refs, &root.objects, toolchain_name)?;
+ let bin = ents_kiln::toolchain::materialize(&recipe, &root.objects, cache.path())?;
+ toolchains.push((toolchain_name.clone(), bin));
+ }
+
+ let author = actor(&signer);
+ let outcomes = run_effect(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ executor,
+ scratch.path(),
+ &toolchains,
+ name,
+ &effect,
+ at_oid,
+ |short| canonical_result_ref(name, short),
+ &author,
+ &|payload| signer.sign(payload),
+ root.mode(),
+ )?;
+ Ok(outcomes)
+}
+
+/// Build the canonical results refname for one run, in the shape
+/// `run_effect`'s own `results_ref` parameter expects.
+///
+/// # Errors
+///
+/// Never in practice: `name` is an already-defined effect and `short` is
+/// always a hex oid slice ([`ents_effect::run::short_oid`]'s own shape), so
+/// both always compose into a well-formed refname; kept fallible only
+/// because `ents_effect::run::run_effect`'s own signature requires it.
+#[expect(
+ clippy::result_large_err,
+ reason = "the Result shape is ents_effect::run_effect's own signature, not this crate's to box"
+)]
+fn canonical_result_ref(name: &str, short: &str) -> ents_effect::Result<gix::refs::FullName> {
+ #[expect(
+ clippy::expect_used,
+ clippy::unwrap_in_result,
+ reason = "see this function's own doc: always well-formed in practice"
+ )]
+ Ok(namespace::result_ref(name, short).expect("well-formed refname segments"))
+}
+
+/// `git ents effect log`: every recorded result for `name`, newest first —
+/// the results ref's own commit log.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `name` has no results yet.
+pub fn log(root: &LocalRoot, name: &str) -> Result<Vec<(gix_hash::ObjectId, Status)>> {
+ let prefix = format!("refs/meta/results/{name}/");
+ let mut out = Vec::new();
+ for entry in root.refs.iter_prefix(&prefix)? {
+ let (_, tip) = entry?;
+ let tree = super::commit_tree(&root.objects, tip)?;
+ if let Ok(record) = facet_git_tree::deserialize::<ResultRecord>(&tree, &root.objects) {
+ out.push((tip, record.status));
+ }
+ }
+ Ok(out)
+}
+
+fn resolve_commit(root: &LocalRoot, rev: &str) -> Result<gix_hash::ObjectId> {
+ let repo = gix::open(&root.path)?;
+ let id = repo
+ .rev_parse_single(rev)
+ .map_err(|source| Error::InvalidArgument(format!("cannot resolve {rev}: {source}")))?;
+ Ok(id.detach())
+}
crates/cli/git-ents/src/commands/inbox.rs
@@ -1,0 +1,105 @@
+//! `git ents inbox`: list entities awaiting adoption and adopt them onto
+//! their canonical ref (`sync.adoption-machinery`,
+//! `sync.adoption-no-cherry-pick`).
+
+use ents_model::namespace;
+use ents_sync::resolve::{Heads, Merged, merge_heads};
+use gix_ref_store::RefStoreRead;
+
+use super::{actor, signer};
+use crate::error::{Error, Result};
+use crate::mutate::outcome_to_result;
+use crate::root::LocalRoot;
+
+/// `git ents inbox list`: every `refs/meta/inbox/<member>/<id>` entry.
+///
+/// # Errors
+///
+/// Propagates a ref-store read failure.
+pub fn list(root: &LocalRoot) -> Result<Vec<String>> {
+ let mut out = Vec::new();
+ for entry in root.refs.iter_prefix("refs/meta/inbox/")? {
+ let (name, _) = entry?;
+ let path = name.as_bstr().to_string();
+ if let Some(rest) = path.strip_prefix("refs/meta/inbox/") {
+ out.push(rest.to_owned());
+ }
+ }
+ Ok(out)
+}
+
+/// `git ents inbox adopt`: fold `entry` (`<member>/<id>`) onto its
+/// canonical ref (`refs/meta/<id>`) via [`merge_heads`], keeping the
+/// author's original signed commit in ancestry
+/// (`sync.adoption-no-cherry-pick`).
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `entry` has no inbox ref; [`Error::InvalidArgument`]
+/// on a merge conflict (a human must resolve it before adoption can
+/// complete — this phase does not implement interactive conflict
+/// resolution); otherwise see [`crate::mutate::outcome_to_result`].
+pub fn adopt(root: &LocalRoot, entry: &str, key: Option<std::path::PathBuf>) -> Result<()> {
+ let Some((member, id)) = entry.split_once('/') else {
+ return Err(Error::InvalidArgument(format!(
+ "expected <member>/<id>, got {entry:?}"
+ )));
+ };
+ let inbox_ref = namespace::inbox_ref(&ents_model::MemberId::new(member), id)?;
+ let Some(theirs) = root.refs.get(inbox_ref.as_ref())? else {
+ return Err(Error::NotFound {
+ what: format!("inbox entry {entry}"),
+ });
+ };
+ let canonical: gix::refs::FullName = format!("refs/meta/{id}")
+ .try_into()
+ .map_err(|_source| Error::InvalidArgument(format!("bad canonical ref for {id}")))?;
+ let ours = root.refs.get(canonical.as_ref())?;
+
+ let signer = signer(root, key)?;
+ let author = actor(&signer);
+ let heads = Heads {
+ refname: canonical.clone(),
+ ours,
+ theirs,
+ };
+ let merged = merge_heads(
+ &root.objects,
+ &heads,
+ &author,
+ &format!("Adopt {entry}"),
+ |payload| signer.sign(payload),
+ )?;
+ let tip = match merged {
+ Merged::Tip(tip) => tip,
+ Merged::Conflict(paths) => {
+ let rendered = paths
+ .iter()
+ .map(|p| p.to_string())
+ .collect::<Vec<_>>()
+ .join(", ");
+ return Err(Error::InvalidArgument(format!(
+ "adoption conflict at: {rendered}"
+ )));
+ }
+ };
+
+ let proposal = ents_receive::Proposal {
+ transitions: vec![ents_receive::RefTransition {
+ name: canonical,
+ old: ours,
+ new: Some(tip),
+ }],
+ objects: vec![tip],
+ auth: None,
+ };
+ let outcome = ents_receive::receive(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ &proposal,
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, ours)?;
+ Ok(())
+}
crates/cli/git-ents/src/commands/issue.rs
@@ -1,0 +1,186 @@
+//! `git ents issue`: a thin wrapper around `ents_forge::issue`'s business
+//! logic, plus the one CLI-only piece that operation needs: composing a
+//! title and body in `$GIT_EDITOR`/`$EDITOR` when `--title` is omitted
+//! (mirroring `git commit`'s own editor fallback) — a frontend concern,
+//! not an operation `ents_forge::issue` offers (`lens.parity`).
+
+use std::io::Write as _;
+use std::path::PathBuf;
+use std::process::Command;
+
+use ents_forge::Issue;
+use ents_forge::issue::{self, EditIssue, NewIssue};
+use ents_model::MemberId;
+use ents_receive::Identity;
+
+use super::{actor, signer};
+use crate::error::{Error, Result};
+use crate::mutate::outcome_to_result;
+use crate::root::LocalRoot;
+
+/// `git ents issue list`: every issue recorded in this repository.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+pub fn list(root: &LocalRoot) -> Result<Vec<(String, Issue)>> {
+ Ok(issue::list(&root.refs, &root.objects)?)
+}
+
+/// `git ents issue show`: `id`'s issue.
+///
+/// # Errors
+///
+/// [`crate::error::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`])
+/// if `id` has no issue ref.
+pub fn show(root: &LocalRoot, id: &str) -> Result<Issue> {
+ Ok(issue::show(&root.refs, &root.objects, id)?)
+}
+
+/// `git ents issue new`: create an issue. When `title` is `None`, composes
+/// the title and body interactively (see `compose_in_editor`).
+///
+/// # Errors
+///
+/// [`Error::InvalidArgument`] if no title was given and the interactively
+/// composed message is empty (the editor path aborts, mirroring `git
+/// commit`'s own empty-message abort); [`Error::Io`] if the editor cannot
+/// be spawned or the scratch file cannot be read or written; otherwise see
+/// [`crate::mutate::outcome_to_result`].
+pub fn new(
+ root: &LocalRoot,
+ title: Option<String>,
+ body: Option<String>,
+ state: String,
+ labels: Vec<String>,
+ assignees: Vec<String>,
+ key: Option<PathBuf>,
+) -> Result<String> {
+ let (title, body) = match title {
+ Some(title) => (title, body.unwrap_or_default()),
+ None => compose_in_editor()?
+ .ok_or_else(|| Error::InvalidArgument("empty issue message, aborting".into()))?,
+ };
+ let signer = signer(root, key)?;
+ let identity = Identity {
+ actor: actor(&signer),
+ sign: &|payload| signer.sign(payload),
+ };
+ let new = NewIssue {
+ title,
+ body,
+ state,
+ assignees: assignees.into_iter().map(MemberId::new).collect(),
+ labels,
+ };
+ let (id, outcome) = issue::new(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ new,
+ &identity,
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(id)
+}
+
+/// `git ents issue edit`: mutate `id`'s state, assignees, and/or labels.
+/// Assignees/labels replace the previous set entirely when at least one
+/// value is given; an empty list leaves that field unchanged.
+///
+/// # Errors
+///
+/// See [`crate::mutate::outcome_to_result`].
+pub fn edit(
+ root: &LocalRoot,
+ id: &str,
+ state: Option<String>,
+ labels: Vec<String>,
+ assignees: Vec<String>,
+ key: Option<PathBuf>,
+) -> Result<()> {
+ let signer = signer(root, key)?;
+ let identity = Identity {
+ actor: actor(&signer),
+ sign: &|payload| signer.sign(payload),
+ };
+ let edit = EditIssue {
+ state,
+ labels: (!labels.is_empty()).then_some(labels),
+ assignees: (!assignees.is_empty())
+ .then(|| assignees.into_iter().map(MemberId::new).collect()),
+ };
+ let outcome = issue::edit(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ id,
+ edit,
+ &identity,
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(())
+}
+
+/// Compose a title (first line) and body (remaining lines) by opening
+/// `$GIT_EDITOR` (or `$EDITOR`, or `vi`) on a scratch file seeded with a
+/// `#`-prefixed instructions footer; lines starting with `#` are stripped
+/// on read-back. Returns `None` (mirroring `git commit`'s own
+/// empty-message abort) when the title line is empty after stripping.
+///
+/// # Errors
+///
+/// [`Error::Io`] if the scratch file cannot be created, written, or read,
+/// or the editor process cannot be spawned or exits with a failure status.
+fn compose_in_editor() -> Result<Option<(String, String)>> {
+ let editor = std::env::var("GIT_EDITOR")
+ .or_else(|_| std::env::var("EDITOR"))
+ .unwrap_or_else(|_| "vi".to_owned());
+
+ let mut file = tempfile::NamedTempFile::new().map_err(|source| Error::Io {
+ path: std::env::temp_dir(),
+ source,
+ })?;
+ let path = file.path().to_owned();
+ writeln!(
+ file,
+ "\n# First line is the title, the rest is the body.\n\
+ # Lines starting with '#' are stripped; an empty title aborts."
+ )
+ .map_err(|source| Error::Io {
+ path: path.clone(),
+ source,
+ })?;
+ file.flush().map_err(|source| Error::Io {
+ path: path.clone(),
+ source,
+ })?;
+
+ let status = Command::new(&editor)
+ .arg(&path)
+ .status()
+ .map_err(|source| Error::Io {
+ path: path.clone(),
+ source,
+ })?;
+ if !status.success() {
+ return Err(Error::Io {
+ path: path.clone(),
+ source: std::io::Error::other(format!("{editor} exited with {status}")),
+ });
+ }
+
+ let contents = std::fs::read_to_string(&path).map_err(|source| Error::Io {
+ path: path.clone(),
+ source,
+ })?;
+ let mut lines = contents.lines().filter(|line| !line.starts_with('#'));
+ let title = lines.next().unwrap_or("").trim();
+ if title.is_empty() {
+ return Ok(None);
+ }
+ let body = lines.collect::<Vec<_>>().join("\n");
+ Ok(Some((title.to_owned(), body.trim_end().to_owned())))
+}
crates/cli/git-ents/src/commands/lsp.rs
@@ -1,0 +1,72 @@
+//! `git ents lsp`: reuse [`LocalRoot`]'s existing wiring and add only the
+//! `ents-lens` Language Server Protocol frontend, over stdio (`lens.serve`).
+//!
+//! `lens.serve` requires this command to serve LSP over stdio reusing the
+//! local composition root exactly as `git ents serve` reuses it for the web
+//! UI (`roots.local`), binding no socket and adding no git transport. This
+//! module upholds that: it is handed an already-open [`LocalRoot`] (never
+//! opens its own), resolves the user's own signing key exactly as every
+//! other mutation command does, and hands both to
+//! [`ents_lens::serve_stdio`], which speaks only stdin/stdout.
+//!
+//! The signing identity is injected the same way `serve` injects
+//! `ents-web`'s (`roots.web-agnostic` parity): the lens crate resolves no
+//! key and assumes no editor is attached; this composition root builds an
+//! owned [`ents_lens::Signing`] from the user's own key
+//! (`roots.web-signing`'s local half — no server-key indirection exists
+//! here) and moves it in.
+
+use std::path::PathBuf;
+
+use ents_lens::{Lens, Signing};
+
+use super::{actor, signer};
+use crate::error::{Error, Result};
+use crate::root::LocalRoot;
+
+/// Run `git ents lsp`: build the lens from `root`'s seams and the user's
+/// resolved signing key, then serve LSP over stdio until the client shuts
+/// it down.
+///
+/// Takes no output writer: the process's stdout is the LSP protocol
+/// channel, so this command must write nothing else to it.
+///
+/// # Errors
+///
+/// Propagates a signing-key resolution failure ([`crate::sign::Signer`]),
+/// or an [`Error::Io`] if the LSP transport fails.
+// @relation(lens.serve, roots.local, scope=function)
+pub fn run(root: LocalRoot, key: Option<PathBuf>) -> Result<()> {
+ let signer = signer(&root, key)?;
+ let identity_actor = actor(&signer);
+ let public_openssh = signer.public_openssh();
+ // The user's own key signs composed comments (`roots.web-signing`'s
+ // local half): no server-key indirection is imported into the local
+ // root, exactly as `serve` keeps it out.
+ let signing = Signing::new(
+ identity_actor,
+ Box::new(move |payload| signer.sign(payload)),
+ public_openssh,
+ );
+
+ let mode = root.mode();
+ let LocalRoot {
+ path,
+ refs,
+ objects,
+ events,
+ executor: _,
+ } = root;
+ let lens = Lens::new(
+ Box::new(refs),
+ objects,
+ Box::new(events),
+ mode,
+ signing,
+ path,
+ );
+ ents_lens::serve_stdio(lens).map_err(|source| Error::Io {
+ path: PathBuf::from("<lsp stdio>"),
+ source,
+ })
+}
crates/cli/git-ents/src/commands/members.rs
@@ -1,0 +1,164 @@
+//! `git ents members`: enroll, remove, revoke, unrevoke, and check members
+//! (`model.member-identity`, `model.member-revocation`).
+
+use ents_model::{Member, MemberId, MemberState, Provenance, namespace};
+use ents_receive::{Identity, propose_delete, propose_entity};
+use gix_ref_store::RefStoreRead;
+
+use super::{actor, signer};
+use crate::error::{Error, Result};
+use crate::mutate::outcome_to_result;
+use crate::root::LocalRoot;
+
+/// `git ents members list`: every member ref and its current state.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+pub fn list(root: &LocalRoot) -> Result<Vec<(String, Member)>> {
+ let mut out = Vec::new();
+ for entry in root.refs.iter_prefix("refs/meta/member/")? {
+ let (name, tip) = entry?;
+ let path = name.as_bstr().to_string();
+ let Some(username) = path.strip_prefix("refs/meta/member/") else {
+ continue;
+ };
+ if let Some(member) = read_member(root, tip)? {
+ out.push((username.to_owned(), member));
+ }
+ }
+ Ok(out)
+}
+
+/// `git ents members add`: enroll `username` with `pubkey` (or the
+/// signer's own public key), admin-registered.
+///
+/// # Errors
+///
+/// Propagates a signing, serialization, or `receive` failure; see
+/// [`crate::mutate::outcome_to_result`] for how a reached refusal renders.
+pub fn add(
+ root: &LocalRoot,
+ username: &str,
+ pubkey: Option<String>,
+ key: Option<std::path::PathBuf>,
+) -> Result<()> {
+ let signer = signer(root, key)?;
+ let pubkey = pubkey.unwrap_or_else(|| signer.public_openssh());
+ let member = Member::new(MemberId::new(username), pubkey, Provenance::AdminRegistered);
+ let name = namespace::member_ref(&MemberId::new(username))?;
+ let identity = Identity {
+ actor: actor(&signer),
+ sign: &|payload| signer.sign(payload),
+ };
+ let outcome = propose_entity(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ name,
+ &member,
+ &identity,
+ &format!("Enroll {username}"),
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(())
+}
+
+/// `git ents members remove`: delete `username`'s ref entirely.
+///
+/// # Errors
+///
+/// See [`add`].
+pub fn remove(root: &LocalRoot, username: &str, key: Option<std::path::PathBuf>) -> Result<()> {
+ let signer = signer(root, key)?;
+ let name = namespace::member_ref(&MemberId::new(username))?;
+ let outcome = propose_delete(&root.refs, &root.objects, &root.events, name, root.mode())?;
+ let _ = signer; // signing material is not needed for a deletion transition.
+ outcome_to_result(outcome, None)?;
+ Ok(())
+}
+
+/// `git ents members revoke`/`unrevoke`: flip `username`'s
+/// [`MemberState`] without deleting the record (`model.member-revocation`).
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `username` has no member ref; otherwise see
+/// [`add`].
+pub fn set_revoked(
+ root: &LocalRoot,
+ username: &str,
+ revoked: bool,
+ key: Option<std::path::PathBuf>,
+) -> Result<()> {
+ let signer = signer(root, key)?;
+ let name = namespace::member_ref(&MemberId::new(username))?;
+ let Some(tip) = root.refs.get(name.as_ref())? else {
+ return Err(Error::NotFound {
+ what: format!("member {username}"),
+ });
+ };
+ let mut member = read_member(root, tip)?.ok_or_else(|| Error::NotFound {
+ what: format!("member {username}"),
+ })?;
+ member.state = if revoked {
+ MemberState::Revoked
+ } else {
+ MemberState::Active
+ };
+ let identity = Identity {
+ actor: actor(&signer),
+ sign: &|payload| signer.sign(payload),
+ };
+ let verb = if revoked { "Revoke" } else { "Unrevoke" };
+ let outcome = propose_entity(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ name,
+ &member,
+ &identity,
+ &format!("{verb} {username}"),
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, Some(tip))?;
+ Ok(())
+}
+
+/// `git ents members check`: whether `key` (or the resolved signing key)
+/// names an active member, and which username.
+///
+/// # Errors
+///
+/// Propagates a signing-key or ref-store read failure.
+pub fn check(
+ root: &LocalRoot,
+ key: Option<std::path::PathBuf>,
+) -> Result<Option<(String, MemberState)>> {
+ let signer = signer(root, key)?;
+ find_by_key(root, &signer.public_openssh())
+}
+
+/// Resolve `pubkey` to the enrolled member whose stored key matches it, if
+/// any -- the shared match loop behind [`check`] and `git ents serve`'s own
+/// identity-chip label (`crate::commands::serve::build_state`,
+/// `roots.web-signing`): both need "which member owns this key," never a
+/// bespoke re-scan of `list`'s own rows.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+pub fn find_by_key(root: &LocalRoot, pubkey: &str) -> Result<Option<(String, MemberState)>> {
+ for (username, member) in list(root)? {
+ if member.key == pubkey {
+ return Ok(Some((username, member.state)));
+ }
+ }
+ Ok(None)
+}
+
+fn read_member(root: &LocalRoot, tip: gix_hash::ObjectId) -> Result<Option<Member>> {
+ let tree = crate::commands::commit_tree(&root.objects, tip)?;
+ Ok(facet_git_tree::deserialize::<Member>(&tree, &root.objects).ok())
+}
crates/cli/git-ents/src/commands/mod.rs
@@ -1,0 +1,120 @@
+//! One module per `git ents` subcommand family — [`crate::cli`]'s
+//! definitions given a body. Each function here is a thin caller into a
+//! library crate: [`crate::exe`] dispatches to these, never the other way
+//! around, so the same logic is callable from a test without a terminal.
+#![expect(
+ clippy::let_underscore_must_use,
+ reason = "rendering an advisory-gate verdict to a writer is best-effort; a broken pipe here \
+ is not actionable"
+)]
+
+pub mod account;
+pub mod comment;
+pub mod effect;
+pub mod inbox;
+pub mod issue;
+pub mod lsp;
+pub mod members;
+pub mod redact;
+pub mod review;
+pub mod serve;
+pub mod setup;
+pub mod toolchain;
+
+use std::io::Write;
+use std::path::PathBuf;
+
+use gix_hash::ObjectId;
+use gix_object::{CommitRef, Find, Kind};
+
+use crate::error::{Error, Result};
+use crate::root::LocalRoot;
+use crate::sign::Signer;
+
+/// The tree of the commit at `oid` — every command that reads back a typed
+/// entity needs this, and neither `ents_receive` nor `ents_effect` exports
+/// their own copy publicly, so it is a small, shared utility here rather
+/// than duplicated per command module.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `oid` is missing or not a commit.
+pub(crate) fn commit_tree(objects: &impl Find, oid: ObjectId) -> Result<ObjectId> {
+ let mut buf = Vec::new();
+ let data = objects
+ .try_find(&oid, &mut buf)
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?
+ .ok_or_else(|| Error::NotFound {
+ what: oid.to_string(),
+ })?;
+ if data.kind != Kind::Commit {
+ return Err(Error::NotFound {
+ what: oid.to_string(),
+ });
+ }
+ let commit = CommitRef::from_bytes(data.data, oid.kind())
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?;
+ Ok(commit.tree())
+}
+
+/// Resolve `--key` (or the repository's `user.signingkey`, or the default
+/// `~/.ssh/id_ed25519`) into a loaded [`Signer`] — the one place every
+/// write-side command turns an optional key path into a usable identity.
+///
+/// # Errors
+///
+/// See [`crate::sign::resolve_key_path`] and [`Signer::load`].
+pub fn signer(root: &LocalRoot, key: Option<PathBuf>) -> Result<Signer> {
+ let repo = gix::open(&root.path)?;
+ let path = crate::sign::resolve_key_path(&repo, key.as_deref())?;
+ Signer::load(&path)
+}
+
+/// The commit author/committer signature every mutation this CLI produces
+/// carries: the current wall-clock time, under a fixed name/email derived
+/// from the signer's own key fingerprint (this crate never depends on
+/// `user.name`/`user.email` being configured, mirroring
+/// `gix-ref-store`'s own reflog-identity rationale).
+#[must_use]
+pub fn actor(signer: &Signer) -> gix::actor::Signature {
+ let seconds = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
+ .unwrap_or_default();
+ gix::actor::Signature {
+ name: "git-ents".into(),
+ email: format!("{}@git-ents.local", short_fingerprint(signer)).into(),
+ time: gix::date::Time { seconds, offset: 0 },
+ }
+}
+
+fn short_fingerprint(signer: &Signer) -> String {
+ let key = signer.public_openssh();
+ let hex = key
+ .split_whitespace()
+ .nth(1)
+ .unwrap_or(&key)
+ .chars()
+ .take(12)
+ .collect::<String>();
+ if hex.is_empty() {
+ "member".to_owned()
+ } else {
+ hex
+ }
+}
+
+/// Print `verdicts` (`gate.verdict-reason`) for a command that succeeded
+/// under the advisory gate but still wants to surface a non-passing
+/// verdict to the user, mirroring `sync.local-advisory`: a failing verdict
+/// here is information, never a block.
+pub fn render_verdicts(
+ out: &mut impl Write,
+ verdicts: &[(gix::refs::FullName, ents_gate::Verdict)],
+) {
+ for (name, verdict) in verdicts {
+ if let ents_gate::Verdict::Fail(refusal) = verdict {
+ let _ = writeln!(out, "warning: {name}: {refusal}", name = name.as_bstr());
+ }
+ }
+}
crates/cli/git-ents/src/commands/redact.rs
@@ -1,0 +1,77 @@
+//! `git ents redact`: record that an object was redacted
+//! (`model.redaction`), refusing any future push that would refill it
+//! (`receive.redaction-ingest`).
+
+use ents_model::{Redaction, namespace};
+use ents_receive::{Identity, propose_entity};
+use gix_ref_store::RefStoreRead;
+
+use super::{actor, signer};
+use crate::error::{Error, Result};
+use crate::mutate::outcome_to_result;
+use crate::root::LocalRoot;
+
+/// `git ents redact list`: every redaction recorded in this repository.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+pub fn list(root: &LocalRoot) -> Result<Vec<(String, Redaction)>> {
+ let mut out = Vec::new();
+ for entry in root.refs.iter_prefix("refs/meta/redactions/")? {
+ let (name, tip) = entry?;
+ let path = name.as_bstr().to_string();
+ let Some(id) = path.strip_prefix("refs/meta/redactions/") else {
+ continue;
+ };
+ let tree = super::commit_tree(&root.objects, tip)?;
+ if let Ok(redaction) = facet_git_tree::deserialize::<Redaction>(&tree, &root.objects) {
+ out.push((id.to_owned(), redaction));
+ }
+ }
+ Ok(out)
+}
+
+/// Run `git ents redact add <oid> --reason ...`.
+///
+/// The record lands at `refs/meta/redactions/<id>`; the gate's default
+/// namespace-authorization arm requires admin-registered provenance for
+/// this namespace, so a non-admin signer is refused here exactly as any
+/// other call site would refuse it (`gate.call-sites`,
+/// `receive.redaction-admin-only`).
+///
+/// # Errors
+///
+/// [`Error::InvalidArgument`] if `oid` does not parse as an object id;
+/// otherwise see [`crate::mutate::outcome_to_result`].
+pub fn add(
+ root: &LocalRoot,
+ oid: &str,
+ reason: String,
+ key: Option<std::path::PathBuf>,
+) -> Result<()> {
+ let target: gix_hash::ObjectId = oid
+ .parse()
+ .map_err(|_source| Error::InvalidArgument(format!("not an object id: {oid}")))?;
+ let redaction = Redaction::new(target, reason);
+ let id = target.to_string();
+ let ref_name = namespace::redaction_ref(&id)?;
+
+ let signer = signer(root, key)?;
+ let identity = Identity {
+ actor: actor(&signer),
+ sign: &|payload| signer.sign(payload),
+ };
+ let outcome = propose_entity(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ ref_name,
+ &redaction,
+ &identity,
+ &format!("Redact {id}"),
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(())
+}
crates/cli/git-ents/src/commands/review.rs
@@ -1,0 +1,108 @@
+//! `git ents review`: a thin wrapper around `ents_forge::review`'s
+//! business logic — this module only resolves the signer/actor identity
+//! against [`LocalRoot`] (plus, for [`new`], the reviewer's own member id —
+//! the composite key's `<member>` segment, `meta-ref.identity-binding`) and
+//! translates a reached `Outcome` into a CLI-facing [`Result`]
+//! (`crate::mutate::outcome_to_result`), exactly as every other mutation
+//! command does. Every operation is the library call itself
+//! (`lens.parity`); nothing here re-implements one.
+
+use ents_forge::comment::Comment;
+use ents_forge::review;
+use ents_forge::review::{NewReview, Review};
+use ents_model::MemberId;
+use ents_receive::Identity;
+
+use super::{actor, signer};
+use crate::error::Result;
+use crate::mutate::outcome_to_result;
+use crate::root::LocalRoot;
+
+/// `git ents review new`: review a commit as the signer's own member,
+/// writing both its entity ref and its retention pin — or, when this
+/// member already has a review of an ancestor of the target, advancing
+/// that same review fast-forward (`model.review-pin`).
+///
+/// # Errors
+///
+/// [`crate::error::Error::Forge`] if `new.target` does not resolve to a
+/// commit, or serialization or `receive` itself fails for either ref; see
+/// [`crate::mutate::outcome_to_result`] for how a reached refusal renders.
+pub fn new(root: &LocalRoot, new: NewReview, key: Option<std::path::PathBuf>) -> Result<String> {
+ let signer = signer(root, key)?;
+ let member = reviewer_member_id(root, &signer)?;
+ let identity = Identity {
+ actor: actor(&signer),
+ sign: &|payload| signer.sign(payload),
+ };
+ let (target, outcome) = review::new(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ &root.path,
+ new,
+ &member,
+ &identity,
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(target)
+}
+
+/// The member id owning the signer's key — the composite review key's
+/// `<member>` segment — via the same key-to-member scan
+/// [`super::members::find_by_key`] already performs for `git ents members
+/// check`. When the signing key enrolls no member (`roots.local`'s
+/// advisory gate never requires enrollment before a local mutation lands),
+/// falls back to the same fingerprint-derived placeholder [`super::actor`]
+/// already uses for its own commit signature: a composite review key still
+/// needs *some* member segment, and `gate.owner-mutation` — not this
+/// fallback — is what actually keys ownership once a real deployment's
+/// mandatory gate is in force.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+fn reviewer_member_id(root: &LocalRoot, signer: &crate::sign::Signer) -> Result<MemberId> {
+ let pubkey = signer.public_openssh();
+ if let Some((username, _state)) = super::members::find_by_key(root, &pubkey)? {
+ return Ok(MemberId::new(username));
+ }
+ Ok(MemberId::new(super::short_fingerprint(signer)))
+}
+
+/// `git ents review list [--target rev]`: every review recorded in this
+/// repository, keyed by its composite `(target, member)` segments,
+/// optionally filtered to those reviewing `target`.
+///
+/// # Errors
+///
+/// Propagates a ref-store, object read, or revision-resolution failure.
+pub fn list(root: &LocalRoot, target: Option<String>) -> Result<Vec<((String, MemberId), Review)>> {
+ Ok(review::list(
+ &root.refs,
+ &root.objects,
+ &root.path,
+ target.as_deref(),
+ )?)
+}
+
+/// `git ents review show`: `target`/`member`'s review, plus its discussion
+/// thread.
+///
+/// # Errors
+///
+/// [`crate::error::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`])
+/// if `target`/`member` has no review ref.
+pub fn show(
+ root: &LocalRoot,
+ target: &str,
+ member: &str,
+) -> Result<(Review, Vec<(String, Comment)>)> {
+ Ok(review::show(
+ &root.refs,
+ &root.objects,
+ target,
+ &MemberId::new(member),
+ )?)
+}
crates/cli/git-ents/src/commands/serve.rs
@@ -1,0 +1,234 @@
+//! `git ents serve`: reuse [`LocalRoot`]'s existing wiring and add only
+//! the `ents-web` HTTP frontend, bound to loopback (`roots.local`).
+//!
+//! `roots.local` is explicit that this command MUST reuse the local
+//! root's own seams rather than construct a second one, and MUST NOT
+//! expose git's smart-HTTP transport in any form. This module upholds
+//! both: [`build_state`] is handed an already-open [`LocalRoot`] (never
+//! opens its own), and adds nothing but `ents_web::router()`'s own route
+//! table -- which carries no `/info/refs` or `git-upload-pack` surface at
+//! all (see `ents-web`'s own test coverage for that half).
+//!
+//! # Signing identity (`roots.web-signing`)
+//!
+//! `LocalIdentity` is the one place this crate bridges its own
+//! [`Signer`] to [`ents_web::identity::SigningIdentity`]: the local root
+//! signs every web edit with the user's own member key, resolved exactly
+//! as every other mutation command resolves it (`--key`, else
+//! `user.signingkey`, else the default `~/.ssh/id_ed25519`) — no
+//! server-key indirection exists anywhere in this module, which is
+//! exactly what keeps `roots.web-signing`'s hosted-only indirection from
+//! leaking into the local root. `LocalIdentity::label` additionally
+//! resolves the signer's own enrolled member (reusing
+//! `crate::commands::members::find_by_key`, the same key-match loop
+//! `git ents members check` runs), so the web shell's identity chip shows
+//! a username instead of [`actor`]'s fixed `"git-ents"` commit-author
+//! wordmark.
+
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use ents_web::identity::SigningIdentity;
+use ents_web::state::AppState;
+
+use super::actor;
+use crate::error::{Error, Result};
+use crate::root::LocalRoot;
+use crate::sign::Signer;
+
+/// Bridges [`Signer`] to [`SigningIdentity`]: the local root's half of
+/// `roots.web-signing`'s indirection (the user's own key, captured once
+/// at `serve` startup rather than re-resolved per request).
+// @relation(roots.web-signing, scope=file)
+struct LocalIdentity {
+ signer: Signer,
+ actor: gix::actor::Signature,
+ /// The web shell's identity-chip label (see [`SigningIdentity::label`]'s
+ /// own doc): the signer's enrolled member username when one matches,
+ /// its short key fingerprint otherwise — resolved once in
+ /// [`build_state`], not per request.
+ label: String,
+}
+
+impl SigningIdentity for LocalIdentity {
+ fn actor(&self) -> gix::actor::Signature {
+ self.actor.clone()
+ }
+
+ fn sign(&self, payload: &[u8]) -> String {
+ self.signer.sign(payload)
+ }
+
+ fn public_openssh(&self) -> String {
+ self.signer.public_openssh()
+ }
+
+ fn label(&self) -> String {
+ self.label.clone()
+ }
+}
+
+/// The loopback address `git ents serve` binds -- `roots.local` forbids
+/// this command from exposing anything but loopback, so there is no
+/// `--host` flag anywhere in [`crate::cli`] to override it.
+// @relation(roots.local, scope=function)
+fn loopback_addr(port: u16) -> SocketAddr {
+ SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)
+}
+
+/// The `<label>.localhost` hostname printed for the served repository:
+/// its directory name lowercased with every character outside
+/// `[a-z0-9]` folded to `-` (a DNS label), `repo` when nothing
+/// survives. `*.localhost` names resolve to loopback inside Firefox
+/// and Chrome themselves (RFC 6761) and count as a secure context, so
+/// the printed URL carries the repo's name instead of a bare
+/// `127.0.0.1` — same socket, nicer address bar. Safari delegates to
+/// the system resolver and needs an `/etc/hosts` line, which is why
+/// the raw bound address is still printed alongside.
+fn host_label(path: &std::path::Path) -> String {
+ // `LocalRoot::discover(".")` hands this a relative path whose
+ // file_name is `.`; canonicalize first so the label is the repo
+ // directory's real name, not the fallback.
+ let path = path.canonicalize().unwrap_or_else(|_io| path.to_path_buf());
+ let name = path
+ .file_name()
+ .map(|n| n.to_string_lossy().to_lowercase())
+ .unwrap_or_default();
+ let label: String = name
+ .chars()
+ .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
+ .collect();
+ let label = label.trim_matches('-');
+ if label.is_empty() {
+ "repo".to_owned()
+ } else {
+ label.to_owned()
+ }
+}
+
+/// Build the [`AppState`] `git ents serve` runs, from an already-open
+/// [`LocalRoot`] -- the one seam-wiring step `roots.composition` allows,
+/// and the only place this command touches `root`'s fields at all.
+///
+/// Split out from [`run`] so tests can drive the resulting state through
+/// `ents_web::router()` directly (via `tower::ServiceExt::oneshot`, per
+/// `roots.web-agnostic`) without binding a socket or blocking.
+///
+/// # Errors
+///
+/// Propagates a signing-key resolution failure ([`crate::sign::Signer`]).
+// @relation(roots.local, roots.composition, scope=function)
+pub fn build_state(
+ root: LocalRoot,
+ key: Option<PathBuf>,
+) -> Result<Arc<AppState<crate::root::Objects>>> {
+ let signer = super::signer(&root, key)?;
+ let pubkey = signer.public_openssh();
+ // The identity chip's label (`roots.web-signing`): reuse the same
+ // key-match loop `git ents members check` runs (`find_by_key`) rather
+ // than re-scanning `refs/meta/member/*` by hand, falling back to the
+ // signer's own short fingerprint when no enrolled member's key matches
+ // (an unenrolled local key, still allowed to browse and sign).
+ let label = super::members::find_by_key(&root, &pubkey)?
+ .map(|(username, _state)| username)
+ .unwrap_or_else(|| super::short_fingerprint(&signer));
+ let identity = LocalIdentity {
+ actor: actor(&signer),
+ label,
+ signer,
+ };
+ let mode = root.mode();
+ let LocalRoot {
+ path,
+ refs,
+ objects,
+ events,
+ executor: _,
+ } = root;
+ Ok(Arc::new(AppState::new(
+ Box::new(refs),
+ objects,
+ Box::new(events),
+ mode,
+ Box::new(identity),
+ path,
+ )))
+}
+
+/// Run `git ents serve`: bind loopback and block, serving the web UI
+/// until the process is killed.
+///
+/// # Errors
+///
+/// Propagates [`build_state`]'s own errors, or an [`Error::Io`] binding
+/// the loopback socket or constructing the async runtime.
+// @relation(roots.local, scope=function)
+pub fn run(
+ root: LocalRoot,
+ port: Option<u16>,
+ key: Option<PathBuf>,
+ mut report: impl std::io::Write,
+) -> Result<()> {
+ let label = host_label(&root.path);
+ let state = build_state(root, key)?;
+ let addr = loopback_addr(port.unwrap_or(4880));
+
+ let runtime = tokio::runtime::Runtime::new().map_err(|source| Error::Io {
+ path: PathBuf::from("<tokio runtime>"),
+ source,
+ })?;
+ runtime.block_on(async move {
+ let listener = ents_web::bind(addr).await.map_err(|source| Error::Io {
+ path: PathBuf::from(addr.to_string()),
+ source,
+ })?;
+ let bound = listener.local_addr().map_err(|source| Error::Io {
+ path: PathBuf::from(addr.to_string()),
+ source,
+ })?;
+ let _ = writeln!(
+ report,
+ "listening on http://{label}.localhost:{port} (http://{bound})",
+ port = bound.port()
+ );
+ ents_web::serve_on(listener, state)
+ .await
+ .map_err(|source| Error::Io {
+ path: PathBuf::from(addr.to_string()),
+ source,
+ })
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ #[case::plain_repo_name("git-ents", "git-ents")]
+ #[case::uppercase_and_dots("My_Repo.git", "my-repo-git")]
+ #[case::nothing_survives("...", "repo")]
+ fn host_label_folds_to_a_dns_label(#[case] dir: &str, #[case] expected: &str) {
+ assert_eq!(host_label(std::path::Path::new(dir)), expected);
+ }
+
+ /// `discover(".")` hands serve a relative path; the label must be the
+ /// directory's real name, never the `repo` fallback `.`'s empty
+ /// file_name would fold to.
+ #[rstest]
+ fn host_label_canonicalizes_a_relative_path() {
+ assert_ne!(host_label(std::path::Path::new(".")), "repo");
+ }
+
+ #[rstest]
+ // @relation(roots.local, scope=function, role=Verifies)
+ fn serve_only_ever_binds_loopback() {
+ assert_eq!(loopback_addr(4880).ip(), IpAddr::V4(Ipv4Addr::LOCALHOST));
+ assert_eq!(loopback_addr(0).ip(), IpAddr::V4(Ipv4Addr::LOCALHOST));
+ }
+}
crates/cli/git-ents/src/commands/setup.rs
@@ -1,0 +1,206 @@
+//! `git ents setup`: resolve or generate a signing key, record it as this
+//! repository's `user.signingkey` with `gpg.format=ssh`, and set
+//! `receive.denyCurrentBranch=updateInstead` (`roots.worktree-update`).
+//!
+//! `receive.denyCurrentBranch=updateInstead` is the integration-test
+//! harness edge case `roots.worktree-update` names: it lets an external
+//! push land on this repository's checked-out branch and still update the
+//! working tree, which is not how a normal git remote behaves and is never
+//! needed for `refs/meta/*` traffic (which never touches a worktree at
+//! all).
+//!
+//! `--hosted` ([`run_hosted`]) configures the single-node hosted root
+//! instead (`roots.single-node-hosted`): a signing key for the hosted
+//! worker, and this binary's own `hook pre-receive`/`hook post-receive`
+//! installed into a bare repository's `hooks/`. Without this, a hosted
+//! bare repository accepts every push completely ungated — stock git's
+//! `receive-pack` enforces nothing on its own; the gate exists only where
+//! a hook calls it.
+
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use rand_core::OsRng;
+use ssh_key::{Algorithm, LineEnding, PrivateKey};
+
+use crate::error::{Error, Result};
+use crate::root::LocalRoot;
+use crate::sign::Signer;
+
+/// Run `git ents setup` against `root`: resolve `key`, generating a new
+/// `~/.ssh/id_ed25519` if neither `key` nor `user.signingkey` resolves to
+/// an existing file, then write `user.signingkey`, `gpg.format=ssh`, and
+/// `receive.denyCurrentBranch=updateInstead` to the repository's own
+/// (local) config.
+///
+/// # Errors
+///
+/// [`Error::BadSigningKey`] if a given or configured key cannot be loaded;
+/// [`Error::Io`] if generating or writing a new key fails; propagates a
+/// config-write failure.
+pub fn run(root: &LocalRoot, key: Option<PathBuf>) -> Result<PathBuf> {
+ let resolved = resolve_or_generate_key(&root.path, key)?;
+ let path_str = resolved.to_string_lossy().into_owned();
+ for (key, value) in [
+ ("user.signingkey", path_str.as_str()),
+ ("gpg.format", "ssh"),
+ ("receive.denyCurrentBranch", "updateInstead"),
+ ] {
+ set_local_config(&root.path, key, value)?;
+ }
+ Ok(resolved)
+}
+
+/// Run `git ents setup --hosted` against the bare repository at `path`:
+/// resolve or generate a signing key for the hosted worker (recorded as
+/// `path`'s own `user.signingkey`/`gpg.format=ssh`, same as [`run`]), and
+/// install this binary's `hook pre-receive`/`hook post-receive` as
+/// `path`'s own git hooks (`roots.single-node-hosted`).
+///
+/// `receive.denyCurrentBranch=updateInstead` is deliberately not set here:
+/// it is the local-root, checked-out-worktree edge case
+/// (`roots.worktree-update`), meaningless for a bare repository with no
+/// worktree to update.
+///
+/// # Errors
+///
+/// [`Error::BadSigningKey`] if a given or configured key cannot be loaded;
+/// [`Error::Io`] if generating a key, writing config, resolving this
+/// binary's own path, or writing a hook file fails.
+// @relation(roots.single-node-hosted, scope=function)
+pub fn run_hosted(path: &Path, key: Option<PathBuf>) -> Result<PathBuf> {
+ let resolved = resolve_or_generate_key(path, key)?;
+ let path_str = resolved.to_string_lossy().into_owned();
+ for (key, value) in [
+ ("user.signingkey", path_str.as_str()),
+ ("gpg.format", "ssh"),
+ ] {
+ set_local_config(path, key, value)?;
+ }
+ install_hooks(path)?;
+ Ok(resolved)
+}
+
+/// Resolve `key` (or `path`'s `user.signingkey`, or a default
+/// `~/.ssh/id_ed25519`), generating a fresh key if nothing resolves to an
+/// existing file, and confirm the result actually loads.
+fn resolve_or_generate_key(path: &Path, key: Option<PathBuf>) -> Result<PathBuf> {
+ let repo = gix::open(path)?;
+ let resolved = match crate::sign::resolve_key_path(&repo, key.as_deref()) {
+ Ok(candidate) if candidate.exists() => candidate,
+ Ok(candidate) => generate_key(&candidate)?,
+ Err(Error::NoSigningKey) => {
+ let default = default_key_path()?;
+ generate_key(&default)?
+ }
+ Err(other) => return Err(other),
+ };
+ // Confirm the resolved key actually loads before recording it.
+ Signer::load(&resolved)?;
+ Ok(resolved)
+}
+
+/// Install this binary's own `hook pre-receive`/`hook post-receive` as
+/// `repo_path`'s git hooks, overwriting any existing scripts of the same
+/// name — the mechanism `roots.single-node-hosted` requires: without
+/// these hooks, git's own `receive-pack` performs no gate check at all,
+/// and a hosted bare repository would accept every push ungated.
+///
+/// # Errors
+///
+/// [`Error::Io`] if this binary's own path cannot be resolved, the
+/// `hooks/` directory cannot be created, or a hook file cannot be written
+/// or (on unix) made executable.
+fn install_hooks(repo_path: &Path) -> Result<()> {
+ let this_binary = std::env::current_exe().map_err(|source| Error::Io {
+ path: repo_path.to_owned(),
+ source,
+ })?;
+ let hooks_dir = repo_path.join("hooks");
+ std::fs::create_dir_all(&hooks_dir).map_err(|source| Error::Io {
+ path: hooks_dir.clone(),
+ source,
+ })?;
+ for hook in ["pre-receive", "post-receive"] {
+ let script = format!("#!/bin/sh\nexec {:?} hook {hook}\n", this_binary.display());
+ let hook_path = hooks_dir.join(hook);
+ std::fs::write(&hook_path, script).map_err(|source| Error::Io {
+ path: hook_path.clone(),
+ source,
+ })?;
+ set_executable(&hook_path)?;
+ }
+ Ok(())
+}
+
+#[cfg(unix)]
+fn set_executable(path: &Path) -> Result<()> {
+ use std::os::unix::fs::PermissionsExt as _;
+ let mut perms = std::fs::metadata(path)
+ .map_err(|source| Error::Io {
+ path: path.to_owned(),
+ source,
+ })?
+ .permissions();
+ perms.set_mode(0o755);
+ std::fs::set_permissions(path, perms).map_err(|source| Error::Io {
+ path: path.to_owned(),
+ source,
+ })
+}
+
+#[cfg(not(unix))]
+fn set_executable(_path: &Path) -> Result<()> {
+ Ok(())
+}
+
+/// Set `key` to `value` in `repo_path`'s own local config via `git config`.
+fn set_local_config(repo_path: &Path, key: &str, value: &str) -> Result<()> {
+ let output = Command::new("git")
+ .arg("-C")
+ .arg(repo_path)
+ .args(["config", "--local", key, value])
+ .output()
+ .map_err(|source| Error::Io {
+ path: repo_path.to_owned(),
+ source,
+ })?;
+ if !output.status.success() {
+ return Err(Error::BadSigningKey {
+ path: repo_path.to_owned(),
+ detail: format!(
+ "git config --local {key} {value} failed: {}",
+ String::from_utf8_lossy(&output.stderr)
+ ),
+ });
+ }
+ Ok(())
+}
+
+/// Generate a fresh, unencrypted ed25519 key at `path` (creating parent
+/// directories as needed) and return `path` unchanged.
+fn generate_key(path: &Path) -> Result<PathBuf> {
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).map_err(|source| Error::Io {
+ path: parent.to_owned(),
+ source,
+ })?;
+ }
+ let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).map_err(|source| {
+ Error::BadSigningKey {
+ path: path.to_owned(),
+ detail: source.to_string(),
+ }
+ })?;
+ key.write_openssh_file(path, LineEnding::LF)
+ .map_err(|source| Error::BadSigningKey {
+ path: path.to_owned(),
+ detail: source.to_string(),
+ })?;
+ Ok(path.to_owned())
+}
+
+fn default_key_path() -> Result<PathBuf> {
+ let home = std::env::var_os("HOME").ok_or(Error::NoSigningKey)?;
+ Ok(PathBuf::from(home).join(".ssh").join("id_ed25519"))
+}
crates/cli/git-ents/src/commands/toolchain.rs
@@ -1,0 +1,75 @@
+//! `git ents toolchain`: a thin wrapper around `ents_kiln::toolchain`'s
+//! business logic — this module only resolves the signer/actor identity
+//! against [`LocalRoot`] and translates a reached `Outcome` into a
+//! CLI-facing [`Result`] (`crate::mutate::outcome_to_result`), exactly as
+//! every other mutation command does.
+
+use ents_kiln::{Recipe, Toolchain, toolchain};
+use ents_receive::Identity;
+
+use super::{actor, signer};
+use crate::error::Result;
+use crate::mutate::outcome_to_result;
+use crate::root::LocalRoot;
+
+/// `git ents toolchain list`: every toolchain name currently defined.
+///
+/// # Errors
+///
+/// Propagates a ref-store read failure.
+pub fn list(root: &LocalRoot) -> Result<Vec<String>> {
+ Ok(toolchain::list(&root.refs)?)
+}
+
+/// `git ents toolchain import`: embed `bin` whole as toolchain `name`.
+///
+/// # Errors
+///
+/// [`crate::error::Error::Effect`] if `bin` cannot be walked, or
+/// serialization or `receive` itself fails; see
+/// [`crate::mutate::outcome_to_result`] for how a reached refusal renders.
+pub fn import(
+ root: &LocalRoot,
+ name: &str,
+ bin: &std::path::Path,
+ key: Option<std::path::PathBuf>,
+) -> Result<()> {
+ let signer = signer(root, key)?;
+ let identity = Identity {
+ actor: actor(&signer),
+ sign: &|payload| signer.sign(payload),
+ };
+ let outcome = toolchain::import(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ bin,
+ name,
+ &identity,
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(())
+}
+
+/// `git ents toolchain view`: the toolchain's recorded recipe.
+///
+/// # Errors
+///
+/// [`crate::error::Error::Effect`] (wrapping
+/// [`ents_effect::Error::UnknownToolchain`]) if `name` has no toolchain
+/// ref.
+pub fn view(root: &LocalRoot, name: &str) -> Result<(Toolchain, Recipe)> {
+ Ok(toolchain::view(&root.refs, &root.objects, name)?)
+}
+
+/// `git ents toolchain log`: every past import, newest first — the ref's
+/// own commit log.
+///
+/// # Errors
+///
+/// [`crate::error::Error::Effect`] (wrapping [`ents_effect::Error::NotFound`])
+/// if `name` has no toolchain ref.
+pub fn log(root: &LocalRoot, name: &str) -> Result<Vec<gix_hash::ObjectId>> {
+ Ok(toolchain::log(&root.refs, &root.objects, name)?)
+}
crates/cli/git-ents/src/error.rs
@@ -1,0 +1,174 @@
+//! The porcelain-wide error type: every subcommand's failure, rendered for
+//! a terminal.
+
+use std::path::PathBuf;
+
+/// Every way a `git-ents` subcommand can fail.
+///
+/// Each variant documents when it occurs and what the user should do —
+/// this is the only layer that renders a failure for a human, so the
+/// detail belongs here rather than in a library crate's own error type.
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ /// The current directory is not inside a git repository, or the
+ /// discovered repository has no `.git` directory `git-ents` can open.
+ /// Run the command inside a git repository.
+ #[error("not a git repository (or any parent up to mount point): {path}")]
+ NotARepo {
+ /// The directory `git-ents` started looking from.
+ path: PathBuf,
+ },
+
+ /// No signing key could be resolved: `--key` was not given,
+ /// `user.signingkey` is unset, and no default key exists at
+ /// `~/.ssh/id_ed25519`. Run `git ents setup` first.
+ #[error("no signing key configured; run `git ents setup` or pass --key")]
+ NoSigningKey,
+
+ /// The signing key at `path` could not be read as an OpenSSH private
+ /// key, or is passphrase-protected (unsupported in this phase: use an
+ /// unencrypted key, or load one via `ssh-agent` in a future phase).
+ #[error("cannot use signing key at {path}: {detail}")]
+ BadSigningKey {
+ /// The key file that failed to load.
+ path: PathBuf,
+ /// What went wrong.
+ detail: String,
+ },
+
+ /// The gate refused the proposed mutation (`gate.verdict-reason`): the
+ /// refusal's own rendering names the rule and offers the inbox
+ /// alternative when one applies.
+ #[error("rejected: {0}")]
+ Refused(String),
+
+ /// `receive` rejected the batch as a stale compare-and-swap: another
+ /// writer moved a ref between read and write. Retry the command.
+ #[error("rejected: {name} changed concurrently, retry")]
+ Stale {
+ /// The ref whose precondition was stale.
+ name: String,
+ },
+
+ /// A previously redacted object would have been refilled by this
+ /// mutation (`receive.redaction-ingest`); the mutation is refused.
+ #[error("refused: object {oid} was redacted and cannot be refilled")]
+ Redacted {
+ /// The redacted object id.
+ oid: gix_hash::ObjectId,
+ },
+
+ /// The named entity (member, effect, toolchain, comment, inbox entry)
+ /// does not exist.
+ #[error("not found: {what}")]
+ NotFound {
+ /// What was being looked up.
+ what: String,
+ },
+
+ /// A local (non-git, non-gate) I/O failure: reading or writing a file
+ /// outside the object database.
+ #[error("io error at {path}: {source}")]
+ Io {
+ /// The path being read or written.
+ path: PathBuf,
+ /// The underlying I/O failure.
+ #[source]
+ source: std::io::Error,
+ },
+
+ /// A malformed command-line argument that passed `figue`'s own parsing
+ /// but fails a semantic check this crate makes (an invalid line range,
+ /// an unparsable oid, ...).
+ #[error("invalid argument: {0}")]
+ InvalidArgument(String),
+
+ /// Opening or reading the local git repository failed. Boxed (like the
+ /// other large variants below): `gix::open::Error` is large enough on
+ /// its own to trip `clippy::result_large_err` for every fallible
+ /// function in this crate if stored inline, the same reasoning
+ /// `ents-effect`'s own error type documents for its boxed
+ /// `ents_receive::Error` variant.
+ #[error(transparent)]
+ Repo(Box<gix::open::Error>),
+
+ /// A `gix-ref-store` failure: reading or writing a ref.
+ #[error(transparent)]
+ Refs(#[from] gix_ref_store::Error),
+
+ /// An `ents-gate` failure: the gate itself could not reach a verdict
+ /// (a store or object read failed), distinct from a reached refusal.
+ #[error(transparent)]
+ Gate(#[from] ents_gate::Error),
+
+ /// An `ents-receive` failure: `receive` itself could not reach an
+ /// outcome. Boxed; see [`Error::Repo`]'s own doc.
+ #[error(transparent)]
+ Receive(Box<ents_receive::Error>),
+
+ /// An `ents-effect` failure: toolchain resolution, materialization, or
+ /// the executor itself. Boxed; see [`Error::Repo`]'s own doc.
+ #[error(transparent)]
+ Effect(Box<ents_effect::Error>),
+
+ /// An `ents-anchor` failure: capturing or projecting a code anchor.
+ #[error(transparent)]
+ Anchor(#[from] ents_anchor::Error),
+
+ /// An `ents-sync` failure: pre-flight, routing, or merge. Boxed; see
+ /// [`Error::Repo`]'s own doc.
+ #[error(transparent)]
+ Sync(Box<ents_sync::Error>),
+
+ /// An `ents-model` failure: building or validating a refname or typed
+ /// tree.
+ #[error(transparent)]
+ Model(#[from] ents_model::Error),
+
+ /// A `facet-git-tree` (de)serialization failure.
+ #[error(transparent)]
+ Tree(#[from] facet_git_tree::Error),
+
+ /// A raw object-store write failed (building a toolchain import's tree,
+ /// or a mutation commit).
+ #[error(transparent)]
+ ObjectWrite(#[from] gix_object::write::Error),
+
+ /// An `ents-forge` failure: anchoring, serializing, or proposing a
+ /// comment mutation. Boxed; see [`Error::Repo`]'s own doc.
+ #[error(transparent)]
+ Forge(Box<ents_forge::Error>),
+}
+
+impl From<gix::open::Error> for Error {
+ fn from(source: gix::open::Error) -> Self {
+ Self::Repo(Box::new(source))
+ }
+}
+
+impl From<ents_receive::Error> for Error {
+ fn from(source: ents_receive::Error) -> Self {
+ Self::Receive(Box::new(source))
+ }
+}
+
+impl From<ents_effect::Error> for Error {
+ fn from(source: ents_effect::Error) -> Self {
+ Self::Effect(Box::new(source))
+ }
+}
+
+impl From<ents_sync::Error> for Error {
+ fn from(source: ents_sync::Error) -> Self {
+ Self::Sync(Box::new(source))
+ }
+}
+
+impl From<ents_forge::Error> for Error {
+ fn from(source: ents_forge::Error) -> Self {
+ Self::Forge(Box::new(source))
+ }
+}
+
+/// This crate's `Result` alias.
+pub type Result<T> = std::result::Result<T, Error>;
Diff truncated (over 1 MiB).