git-ents.gitmain
⌘K
foforge
commit 187a5a3
Merge branch 'web-ui-polish'
Joseph D. Carpinelli · 1 month ago

Reviews

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

Start a review

verdict

.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(&params.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(&params.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(&params.command, &params.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(&params.text_document.uri); + // Clear this document's diagnostics on close. + self.publish_list(&params.text_document.uri, Vec::new()); + } + } + DidSaveTextDocument::METHOD => { + if let Ok(params) = extract_notification::<DidSaveTextDocumentParams>(notification) + { + match self.lens.did_save(&params.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 &current { + 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 &sections { (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 +/// (`&lt;`, `&amp;`, ...) -- [`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 &lt; 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 &lt; 2"); + assert_eq!( + tokens, + vec![ + Token::Open("keyword"), + Token::Text("fn"), + Token::Close, + Token::Text(" 1 &lt; 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\">&lt;</span>\nnext"; + let lines = split_highlighted_lines(html, 2); + assert_eq!( + lines, + vec![ + "<span class=\"operator\">&lt;</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 &lt; 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).