diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 215fa562f..989ff52b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ on: - feat/keiko-voice-digital-twin - feat/keiko-isolated-task-workspaces - feat/keiko-colleague-like-voice-dialogue-mode + - feat/keiko-repository-centered-desktop-workflow - "release/**" pull_request: branches: @@ -22,6 +23,7 @@ on: - feat/keiko-voice-digital-twin - feat/keiko-isolated-task-workspaces - feat/keiko-colleague-like-voice-dialogue-mode + - feat/keiko-repository-centered-desktop-workflow - "release/**" workflow_dispatch: @@ -36,7 +38,7 @@ jobs: - name: Confirm dev branch gate run: | case "${{ github.ref }}:${{ github.base_ref }}" in - refs/heads/dev: | refs/heads/feat/keiko-editor: | refs/heads/feat/keiko-agent-native-editor-foundation-and-runtime: | refs/heads/feat/prompt-enhancer-1307: | refs/heads/feat/keiko-establish-governed-end-to-end-git-delivery: | refs/heads/feat/keiko-voice-digital-twin: | refs/heads/feat/keiko-isolated-task-workspaces: | refs/heads/feat/keiko-colleague-like-voice-dialogue-mode: | refs/heads/release/*: | *:dev | *:feat/keiko-editor | *:feat/keiko-agent-native-editor-foundation-and-runtime | *:feat/prompt-enhancer-1307 | *:feat/keiko-establish-governed-end-to-end-git-delivery | *:feat/keiko-voice-digital-twin | *:feat/keiko-isolated-task-workspaces | *:feat/keiko-colleague-like-voice-dialogue-mode | *:release/*) + refs/heads/dev: | refs/heads/feat/keiko-editor: | refs/heads/feat/keiko-agent-native-editor-foundation-and-runtime: | refs/heads/feat/prompt-enhancer-1307: | refs/heads/feat/keiko-establish-governed-end-to-end-git-delivery: | refs/heads/feat/keiko-voice-digital-twin: | refs/heads/feat/keiko-isolated-task-workspaces: | refs/heads/feat/keiko-colleague-like-voice-dialogue-mode: | refs/heads/feat/keiko-repository-centered-desktop-workflow: | refs/heads/release/*: | *:dev | *:feat/keiko-editor | *:feat/keiko-agent-native-editor-foundation-and-runtime | *:feat/prompt-enhancer-1307 | *:feat/keiko-establish-governed-end-to-end-git-delivery | *:feat/keiko-voice-digital-twin | *:feat/keiko-isolated-task-workspaces | *:feat/keiko-colleague-like-voice-dialogue-mode | *:feat/keiko-repository-centered-desktop-workflow | *:release/*) echo "Protected or integration branch gate satisfied." ;; *) @@ -76,6 +78,7 @@ jobs: npm --workspace @oscharko-dev/keiko-editor test - run: npm run typecheck - run: npm run check:version-consistency + - run: npm run check:git-client-evidence - run: npm run lint - run: npm run arch:check - run: npm run arch:check:negative diff --git a/docs/adr/ADR-0098-git-client-repository-state-and-sync-api.md b/docs/adr/ADR-0098-git-client-repository-state-and-sync-api.md new file mode 100644 index 000000000..3c53fe4a5 --- /dev/null +++ b/docs/adr/ADR-0098-git-client-repository-state-and-sync-api.md @@ -0,0 +1,375 @@ +# ADR-0098: Git client repository state, history, remotes, and fetch/pull sync API — read-route reuse plus a non-governing sync executor with a sibling evidence ledger + +## Status + +Accepted (Issue #1573, Epic #1572, 2026-06-27) + +## Version + +0.2.0 + +## Context + +Epic #1572 redesigns Keiko's local Git surface into a single GitHub-Desktop-inspired window. +The reuse contract for that epic (`docs/git-delivery/git-client-desktop-reuse-contract.md`) +audited every Desktop flow against an existing Keiko building block and found that **most of the +surface is already satisfied by reuse**: the Changes list, per-file/scope diff, branch list, and +clone reads exist as `GET /api/git/status` / `/api/git/diff` / `/api/git/branches` in +`gitRoutes.ts`; the entire governed mutation/publish/PR/merge/evidence write surface exists under +`gitDelivery/*` and the `keiko-tools` gateways; and the window registry, rail, and design tokens +exist in `keiko-ui`. The write contract is frozen — no child issue adds a BFF mutation route. + +Issue #1573 is the **API foundation** child. Its job is precisely the small set of genuine gaps the +reuse contract isolated in its Section 3, plus the read-only/preview/execute sync surface the +History and Sync panes need. It is backend + contracts only; it ships no UI, and it changes no +existing route or contract. + +Three forces shape this ADR. + +**Force 1 — Three server-side reads are genuine gaps, not parallel implementations.** The reuse +contract §3 named exactly three reads that do not exist today and that the History tab, remote +management, and the Fetch/Pull/Push sync banner require: + +1. **Commit history / log.** No `git log` endpoint exists; `gitRoutes.ts` exposes status/diff/ + branches only. The History pane cannot render a commit timeline or per-commit detail without + one. (Hand-off: #1573 produces it, #1576 consumes it.) +2. **Remotes list.** `repositoryUrlAllowed`/clone handle a clone URL, but there is no `git remote -v` + read endpoint to enumerate the configured remotes and fetch/push URLs of an already-open + repository. The repository and sync surfaces need this. (Hand-off: #1573 → #1576.) +3. **Ahead/behind sync state.** The existing `parseBranch` deliberately strips the upstream tracking + segment and `parseBranches` returns local `refs/heads` only with no upstream, so there is no + ahead/behind/upstream datum for a fetch/pull/push sync UI. (Hand-off: #1573 → #1576 sync banner.) + +Each gap is a real missing read, not a re-derivation of something that already exists. The reuse +contract §3 mandated that each be added as a **sibling `GET` in `gitRoutes.ts`** reusing +`resolveRepository`, the hardened runner, `redacted()`, and the `GitRouteOptions` byte-cap/timeout +pattern, registered alongside the existing reads. This ADR ratifies that placement. + +**Force 2 — Fetch and pull are network Git, but they are not governed mutations.** The History and +Remotes gaps are pure local reads, but the Sync banner additionally needs to **execute** a fetch or +a pull. The governed Git delivery stack already has an execution kernel (`runGitMutation`, ADR-0081) +fronted by the closed `GitDeliveryActionKind` taxonomy (ADR-0080). The naive move would be to add +`fetch`/`pull` as two new action kinds and route them through the kernel. The #1572 reuse contract +§3 forbids this: the governed write contract is **frozen**, and the action-kind taxonomy is a +control-plane invariant the governed epic (#470) holds. `GitDeliveryActionKind` carries no +fetch/pull, and adding them would force a change across every exhaustive per-kind policy/risk/ +preview table, widening the governed authority surface for two operations that — unlike a push, +commit, or merge — do not write to the local refs the governance model exists to protect a +fast-forward-only pull updates tracking refs and fast-forwards the current branch; a fetch updates +only remote-tracking refs. This is the deliberate reuse boundary D4 records. + +**Force 3 — Sync must still be evidence-compatible and content-free.** Declining to govern fetch/pull +through the kernel must not mean declining to audit them. The governed stack's evidence pattern +(`mutationEvidenceLedger.ts`, ADR-0083) — one date-bucketed document per UTC day via +`EvidenceStore.update ?? get+put`, `deepRedactStrings`, a bounded bucket, fail-closed on corruption, +best-effort so an audit write never throws into the caller — is the proven reuse target. Sync mirrors +it in a **sibling** module rather than extending the mutation ledger, because the mutation ledger's +records are keyed to kernel outcomes and the fetch/pull outcome taxonomy is different (D5). + +### Scope boundary (Issue #1573) + +In scope: three read contracts (`git-repository-summary.ts`, `git-history.ts`, plus the read fields +of `git-sync.ts`) and the sync request/response/outcome contracts; the three read routes +(`gitRepositoryReads.ts`) plus a shared porcelain-v2 parser (`gitPorcelainStatus.ts`); the +non-governing sync executor (`gitDelivery/syncExecution.ts`), its sibling evidence ledger +(`gitDelivery/syncEvidence.ts`), and its routes (`gitDelivery/syncRoutes.ts`); the barrel exports and +route registrations; tests; and this documentation. + +Out of scope: any UI (deferred to #1574–#1578); any change to an existing route, contract, or the +governed mutation taxonomy; conflict resolution, merge, or push (push remains the governed publish +gateway, ADR-0085); and any package version bump (all new exports are additive). + +## Decision + +### D1 — Three additive read contracts reusing the existing repository-state unions + +Three new strict-leaf contract modules are added in `keiko-contracts`, each pure (no filesystem, +process, clock, or crypto) and each reusing the `GitRepositoryState`, `GitUnavailableReason`, and +`GitRepositoryValidation` unions already exported by `git-repository.ts` (Issue #1386). No existing +union is changed. + +- `git-repository-summary.ts` — `GitRepositorySummary` (branch, detached, `GitUpstreamSummary`, + ahead/behind, staged/unstaged/untracked/conflicted counts, clean flag, `GitRemoteSummary[]`, + optional `GitLastSyncMetadata`, truncated) and a dedicated `GitRemotesResponse` reusing + `GitRemoteSummary`. `GIT_REPOSITORY_SUMMARY_SCHEMA_VERSION = "1"`. Validators + `validateGitRepositorySummary` / `validateGitRemotesResponse`. +- `git-history.ts` — `GitHistoryEntry` (sha, shortSha, subject, author, ISO date, refs[], + parentCount, changedFileCount) and the paginated `GitHistoryResponse` (entries, limit, skip, + truncated). `GIT_HISTORY_SCHEMA_VERSION = "1"`. Validator `validateGitHistoryResponse`. +- `git-sync.ts` — the sync contracts (D3): `GitSyncOperation`, `GitSyncOutcome` (13 members), + `GitSyncBlockReason`, `GitSyncExecuteRequest`, `GitSyncPreview`, `GitSyncExecuteResponse`, + `GIT_SYNC_SCHEMA_VERSION = "1"`, the frozen `GIT_SYNC_OPERATIONS` / `GIT_SYNC_OUTCOMES` / + `GIT_SYNC_BLOCK_REASONS` arrays, the `isGitSyncOperation` / `isGitSyncOutcome` guards, and the + `validateGitSyncPreview` / `validateGitSyncExecuteResponse` validators. `GitSyncPreview` imports + `GitUpstreamSummary` from `git-repository-summary.ts`, so the upstream shape is defined once. + +The barrel (`index.ts`) re-exports each module in its own `export type {…}` + `export {…}` block, +following the existing `git-repository.js` block (isolatedModules: runtime values in the value block, +types in the type block). The exports are additive; `KEIKO_CONTRACTS_VERSION` is untouched. + +### D2 — The read routes reuse the hardened `gitRoutes.ts` seams unchanged in behavior + +`gitRepositoryReads.ts` adds three GET handlers — `handleGitSummary` (`/api/git/summary`), +`handleGitHistory` (`/api/git/history`), `handleGitRemotes` (`/api/git/remotes`) — each wrapped in +`runFilesHandler` and each reusing the existing `gitRoutes.ts` building blocks: + +- `resolveRepository(ctx, deps, options)` for selected-root containment, `rev-parse --show-toplevel`, + and unsafe-owner / missing classification. An unavailable resolution short-circuits to a + content-free `available: false` envelope with zeroed counts and empty remotes. +- `optionsWithDefaults` for the byte-cap/timeout normalization, and `options.runner` (defaulting to + `defaultGitProcessRunner`) for the bounded process effect through fixed argv and the hardened + `gitEnv()` (`GIT_TERMINAL_PROMPT=0`, `GIT_PAGER=cat`, `GIT_CONFIG_NOSYSTEM=1`, + `GIT_CONFIG_GLOBAL=/dev/null`, no system/global config), with spawn-error → exit code 127 and a + byte-cap/timeout truncation flag. +- `classifyFailure` to map a non-zero status read to the existing reason union (`git-missing`, + `unsafe-repository`, `not-a-repository`, `git-error`). +- `deps.redactor` applied to every response body, so any URL inside a remote is redacted at the + boundary. + +The only edit to `gitRoutes.ts` is **behavior-preserving**: `resolveRepository`, +`optionsWithDefaults`, `classifyFailure`, `interface RepositoryContext`, and +`interface NormalizedGitRouteOptions` gain `export` so the sibling file can consume them. No logic +in `gitRoutes.ts` changes, and the existing `/api/git/status` / `/api/git/diff` / `/api/git/branches` +routes are byte-for-byte unchanged. + +The three routes are registered in `routes.ts` immediately after `/api/git/branches`, each as +`handler: (ctx, deps) => handleX(ctx, deps, deps.gitRouteOptions)`, matching the existing reads' +injection of `gitRouteOptions` so tests can supply a fake runner. + +### D3 — A shared porcelain-v2 parser is the single source of branch/ahead-behind/dirty truth + +`gitPorcelainStatus.ts` exports `parsePorcelainV2Branch(stdout)` returning a `PorcelainV2Status` +(branch, detached, `GitUpstreamSummary`, ahead, behind, staged/unstaged/untracked/conflicted counts, +dirty). It parses `git status --porcelain=v2 --branch -z`: the `# branch.head` / `# branch.upstream` +/ `# branch.ab` headers, the ordinary (`1 `) and rename/copy (`2 `) XY change records (X = index/ +staged status, Y = worktree/unstaged status), the unmerged (`u `) and untracked (`? `) records, and +the extra NUL-separated original-path field that rename records carry. Both `gitRepositoryReads.ts` +(for `handleGitSummary`) and `gitDelivery/syncExecution.ts` (for the sync preview and post-op +re-read) consume this one parser, so the ahead/behind/dirty semantics are audited in one place and +cannot diverge between the read and sync surfaces. + +### D4 — Fetch/pull deliberately do NOT enter `GitDeliveryActionKind` / `runGitMutation` + +This is the load-bearing reuse-vs-new boundary. `gitDelivery/syncExecution.ts` runs fetch/pull +through a dedicated bounded executor, **not** the #472 kernel: + +- It reuses `defaultGitProcessRunner` (the same hardened, fixed-argv, fixed-env, byte-capped, + timeout-bounded runner the read routes use) but composes its own argv: + `fetch --no-tags [remote]` and `pull --ff-only --no-edit [remote]`. The `--ff-only` flag makes a + pull refuse anything but a fast-forward, so a pull can never create a merge commit or rewrite local + history outside the governed surface. +- It does **not** import `runGitMutation`, the policy packs, the approval-token gate, or any + `GitDeliveryActionKind`. `GitDeliveryActionKind` carries no `fetch`/`pull` member, and this ADR + does not add one. + +The rationale is reuse-vs-new discipline (the Stop Condition against widening a frozen control +plane): adding two action kinds would ripple through every exhaustive per-kind policy/risk/preview/ +recovery table in the governed contract and would extend the kernel's authority to two operations +the governance model was not built to mediate. A fetch writes only remote-tracking refs; a +fast-forward-only pull advances the current branch by replay, never by a mutation the kernel's +preflight/approval lifecycle is needed to guard. The control surface fetch/pull actually need is the +fixed argv plus the hardened environment of the reused runner, which is exactly what this executor +provides — without taking on the kernel's heavier governance machinery or weakening it by widening +its taxonomy. The deliberate boundary is documented here so a future maintainer does not "fix" the +asymmetry by routing fetch/pull through the kernel. + +#### D4a — Two process environments: hardened for local reads, credential-capable for network sync + +The reused runner is parameterized over its environment by a small factory, +`createGitProcessRunner(buildEnv)`, which holds the unchanged spawn / byte-cap / timeout / +spawn-error-to-127 machinery and takes the environment as its only seam. Two runners are built from +it, because a **local read** and a **network sync** have opposite credential requirements: + +- **`gitEnv` (local reads — `defaultGitProcessRunner`).** Fully config-isolated: `HOME` and + `XDG_CONFIG_HOME` are pointed at `/nonexistent`, `GIT_CONFIG_GLOBAL` at the null device, and + `GIT_CONFIG_NOSYSTEM=1`. A `status`/`diff`/`branches`/`summary`/`history`/`remote` read never + authenticates to a remote, so it must not be able to load a user `~/.gitconfig`, a credential + helper, or an SSH identity. This is the correct, unchanged behavior for every read route and for + the sync preview and the post-op ahead/behind re-read. +- **`networkGitEnv` (the fetch/pull command only — `defaultGitNetworkProcessRunner`).** A fetch or + pull against a private or SSH remote *must* be able to authenticate, so this env inherits the real + process environment (the user's global `~/.gitconfig` `credential.helper`, the macOS `osxkeychain` + helper, and the real `~/.ssh` identities). It still **never prompts**: `GIT_TERMINAL_PROMPT=0` and + `GIT_SSH_COMMAND="ssh -oBatchMode=yes -oStrictHostKeyChecking=yes"` force the operation to + **fail closed** when no stored credential satisfies the remote (`auth-failed`) or when SSH cannot + verify a known host key (`untrusted-host-key`), rather than hang on an interactive prompt or trust + a first-use host implicitly. + +`syncExecution.ts` therefore resolves two runners from its seams: the local read runner +(`seams.runner ?? defaultGitProcessRunner`) drives `buildSyncPreview` and the pre/post ahead-behind +re-reads, while the network runner (`seams.runner ?? defaultGitNetworkProcessRunner`) drives **only** +the actual `git fetch` / `git pull` command. Because tests inject `seams.runner`, both runners +collapse to the single fake runner under test, so determinism is preserved; only in production do the +two environments diverge. This corrects a latent availability defect: routing the network command +through the config-isolated `gitEnv` hid every stored credential and pointed SSH key discovery at +`/nonexistent/.ssh`, so an authenticated fetch/pull failed `auth-failed` regardless of valid +credentials. The split keeps the security posture of the local reads exactly as before while making +sync functional for private and SSH remotes. The deliberate output-display follow-ups (the shared +redactor not stripping the colon-less `https://@host` token form, and git-output strings not +being stripped of bidi/zero-width characters before reaching the browser) are recorded as +low-severity items and are out of scope for #1573. + +### D5 — Sync is evidence-compatible through a sibling ledger, not the mutation ledger + +`gitDelivery/syncEvidence.ts` is a sibling of `mutationEvidenceLedger.ts` and mirrors its persistence +shape exactly: ONE document per UTC date bucket (run id `git-sync-evidence-YYYY-MM-DD` via +`gitSyncEvidenceRunIdFor`), written through `EvidenceStore.update ?? get+put`, redacted leaf-by-leaf +with `deepRedactStrings`, bounded to the most recent N records +(`GIT_SYNC_EVIDENCE_DEFAULT_BUCKET_CAP = 500`), fail-closed on a corrupt bucket (it throws rather +than overwrite existing audit evidence), and best-effort (`recordGitSyncEvidence` never throws into +the caller; a persistence failure is reported through an injectable `onPersistError` sink). + +The record (`GitSyncEvidenceRecord`) is content-free by construction: the operation, the typed +`GitSyncOutcome`, a content-free `repoIdHash = sha256Hex(workspace.root).slice(0, 24)` (never the +path itself), the branch and remote **names**, the ahead/behind counts before and after, and an +epoch-ms timestamp. No URL, secret, or command output enters the record. A separate ledger (not an +extension of the mutation ledger) keeps the two outcome taxonomies — kernel terminal status vs the +fetch/pull `GitSyncOutcome` — structurally distinct, consistent with the domain-separation precedent +in ADR-0083. + +### D6 — The sync routes mirror the push route structure but never govern + +`gitDelivery/syncRoutes.ts` mirrors `pushRoutes.ts`: the same bounded body read +(`readGitDeliveryBody`), allowed-key whitelist (`schemaVersion`, `projectId`, `remote`), credential- +shape and unsafe-format-char scans (`scanForbiddenStrings` / `scanUnsafeFormatChars`), an +`isSafeGitRef` operand guard on the optional remote alias (rejecting whitespace, a leading `-`, a +`:`, and control characters so a malformed remote is a clean 400 rather than an internal error), a +configured-remote check that prevents the optional alias from becoming an arbitrary Git +`` operand, +content-free typed error envelopes (`GitDeliverySyncErrorCode`), and a +`createGitDeliverySyncRouteGroup(options)` factory with an injectable `execution` seam plus a +`GIT_DELIVERY_SYNC_ROUTE_GROUP` default export. CSRF and JSON content-type are enforced **centrally** +in `server.ts` for every POST and are not re-checked here. + +The group registers four POST routes: + +- `/api/git-delivery/fetch/preview` and `/api/git-delivery/pull/preview` — READ-ONLY readiness. They + resolve the workspace through `resolveProjectWorkspace(deps, projectId)` (404 when the project is + unknown), run `buildSyncPreview` (a 409 when the worktree cannot be inspected), and return the + redacted `GitSyncPreview`. They never mutate and never record evidence. +- `/api/git-delivery/fetch/execute` and `/api/git-delivery/pull/execute` — require a successful + executable pre-op preview before network Git runs. Inspectable blocked previews return a typed + `GitSyncOutcome` and append content-free `recordGitSyncEvidence`; uninspectable worktrees return a + 409 without invoking network Git. + +The group is registered in `routes.ts` by spreading `...GIT_DELIVERY_SYNC_ROUTE_GROUP` next to the +other git-delivery groups, with a comment citing #1573. + +### D7 — No existing route or contract changed + +The only file with an existing public surface that is edited is `gitRoutes.ts`, and that edit only +adds `export` to five already-defined symbols — behavior-preserving. `/api/projects`, +`/api/repositories/clone`, `/api/git/status|diff|branches`, every `gitDelivery/*` route, and every +existing contract type are byte-for-byte unchanged. `GitDeliveryActionKind`, the governed policy +packs, the mutation kernel, and `mutationEvidenceLedger.ts` are untouched. All new surface is +additive, so no package version is bumped. + +## Consequences + +### Positive + +- The three reuse-contract §3 gaps (history, remotes, ahead/behind) are closed with sibling reads + that reuse `resolveRepository` / `defaultGitProcessRunner` / `redacted()` rather than a parallel + Git read subsystem (D2), exactly as the reuse contract mandated. +- The shared porcelain-v2 parser (D3) makes the summary read and the sync preview consume identical + branch/ahead-behind/dirty logic, so the two surfaces cannot disagree. +- Fetch/pull gain an audited, bounded execution path without widening the frozen governed mutation + taxonomy (D4), preserving the #470 control-plane invariant the reuse contract protects. +- The local reads stay fully config-isolated while the fetch/pull command alone runs with a + credential-capable, non-interactive, fail-closed environment (D4a), so authenticated private/SSH + remotes work without relaxing the security posture of any read route. +- Sync remains fully evidence-compatible through a sibling ledger that mirrors the proven mutation- + ledger pattern (D5), so an operator can audit every executed sync with a content-free record. +- Every response body and evidence record is content-free (counts, typed codes, branch/remote names, + ISO dates, hashes) and passes through `deps.redactor`, so a remote URL or credential never reaches + the browser or the ledger. +- The change is additive end to end (D7); no existing route, contract, or version moves. + +### Negative + +- The fetch/pull execution path and the kernel mutation path are now two execution surfaces in + `gitDelivery/`. The asymmetry is deliberate (D4) and documented, but a maintainer must understand + why sync does not route through `runGitMutation` before changing either. +- The sync evidence ledger is a second ledger alongside the mutation ledger. The duplication of the + bounded-bucket/redaction/fail-closed mechanics is accepted in exchange for keeping the two outcome + taxonomies structurally separate (D5), mirroring the domain-separation reasoning of ADR-0083. +- A `pull --ff-only` refuses any non-fast-forward, so a divergent branch reports `not-fast-forward` + and the user must resolve it through the governed merge gateway. This is intentional: the sync + executor never creates a merge commit. + +### Neutral + +- The read routes are always registered (no deployment enable flag), consistent with the existing + `/api/git/status|diff|branches` reads; each handler still degrades to a typed `available: false` + envelope when Git is missing, the path is not a repository, or the owner is unsafe. +- `GitSyncPreview` reuses `GitUpstreamSummary` from `git-repository-summary.ts`, so the upstream + shape is defined once and shared across the summary and sync contracts. +- The sync routes use `resolveProjectWorkspace` (a `projectId` that is the workspace root path, + authorized through the project store), the same authorization seam the governed push/commit routes + use, so an unregistered path cannot drive a fetch or pull. + +## Alternatives Considered + +### Alternative 1: Add `fetch` and `pull` as governed `GitDeliveryActionKind` members through the kernel + +- **Pros**: one execution authority for every network and local Git operation; sync would inherit the + kernel's policy/approval/evidence lifecycle for free. +- **Cons**: the governed write contract and the action-kind taxonomy are frozen by the #1572 reuse + contract §3 and are a #470 control-plane invariant. Adding two kinds ripples through every + exhaustive per-kind policy/risk/preview/recovery table and extends the kernel's authority to + operations it was not built to mediate — a fetch touches only remote-tracking refs and a + fast-forward-only pull advances by replay, neither of which is the local-ref write the governance + model exists to guard. It would weaken the control plane for no governance benefit. +- **Why rejected**: D4. Reuse the hardened runner, not the kernel; keep the taxonomy frozen. + +### Alternative 2: Record sync outcomes in the existing mutation evidence ledger + +- **Pros**: one ledger, one bounded bucket, one export path. +- **Cons**: the mutation ledger's records are keyed to kernel terminal outcomes; the fetch/pull + `GitSyncOutcome` taxonomy is different, and conflating them would couple two unrelated outcome + domains at the storage layer and complicate independent retention. ADR-0083 already establishes + that evidence domains stay structurally separate even when their infrastructure is similar. +- **Why rejected**: D5. A sibling ledger mirrors the proven pattern without conflating taxonomies. + +### Alternative 3: Add ahead/behind and history by extending the existing `parseBranch` / status route + +- **Pros**: no new contract or route; the sync UI would read everything from one enriched status + envelope. +- **Cons**: the existing `/api/git/status` response is consumed byte-for-byte by the Changes pane and + its tests; widening it would change a frozen contract (violating "only ADD code"), and `parseBranch` + deliberately strips the upstream segment. History in particular is a paginated `git log` read with + a different shape that does not belong in the status envelope. +- **Why rejected**: D1/D7. Additive sibling contracts keep the existing status route unchanged and + give history its own paginated envelope. + +### Alternative 4: Duplicate the porcelain-v2 parsing in the read route and the sync executor + +- **Pros**: each module is self-contained with no shared import. +- **Cons**: the XY change-record and `# branch.*` header semantics are subtle (rename records carry + an extra NUL field; ahead/behind is `+A -B`); two copies would drift, and a single-line mutation in + one copy could pass while the other is correct, exactly the divergence the summary-vs-sync surfaces + must not have. +- **Why rejected**: D3. One `parsePorcelainV2Branch` consumed by both surfaces is audited once. + +## Related + +- [ADR-0080](ADR-0080-governed-git-delivery-contracts.md): the governed Git delivery contracts and + the frozen `GitDeliveryActionKind` taxonomy this ADR deliberately does not extend (D4). +- [ADR-0081](ADR-0081-governed-git-mutation-execution-kernel.md): the `runGitMutation` kernel the + sync executor deliberately does not enter (D4). +- [ADR-0083](ADR-0083-governed-git-mutation-evidence-ledger.md): the bounded, date-bucketed, + redacted, fail-closed evidence-ledger pattern the sync ledger mirrors in a sibling module (D5). +- [ADR-0085](ADR-0085-governed-remote-publish-gateway.md): the governed push authority; push remains + governed and is out of scope for #1573's read/preview/execute sync surface. +- `docs/git-delivery/git-client-desktop-reuse-contract.md`: the #1572 reuse contract whose §3 + isolated the three read gaps this ADR closes and whose §3 froze the write contract. +- `docs/git-delivery/git-client-repository-api.md`: the endpoint reference for the routes this ADR + ratifies (query/body, response shapes, the `GitSyncOutcome` taxonomy, and the reused safety + constraints). +- Epic [#1572](https://github.com/oscharko-dev/Keiko/issues/1572); Issue + [#1573](https://github.com/oscharko-dev/Keiko/issues/1573); consuming UI issue + [#1576](https://github.com/oscharko-dev/Keiko/issues/1576). + +## Date + +2026-06-27 diff --git a/docs/adr/README.md b/docs/adr/README.md index 5606232f0..8cf6d48cd 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -72,6 +72,7 @@ This page keeps only the product decisions needed by reviewers. It is not an imp | Voice assistant speech synthesis — Model Gateway TTS adapter, BFF synthesis route, audio playback engine | [ADR-0095](ADR-0095-voice-assistant-speech-synthesis.md) implements Issue #1558 by reusing the existing Model Gateway `gatewayFetch` egress seam for OpenAI-compatible text-to-speech, adding bounded binary response handling, a capability-gated `/api/voice/speak` BFF route, server-side persona-to-voice-id resolution, base64 audio responses with canonical MIME types, and a UI playback hook bound to the exact visible assistant message. No new egress path, raw generated-audio persistence, SDK dependency, or independent answer generation. | | Voice dialogue session orchestration — deterministic turn controller, STT+TTS fallback, barge-in, master cleanup | [ADR-0096](ADR-0096-voice-dialogue-session-orchestration.md) implements Issue #1560 as a pure `keiko-ui` orchestration layer over the existing dictation, assistant speech, and Voice Turn Manager surfaces: dialogue is offered only for the fail-closed STT plus speech-output plus persona matrix, the regulated STT+TTS fallback is active even without browser WebRTC media, barge-in routes through existing playback and turn-manager effects, and cleanup is idempotent across stop, unmount, leave, and capability loss. No server route, provider authority, contract, egress path, or parallel state machine is added. | | Editor file-tree mutations (create / rename / delete) and open-tab re-homing | [ADR-0097](ADR-0097-files-tree-mutations-and-tab-rehoming.md) adds `POST /api/files/{create,rename,delete}` that reuse the read-surface containment model (realpath root, both-ends deny-list, metadata redaction) and are non-destructive by default (atomic `O_EXCL` create, no-clobber rename with a realpath-gated case-only exception, symlink- and root-rejecting delete), with a single non-probeable errno mapper and the server CSRF + JSON gate; two prefix-aware layout reducer actions (`rename-file` / `remove-file`) re-home or close open tabs across every pane; and a Files-widget UX (toolbar, right-click menu, inline rename, confirm-gated delete, `F2`/`Delete`) that reuses existing CSS classes so the #1300 `globals.css` proof gate is untouched. Wire types in `keiko-contracts`; no new trust, no server-side parallel subsystem. | +| Git client repository state, history, remotes, and fetch/pull sync API | [ADR-0098](ADR-0098-git-client-repository-state-and-sync-api.md) implements the Issue #1573 (Epic #1572) API foundation: three additive read contracts and routes close the reuse-contract §3 gaps (`GET /api/git/summary` ahead/behind + remotes + last-sync, `GET /api/git/history` paginated `git log`, `GET /api/git/remotes`) reusing `resolveRepository` / `defaultGitProcessRunner` / `classifyFailure` / `redacted()` from `gitRoutes.ts` (only behavior-preservingly `export`ed) plus a shared `parsePorcelainV2Branch`; fetch/pull deliberately do NOT enter the frozen `GitDeliveryActionKind` / `runGitMutation` taxonomy but run through a dedicated bounded executor that uses the hardened runner for reads and a preflight-gated credential-capable runner for network sync (`fetch --no-tags`, `pull --ff-only`), exposed as read-only `POST .../{fetch,pull}/preview` plus audited `POST .../{fetch,pull}/execute` with a 13-member `GitSyncOutcome` taxonomy; sync stays evidence-compatible through a sibling `syncEvidence.ts` ledger mirroring the ADR-0083 bounded/redacted/fail-closed pattern with a content-free `repoIdHash`. Every response and record is content-free and redacted; no existing route or contract changed and no version bumped. | | Capability-gated Voice Digital Twin architecture | [ADR-0058](ADR-0058-voice-digital-twin-capability-architecture.md) defines the design-only baseline for Epic #491's optional Voice Digital Twin (Issue #492; child issues #493–#506): voice is optional and capability-gated so Keiko starts and stays fully usable with no voice model (D1); four provider profiles (`none` / STT-only dictation / speech output only / full realtime) with STT-only kept distinct from full conversation (D2); WebSocket as the authoritative control/signaling plane realized today on the existing loopback HTTP + SSE seam (the BFF hard-rejects WS upgrades, so a bidirectional channel is a deferred #496/#497 decision) and native-browser WebRTC as the preferred media plane (D3); a local-first data boundary with no external destinations except explicitly configured model endpoints selected by runtime capability metadata, reusing `gatewayFetch` (ADR-0038) + `model-selection` and honestly recording that no outbound host allowlist exists yet (D4); zero-dependency voice capability advertisement through the existing `ModelCapability` metadata (additive flags or a new `ModelKind`, D5); a security-review contract for ephemeral tokens, provider credentials, ICE candidate privacy, allowlisted endpoints, and audit redaction reusing the AES-256-GCM / redaction / hashing stack (D6); Azure Foundry development-or-academic and customer-hosted controlled-network deployment profiles plus no-voice (D7); a "no new runtime media packages by default" supply-chain policy beyond the existing `ws` 8.21.0 and browser-native WebRTC (D8); and child-issue sequencing / write-ownership (D9). Detailed contracts in `docs/voice/`. No runtime code, no model deployment, no new dependency (Status: Proposed). | | Voice control, WebRTC media, capability-gating, and replay protocol | [ADR-0059](ADR-0059-voice-control-media-capability-replay-protocol.md) defines the versioned voice protocol contract for Epic #491 (Issue #496; transport is #497): a dedicated `VOICE_PROTOCOL_VERSION` independent of `CONVERSATION_CAPABILITY_CONTRACT_VERSION` (D1); two planes — the WebSocket control/signaling plane (every message kind) separated from the WebRTC media plane (raw audio only, never a control message) (D2); v1 control transport on loopback HTTP + SSE with the WebSocket upgrade reopening deferred to #497 (D3); a deterministic capability-gating fallback table where `none` permits nothing and STT-only excludes all WebRTC signaling/media (D4); replay/reconnect/idempotency where committed transcripts and control are replayable but raw audio and ephemeral SDP/ICE are excluded (D5); redaction classes reusing the existing redaction/hashing stack (D6); browser↔provider negotiation modes (`proxied-sdp` preferred / `direct-ephemeral` / `disabled`) and a content-free security surface (D7); and no new runtime media packages beyond `ws` + native WebRTC (D8). Typed contract in `packages/keiko-contracts/src/voice-protocol.ts`, specification in `docs/voice/protocol.md`. No transport code, no new dependency (Status: Accepted — realized by the transport in Issue #497 / [ADR-0060](ADR-0060-realtime-voice-transport.md)). | | Realtime voice transport — re-opened loopback WebSocket control + browser WebRTC media | [ADR-0060](ADR-0060-realtime-voice-transport.md) records the transport decision deferred by ADR-0058 D3 / ADR-0059 D3 for Epic #491 (Issue #497): re-open the deliberately hard-rejected BFF WebSocket upgrade for the single loopback path `/api/voice/control`, and **only** when the deployment is full-realtime capable and policy permits — every other upgrade keeps the unchanged `404` + `socket.destroy()` default (D1); proxied-SDP media negotiation through the Model Gateway egress (`requestRealtimeNegotiation` via `gatewayFetch`) so no long-lived provider credential reaches the browser, with native-browser WebRTC media and the contract's `direct-ephemeral` mode left opt-in/out-of-scope (D2); a security posture reusing the loopback `Host`/`Origin` check (no CSRF on a WS handshake), redaction on every outbound frame, opaque `secret-bearing` SDP/ICE never logged, raw audio rejected on the control plane, a bounded replay buffer, and deterministic teardown (D3); existing strict controls re-justified not relaxed — `Permissions-Policy microphone=(self)` scoped to STT-or-realtime (never widened), CSP unchanged (same-origin WS covered by `connect-src 'self'`), and no new runtime media package (D4); and no new persisted local-runtime state, with transcript/recap persistence deferred to #504 (D5). Transport in `packages/keiko-server/src/voice-realtime.ts` + `packages/keiko-model-gateway/src/realtime-voice-adapter.ts` + the keiko-ui realtime client; notes in `docs/voice/realtime-transport.md` (Status: Accepted). | diff --git a/docs/design-system/evidence/1300/browser/capture.mjs b/docs/design-system/evidence/1300/browser/capture.mjs index e03a45da5..3f33a6a14 100644 --- a/docs/design-system/evidence/1300/browser/capture.mjs +++ b/docs/design-system/evidence/1300/browser/capture.mjs @@ -182,6 +182,34 @@ const WORKSPACE_WINDOWS = [ cfg: { root: DEMO_ROOT, file: "src/App.tsx", openFiles: ["src/App.tsx", "README.md"] }, max: false, }, + // Issue #1574 (EV3) — Git client window shell at a generous desktop size. Wide enough for the full + // desktop IA (header toolbar with repository + branch selectors and Sync pill, the Changes/History + // sidebar, and the diff pane side by side). + { + id: "issue-1574-git-desktop", + type: "governedGit", + x: 40, + y: 44, + w: 760, + h: 620, + z: 30, + cfg: { projectPath: DEMO_ROOT }, + max: false, + }, + // Issue #1574 (EV3) — the same shell at a constrained window size. Above the governedGit tiny + // threshold (300x240) so the window renders the full shell rather than the too-small placeholder, + // proving the desktop IA stays coherent when the window is narrow. + { + id: "issue-1574-git-constrained", + type: "governedGit", + x: 40, + y: 44, + w: 360, + h: 460, + z: 31, + cfg: { projectPath: DEMO_ROOT }, + max: false, + }, ]; const WINDOWS_BY_ID = Object.fromEntries(WORKSPACE_WINDOWS.map((win) => [win.id, win])); function scenarioWindows(ids) { @@ -226,6 +254,45 @@ const SCENARIOS = [ '[data-window-id="issue-1300-editor"]', ], }, + // Issue #1574 (EV3) — Git client window shell at a generous desktop size. The required selectors + // prove the composed desktop IA rendered: the window frame, the Git workspace root, the repository + // and branch combobox triggers and the Changes/History tablist in the sidebar, and the populated + // changed-files navigation. These target the real classes/roles the shell composes (no new CSS). + { + id: "git-window-desktop", + description: "Git client window shell at a generous desktop size", + windows: scenarioWindows(["issue-1574-git-desktop"]), + requiredSelectors: [ + '[data-window-id="issue-1574-git-desktop"]', + '[data-window-id="issue-1574-git-desktop"] [aria-label="Git"]', + '[data-window-id="issue-1574-git-desktop"] [role="combobox"][aria-label="Repository"]', + '[data-window-id="issue-1574-git-desktop"] [role="combobox"][aria-label="Branch"]', + '[data-window-id="issue-1574-git-desktop"] [role="tablist"][aria-label="Changes and history"]', + '[data-window-id="issue-1574-git-desktop"] nav.rv-filelist[aria-label="Changed files"]', + // Issue #1575 — per-file staging checkboxes and the pinned commit composer. + '[data-window-id="issue-1574-git-desktop"] nav.rv-filelist[aria-label="Changed files"] input[type="checkbox"]', + '[data-window-id="issue-1574-git-desktop"] section[aria-label="Commit"]', + ], + }, + // Issue #1574 (EV3) — the same shell at a constrained window size. The IA must stay coherent when the + // window is narrow: the same window frame, Git workspace root, repository/branch selectors, tablist, + // and changed-files list are still required (the shell reflows rather than dropping structure). + { + id: "git-window-constrained", + description: "Git client window shell at a constrained window size", + windows: scenarioWindows(["issue-1574-git-constrained"]), + requiredSelectors: [ + '[data-window-id="issue-1574-git-constrained"]', + '[data-window-id="issue-1574-git-constrained"] [aria-label="Git"]', + '[data-window-id="issue-1574-git-constrained"] [role="combobox"][aria-label="Repository"]', + '[data-window-id="issue-1574-git-constrained"] [role="combobox"][aria-label="Branch"]', + '[data-window-id="issue-1574-git-constrained"] [role="tablist"][aria-label="Changes and history"]', + '[data-window-id="issue-1574-git-constrained"] nav.rv-filelist[aria-label="Changed files"]', + // Issue #1575 — staging checkboxes and the pinned commit composer must survive the reflow. + '[data-window-id="issue-1574-git-constrained"] nav.rv-filelist[aria-label="Changed files"] input[type="checkbox"]', + '[data-window-id="issue-1574-git-constrained"] section[aria-label="Commit"]', + ], + }, ]; const FILE_CONTENT = "export function App() { return
Issue #1300 evidence
; }\n"; const FILE_VERSION = { @@ -286,6 +353,25 @@ const ZERO_RELATIONSHIP_TOTALS = { blocked: 0, stale: 0, }; +const DEMO_MODELS = [ + { + id: "static-evidence-chat", + kind: "chat", + contextWindow: 128000, + maxOutputTokens: 4096, + toolCalling: true, + structuredOutput: true, + streaming: true, + supportsImageInput: false, + supportsDocumentInput: false, + workflowEligible: true, + costClass: "medium", + latencyClass: "standard", + throughputHint: "standard", + preferredUseCases: ["static browser evidence"], + knownLimitations: [], + }, +]; function apiBody(url) { const pathname = typeof url === "string" ? url : url.pathname; @@ -298,7 +384,7 @@ function apiBody(url) { effectiveGroundingLimits: { maxConnectedSources: 16 }, }; } - if (pathname === "/api/models") return { models: [] }; + if (pathname === "/api/models") return { models: DEMO_MODELS }; if (pathname === "/api/workflows") return { workflows: [] }; if (pathname === "/api/chats") return { chats: [] }; if (pathname === "/api/projects") { @@ -337,6 +423,93 @@ function apiBody(url) { }; } if (pathname === "/api/editor/agent/sessions") return { sessions: [] }; + // Issue #446 (Epic #443) — the globally mounted task-workspace switcher reads the inventory and the + // active binding on boot. Without these the malformed fallback leaves `instances` undefined and the + // switcher throws on every route, so the read surface must return an empty inventory and no active + // binding (the unbound studio default), keeping every scenario error-free. + if (pathname === "/api/task-workspaces") return { instances: [] }; + if (pathname === "/api/task-workspaces/active") return { active: null }; + // Issue #1574 — read surface for the Git client window shell (repository status / branches / diff). + // Fixtures keep the shell's desktop IA fully populated: a dirty repository (changed-file list), a + // current branch in the branch selector, and a Sync status pill, proving the shell renders at all + // viewport widths. No mutation endpoints are exercised (#1575/#1576/#1577 own those). + if (pathname === "/api/git/status") { + return { + schemaVersion: "1", + root: DEMO_ROOT, + repositoryRoot: DEMO_ROOT, + state: "available", + available: true, + branch: "main", + detached: false, + clean: false, + stagedCount: 1, + unstagedCount: 1, + untrackedCount: 0, + conflictedCount: 0, + changes: [ + { + path: "src/App.tsx", + indexStatus: "M", + worktreeStatus: " ", + staged: true, + unstaged: false, + untracked: false, + conflicted: false, + }, + { + path: "README.md", + indexStatus: " ", + worktreeStatus: "M", + staged: false, + unstaged: true, + untracked: false, + conflicted: false, + }, + ], + truncated: false, + maxChanges: 1000, + }; + } + if (pathname === "/api/git/branches") { + return { + schemaVersion: "1", + root: DEMO_ROOT, + repositoryRoot: DEMO_ROOT, + available: true, + state: "available", + branches: [ + { name: "main", headRefHash: "0".repeat(40), current: true }, + { name: "feature/git-window-shell", headRefHash: "1".repeat(40), current: false }, + ], + truncated: false, + }; + } + if (pathname === "/api/git/diff") { + return { + schemaVersion: "1", + root: DEMO_ROOT, + repositoryRoot: DEMO_ROOT, + state: "available", + available: true, + scope: "all", + diff: "", + truncated: false, + maxBytes: 262144, + }; + } + // Issue #1575 — the commit composer auto-previews policy for the staged set, so the live shell + // posts here on mount. Return a content-free, passing preview so the policy preview renders. + if (pathname === "/api/git-delivery/commit/preview") { + return { + schemaVersion: "1", + summary: { stagedFileCount: 1, areaCount: 1, areas: ["src"], touchesTests: false }, + intent: { warnings: [], mixedScope: false, isWip: false }, + messageValidation: { ok: true }, + preflightFindingCodes: [], + policyOutcome: "allowed", + }; + } if (pathname === "/api/quality-intelligence/runs") { return { runs: [ diff --git a/docs/design-system/evidence/1300/browser/desktop__git-window-constrained__dark-hc.png b/docs/design-system/evidence/1300/browser/desktop__git-window-constrained__dark-hc.png new file mode 100644 index 000000000..fea603605 Binary files /dev/null and b/docs/design-system/evidence/1300/browser/desktop__git-window-constrained__dark-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__git-window-constrained__dark.png b/docs/design-system/evidence/1300/browser/desktop__git-window-constrained__dark.png new file mode 100644 index 000000000..adabbe38a Binary files /dev/null and b/docs/design-system/evidence/1300/browser/desktop__git-window-constrained__dark.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__git-window-constrained__forced-colors.png b/docs/design-system/evidence/1300/browser/desktop__git-window-constrained__forced-colors.png new file mode 100644 index 000000000..09b2753e0 Binary files /dev/null and b/docs/design-system/evidence/1300/browser/desktop__git-window-constrained__forced-colors.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__git-window-constrained__light-hc.png b/docs/design-system/evidence/1300/browser/desktop__git-window-constrained__light-hc.png new file mode 100644 index 000000000..75fb7f7ec Binary files /dev/null and b/docs/design-system/evidence/1300/browser/desktop__git-window-constrained__light-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__git-window-constrained__light.png b/docs/design-system/evidence/1300/browser/desktop__git-window-constrained__light.png new file mode 100644 index 000000000..9fccb7df5 Binary files /dev/null and b/docs/design-system/evidence/1300/browser/desktop__git-window-constrained__light.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__git-window-constrained__reduced-motion.png b/docs/design-system/evidence/1300/browser/desktop__git-window-constrained__reduced-motion.png new file mode 100644 index 000000000..adabbe38a Binary files /dev/null and b/docs/design-system/evidence/1300/browser/desktop__git-window-constrained__reduced-motion.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__git-window-desktop__dark-hc.png b/docs/design-system/evidence/1300/browser/desktop__git-window-desktop__dark-hc.png new file mode 100644 index 000000000..0b15f4360 Binary files /dev/null and b/docs/design-system/evidence/1300/browser/desktop__git-window-desktop__dark-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__git-window-desktop__dark.png b/docs/design-system/evidence/1300/browser/desktop__git-window-desktop__dark.png new file mode 100644 index 000000000..7ced2b0dc Binary files /dev/null and b/docs/design-system/evidence/1300/browser/desktop__git-window-desktop__dark.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__git-window-desktop__forced-colors.png b/docs/design-system/evidence/1300/browser/desktop__git-window-desktop__forced-colors.png new file mode 100644 index 000000000..b8e560f1f Binary files /dev/null and b/docs/design-system/evidence/1300/browser/desktop__git-window-desktop__forced-colors.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__git-window-desktop__light-hc.png b/docs/design-system/evidence/1300/browser/desktop__git-window-desktop__light-hc.png new file mode 100644 index 000000000..95f778379 Binary files /dev/null and b/docs/design-system/evidence/1300/browser/desktop__git-window-desktop__light-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__git-window-desktop__light.png b/docs/design-system/evidence/1300/browser/desktop__git-window-desktop__light.png new file mode 100644 index 000000000..db408b7be Binary files /dev/null and b/docs/design-system/evidence/1300/browser/desktop__git-window-desktop__light.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__git-window-desktop__reduced-motion.png b/docs/design-system/evidence/1300/browser/desktop__git-window-desktop__reduced-motion.png new file mode 100644 index 000000000..7ced2b0dc Binary files /dev/null and b/docs/design-system/evidence/1300/browser/desktop__git-window-desktop__reduced-motion.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__shell__dark-hc.png b/docs/design-system/evidence/1300/browser/desktop__shell__dark-hc.png index d57261ccd..e39aaf9b2 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__shell__dark-hc.png and b/docs/design-system/evidence/1300/browser/desktop__shell__dark-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__shell__dark.png b/docs/design-system/evidence/1300/browser/desktop__shell__dark.png index a5b0eda7e..14071a168 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__shell__dark.png and b/docs/design-system/evidence/1300/browser/desktop__shell__dark.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__shell__forced-colors.png b/docs/design-system/evidence/1300/browser/desktop__shell__forced-colors.png index fb1e31504..7654bd623 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__shell__forced-colors.png and b/docs/design-system/evidence/1300/browser/desktop__shell__forced-colors.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__shell__light-hc.png b/docs/design-system/evidence/1300/browser/desktop__shell__light-hc.png index bd22881b9..8e4482cc0 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__shell__light-hc.png and b/docs/design-system/evidence/1300/browser/desktop__shell__light-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__shell__light.png b/docs/design-system/evidence/1300/browser/desktop__shell__light.png index 81f18f1df..244bcc650 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__shell__light.png and b/docs/design-system/evidence/1300/browser/desktop__shell__light.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__shell__reduced-motion.png b/docs/design-system/evidence/1300/browser/desktop__shell__reduced-motion.png index a5b0eda7e..6ea947a44 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__shell__reduced-motion.png and b/docs/design-system/evidence/1300/browser/desktop__shell__reduced-motion.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__dark-hc.png b/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__dark-hc.png index 3b8e04a36..8ba0147ee 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__dark-hc.png and b/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__dark-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__dark.png b/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__dark.png index 94e16dfd3..6cbccd060 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__dark.png and b/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__dark.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__forced-colors.png b/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__forced-colors.png index 44d1fd2d9..66becde84 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__forced-colors.png and b/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__forced-colors.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__light-hc.png b/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__light-hc.png index 21f8c68f6..9841390d2 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__light-hc.png and b/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__light-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__light.png b/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__light.png index 27dcc9e81..3b3baf252 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__light.png and b/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__light.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__reduced-motion.png b/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__reduced-motion.png index 94e16dfd3..f5f4b9f37 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__reduced-motion.png and b/docs/design-system/evidence/1300/browser/desktop__workspace-chat-quality__reduced-motion.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__dark-hc.png b/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__dark-hc.png index 416a7fa49..3fe70754a 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__dark-hc.png and b/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__dark-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__dark.png b/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__dark.png index 1c4d277c4..b1f18e82d 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__dark.png and b/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__dark.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__forced-colors.png b/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__forced-colors.png index 712a8cd96..6f181e860 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__forced-colors.png and b/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__forced-colors.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__light-hc.png b/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__light-hc.png index e8db47752..ac4b65073 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__light-hc.png and b/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__light-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__light.png b/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__light.png index f2752cf21..f72d283be 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__light.png and b/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__light.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__reduced-motion.png b/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__reduced-motion.png index a343fcf1d..b1f18e82d 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__reduced-motion.png and b/docs/design-system/evidence/1300/browser/desktop__workspace-files-editor__reduced-motion.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__dark-hc.png b/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__dark-hc.png index c6e44cba5..9573ab814 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__dark-hc.png and b/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__dark-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__dark.png b/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__dark.png index 565865fc6..890fed7c5 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__dark.png and b/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__dark.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__forced-colors.png b/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__forced-colors.png index c15aee458..c4c24b1e6 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__forced-colors.png and b/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__forced-colors.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__light-hc.png b/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__light-hc.png index 8cdb4e451..96cb89222 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__light-hc.png and b/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__light-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__light.png b/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__light.png index 174b56030..c6d30d23b 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__light.png and b/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__light.png differ diff --git a/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__reduced-motion.png b/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__reduced-motion.png index 565865fc6..890fed7c5 100644 Binary files a/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__reduced-motion.png and b/docs/design-system/evidence/1300/browser/desktop__workspace-memory-relationships__reduced-motion.png differ diff --git a/docs/design-system/evidence/1300/browser/manifest.json b/docs/design-system/evidence/1300/browser/manifest.json index f9dac77ef..4ba32adb8 100644 --- a/docs/design-system/evidence/1300/browser/manifest.json +++ b/docs/design-system/evidence/1300/browser/manifest.json @@ -3,7 +3,7 @@ "epic": 1290, "appPath": "packages/keiko-ui/out", "route": "/", - "shotCount": 72, + "shotCount": 108, "modes": [ "dark", "light", @@ -51,6 +51,34 @@ "[data-window-id=\"issue-1300-files\"]", "[data-window-id=\"issue-1300-editor\"]" ] + }, + { + "id": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ] + }, + { + "id": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ] } ], "manifest": [ @@ -69,7 +97,7 @@ "theme": "dark", "hasShell": true, "windowCount": 0, - "textLen": 812, + "textLen": 162, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -88,7 +116,7 @@ "theme": "dark", "hasShell": true, "windowCount": 3, - "textLen": 1808, + "textLen": 1040, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -106,7 +134,7 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1686, + "textLen": 1036, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -124,7 +152,55 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1220, + "textLen": 703, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "desktop__git-window-desktop__dark.png", + "route": "/", + "scenario": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "viewport": "desktop", + "mode": "dark", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 588, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "desktop__git-window-constrained__dark.png", + "route": "/", + "scenario": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "viewport": "desktop", + "mode": "dark", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 588, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -143,7 +219,7 @@ "theme": "light", "hasShell": true, "windowCount": 0, - "textLen": 812, + "textLen": 162, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -162,7 +238,7 @@ "theme": "light", "hasShell": true, "windowCount": 3, - "textLen": 1808, + "textLen": 1040, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -180,7 +256,7 @@ "theme": "light", "hasShell": true, "windowCount": 2, - "textLen": 1686, + "textLen": 1036, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -198,7 +274,55 @@ "theme": "light", "hasShell": true, "windowCount": 2, - "textLen": 1220, + "textLen": 703, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "desktop__git-window-desktop__light.png", + "route": "/", + "scenario": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "viewport": "desktop", + "mode": "light", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ], + "theme": "light", + "hasShell": true, + "windowCount": 1, + "textLen": 588, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "desktop__git-window-constrained__light.png", + "route": "/", + "scenario": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "viewport": "desktop", + "mode": "light", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ], + "theme": "light", + "hasShell": true, + "windowCount": 1, + "textLen": 588, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -217,7 +341,7 @@ "theme": "dark", "hasShell": true, "windowCount": 0, - "textLen": 812, + "textLen": 162, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -236,7 +360,7 @@ "theme": "dark", "hasShell": true, "windowCount": 3, - "textLen": 1808, + "textLen": 1040, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -254,7 +378,7 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1686, + "textLen": 1036, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -272,7 +396,55 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1220, + "textLen": 703, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "desktop__git-window-desktop__dark-hc.png", + "route": "/", + "scenario": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "viewport": "desktop", + "mode": "dark-hc", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 588, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "desktop__git-window-constrained__dark-hc.png", + "route": "/", + "scenario": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "viewport": "desktop", + "mode": "dark-hc", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 588, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -291,7 +463,7 @@ "theme": "light", "hasShell": true, "windowCount": 0, - "textLen": 812, + "textLen": 162, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -310,7 +482,7 @@ "theme": "light", "hasShell": true, "windowCount": 3, - "textLen": 1808, + "textLen": 1040, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -328,7 +500,7 @@ "theme": "light", "hasShell": true, "windowCount": 2, - "textLen": 1686, + "textLen": 1036, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -346,7 +518,55 @@ "theme": "light", "hasShell": true, "windowCount": 2, - "textLen": 1220, + "textLen": 703, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "desktop__git-window-desktop__light-hc.png", + "route": "/", + "scenario": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "viewport": "desktop", + "mode": "light-hc", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ], + "theme": "light", + "hasShell": true, + "windowCount": 1, + "textLen": 588, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "desktop__git-window-constrained__light-hc.png", + "route": "/", + "scenario": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "viewport": "desktop", + "mode": "light-hc", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ], + "theme": "light", + "hasShell": true, + "windowCount": 1, + "textLen": 588, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -365,7 +585,7 @@ "theme": "dark", "hasShell": true, "windowCount": 0, - "textLen": 812, + "textLen": 162, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -384,7 +604,7 @@ "theme": "dark", "hasShell": true, "windowCount": 3, - "textLen": 1808, + "textLen": 1040, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -402,7 +622,7 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1686, + "textLen": 1036, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -420,7 +640,55 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1220, + "textLen": 703, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "desktop__git-window-desktop__reduced-motion.png", + "route": "/", + "scenario": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "viewport": "desktop", + "mode": "reduced-motion", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 588, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "desktop__git-window-constrained__reduced-motion.png", + "route": "/", + "scenario": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "viewport": "desktop", + "mode": "reduced-motion", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 588, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -439,7 +707,7 @@ "theme": "dark", "hasShell": true, "windowCount": 0, - "textLen": 812, + "textLen": 162, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -458,7 +726,7 @@ "theme": "dark", "hasShell": true, "windowCount": 3, - "textLen": 1808, + "textLen": 1040, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -476,7 +744,7 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1686, + "textLen": 1036, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -494,7 +762,55 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1220, + "textLen": 703, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "desktop__git-window-desktop__forced-colors.png", + "route": "/", + "scenario": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "viewport": "desktop", + "mode": "forced-colors", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 588, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "desktop__git-window-constrained__forced-colors.png", + "route": "/", + "scenario": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "viewport": "desktop", + "mode": "forced-colors", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 588, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -513,7 +829,7 @@ "theme": "dark", "hasShell": true, "windowCount": 0, - "textLen": 806, + "textLen": 156, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -532,7 +848,7 @@ "theme": "dark", "hasShell": true, "windowCount": 3, - "textLen": 1802, + "textLen": 1034, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -550,7 +866,7 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1680, + "textLen": 1030, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -568,7 +884,55 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1214, + "textLen": 697, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "tablet__git-window-desktop__dark.png", + "route": "/", + "scenario": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "viewport": "tablet", + "mode": "dark", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 582, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "tablet__git-window-constrained__dark.png", + "route": "/", + "scenario": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "viewport": "tablet", + "mode": "dark", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 582, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -587,7 +951,7 @@ "theme": "light", "hasShell": true, "windowCount": 0, - "textLen": 806, + "textLen": 156, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -606,7 +970,7 @@ "theme": "light", "hasShell": true, "windowCount": 3, - "textLen": 1802, + "textLen": 1034, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -624,7 +988,7 @@ "theme": "light", "hasShell": true, "windowCount": 2, - "textLen": 1680, + "textLen": 1030, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -642,7 +1006,55 @@ "theme": "light", "hasShell": true, "windowCount": 2, - "textLen": 1214, + "textLen": 697, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "tablet__git-window-desktop__light.png", + "route": "/", + "scenario": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "viewport": "tablet", + "mode": "light", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ], + "theme": "light", + "hasShell": true, + "windowCount": 1, + "textLen": 582, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "tablet__git-window-constrained__light.png", + "route": "/", + "scenario": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "viewport": "tablet", + "mode": "light", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ], + "theme": "light", + "hasShell": true, + "windowCount": 1, + "textLen": 582, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -661,7 +1073,7 @@ "theme": "dark", "hasShell": true, "windowCount": 0, - "textLen": 806, + "textLen": 156, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -680,7 +1092,7 @@ "theme": "dark", "hasShell": true, "windowCount": 3, - "textLen": 1802, + "textLen": 1034, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -698,7 +1110,7 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1680, + "textLen": 1030, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -716,7 +1128,55 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1214, + "textLen": 697, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "tablet__git-window-desktop__dark-hc.png", + "route": "/", + "scenario": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "viewport": "tablet", + "mode": "dark-hc", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 582, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "tablet__git-window-constrained__dark-hc.png", + "route": "/", + "scenario": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "viewport": "tablet", + "mode": "dark-hc", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 582, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -735,7 +1195,7 @@ "theme": "light", "hasShell": true, "windowCount": 0, - "textLen": 806, + "textLen": 156, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -754,7 +1214,7 @@ "theme": "light", "hasShell": true, "windowCount": 3, - "textLen": 1802, + "textLen": 1034, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -772,7 +1232,7 @@ "theme": "light", "hasShell": true, "windowCount": 2, - "textLen": 1680, + "textLen": 1030, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -790,7 +1250,55 @@ "theme": "light", "hasShell": true, "windowCount": 2, - "textLen": 1214, + "textLen": 697, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "tablet__git-window-desktop__light-hc.png", + "route": "/", + "scenario": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "viewport": "tablet", + "mode": "light-hc", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ], + "theme": "light", + "hasShell": true, + "windowCount": 1, + "textLen": 582, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "tablet__git-window-constrained__light-hc.png", + "route": "/", + "scenario": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "viewport": "tablet", + "mode": "light-hc", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ], + "theme": "light", + "hasShell": true, + "windowCount": 1, + "textLen": 582, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -809,7 +1317,7 @@ "theme": "dark", "hasShell": true, "windowCount": 0, - "textLen": 806, + "textLen": 156, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -828,7 +1336,7 @@ "theme": "dark", "hasShell": true, "windowCount": 3, - "textLen": 1802, + "textLen": 1034, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -846,7 +1354,7 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1680, + "textLen": 1030, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -864,7 +1372,55 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1214, + "textLen": 697, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "tablet__git-window-desktop__reduced-motion.png", + "route": "/", + "scenario": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "viewport": "tablet", + "mode": "reduced-motion", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 582, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "tablet__git-window-constrained__reduced-motion.png", + "route": "/", + "scenario": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "viewport": "tablet", + "mode": "reduced-motion", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 582, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -883,7 +1439,7 @@ "theme": "dark", "hasShell": true, "windowCount": 0, - "textLen": 806, + "textLen": 156, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -902,7 +1458,7 @@ "theme": "dark", "hasShell": true, "windowCount": 3, - "textLen": 1802, + "textLen": 1034, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -920,7 +1476,7 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1680, + "textLen": 1030, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -938,7 +1494,55 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1214, + "textLen": 697, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "tablet__git-window-desktop__forced-colors.png", + "route": "/", + "scenario": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "viewport": "tablet", + "mode": "forced-colors", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 582, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "tablet__git-window-constrained__forced-colors.png", + "route": "/", + "scenario": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "viewport": "tablet", + "mode": "forced-colors", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 582, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -957,7 +1561,7 @@ "theme": "dark", "hasShell": true, "windowCount": 0, - "textLen": 806, + "textLen": 156, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -976,7 +1580,7 @@ "theme": "dark", "hasShell": true, "windowCount": 3, - "textLen": 1802, + "textLen": 1034, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -994,7 +1598,7 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1680, + "textLen": 1030, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1012,7 +1616,55 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1214, + "textLen": 697, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "mobile__git-window-desktop__dark.png", + "route": "/", + "scenario": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "viewport": "mobile", + "mode": "dark", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 582, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "mobile__git-window-constrained__dark.png", + "route": "/", + "scenario": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "viewport": "mobile", + "mode": "dark", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 582, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1031,7 +1683,7 @@ "theme": "light", "hasShell": true, "windowCount": 0, - "textLen": 806, + "textLen": 156, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1050,7 +1702,7 @@ "theme": "light", "hasShell": true, "windowCount": 3, - "textLen": 1802, + "textLen": 1034, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1068,7 +1720,7 @@ "theme": "light", "hasShell": true, "windowCount": 2, - "textLen": 1680, + "textLen": 1030, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1086,7 +1738,55 @@ "theme": "light", "hasShell": true, "windowCount": 2, - "textLen": 1214, + "textLen": 697, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "mobile__git-window-desktop__light.png", + "route": "/", + "scenario": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "viewport": "mobile", + "mode": "light", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ], + "theme": "light", + "hasShell": true, + "windowCount": 1, + "textLen": 582, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "mobile__git-window-constrained__light.png", + "route": "/", + "scenario": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "viewport": "mobile", + "mode": "light", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ], + "theme": "light", + "hasShell": true, + "windowCount": 1, + "textLen": 582, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1105,7 +1805,7 @@ "theme": "dark", "hasShell": true, "windowCount": 0, - "textLen": 806, + "textLen": 156, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1124,7 +1824,7 @@ "theme": "dark", "hasShell": true, "windowCount": 3, - "textLen": 1802, + "textLen": 1034, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1142,7 +1842,7 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1680, + "textLen": 1030, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1160,7 +1860,55 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1214, + "textLen": 697, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "mobile__git-window-desktop__dark-hc.png", + "route": "/", + "scenario": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "viewport": "mobile", + "mode": "dark-hc", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 582, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "mobile__git-window-constrained__dark-hc.png", + "route": "/", + "scenario": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "viewport": "mobile", + "mode": "dark-hc", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 582, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1179,7 +1927,7 @@ "theme": "light", "hasShell": true, "windowCount": 0, - "textLen": 806, + "textLen": 156, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1198,7 +1946,7 @@ "theme": "light", "hasShell": true, "windowCount": 3, - "textLen": 1802, + "textLen": 1034, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1216,7 +1964,7 @@ "theme": "light", "hasShell": true, "windowCount": 2, - "textLen": 1680, + "textLen": 1030, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1234,7 +1982,55 @@ "theme": "light", "hasShell": true, "windowCount": 2, - "textLen": 1214, + "textLen": 697, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "mobile__git-window-desktop__light-hc.png", + "route": "/", + "scenario": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "viewport": "mobile", + "mode": "light-hc", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ], + "theme": "light", + "hasShell": true, + "windowCount": 1, + "textLen": 582, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "mobile__git-window-constrained__light-hc.png", + "route": "/", + "scenario": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "viewport": "mobile", + "mode": "light-hc", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ], + "theme": "light", + "hasShell": true, + "windowCount": 1, + "textLen": 582, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1253,7 +2049,7 @@ "theme": "dark", "hasShell": true, "windowCount": 0, - "textLen": 806, + "textLen": 156, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1272,7 +2068,7 @@ "theme": "dark", "hasShell": true, "windowCount": 3, - "textLen": 1802, + "textLen": 1034, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1290,7 +2086,7 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1680, + "textLen": 1030, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1308,7 +2104,55 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1214, + "textLen": 697, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "mobile__git-window-desktop__reduced-motion.png", + "route": "/", + "scenario": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "viewport": "mobile", + "mode": "reduced-motion", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 582, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "mobile__git-window-constrained__reduced-motion.png", + "route": "/", + "scenario": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "viewport": "mobile", + "mode": "reduced-motion", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 582, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1327,7 +2171,7 @@ "theme": "dark", "hasShell": true, "windowCount": 0, - "textLen": 806, + "textLen": 156, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1346,7 +2190,7 @@ "theme": "dark", "hasShell": true, "windowCount": 3, - "textLen": 1802, + "textLen": 1034, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1364,7 +2208,7 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1680, + "textLen": 1030, "missingRequiredSelectors": [], "pageErrors": [] }, @@ -1382,7 +2226,55 @@ "theme": "dark", "hasShell": true, "windowCount": 2, - "textLen": 1214, + "textLen": 697, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "mobile__git-window-desktop__forced-colors.png", + "route": "/", + "scenario": "git-window-desktop", + "description": "Git client window shell at a generous desktop size", + "viewport": "mobile", + "mode": "forced-colors", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-desktop\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-desktop\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-desktop\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-desktop\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 582, + "missingRequiredSelectors": [], + "pageErrors": [] + }, + { + "file": "mobile__git-window-constrained__forced-colors.png", + "route": "/", + "scenario": "git-window-constrained", + "description": "Git client window shell at a constrained window size", + "viewport": "mobile", + "mode": "forced-colors", + "requiredSelectors": [ + "[data-window-id=\"issue-1574-git-constrained\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [aria-label=\"Git\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Repository\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"combobox\"][aria-label=\"Branch\"]", + "[data-window-id=\"issue-1574-git-constrained\"] [role=\"tablist\"][aria-label=\"Changes and history\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"]", + "[data-window-id=\"issue-1574-git-constrained\"] nav.rv-filelist[aria-label=\"Changed files\"] input[type=\"checkbox\"]", + "[data-window-id=\"issue-1574-git-constrained\"] section[aria-label=\"Commit\"]" + ], + "theme": "dark", + "hasShell": true, + "windowCount": 1, + "textLen": 582, "missingRequiredSelectors": [], "pageErrors": [] } diff --git a/docs/design-system/evidence/1300/browser/mobile__git-window-constrained__dark-hc.png b/docs/design-system/evidence/1300/browser/mobile__git-window-constrained__dark-hc.png new file mode 100644 index 000000000..f08dfc21d Binary files /dev/null and b/docs/design-system/evidence/1300/browser/mobile__git-window-constrained__dark-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__git-window-constrained__dark.png b/docs/design-system/evidence/1300/browser/mobile__git-window-constrained__dark.png new file mode 100644 index 000000000..49603b72f Binary files /dev/null and b/docs/design-system/evidence/1300/browser/mobile__git-window-constrained__dark.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__git-window-constrained__forced-colors.png b/docs/design-system/evidence/1300/browser/mobile__git-window-constrained__forced-colors.png new file mode 100644 index 000000000..39c3beb57 Binary files /dev/null and b/docs/design-system/evidence/1300/browser/mobile__git-window-constrained__forced-colors.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__git-window-constrained__light-hc.png b/docs/design-system/evidence/1300/browser/mobile__git-window-constrained__light-hc.png new file mode 100644 index 000000000..d747b2dee Binary files /dev/null and b/docs/design-system/evidence/1300/browser/mobile__git-window-constrained__light-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__git-window-constrained__light.png b/docs/design-system/evidence/1300/browser/mobile__git-window-constrained__light.png new file mode 100644 index 000000000..a2061e52a Binary files /dev/null and b/docs/design-system/evidence/1300/browser/mobile__git-window-constrained__light.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__git-window-constrained__reduced-motion.png b/docs/design-system/evidence/1300/browser/mobile__git-window-constrained__reduced-motion.png new file mode 100644 index 000000000..49603b72f Binary files /dev/null and b/docs/design-system/evidence/1300/browser/mobile__git-window-constrained__reduced-motion.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__git-window-desktop__dark-hc.png b/docs/design-system/evidence/1300/browser/mobile__git-window-desktop__dark-hc.png new file mode 100644 index 000000000..942048c6f Binary files /dev/null and b/docs/design-system/evidence/1300/browser/mobile__git-window-desktop__dark-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__git-window-desktop__dark.png b/docs/design-system/evidence/1300/browser/mobile__git-window-desktop__dark.png new file mode 100644 index 000000000..bb9fd4019 Binary files /dev/null and b/docs/design-system/evidence/1300/browser/mobile__git-window-desktop__dark.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__git-window-desktop__forced-colors.png b/docs/design-system/evidence/1300/browser/mobile__git-window-desktop__forced-colors.png new file mode 100644 index 000000000..ad2685296 Binary files /dev/null and b/docs/design-system/evidence/1300/browser/mobile__git-window-desktop__forced-colors.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__git-window-desktop__light-hc.png b/docs/design-system/evidence/1300/browser/mobile__git-window-desktop__light-hc.png new file mode 100644 index 000000000..235b1f475 Binary files /dev/null and b/docs/design-system/evidence/1300/browser/mobile__git-window-desktop__light-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__git-window-desktop__light.png b/docs/design-system/evidence/1300/browser/mobile__git-window-desktop__light.png new file mode 100644 index 000000000..2943b597d Binary files /dev/null and b/docs/design-system/evidence/1300/browser/mobile__git-window-desktop__light.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__git-window-desktop__reduced-motion.png b/docs/design-system/evidence/1300/browser/mobile__git-window-desktop__reduced-motion.png new file mode 100644 index 000000000..bb9fd4019 Binary files /dev/null and b/docs/design-system/evidence/1300/browser/mobile__git-window-desktop__reduced-motion.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__shell__dark-hc.png b/docs/design-system/evidence/1300/browser/mobile__shell__dark-hc.png index d6c50fe3c..bd5e01e61 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__shell__dark-hc.png and b/docs/design-system/evidence/1300/browser/mobile__shell__dark-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__shell__dark.png b/docs/design-system/evidence/1300/browser/mobile__shell__dark.png index 65a77375d..84dd5b027 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__shell__dark.png and b/docs/design-system/evidence/1300/browser/mobile__shell__dark.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__shell__forced-colors.png b/docs/design-system/evidence/1300/browser/mobile__shell__forced-colors.png index 7fac05e28..03e7dcda3 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__shell__forced-colors.png and b/docs/design-system/evidence/1300/browser/mobile__shell__forced-colors.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__shell__light-hc.png b/docs/design-system/evidence/1300/browser/mobile__shell__light-hc.png index 215dfece3..966e116f8 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__shell__light-hc.png and b/docs/design-system/evidence/1300/browser/mobile__shell__light-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__shell__light.png b/docs/design-system/evidence/1300/browser/mobile__shell__light.png index 65a5b26c9..15f271a1f 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__shell__light.png and b/docs/design-system/evidence/1300/browser/mobile__shell__light.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__shell__reduced-motion.png b/docs/design-system/evidence/1300/browser/mobile__shell__reduced-motion.png index 65a77375d..2ddb2d9f1 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__shell__reduced-motion.png and b/docs/design-system/evidence/1300/browser/mobile__shell__reduced-motion.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__dark-hc.png b/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__dark-hc.png index bfbd1aa29..297e7e3dc 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__dark-hc.png and b/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__dark-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__dark.png b/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__dark.png index c1244e40e..707bdfa63 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__dark.png and b/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__dark.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__forced-colors.png b/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__forced-colors.png index 1f66f58f8..0111e4075 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__forced-colors.png and b/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__forced-colors.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__light-hc.png b/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__light-hc.png index 2f6876a67..8ea8ace73 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__light-hc.png and b/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__light-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__light.png b/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__light.png index d96b1c236..cbd01a3b8 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__light.png and b/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__light.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__reduced-motion.png b/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__reduced-motion.png index c1244e40e..db2be3e6b 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__reduced-motion.png and b/docs/design-system/evidence/1300/browser/mobile__workspace-chat-quality__reduced-motion.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__dark-hc.png b/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__dark-hc.png index bf8f04109..81598e4a4 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__dark-hc.png and b/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__dark-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__dark.png b/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__dark.png index 4a58268eb..81ef70935 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__dark.png and b/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__dark.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__forced-colors.png b/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__forced-colors.png index 63787ce22..2b0d564d3 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__forced-colors.png and b/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__forced-colors.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__light-hc.png b/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__light-hc.png index aef1ff68c..c7ad1f37b 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__light-hc.png and b/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__light-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__light.png b/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__light.png index 036493051..4523da715 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__light.png and b/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__light.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__reduced-motion.png b/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__reduced-motion.png index 4a58268eb..81ef70935 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__reduced-motion.png and b/docs/design-system/evidence/1300/browser/mobile__workspace-files-editor__reduced-motion.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__dark-hc.png b/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__dark-hc.png index d629ec146..1010efe1f 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__dark-hc.png and b/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__dark-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__dark.png b/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__dark.png index 8c0c1e0bf..4c925a9de 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__dark.png and b/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__dark.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__forced-colors.png b/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__forced-colors.png index b59141df3..7736b2091 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__forced-colors.png and b/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__forced-colors.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__light-hc.png b/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__light-hc.png index 0b530bf05..019f7590b 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__light-hc.png and b/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__light-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__light.png b/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__light.png index 85c1ffa25..361e357c2 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__light.png and b/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__light.png differ diff --git a/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__reduced-motion.png b/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__reduced-motion.png index 8c0c1e0bf..c2cdb2129 100644 Binary files a/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__reduced-motion.png and b/docs/design-system/evidence/1300/browser/mobile__workspace-memory-relationships__reduced-motion.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__git-window-constrained__dark-hc.png b/docs/design-system/evidence/1300/browser/tablet__git-window-constrained__dark-hc.png new file mode 100644 index 000000000..5c9ce567c Binary files /dev/null and b/docs/design-system/evidence/1300/browser/tablet__git-window-constrained__dark-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__git-window-constrained__dark.png b/docs/design-system/evidence/1300/browser/tablet__git-window-constrained__dark.png new file mode 100644 index 000000000..8991ab9a4 Binary files /dev/null and b/docs/design-system/evidence/1300/browser/tablet__git-window-constrained__dark.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__git-window-constrained__forced-colors.png b/docs/design-system/evidence/1300/browser/tablet__git-window-constrained__forced-colors.png new file mode 100644 index 000000000..8cc510316 Binary files /dev/null and b/docs/design-system/evidence/1300/browser/tablet__git-window-constrained__forced-colors.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__git-window-constrained__light-hc.png b/docs/design-system/evidence/1300/browser/tablet__git-window-constrained__light-hc.png new file mode 100644 index 000000000..604ebad27 Binary files /dev/null and b/docs/design-system/evidence/1300/browser/tablet__git-window-constrained__light-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__git-window-constrained__light.png b/docs/design-system/evidence/1300/browser/tablet__git-window-constrained__light.png new file mode 100644 index 000000000..15a5b7741 Binary files /dev/null and b/docs/design-system/evidence/1300/browser/tablet__git-window-constrained__light.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__git-window-constrained__reduced-motion.png b/docs/design-system/evidence/1300/browser/tablet__git-window-constrained__reduced-motion.png new file mode 100644 index 000000000..8991ab9a4 Binary files /dev/null and b/docs/design-system/evidence/1300/browser/tablet__git-window-constrained__reduced-motion.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__git-window-desktop__dark-hc.png b/docs/design-system/evidence/1300/browser/tablet__git-window-desktop__dark-hc.png new file mode 100644 index 000000000..b1af9e15c Binary files /dev/null and b/docs/design-system/evidence/1300/browser/tablet__git-window-desktop__dark-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__git-window-desktop__dark.png b/docs/design-system/evidence/1300/browser/tablet__git-window-desktop__dark.png new file mode 100644 index 000000000..7f4801a47 Binary files /dev/null and b/docs/design-system/evidence/1300/browser/tablet__git-window-desktop__dark.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__git-window-desktop__forced-colors.png b/docs/design-system/evidence/1300/browser/tablet__git-window-desktop__forced-colors.png new file mode 100644 index 000000000..2d14e0a5c Binary files /dev/null and b/docs/design-system/evidence/1300/browser/tablet__git-window-desktop__forced-colors.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__git-window-desktop__light-hc.png b/docs/design-system/evidence/1300/browser/tablet__git-window-desktop__light-hc.png new file mode 100644 index 000000000..9061d2eed Binary files /dev/null and b/docs/design-system/evidence/1300/browser/tablet__git-window-desktop__light-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__git-window-desktop__light.png b/docs/design-system/evidence/1300/browser/tablet__git-window-desktop__light.png new file mode 100644 index 000000000..87d58dc42 Binary files /dev/null and b/docs/design-system/evidence/1300/browser/tablet__git-window-desktop__light.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__git-window-desktop__reduced-motion.png b/docs/design-system/evidence/1300/browser/tablet__git-window-desktop__reduced-motion.png new file mode 100644 index 000000000..7f4801a47 Binary files /dev/null and b/docs/design-system/evidence/1300/browser/tablet__git-window-desktop__reduced-motion.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__shell__dark-hc.png b/docs/design-system/evidence/1300/browser/tablet__shell__dark-hc.png index c02395b1e..4553ad136 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__shell__dark-hc.png and b/docs/design-system/evidence/1300/browser/tablet__shell__dark-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__shell__dark.png b/docs/design-system/evidence/1300/browser/tablet__shell__dark.png index 3575310b2..f4ca6f492 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__shell__dark.png and b/docs/design-system/evidence/1300/browser/tablet__shell__dark.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__shell__forced-colors.png b/docs/design-system/evidence/1300/browser/tablet__shell__forced-colors.png index 3327c4a32..e729b1bf7 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__shell__forced-colors.png and b/docs/design-system/evidence/1300/browser/tablet__shell__forced-colors.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__shell__light-hc.png b/docs/design-system/evidence/1300/browser/tablet__shell__light-hc.png index d5a381d96..44d726549 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__shell__light-hc.png and b/docs/design-system/evidence/1300/browser/tablet__shell__light-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__shell__light.png b/docs/design-system/evidence/1300/browser/tablet__shell__light.png index 45d5b5a77..36ebb2412 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__shell__light.png and b/docs/design-system/evidence/1300/browser/tablet__shell__light.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__shell__reduced-motion.png b/docs/design-system/evidence/1300/browser/tablet__shell__reduced-motion.png index 3575310b2..a2500c1f1 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__shell__reduced-motion.png and b/docs/design-system/evidence/1300/browser/tablet__shell__reduced-motion.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__dark-hc.png b/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__dark-hc.png index 4084fe9ff..868f537c5 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__dark-hc.png and b/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__dark-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__dark.png b/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__dark.png index 35fdbe42c..a23561b07 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__dark.png and b/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__dark.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__forced-colors.png b/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__forced-colors.png index 27a88c95d..ac57ec7d0 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__forced-colors.png and b/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__forced-colors.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__light-hc.png b/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__light-hc.png index 1057dafa9..96e010608 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__light-hc.png and b/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__light-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__light.png b/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__light.png index ed3270e0f..1728a1eb3 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__light.png and b/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__light.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__reduced-motion.png b/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__reduced-motion.png index 7ce67aa61..1018da420 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__reduced-motion.png and b/docs/design-system/evidence/1300/browser/tablet__workspace-chat-quality__reduced-motion.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__dark-hc.png b/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__dark-hc.png index 51c1adcae..d11908275 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__dark-hc.png and b/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__dark-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__dark.png b/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__dark.png index e26664dd0..39cd9a1bf 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__dark.png and b/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__dark.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__forced-colors.png b/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__forced-colors.png index e721ebdfd..02f1b2056 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__forced-colors.png and b/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__forced-colors.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__light-hc.png b/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__light-hc.png index b9495489d..03efd5084 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__light-hc.png and b/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__light-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__light.png b/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__light.png index 2ff45505a..fab405785 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__light.png and b/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__light.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__reduced-motion.png b/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__reduced-motion.png index ee4e5c578..39cd9a1bf 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__reduced-motion.png and b/docs/design-system/evidence/1300/browser/tablet__workspace-files-editor__reduced-motion.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__dark-hc.png b/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__dark-hc.png index 59e7ba9b1..1251f0fe3 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__dark-hc.png and b/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__dark-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__dark.png b/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__dark.png index 293985663..927e47881 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__dark.png and b/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__dark.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__forced-colors.png b/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__forced-colors.png index 27215063b..fae9fc05a 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__forced-colors.png and b/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__forced-colors.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__light-hc.png b/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__light-hc.png index 02041e00e..4486b1c15 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__light-hc.png and b/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__light-hc.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__light.png b/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__light.png index a880ce05e..b19ddfc27 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__light.png and b/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__light.png differ diff --git a/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__reduced-motion.png b/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__reduced-motion.png index 293985663..927e47881 100644 Binary files a/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__reduced-motion.png and b/docs/design-system/evidence/1300/browser/tablet__workspace-memory-relationships__reduced-motion.png differ diff --git a/docs/git-delivery/README.md b/docs/git-delivery/README.md index 2cb7c9408..ccc8c023b 100644 --- a/docs/git-delivery/README.md +++ b/docs/git-delivery/README.md @@ -27,6 +27,7 @@ Architecture background: - [Governed remote publish](governed-remote-publish.md) - [Governed GitHub pull request command center](governed-github-pull-request.md) - [Governed merge](governed-merge.md) +- [Git client repository state, history, remotes, and sync API](git-client-repository-api.md) Executable closure check: diff --git a/docs/git-delivery/epic-1571-closeout.md b/docs/git-delivery/epic-1571-closeout.md new file mode 100644 index 000000000..9b55e7dce --- /dev/null +++ b/docs/git-delivery/epic-1571-closeout.md @@ -0,0 +1,119 @@ +# Git Client Epic #1571 Closeout Evidence + +Issue #1578 is the closure gate for Epic #1571. It does not add new Git product capability. It proves +that the repository-centered Git client delivered by #1573 through #1577 is wired through the intended +Keiko authority boundaries, has deterministic browser evidence, and remains covered by the required +quality gates. + +## Scope And Decision + +The source of truth for the desktop Git client shape is the +[Git client reuse contract](git-client-desktop-reuse-contract.md). GitHub Desktop was used as an +interaction reference only. Keiko did not copy GitHub Desktop source, stylesheets, icons, assets, or +trademarked product text. + +The closeout decision is: + +- Keep the `governedGit` window registration for compatibility while visible product text says `Git`. +- Reuse existing Keiko BFF and Git delivery routes rather than introducing a new shell or command + runner. +- Treat #1578 as docs, evidence, validation, and narrowly scoped evidence-hygiene repair only. + +## Child Delivery Ledger + +| Issue | Delivery source | Evidence and docs | Closure disposition | +| --- | --- | --- | --- | +| #1573 | PR [#1606](https://github.com/oscharko-dev/Keiko/pull/1606), repair PR [#1607](https://github.com/oscharko-dev/Keiko/pull/1607), supporting redaction PR [#1608](https://github.com/oscharko-dev/Keiko/pull/1608) | [Repository state and sync API](git-client-repository-api.md), [ADR-0098](../adr/ADR-0098-git-client-repository-state-and-sync-api.md) | Read/history/remotes/fetch/pull foundation delivered and hardened. | +| #1574 | PR [#1614](https://github.com/oscharko-dev/Keiko/pull/1614), repair PR [#1619](https://github.com/oscharko-dev/Keiko/pull/1619) | #1300 Git-window browser evidence under `docs/design-system/evidence/1300/browser/` | Git window shell, repository selector, and entry states delivered. | +| #1575 | PR [#1620](https://github.com/oscharko-dev/Keiko/pull/1620), repair PR [#1629](https://github.com/oscharko-dev/Keiko/pull/1629) | [#1575 manifest](evidence/1575/manifest.json), `evidence/1575/git-changes-view.png` | Changes, diff, staging, and commit composer delivered. | +| #1576 | PR [#1638](https://github.com/oscharko-dev/Keiko/pull/1638), audit repair PR [#1643](https://github.com/oscharko-dev/Keiko/pull/1643) | [#1576 manifest](evidence/1576/manifest.json), `evidence/1576/git-branch-history-sync.png` | Branch selector/create/switch, history, and sync states delivered and evidence hardened. | +| #1577 | PR [#1640](https://github.com/oscharko-dev/Keiko/pull/1640), audit repair PR [#1641](https://github.com/oscharko-dev/Keiko/pull/1641) | [#1577 manifest](evidence/1577/manifest.json), `evidence/1577/git-pr-merge-agent-ops.png` | Pull Request, Merge, and typed agent operation facade delivered. | +| #1578 | This closeout PR | [#1578 manifest](evidence/1578/manifest.json), desktop and constrained screenshots, this document | Final verification, deterministic evidence gate, and epic closure evidence. | + +## Reuse And No-Duplicate Decisions + +The implementation extends the existing Keiko Git surface: + +- Git status, diff, and branches reuse the existing `/api/git/*` read routes. +- Summary, history, and remotes are documented in [the repository API reference](git-client-repository-api.md). +- Local staging, branch, and commit operations reuse existing Git delivery mutation routes and the + `keiko-tools` mutation kernel. +- Fetch and pull reuse the fixed-argv sync executor from #1573, with strict preview/readiness gating. +- Push, Pull Request, and Merge reuse the existing publish, PR, and merge gateways. +- Browser UI calls flow through `git-client-seam.ts`; the Git window does not own a shell runner, + provider adapter, credential lookup path, or model access path. + +No new backend Git mutation route was introduced for #1578. No new public product capability is hidden +inside the closeout issue. + +## Trust And Credential Boundaries + +The Git client keeps mutating operations behind BFF/tooling authority: + +- POST routes remain under the server's central JSON, content-type, and CSRF enforcement. +- Workspaces are resolved through registered project roots before Git reads or writes run. +- Git argv is fixed by the server/tooling layer; browser requests do not carry raw command text. +- Evidence is content-free: outcomes, counts, route names, and policy codes are recorded instead of raw + command output. +- Remote URLs and credentials are not rendered in browser evidence. Summary responses expose remote + aliases only; owner/repo inference uses the dedicated remotes seam without displaying URLs. +- GitHub provider credentials remain owned by the provider tooling, for example `gh` or the configured + runtime environment. They are not copied into browser state, screenshots, manifests, issue comments, + or documentation. + +The #1578 Playwright evidence uses a local bare repository fixture and deterministic route stubs only. +It performs no external provider calls and uses no real credentials. + +## Agent Operation Boundary + +#1577 added a typed agent operation facade at `/api/git/agent/operations`. The facade is intentionally +not a shell capability: + +- It accepts a fixed operation/mode union and typed payload. +- It rejects shell-shaped, argv-shaped, extra-key, and credential-shaped requests before delegation. +- Read operations delegate to existing Git read seams. +- Preview and execute operations delegate to existing local mutation, sync, push, Pull Request, and + Merge preview/execute routes. +- Execute idempotency is enforced at the facade boundary. + +This keeps agent repository operations inside the same preview, policy, evidence, redaction, and +provider boundaries as human-triggered UI operations. + +## MIT And No-Copied-Code Disposition + +The selective-code policy in [the reuse contract](git-client-desktop-reuse-contract.md) allowed +GitHub Desktop as an interaction reference, not as a source-code donor. The implementation was built +from Keiko components, BFF routes, contracts, tests, and design-system tokens already in this +repository. No copied GitHub Desktop code, assets, or trademarked UI strings were introduced, so no +additional MIT attribution block is required beyond normal third-party license handling. + +## Verification And Static Gates + +The final evidence package is checked by `npm run check:git-client-evidence`. That checker validates: + +- #1575 through #1578 manifests exist and map to Epic #1571. +- Required browser artifacts exist and are PNGs within size bounds. +- Committed manifests are path-free, timestamp-free, remote-URL-free, and credential-free. +- #1578 links to the child evidence and records the expected no-leak assertions. + +Required GitHub `ci` remains the merge gate for the final PR. Relevant static and release gates remain: +`typecheck`, `lint`, `arch:check`, `arch:check:negative`, `check:version-consistency`, coverage quality, +actionlint, pinned action SHA verification, npm audit, SBOM/license gates, build/package smoke, and +platform smoke jobs. + +Qodana is not configured as a separate repository gate. CodeQL is present as a workflow, but it is not +configured as a required gate for `feat/keiko-repository-centered-desktop-workflow`; adding or changing +CodeQL branch coverage is outside #1578 unless maintainers explicitly request it. + +## Closure Limits + +The browser evidence is deterministic and uses local fixtures plus route stubs; it is not proof of live +provider availability or customer credential validity. That is intentional for CI safety. + +#1573 and #1574 are evidenced by contracts, docs, tests, screenshots, PR checks, and issue comments +rather than dedicated `docs/git-delivery/evidence/1573` or `docs/git-delivery/evidence/1574` +directories. #1575 through #1578 carry dedicated manifests and screenshots. + +No stop condition triggered during #1578 closeout. Any future provider-specific GitHub API behavior, +live credential walkthrough, or additional CodeQL/Qodana branch policy should be tracked as a new issue +outside the completed Epic #1571 delivery. diff --git a/docs/git-delivery/evidence/1575/git-changes-view.png b/docs/git-delivery/evidence/1575/git-changes-view.png new file mode 100644 index 000000000..7c56e5c3d Binary files /dev/null and b/docs/git-delivery/evidence/1575/git-changes-view.png differ diff --git a/docs/git-delivery/evidence/1575/manifest.json b/docs/git-delivery/evidence/1575/manifest.json new file mode 100644 index 000000000..ee35a4cd5 --- /dev/null +++ b/docs/git-delivery/evidence/1575/manifest.json @@ -0,0 +1,50 @@ +{ + "issue": "#1575", + "epic": "#1571", + "harness": "tests/e2e/config/playwright.issue-1575-git-changes.config.ts", + "appPath": "packaged-cli-ui", + "route": "/", + "evidencePath": "docs/git-delivery/evidence/1575", + "fixture": { + "kind": "local-git-repository", + "worktree": "temporary-local-worktree", + "browserProjectPath": "keiko-git-changes-1575" + }, + "routesIntercepted": [ + "/api/git/status", + "/api/git/branches", + "/api/git/summary", + "/api/git/history", + "/api/git/remotes", + "/api/git/diff", + "/api/projects", + "/api/git-delivery/staging/stage", + "/api/git-delivery/staging/unstage", + "/api/git-delivery/commit/preview" + ], + "windowRegistration": { + "kind": "gitClient", + "windowType": "governedGit", + "seededVia": "keiko.workspace.v4", + "renderedBy": "GitClientWindow", + "cfgPersistence": "fs-reference" + }, + "gitFixtureStates": { + "modified": "src/app.ts — tracked, committed, then edited in worktree (worktreeStatus=M)", + "added": "src/new-feature.ts — new file staged but never committed (indexStatus=A)", + "deleted": "src/legacy.ts — committed then staged for removal (indexStatus=D)", + "renamed": "docs/README.md → docs/OVERVIEW.md — staged rename (indexStatus=R)", + "untracked": "notes.txt — written but never staged (untracked=true)", + "conflicted": "src/shared.ts — unresolved merge conflict between main and feature branches" + }, + "notes": [ + "Real governedGit window rendered through the real WindowsRegistry + GitClientWindow.", + "Read surface (status/diff) intercepted with deterministic fixture carrying all six states.", + "Staging mutation routes intercepted for determinism; commit is not exercised (no staged files).", + "The real git fixture is built in a temp dir (execFileSync git init/add/commit/mv/rm/merge)." + ], + "artifacts": [ + "manifest.json", + "git-changes-view.png" + ] +} diff --git a/docs/git-delivery/evidence/1576/git-branch-history-sync.png b/docs/git-delivery/evidence/1576/git-branch-history-sync.png new file mode 100644 index 000000000..af78dc4ff Binary files /dev/null and b/docs/git-delivery/evidence/1576/git-branch-history-sync.png differ diff --git a/docs/git-delivery/evidence/1576/manifest.json b/docs/git-delivery/evidence/1576/manifest.json new file mode 100644 index 000000000..ac2c70671 --- /dev/null +++ b/docs/git-delivery/evidence/1576/manifest.json @@ -0,0 +1,58 @@ +{ + "issue": "#1576", + "epic": "#1571", + "harness": "tests/e2e/config/playwright.issue-1576-git-branch-sync.config.ts", + "appPath": "packaged-cli-ui", + "route": "/", + "evidencePath": "docs/git-delivery/evidence/1576", + "fixture": { + "kind": "local-bare-repository", + "worktree": "temporary-local-worktree", + "remote": "temporary-local-bare-remote", + "branches": [ + "main", + "feature/local" + ] + }, + "routesIntercepted": [ + "/api/projects", + "/api/git/branches", + "/api/git/status", + "/api/git/summary", + "/api/git/history", + "/api/git/remotes", + "/api/git-delivery/local-branch/create", + "/api/git-delivery/local-branch/switch", + "/api/git-delivery/fetch/preview", + "/api/git-delivery/fetch/execute", + "/api/git-delivery/pull/preview", + "/api/git-delivery/pull/execute", + "/api/git-delivery/push/preview", + "/api/git-delivery/push/execute" + ], + "windowRegistration": { + "windowType": "governedGit", + "seededVia": "keiko.workspace.v4", + "renderedBy": "GitClientWindow", + "cfgPersistence": "fs-reference" + }, + "statesCovered": [ + "branch list and searchable branch switch", + "new branch from selected real branch data with hidden hash", + "history list and selected commit metadata", + "behind -> pull", + "ahead -> push", + "no upstream -> publish upstream", + "diverged -> fetch with merge guidance", + "detached and conflicted blocked sync states" + ], + "notes": [ + "The fixture uses git init --bare plus a local worktree remote; no external network or credentials.", + "Read and mutation routes are intercepted for deterministic browser assertions.", + "Branch create uses headRefHash internally; the UI assertions verify hashes are not rendered." + ], + "artifacts": [ + "manifest.json", + "git-branch-history-sync.png" + ] +} diff --git a/docs/git-delivery/evidence/1577/git-pr-merge-agent-ops.png b/docs/git-delivery/evidence/1577/git-pr-merge-agent-ops.png new file mode 100644 index 000000000..dab70f1c1 Binary files /dev/null and b/docs/git-delivery/evidence/1577/git-pr-merge-agent-ops.png differ diff --git a/docs/git-delivery/evidence/1577/manifest.json b/docs/git-delivery/evidence/1577/manifest.json new file mode 100644 index 000000000..9f289d404 --- /dev/null +++ b/docs/git-delivery/evidence/1577/manifest.json @@ -0,0 +1,42 @@ +{ + "issue": "#1577", + "epic": "#1571", + "harness": "tests/e2e/config/playwright.issue-1577-git-pr-merge.config.ts", + "appPath": "packaged-cli-ui", + "evidencePath": "docs/git-delivery/evidence/1577", + "routesIntercepted": [ + "/api/projects", + "/api/git/status", + "/api/git/branches", + "/api/git/summary", + "/api/git/history", + "/api/git/remotes", + "/api/git/diff", + "/api/git-delivery/pr/preview", + "/api/git-delivery/pr/execute", + "/api/git-delivery/merge/preview", + "/api/git-delivery/merge/execute" + ], + "fixture": { + "kind": "temporary registered repository with deterministic route stubs", + "browserProjectPath": "keiko-git-pr-merge-1577" + }, + "statesVerified": [ + "Git-window right pane switches from Diff to Pull Request and back", + "Git-window right pane switches from Diff to Merge", + "owner/repo, head branch, and base branch prefill without rendering remote URLs", + "Pull Request preview delegates to /api/git-delivery/pr/preview and renders blocked policy", + "Merge preview delegates to /api/git-delivery/merge/preview and renders readiness recovery" + ], + "assertions": { + "prPreviewCalls": 1, + "mergePreviewCalls": 1, + "remoteUrlsRendered": false, + "localPathsRendered": false, + "externalCredentialsUsed": false + }, + "artifacts": [ + "manifest.json", + "git-pr-merge-agent-ops.png" + ] +} diff --git a/docs/git-delivery/evidence/1578/git-client-closeout-constrained.png b/docs/git-delivery/evidence/1578/git-client-closeout-constrained.png new file mode 100644 index 000000000..c9b8ae060 Binary files /dev/null and b/docs/git-delivery/evidence/1578/git-client-closeout-constrained.png differ diff --git a/docs/git-delivery/evidence/1578/git-client-closeout-desktop.png b/docs/git-delivery/evidence/1578/git-client-closeout-desktop.png new file mode 100644 index 000000000..a2b7aa5be Binary files /dev/null and b/docs/git-delivery/evidence/1578/git-client-closeout-desktop.png differ diff --git a/docs/git-delivery/evidence/1578/manifest.json b/docs/git-delivery/evidence/1578/manifest.json new file mode 100644 index 000000000..18ce39a00 --- /dev/null +++ b/docs/git-delivery/evidence/1578/manifest.json @@ -0,0 +1,70 @@ +{ + "issue": "#1578", + "epic": "#1571", + "harness": "tests/e2e/config/playwright.issue-1578-git-client-closeout.config.ts", + "appPath": "packaged-cli-ui", + "route": "/", + "evidencePath": "docs/git-delivery/evidence/1578", + "fixture": { + "kind": "local-bare-repository", + "worktree": "temporary-local-worktree", + "remote": "temporary-local-bare-remote", + "browserProjectPath": "keiko-git-client-closeout-1578" + }, + "routesIntercepted": [ + "/api/projects", + "/api/git/status", + "/api/git/branches", + "/api/git/summary", + "/api/git/history", + "/api/git/remotes", + "/api/git/diff", + "/api/git-delivery/staging/stage", + "/api/git-delivery/commit/preview", + "/api/git-delivery/commit/execute", + "/api/git-delivery/local-branch/create", + "/api/git-delivery/local-branch/switch", + "/api/git-delivery/pull/preview", + "/api/git-delivery/pull/execute", + "/api/git-delivery/pr/preview", + "/api/git-delivery/merge/preview" + ], + "childEvidence": [ + "docs/git-delivery/evidence/1575/manifest.json", + "docs/git-delivery/evidence/1575/git-changes-view.png", + "docs/git-delivery/evidence/1576/manifest.json", + "docs/git-delivery/evidence/1576/git-branch-history-sync.png", + "docs/git-delivery/evidence/1577/manifest.json", + "docs/git-delivery/evidence/1577/git-pr-merge-agent-ops.png" + ], + "statesVerified": [ + "repository selection and add-repository clone/open dialog", + "changed files, staging, diff pane, commit preview, and commit execute", + "branch search, branch switch, and new branch creation", + "history list and selected commit details", + "sync pull preview and execute", + "Pull Request and Merge panel entry, preview, blocked/readiness outcomes", + "desktop and constrained viewport screenshots", + "path-free and credential-free browser evidence" + ], + "assertions": { + "stageCalls": 1, + "commitPreviews": 2, + "commitExecutes": 1, + "branchCreates": 1, + "branchSwitches": 2, + "syncPreviews": 1, + "syncExecutes": 1, + "prPreviews": 1, + "mergePreviews": 1, + "localPathsRendered": false, + "remoteUrlsRendered": false, + "externalCredentialsUsed": false, + "forbiddenVisibleGovernanceLanguage": false + }, + "artifacts": [ + "manifest.json", + "git-client-closeout-desktop.png", + "git-client-closeout-constrained.png" + ] +} diff --git a/docs/git-delivery/git-client-desktop-reuse-contract.md b/docs/git-delivery/git-client-desktop-reuse-contract.md new file mode 100644 index 000000000..a2e6976d8 --- /dev/null +++ b/docs/git-delivery/git-client-desktop-reuse-contract.md @@ -0,0 +1,172 @@ +# Git Client: GitHub Desktop Adaptation and Keiko Reuse Contract + +**Status:** Decision-ready for Epic #1571 +**Issue:** #1572 (audit/contract) +**Date:** 2026-06-27 + +## Purpose + +This note is the decision-ready architecture contract for redesigning Keiko's local Git surface into a single coherent, GitHub-Desktop-inspired Git window. It audits the GitHub Desktop interaction flows worth adapting, maps each one to the existing Keiko building block that satisfies it (with verified `file:line` citations and a reuse classification), and identifies the genuine capability gaps that remain. It freezes a target layout spec and the visible-naming policy so the child issues (#1573 API foundation, #1574 window shell, #1575 changes/diff/commit, #1576 branch/history/sync, #1577 PR/merge/agent ops, #1578 verification) implement against one agreed design rather than re-deriving these decisions. This issue changes no production UI, routes, or Git mutation behavior; its only deliverable is this note. + +--- + +## 1. GitHub Desktop Flows to Adapt + +GitHub Desktop is used strictly as a UX/interaction reference (see Selective-Code Policy). For each flow: the Desktop behavior, the Keiko adaptation, and the entry point in the target Git window. + +| Flow | GitHub Desktop behavior (UX reference) | Keiko adaptation | Entry point in the Git window | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Repository selection** | "Current Repository" dropdown anchors the top-left toolbar; lists cloned local repos, filterable, with Add (Clone/Add Existing/Create New) and recent entries; selection scopes the whole window. | Repository picker showing only Keiko-registered/bound managed workspaces, every path resolved through the bound-root choke point so the selected repo is a governed workspace, not an arbitrary path. Add = bounded clone/open that registers a project. | Top-left repository picker in the window header, workspace-bound via `resolveBoundRoot` (`packages/keiko-ui/src/app/components/desktop/widgets/index.tsx:61`). | +| **Branch selector** | "Current Branch" dropdown lists local/remote branches, filterable, with New Branch dialog (name + base) and switch-on-click; dirty-switch prompt; Publish branch for unpublished. | Filterable branch dropdown for the bound repo with create/switch/publish actions; switching with a dirty buffer routes through the explicit dirty-buffer guard; publish is a credential-bound remote op behind the publish gateway. | Branch dropdown in the toolbar, adjacent to the repository picker. | +| **Changes** | Left "Changes" tab lists modified files with color-coded status icons and per-file/hunk staging checkboxes; commit composer docked at the bottom. | Changes list with status badges and explicit, reviewable selection before any write; agent-proposed changes appear in the same list and must be previewed/approved through the shared action-sheet pattern. | "Changes" pane in the left sidebar of the window. | +| **History** | "History" tab shows a reverse-chronological commit list (summary, time, SHA); selecting a commit reveals message + changed files + diff; context actions (branch-from-commit, revert). | Commit timeline for the bound repo with selectable commits revealing message + file-level diffs; reads are bounded read-only `git log`; revert/branch-from-commit are governed writes via preview + approval. | "History" tab in the left sidebar, peer to "Changes". | +| **Diff** | Main pane renders the selected file's diff; unified/split toggle; hide-whitespace; specialized renderers for binary/image. | Reuse the existing parsed/highlighted diff renderer and the optional Monaco side-by-side surface; the diff is the canonical explicit preview for writes. | Central diff viewer, populated from Changes or History selection. | +| **Commit composer** | Summary + optional Description fields, co-author control, "Commit to BRANCH" button; commit disabled until summary + staged changes exist. | Composer with summary/description and Co-Authored-By trailer targeting the current branch; commit is a bounded local write executed only after explicit confirmation with the staged set + final message previewed; commits are signed and recorded as evidence. | Commit composer docked at the bottom of the Changes pane. | +| **Sync** | Single context-aware button shifting between Fetch / Pull / Push with ahead/behind counts; Publish branch for unpublished. | Sync control showing ahead/behind state; each remote leg routes through the governed publish gateway with bound credentials; protected targets require readiness checks; preview the commit range before executing; no silent background pushes. | Sync button on the right of the toolbar, state-driven like Desktop's single button. | +| **Pull request** | After push, "Create Pull Request" opens github.com pre-filled; PR list in the branch dropdown allows local checkout. | PR creation and PR list in-app via the governed PR command center using credential-bound API calls (no browser handoff); metadata authored and previewed in-app; agent can draft a PR a human approves. | PR action/list reachable from the branch dropdown and a post-push banner. | +| **Merge** | Branch > "Merge into current branch" dialog picks a source branch (ahead/behind), merges locally; conflict resolver lists conflicted files; "Commit merge" step. | Governed merge gateway: source-branch dialog with ahead/behind context, bounded local merge behind protected-branch readiness, conflicts surfaced in the diff/editor surface; signed, audited merge commit; fail-closed on ambiguous conflict state. | Merge action in the branch menu/dropdown, backed by the merge gateway. | + +--- + +## 2. Keiko Reuse Map + +Each reuse point with its primary `file:line`, a classification, and rationale. Classifications: **reuse** (consume unchanged), **extend** (reuse core, add a sibling capability), **generalize** (lift an embedded pattern into a shared form), **replace** (discard the current presentation, carry forward internals), **untouched** (no change). + +| Reuse point | Primary citation | Classification | Rationale | +| ------------------------------------------------------ | --------------------------------------------------------------------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **GovernedGitFlowCard** (form-heavy local Git surface) | `packages/keiko-ui/src/app/components/desktop/widgets/cards/GovernedGitFlowCard.tsx:50` | **replace** (presentation) | The stacked-forms presentation is removed: inline-style panels (`:123`), Branch/Stage/Commit/Publish forms (`:553`, `:687`, `:842`, `:942`), RepositoryManager clone/register forms (`:1044`), and the "Delivery path" sidebar (`:1510`). Carried forward verbatim: the `GovernedGitFlowClient` DI seam (`:50`), `DEFAULT_CLIENT` BFF wiring (`:65`), typed-code label maps (`:82`), the `useGovernedGitActions` state hook with its seqRef stale-guard (`:1417`), and the branch-reload-after-mutation pattern (`:1697`). | +| **gitRoutes** (status/diff/branches READ) | `packages/keiko-server/src/gitRoutes.ts:530` | **extend** | `GET /api/git/status` (`:530`), `/api/git/diff` (`:584`), `/api/git/branches` (`:441`) are reused as-is for the Changes list, diff pane, and branch switcher. New sibling reads (commit log, remotes, ahead/behind) are added here following the same `resolveRepository` (`:239`) + hardened runner (`:111`) + `redacted()` (`:393`) pattern. | +| **gitRepositoryRoutes** (clone) | `packages/keiko-server/src/gitRepositoryRoutes.ts:216` | **reuse** | `POST /api/repositories/clone` (`:216`) backs the Add > Clone flow unchanged, with its URL allowlist (`:88`) and destination containment (`:108`). | +| **gitDelivery/\* mutation routes** | `packages/keiko-server/src/gitDelivery/execution.ts:146` | **reuse** | The new window is a new consumer of the existing governed mutation/publish/PR/merge/evidence routes (`routes.ts:835`). No route signature, guard, gate, or evidence behavior changes. The action-sheet (`actionSheetRoutes.ts:407`) is the unified preview/approval model the window calls before any execute. | +| **keiko-tools mutation gateway** (`runGitMutation`) | `packages/keiko-tools/src/git-mutation-orchestrator.ts:532` | **reuse** | Branch/stage/commit map to `runGitMutation`. The same lifecycle (`:485`) and result shape (`:166`) back both UI and agent ops. | +| **keiko-tools publish gateway** (`runGitPublish`) | `packages/keiko-tools/src/git-publish-gateway.ts:620` | **reuse** | The Push/Sync action maps to `runGitPublish`; force push is refused in `buildPushArgv` (`:326`). | +| **keiko-tools PR gateway** (`runGitPullRequest`) | `packages/keiko-tools/src/git-pr-gateway.ts:690` | **reuse** | Create/Update PR and draft toggle map to `runGitPullRequest`. | +| **keiko-tools merge gateway** (`runGitMerge`) | `packages/keiko-tools/src/git-merge-gateway.ts:810` | **reuse** | Merge maps to `runGitMerge` with its readiness gate (`:770`). | +| **FilesWidget** (status decoration + inline diff) | `packages/keiko-ui/src/app/components/desktop/widgets/cards/FilesWidget.tsx:307` | **extend** | The live data ports `fetchGitDiff`/`fetchGitStatus` (`:7`/`:8`) and the status vocabulary `gitStatusSummary` (`:153`) / `gitChangeLabel` (`:165`) are reused for the Changes pane. The raw `.fpv-code` inline viewer (`:663`) is **replaced** by the parsed diff renderer. | +| **ReviewWidget + diffParser** (parsed diff) | `packages/keiko-ui/src/app/components/desktop/widgets/cards/ReviewWidget.tsx:461` | **generalize** | The `.rv-filelist` changed-file nav, `DiffFileSection`/`DiffHunkView`/`DiffLineView` (`:185`/`:164`/`:131`), and the pure `parseUnifiedDiff` (`shared/diffParser.ts:81`) are lifted into a shared diff-view component fed by live `fetchGitDiff` output instead of a run-report `proposedDiff`. | +| **KeikoDiffEditor** (Monaco side-by-side) | `packages/keiko-editor/src/components/KeikoDiffEditor.tsx:378` | **extend** | Optional rich side-by-side mode; needs a git-original → `PatchPreviewModel` adapter mirroring `buildPatchPreview` (`packages/keiko-editor/src/patch-preview.ts:349`). | +| **Window/widget registry** | `packages/keiko-ui/src/app/components/desktop/windows/WindowsRegistry.ts:603` | **extend** | The `governedGit` entry (title `"Git"`, `:604`) and renderer registration (`widgets/index.tsx:461`) are retargeted to the new client under the same `governedGit` key, keeping `resolveBoundRoot` injection (`index.tsx:465`), singleton/tool flags, and descriptor-meta (`descriptor-meta.ts:248`). | +| **Left rail** | `packages/keiko-ui/src/app/components/desktop/LeftRail.tsx:125` | **untouched** | The Git rail button (`aria-label "Git"`, `onClick onTool('governedGit')`) already opens the surface; no change. | +| **Design tokens / DS primitives** | `docs/design-system/token-component-reuse-map.md:19` | **reuse** | `globals.css` is the sole live token engine; no new token source. KeikoSelect (`KeikoSelect.tsx:34`), the GovernedGitFlowCard button/badge/panel kit (`:309`/`:351`/`:398`), qiShared badges (`qiShared.tsx:104`), `ed-tablist` tabs (`EditorRuntimeWidget.tsx:1963`), WindowFrame resize (`WindowFrame.tsx:294`), and EditorWidget split panes (`EditorWidget.tsx:133`) are reused by composition. | + +--- + +## 3. Capability Gaps + +Most of the surface is fully reused. Three server-side reads are genuine gaps — they are required by GitHub Desktop's History tab, remote management, and the Push/Pull/Fetch sync banner, and do not exist today. Each is justified below as a real gap (not a parallel implementation), and each must be added as a sibling `GET` in `gitRoutes.ts` reusing `resolveRepository` (`gitRoutes.ts:239`), the hardened runner (`:111`), `redacted()` (`:393`), and the `GitRouteOptions` byte-cap/timeout pattern (`deps.ts:220`), registered alongside the existing reads (`routes.ts:345`). + +- **Commit history / log** — no `git log` endpoint exists. `gitRoutes.ts` exposes status/diff/branches only. The History pane cannot render a commit timeline or per-commit detail without one. **Scope hand-off: #1573 (API foundation), consumed by #1576 (history).** +- **Remotes list** — `repositoryUrlAllowed`/clone handle a clone URL (`gitRepositoryRoutes.ts:88`), but there is no `git remote -v` read endpoint to enumerate the configured remotes/fetch+push URLs of an already-open repo. The repository and sync surfaces need this. **Scope hand-off: #1573, consumed by #1576 (sync).** +- **Ahead/behind sync state** — `parseBranch` deliberately strips the upstream tracking segment (`gitRoutes.ts:287`) and `parseBranches` returns local `refs/heads` only with no upstream (`:410`). There is no ahead/behind/upstream data for a fetch/pull/push sync UI. **Scope hand-off: #1573, consumed by #1576 (sync banner).** + +Everything else is fully reused (no new capability): the Changes list, per-file/scope diff, branch list, and clone reads (Section 2); the entire governed mutation/publish/PR/merge/evidence write surface (`gitDelivery/*` and the `keiko-tools` gateways, consumed unchanged by #1575/#1577); the window registry, rail, and design tokens (#1574). No new BFF mutation routes are introduced by any child issue; the write contract is frozen. + +--- + +## 4. Selective-Code Policy + +- **No wholesale fork** of `github.com/desktop/desktop`. It is used in Epic #1571 and this audit strictly as a UX/interaction reference. This audit copies no code. +- **MIT attribution for any later copy.** If a child issue copies any code, it must carry MIT attribution citing the upstream license at `https://github.com/desktop/desktop/blob/development/LICENSE`, retaining the MIT copyright and permission notice. +- **No GitHub trademarks/branding.** Copied or reimplemented code must exclude all GitHub trademarks, branding, product names, logos, and the Octocat/Invertocat assets. +- **Reimplement, do not lift.** Any copied logic is selective and reimplemented into the Keiko design system and Keiko's safety model (bounded local execution, explicit preview for writes, credential-bound remote ops, audit evidence, shared human+agent operation layer) — never lifted as-is with GitHub branding. + +--- + +## 5. Frozen Target Layout Spec + +This is THE layout child issues build to. It is a single coherent Git window viewport hosting one bound repository, styled entirely with the existing `globals.css` token engine and the composition-embedded primitives from Section 2 — no new token source, CSS Modules, Tailwind, or component library (`token-component-reuse-map.md:36`). + +**Regions and their reusable bindings:** + +- **Header bar** — repository selector (left) + branch selector + sync control (right). Selectors use `KeikoSelect` (`KeikoSelect.tsx:34`, sectioned/filterable listbox). The repository selector resolves through `resolveBoundRoot` (`widgets/index.tsx:61`). The sync control is a state-driven button (Fetch/Pull/Push + ahead/behind), rendered with `PrimaryButton`/`StatusPill` (`GovernedGitFlowCard.tsx:309`/`:351`) once the ahead/behind read (Section 3) lands. +- **Left sidebar** — a Changes/History toggle implemented with the `ed-tablist` `role=tablist` tab strip (`EditorRuntimeWidget.tsx:1963`). Below it, the file/commit list pane: Changes uses the `fetchGitStatus` model + `gitChangeLabel` status glyphs (`FilesWidget.tsx:165`) rendered with the `.rv-filelist`/`.rv-filerow` row + `+N/−M` stat-chip pattern (`ReviewWidget.tsx:461`, `globals.css:8777`/`:8604`); History reuses the WAI-ARIA tree/roving-tabindex selection pattern (`ProjectPanel.tsx:30`) for the commit list (new, per Section 3). +- **Center/right diff pane** — the shared parsed diff renderer generalized from `ReviewWidget` (`DiffLineView`/`DiffHunkView`/`DiffFileSection`, `:131`/`:164`/`:185`) fed by `fetchGitDiff({root,path,scope})` (`api.ts:1040`), using the `--ed-diff-*` and `.rv-line`/`.rv-add`/`.rv-del` tokens (`globals.css:1177`/`:8624`). Optional Monaco side-by-side mode via `EditorDiffSurface`/`KeikoDiffEditor` (`EditorDiffSurface.tsx:23`) behind a git-original adapter. +- **Commit composer footer** — docked at the bottom of the Changes pane: summary + description fields (`FieldLabel`/`FIELD_STYLE`, `GovernedGitFlowCard.tsx:428`) + "Commit to BRANCH" `PrimaryButton`. Commit calls `/commit/preview` then `/commit/execute` (`commitRoutes.ts:306`). +- **Sync + PR + merge entry points** — Sync in the header; PR action/list from the branch dropdown + post-push banner (calls `/pr/preview`→`/pr/execute`, `prRoutes.ts:318`); Merge from the branch menu (calls `/merge/preview`→`/merge/execute`, `mergeRoutes.ts:268`). Approval/blocker/recovery surfaces reuse the action-sheet semantics (`GitDeliveryActionSheetCard.tsx:409`) and the `POST /api/git-delivery/action-sheet` model (`actionSheetRoutes.ts:407`). +- **Resizable split** — sidebar/diff column split reuses the `WindowFrame` 8-direction resize engine (`WindowFrame.tsx:294`) / `EditorWidget` multi-pane model (`EditorWidget.tsx:133`); no SplitPane dependency is added. + +**ASCII wireframe (frozen):** + +``` +┌───────────────────────────────────────────────────────────────────────────┐ +│ Git ◯ ◯ ◯ │ ← WindowFrame chrome, title "Git" (WindowsRegistry.ts:604) +├───────────────────────────────────────────────────────────────────────────┤ +│ [ ▾ Repository: my-repo ] [ ▾ Branch: feature/x ] [ Fetch ↑2 ↓1 ] │ ← KeikoSelect ×2 + state-driven sync button +├──────────────────────────────┬────────────────────────────────────────────┤ +│ ┌──────────┬───────────────┐ │ src/app/foo.ts │ +│ │ Changes │ History │ │ ──────────────────────────────────────────│ +│ ├──────────┴───────────────┤ │ @@ -10,7 +10,9 @@ │ +│ │ ● src/app/foo.ts +12 -3│ │ 10 10 const x = 1; │ +│ │ ● src/lib/bar.ts +4 -0│ │ 11 - const old = ... │ +│ │ ? new/file.md │ │ 11 + const neu = ... │ +│ │ │ │ 12 + const more = ... │ +│ │ (rv-filelist rows + │ │ │ +│ │ status glyphs + │ │ (shared parsed diff renderer; unified or │ +│ │ +N/−M stat chips) │ │ optional Monaco side-by-side) │ +│ ├──────────────────────────┤ │ │ +│ │ Summary: [_____________] │ │ │ +│ │ Description: │ │ │ +│ │ [______________________] │ │ │ +│ │ [ Commit to feature/x ] │ │ │ +│ └──────────────────────────┘ │ │ +├──────────────────────────────┴────────────────────────────────────────────┤ +│ [ Create Pull Request ] [ Merge… ] (action-sheet on execute) │ +└───────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 6. Removal of Current Form Layout + +The implementation plan **removes the current GovernedGitFlowCard form/workflow layout from the visible product surface.** The exact component is `packages/keiko-ui/src/app/components/desktop/widgets/cards/GovernedGitFlowCard.tsx`. Specifically removed from the rendered surface: + +- The stacked vertical-form panels: `BranchSection` (`:553`), `StagingSection` pathspec textarea (`:687`), `CommitComposer` textarea form (`:842`), `PublishSection` inputs (`:942`). +- The `RepositoryManager` clone/register forms and accordion (`:1044`). +- The `WorktreeWorkflow` "Delivery path" `FlowOverview` sidebar (`:1510`). +- The ~40 inline `CSSProperties` style constants backing the form look (`:123`). + +The `GitDeliveryActionSheetCard` `gdas-*` dialog layout (`GitDeliveryActionSheetCard.tsx:409`) is superseded by the in-window approval surface; its typed-code label maps and DI/fetch container are retained. + +This is a layout replacement, not a behavior teardown — the reusable internals listed in Section 2 (DI seam, state hook, label maps, branch-reload) are carried forward. + +**Window registry retargeting:** the window keeps the same `governedGit` key. The renderer registration at `packages/keiko-ui/src/app/components/desktop/widgets/index.tsx:461` (`registerWindowRender('governedGit', …)`) is re-pointed from `` to the new Git client component, keeping `projectId = resolveBoundRoot(…)` injection (`index.tsx:465`), the singleton/tool flags and `{projectPath}` config (`WindowsRegistry.ts:603`), the descriptor-meta row (`descriptor-meta.ts:248`), and the cross-surface entry points (`index.tsx:343` Files→Git, `:452` Runtime→Git). The rail button (`LeftRail.tsx:125`), Footer chip (`Footer.tsx:147`), and WindowFrame title (`WindowFrame.tsx:779`) then work unchanged. + +--- + +## 7. Visible Naming Policy + +Visible product text uses **"Git"** — never "Governed Git", "Governance Git", or "Delivery path" — for the window title, labels, and tabs. Internal symbol/file/type names (`GovernedGitFlowClient`, `useGovernedGitActions`, `onOpenGovernedGit`, the `governedGit`/`governedPullRequest`/`governedMerge` registry keys and file names) may remain. + +Window chrome is already compliant: `WindowsRegistry.ts:604` (title `"Git"`), `:619` (`"Pull Request"`), `:635` (`"Merge"`); RuntimeHub tile label `"Git"` (`RuntimeHubWidget.tsx:76`). The remaining visible strings to rename to plain Git wording: + +| Current visible string | Citation | Rename to | +| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| "Delivery path" (`

` in FlowOverview sidebar) | `packages/keiko-ui/src/app/components/desktop/widgets/cards/GovernedGitFlowCard.tsx:1510` | plain Git wording (e.g. "Steps"/"Workflow"); the region is removed entirely in the new layout (Section 6). | +| "Workflow" eyebrow above it | `packages/keiko-ui/src/app/components/desktop/widgets/cards/GovernedGitFlowCard.tsx:1509` | removed with the sidebar. | +| "Repository Manager" (`

` top heading) | `packages/keiko-ui/src/app/components/desktop/widgets/cards/GovernedGitFlowCard.tsx:1652` | "Git" / "Repositories" (clean, no governance framing). | +| "Git delivery" (action-sheet header) | `packages/keiko-ui/src/app/components/desktop/widgets/cards/GitDeliveryActionSheetCard.tsx:457` | plain "Git". | +| "Git delivery action {id} is {state}" (SR live-region) | `packages/keiko-ui/src/app/components/desktop/widgets/cards/GitDeliveryActionSheetCard.tsx:468` | plain "Git" phrasing. | +| "No Git delivery action to review." (empty state) | `packages/keiko-ui/src/app/components/desktop/widgets/cards/GitDeliveryActionSheetCard.tsx:575` | plain "Git" phrasing. | +| "execute through the Git workflow" (Commit panel description) | `packages/keiko-ui/src/app/components/desktop/widgets/cards/GovernedGitFlowCard.tsx:854` | reword to drop "workflow" framing (borderline; the form it describes is removed). | + +--- + +## 8. Scope and Out-of-Scope + +This issue (#1572) is an audit/contract. **It changes no production UI, no BFF routes, no Git mutation behavior, and copies no GitHub Desktop code.** Its only deliverable is this note. Out of scope: the production UI rewrite, new BFF routes, Git mutation changes, and any copied GitHub Desktop code — those land in child issues #1573–#1578 against this frozen contract. + +--- + +## 9. Acceptance Criteria Mapping + +| Issue #1572 item | Satisfied by section | +| ------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| **Deliverable 1** — GitHub Desktop flows to adapt (behavior → adaptation → entry point) | §1 GitHub Desktop Flows to Adapt | +| **Deliverable 2** — Keiko reuse map (file:line + classification) | §2 Keiko Reuse Map | +| **Deliverable 3** — Selective-code policy (MIT attribution; no trademarks) | §4 Selective-Code Policy | +| **Acceptance Criterion 1** — every reuse point classified with rationale | §2 Keiko Reuse Map | +| **Acceptance Criterion 2** — frozen GitHub-Desktop-inspired target layout spec mapped to DS tokens/components | §5 Frozen Target Layout Spec | +| **Acceptance Criterion 3** — plan removes the current form layout, citing the component + registry retarget | §6 Removal of Current Form Layout | +| **Acceptance Criterion 4** — visible naming is "Git"; exact strings to rename listed with file:line | §7 Visible Naming Policy | +| Capability-gap hand-off to #1573–#1577 | §3 Capability Gaps | +| Scope / out-of-scope restatement | §8 Scope and Out-of-Scope | diff --git a/docs/git-delivery/git-client-repository-api.md b/docs/git-delivery/git-client-repository-api.md new file mode 100644 index 000000000..0c6b6a56a --- /dev/null +++ b/docs/git-delivery/git-client-repository-api.md @@ -0,0 +1,245 @@ +# Git Client Repository State, History, Remotes, and Sync API + +This document is the endpoint reference for the read and fetch/pull-sync API foundation introduced in +Issue #1573 (Epic #1572) and defined by +[ADR-0098](../adr/ADR-0098-git-client-repository-state-and-sync-api.md). It is written for engineers +building the Git client window (#1574–#1578) against these routes and for reviewers verifying that the +reads stay bounded and content-free and that fetch/pull stay audited without entering the governed +mutation taxonomy. + +The three gaps these reads close — commit history, remotes enumeration, and ahead/behind sync state — +were isolated in `git-client-desktop-reuse-contract.md` §3. Everything else the Git window needs is +reused unchanged: the Changes list, per-file/scope diff, and branch list are the existing +`/api/git/status` / `/api/git/diff` / `/api/git/branches` reads; the governed mutation/publish/PR/ +merge/evidence write surface is the existing `gitDelivery/*` routes and the `keiko-tools` gateways. +This slice adds no mutation route and changes no existing route or contract. + +## 1. Common model + +### Repository resolution and availability + +The three GET reads resolve the target repository through `resolveRepository` in `gitRoutes.ts`: the +`root` query parameter is contained within the selected project root, then `git rev-parse +--show-toplevel` confirms a repository and surfaces an unsafe-owner or missing classification. When the +repository cannot be resolved, the handler returns HTTP 200 with a content-free `available: false` +envelope (zeroed counts, empty arrays) carrying a typed `reason`: + +| `reason` | Meaning | +| ------------------- | ------------------------------------------------------------- | +| `not-a-repository` | The resolved path is not a Git repository. | +| `git-missing` | The `git` executable is unavailable (runner exit code 127). | +| `unsafe-repository` | Git refused the repository owner (`dubious ownership`). | +| `git-error` | A non-zero Git status read that none of the above classifies. | + +`state` is `available` when `available` is true, `unsafe` for `unsafe-repository`, and `unavailable` +otherwise. The reason union is reused from `git-repository.ts` (`GitUnavailableReason` plus the +`unsafe-repository` / `git-error` literals); these reads do not introduce a new reason taxonomy. + +### Bounded, hardened Git execution + +Every Git invocation runs server-side through `defaultGitProcessRunner` (or an injected +`gitRouteOptions.runner` in tests) with fixed argv prefixed by `--no-pager --no-optional-locks -C +`, a hardened environment (`GIT_TERMINAL_PROMPT=0`, `GIT_PAGER=cat`, +`GIT_CONFIG_NOSYSTEM=1`, `GIT_CONFIG_GLOBAL=/dev/null`, no system/global config, no shell), a byte cap, +and a timeout. Exceeding the byte cap or the timeout sets `truncated: true`; a spawn failure maps to +exit code 127. There is no client-supplied argv anywhere in this surface. + +### Content-free responses + +Response bodies carry counts, typed codes, branch and remote **names**, ISO 8601 dates, and content- +free hashes only — never raw command output, diff text, secrets, or credentials. Every response body +passes through `deps.redactor`, which redacts URLs inside remote entries at the boundary. + +## 2. Read routes (`gitRoutes.ts` siblings, registered in `routes.ts`) + +### `GET /api/git/summary` + +Repository state for the sync banner and header. + +- **Query**: `root` (project-contained repository path). +- **Underlying reads**: `status --porcelain=v2 --branch -z --untracked-files=all`, then `remote -v`, + then a best-effort `rev-parse --git-path FETCH_HEAD` `stat` for the last-fetch time. +- **Response** (`GitRepositorySummary`, `schemaVersion: "1"`): + +| Field | Type | Notes | +| ------------------------------------------------------------------- | ---------------------- | ---------------------------------------------------------------- | +| `root`, `repositoryRoot` | string | Selected root and resolved repository toplevel. | +| `state`, `available`, `reason`, `message` | — | Availability envelope (see §1). | +| `branch`, `detached` | string? / boolean | `branch` absent and `detached` true for a detached HEAD. | +| `upstream` | `GitUpstreamSummary`? | `{ ref, remote?, branch? }`, e.g. `origin/main`. | +| `ahead`, `behind` | non-negative int | Parsed from `# branch.ab +A -B`; 0 when no upstream. | +| `stagedCount`, `unstagedCount`, `untrackedCount`, `conflictedCount` | non-negative int | Parsed from porcelain-v2 change records. | +| `clean` | boolean | True when there are no change records. | +| `remotes` | `GitRemoteSummary[]` | `{ name, fetchUrl?, pushUrl? }`, deduplicated by name. | +| `lastSync` | `GitLastSyncMetadata`? | `{ lastFetchAtMs }` when `FETCH_HEAD` exists; omitted otherwise. | +| `truncated` | boolean | True when the bounded status read was truncated. | + +- **Validator**: `validateGitRepositorySummary`. + +### `GET /api/git/history` + +Paginated commit timeline for the History pane. + +- **Query**: `root`; `limit` (integer, default 50, clamped to 1..200); `skip` (integer, default 0, + clamped to 0..100000). A non-integer `limit`/`skip` is rejected with HTTP 400 (`BAD_REQUEST`). +- **Underlying read**: `log --no-color --max-count= --skip= +--pretty=format: --shortstat`. An empty repository (no commits) + is detected from stderr and returned as `available: true` with `entries: []`, not an error. +- **Response** (`GitHistoryResponse`, `schemaVersion: "1"`): the availability envelope plus + `entries`, `limit`, `skip`, `truncated`. Each `GitHistoryEntry`: + +| Field | Type | Notes | +| ------------------ | ---------------- | --------------------------------------------------------------- | +| `sha`, `shortSha` | string | Full and abbreviated commit hashes. | +| `subject` | string | Commit subject (`%s`). | +| `author` | string | Author name (`%an`). | +| `date` | string | Strict ISO 8601 author date (`%aI`). | +| `refs` | string[] | Decoration names (`%D`), e.g. `["HEAD -> main","origin/main"]`. | +| `parentCount` | non-negative int | Parent count from `%P`; a merge commit reports 2. | +| `changedFileCount` | non-negative int | From the `--shortstat` line; 0 for merge/empty commits. | + +- **Truncation**: `truncated` is true when the bounded read was truncated **or** when + `entries.length === limit` (more commits may exist past the page). +- **Validator**: `validateGitHistoryResponse`. + +### `GET /api/git/remotes` + +Configured remotes for the repository/sync surface. + +- **Query**: `root`. +- **Underlying read**: `remote -v`, parsed into `GitRemoteSummary[]` (fetch and push URLs + deduplicated by remote name). +- **Response** (`GitRemotesResponse`, `schemaVersion: "1"`): the availability envelope plus + `remotes` and `truncated`. +- **Validator**: `validateGitRemotesResponse`. + +## 3. Sync routes (`gitDelivery/syncRoutes.ts`, registered as `GIT_DELIVERY_SYNC_ROUTE_GROUP`) + +Four POST routes mirror the push route structure: a bounded body read, an allowed-key whitelist +(`schemaVersion`, `projectId`, `remote`), credential-shape and unsafe-format-char scans, an +`isSafeGitRef` guard on the optional remote alias, content-free typed error envelopes, and an +injectable `execution` seam. CSRF and JSON content-type are enforced centrally in `server.ts` for every +POST and are not re-checked here. + +### Request body (all four routes) + +`GitSyncExecuteRequest`-shaped: + +| Field | Type | Required | Notes | +| --------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------- | +| `schemaVersion` | `"1"` | yes | Must equal `GIT_SYNC_SCHEMA_VERSION`. | +| `projectId` | string | yes | The workspace root path; authorized through the project store. | +| `remote` | string | no | Optional remote alias; syntactically validated by `isSafeGitRef` and accepted only when present in `git remote`. | + +### Error envelope + +`{ error: { code, message } }` with `GitDeliverySyncErrorCode`: + +| HTTP | Code | Cause | +| ---- | ---------------------------------------- | --------------------------------------------------------------------------------------------------- | +| 400 | `GIT_DELIVERY_SYNC_BAD_REQUEST` | Malformed JSON, extra key, missing/invalid field, unsafe remote ref, or an unsafe format character. | +| 413 | `GIT_DELIVERY_SYNC_PAYLOAD_TOO_LARGE` | Body exceeds the bounded read size. | +| 400 | `GIT_DELIVERY_SYNC_FORBIDDEN_PAYLOAD` | A credential/header/URL-shaped string was detected. | +| 404 | `GIT_DELIVERY_SYNC_UNKNOWN_PROJECT` | `projectId` is not a registered workspace. | +| 409 | `GIT_DELIVERY_SYNC_WORKTREE_UNAVAILABLE` | Preview could not inspect the worktree (not a repository). | + +### `POST /api/git-delivery/fetch/preview` and `POST /api/git-delivery/pull/preview` + +Read-only readiness. Resolves the workspace (404 when unknown), runs +`status --porcelain=v2 --branch -z --untracked-files=all` plus `git remote` (names only, never URLs), +and returns a redacted `GitSyncPreview`. Never mutates, never records evidence; a worktree that cannot +be inspected yields a 409. + +`GitSyncPreview` (`schemaVersion: "1"`): + +| Field | Type | Notes | +| ----------------------------------- | --------------------- | ------------------------------------------------------ | +| `operation` | `"fetch"` \| `"pull"` | The previewed operation. | +| `available`, `state`, `reason` | — | Availability envelope. | +| `branch`, `detached`, `upstream` | — | Current branch / detached HEAD / `GitUpstreamSummary`. | +| `remote` | string? | Echoed remote alias when supplied. | +| `ahead`, `behind` | non-negative int | Ahead/behind vs the upstream. | +| `hasRemote`, `hasUpstream`, `dirty` | boolean | Readiness inputs. | +| `executable` | boolean | True when the op can run now (`blockReason` absent). | +| `blockReason` | `GitSyncBlockReason`? | See below. | + +`executable` gating: a **fetch** needs `hasRemote`; a **pull** needs `hasRemote`, an upstream, and a +non-detached HEAD. `GitSyncBlockReason` is one of `no-remote`, `no-upstream`, `detached-head`, +`git-missing`, `unsafe-repository`, `unavailable`. Validator: `validateGitSyncPreview`. + +### `POST /api/git-delivery/fetch/execute` and `POST /api/git-delivery/pull/execute` + +Runs ONE bounded fetch or pull through the preflight-gated credential-capable runner (NOT the governed +mutation kernel — see §4) only after a successful executable preview. Blocked inspectable previews +return the matching typed outcome and record content-free evidence without invoking network Git. +Settled network operations re-read branch/upstream/ahead/behind, build a redacted +`GitSyncExecuteResponse`, and append a content-free sync evidence record. + +- **fetch argv**: `fetch --no-tags [remote]`. +- **pull argv**: `pull --ff-only --no-edit [remote]` (fast-forward only; never creates a merge commit). + +`GitSyncExecuteResponse` (`schemaVersion: "1"`): `operation`, `status` (a `GitSyncOutcome`), +`available`, `branch?`, `upstream?`, `remote?`, `ahead?`/`behind?` (post-op counts when known), and +`truncated`. Validator: `validateGitSyncExecuteResponse`. + +### `GitSyncOutcome` taxonomy + +The evidence-friendly outcome union (`GIT_SYNC_OUTCOMES`, 13 members): + +| Outcome | Operation | Meaning | +| ------------------- | --------- | ------------------------------------------------------------- | +| `succeeded` | both | Fetch completed / pull fast-forwarded. | +| `up-to-date` | pull | Already up to date (stdout match). | +| `no-remote` | both | No such remote / not a Git repository on the remote leg. | +| `no-upstream` | pull | No tracking information for the current branch. | +| `detached-head` | pull | Detached HEAD (surfaced as a preview block; pull cannot run). | +| `dirty-worktree` | pull | Local changes would be overwritten. | +| `not-fast-forward` | pull | `--ff-only` refused a non-fast-forward. | +| `auth-failed` | both | Credentials/permission/terminal-prompt-disabled failure. | +| `untrusted-host-key` | both | SSH refused an unknown or changed host key. | +| `timeout` | both | The bounded process was truncated (timeout or byte cap). | +| `git-missing` | both | The `git` executable was unavailable (exit code 127). | +| `unsafe-repository` | both | Dubious ownership / `safe.directory` refusal. | +| `git-error` | both | A non-zero result none of the above classifies. | + +Outcome classification scans stderr case-insensitively in a fixed precedence: truncation → exit code +127 → ownership → host-key trust → auth → remote/repository → (pull only) +tracking/fast-forward/local-changes → exit-code-0 success/up-to-date → `git-error`. Ownership, +host-key trust, and auth precede the generic remote checks so a credential or SSH-trust failure is +never mislabeled. + +## 4. Reuse and safety boundaries + +- **Reads reuse the existing seams.** `gitRepositoryReads.ts` reuses `resolveRepository`, + `optionsWithDefaults`, `classifyFailure`, `defaultGitProcessRunner`, and `deps.redactor` from + `gitRoutes.ts` (those five symbols were made `export` behavior-preservingly), plus the shared + `parsePorcelainV2Branch` from `gitPorcelainStatus.ts` consumed identically by the sync preview. +- **Fetch/pull do NOT enter the governed mutation taxonomy.** `GitDeliveryActionKind` carries no + fetch/pull, and the sync executor (`syncExecution.ts`) does not import `runGitMutation`, the policy + packs, or the approval-token gate. It reuses only the hardened runner with fixed argv. The rationale + (a fetch writes only remote-tracking refs; a fast-forward-only pull advances by replay) and the + reuse-vs-new decision are recorded in + [ADR-0098](../adr/ADR-0098-git-client-repository-state-and-sync-api.md) D4. +- **Sync evidence is a sibling ledger.** `syncEvidence.ts` mirrors `mutationEvidenceLedger.ts`: one + UTC date-bucketed document (run id `git-sync-evidence-YYYY-MM-DD`), `EvidenceStore.update ?? get+put`, + `deepRedactStrings`, a bounded bucket (default 500), fail-closed on corruption, and best-effort + (never throws into the caller). The record is content-free: operation, typed outcome, + `repoIdHash = sha256Hex(workspace.root).slice(0, 24)`, branch/remote names, ahead/behind before and + after, and an epoch-ms timestamp. +- **Project authorization.** Sync routes resolve `projectId` (the workspace root path) through + `resolveProjectWorkspace`; an unregistered path is rejected with 404, so a fetch or pull runs only + inside a known project's worktree. +- **No existing surface changed.** `/api/git/status|diff|branches`, `/api/projects`, + `/api/repositories/clone`, every `gitDelivery/*` route, and every existing contract are byte-for-byte + unchanged. All new exports are additive; no package version is bumped. + +## 5. Named limitations + +- **Push is not here.** Push remains the governed publish gateway (ADR-0085). This surface covers only + read, fetch, and pull; the Sync banner's push leg routes through the existing governed publish route. +- **Pull is fast-forward only.** A divergent branch reports `not-fast-forward`; resolving it is a + governed merge (ADR-0087), not a sync-executor concern. +- **`lastSync` is best-effort.** It is the `FETCH_HEAD` mtime when that file exists; it is omitted when + the repository has never fetched. It is not a guarantee of remote freshness. +- **History pagination is `git log` `--max-count`/`--skip`.** `truncated` flags that more commits may + exist beyond the page; there is no total-count read. diff --git a/package.json b/package.json index 2c9825fdb..0f3c646ea 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,10 @@ "test:e2e:language-intelligence-1383": "playwright test --config tests/e2e/config/playwright.issue-1383-language-intelligence.config.ts --project=chromium", "test:e2e:task-binding-446": "playwright test --config tests/e2e/config/playwright.issue-446-task-workspace-binding.config.ts --project=chromium", "test:e2e:git-delivery-475": "playwright test --config tests/e2e/config/playwright.issue-475-git-delivery.config.ts --project=chromium", + "test:e2e:git-changes-1575": "playwright test --config tests/e2e/config/playwright.issue-1575-git-changes.config.ts --project=chromium", + "test:e2e:git-branch-sync-1576": "playwright test --config tests/e2e/config/playwright.issue-1576-git-branch-sync.config.ts --project=chromium", + "test:e2e:git-pr-merge-1577": "playwright test --config tests/e2e/config/playwright.issue-1577-git-pr-merge.config.ts --project=chromium", + "test:e2e:git-client-closeout-1578": "playwright test --config tests/e2e/config/playwright.issue-1578-git-client-closeout.config.ts --project=chromium", "test:e2e:git-publish-476": "playwright test --config tests/e2e/config/playwright.issue-476-git-publish.config.ts --project=chromium", "test:e2e:pr-command-center-477": "playwright test --config tests/e2e/config/playwright.issue-477-pr-command-center.config.ts --project=chromium", "test:e2e:merge-governance-478": "playwright test --config tests/e2e/config/playwright.issue-478-merge-governance.config.ts --project=chromium", @@ -76,6 +80,7 @@ "check:retrieval-quality": "node scripts/check-retrieval-quality.mjs", "check:context-quality": "node scripts/check-context-quality.mjs", "check:git-delivery-evidence": "node scripts/check-git-delivery-evidence.mjs", + "check:git-client-evidence": "node scripts/check-git-client-evidence.mjs", "check:editor-doc-links": "node scripts/check-editor-doc-links.mjs", "check:package-graph": "node scripts/check-package-graph.mjs", "check:version-consistency": "node scripts/check-version-consistency.mjs", diff --git a/packages/keiko-contracts/src/git-history.test.ts b/packages/keiko-contracts/src/git-history.test.ts new file mode 100644 index 000000000..10d0719b9 --- /dev/null +++ b/packages/keiko-contracts/src/git-history.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { GIT_HISTORY_SCHEMA_VERSION, validateGitHistoryResponse } from "./git-history.js"; + +function validEntry(): Record { + return { + sha: "0123456789abcdef0123456789abcdef01234567", + shortSha: "0123456", + subject: "Initial commit", + author: "Ada Lovelace", + date: "2026-06-27T10:00:00+02:00", + refs: ["HEAD -> main", "origin/main"], + parentCount: 1, + changedFileCount: 3, + }; +} + +function validResponse(): Record { + return { + schemaVersion: GIT_HISTORY_SCHEMA_VERSION, + root: "/repo", + repositoryRoot: "/repo", + state: "available", + available: true, + entries: [validEntry()], + limit: 50, + skip: 0, + truncated: false, + }; +} + +describe("validateGitHistoryResponse", () => { + it("accepts a fully populated history response", () => { + expect(validateGitHistoryResponse(validResponse())).toEqual({ ok: true }); + }); + + it("accepts an empty-history response (no commits)", () => { + const input = validResponse(); + input.entries = []; + expect(validateGitHistoryResponse(input)).toEqual({ ok: true }); + }); + + it("accepts a merge entry with two parents and zero changed files", () => { + const input = validResponse(); + input.entries = [{ ...validEntry(), parentCount: 2, changedFileCount: 0 }]; + expect(validateGitHistoryResponse(input)).toEqual({ ok: true }); + }); + + it("rejects a non-object", () => { + const result = validateGitHistoryResponse(null); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("response must be an object"); + }); + + it("rejects an invalid schemaVersion", () => { + const result = validateGitHistoryResponse({ ...validResponse(), schemaVersion: "0" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("schemaVersion invalid"); + }); + + it("rejects an invalid state", () => { + const result = validateGitHistoryResponse({ ...validResponse(), state: "bogus" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("state invalid"); + }); + + it("rejects a non-boolean available", () => { + const result = validateGitHistoryResponse({ ...validResponse(), available: "yes" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("available must be a boolean"); + }); + + it("rejects a non-boolean truncated", () => { + const result = validateGitHistoryResponse({ ...validResponse(), truncated: 1 }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("truncated must be a boolean"); + }); + + it.each(["limit", "skip"] as const)("rejects a negative %s", (key) => { + const result = validateGitHistoryResponse({ ...validResponse(), [key]: -1 }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain(`${key} must be a non-negative integer`); + }); + + it("rejects a non-array entries", () => { + const result = validateGitHistoryResponse({ ...validResponse(), entries: "nope" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("entries must be an array"); + }); + + it("rejects a non-object entry", () => { + const result = validateGitHistoryResponse({ ...validResponse(), entries: ["x"] }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("entries[0] must be an object"); + }); + + it.each(["sha", "shortSha", "subject", "author", "date"] as const)( + "rejects a non-string entry.%s", + (key) => { + const input = validResponse(); + input.entries = [{ ...validEntry(), [key]: 5 }]; + const result = validateGitHistoryResponse(input); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain(`entries[0].${key} must be a string`); + }, + ); + + it.each(["parentCount", "changedFileCount"] as const)("rejects a negative entry.%s", (key) => { + const input = validResponse(); + input.entries = [{ ...validEntry(), [key]: -2 }]; + const result = validateGitHistoryResponse(input); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reasons).toContain(`entries[0].${key} must be a non-negative integer`); + } + }); + + it("rejects a non-array entry.refs", () => { + const input = validResponse(); + input.entries = [{ ...validEntry(), refs: "main" }]; + const result = validateGitHistoryResponse(input); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("entries[0].refs must be an array"); + }); + + it("rejects entry.refs containing a non-string", () => { + const input = validResponse(); + input.entries = [{ ...validEntry(), refs: ["main", 7] }]; + const result = validateGitHistoryResponse(input); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("entries[0].refs must contain only strings"); + }); +}); diff --git a/packages/keiko-contracts/src/git-history.ts b/packages/keiko-contracts/src/git-history.ts new file mode 100644 index 000000000..22af6cb97 --- /dev/null +++ b/packages/keiko-contracts/src/git-history.ts @@ -0,0 +1,103 @@ +// Read-only Git commit history wire contract (Issue #1573, Epic #1572). +// Pure types + validation helpers only: no filesystem, no process, no clock, no crypto. +// Reuses GitRepositoryState / GitUnavailableReason / GitRepositoryValidation from git-repository. + +import type { + GitRepositoryState, + GitUnavailableReason, + GitRepositoryValidation, +} from "./git-repository.js"; +import { GIT_REPOSITORY_STATES } from "./git-repository.js"; + +export const GIT_HISTORY_SCHEMA_VERSION = "1" as const; + +export interface GitHistoryEntry { + readonly sha: string; + readonly shortSha: string; + readonly subject: string; + readonly author: string; + readonly date: string; // strict ISO 8601 (author date, %aI) + readonly refs: readonly string[]; // decoration names, e.g. ["HEAD -> main","origin/main"] + readonly parentCount: number; + readonly changedFileCount: number; +} + +export interface GitHistoryResponse { + readonly schemaVersion: typeof GIT_HISTORY_SCHEMA_VERSION; + readonly root: string; + readonly repositoryRoot?: string | undefined; + readonly state: GitRepositoryState; + readonly available: boolean; + readonly reason?: GitUnavailableReason | "unsafe-repository" | "git-error" | undefined; + readonly message?: string | undefined; + readonly entries: readonly GitHistoryEntry[]; + readonly limit: number; + readonly skip: number; + readonly truncated: boolean; +} + +function isRecord(input: unknown): input is Readonly> { + return typeof input === "object" && input !== null && !Array.isArray(input); +} + +function isString(input: unknown): input is string { + return typeof input === "string"; +} + +function isBoolean(input: unknown): input is boolean { + return typeof input === "boolean"; +} + +function isNonNegativeInteger(input: unknown): input is number { + return typeof input === "number" && Number.isInteger(input) && input >= 0; +} + +function validateRefs(input: unknown, reasons: string[], index: number): void { + if (!Array.isArray(input)) { + reasons.push(`entries[${String(index)}].refs must be an array`); + return; + } + if (!input.every(isString)) { + reasons.push(`entries[${String(index)}].refs must contain only strings`); + } +} + +function validateEntry(input: unknown, reasons: string[], index: number): void { + if (!isRecord(input)) { + reasons.push(`entries[${String(index)}] must be an object`); + return; + } + for (const key of ["sha", "shortSha", "subject", "author", "date"] as const) { + if (!isString(input[key])) reasons.push(`entries[${String(index)}].${key} must be a string`); + } + for (const key of ["parentCount", "changedFileCount"] as const) { + if (!isNonNegativeInteger(input[key])) { + reasons.push(`entries[${String(index)}].${key} must be a non-negative integer`); + } + } + validateRefs(input.refs, reasons, index); +} + +// History is a paginated wire envelope (entries + limit/skip/truncated); centralizing the +// validator keeps per-field failure messages predictable for tests and callers. +// eslint-disable-next-line complexity +export function validateGitHistoryResponse(input: unknown): GitRepositoryValidation { + const reasons: string[] = []; + if (!isRecord(input)) return { ok: false, reasons: ["response must be an object"] }; + if (input.schemaVersion !== GIT_HISTORY_SCHEMA_VERSION) reasons.push("schemaVersion invalid"); + if (!isString(input.root)) reasons.push("root must be a string"); + if (!GIT_REPOSITORY_STATES.includes(input.state as GitRepositoryState)) { + reasons.push("state invalid"); + } + if (!isBoolean(input.available)) reasons.push("available must be a boolean"); + if (!isBoolean(input.truncated)) reasons.push("truncated must be a boolean"); + if (!isNonNegativeInteger(input.limit)) reasons.push("limit must be a non-negative integer"); + if (!isNonNegativeInteger(input.skip)) reasons.push("skip must be a non-negative integer"); + if (!Array.isArray(input.entries)) reasons.push("entries must be an array"); + else { + input.entries.forEach((entry, index) => { + validateEntry(entry, reasons, index); + }); + } + return reasons.length === 0 ? { ok: true } : { ok: false, reasons }; +} diff --git a/packages/keiko-contracts/src/git-repository-agent.test.ts b/packages/keiko-contracts/src/git-repository-agent.test.ts new file mode 100644 index 000000000..c04da1629 --- /dev/null +++ b/packages/keiko-contracts/src/git-repository-agent.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + isGitRepositoryAgentOperationResponse, + parseGitRepositoryAgentOperationRequest, +} from "./git-repository-agent.js"; + +describe("git repository agent operation contract", () => { + it("accepts a typed read operation", () => { + const parsed = parseGitRepositoryAgentOperationRequest({ + schemaVersion: "1", + operation: "status", + mode: "read", + projectId: "/repos/alpha", + }); + + expect(parsed).toMatchObject({ + ok: true, + value: { operation: "status", mode: "read", projectId: "/repos/alpha" }, + }); + }); + + it("requires idempotency for execute operations", () => { + expect( + parseGitRepositoryAgentOperationRequest({ + schemaVersion: "1", + operation: "branch-switch", + mode: "execute", + projectId: "/repos/alpha", + payload: { branchName: "main" }, + }), + ).toMatchObject({ + ok: false, + denialReason: "bad-request", + message: "execute operations require an idempotencyKey.", + }); + }); + + it("rejects unknown top-level fields", () => { + expect( + parseGitRepositoryAgentOperationRequest({ + schemaVersion: "1", + operation: "status", + mode: "read", + projectId: "/repos/alpha", + extra: true, + }), + ).toMatchObject({ ok: false, denialReason: "bad-request" }); + }); + + it("rejects direct shell and provider-shaped keys at any nesting level", () => { + for (const payload of [ + { command: "git status" }, + { nested: { argv: ["git", "status"] } }, + { endpoint: "/repos/oscharko-dev/Keiko/pulls" }, + { nested: [{ headers: { authorization: "Bearer token" } }] }, + { providerState: { mergeable: true } }, + ]) { + expect( + parseGitRepositoryAgentOperationRequest({ + schemaVersion: "1", + operation: "status", + mode: "read", + projectId: "/repos/alpha", + payload, + }), + ).toMatchObject({ ok: false, denialReason: "unsupported-direct-shell" }); + } + }); + + it("rejects invalid operation/mode pairings", () => { + expect( + parseGitRepositoryAgentOperationRequest({ + schemaVersion: "1", + operation: "status", + mode: "execute", + projectId: "/repos/alpha", + idempotencyKey: "agent-op-1", + }), + ).toMatchObject({ + ok: false, + denialReason: "bad-request", + message: "Operation mode is invalid for this repository operation.", + }); + }); + + it("recognizes delegated and denied facade responses", () => { + expect( + isGitRepositoryAgentOperationResponse({ + schemaVersion: "1", + operation: "pull-request", + mode: "preview", + status: "delegated", + routeStatus: 200, + response: { schemaVersion: "1" }, + }), + ).toBe(true); + expect( + isGitRepositoryAgentOperationResponse({ + schemaVersion: "1", + status: "denied", + denialReason: "unsupported-direct-shell", + message: "No shell commands.", + }), + ).toBe(true); + }); +}); diff --git a/packages/keiko-contracts/src/git-repository-agent.ts b/packages/keiko-contracts/src/git-repository-agent.ts new file mode 100644 index 000000000..63522fc00 --- /dev/null +++ b/packages/keiko-contracts/src/git-repository-agent.ts @@ -0,0 +1,300 @@ +// Agent-facing repository operation facade contract (Issue #1577, Epic #1571). +// Pure wire types and validators only. The facade grants no shell, process, provider, credential, or +// model authority; server handlers must delegate to existing Git read and Git delivery routes. + +export const GIT_REPOSITORY_AGENT_SCHEMA_VERSION = "1" as const; + +export type GitRepositoryAgentOperationMode = "read" | "preview" | "execute"; + +export const GIT_REPOSITORY_AGENT_OPERATION_MODES: readonly GitRepositoryAgentOperationMode[] = [ + "read", + "preview", + "execute", +] as const; + +export type GitRepositoryAgentOperationKind = + | "status" + | "diff" + | "branch-list" + | "branch-create" + | "branch-switch" + | "stage" + | "unstage" + | "commit" + | "fetch" + | "pull" + | "push" + | "pull-request" + | "merge"; + +export const GIT_REPOSITORY_AGENT_OPERATION_KINDS: readonly GitRepositoryAgentOperationKind[] = [ + "status", + "diff", + "branch-list", + "branch-create", + "branch-switch", + "stage", + "unstage", + "commit", + "fetch", + "pull", + "push", + "pull-request", + "merge", +] as const; + +export type GitRepositoryAgentDenialReason = + | "unsupported-direct-shell" + | "unsupported-operation" + | "idempotency-conflict" + | "bad-request"; + +export const GIT_REPOSITORY_AGENT_DENIAL_REASONS: readonly GitRepositoryAgentDenialReason[] = [ + "unsupported-direct-shell", + "unsupported-operation", + "idempotency-conflict", + "bad-request", +] as const; + +export interface GitRepositoryAgentOperationRequest { + readonly schemaVersion: typeof GIT_REPOSITORY_AGENT_SCHEMA_VERSION; + readonly operation: GitRepositoryAgentOperationKind; + readonly mode: GitRepositoryAgentOperationMode; + readonly projectId: string; + readonly idempotencyKey?: string | undefined; + readonly payload?: Readonly> | undefined; +} + +export interface GitRepositoryAgentOperationDelegatedResponse { + readonly schemaVersion: typeof GIT_REPOSITORY_AGENT_SCHEMA_VERSION; + readonly operation: GitRepositoryAgentOperationKind; + readonly mode: GitRepositoryAgentOperationMode; + readonly status: "delegated"; + readonly routeStatus: number; + readonly replay?: boolean | undefined; + readonly response: unknown; +} + +export interface GitRepositoryAgentOperationDeniedResponse { + readonly schemaVersion: typeof GIT_REPOSITORY_AGENT_SCHEMA_VERSION; + readonly operation?: GitRepositoryAgentOperationKind | undefined; + readonly mode?: GitRepositoryAgentOperationMode | undefined; + readonly status: "denied"; + readonly denialReason: GitRepositoryAgentDenialReason; + readonly message: string; +} + +export type GitRepositoryAgentOperationResponse = + | GitRepositoryAgentOperationDelegatedResponse + | GitRepositoryAgentOperationDeniedResponse; + +export interface GitRepositoryAgentParseOk { + readonly ok: true; + readonly value: GitRepositoryAgentOperationRequest; +} + +export interface GitRepositoryAgentParseFail { + readonly ok: false; + readonly denialReason: GitRepositoryAgentDenialReason; + readonly message: string; +} + +export type GitRepositoryAgentParseResult = + | GitRepositoryAgentParseOk + | GitRepositoryAgentParseFail; + +const TOP_LEVEL_KEYS: ReadonlySet = new Set([ + "schemaVersion", + "idempotencyKey", + "operation", + "mode", + "projectId", + "payload", +]); + +const DIRECT_SHELL_KEYS: ReadonlySet = new Set([ + "argv", + "args", + "body", + "command", + "credential", + "endpoint", + "cwd", + "env", + "executable", + "ghEndpoint", + "gitSubcommand", + "headers", + "method", + "providerPayload", + "providerState", + "repositoryRoot", + "root", + "script", + "shell", + "token", + "url", +]); + +const MODE_BY_OPERATION: Readonly< + Record +> = { + status: ["read"], + diff: ["read"], + "branch-list": ["read"], + "branch-create": ["execute"], + "branch-switch": ["execute"], + stage: ["execute"], + unstage: ["execute"], + commit: ["preview", "execute"], + fetch: ["preview", "execute"], + pull: ["preview", "execute"], + push: ["preview", "execute"], + "pull-request": ["preview", "execute"], + merge: ["preview", "execute"], +}; + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isOperation(value: unknown): value is GitRepositoryAgentOperationKind { + return ( + typeof value === "string" && + GIT_REPOSITORY_AGENT_OPERATION_KINDS.includes(value as GitRepositoryAgentOperationKind) + ); +} + +function isMode(value: unknown): value is GitRepositoryAgentOperationMode { + return ( + typeof value === "string" && + GIT_REPOSITORY_AGENT_OPERATION_MODES.includes(value as GitRepositoryAgentOperationMode) + ); +} + +function containsDirectShellShape(value: unknown): boolean { + if (Array.isArray(value)) return value.some(containsDirectShellShape); + if (!isRecord(value)) return false; + for (const [key, child] of Object.entries(value)) { + if (DIRECT_SHELL_KEYS.has(key) || key.toLowerCase().includes("credential")) return true; + if (containsDirectShellShape(child)) return true; + } + return false; +} + +function parseFail( + denialReason: GitRepositoryAgentDenialReason, + message: string, +): GitRepositoryAgentParseFail { + return { ok: false, denialReason, message }; +} + +function validateEnvelope( + input: unknown, +): { readonly ok: true; readonly value: Readonly> } | GitRepositoryAgentParseFail { + if (!isRecord(input)) return parseFail("bad-request", "Request body must be an object."); + if (containsDirectShellShape(input)) { + return parseFail( + "unsupported-direct-shell", + "Repository operations must use typed Git facade actions, not shell commands.", + ); + } + for (const key of Object.keys(input)) { + if (!TOP_LEVEL_KEYS.has(key)) return parseFail("bad-request", "Request contains an extra field."); + } + if (input.schemaVersion !== GIT_REPOSITORY_AGENT_SCHEMA_VERSION) { + return parseFail("bad-request", "schemaVersion is invalid."); + } + return { ok: true, value: input }; +} + +function parseOperation(value: unknown): GitRepositoryAgentOperationKind | GitRepositoryAgentParseFail { + if (isOperation(value)) return value; + return parseFail("unsupported-operation", "Operation is not supported by the repository facade."); +} + +function parseMode( + operation: GitRepositoryAgentOperationKind, + value: unknown, +): GitRepositoryAgentOperationMode | GitRepositoryAgentParseFail { + if (isMode(value) && MODE_BY_OPERATION[operation].includes(value)) return value; + return parseFail("bad-request", "Operation mode is invalid for this repository operation."); +} + +function parseProjectId(value: unknown): string | GitRepositoryAgentParseFail { + if (typeof value === "string" && value.length > 0) return value; + return parseFail("bad-request", "projectId must be a string."); +} + +function parseIdempotencyKey( + mode: GitRepositoryAgentOperationMode, + value: unknown, +): string | undefined | GitRepositoryAgentParseFail { + if (value !== undefined && (typeof value !== "string" || value.length === 0)) { + return parseFail("bad-request", "idempotencyKey must be a non-empty string."); + } + if (mode === "execute" && value === undefined) { + return parseFail("bad-request", "execute operations require an idempotencyKey."); + } + return value; +} + +function parsePayload( + value: unknown, +): Readonly> | undefined | GitRepositoryAgentParseFail { + if (value === undefined) return undefined; + if (isRecord(value)) return value; + return parseFail("bad-request", "payload must be an object."); +} + +function isParseFail(value: unknown): value is GitRepositoryAgentParseFail { + return isRecord(value) && value.ok === false; +} + +export function parseGitRepositoryAgentOperationRequest( + input: unknown, +): GitRepositoryAgentParseResult { + const envelope = validateEnvelope(input); + if (isParseFail(envelope)) return envelope; + const operation = parseOperation(envelope.value.operation); + if (isParseFail(operation)) return operation; + const mode = parseMode(operation, envelope.value.mode); + if (isParseFail(mode)) return mode; + const projectId = parseProjectId(envelope.value.projectId); + if (isParseFail(projectId)) return projectId; + const idempotencyKey = parseIdempotencyKey(mode, envelope.value.idempotencyKey); + if (isParseFail(idempotencyKey)) return idempotencyKey; + const payload = parsePayload(envelope.value.payload); + if (isParseFail(payload)) return payload; + return { + ok: true, + value: { + schemaVersion: GIT_REPOSITORY_AGENT_SCHEMA_VERSION, + operation, + mode, + projectId, + ...(idempotencyKey === undefined ? {} : { idempotencyKey }), + ...(payload === undefined ? {} : { payload }), + }, + }; +} + +export function isGitRepositoryAgentOperationResponse( + input: unknown, +): input is GitRepositoryAgentOperationResponse { + if (!isRecord(input)) return false; + if (input.schemaVersion !== GIT_REPOSITORY_AGENT_SCHEMA_VERSION) return false; + if (input.status === "delegated") { + return isOperation(input.operation) && isMode(input.mode) && typeof input.routeStatus === "number"; + } + if (input.status === "denied") { + return ( + typeof input.message === "string" && + typeof input.denialReason === "string" && + GIT_REPOSITORY_AGENT_DENIAL_REASONS.includes( + input.denialReason as GitRepositoryAgentDenialReason, + ) + ); + } + return false; +} diff --git a/packages/keiko-contracts/src/git-repository-summary.test.ts b/packages/keiko-contracts/src/git-repository-summary.test.ts new file mode 100644 index 000000000..61a974ec9 --- /dev/null +++ b/packages/keiko-contracts/src/git-repository-summary.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vitest"; +import { + GIT_REPOSITORY_SUMMARY_SCHEMA_VERSION, + validateGitRemotesResponse, + validateGitRepositorySummary, +} from "./git-repository-summary.js"; + +function validSummary(): Record { + return { + schemaVersion: GIT_REPOSITORY_SUMMARY_SCHEMA_VERSION, + root: "/repo", + repositoryRoot: "/repo", + state: "available", + available: true, + branch: "main", + detached: false, + upstream: { ref: "origin/main", remote: "origin", branch: "main" }, + ahead: 2, + behind: 1, + stagedCount: 1, + unstagedCount: 2, + untrackedCount: 0, + conflictedCount: 0, + clean: false, + remotes: [{ name: "origin" }], + lastSync: { lastFetchAtMs: 1700000000000 }, + truncated: false, + }; +} + +function validRemotes(): Record { + return { + schemaVersion: GIT_REPOSITORY_SUMMARY_SCHEMA_VERSION, + root: "/repo", + repositoryRoot: "/repo", + state: "available", + available: true, + remotes: [{ name: "origin", fetchUrl: "u", pushUrl: "u" }], + truncated: false, + }; +} + +describe("validateGitRepositorySummary", () => { + it("accepts a fully populated summary", () => { + expect(validateGitRepositorySummary(validSummary())).toEqual({ ok: true }); + }); + + it("accepts a summary with optional fields omitted (no upstream, no lastSync)", () => { + const input = validSummary(); + delete input.upstream; + delete input.lastSync; + delete input.repositoryRoot; + delete input.branch; + expect(validateGitRepositorySummary(input)).toEqual({ ok: true }); + }); + + it("rejects a non-object", () => { + const result = validateGitRepositorySummary("nope"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("response must be an object"); + }); + + it("rejects an invalid schemaVersion", () => { + const result = validateGitRepositorySummary({ ...validSummary(), schemaVersion: "2" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("schemaVersion invalid"); + }); + + it("rejects an invalid state", () => { + const result = validateGitRepositorySummary({ ...validSummary(), state: "bogus" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("state invalid"); + }); + + it.each(["available", "detached", "clean", "truncated"] as const)( + "rejects a non-boolean %s", + (key) => { + const result = validateGitRepositorySummary({ ...validSummary(), [key]: "yes" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain(`${key} must be a boolean`); + }, + ); + + it.each([ + "ahead", + "behind", + "stagedCount", + "unstagedCount", + "untrackedCount", + "conflictedCount", + ] as const)("rejects a negative %s", (key) => { + const result = validateGitRepositorySummary({ ...validSummary(), [key]: -1 }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain(`${key} must be a non-negative integer`); + }); + + it.each([ + "ahead", + "behind", + "stagedCount", + "unstagedCount", + "untrackedCount", + "conflictedCount", + ] as const)("rejects a non-integer %s", (key) => { + const result = validateGitRepositorySummary({ ...validSummary(), [key]: 1.5 }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain(`${key} must be a non-negative integer`); + }); + + it("rejects a non-string root", () => { + const result = validateGitRepositorySummary({ ...validSummary(), root: 42 }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("root must be a string"); + }); + + it("rejects a non-array remotes", () => { + const result = validateGitRepositorySummary({ ...validSummary(), remotes: {} }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("remotes must be an array"); + }); + + it("rejects a remote with a non-string name", () => { + const result = validateGitRepositorySummary({ ...validSummary(), remotes: [{ name: 5 }] }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("remotes[0].name must be a string"); + }); + + it("rejects remote URL fields in the compact summary", () => { + const result = validateGitRepositorySummary({ + ...validSummary(), + remotes: [{ name: "origin", fetchUrl: "https://example.invalid/repo.git" }], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reasons).toContain("remotes[0].fetchUrl is not allowed in summary"); + } + }); + + it("rejects remote push URL fields in the compact summary", () => { + const result = validateGitRepositorySummary({ + ...validSummary(), + remotes: [{ name: "origin", pushUrl: "https://example.invalid/repo.git" }], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reasons).toContain("remotes[0].pushUrl is not allowed in summary"); + } + }); + + it("rejects a non-object remote entry", () => { + const result = validateGitRepositorySummary({ ...validSummary(), remotes: ["origin"] }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("remotes[0] must be an object"); + }); +}); + +describe("validateGitRemotesResponse", () => { + it("accepts a valid remotes response", () => { + expect(validateGitRemotesResponse(validRemotes())).toEqual({ ok: true }); + }); + + it("rejects an invalid schemaVersion", () => { + const result = validateGitRemotesResponse({ ...validRemotes(), schemaVersion: "9" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("schemaVersion invalid"); + }); + + it("rejects an invalid state", () => { + const result = validateGitRemotesResponse({ ...validRemotes(), state: "bogus" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("state invalid"); + }); + + it("rejects a non-boolean available", () => { + const result = validateGitRemotesResponse({ ...validRemotes(), available: 1 }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("available must be a boolean"); + }); + + it("rejects a non-boolean truncated", () => { + const result = validateGitRemotesResponse({ ...validRemotes(), truncated: "no" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("truncated must be a boolean"); + }); + + it("rejects a malformed remote entry", () => { + const result = validateGitRemotesResponse({ ...validRemotes(), remotes: [{ name: 1 }] }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("remotes[0].name must be a string"); + }); + + it("rejects a remote with a non-string fetchUrl when present", () => { + const result = validateGitRemotesResponse({ + ...validRemotes(), + remotes: [{ name: "origin", fetchUrl: 1 }], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reasons).toContain("remotes[0].fetchUrl must be a string when present"); + } + }); + + it("rejects a remote with a non-string pushUrl when present", () => { + const result = validateGitRemotesResponse({ + ...validRemotes(), + remotes: [{ name: "origin", pushUrl: 1 }], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reasons).toContain("remotes[0].pushUrl must be a string when present"); + } + }); + + it("rejects a non-object remote entry", () => { + const result = validateGitRemotesResponse({ ...validRemotes(), remotes: ["origin"] }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("remotes[0] must be an object"); + }); +}); diff --git a/packages/keiko-contracts/src/git-repository-summary.ts b/packages/keiko-contracts/src/git-repository-summary.ts new file mode 100644 index 000000000..bf50e4eab --- /dev/null +++ b/packages/keiko-contracts/src/git-repository-summary.ts @@ -0,0 +1,174 @@ +// Read-only Git repository summary + remotes wire contract (Issue #1573, Epic #1572). +// Pure types + validation helpers only: no filesystem, no process, no clock, no crypto. +// Reuses GitRepositoryState / GitUnavailableReason / GitRepositoryValidation from git-repository. + +import type { + GitRepositoryState, + GitUnavailableReason, + GitRepositoryValidation, +} from "./git-repository.js"; +import { GIT_REPOSITORY_STATES } from "./git-repository.js"; + +export const GIT_REPOSITORY_SUMMARY_SCHEMA_VERSION = "1" as const; + +export interface GitRemoteSummary { + readonly name: string; + readonly fetchUrl?: string | undefined; + readonly pushUrl?: string | undefined; +} + +export interface GitRepositorySummaryRemote { + readonly name: string; +} + +export interface GitUpstreamSummary { + readonly ref: string; // e.g. "origin/main" + readonly remote?: string | undefined; + readonly branch?: string | undefined; +} + +export interface GitLastSyncMetadata { + readonly lastFetchAtMs?: number | undefined; // FETCH_HEAD mtime when available +} + +export interface GitRepositorySummary { + readonly schemaVersion: typeof GIT_REPOSITORY_SUMMARY_SCHEMA_VERSION; + readonly root: string; + readonly repositoryRoot?: string | undefined; + readonly state: GitRepositoryState; + readonly available: boolean; + readonly reason?: GitUnavailableReason | "unsafe-repository" | "git-error" | undefined; + readonly message?: string | undefined; + readonly branch?: string | undefined; + readonly detached: boolean; + readonly upstream?: GitUpstreamSummary | undefined; + readonly ahead: number; + readonly behind: number; + readonly stagedCount: number; + readonly unstagedCount: number; + readonly untrackedCount: number; + readonly conflictedCount: number; + readonly clean: boolean; + readonly remotes: readonly GitRepositorySummaryRemote[]; + readonly lastSync?: GitLastSyncMetadata | undefined; + readonly truncated: boolean; +} + +// Dedicated remotes response for GET /api/git/remotes (reuses GitRemoteSummary). +export interface GitRemotesResponse { + readonly schemaVersion: typeof GIT_REPOSITORY_SUMMARY_SCHEMA_VERSION; + readonly root: string; + readonly repositoryRoot?: string | undefined; + readonly state: GitRepositoryState; + readonly available: boolean; + readonly reason?: GitUnavailableReason | "unsafe-repository" | "git-error" | undefined; + readonly remotes: readonly GitRemoteSummary[]; + readonly truncated: boolean; +} + +export type GitRepositorySummaryValidation = GitRepositoryValidation; // reuse ok/fail shape + +function isRecord(input: unknown): input is Readonly> { + return typeof input === "object" && input !== null && !Array.isArray(input); +} + +function isString(input: unknown): input is string { + return typeof input === "string"; +} + +function isBoolean(input: unknown): input is boolean { + return typeof input === "boolean"; +} + +function isNonNegativeInteger(input: unknown): input is number { + return typeof input === "number" && Number.isInteger(input) && input >= 0; +} + +function validateRemote( + input: unknown, + reasons: string[], + index: number, + options: { readonly allowUrls: boolean }, +): void { + if (!isRecord(input)) { + reasons.push(`remotes[${String(index)}] must be an object`); + return; + } + if (!isString(input.name)) reasons.push(`remotes[${String(index)}].name must be a string`); + if (!options.allowUrls) { + if (input.fetchUrl !== undefined) { + reasons.push(`remotes[${String(index)}].fetchUrl is not allowed in summary`); + } + if (input.pushUrl !== undefined) { + reasons.push(`remotes[${String(index)}].pushUrl is not allowed in summary`); + } + return; + } + if (input.fetchUrl !== undefined && !isString(input.fetchUrl)) { + reasons.push(`remotes[${String(index)}].fetchUrl must be a string when present`); + } + if (input.pushUrl !== undefined && !isString(input.pushUrl)) { + reasons.push(`remotes[${String(index)}].pushUrl must be a string when present`); + } +} + +function validateRemotesArray( + input: unknown, + reasons: string[], + options: { readonly allowUrls: boolean }, +): void { + if (!Array.isArray(input)) { + reasons.push("remotes must be an array"); + return; + } + input.forEach((remote, index) => { + validateRemote(remote, reasons, index, options); + }); +} + +// The summary is a compact wire envelope with required counters, ahead/behind, and a remotes +// array; keeping the validator in one place makes failure messages predictable for callers. +// eslint-disable-next-line complexity +export function validateGitRepositorySummary(input: unknown): GitRepositoryValidation { + const reasons: string[] = []; + if (!isRecord(input)) return { ok: false, reasons: ["response must be an object"] }; + if (input.schemaVersion !== GIT_REPOSITORY_SUMMARY_SCHEMA_VERSION) { + reasons.push("schemaVersion invalid"); + } + if (!isString(input.root)) reasons.push("root must be a string"); + if (!GIT_REPOSITORY_STATES.includes(input.state as GitRepositoryState)) { + reasons.push("state invalid"); + } + if (!isBoolean(input.available)) reasons.push("available must be a boolean"); + if (!isBoolean(input.detached)) reasons.push("detached must be a boolean"); + if (!isBoolean(input.clean)) reasons.push("clean must be a boolean"); + if (!isBoolean(input.truncated)) reasons.push("truncated must be a boolean"); + for (const key of [ + "ahead", + "behind", + "stagedCount", + "unstagedCount", + "untrackedCount", + "conflictedCount", + ] as const) { + if (!isNonNegativeInteger(input[key])) reasons.push(`${key} must be a non-negative integer`); + } + validateRemotesArray(input.remotes, reasons, { allowUrls: false }); + return reasons.length === 0 ? { ok: true } : { ok: false, reasons }; +} + +export function validateGitRemotesResponse(input: unknown): GitRepositoryValidation { + const reasons: string[] = []; + if (!isRecord(input)) return { ok: false, reasons: ["response must be an object"] }; + if (input.schemaVersion !== GIT_REPOSITORY_SUMMARY_SCHEMA_VERSION) { + reasons.push("schemaVersion invalid"); + } + if (!isString(input.root)) reasons.push("root must be a string"); + if (!GIT_REPOSITORY_STATES.includes(input.state as GitRepositoryState)) { + reasons.push("state invalid"); + } + if (!isBoolean(input.available)) reasons.push("available must be a boolean"); + if (!isBoolean(input.truncated)) reasons.push("truncated must be a boolean"); + validateRemotesArray(input.remotes, reasons, { allowUrls: true }); + return reasons.length === 0 ? { ok: true } : { ok: false, reasons }; +} diff --git a/packages/keiko-contracts/src/git-sync.test.ts b/packages/keiko-contracts/src/git-sync.test.ts new file mode 100644 index 000000000..19c9dc57e --- /dev/null +++ b/packages/keiko-contracts/src/git-sync.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; +import { + GIT_SYNC_OUTCOMES, + GIT_SYNC_SCHEMA_VERSION, + isGitSyncOperation, + isGitSyncOutcome, + validateGitSyncExecuteResponse, + validateGitSyncPreview, +} from "./git-sync.js"; + +function validPreview(): Record { + return { + schemaVersion: GIT_SYNC_SCHEMA_VERSION, + operation: "pull", + available: true, + state: "available", + branch: "main", + detached: false, + upstream: { ref: "origin/main", remote: "origin", branch: "main" }, + remote: "origin", + ahead: 0, + behind: 3, + hasRemote: true, + hasUpstream: true, + dirty: false, + executable: true, + }; +} + +function validExecute(): Record { + return { + schemaVersion: GIT_SYNC_SCHEMA_VERSION, + operation: "fetch", + status: "succeeded", + available: true, + branch: "main", + upstream: { ref: "origin/main" }, + remote: "origin", + ahead: 0, + behind: 0, + truncated: false, + }; +} + +describe("isGitSyncOperation", () => { + it.each(["fetch", "pull"] as const)("accepts %s", (op) => { + expect(isGitSyncOperation(op)).toBe(true); + }); + + it.each(["push", "clone", "", 5, null, undefined])("rejects %s", (value) => { + expect(isGitSyncOperation(value)).toBe(false); + }); +}); + +describe("isGitSyncOutcome", () => { + it("accepts every member of the taxonomy", () => { + for (const outcome of GIT_SYNC_OUTCOMES) { + expect(isGitSyncOutcome(outcome)).toBe(true); + } + }); + + it("has exactly thirteen outcomes", () => { + expect(GIT_SYNC_OUTCOMES).toHaveLength(13); + }); + + it.each(["ok", "failed", "", 0, null, undefined])("rejects %s", (value) => { + expect(isGitSyncOutcome(value)).toBe(false); + }); +}); + +describe("validateGitSyncPreview", () => { + it("accepts a ready pull preview", () => { + expect(validateGitSyncPreview(validPreview())).toEqual({ ok: true }); + }); + + it("accepts a blocked preview with a block reason", () => { + const input = { ...validPreview(), executable: false, blockReason: "no-upstream" }; + expect(validateGitSyncPreview(input)).toEqual({ ok: true }); + }); + + it("rejects a non-object", () => { + const result = validateGitSyncPreview(7); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("response must be an object"); + }); + + it("rejects an invalid schemaVersion", () => { + const result = validateGitSyncPreview({ ...validPreview(), schemaVersion: "2" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("schemaVersion invalid"); + }); + + it("rejects an invalid operation", () => { + const result = validateGitSyncPreview({ ...validPreview(), operation: "push" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("operation invalid"); + }); + + it("rejects an invalid state", () => { + const result = validateGitSyncPreview({ ...validPreview(), state: "bogus" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("state invalid"); + }); + + it.each(["available", "detached", "hasRemote", "hasUpstream", "dirty", "executable"] as const)( + "rejects a non-boolean %s", + (key) => { + const result = validateGitSyncPreview({ ...validPreview(), [key]: "x" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain(`${key} must be a boolean`); + }, + ); + + it.each(["ahead", "behind"] as const)("rejects a negative %s", (key) => { + const result = validateGitSyncPreview({ ...validPreview(), [key]: -1 }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain(`${key} must be a non-negative integer`); + }); + + it("rejects an invalid blockReason", () => { + const result = validateGitSyncPreview({ ...validPreview(), blockReason: "bogus" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("blockReason invalid"); + }); +}); + +describe("validateGitSyncExecuteResponse", () => { + it("accepts a populated execute response", () => { + expect(validateGitSyncExecuteResponse(validExecute())).toEqual({ ok: true }); + }); + + it("accepts an execute response with optional fields omitted", () => { + const input = validExecute(); + delete input.branch; + delete input.upstream; + delete input.remote; + delete input.ahead; + delete input.behind; + expect(validateGitSyncExecuteResponse(input)).toEqual({ ok: true }); + }); + + it("rejects a non-object", () => { + const result = validateGitSyncExecuteResponse([]); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("response must be an object"); + }); + + it("rejects an invalid schemaVersion", () => { + const result = validateGitSyncExecuteResponse({ ...validExecute(), schemaVersion: "3" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("schemaVersion invalid"); + }); + + it("rejects an invalid operation", () => { + const result = validateGitSyncExecuteResponse({ ...validExecute(), operation: "rebase" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("operation invalid"); + }); + + it("rejects an invalid status", () => { + const result = validateGitSyncExecuteResponse({ ...validExecute(), status: "done" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("status invalid"); + }); + + it("rejects a non-boolean available", () => { + const result = validateGitSyncExecuteResponse({ ...validExecute(), available: 1 }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("available must be a boolean"); + }); + + it("rejects a non-boolean truncated", () => { + const result = validateGitSyncExecuteResponse({ ...validExecute(), truncated: "no" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain("truncated must be a boolean"); + }); + + it.each(["ahead", "behind"] as const)("rejects a negative %s when present", (key) => { + const result = validateGitSyncExecuteResponse({ ...validExecute(), [key]: -1 }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reasons).toContain(`${key} must be a non-negative integer when present`); + } + }); + + it.each(["branch", "remote"] as const)("rejects a non-string %s when present", (key) => { + const result = validateGitSyncExecuteResponse({ ...validExecute(), [key]: 5 }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reasons).toContain(`${key} must be a string when present`); + }); +}); diff --git a/packages/keiko-contracts/src/git-sync.ts b/packages/keiko-contracts/src/git-sync.ts new file mode 100644 index 000000000..12efaf817 --- /dev/null +++ b/packages/keiko-contracts/src/git-sync.ts @@ -0,0 +1,182 @@ +// Git fetch/pull sync preview + execute wire contract (Issue #1573, Epic #1572). +// Pure types + validation helpers only: no filesystem, no process, no clock, no crypto. +// Reuses GitRepositoryState / GitUnavailableReason / GitRepositoryValidation from git-repository +// and GitUpstreamSummary from git-repository-summary. + +import type { + GitRepositoryState, + GitUnavailableReason, + GitRepositoryValidation, +} from "./git-repository.js"; +import { GIT_REPOSITORY_STATES } from "./git-repository.js"; +import type { GitUpstreamSummary } from "./git-repository-summary.js"; + +export const GIT_SYNC_SCHEMA_VERSION = "1" as const; + +export type GitSyncOperation = "fetch" | "pull"; +export const GIT_SYNC_OPERATIONS: readonly GitSyncOperation[] = ["fetch", "pull"]; + +// Evidence-friendly outcome taxonomy: +export type GitSyncOutcome = + | "succeeded" // fetch ok / pull fast-forwarded + | "up-to-date" // pull: already up to date + | "no-remote" + | "no-upstream" + | "detached-head" + | "dirty-worktree" // pull blocked: local changes would be overwritten + | "not-fast-forward" // pull --ff-only refused + | "auth-failed" + | "untrusted-host-key" + | "timeout" + | "git-missing" + | "unsafe-repository" + | "git-error"; +export const GIT_SYNC_OUTCOMES: readonly GitSyncOutcome[] = [ + "succeeded", + "up-to-date", + "no-remote", + "no-upstream", + "detached-head", + "dirty-worktree", + "not-fast-forward", + "auth-failed", + "untrusted-host-key", + "timeout", + "git-missing", + "unsafe-repository", + "git-error", +]; + +// Preview blocked reasons (read-only readiness): +export type GitSyncBlockReason = + | "no-remote" + | "no-upstream" + | "detached-head" + | "git-missing" + | "unsafe-repository" + | "unavailable"; +export const GIT_SYNC_BLOCK_REASONS: readonly GitSyncBlockReason[] = [ + "no-remote", + "no-upstream", + "detached-head", + "git-missing", + "unsafe-repository", + "unavailable", +]; + +export interface GitSyncExecuteRequest { + readonly schemaVersion: typeof GIT_SYNC_SCHEMA_VERSION; + readonly projectId: string; + readonly remote?: string | undefined; // optional remote alias; validated by isSafeGitRef +} + +export interface GitSyncPreview { + readonly schemaVersion: typeof GIT_SYNC_SCHEMA_VERSION; + readonly operation: GitSyncOperation; + readonly available: boolean; + readonly state: GitRepositoryState; + readonly reason?: GitUnavailableReason | "unsafe-repository" | "git-error" | undefined; + readonly branch?: string | undefined; + readonly detached: boolean; + readonly upstream?: GitUpstreamSummary | undefined; + readonly remote?: string | undefined; + readonly ahead: number; + readonly behind: number; + readonly hasRemote: boolean; + readonly hasUpstream: boolean; + readonly dirty: boolean; + readonly executable: boolean; // true when the op can run now + readonly blockReason?: GitSyncBlockReason | undefined; +} + +export interface GitSyncExecuteResponse { + readonly schemaVersion: typeof GIT_SYNC_SCHEMA_VERSION; + readonly operation: GitSyncOperation; + readonly status: GitSyncOutcome; + readonly available: boolean; + readonly branch?: string | undefined; + readonly upstream?: GitUpstreamSummary | undefined; + readonly remote?: string | undefined; + readonly ahead?: number | undefined; // ahead/behind AFTER the op when known + readonly behind?: number | undefined; + readonly truncated: boolean; +} + +function isRecord(input: unknown): input is Readonly> { + return typeof input === "object" && input !== null && !Array.isArray(input); +} + +function isString(input: unknown): input is string { + return typeof input === "string"; +} + +function isBoolean(input: unknown): input is boolean { + return typeof input === "boolean"; +} + +function isNonNegativeInteger(input: unknown): input is number { + return typeof input === "number" && Number.isInteger(input) && input >= 0; +} + +export function isGitSyncOperation(v: unknown): v is GitSyncOperation { + return v === "fetch" || v === "pull"; +} + +export function isGitSyncOutcome(v: unknown): v is GitSyncOutcome { + return typeof v === "string" && GIT_SYNC_OUTCOMES.includes(v as GitSyncOutcome); +} + +// The preview is a read-only readiness envelope (branch/upstream/ahead/behind + executable gate); +// centralizing the validator keeps per-field failure messages predictable for callers. +// eslint-disable-next-line complexity +export function validateGitSyncPreview(input: unknown): GitRepositoryValidation { + const reasons: string[] = []; + if (!isRecord(input)) return { ok: false, reasons: ["response must be an object"] }; + if (input.schemaVersion !== GIT_SYNC_SCHEMA_VERSION) reasons.push("schemaVersion invalid"); + if (!isGitSyncOperation(input.operation)) reasons.push("operation invalid"); + if (!GIT_REPOSITORY_STATES.includes(input.state as GitRepositoryState)) { + reasons.push("state invalid"); + } + for (const key of [ + "available", + "detached", + "hasRemote", + "hasUpstream", + "dirty", + "executable", + ] as const) { + if (!isBoolean(input[key])) reasons.push(`${key} must be a boolean`); + } + for (const key of ["ahead", "behind"] as const) { + if (!isNonNegativeInteger(input[key])) reasons.push(`${key} must be a non-negative integer`); + } + if ( + input.blockReason !== undefined && + !GIT_SYNC_BLOCK_REASONS.includes(input.blockReason as GitSyncBlockReason) + ) { + reasons.push("blockReason invalid"); + } + return reasons.length === 0 ? { ok: true } : { ok: false, reasons }; +} + +// eslint-disable-next-line complexity +export function validateGitSyncExecuteResponse(input: unknown): GitRepositoryValidation { + const reasons: string[] = []; + if (!isRecord(input)) return { ok: false, reasons: ["response must be an object"] }; + if (input.schemaVersion !== GIT_SYNC_SCHEMA_VERSION) reasons.push("schemaVersion invalid"); + if (!isGitSyncOperation(input.operation)) reasons.push("operation invalid"); + if (!isGitSyncOutcome(input.status)) reasons.push("status invalid"); + if (!isBoolean(input.available)) reasons.push("available must be a boolean"); + if (!isBoolean(input.truncated)) reasons.push("truncated must be a boolean"); + for (const key of ["ahead", "behind"] as const) { + if (input[key] !== undefined && !isNonNegativeInteger(input[key])) { + reasons.push(`${key} must be a non-negative integer when present`); + } + } + for (const key of ["branch", "remote"] as const) { + if (input[key] !== undefined && !isString(input[key])) { + reasons.push(`${key} must be a string when present`); + } + } + return reasons.length === 0 ? { ok: true } : { ok: false, reasons }; +} diff --git a/packages/keiko-contracts/src/index.ts b/packages/keiko-contracts/src/index.ts index d6ef7623b..46151cc06 100644 --- a/packages/keiko-contracts/src/index.ts +++ b/packages/keiko-contracts/src/index.ts @@ -405,6 +405,78 @@ export { validateGitRepositoryDiffResponse, } from "./git-repository.js"; +// ─── Git repository summary + remotes BFF (Issue #1573, Epic #1572) ─────────────── +// Read-only repository summary (branch/upstream/ahead-behind/counts/remotes/last-sync) and a +// dedicated remotes response. The browser receives bounded, redacted metadata only; all Git +// process execution stays server-side. Reuses GitRepositoryState/GitUnavailableReason unions. +export type { + GitRemoteSummary, + GitRepositorySummaryRemote, + GitUpstreamSummary, + GitLastSyncMetadata, + GitRepositorySummary, + GitRemotesResponse, + GitRepositorySummaryValidation, +} from "./git-repository-summary.js"; +export { + GIT_REPOSITORY_SUMMARY_SCHEMA_VERSION, + validateGitRepositorySummary, + validateGitRemotesResponse, +} from "./git-repository-summary.js"; + +// ─── Git commit history BFF (Issue #1573, Epic #1572) ───────────────────────────── +// Read-only, paginated commit history (sha/subject/author/ISO date/refs/parent and changed-file +// counts). Bounded entries with limit/skip/truncated; all Git process execution stays server-side. +export type { GitHistoryEntry, GitHistoryResponse } from "./git-history.js"; +export { GIT_HISTORY_SCHEMA_VERSION, validateGitHistoryResponse } from "./git-history.js"; + +// ─── Git fetch/pull sync BFF (Issue #1573, Epic #1572) ──────────────────────────── +// Read-only sync preview (readiness/executable gate + block reason) and the governed execute +// request/response with an evidence-friendly outcome taxonomy. Fetch/pull deliberately do NOT +// enter the GitDeliveryActionKind mutation taxonomy; they use a dedicated bounded git executor. +export type { + GitSyncOperation, + GitSyncOutcome, + GitSyncBlockReason, + GitSyncExecuteRequest, + GitSyncPreview, + GitSyncExecuteResponse, +} from "./git-sync.js"; +export { + GIT_SYNC_SCHEMA_VERSION, + GIT_SYNC_OPERATIONS, + GIT_SYNC_OUTCOMES, + GIT_SYNC_BLOCK_REASONS, + isGitSyncOperation, + isGitSyncOutcome, + validateGitSyncPreview, + validateGitSyncExecuteResponse, +} from "./git-sync.js"; + +// ─── Agent repository operation facade (Issue #1577, Epic #1571) ───────────── +// Typed agent admission contract over existing Git reads and governed Git delivery routes. It grants +// no shell/provider authority and reject command-shaped payloads before the BFF can delegate. +export type { + GitRepositoryAgentOperationMode, + GitRepositoryAgentOperationKind, + GitRepositoryAgentDenialReason, + GitRepositoryAgentOperationRequest, + GitRepositoryAgentOperationDelegatedResponse, + GitRepositoryAgentOperationDeniedResponse, + GitRepositoryAgentOperationResponse, + GitRepositoryAgentParseOk, + GitRepositoryAgentParseFail, + GitRepositoryAgentParseResult, +} from "./git-repository-agent.js"; +export { + GIT_REPOSITORY_AGENT_SCHEMA_VERSION, + GIT_REPOSITORY_AGENT_OPERATION_MODES, + GIT_REPOSITORY_AGENT_OPERATION_KINDS, + GIT_REPOSITORY_AGENT_DENIAL_REASONS, + parseGitRepositoryAgentOperationRequest, + isGitRepositoryAgentOperationResponse, +} from "./git-repository-agent.js"; + // ─── Controlled command executor (Issue #1387, Epic #1491) ──────────────────────── // Wire contract for the governed test/build/run command runner: a server-discovered catalog of // vetted tasks, a run request that names a catalog `taskId` (never free-form argv), the structured diff --git a/packages/keiko-security/src/redaction.test.ts b/packages/keiko-security/src/redaction.test.ts index fedf73d9a..99ab7569b 100644 --- a/packages/keiko-security/src/redaction.test.ts +++ b/packages/keiko-security/src/redaction.test.ts @@ -161,6 +161,48 @@ describe("redact", () => { expect(result).toContain("@db.internal:5432/app"); }); + // #1573 security follow-up — a token-as-username remote URL carries the secret in the userinfo + // with NO ':' (https://@host), so the colon-bearing pattern misses it. The colon-less + // userinfo form must be masked for credential-carrying schemes too. `git remote -v` output from + // /api/git/summary and /api/git/remotes can otherwise surface an opaque PAT to the browser. + it("strips a colon-less token-as-username from an HTTPS remote URL", () => { + // Short suffix so it does NOT match the gh[pousr]_ token SHAPE — proves the URL userinfo + // pattern (not GITHUB_TOKEN_PATTERN) does the redaction. + const url = "https://ghp_xxx@github.com/o/r.git"; + const result = redact(`origin\t${url} (fetch)`); + expect(result).not.toContain("ghp_xxx@"); + expect(result).toContain("https://[REDACTED]@github.com/o/r.git"); + }); + + it("strips an opaque (unknown-shape) token used as the userinfo of a URL", () => { + const url = "https://opaque-token@host/r"; + const result = redact(url); + expect(result).not.toContain("opaque-token@"); + expect(result).toContain("https://[REDACTED]@host/r"); + }); + + it("preserves a bare SSH userinfo (a login name, not a credential)", () => { + // SSH authenticates with keys, not userinfo; `git@`/`user@` is a non-secret login name. + expect(redact("ssh://user@host/repo.git")).toBe("ssh://user@host/repo.git"); + expect(redact("ssh://git@github.com/o/r.git")).toBe("ssh://git@github.com/o/r.git"); + }); + + it("still strips userinfo credentials from an SSH URL that carries a password", () => { + const result = redact("ssh://user:s3cr3tPassw0rd@host/repo.git"); + expect(result).not.toContain("s3cr3tPassw0rd"); + expect(result).toContain("ssh://[REDACTED]@host/repo.git"); + }); + + it("does not over-match general '@' text with no scheme authority", () => { + const prose = "ping me at user@example.com or see path/to@file"; + expect(redact(prose)).toBe(prose); + }); + + it("does not treat an '@' inside a URL path as userinfo", () => { + const url = "https://example.com/users/@handle/profile"; + expect(redact(url)).toBe(url); + }); + it("does not redact a benign 'password reset' sentence with no assignment", () => { const prose = "Follow the password reset link to continue."; expect(redact(prose)).toBe(prose); @@ -349,4 +391,28 @@ describe("deepRedactStrings", () => { deepRedactStrings(original, redactor); expect(original.a).toContain("z".repeat(20)); }); + + // #1573/#1606 — `/api/git/summary` and `/api/git/remotes` serialize `git remote -v` URLs as the + // fetchUrl/pushUrl string leaves of a GitRemoteSummary[] and return the payload through + // deepRedactStrings(body, createAuditRedactor(...)). This locks in that a colon-less + // token-as-username URL is scrubbed at that exact (object-leaf) call shape, not just on a bare string. + it("scrubs a colon-less token URL nested in a git remote-summary payload", () => { + const redactor = createAuditRedactor({}, {}); + const body = { + branch: "main", + remotes: [ + { + name: "origin", + fetchUrl: "https://opaque-pat-value@github.com/o/r.git", + pushUrl: undefined, + }, + { name: "ssh", fetchUrl: "ssh://git@github.com/o/r.git", pushUrl: undefined }, + ], + }; + const result = deepRedactStrings(body, redactor) as typeof body; + expect(result.remotes[0]?.fetchUrl).toBe("https://[REDACTED]@github.com/o/r.git"); + expect(result.remotes[0]?.fetchUrl).not.toContain("opaque-pat-value"); + // SSH login name preserved (not a credential), matching the redactor's existing intent. + expect(result.remotes[1]?.fetchUrl).toBe("ssh://git@github.com/o/r.git"); + }); }); diff --git a/packages/keiko-security/src/redaction.ts b/packages/keiko-security/src/redaction.ts index 9263d9133..1f1edc454 100644 --- a/packages/keiko-security/src/redaction.ts +++ b/packages/keiko-security/src/redaction.ts @@ -49,6 +49,18 @@ const SECRET_KEY_VALUE_PATTERN = new RegExp( // userinfo class on each side of the ':' and bounded by '@', so no catastrophic backtracking. const URL_CREDENTIALS_PATTERN = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s:@/]+:[^\s:@/]+@/gi; +// scheme://@host with NO ':' in the userinfo. A personal-access token used as the +// username (https://@github.com/o/r.git — common for GitHub/GitLab) carries no colon, so the +// colon-bearing pattern above misses it unless the token matches a known SHAPE (ghp_, sk-, …); an +// opaque token would otherwise reach the browser via `git remote -v` output (#1573). This masks the +// userinfo for credential-carrying schemes. A bare SSH userinfo is conventionally a non-secret login +// name (git@…) — SSH authenticates with keys, not userinfo — so SSH-family schemes are preserved, +// matching the existing intent of stripping credentials rather than usernames. Scoped to the URL +// authority (a real scheme:// must precede the userinfo), so general '@' text is not over-matched. +// ReDoS-safe: one linear userinfo class bounded by '@', no nesting. +const URL_USERINFO_PATTERN = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s:@/]+@/gi; +const SSH_USERINFO_SCHEME = /^(?:git\+)?ssh(?:\+git)?:\/\/$/i; + const BUILTIN_PATTERNS: readonly RegExp[] = [ GITHUB_TOKEN_PATTERN, AWS_ACCESS_KEY_PATTERN, @@ -74,6 +86,9 @@ export function redact(input: string, additionalSecrets: readonly string[] = []) .replace(GENERIC_API_KEY_ASSIGNMENT_PATTERN, `$1${REDACTED}`) .replace(SECRET_KEY_VALUE_PATTERN, `$1$2${REDACTED}`) .replace(URL_CREDENTIALS_PATTERN, `$1${REDACTED}@`) + .replace(URL_USERINFO_PATTERN, (match, scheme: string) => + SSH_USERINFO_SCHEME.test(scheme) ? match : `${scheme}${REDACTED}@`, + ) .replace(API_KEY_PATTERN, REDACTED); for (const pattern of BUILTIN_PATTERNS) { output = output.replace(pattern, REDACTED); diff --git a/packages/keiko-server/src/gitDelivery/agentOperationsRoutes.test.ts b/packages/keiko-server/src/gitDelivery/agentOperationsRoutes.test.ts new file mode 100644 index 000000000..72315b942 --- /dev/null +++ b/packages/keiko-server/src/gitDelivery/agentOperationsRoutes.test.ts @@ -0,0 +1,359 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + GitRepositoryAgentOperationRequest, + GitRepositoryAgentOperationResponse, +} from "@oscharko-dev/keiko-contracts"; +import { buildRedactor, createRunRegistry, type UiHandlerDeps } from "../index.js"; +import type { GitProcessRunner } from "../gitRoutes.js"; +import { matchRoute, type RouteContext } from "../routes.js"; +import { createInMemoryUiStore, type UiStore } from "../store/index.js"; +import { + handleGitAgentOperation, + handleGitAgentOperationWithDelegate, + IdempotencyCache, +} from "./agentOperationsRoutes.js"; + +let store: UiStore; +let root: string; + +function ok(stdout: string): Awaited> { + return { exitCode: 0, signal: null, stdout, stderr: "", truncated: false }; +} + +function deps(runner: GitProcessRunner = vi.fn(() => Promise.resolve(ok("")))): UiHandlerDeps { + return { + config: undefined, + configPresent: false, + evidenceStore: { put: () => "", list: () => [], get: () => undefined, delete: () => undefined }, + env: {}, + redactor: buildRedactor({}), + registry: createRunRegistry(), + modelPortFactory: () => undefined, + store, + gitRouteOptions: { runner, maxDiffBytes: 64, maxStatusBytes: 4096, maxChanges: 10 }, + }; +} + +function ctx(body: unknown): RouteContext { + const raw = typeof body === "string" ? body : JSON.stringify(body); + const req = Readable.from([Buffer.from(raw, "utf8")]) as IncomingMessage; + req.method = "POST"; + req.headers = { "content-type": "application/json", "x-keiko-csrf": "1" }; + return { + req, + res: {} as ServerResponse, + params: {}, + url: new URL("http://127.0.0.1/api/git/agent/operations"), + }; +} + +function request(overrides: Record = {}): Record { + return { + schemaVersion: "1", + operation: "status", + mode: "read", + projectId: root, + ...overrides, + }; +} + +async function waitUntil(assertion: () => void): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => { + setTimeout(resolve, 1); + }); + } + } + throw lastError; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "keiko-agent-git-")); + store = createInMemoryUiStore(); + store.createProject(root, "fixture"); +}); + +afterEach(() => { + store.close(); + rmSync(root, { recursive: true, force: true }); +}); + +describe("POST /api/git/agent/operations", () => { + it("is registered as an exact POST route only", () => { + const match = matchRoute("POST", "/api/git/agent/operations"); + expect(match).not.toBe("method-not-allowed"); + expect(match).toBeDefined(); + if (match === undefined || match === "method-not-allowed") { + throw new Error("route did not resolve"); + } + expect(match.definition.pattern).toBe("/api/git/agent/operations"); + expect(matchRoute("GET", "/api/git/agent/operations")).toBe("method-not-allowed"); + expect(matchRoute("POST", "/api/git/agent/operations/foo")).toBeUndefined(); + }); + + it("denies direct shell payloads before any Git runner is called", async () => { + const runner = vi.fn(() => Promise.resolve(ok(""))); + const result = await handleGitAgentOperation( + ctx(request({ payload: { command: "git status", argv: ["git", "status"] } })), + deps(runner), + ); + + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ + status: "denied", + denialReason: "unsupported-direct-shell", + }); + expect(runner).not.toHaveBeenCalled(); + }); + + it("rejects extra top-level fields and credential-shaped strings", async () => { + expect(await handleGitAgentOperation(ctx(request({ extra: true })), deps())).toMatchObject({ + status: 400, + body: { status: "denied", denialReason: "bad-request" }, + }); + expect( + await handleGitAgentOperation(ctx(request({ payload: { remote: "api_keyleak" } })), deps()), + ).toMatchObject({ + status: 400, + body: { error: { code: "GIT_AGENT_OPERATION_FORBIDDEN_PAYLOAD" } }, + }); + }); + + it("delegates read operations to the existing Git read route", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce(ok("## main\0")); + const result = await handleGitAgentOperation(ctx(request()), deps(runner)); + + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ + status: "delegated", + operation: "status", + routeStatus: 200, + response: { state: "available", branch: "main" }, + }); + }); + + it("passes through unknown project results from delegated routes", async () => { + const result = await handleGitAgentOperation( + ctx( + request({ + operation: "branch-switch", + mode: "execute", + idempotencyKey: "unknown-1", + projectId: join(tmpdir(), "keiko-missing-project"), + payload: { branchName: "main" }, + }), + ), + deps(), + ); + + expect(result.status).toBe(404); + expect(result.body).toMatchObject({ + status: "delegated", + operation: "branch-switch", + routeStatus: 404, + }); + }); + + it("requires execute idempotency and conflicts reused keys with different bodies", async () => { + const firstBody = request({ + operation: "branch-switch", + mode: "execute", + idempotencyKey: "switch-1", + payload: { branchName: "main" }, + }); + const first = await handleGitAgentOperation(ctx(firstBody), deps()); + const replay = await handleGitAgentOperation(ctx(firstBody), deps()); + const conflict = await handleGitAgentOperation( + ctx({ + ...firstBody, + payload: { branchName: "other" }, + }), + deps(), + ); + + expect(first.body).toMatchObject({ status: "delegated", operation: "branch-switch" }); + expect(replay.body).toMatchObject({ status: "delegated", replay: true }); + expect(conflict).toMatchObject({ + status: 409, + body: { status: "denied", denialReason: "idempotency-conflict" }, + }); + }); + + it("reserves execute idempotency before the delegated mutation settles", async () => { + let releaseDelegate!: () => void; + const delegateGate = new Promise((resolve) => { + releaseDelegate = resolve; + }); + const delegated = vi.fn(async () => { + await delegateGate; + return { status: 200, body: { ok: true } }; + }); + const body = request({ + operation: "branch-switch", + mode: "execute", + idempotencyKey: "switch-concurrent", + payload: { branchName: "main" }, + }) as unknown as GitRepositoryAgentOperationRequest; + const first = handleGitAgentOperationWithDelegate(body, "same-fingerprint", delegated); + + await waitUntil(() => { + expect(delegated).toHaveBeenCalledTimes(1); + }); + const second = handleGitAgentOperationWithDelegate(body, "same-fingerprint", delegated); + await new Promise((resolve) => { + setTimeout(resolve, 5); + }); + + expect(delegated).toHaveBeenCalledTimes(1); + releaseDelegate(); + const [firstResult, secondResult] = await Promise.all([first, second]); + + expect(firstResult.body).toMatchObject({ status: "delegated", operation: "branch-switch" }); + expect(secondResult.body).toMatchObject({ + status: "delegated", + operation: "branch-switch", + replay: true, + }); + }); +}); + +type CacheEntry = Parameters[1]; + +describe("IdempotencyCache eviction", () => { + const response = { schemaVersion: "1" } as unknown as GitRepositoryAgentOperationResponse; + const settledEntry = (fingerprint: string): CacheEntry => ({ fingerprint, result: response }); + const pendingEntry = (fingerprint: string): CacheEntry => ({ + fingerprint, + pending: Promise.resolve(response), + }); + + it("evicts the least-recently-used settled entry once over the size cap", () => { + const cache = new IdempotencyCache({ maxEntries: 2, ttlMs: 1_000_000, now: (): number => 0 }); + cache.set("a", settledEntry("fa")); + cache.set("b", settledEntry("fb")); + // Touch "a" so "b" becomes the least-recently-used entry. + expect(cache.get("a")?.fingerprint).toBe("fa"); + + cache.set("c", settledEntry("fc")); + + expect(cache.size).toBe(2); + expect(cache.get("b")).toBeUndefined(); + expect(cache.get("a")?.fingerprint).toBe("fa"); + expect(cache.get("c")?.fingerprint).toBe("fc"); + }); + + it("expires a settled entry once its TTL has elapsed", () => { + let clockMs = 0; + const cache = new IdempotencyCache({ maxEntries: 16, ttlMs: 1000, now: (): number => clockMs }); + cache.set("k", settledEntry("fk")); + + clockMs = 999; + expect(cache.get("k")?.fingerprint).toBe("fk"); + + clockMs = 1000; + expect(cache.get("k")).toBeUndefined(); + expect(cache.size).toBe(0); + }); + + it("prunes expired settled entries when a new key is inserted", () => { + let clockMs = 0; + const cache = new IdempotencyCache({ maxEntries: 16, ttlMs: 1000, now: (): number => clockMs }); + cache.set("old", settledEntry("fold")); + + clockMs = 5000; // well past the TTL + cache.set("new", settledEntry("fnew")); // insertion triggers a prune sweep + + expect(cache.size).toBe(1); + expect(cache.get("new")?.fingerprint).toBe("fnew"); + }); + + it("never evicts an in-flight reservation to make room for a settled entry", () => { + const cache = new IdempotencyCache({ maxEntries: 1, ttlMs: 1_000_000, now: (): number => 0 }); + cache.set("pending", pendingEntry("fp")); + cache.set("settled", settledEntry("fs")); + + expect(cache.size).toBe(1); + expect(cache.get("pending")?.fingerprint).toBe("fp"); + expect(cache.get("settled")).toBeUndefined(); + }); + + it("retains concurrent in-flight reservations even beyond the cap", () => { + const cache = new IdempotencyCache({ maxEntries: 1, ttlMs: 1_000_000, now: (): number => 0 }); + cache.set("p1", pendingEntry("f1")); + cache.set("p2", pendingEntry("f2")); + + expect(cache.size).toBe(2); + expect(cache.get("p1")?.fingerprint).toBe("f1"); + expect(cache.get("p2")?.fingerprint).toBe("f2"); + }); +}); + +describe("agent idempotency cache lifecycle through the handler", () => { + const execute = (idempotencyKey: string): GitRepositoryAgentOperationRequest => + request({ + operation: "branch-switch", + mode: "execute", + idempotencyKey, + payload: { branchName: "main" }, + }) as unknown as GitRepositoryAgentOperationRequest; + + it("replays a completed operation for a repeated key without re-delegating", async () => { + const cache = new IdempotencyCache({ maxEntries: 8, ttlMs: 1_000_000 }); + const delegated = vi.fn(() => Promise.resolve({ status: 200, body: { ok: true } })); + const body = execute("replay-1"); + + const first = await handleGitAgentOperationWithDelegate(body, "fp", delegated, cache); + const second = await handleGitAgentOperationWithDelegate(body, "fp", delegated, cache); + + expect(delegated).toHaveBeenCalledTimes(1); + expect(first.body).toMatchObject({ status: "delegated" }); + expect(first.body).not.toMatchObject({ replay: true }); + expect(second.body).toMatchObject({ status: "delegated", replay: true }); + }); + + it("bounds the cache when many distinct keys are delegated", async () => { + const cache = new IdempotencyCache({ maxEntries: 3, ttlMs: 1_000_000 }); + const delegated = vi.fn(() => Promise.resolve({ status: 200, body: { ok: true } })); + + for (let i = 0; i < 10; i += 1) { + await handleGitAgentOperationWithDelegate( + execute(`bound-${String(i)}`), + `fp-${String(i)}`, + delegated, + cache, + ); + } + + expect(delegated).toHaveBeenCalledTimes(10); + expect(cache.size).toBe(3); + }); + + it("re-delegates once the replay entry has expired from the cache", async () => { + let clockMs = 0; + const cache = new IdempotencyCache({ maxEntries: 8, ttlMs: 1000, now: (): number => clockMs }); + const delegated = vi.fn(() => Promise.resolve({ status: 200, body: { ok: true } })); + const body = execute("ttl-1"); + + await handleGitAgentOperationWithDelegate(body, "fp", delegated, cache); + clockMs = 5000; // past the replay window + const after = await handleGitAgentOperationWithDelegate(body, "fp", delegated, cache); + + expect(delegated).toHaveBeenCalledTimes(2); + expect(after.body).toMatchObject({ status: "delegated" }); + expect(after.body).not.toMatchObject({ replay: true }); + }); +}); diff --git a/packages/keiko-server/src/gitDelivery/agentOperationsRoutes.ts b/packages/keiko-server/src/gitDelivery/agentOperationsRoutes.ts new file mode 100644 index 000000000..7b2af18f5 --- /dev/null +++ b/packages/keiko-server/src/gitDelivery/agentOperationsRoutes.ts @@ -0,0 +1,550 @@ +// Typed repository-operation facade for agents (Issue #1577, Epic #1571). +// +// This is an admission and dispatch layer only. It accepts a closed semantic operation envelope, +// rejects shell/provider-shaped input before delegation, and forwards to existing Git read / +// git-delivery route handlers. It does not import or create any new Git, gh, terminal, or provider +// adapter authority. + +import { createHash } from "node:crypto"; +import type { IncomingMessage } from "node:http"; +import { Readable } from "node:stream"; +import { + GIT_REPOSITORY_AGENT_SCHEMA_VERSION, + parseGitRepositoryAgentOperationRequest, + type GitRepositoryAgentDenialReason, + type GitRepositoryAgentOperationKind, + type GitRepositoryAgentOperationRequest, + type GitRepositoryAgentOperationResponse, +} from "@oscharko-dev/keiko-contracts"; +import { STREAMING, type RouteContext, type RouteDefinition, type RouteResult } from "../routes.js"; +import { handleGitBranches, handleGitDiff, handleGitStatus } from "../gitRoutes.js"; +import type { UiHandlerDeps } from "../deps.js"; +import { createGitDeliveryCommitRouteGroup } from "./commitRoutes.js"; +import { createGitDeliveryLocalMutationRouteGroup } from "./localMutationRoutes.js"; +import { createGitDeliveryMergeRouteGroup } from "./mergeRoutes.js"; +import { createGitDeliveryPrRouteGroup } from "./prRoutes.js"; +import { createGitDeliveryPushRouteGroup } from "./pushRoutes.js"; +import { createGitDeliverySyncRouteGroup } from "./syncRoutes.js"; +import { + GitDeliveryBodyTooLargeError, + hasOnlyAllowedKeys, + isPlainObject, + readGitDeliveryBody, + scanForbiddenStrings, + scanUnsafeFormatChars, +} from "./requestGuards.js"; + +type DelegatedResult = RouteResult; + +type AgentErrorCode = + | "GIT_AGENT_OPERATION_BAD_REQUEST" + | "GIT_AGENT_OPERATION_PAYLOAD_TOO_LARGE" + | "GIT_AGENT_OPERATION_FORBIDDEN_PAYLOAD" + | "GIT_AGENT_OPERATION_UNSUPPORTED"; + +const SAFE_MESSAGES: Readonly> = { + GIT_AGENT_OPERATION_BAD_REQUEST: "The request body is not a valid repository operation.", + GIT_AGENT_OPERATION_PAYLOAD_TOO_LARGE: + "The repository operation request exceeds the maximum permitted size.", + GIT_AGENT_OPERATION_FORBIDDEN_PAYLOAD: + "The request contained a forbidden credential, header, URL, or unsafe text shape.", + GIT_AGENT_OPERATION_UNSUPPORTED: "The repository operation is not supported.", +}; + +const errResult = (status: number, code: AgentErrorCode): RouteResult => ({ + status, + body: { error: { code, message: SAFE_MESSAGES[code] } }, +}); + +const routeGroups = [ + ...createGitDeliveryLocalMutationRouteGroup(), + ...createGitDeliveryCommitRouteGroup(), + ...createGitDeliveryPushRouteGroup(), + ...createGitDeliveryPrRouteGroup(), + ...createGitDeliveryMergeRouteGroup(), + ...createGitDeliverySyncRouteGroup(), +] as const; + +interface IdempotencyEntry { + readonly fingerprint: string; + readonly pending?: Promise; + readonly result?: GitRepositoryAgentOperationResponse; +} + +// Defaults for the process-memory idempotency cache. The cap bounds worst-case memory against a client +// that streams many distinct idempotency keys; the TTL lets settled replay entries self-evict so the +// map self-cleans even for keys that are never queried again. +export const DEFAULT_IDEMPOTENCY_MAX_ENTRIES = 1024; +export const DEFAULT_IDEMPOTENCY_TTL_MS = 10 * 60 * 1000; + +export interface IdempotencyCacheOptions { + readonly maxEntries?: number; + readonly ttlMs?: number; + readonly now?: () => number; +} + +interface StoredEntry { + readonly entry: IdempotencyEntry; + // Wall-clock expiry, enforced only once the entry holds a settled result. A pending reservation is a + // short-lived guard for an in-flight delegation; it is never TTL-pruned or LRU-evicted so a duplicate + // request can never re-trigger an operation that is still running (idempotency is preserved exactly). + readonly expiresAt: number; +} + +// Bounded LRU + TTL store for the agent-facade idempotency replay window. Exposes the Map subset the +// handler relies on (get / set / delete) plus `size` for tests. Overflow eviction targets the +// least-recently-used *settled* entry only; in-flight reservations are exempt. +export class IdempotencyCache { + private readonly entries = new Map(); + private readonly maxEntries: number; + private readonly ttlMs: number; + private readonly now: () => number; + + public constructor(options: IdempotencyCacheOptions = {}) { + this.maxEntries = Math.max( + 1, + Math.floor(options.maxEntries ?? DEFAULT_IDEMPOTENCY_MAX_ENTRIES), + ); + this.ttlMs = Math.max(1, Math.floor(options.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS)); + this.now = options.now ?? Date.now; + } + + public get size(): number { + return this.entries.size; + } + + public get(key: string): IdempotencyEntry | undefined { + const stored = this.entries.get(key); + if (stored === undefined) return undefined; + if (stored.entry.result !== undefined && this.now() >= stored.expiresAt) { + this.entries.delete(key); + return undefined; + } + // Refresh LRU recency on a hit by reinserting at the tail; the TTL window is unchanged. + this.entries.delete(key); + this.entries.set(key, stored); + return stored.entry; + } + + public set(key: string, entry: IdempotencyEntry): void { + this.entries.delete(key); + this.pruneExpired(); + this.entries.set(key, { entry, expiresAt: this.now() + this.ttlMs }); + this.evictOverflow(); + } + + public delete(key: string): void { + this.entries.delete(key); + } + + private pruneExpired(): void { + const now = this.now(); + for (const [key, stored] of this.entries) { + if (stored.entry.result !== undefined && now >= stored.expiresAt) { + this.entries.delete(key); + } + } + } + + private evictOverflow(): void { + while (this.entries.size > this.maxEntries) { + let victim: string | undefined; + for (const [key, stored] of this.entries) { + if (stored.entry.pending === undefined) { + victim = key; + break; + } + } + if (victim === undefined) break; // every entry is an in-flight reservation — cannot safely evict + this.entries.delete(victim); + } + } +} + +const idempotencyCache = new IdempotencyCache(); + +function denied( + request: Partial, + denialReason: GitRepositoryAgentDenialReason, + message: string, +): GitRepositoryAgentOperationResponse { + return { + schemaVersion: GIT_REPOSITORY_AGENT_SCHEMA_VERSION, + ...(request.operation === undefined ? {} : { operation: request.operation }), + ...(request.mode === undefined ? {} : { mode: request.mode }), + status: "denied", + denialReason, + message, + }; +} + +async function readParsed( + req: IncomingMessage, +): Promise< + | { readonly ok: true; readonly value: unknown } + | { readonly ok: false; readonly result: RouteResult } +> { + let raw: string; + try { + raw = await readGitDeliveryBody(req); + } catch (error) { + if (error instanceof GitDeliveryBodyTooLargeError) { + return { ok: false, result: errResult(413, "GIT_AGENT_OPERATION_PAYLOAD_TOO_LARGE") }; + } + return { ok: false, result: errResult(400, "GIT_AGENT_OPERATION_BAD_REQUEST") }; + } + try { + return { ok: true, value: JSON.parse(raw) }; + } catch { + return { ok: false, result: errResult(400, "GIT_AGENT_OPERATION_BAD_REQUEST") }; + } +} + +function makeRequest(body: unknown, base: IncomingMessage): IncomingMessage { + const req = Readable.from([Buffer.from(JSON.stringify(body), "utf8")]) as IncomingMessage; + req.method = "POST"; + req.headers = { ...base.headers, "content-type": "application/json" }; + return req; +} + +function postContext(ctx: RouteContext, pattern: string, body: unknown): RouteContext { + return { + req: makeRequest(body, ctx.req), + res: ctx.res, + params: {}, + url: new URL(`http://127.0.0.1${pattern}`), + }; +} + +function readContext(ctx: RouteContext, path: string): RouteContext { + return { + req: Readable.from([]) as IncomingMessage, + res: ctx.res, + params: {}, + url: new URL(`http://127.0.0.1${path}`), + }; +} + +function queryFor( + path: string, + entries: readonly (readonly [string, string | undefined])[], +): string { + const url = new URL(`http://127.0.0.1${path}`); + for (const [key, value] of entries) { + if (value !== undefined && value !== "") url.searchParams.set(key, value); + } + return `${url.pathname}${url.search}`; +} + +const READ_PAYLOAD_KEYS: Readonly>> = + { + status: new Set(), + diff: new Set(["path", "scope"]), + "branch-list": new Set(), + }; + +function payloadOrEmpty( + request: GitRepositoryAgentOperationRequest, +): Readonly> { + return request.payload ?? {}; +} + +function validatePayloadKeys( + request: GitRepositoryAgentOperationRequest, + allowed: ReadonlySet, +): RouteResult | undefined { + const payload = payloadOrEmpty(request); + if (!hasOnlyAllowedKeys(payload, allowed)) { + return errResult(400, "GIT_AGENT_OPERATION_BAD_REQUEST"); + } + return undefined; +} + +async function delegateRead( + request: GitRepositoryAgentOperationRequest, + ctx: RouteContext, + deps: UiHandlerDeps, +): Promise { + if ( + request.operation !== "status" && + request.operation !== "diff" && + request.operation !== "branch-list" + ) { + return errResult(400, "GIT_AGENT_OPERATION_BAD_REQUEST"); + } + const keyError = validatePayloadKeys(request, READ_PAYLOAD_KEYS[request.operation]); + if (keyError !== undefined) return keyError; + const payload = payloadOrEmpty(request); + if (request.operation === "status") { + return handleGitStatus( + readContext(ctx, queryFor("/api/git/status", [["root", request.projectId]])), + deps, + deps.gitRouteOptions, + ); + } + if (request.operation === "branch-list") { + return handleGitBranches( + readContext(ctx, queryFor("/api/git/branches", [["root", request.projectId]])), + deps, + deps.gitRouteOptions, + ); + } + return handleGitDiff( + readContext( + ctx, + queryFor("/api/git/diff", [ + ["root", request.projectId], + ["path", typeof payload.path === "string" ? payload.path : undefined], + ["scope", typeof payload.scope === "string" ? payload.scope : undefined], + ]), + ), + deps, + deps.gitRouteOptions, + ); +} + +const WRITE_KEYS: Readonly>> = { + status: new Set(), + diff: new Set(), + "branch-list": new Set(), + "branch-create": new Set(["branchName", "baseBranchName", "startPointRefHash"]), + "branch-switch": new Set(["branchName"]), + stage: new Set(["pathspecs", "includeUntracked"]), + unstage: new Set(["pathspecs"]), + commit: new Set(["messageDraft", "message", "allowEmpty"]), + fetch: new Set(["remote"]), + pull: new Set(["remote"]), + push: new Set([ + "remoteAlias", + "remoteBranchName", + "sourceBranchName", + "forcePush", + "setUpstreamTracking", + ]), + "pull-request": new Set([ + "kind", + "ownerAndRepo", + "headBranchName", + "baseBranchName", + "title", + "description", + "isDraft", + "prExternalId", + "convertToDraft", + "convertFromDraft", + ]), + merge: new Set([ + "kind", + "ownerAndRepo", + "prExternalId", + "baseBranchName", + "headBranchName", + "mergeStrategy", + "deleteBranchAfterMerge", + "expectedHeadRefHash", + ]), +}; + +const STATIC_WRITE_PATTERNS: Readonly>> = { + "branch-create": "/api/git-delivery/local-branch/create", + "branch-switch": "/api/git-delivery/local-branch/switch", + stage: "/api/git-delivery/staging/stage", + unstage: "/api/git-delivery/staging/unstage", +}; + +const PHASED_WRITE_PATTERNS: Readonly>> = { + commit: "/api/git-delivery/commit", + fetch: "/api/git-delivery/fetch", + pull: "/api/git-delivery/pull", + push: "/api/git-delivery/push", + "pull-request": "/api/git-delivery/pr", + merge: "/api/git-delivery/merge", +}; + +function delegatedPattern(request: GitRepositoryAgentOperationRequest): string | undefined { + const staticPattern = STATIC_WRITE_PATTERNS[request.operation]; + if (staticPattern !== undefined) return staticPattern; + const phasedPrefix = PHASED_WRITE_PATTERNS[request.operation]; + return phasedPrefix === undefined ? undefined : `${phasedPrefix}/${request.mode}`; +} + +function delegatedBody(request: GitRepositoryAgentOperationRequest): Record { + const payload = payloadOrEmpty(request); + const body: Record = { + schemaVersion: "1", + projectId: request.projectId, + ...payload, + }; + if (request.operation === "pull-request") { + body.body = typeof payload.description === "string" ? payload.description : ""; + delete body.description; + } + if (request.operation === "merge") { + body.kind = "merge"; + } + return body; +} + +async function delegateWrite( + request: GitRepositoryAgentOperationRequest, + ctx: RouteContext, + deps: UiHandlerDeps, +): Promise { + const keyError = validatePayloadKeys(request, WRITE_KEYS[request.operation]); + if (keyError !== undefined) return keyError; + const pattern = delegatedPattern(request); + if (pattern === undefined) return errResult(400, "GIT_AGENT_OPERATION_UNSUPPORTED"); + const route = routeGroups.find((candidate) => candidate.pattern === pattern); + if (route === undefined) return errResult(400, "GIT_AGENT_OPERATION_UNSUPPORTED"); + const body = delegatedBody(request); + const outcome = await route.handler(postContext(ctx, pattern, body), deps); + return outcome === STREAMING ? errResult(400, "GIT_AGENT_OPERATION_UNSUPPORTED") : outcome; +} + +function wrapDelegated( + request: GitRepositoryAgentOperationRequest, + result: RouteResult, + replay = false, +): GitRepositoryAgentOperationResponse { + return { + schemaVersion: GIT_REPOSITORY_AGENT_SCHEMA_VERSION, + operation: request.operation, + mode: request.mode, + status: "delegated", + routeStatus: result.status, + ...(replay ? { replay: true } : {}), + response: result.body, + }; +} + +function cacheKey(request: GitRepositoryAgentOperationRequest): string | undefined { + if (request.mode !== "execute" || request.idempotencyKey === undefined) return undefined; + return `${request.projectId}\0${request.idempotencyKey}`; +} + +function normalizedForDigest(value: unknown): unknown { + if (Array.isArray(value)) return value.map(normalizedForDigest); + if (!isPlainObject(value)) return value; + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, normalizedForDigest(value[key])]), + ); +} + +function fingerprintRequest(request: GitRepositoryAgentOperationRequest): string { + return createHash("sha256") + .update(JSON.stringify(normalizedForDigest(request))) + .digest("hex"); +} + +function deniedStatus(reason: GitRepositoryAgentDenialReason): number { + return reason === "unsupported-direct-shell" ? 200 : 400; +} + +async function parseAgentRequest(req: IncomingMessage): Promise< + | { + readonly ok: true; + readonly request: GitRepositoryAgentOperationRequest; + readonly fingerprint: string; + } + | { readonly ok: false; readonly result: RouteResult } +> { + const read = await readParsed(req); + if (!read.ok) return read; + const parsed = parseGitRepositoryAgentOperationRequest(read.value); + if (!parsed.ok) { + return { + ok: false, + result: { + status: deniedStatus(parsed.denialReason), + body: denied({}, parsed.denialReason, parsed.message), + }, + }; + } + if (scanForbiddenStrings(read.value)) { + return { ok: false, result: errResult(400, "GIT_AGENT_OPERATION_FORBIDDEN_PAYLOAD") }; + } + if (scanUnsafeFormatChars(read.value) || !isPlainObject(read.value)) { + return { ok: false, result: errResult(400, "GIT_AGENT_OPERATION_BAD_REQUEST") }; + } + return { ok: true, request: parsed.value, fingerprint: fingerprintRequest(parsed.value) }; +} + +function idempotencyConflict(request: GitRepositoryAgentOperationRequest): RouteResult { + return { + status: 409, + body: denied( + request, + "idempotency-conflict", + "The idempotencyKey was already used for a different repository operation.", + ), + }; +} + +function responseResult(body: GitRepositoryAgentOperationResponse, replay = false): RouteResult { + const response = body.status === "delegated" && replay ? { ...body, replay: true } : body; + return { + status: body.status === "delegated" ? body.routeStatus : 200, + body: response, + }; +} + +async function delegateRequest( + request: GitRepositoryAgentOperationRequest, + ctx: RouteContext, + deps: UiHandlerDeps, +): Promise { + return request.mode === "read" + ? delegateRead(request, ctx, deps) + : delegateWrite(request, ctx, deps); +} + +export async function handleGitAgentOperationWithDelegate( + request: GitRepositoryAgentOperationRequest, + fingerprint: string, + delegate: () => Promise, + cache: IdempotencyCache = idempotencyCache, +): Promise { + const key = cacheKey(request); + if (key === undefined) { + const result = await delegate(); + return { status: result.status, body: wrapDelegated(request, result) }; + } + const cached = cache.get(key); + if (cached !== undefined) { + if (cached.fingerprint !== fingerprint) return idempotencyConflict(request); + if (cached.result !== undefined) return responseResult(cached.result, true); + if (cached.pending !== undefined) return responseResult(await cached.pending, true); + } + const pending = delegate().then((result) => wrapDelegated(request, result)); + cache.set(key, { fingerprint, pending }); + try { + const body = await pending; + cache.set(key, { fingerprint, result: body }); + return responseResult(body); + } catch (error) { + const current = cache.get(key); + if (current?.pending === pending) cache.delete(key); + throw error; + } +} + +export async function handleGitAgentOperation( + ctx: RouteContext, + deps: UiHandlerDeps, +): Promise { + const parsed = await parseAgentRequest(ctx.req); + if (!parsed.ok) return parsed.result; + return handleGitAgentOperationWithDelegate(parsed.request, parsed.fingerprint, () => + delegateRequest(parsed.request, ctx, deps), + ); +} + +export const GIT_AGENT_OPERATION_ROUTE_GROUP: readonly RouteDefinition[] = [ + { + method: "POST", + pattern: "/api/git/agent/operations", + handler: handleGitAgentOperation, + }, +]; diff --git a/packages/keiko-server/src/gitDelivery/requestGuards.test.ts b/packages/keiko-server/src/gitDelivery/requestGuards.test.ts new file mode 100644 index 000000000..95daab7af --- /dev/null +++ b/packages/keiko-server/src/gitDelivery/requestGuards.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { GIT_DELIVERY_PATHSPEC_CONTROL_CHAR, isContainedPathspec } from "./requestGuards.js"; + +describe("isContainedPathspec", () => { + it("accepts contained relative pathspecs", () => { + expect(isContainedPathspec("src/index.ts")).toBe(true); + expect(isContainedPathspec("a/b/c.txt")).toBe(true); + expect(isContainedPathspec("file.with.dots")).toBe(true); + expect(isContainedPathspec("dir/my file.txt")).toBe(true); // a literal space is a valid filename char + }); + + it("rejects C0 control characters (TAB, LF, CR)", () => { + expect(isContainedPathspec("src/\tindex.ts")).toBe(false); + expect(isContainedPathspec("src/index.ts\n")).toBe(false); + expect(isContainedPathspec("src\r/index.ts")).toBe(false); + }); + + it("rejects the NUL byte and DEL alongside other C0 controls", () => { + expect(isContainedPathspec("src/\0index.ts")).toBe(false); + expect(isContainedPathspec("src/\x7findex.ts")).toBe(false); + expect(isContainedPathspec("\x01\x02\x1f")).toBe(false); + }); + + it("keeps rejecting the pre-existing unsafe shapes", () => { + expect(isContainedPathspec("")).toBe(false); + expect(isContainedPathspec("-flag")).toBe(false); + expect(isContainedPathspec("/absolute")).toBe(false); + expect(isContainedPathspec("C:\\windows")).toBe(false); + expect(isContainedPathspec("../escape")).toBe(false); + expect(isContainedPathspec(42)).toBe(false); + }); + + it("matches the C0 control range symmetrically with the network-ref guard", () => { + expect(GIT_DELIVERY_PATHSPEC_CONTROL_CHAR.test("\t")).toBe(true); + expect(GIT_DELIVERY_PATHSPEC_CONTROL_CHAR.test("\n")).toBe(true); + expect(GIT_DELIVERY_PATHSPEC_CONTROL_CHAR.test("\r")).toBe(true); + expect(GIT_DELIVERY_PATHSPEC_CONTROL_CHAR.test("\x00")).toBe(true); + expect(GIT_DELIVERY_PATHSPEC_CONTROL_CHAR.test("\x7f")).toBe(true); + expect(GIT_DELIVERY_PATHSPEC_CONTROL_CHAR.test("ok")).toBe(false); + }); +}); diff --git a/packages/keiko-server/src/gitDelivery/requestGuards.ts b/packages/keiko-server/src/gitDelivery/requestGuards.ts index a7b80370c..7eea66dd7 100644 Binary files a/packages/keiko-server/src/gitDelivery/requestGuards.ts and b/packages/keiko-server/src/gitDelivery/requestGuards.ts differ diff --git a/packages/keiko-server/src/gitDelivery/syncEvidence.test.ts b/packages/keiko-server/src/gitDelivery/syncEvidence.test.ts new file mode 100644 index 000000000..a7b7736de --- /dev/null +++ b/packages/keiko-server/src/gitDelivery/syncEvidence.test.ts @@ -0,0 +1,218 @@ +// Unit tests for the fetch/pull sync evidence ledger (Issue #1573, Epic #1572). +// +// Mirror the mutation-ledger guarantees: ONE date-bucketed document per UTC day, append-and-bound, +// `update ?? get+put`, leaf-level redaction, fail-closed on corruption, never throws into the caller, +// and a content-free repository identifier (hash, never the path). + +import { describe, expect, it } from "vitest"; +import type { EvidenceStore } from "@oscharko-dev/keiko-evidence"; +import { sha256Hex } from "@oscharko-dev/keiko-security"; +import { + gitSyncEvidenceRunIdFor, + gitSyncRepoIdHash, + recordGitSyncEvidence, + GIT_SYNC_EVIDENCE_RUNID_PREFIX, + GIT_SYNC_EVIDENCE_SCHEMA_VERSION, + type GitSyncEvidenceLedgerDoc, + type GitSyncEvidenceRecord, +} from "./syncEvidence.js"; + +const AT = Date.UTC(2026, 5, 27, 9, 30, 0); // 2026-06-27T09:30:00Z + +function record(overrides: Partial = {}): GitSyncEvidenceRecord { + return { + schemaVersion: GIT_SYNC_EVIDENCE_SCHEMA_VERSION, + operation: "fetch", + outcome: "succeeded", + repoIdHash: "0123456789abcdef01234567", + branch: "main", + remote: "origin", + aheadBefore: 0, + behindBefore: 2, + aheadAfter: 0, + behindAfter: 0, + recordedAtMs: AT, + ...overrides, + }; +} + +function inMemoryStore(): { store: EvidenceStore; docs: Map } { + const docs = new Map(); + return { + docs, + store: { + put: (runId, json): string => { + docs.set(runId, json); + return runId; + }, + list: () => [...docs.keys()], + get: (runId) => docs.get(runId), + delete: (runId) => docs.delete(runId), + }, + }; +} + +// A store that exercises the update() serialize path instead of get+put. +function updatingStore(): { store: EvidenceStore; docs: Map } { + const base = inMemoryStore(); + return { + docs: base.docs, + store: { + ...base.store, + update: (runId, update): string => { + const next = update(base.docs.get(runId)); + base.docs.set(runId, next); + return runId; + }, + }, + }; +} + +function bucket(docs: Map, runId: string): GitSyncEvidenceLedgerDoc { + const json = docs.get(runId); + if (json === undefined) throw new Error("bucket missing"); + return JSON.parse(json) as GitSyncEvidenceLedgerDoc; +} + +const identityRedact = (input: string): string => input; + +describe("gitSyncEvidenceRunIdFor", () => { + it("buckets by UTC date with the sync prefix", () => { + expect(gitSyncEvidenceRunIdFor(AT)).toBe(`${GIT_SYNC_EVIDENCE_RUNID_PREFIX}2026-06-27`); + }); + + it("buckets two records on the same UTC day into the same runId", () => { + const later = AT + 6 * 60 * 60 * 1000; + expect(gitSyncEvidenceRunIdFor(later)).toBe(gitSyncEvidenceRunIdFor(AT)); + }); + + it("buckets the next UTC day separately", () => { + const next = AT + 24 * 60 * 60 * 1000; + expect(gitSyncEvidenceRunIdFor(next)).not.toBe(gitSyncEvidenceRunIdFor(AT)); + }); +}); + +describe("gitSyncRepoIdHash", () => { + it("is the 24-char prefix of sha256Hex(root) and never the path", () => { + const root = "/home/u/projects/secret-repo"; + const hash = gitSyncRepoIdHash(root); + expect(hash).toBe(sha256Hex(root).slice(0, 24)); + expect(hash.length).toBe(24); + expect(hash).not.toContain("secret-repo"); + }); +}); + +describe("recordGitSyncEvidence — persistence", () => { + it("writes one record into the day bucket via get+put", () => { + const { store, docs } = inMemoryStore(); + recordGitSyncEvidence({ evidenceStore: store, redactString: identityRedact }, record()); + const runId = gitSyncEvidenceRunIdFor(AT); + const doc = bucket(docs, runId); + expect(doc.schemaVersion).toBe(GIT_SYNC_EVIDENCE_SCHEMA_VERSION); + expect(doc.records).toHaveLength(1); + expect(doc.records[0]?.operation).toBe("fetch"); + expect(doc.records[0]?.outcome).toBe("succeeded"); + }); + + it("appends through the update() serialize path", () => { + const { store, docs } = updatingStore(); + recordGitSyncEvidence({ evidenceStore: store, redactString: identityRedact }, record()); + recordGitSyncEvidence( + { evidenceStore: store, redactString: identityRedact }, + record({ operation: "pull", outcome: "up-to-date" }), + ); + const doc = bucket(docs, gitSyncEvidenceRunIdFor(AT)); + expect(doc.records).toHaveLength(2); + expect(doc.records[1]?.operation).toBe("pull"); + }); + + it("bounds the bucket to the most recent N records", () => { + const { store, docs } = inMemoryStore(); + for (let i = 0; i < 5; i += 1) { + recordGitSyncEvidence( + { evidenceStore: store, redactString: identityRedact, maxRecordsPerBucket: 3 }, + record({ behindBefore: i }), + ); + } + const doc = bucket(docs, gitSyncEvidenceRunIdFor(AT)); + expect(doc.records).toHaveLength(3); + // The oldest two (behindBefore 0,1) were evicted; the most recent three remain. + expect(doc.records.map((r) => r.behindBefore)).toEqual([2, 3, 4]); + }); + + it("applies the string-leaf redactor to every record string", () => { + const { store, docs } = inMemoryStore(); + const redact = (input: string): string => (input === "origin" ? "[redacted]" : input); + recordGitSyncEvidence({ evidenceStore: store, redactString: redact }, record()); + const doc = bucket(docs, gitSyncEvidenceRunIdFor(AT)); + expect(doc.records[0]?.remote).toBe("[redacted]"); + expect(doc.records[0]?.branch).toBe("main"); + }); +}); + +describe("recordGitSyncEvidence — best-effort and fail-closed", () => { + it("never throws and reports a put failure through onPersistError", () => { + let captured: unknown; + const store: EvidenceStore = { + put: () => { + throw new Error("disk full"); + }, + list: () => [], + get: () => undefined, + delete: () => undefined, + }; + expect(() => { + recordGitSyncEvidence( + { + evidenceStore: store, + redactString: identityRedact, + onPersistError: (error) => { + captured = error; + }, + }, + record(), + ); + }).not.toThrow(); + expect(captured).toBeInstanceOf(Error); + }); + + it("fails closed on a corrupt bucket and preserves the existing artifact", () => { + const { store, docs } = inMemoryStore(); + const runId = gitSyncEvidenceRunIdFor(AT); + docs.set(runId, "{not-json"); + let captured: unknown; + recordGitSyncEvidence( + { + evidenceStore: store, + redactString: identityRedact, + onPersistError: (error) => { + captured = error; + }, + }, + record(), + ); + expect(captured).toBeInstanceOf(Error); + // The corrupt artifact is untouched — not overwritten with a fresh bucket. + expect(docs.get(runId)).toBe("{not-json"); + }); + + it("fails closed on a structurally wrong bucket (records not an array)", () => { + const { store, docs } = inMemoryStore(); + const runId = gitSyncEvidenceRunIdFor(AT); + docs.set(runId, JSON.stringify({ schemaVersion: "1", records: "nope" })); + let captured: unknown; + recordGitSyncEvidence( + { + evidenceStore: store, + redactString: identityRedact, + onPersistError: (error) => { + captured = error; + }, + }, + record(), + ); + expect(captured).toBeInstanceOf(Error); + const preserved = JSON.parse(docs.get(runId) ?? "{}") as { records?: unknown }; + expect(preserved.records).toBe("nope"); + }); +}); diff --git a/packages/keiko-server/src/gitDelivery/syncEvidence.ts b/packages/keiko-server/src/gitDelivery/syncEvidence.ts new file mode 100644 index 000000000..88ddb83b9 --- /dev/null +++ b/packages/keiko-server/src/gitDelivery/syncEvidence.ts @@ -0,0 +1,145 @@ +// Content-free fetch/pull sync EVIDENCE ledger (Issue #1573, Epic #1572). +// +// fetch/pull do NOT enter the #472 kernel / #474 mutation ledger (that taxonomy is frozen), so this is +// a dedicated SIBLING of mutationEvidenceLedger.ts that records the terminal outcome of every executed +// sync. Persistence shape mirrors the mutation ledger exactly: ONE document per UTC date bucket (runId +// `git-sync-evidence-YYYY-MM-DD`), serialized through `EvidenceStore.update ?? get+put`, redacted leaf +// by leaf with `deepRedactStrings`, bounded to the most recent N records, and best-effort — an audit +// write must NEVER break the user's sync. Corrupt buckets fail closed and are never overwritten. +// +// The repository is identified only by `sha256Hex(workspace.root).slice(0,24)` — a content-free hash, +// never the path itself. Records carry counts, the typed outcome, and branch/remote NAMES only; no +// URLs, secrets, or command output. + +import type { GitSyncOperation, GitSyncOutcome } from "@oscharko-dev/keiko-contracts"; +import { deepRedactStrings } from "@oscharko-dev/keiko-evidence"; +import type { EvidenceStore } from "@oscharko-dev/keiko-evidence"; +import { sha256Hex } from "@oscharko-dev/keiko-security"; + +export const GIT_SYNC_EVIDENCE_RUNID_PREFIX = "git-sync-evidence-" as const; + +// Default bound on records retained per UTC date bucket. The most recent records are kept. +export const GIT_SYNC_EVIDENCE_DEFAULT_BUCKET_CAP = 500; + +export const GIT_SYNC_EVIDENCE_SCHEMA_VERSION = "1" as const; + +export interface GitSyncEvidenceRecord { + readonly schemaVersion: typeof GIT_SYNC_EVIDENCE_SCHEMA_VERSION; + readonly operation: GitSyncOperation; + readonly outcome: GitSyncOutcome; + // sha256Hex(workspace.root).slice(0, 24) — content-free repository identifier. + readonly repoIdHash: string; + readonly branch?: string | undefined; + readonly remote?: string | undefined; + readonly aheadBefore?: number | undefined; + readonly behindBefore?: number | undefined; + readonly aheadAfter?: number | undefined; + readonly behindAfter?: number | undefined; + readonly recordedAtMs: number; +} + +export interface GitSyncEvidenceLedgerDoc { + readonly schemaVersion: typeof GIT_SYNC_EVIDENCE_SCHEMA_VERSION; + readonly records: readonly GitSyncEvidenceRecord[]; +} + +// Content-free repository identifier from the workspace root path. Never the path itself. +export function gitSyncRepoIdHash(workspaceRoot: string): string { + return sha256Hex(workspaceRoot).slice(0, 24); +} + +// UTC date-bucket runId. `git-sync-evidence-` (17) + `YYYY-MM-DD` (10) = 27, well under the +// evidence-store run-id length cap. +export function gitSyncEvidenceRunIdFor(nowMs: number): string { + const iso = new Date(nowMs).toISOString(); + return `${GIT_SYNC_EVIDENCE_RUNID_PREFIX}${iso.slice(0, 10)}`; +} + +export interface RecordGitSyncEvidenceOptions { + readonly evidenceStore: EvidenceStore; + // String-leaf redactor (deps.redactString / the audit redactor). Defence-in-depth over the + // by-construction content-free record shape. + readonly redactString: (input: string) => string; + readonly maxRecordsPerBucket?: number | undefined; + // Persistence-error sink; defaults to console.error. An audit-write failure never throws here. + readonly onPersistError?: ((error: unknown) => void) | undefined; +} + +function defaultOnPersistError(error: unknown): void { + // eslint-disable-next-line no-console + console.error("git-sync evidence ledger: persistence failed", error); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// Parses an existing bucket document. Returns the records faithfully (they were written valid). +// Throws on gross corruption so the caller fails closed and preserves the artifact. +function parseExistingRecords(json: string | undefined): readonly GitSyncEvidenceRecord[] { + if (json === undefined) { + return []; + } + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch (error) { + throw new Error( + "git-sync evidence ledger is corrupt; refusing to overwrite existing audit evidence", + { cause: error }, + ); + } + if (!isPlainObject(parsed) || !Array.isArray(parsed.records)) { + throw new Error( + "git-sync evidence ledger has an unexpected shape; refusing to overwrite existing audit evidence", + ); + } + return parsed.records as readonly GitSyncEvidenceRecord[]; +} + +function boundedDoc( + existing: readonly GitSyncEvidenceRecord[], + record: GitSyncEvidenceRecord, + cap: number, +): GitSyncEvidenceLedgerDoc { + const records = [...existing, record]; + return { + schemaVersion: GIT_SYNC_EVIDENCE_SCHEMA_VERSION, + records: cap > 0 && records.length > cap ? records.slice(records.length - cap) : records, + }; +} + +function appendRecord( + store: EvidenceStore, + runId: string, + record: GitSyncEvidenceRecord, + cap: number, +): void { + const append = (existingJson: string | undefined): string => + JSON.stringify(boundedDoc(parseExistingRecords(existingJson), record, cap)); + if (store.update !== undefined) { + store.update(runId, append); + return; + } + store.put(runId, append(store.get(runId))); +} + +/** + * Appends one fetch/pull sync evidence record to its date-bucketed ledger. Redacts every string leaf, + * bounds the bucket, and never throws into the caller's path. Returns nothing — audit recording is + * best-effort and is reported (not propagated) on failure. + */ +export function recordGitSyncEvidence( + options: RecordGitSyncEvidenceOptions, + record: GitSyncEvidenceRecord, +): void { + const onPersistError = options.onPersistError ?? defaultOnPersistError; + const cap = options.maxRecordsPerBucket ?? GIT_SYNC_EVIDENCE_DEFAULT_BUCKET_CAP; + const safe = deepRedactStrings(record, options.redactString) as GitSyncEvidenceRecord; + const runId = gitSyncEvidenceRunIdFor(record.recordedAtMs); + try { + appendRecord(options.evidenceStore, runId, safe, cap); + } catch (error) { + onPersistError(error); + } +} diff --git a/packages/keiko-server/src/gitDelivery/syncExecution.ts b/packages/keiko-server/src/gitDelivery/syncExecution.ts new file mode 100644 index 000000000..3936db57f --- /dev/null +++ b/packages/keiko-server/src/gitDelivery/syncExecution.ts @@ -0,0 +1,355 @@ +// Bounded fetch/pull sync execution core (Issue #1573, Epic #1572). +// +// fetch/pull are NOT governed mutations: they have no `GitDeliveryActionKind` and do NOT enter the +// #472 `runGitMutation` kernel. That taxonomy is frozen (#1572 reuse contract §3); widening it to add +// fetch/pull would weaken the governed control plane. Instead this module mirrors the push route +// STRUCTURE while reusing the hardened, fixed-arg process runner (`defaultGitProcessRunner`) and the +// shared porcelain-v2 parser. Two operations: +// +// * buildSyncPreview — READ-ONLY readiness: parse `status --porcelain=v2 --branch` + remote names to +// compute branch/detached/upstream/ahead/behind/hasRemote/hasUpstream/dirty + an executable gate +// and a typed blockReason. +// * runSyncExecute — requires an executable preview before running the bounded fetch/pull command, +// classifies exitCode/stderr/stdout/truncated, and re-reads ahead/behind after success. +// +// Pure parsing lives in gitPorcelainStatus.ts; this module owns only the bounded process effect and +// the deterministic outcome classifier. Both are seam-injectable for tests. + +import type { + GitSyncBlockReason, + GitSyncOperation, + GitSyncOutcome, + GitUpstreamSummary, +} from "@oscharko-dev/keiko-contracts"; +import { GIT_SYNC_SCHEMA_VERSION } from "@oscharko-dev/keiko-contracts"; +import type { GitSyncPreview } from "@oscharko-dev/keiko-contracts"; +import { + defaultGitNetworkProcessRunner, + defaultGitProcessRunner, + type GitProcessResult, + type GitProcessRunner, +} from "../gitRoutes.js"; +import { parsePorcelainV2Branch, type PorcelainV2Status } from "../gitPorcelainStatus.js"; + +const DEFAULT_SYNC_MAX_BYTES = 128 * 1024; +const DEFAULT_SYNC_TIMEOUT_MS = 30_000; + +export interface GitDeliverySyncSeams { + readonly runner?: GitProcessRunner | undefined; + readonly now?: (() => number) | undefined; + readonly maxBytes?: number | undefined; + readonly timeoutMs?: number | undefined; +} + +interface NormalizedSyncSeams { + // Local config-isolated reads (status / remote / post-op re-read): never authenticate. + readonly readRunner: GitProcessRunner; + // The actual `git fetch` / `git pull` network command: credential-capable, still fail-closed. + readonly networkRunner: GitProcessRunner; + readonly maxBytes: number; + readonly timeoutMs: number; +} + +// An injected `seams.runner` overrides BOTH runners so tests stay deterministic; in production the +// local reads use the hardened `defaultGitProcessRunner` while the fetch/pull command uses the +// credential-capable `defaultGitNetworkProcessRunner` (see networkGitEnv in gitRoutes.ts). +function normalizeSeams(seams: GitDeliverySyncSeams): NormalizedSyncSeams { + return { + readRunner: seams.runner ?? defaultGitProcessRunner, + networkRunner: seams.runner ?? defaultGitNetworkProcessRunner, + maxBytes: seams.maxBytes ?? DEFAULT_SYNC_MAX_BYTES, + timeoutMs: seams.timeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS, + }; +} + +function runWith( + runner: GitProcessRunner, + repoRoot: string, + seams: NormalizedSyncSeams, + args: readonly string[], +): Promise { + return runner(["--no-pager", "--no-optional-locks", "-C", repoRoot, ...args], { + cwd: repoRoot, + maxBytes: seams.maxBytes, + timeoutMs: seams.timeoutMs, + }); +} + +// Local read (status / remote / post-op re-read): config-isolated, never authenticates. +function runGit( + repoRoot: string, + seams: NormalizedSyncSeams, + args: readonly string[], +): Promise { + return runWith(seams.readRunner, repoRoot, seams, args); +} + +// The actual fetch/pull network command: credential-capable env, still GIT_TERMINAL_PROMPT=0. +function runNetworkGit( + repoRoot: string, + seams: NormalizedSyncSeams, + args: readonly string[], +): Promise { + return runWith(seams.networkRunner, repoRoot, seams, args); +} + +// `git remote` (names only — never URLs) tells us whether a fetch target exists at all. +function parseRemoteNames(stdout: string): readonly string[] { + return stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +// --- preview --------------------------------------------------------------- + +function hasRequestedRemote(remoteNames: readonly string[], remote: string | undefined): boolean { + return remote === undefined ? remoteNames.length > 0 : remoteNames.includes(remote); +} + +function previewBlockReason( + operation: GitSyncOperation, + status: PorcelainV2Status, + hasRemote: boolean, +): GitSyncBlockReason | undefined { + if (!hasRemote) return "no-remote"; + if (operation === "fetch") return undefined; + if (status.detached) return "detached-head"; + if (status.upstream === undefined) return "no-upstream"; + return undefined; +} + +function previewFor( + operation: GitSyncOperation, + status: PorcelainV2Status, + remote: string | undefined, + hasRemote: boolean, +): GitSyncPreview { + const blockReason = previewBlockReason(operation, status, hasRemote); + return { + schemaVersion: GIT_SYNC_SCHEMA_VERSION, + operation, + available: true, + state: "available", + branch: status.branch, + detached: status.detached, + upstream: status.upstream, + remote, + ahead: status.ahead, + behind: status.behind, + hasRemote, + hasUpstream: status.upstream !== undefined, + dirty: status.dirty, + executable: blockReason === undefined, + blockReason, + }; +} + +/** + * Read-only fetch/pull readiness. Runs `status --porcelain=v2 --branch -z` + `git remote` (names only) + * and projects a content-free preview with an executable gate. Throws only when the status read fails + * (not a repository / unsafe owner); the caller maps that to a 409. + */ +export async function buildSyncPreview( + operation: GitSyncOperation, + repoRoot: string, + remote: string | undefined, + seams: GitDeliverySyncSeams = {}, +): Promise { + const normalized = normalizeSeams(seams); + const status = await runGit(repoRoot, normalized, [ + "status", + "--porcelain=v2", + "--branch", + "-z", + "--untracked-files=all", + ]); + if (status.exitCode !== 0) { + throw new Error("git status failed for the sync preview"); + } + const parsed = parsePorcelainV2Branch(status.stdout); + const remotesResult = await runGit(repoRoot, normalized, ["remote"]); + const remoteNames = remotesResult.exitCode === 0 ? parseRemoteNames(remotesResult.stdout) : []; + return previewFor(operation, parsed, remote, hasRequestedRemote(remoteNames, remote)); +} + +// --- execute --------------------------------------------------------------- + +export interface SyncExecuteResult { + readonly outcome: GitSyncOutcome; + readonly branch?: string | undefined; + readonly upstream?: GitUpstreamSummary | undefined; + readonly ahead?: number | undefined; + readonly behind?: number | undefined; + readonly truncated: boolean; +} + +function syncArgs(operation: GitSyncOperation, remote: string | undefined): readonly string[] { + const remoteArgs = remote === undefined ? [] : [remote]; + return operation === "fetch" + ? ["fetch", "--no-tags", ...remoteArgs] + : ["pull", "--ff-only", "--no-edit", ...remoteArgs]; +} + +// Classifies the most-specific failure the stderr surfaces. Order matters: ownership, host trust, and +// auth checks precede generic remote/repository checks so trust and credential failures stay precise. +function isUnsafeRepositoryStderr(text: string): boolean { + return text.includes("dubious ownership") || text.includes("safe.directory"); +} + +function isUntrustedHostKeyStderr(text: string): boolean { + return ( + text.includes("host key verification failed") || + text.includes("remote host identification has changed") || + text.includes("strict host key checking") || + text.includes("offending key") + ); +} + +function isAuthFailedStderr(text: string): boolean { + return ( + text.includes("could not read username") || + text.includes("authentication failed") || + text.includes("permission denied") || + text.includes("could not read from remote") || + text.includes("terminal prompts disabled") + ); +} + +function isNoRemoteStderr(text: string): boolean { + return text.includes("no such remote") || text.includes("does not appear to be a git repository"); +} + +function classifyStderr(stderr: string): GitSyncOutcome | undefined { + const text = stderr.toLowerCase(); + if (isUnsafeRepositoryStderr(text)) return "unsafe-repository"; + if (isUntrustedHostKeyStderr(text)) return "untrusted-host-key"; + if (isAuthFailedStderr(text)) return "auth-failed"; + if (isNoRemoteStderr(text)) return "no-remote"; + return undefined; +} + +// Pull-only refusal reasons layered on top of the shared classifier. +function classifyPullStderr(stderr: string): GitSyncOutcome | undefined { + const text = stderr.toLowerCase(); + // A pull on a detached HEAD aborts with "You are not currently on a branch." — the execute-side + // mirror of the preview block reason, keeping the sync taxonomy fully live from execute. + if (text.includes("not currently on a branch")) return "detached-head"; + if ( + text.includes("there is no tracking information") || + text.includes("no tracking information for the current branch") + ) { + return "no-upstream"; + } + if (text.includes("not possible to fast-forward")) return "not-fast-forward"; + if (text.includes("local changes") || text.includes("would be overwritten")) { + return "dirty-worktree"; + } + return undefined; +} + +function isAlreadyUpToDate(stdout: string): boolean { + return stdout.toLowerCase().includes("already up to date"); +} + +function classifyOutcome(operation: GitSyncOperation, result: GitProcessResult): GitSyncOutcome { + if (result.truncated) return "timeout"; + if (result.exitCode === 127) return "git-missing"; + const shared = classifyStderr(result.stderr); + if (shared !== undefined) return shared; + if (operation === "pull") { + const pullReason = classifyPullStderr(result.stderr); + if (pullReason !== undefined) return pullReason; + } + if (result.exitCode === 0) { + if (operation === "pull" && isAlreadyUpToDate(result.stdout)) return "up-to-date"; + return "succeeded"; + } + return "git-error"; +} + +function isSettledOk(outcome: GitSyncOutcome): boolean { + return outcome === "succeeded" || outcome === "up-to-date"; +} + +function blockedOutcomeFor(preview: GitSyncPreview): GitSyncOutcome { + switch (preview.blockReason) { + case "no-remote": + return "no-remote"; + case "no-upstream": + return "no-upstream"; + case "detached-head": + return "detached-head"; + case "git-missing": + return "git-missing"; + case "unsafe-repository": + return "unsafe-repository"; + case "unavailable": + case undefined: + return "git-error"; + } +} + +function blockedResultFor(preview: GitSyncPreview): SyncExecuteResult { + return { + outcome: blockedOutcomeFor(preview), + branch: preview.branch, + upstream: preview.upstream, + ahead: preview.ahead, + behind: preview.behind, + truncated: false, + }; +} + +// Re-reads branch/upstream/ahead/behind after a settled op so the response reflects the post-sync +// position. Best-effort: any failure tolerates and omits the counts. +async function readPostState( + repoRoot: string, + seams: NormalizedSyncSeams, +): Promise> { + try { + const status = await runGit(repoRoot, seams, [ + "status", + "--porcelain=v2", + "--branch", + "-z", + "--untracked-files=all", + ]); + if (status.exitCode !== 0) return {}; + const parsed = parsePorcelainV2Branch(status.stdout); + return { + branch: parsed.branch, + upstream: parsed.upstream, + ahead: parsed.ahead, + behind: parsed.behind, + }; + } catch { + return {}; + } +} + +/** + * Runs ONE bounded fetch/pull and classifies the outcome. Never governs (no kernel, no policy) — the + * control surface is the fixed argv + hardened env of the reused runner. After a settled op the + * branch/upstream/ahead/behind are re-read best-effort for the response. + */ +export async function runSyncExecute( + operation: GitSyncOperation, + repoRoot: string, + remote: string | undefined, + seams: GitDeliverySyncSeams = {}, + preflight?: GitSyncPreview, +): Promise { + const normalized = normalizeSeams(seams); + const preview = preflight ?? (await buildSyncPreview(operation, repoRoot, remote, seams)); + if (!preview.executable) return blockedResultFor(preview); + // ONLY the network fetch/pull uses the credential-capable runner; the post-state re-read below + // stays on the hardened local read runner. + const result = await runNetworkGit(repoRoot, normalized, syncArgs(operation, remote)); + const outcome = classifyOutcome(operation, result); + if (!isSettledOk(outcome)) { + return { outcome, truncated: result.truncated }; + } + const post = await readPostState(repoRoot, normalized); + return { outcome, ...post, truncated: result.truncated }; +} diff --git a/packages/keiko-server/src/gitDelivery/syncRoutes.test.ts b/packages/keiko-server/src/gitDelivery/syncRoutes.test.ts new file mode 100644 index 000000000..c493efebe --- /dev/null +++ b/packages/keiko-server/src/gitDelivery/syncRoutes.test.ts @@ -0,0 +1,616 @@ +// Route tests for the governed fetch/pull sync preview + execute routes (Issue #1573, Epic #1572). +// +// Drives the handlers at the BFF seam with an injected fake GitProcessRunner (no real git). Proves: +// * fetch/pull preview readiness + executable gate + typed blockReason. +// * fetch/pull execute outcome classification across the full taxonomy. +// * request hardening (404 unknown project, 400 bad/forbidden/extra-key/unsafe-ref, 413 oversize). +// * a content-free sync evidence record lands after execute (no URLs / secrets). + +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { EvidenceStore } from "@oscharko-dev/keiko-evidence"; +import type { GitSyncExecuteResponse, GitSyncPreview } from "@oscharko-dev/keiko-contracts"; +import { buildRedactor, createRunRegistry, type UiHandlerDeps } from "../index.js"; +import { createInMemoryUiStore, type UiStore } from "../store/index.js"; +import type { RouteContext } from "../routes.js"; +import type { GitProcessResult, GitProcessRunner } from "../gitRoutes.js"; +import { createHandleSyncExecute, createHandleSyncPreview } from "./syncRoutes.js"; +import type { GitDeliverySyncSeams } from "./syncExecution.js"; + +const FETCH_PREVIEW = "/api/git-delivery/fetch/preview"; +const FETCH_EXECUTE = "/api/git-delivery/fetch/execute"; +const PULL_PREVIEW = "/api/git-delivery/pull/preview"; +const PULL_EXECUTE = "/api/git-delivery/pull/execute"; + +// --- porcelain-v2 fixtures (NUL-separated) --------------------------------- + +interface StatusFixture { + readonly branch?: string; + readonly detached?: boolean; + readonly upstream?: string; + readonly ahead?: number; + readonly behind?: number; + readonly dirty?: boolean; +} + +function porcelain(fixture: StatusFixture = {}): string { + const lines: string[] = []; + lines.push( + fixture.detached ? "# branch.head (detached)" : `# branch.head ${fixture.branch ?? "main"}`, + ); + if (fixture.upstream !== undefined) lines.push(`# branch.upstream ${fixture.upstream}`); + if (fixture.ahead !== undefined || fixture.behind !== undefined) { + lines.push(`# branch.ab +${String(fixture.ahead ?? 0)} -${String(fixture.behind ?? 0)}`); + } + if (fixture.dirty === true) lines.push("1 M. N... 100644 100644 100644 aaa bbb file.txt"); + return `${lines.join("\0")}\0`; +} + +function ok(stdout: string, stderr = ""): GitProcessResult { + return { exitCode: 0, signal: null, stdout, stderr, truncated: false }; +} + +function fail(stderr: string, exitCode = 1, truncated = false): GitProcessResult { + return { exitCode, signal: null, stdout: "", stderr, truncated }; +} + +// --- scripted runner ------------------------------------------------------- + +interface RunnerScript { + readonly status?: GitProcessResult; + readonly remote?: GitProcessResult; + readonly fetch?: GitProcessResult; + readonly pull?: GitProcessResult; +} + +interface ScriptedRunner { + readonly runner: GitProcessRunner; + readonly calls: () => readonly string[]; + readonly args: () => readonly (readonly string[])[]; +} + +function subcommand(args: readonly string[]): string { + // args = ["--no-pager","--no-optional-locks","-C",root, , ...] + return args[4] ?? ""; +} + +function scriptedRunner(script: RunnerScript): ScriptedRunner { + const calls: string[] = []; + const argsSeen: string[][] = []; + const runner: GitProcessRunner = (args) => { + const cmd = subcommand(args); + argsSeen.push([...args]); + calls.push(cmd); + if (cmd === "status") return Promise.resolve(script.status ?? ok(porcelain())); + if (cmd === "remote") return Promise.resolve(script.remote ?? ok("origin\n")); + if (cmd === "fetch") return Promise.resolve(script.fetch ?? ok("")); + if (cmd === "pull") return Promise.resolve(script.pull ?? ok("Already up to date.\n")); + return Promise.resolve(ok("")); + }; + return { runner, calls: () => calls, args: () => argsSeen }; +} + +function seams(script: RunnerScript): GitDeliverySyncSeams { + return { runner: scriptedRunner(script).runner, now: () => 1_700_000_000_000 }; +} + +// --- evidence capture ------------------------------------------------------ + +interface EvidenceCapture { + readonly store: EvidenceStore; + readonly records: () => readonly Record[]; +} + +function capturingEvidenceStore(): EvidenceCapture { + const docs = new Map(); + return { + store: { + put: (runId, json): string => { + docs.set(runId, json); + return runId; + }, + list: () => [...docs.keys()], + get: (runId) => docs.get(runId), + delete: (runId) => docs.delete(runId), + }, + records: (): readonly Record[] => { + const out: Record[] = []; + for (const json of docs.values()) { + const doc = JSON.parse(json) as { records?: Record[] }; + if (Array.isArray(doc.records)) out.push(...doc.records); + } + return out; + }, + }; +} + +// --- harness --------------------------------------------------------------- + +let store: UiStore; +let projectId: string; + +function deps(overrides: Partial = {}): UiHandlerDeps { + return { + config: undefined, + configPresent: false, + evidenceStore: { put: () => "", list: () => [], get: () => undefined, delete: () => undefined }, + env: {}, + redactor: buildRedactor({}), + registry: createRunRegistry(), + modelPortFactory: () => undefined, + store, + ...overrides, + }; +} + +function ctxFor(path: string, body: unknown): RouteContext { + const raw = typeof body === "string" ? body : JSON.stringify(body); + const req = Readable.from([Buffer.from(raw, "utf8")]) as IncomingMessage; + req.method = "POST"; + req.headers = { "content-type": "application/json", "x-keiko-csrf": "1" }; + return { req, res: {} as ServerResponse, params: {}, url: new URL(`http://127.0.0.1${path}`) }; +} + +function syncBody(overrides: Record = {}): Record { + return { schemaVersion: "1", projectId, ...overrides }; +} + +beforeEach(() => { + store = createInMemoryUiStore(); + projectId = store.createProject(mkdtempSync(join(tmpdir(), "keiko-gd-sync-proj-"))).path; +}); + +afterEach(() => { + store.close(); +}); + +// ─── fetch preview ────────────────────────────────────────────────────────── + +describe("fetch preview — readiness", () => { + it("is executable when a remote exists", async () => { + const handler = createHandleSyncPreview("fetch", { + execution: seams({ status: ok(porcelain({ ahead: 1, behind: 0, upstream: "origin/main" })) }), + }); + const res = await handler(ctxFor(FETCH_PREVIEW, syncBody()), deps()); + expect(res.status).toBe(200); + const body = res.body as GitSyncPreview; + expect(body.operation).toBe("fetch"); + expect(body.hasRemote).toBe(true); + expect(body.executable).toBe(true); + expect(body.blockReason).toBeUndefined(); + }); + + it("blocks with no-remote when there is no remote", async () => { + const handler = createHandleSyncPreview("fetch", { + execution: seams({ remote: ok("") }), + }); + const res = await handler(ctxFor(FETCH_PREVIEW, syncBody()), deps()); + const body = res.body as GitSyncPreview; + expect(body.hasRemote).toBe(false); + expect(body.executable).toBe(false); + expect(body.blockReason).toBe("no-remote"); + }); + + it("blocks a requested remote that is not configured", async () => { + const handler = createHandleSyncPreview("fetch", { + execution: seams({ remote: ok("origin\n") }), + }); + const res = await handler(ctxFor(FETCH_PREVIEW, syncBody({ remote: "upstream" })), deps()); + const body = res.body as GitSyncPreview; + expect(body.remote).toBe("upstream"); + expect(body.hasRemote).toBe(false); + expect(body.executable).toBe(false); + expect(body.blockReason).toBe("no-remote"); + }); + + it("accepts a requested remote only when it is configured", async () => { + const handler = createHandleSyncPreview("fetch", { + execution: seams({ remote: ok("origin\nupstream\n") }), + }); + const res = await handler(ctxFor(FETCH_PREVIEW, syncBody({ remote: "upstream" })), deps()); + const body = res.body as GitSyncPreview; + expect(body.remote).toBe("upstream"); + expect(body.hasRemote).toBe(true); + expect(body.executable).toBe(true); + expect(body.blockReason).toBeUndefined(); + }); + + it("409s when the status read fails (not a repository)", async () => { + const handler = createHandleSyncPreview("fetch", { + execution: seams({ status: fail("fatal: not a git repository", 128) }), + }); + const res = await handler(ctxFor(FETCH_PREVIEW, syncBody()), deps()); + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ error: { code: "GIT_DELIVERY_SYNC_WORKTREE_UNAVAILABLE" } }); + }); +}); + +// ─── pull preview ─────────────────────────────────────────────────────────── + +describe("pull preview — readiness", () => { + it("is executable with ahead/behind reported when upstream is set", async () => { + const handler = createHandleSyncPreview("pull", { + execution: seams({ status: ok(porcelain({ upstream: "origin/main", ahead: 0, behind: 3 })) }), + }); + const res = await handler(ctxFor(PULL_PREVIEW, syncBody()), deps()); + const body = res.body as GitSyncPreview; + expect(body.executable).toBe(true); + expect(body.hasUpstream).toBe(true); + expect(body.behind).toBe(3); + expect(body.upstream?.ref).toBe("origin/main"); + expect(body.blockReason).toBeUndefined(); + }); + + it("blocks with detached-head on a detached HEAD", async () => { + const handler = createHandleSyncPreview("pull", { + execution: seams({ status: ok(porcelain({ detached: true })) }), + }); + const res = await handler(ctxFor(PULL_PREVIEW, syncBody()), deps()); + const body = res.body as GitSyncPreview; + expect(body.detached).toBe(true); + expect(body.executable).toBe(false); + expect(body.blockReason).toBe("detached-head"); + }); + + it("blocks with no-upstream when no tracking branch is set", async () => { + const handler = createHandleSyncPreview("pull", { + execution: seams({ status: ok(porcelain({ branch: "main" })) }), + }); + const res = await handler(ctxFor(PULL_PREVIEW, syncBody()), deps()); + const body = res.body as GitSyncPreview; + expect(body.hasUpstream).toBe(false); + expect(body.executable).toBe(false); + expect(body.blockReason).toBe("no-upstream"); + }); + + it("blocks with no-remote ahead of the upstream check", async () => { + const handler = createHandleSyncPreview("pull", { + execution: seams({ status: ok(porcelain({ upstream: "origin/main" })), remote: ok("") }), + }); + const res = await handler(ctxFor(PULL_PREVIEW, syncBody()), deps()); + expect((res.body as GitSyncPreview).blockReason).toBe("no-remote"); + }); + + it("stays executable with a dirty worktree when a tracking upstream exists", async () => { + // A dirty worktree does not block the readiness gate (the pull may still fast-forward, and a + // conflicting pull is reported at execute time as dirty-worktree, not blocked at preview). + const handler = createHandleSyncPreview("pull", { + execution: seams({ + status: ok(porcelain({ upstream: "origin/main", behind: 1, dirty: true })), + }), + }); + const res = await handler(ctxFor(PULL_PREVIEW, syncBody()), deps()); + const body = res.body as GitSyncPreview; + expect(body.dirty).toBe(true); + expect(body.executable).toBe(true); + expect(body.blockReason).toBeUndefined(); + }); +}); + +// ─── fetch execute ────────────────────────────────────────────────────────── + +async function runFetch( + script: RunnerScript, + store?: EvidenceStore, +): Promise { + const handler = createHandleSyncExecute("fetch", { execution: seams(script) }); + const res = await handler( + ctxFor(FETCH_EXECUTE, syncBody()), + deps(store ? { evidenceStore: store } : {}), + ); + return res.body as GitSyncExecuteResponse; +} + +describe("fetch execute — outcomes", () => { + it("reports succeeded on a clean fetch", async () => { + const body = await runFetch({ fetch: ok("") }); + expect(body.status).toBe("succeeded"); + expect(body.operation).toBe("fetch"); + }); + + it("reports auth-failed on a credential rejection", async () => { + const body = await runFetch({ + fetch: fail("fatal: Authentication failed for 'https://x'", 128), + }); + expect(body.status).toBe("auth-failed"); + }); + + it("reports untrusted-host-key when SSH host verification fails", async () => { + const body = await runFetch({ + fetch: fail("Host key verification failed.", 128), + }); + expect(body.status).toBe("untrusted-host-key"); + }); + + it("reports no-remote on an unknown remote", async () => { + const body = await runFetch({ + fetch: fail("fatal: 'up' does not appear to be a git repository", 128), + }); + expect(body.status).toBe("no-remote"); + }); + + it("reports timeout when the process truncated", async () => { + const body = await runFetch({ fetch: fail("", null as unknown as number, true) }); + expect(body.status).toBe("timeout"); + expect(body.truncated).toBe(true); + }); + + it("reports git-missing on exit code 127", async () => { + const body = await runFetch({ fetch: fail("git executable unavailable", 127) }); + expect(body.status).toBe("git-missing"); + }); + + it("reports unsafe-repository on dubious ownership", async () => { + const body = await runFetch({ + fetch: fail("fatal: detected dubious ownership in repository", 128), + }); + expect(body.status).toBe("unsafe-repository"); + }); + + it("reports git-error when a non-zero fetch matches no known stderr pattern", async () => { + const body = await runFetch({ fetch: fail("fatal: unknown internal error", 1) }); + expect(body.status).toBe("git-error"); + }); + + it("does not run fetch when preview blocks with no-remote", async () => { + const scripted = scriptedRunner({ remote: ok("") }); + const cap = capturingEvidenceStore(); + const handler = createHandleSyncExecute("fetch", { + execution: { runner: scripted.runner, now: () => 1_700_000_000_000 }, + }); + const res = await handler(ctxFor(FETCH_EXECUTE, syncBody()), deps({ evidenceStore: cap.store })); + const body = res.body as GitSyncExecuteResponse; + expect(body.status).toBe("no-remote"); + expect(scripted.calls()).toEqual(["status", "remote"]); + expect(cap.records()[0]?.outcome).toBe("no-remote"); + }); + + it("does not run fetch for a requested remote that is not configured", async () => { + const scripted = scriptedRunner({ remote: ok("origin\n") }); + const handler = createHandleSyncExecute("fetch", { + execution: { runner: scripted.runner, now: () => 1_700_000_000_000 }, + }); + const res = await handler(ctxFor(FETCH_EXECUTE, syncBody({ remote: "upstream" })), deps()); + const body = res.body as GitSyncExecuteResponse; + expect(body.status).toBe("no-remote"); + expect(scripted.calls()).toEqual(["status", "remote"]); + }); + + it("passes a configured requested remote as a remote alias", async () => { + const scripted = scriptedRunner({ remote: ok("origin\nupstream\n"), fetch: ok("") }); + const handler = createHandleSyncExecute("fetch", { + execution: { runner: scripted.runner, now: () => 1_700_000_000_000 }, + }); + const res = await handler(ctxFor(FETCH_EXECUTE, syncBody({ remote: "upstream" })), deps()); + const body = res.body as GitSyncExecuteResponse; + expect(body.status).toBe("succeeded"); + const fetchArgs = scripted.args().find((args) => subcommand(args) === "fetch"); + expect(fetchArgs).toBeDefined(); + expect(fetchArgs?.slice(-2)).toEqual(["--no-tags", "upstream"]); + }); + + it("does not run fetch when preview status fails", async () => { + const scripted = scriptedRunner({ status: fail("fatal: not a git repository", 128) }); + const handler = createHandleSyncExecute("fetch", { + execution: { runner: scripted.runner, now: () => 1_700_000_000_000 }, + }); + const res = await handler(ctxFor(FETCH_EXECUTE, syncBody()), deps()); + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ error: { code: "GIT_DELIVERY_SYNC_WORKTREE_UNAVAILABLE" } }); + expect(scripted.calls()).toEqual(["status"]); + }); +}); + +// ─── pull execute ─────────────────────────────────────────────────────────── + +async function runPull( + script: RunnerScript, + store?: EvidenceStore, +): Promise { + const readyScript: RunnerScript = { + status: ok(porcelain({ upstream: "origin/main", ahead: 0, behind: 1 })), + ...script, + }; + const handler = createHandleSyncExecute("pull", { execution: seams(readyScript) }); + const res = await handler( + ctxFor(PULL_EXECUTE, syncBody()), + deps(store ? { evidenceStore: store } : {}), + ); + return res.body as GitSyncExecuteResponse; +} + +describe("pull execute — outcomes", () => { + it("reports succeeded on a fast-forward pull", async () => { + const body = await runPull({ + pull: ok("Updating a1b2..c3d4\nFast-forward\n"), + status: ok(porcelain({ upstream: "origin/main", ahead: 0, behind: 0 })), + }); + expect(body.status).toBe("succeeded"); + expect(body.behind).toBe(0); + }); + + it("reports up-to-date when already up to date", async () => { + const body = await runPull({ pull: ok("Already up to date.\n") }); + expect(body.status).toBe("up-to-date"); + }); + + it("reports not-fast-forward when ff-only refuses", async () => { + const body = await runPull({ + pull: fail("fatal: Not possible to fast-forward, aborting.", 128), + }); + expect(body.status).toBe("not-fast-forward"); + }); + + it("reports dirty-worktree when local changes would be overwritten", async () => { + const body = await runPull({ + pull: fail( + "error: Your local changes to the following files would be overwritten by merge", + 1, + ), + }); + expect(body.status).toBe("dirty-worktree"); + }); + + it("reports no-upstream when there is no tracking information", async () => { + const body = await runPull({ + pull: fail("There is no tracking information for the current branch.", 1), + }); + expect(body.status).toBe("no-upstream"); + }); + + it("reports auth-failed on a credential rejection", async () => { + const body = await runPull({ + pull: fail("fatal: could not read Username for 'https://x'", 128), + }); + expect(body.status).toBe("auth-failed"); + }); + + it("reports untrusted-host-key when SSH host identity changes", async () => { + const body = await runPull({ + pull: fail("WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!", 128), + }); + expect(body.status).toBe("untrusted-host-key"); + }); + + it("reports timeout when the process truncated", async () => { + const body = await runPull({ pull: fail("", null as unknown as number, true) }); + expect(body.status).toBe("timeout"); + }); + + it("reports git-missing on exit code 127", async () => { + const body = await runPull({ pull: fail("git executable unavailable", 127) }); + expect(body.status).toBe("git-missing"); + }); + + it("reports unsafe-repository on dubious ownership", async () => { + const body = await runPull({ + pull: fail("fatal: detected dubious ownership in repository", 128), + }); + expect(body.status).toBe("unsafe-repository"); + }); + + it("reports detached-head when the pull aborts off a branch", async () => { + const body = await runPull({ + pull: fail("fatal: You are not currently on a branch.", 1), + }); + expect(body.status).toBe("detached-head"); + }); + + it("reports git-error when a non-zero pull matches no known stderr pattern", async () => { + const body = await runPull({ pull: fail("fatal: unknown internal error", 1) }); + expect(body.status).toBe("git-error"); + }); + + it("does not run pull when preview blocks with no-upstream", async () => { + const scripted = scriptedRunner({ status: ok(porcelain({ branch: "main" })) }); + const handler = createHandleSyncExecute("pull", { + execution: { runner: scripted.runner, now: () => 1_700_000_000_000 }, + }); + const res = await handler(ctxFor(PULL_EXECUTE, syncBody()), deps()); + const body = res.body as GitSyncExecuteResponse; + expect(body.status).toBe("no-upstream"); + expect(scripted.calls()).toEqual(["status", "remote"]); + }); + + it("does not run pull when preview blocks with detached-head", async () => { + const scripted = scriptedRunner({ status: ok(porcelain({ detached: true })) }); + const handler = createHandleSyncExecute("pull", { + execution: { runner: scripted.runner, now: () => 1_700_000_000_000 }, + }); + const res = await handler(ctxFor(PULL_EXECUTE, syncBody()), deps()); + const body = res.body as GitSyncExecuteResponse; + expect(body.status).toBe("detached-head"); + expect(scripted.calls()).toEqual(["status", "remote"]); + }); +}); + +// ─── request hardening ──────────────────────────────────────────────────────── + +describe("request hardening", () => { + it("404s an unknown project", async () => { + const handler = createHandleSyncPreview("fetch", { execution: seams({}) }); + const res = await handler(ctxFor(FETCH_PREVIEW, syncBody({ projectId: "/nope" })), deps()); + expect(res.status).toBe(404); + expect(res.body).toMatchObject({ error: { code: "GIT_DELIVERY_SYNC_UNKNOWN_PROJECT" } }); + }); + + it("400s a forbidden secret-shape payload", async () => { + const handler = createHandleSyncExecute("fetch", { execution: seams({}) }); + const res = await handler(ctxFor(FETCH_EXECUTE, syncBody({ remote: "api_keyleak" })), deps()); + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ error: { code: "GIT_DELIVERY_SYNC_FORBIDDEN_PAYLOAD" } }); + }); + + it("413s an oversize payload", async () => { + const handler = createHandleSyncExecute("fetch", { execution: seams({}) }); + const huge = "x".repeat(65 * 1024); + const res = await handler(ctxFor(FETCH_EXECUTE, syncBody({ projectId: huge })), deps()); + expect(res.status).toBe(413); + }); + + it("400s invalid JSON", async () => { + const handler = createHandleSyncExecute("fetch", { execution: seams({}) }); + const res = await handler(ctxFor(FETCH_EXECUTE, "{not json"), deps()); + expect(res.status).toBe(400); + }); + + it("400s an extra key outside the allowed set", async () => { + const handler = createHandleSyncExecute("fetch", { execution: seams({}) }); + const res = await handler(ctxFor(FETCH_EXECUTE, syncBody({ extra: 1 })), deps()); + expect(res.status).toBe(400); + }); + + it("400s a flag-injection remote ref (leading dash)", async () => { + const handler = createHandleSyncExecute("fetch", { execution: seams({}) }); + const res = await handler(ctxFor(FETCH_EXECUTE, syncBody({ remote: "-x" })), deps()); + expect(res.status).toBe(400); + }); + + it("400s a refspec-injection remote ref (contains colon)", async () => { + const handler = createHandleSyncExecute("fetch", { execution: seams({}) }); + const res = await handler(ctxFor(FETCH_EXECUTE, syncBody({ remote: "a:b" })), deps()); + expect(res.status).toBe(400); + }); + + it("400s a remote ref carrying a control character", async () => { + const handler = createHandleSyncExecute("fetch", { execution: seams({}) }); + const withControl = `origin${String.fromCharCode(0x1f)}`; + const res = await handler(ctxFor(FETCH_EXECUTE, syncBody({ remote: withControl })), deps()); + expect(res.status).toBe(400); + }); +}); + +// ─── evidence ─────────────────────────────────────────────────────────────── + +describe("evidence — content-free recording", () => { + it("records a content-free outcome after a fetch execute", async () => { + const cap = capturingEvidenceStore(); + await runFetch( + { fetch: ok(""), status: ok(porcelain({ upstream: "origin/main" })) }, + cap.store, + ); + const records = cap.records(); + expect(records).toHaveLength(1); + const rec = records[0] ?? {}; + expect(rec.operation).toBe("fetch"); + expect(rec.outcome).toBe("succeeded"); + expect(typeof rec.repoIdHash).toBe("string"); + // No URL / secret leaf — the record carries hashes, counts, and branch/remote NAMES only. + const serialized = JSON.stringify(rec); + expect(serialized).not.toContain("http"); + expect(serialized).not.toContain(projectId); + }); + + it("records evidence even when the op fails", async () => { + const cap = capturingEvidenceStore(); + await runPull({ pull: fail("fatal: Authentication failed", 128) }, cap.store); + const records = cap.records(); + expect(records).toHaveLength(1); + expect(records[0]?.outcome).toBe("auth-failed"); + expect(records[0]?.operation).toBe("pull"); + }); +}); diff --git a/packages/keiko-server/src/gitDelivery/syncRoutes.ts b/packages/keiko-server/src/gitDelivery/syncRoutes.ts new file mode 100644 index 000000000..8d3be004d --- /dev/null +++ b/packages/keiko-server/src/gitDelivery/syncRoutes.ts @@ -0,0 +1,288 @@ +// Governed fetch/pull sync routes: read-only preview + bounded execute (Issue #1573, Epic #1572). +// +// * POST /api/git-delivery/{fetch,pull}/preview — READ-ONLY. Projects the sync readiness envelope +// (branch / upstream / ahead / behind / hasRemote / hasUpstream / dirty + an executable gate and +// typed blockReason). Never mutates, never records evidence. +// * POST /api/git-delivery/{fetch,pull}/execute — Requires an executable preview before running +// ONE bounded fetch/pull through the credential-capable runner (NOT the #472 kernel — +// fetch/pull have no GitDeliveryActionKind) and appends a content-free sync evidence record for +// the terminal outcome. +// +// Mirrors pushRoutes.ts: the same bounded body read, allowed-key whitelist, credential-shape + +// unsafe-format-char scans, isSafeGitRef operand guard plus configured-remote preflight, +// content-free typed error envelope, and a `createGitDeliverySyncRouteGroup(options)` factory with +// an injectable execution seam. CSRF + JSON +// content type are enforced CENTRALLY by server.ts for POST, so they are NOT re-checked here. + +import type { IncomingMessage } from "node:http"; +import type { + GitSyncExecuteResponse, + GitSyncOperation, + GitSyncPreview, +} from "@oscharko-dev/keiko-contracts"; +import { GIT_SYNC_SCHEMA_VERSION } from "@oscharko-dev/keiko-contracts"; +import type { RouteContext, RouteDefinition, RouteResult } from "../routes.js"; +import type { UiHandlerDeps } from "../deps.js"; +import { resolveProjectWorkspace } from "./execution.js"; +import { + GitDeliveryBodyTooLargeError, + hasOnlyAllowedKeys, + isNonEmptyString, + isPlainObject, + readGitDeliveryBody, + scanForbiddenStrings, + scanUnsafeFormatChars, +} from "./requestGuards.js"; +import { + buildSyncPreview, + runSyncExecute, + type GitDeliverySyncSeams, + type SyncExecuteResult, +} from "./syncExecution.js"; +import { + gitSyncRepoIdHash, + recordGitSyncEvidence, + GIT_SYNC_EVIDENCE_SCHEMA_VERSION, + type GitSyncEvidenceRecord, +} from "./syncEvidence.js"; + +// ─── Error envelope ─────────────────────────────────────────────────────────────────────────── + +export type GitDeliverySyncErrorCode = + | "GIT_DELIVERY_SYNC_BAD_REQUEST" + | "GIT_DELIVERY_SYNC_PAYLOAD_TOO_LARGE" + | "GIT_DELIVERY_SYNC_FORBIDDEN_PAYLOAD" + | "GIT_DELIVERY_SYNC_UNKNOWN_PROJECT" + | "GIT_DELIVERY_SYNC_WORKTREE_UNAVAILABLE"; + +const SAFE_MESSAGES: Readonly> = { + GIT_DELIVERY_SYNC_BAD_REQUEST: "The request body is not a valid git sync request.", + GIT_DELIVERY_SYNC_PAYLOAD_TOO_LARGE: "The git sync request exceeds the maximum size.", + GIT_DELIVERY_SYNC_FORBIDDEN_PAYLOAD: + "The request contained a forbidden field. Requests may not carry credentials, headers, or URLs.", + GIT_DELIVERY_SYNC_UNKNOWN_PROJECT: "The requested project is not a known workspace.", + GIT_DELIVERY_SYNC_WORKTREE_UNAVAILABLE: + "The repository worktree could not be inspected. Confirm the project is a Git repository.", +}; + +const errResult = (status: number, code: GitDeliverySyncErrorCode): RouteResult => ({ + status, + body: { error: { code, message: SAFE_MESSAGES[code] } }, +}); + +// ─── Options ──────────────────────────────────────────────────────────────────────────────── + +export interface GitDeliverySyncRouteOptions { + readonly execution?: GitDeliverySyncSeams; +} + +type BodyRead = + | { readonly ok: true; readonly value: unknown } + | { readonly ok: false; readonly result: RouteResult }; + +async function readParsed(req: IncomingMessage): Promise { + let raw: string; + try { + raw = await readGitDeliveryBody(req); + } catch (error) { + const result = + error instanceof GitDeliveryBodyTooLargeError + ? errResult(413, "GIT_DELIVERY_SYNC_PAYLOAD_TOO_LARGE") + : errResult(400, "GIT_DELIVERY_SYNC_BAD_REQUEST"); + return { ok: false, result }; + } + try { + return { ok: true, value: JSON.parse(raw) }; + } catch { + return { ok: false, result: errResult(400, "GIT_DELIVERY_SYNC_BAD_REQUEST") }; + } +} + +// A remote alias operand: non-empty, no whitespace, no leading "-" (flag-injection guard), no ":" +// (refspec-injection guard), no NUL. Defence-in-depth so a malformed remote is a clean 400 rather than +// an internal execution error. +// eslint-disable-next-line no-control-regex -- intentionally matches control chars to REJECT them +const REF_CONTROL_CHAR = new RegExp("[\u0000-\u001f\u007f]"); + +function isSafeGitRef(value: unknown): value is string { + if (typeof value !== "string" || value.length === 0) return false; + if (/\s/.test(value)) return false; + if (value.startsWith("-")) return false; + if (REF_CONTROL_CHAR.test(value)) return false; + if (value.includes(":")) return false; + return true; +} + +const ALLOWED_KEYS: ReadonlySet = new Set(["schemaVersion", "projectId", "remote"]); + +interface ValidatedRequest { + readonly projectId: string; + readonly remote: string | undefined; +} + +type Validation = + | { readonly kind: "ok"; readonly value: ValidatedRequest } + | { readonly kind: "err"; readonly result: RouteResult }; + +// The credential-shape + unsafe-format-char boundary scans. Returns the typed error RouteResult or +// undefined when the payload is clean. +function scanError(parsed: Record): RouteResult | undefined { + if (scanForbiddenStrings(parsed)) { + return errResult(400, "GIT_DELIVERY_SYNC_FORBIDDEN_PAYLOAD"); + } + if (scanUnsafeFormatChars(parsed)) { + return errResult(400, "GIT_DELIVERY_SYNC_BAD_REQUEST"); + } + return undefined; +} + +function validate(parsed: unknown): Validation { + const bad: Validation = { kind: "err", result: errResult(400, "GIT_DELIVERY_SYNC_BAD_REQUEST") }; + if (!isPlainObject(parsed) || !hasOnlyAllowedKeys(parsed, ALLOWED_KEYS)) return bad; + if (parsed.schemaVersion !== GIT_SYNC_SCHEMA_VERSION || !isNonEmptyString(parsed.projectId)) { + return bad; + } + const scanErr = scanError(parsed); + if (scanErr !== undefined) return { kind: "err", result: scanErr }; + if (parsed.remote !== undefined && !isSafeGitRef(parsed.remote)) return bad; + return { kind: "ok", value: { projectId: parsed.projectId, remote: parsed.remote } }; +} + +// ─── Preview handler (read-only) ──────────────────────────────────────────────────────────────── + +export const createHandleSyncPreview = ( + operation: GitSyncOperation, + options: GitDeliverySyncRouteOptions = {}, +): ((ctx: RouteContext, deps: UiHandlerDeps) => Promise) => { + const seams = options.execution ?? {}; + return async (ctx, deps): Promise => { + const read = await readParsed(ctx.req); + if (!read.ok) return read.result; + const validation = validate(read.value); + if (validation.kind === "err") return validation.result; + const { projectId, remote } = validation.value; + const workspace = resolveProjectWorkspace(deps, projectId); + if (workspace === undefined) return errResult(404, "GIT_DELIVERY_SYNC_UNKNOWN_PROJECT"); + try { + const preview = await buildSyncPreview(operation, workspace.root, remote, seams); + return { status: 200, body: deps.redactor(preview) }; + } catch { + return errResult(409, "GIT_DELIVERY_SYNC_WORKTREE_UNAVAILABLE"); + } + }; +}; + +// ─── Execute handler (bounded fetch/pull) ─────────────────────────────────────────────────────── + +function redactStringFor(deps: Pick): (input: string) => string { + return (input: string): string => deps.redactor(input) as string; +} + +function executeResponse( + operation: GitSyncOperation, + remote: string | undefined, + result: SyncExecuteResult, +): GitSyncExecuteResponse { + return { + schemaVersion: GIT_SYNC_SCHEMA_VERSION, + operation, + status: result.outcome, + available: true, + branch: result.branch, + upstream: result.upstream, + remote, + ahead: result.ahead, + behind: result.behind, + truncated: result.truncated, + }; +} + +function evidenceRecord( + operation: GitSyncOperation, + remote: string | undefined, + repoIdHash: string, + before: GitSyncPreview | undefined, + result: SyncExecuteResult, + recordedAtMs: number, +): GitSyncEvidenceRecord { + return { + schemaVersion: GIT_SYNC_EVIDENCE_SCHEMA_VERSION, + operation, + outcome: result.outcome, + repoIdHash, + branch: result.branch ?? before?.branch, + remote, + aheadBefore: before?.ahead, + behindBefore: before?.behind, + aheadAfter: result.ahead, + behindAfter: result.behind, + recordedAtMs, + }; +} + +export const createHandleSyncExecute = ( + operation: GitSyncOperation, + options: GitDeliverySyncRouteOptions = {}, +): ((ctx: RouteContext, deps: UiHandlerDeps) => Promise) => { + const seams = options.execution ?? {}; + const now = (): number => (seams.now ?? Date.now)(); + return async (ctx, deps): Promise => { + const read = await readParsed(ctx.req); + if (!read.ok) return read.result; + const validation = validate(read.value); + if (validation.kind === "err") return validation.result; + const { projectId, remote } = validation.value; + const workspace = resolveProjectWorkspace(deps, projectId); + if (workspace === undefined) return errResult(404, "GIT_DELIVERY_SYNC_UNKNOWN_PROJECT"); + let before: GitSyncPreview; + try { + before = await buildSyncPreview(operation, workspace.root, remote, seams); + } catch { + return errResult(409, "GIT_DELIVERY_SYNC_WORKTREE_UNAVAILABLE"); + } + const result = await runSyncExecute(operation, workspace.root, remote, seams, before); + const record = evidenceRecord( + operation, + remote, + gitSyncRepoIdHash(workspace.root), + before, + result, + now(), + ); + recordGitSyncEvidence( + { evidenceStore: deps.evidenceStore, redactString: redactStringFor(deps) }, + record, + ); + return { status: 200, body: deps.redactor(executeResponse(operation, remote, result)) }; + }; +}; + +// ─── Route group ─────────────────────────────────────────────────────────────────────────────── + +export const createGitDeliverySyncRouteGroup = ( + options: GitDeliverySyncRouteOptions = {}, +): readonly RouteDefinition[] => [ + { + method: "POST", + pattern: "/api/git-delivery/fetch/preview", + handler: createHandleSyncPreview("fetch", options), + }, + { + method: "POST", + pattern: "/api/git-delivery/fetch/execute", + handler: createHandleSyncExecute("fetch", options), + }, + { + method: "POST", + pattern: "/api/git-delivery/pull/preview", + handler: createHandleSyncPreview("pull", options), + }, + { + method: "POST", + pattern: "/api/git-delivery/pull/execute", + handler: createHandleSyncExecute("pull", options), + }, +]; + +export const GIT_DELIVERY_SYNC_ROUTE_GROUP: readonly RouteDefinition[] = + createGitDeliverySyncRouteGroup(); diff --git a/packages/keiko-server/src/gitPorcelainStatus.test.ts b/packages/keiko-server/src/gitPorcelainStatus.test.ts new file mode 100644 index 000000000..e9fde5046 --- /dev/null +++ b/packages/keiko-server/src/gitPorcelainStatus.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { parsePorcelainV2Branch } from "./gitPorcelainStatus.js"; + +// Porcelain v2 records are NUL-separated under `-z`; helper joins them with the trailing NUL git emits. +function porcelain(records: readonly string[]): string { + return records.join("\0") + "\0"; +} + +describe("parsePorcelainV2Branch", () => { + it("parses a clean branch with upstream and ahead/behind", () => { + const result = parsePorcelainV2Branch( + porcelain([ + "# branch.oid abc123", + "# branch.head main", + "# branch.upstream origin/main", + "# branch.ab +2 -3", + ]), + ); + + expect(result).toMatchObject({ + branch: "main", + detached: false, + upstream: { ref: "origin/main", remote: "origin", branch: "main" }, + ahead: 2, + behind: 3, + dirty: false, + stagedCount: 0, + unstagedCount: 0, + untrackedCount: 0, + conflictedCount: 0, + }); + }); + + it("reports a detached HEAD with no branch name", () => { + const result = parsePorcelainV2Branch( + porcelain(["# branch.head (detached)", "# branch.ab +0 -0"]), + ); + + expect(result.detached).toBe(true); + expect(result.branch).toBeUndefined(); + }); + + it("omits upstream and zeroes ahead/behind when there is no tracking branch", () => { + const result = parsePorcelainV2Branch(porcelain(["# branch.head feature"])); + + expect(result.upstream).toBeUndefined(); + expect(result.ahead).toBe(0); + expect(result.behind).toBe(0); + }); + + it("counts staged, unstaged, untracked, and conflicted change records", () => { + const result = parsePorcelainV2Branch( + porcelain([ + "# branch.head main", + "1 M. N... 100644 100644 100644 aaa bbb src/staged.ts", + "1 .M N... 100644 100644 100644 ccc ddd src/dirty.ts", + "u UU N... 100644 100644 100644 100644 eee fff ggg src/conflict.ts", + "? src/new.ts", + ]), + ); + + expect(result).toMatchObject({ + dirty: true, + stagedCount: 1, + unstagedCount: 1, + conflictedCount: 1, + untrackedCount: 1, + }); + }); + + it("counts a record that is both staged and unstaged in both buckets", () => { + const result = parsePorcelainV2Branch( + porcelain(["# branch.head main", "1 MM N... 100644 100644 100644 aaa bbb src/both.ts"]), + ); + + expect(result.stagedCount).toBe(1); + expect(result.unstagedCount).toBe(1); + }); + + it("skips the extra NUL-separated original path of a rename record", () => { + // The original-path field is crafted to look like an untracked record (`? `) so that a mutation + // dropping the skip would over-count untracked entries and fail this assertion. + const result = parsePorcelainV2Branch( + porcelain([ + "# branch.head main", + "2 R. N... 100644 100644 100644 aaa bbb R100 src/new-name.ts", + "? src/old-name.ts", + "? src/new.ts", + ]), + ); + + expect(result.stagedCount).toBe(1); + expect(result.untrackedCount).toBe(1); + expect(result.dirty).toBe(true); + }); + + it("treats an empty porcelain payload as a clean worktree", () => { + const result = parsePorcelainV2Branch(porcelain(["# branch.head main"])); + + expect(result.dirty).toBe(false); + expect(result.stagedCount).toBe(0); + }); + + it("keeps the raw upstream ref when it has no remote/branch separator", () => { + const result = parsePorcelainV2Branch( + porcelain(["# branch.head main", "# branch.upstream weirdref"]), + ); + + expect(result.upstream).toEqual({ ref: "weirdref" }); + }); +}); diff --git a/packages/keiko-server/src/gitPorcelainStatus.ts b/packages/keiko-server/src/gitPorcelainStatus.ts new file mode 100644 index 000000000..6f9dbc0e7 --- /dev/null +++ b/packages/keiko-server/src/gitPorcelainStatus.ts @@ -0,0 +1,130 @@ +// Shared `git status --porcelain=v2 --branch -z` parser (Issue #1573, Epic #1572). +// Pure parsing only: no process, no filesystem, no clock. Centralizing the porcelain-v2 XY +// semantics keeps branch/ahead/behind and change-record counting audited in one place so the +// summary read route and the fetch/pull sync preview consume identical logic. + +import type { GitUpstreamSummary } from "@oscharko-dev/keiko-contracts"; + +export interface PorcelainV2Status { + readonly branch?: string | undefined; + readonly detached: boolean; + readonly upstream?: GitUpstreamSummary | undefined; + readonly ahead: number; + readonly behind: number; + readonly stagedCount: number; + readonly unstagedCount: number; + readonly untrackedCount: number; + readonly conflictedCount: number; + readonly dirty: boolean; +} + +interface PorcelainCounts { + staged: number; + unstaged: number; + untracked: number; + conflicted: number; +} + +interface PorcelainHeaders { + branch?: string | undefined; + detached: boolean; + upstream?: GitUpstreamSummary | undefined; + ahead: number; + behind: number; +} + +// Upstream ref splits on the first `/`: prefix is the remote, remainder the tracked branch. +function parseUpstreamRef(ref: string): GitUpstreamSummary { + const slash = ref.indexOf("/"); + if (slash <= 0 || slash >= ref.length - 1) { + return { ref }; + } + return { ref, remote: ref.slice(0, slash), branch: ref.slice(slash + 1) }; +} + +function parseAheadBehind(value: string): { readonly ahead: number; readonly behind: number } { + let ahead = 0; + let behind = 0; + for (const token of value.split(/\s+/u).filter((entry) => entry.length > 0)) { + const magnitude = Number.parseInt(token.slice(1), 10); + if (Number.isNaN(magnitude)) continue; + if (token.startsWith("+")) ahead = magnitude; + else if (token.startsWith("-")) behind = magnitude; + } + return { ahead, behind }; +} + +function applyHeader(record: string, headers: PorcelainHeaders): void { + const body = record.slice(2).trim(); + if (body.startsWith("branch.head ")) { + const name = body.slice("branch.head ".length).trim(); + if (name === "(detached)") headers.detached = true; + else if (name.length > 0) headers.branch = name; + return; + } + if (body.startsWith("branch.upstream ")) { + const ref = body.slice("branch.upstream ".length).trim(); + if (ref.length > 0) headers.upstream = parseUpstreamRef(ref); + return; + } + if (body.startsWith("branch.ab ")) { + const parsed = parseAheadBehind(body.slice("branch.ab ".length)); + headers.ahead = parsed.ahead; + headers.behind = parsed.behind; + } +} + +// Ordinary (`1`) and rename/copy (`2`) records carry XY at field offset 2..4 of the +// space-split tokens: X = index (staged) status, Y = worktree (unstaged) status. +function applyOrdinaryChange(record: string, counts: PorcelainCounts): void { + const xy = record.split(" ")[1] ?? ".."; + const index = xy[0] ?? "."; + const worktree = xy[1] ?? "."; + if (index !== ".") counts.staged += 1; + if (worktree !== ".") counts.unstaged += 1; +} + +function applyChangeRecord(record: string, counts: PorcelainCounts): void { + if (record.startsWith("1 ") || record.startsWith("2 ")) { + applyOrdinaryChange(record, counts); + } else if (record.startsWith("u ")) { + counts.conflicted += 1; + } else if (record.startsWith("? ")) { + counts.untracked += 1; + } +} + +// Rename/copy (`2`) records are followed by an extra NUL-separated original-path field that must +// be skipped so it is never mistaken for a change record. +function isRenameRecord(record: string): boolean { + return record.startsWith("2 "); +} + +export function parsePorcelainV2Branch(stdout: string): PorcelainV2Status { + const records = stdout.split("\0").filter((record) => record.length > 0); + const headers: PorcelainHeaders = { detached: false, ahead: 0, behind: 0 }; + const counts: PorcelainCounts = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0 }; + let changeRecords = 0; + for (let index = 0; index < records.length; index += 1) { + const record = records[index] ?? ""; + if (record.startsWith("# ")) { + applyHeader(record, headers); + continue; + } + applyChangeRecord(record, counts); + changeRecords += 1; + if (isRenameRecord(record)) index += 1; + } + return { + branch: headers.branch, + detached: headers.detached, + upstream: headers.upstream, + ahead: headers.ahead, + behind: headers.behind, + stagedCount: counts.staged, + unstagedCount: counts.unstaged, + untrackedCount: counts.untracked, + conflictedCount: counts.conflicted, + dirty: changeRecords > 0, + }; +} diff --git a/packages/keiko-server/src/gitRepositoryReads.test.ts b/packages/keiko-server/src/gitRepositoryReads.test.ts new file mode 100644 index 000000000..369f63084 --- /dev/null +++ b/packages/keiko-server/src/gitRepositoryReads.test.ts @@ -0,0 +1,784 @@ +import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildRedactor, + createInMemoryUiStore, + createRunRegistry, + type UiHandlerDeps, +} from "./index.js"; +import type { RouteContext } from "./routes.js"; +import type { UiStore } from "./store/index.js"; +import { handleGitHistory, handleGitRemotes, handleGitSummary } from "./gitRepositoryReads.js"; +import { gitEnv, networkGitEnv, type GitProcessRunner } from "./gitRoutes.js"; + +let root: string; +let store: UiStore; + +function deps(runner: GitProcessRunner, redactor = buildRedactor({})): UiHandlerDeps { + return { + config: undefined, + configPresent: false, + evidenceStore: { put: () => "", list: () => [], get: () => undefined, delete: () => undefined }, + env: {}, + redactor, + registry: createRunRegistry(), + modelPortFactory: () => undefined, + store, + gitRouteOptions: { runner, maxDiffBytes: 64, maxStatusBytes: 4096, maxChanges: 10 }, + }; +} + +function ctx(path: string): RouteContext { + return { + req: Readable.from([]) as unknown as IncomingMessage, + res: {} as unknown as ServerResponse, + params: {}, + url: new URL(`http://localhost${path}`), + }; +} + +const ok = (stdout: string): Awaited> => ({ + exitCode: 0, + signal: null, + stdout, + stderr: "", + truncated: false, +}); + +const fail = (stderr: string, exitCode = 128): Awaited> => ({ + exitCode, + signal: null, + stdout: "", + stderr, + truncated: false, +}); + +// Porcelain v2 NUL-separated payload helper (matches `status --porcelain=v2 --branch -z`). +function porcelain(records: readonly string[]): string { + return records.join("\0") + "\0"; +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "keiko-git-reads-")); + store = createInMemoryUiStore(); + store.createProject(root, "fixture"); +}); + +afterEach(async () => { + store.close(); + await rm(root, { recursive: true, force: true }); +}); + +describe("GET /api/git/summary", () => { + it("summarizes a clean repository with upstream, remote aliases, and last-sync metadata", async () => { + // FETCH_HEAD must resolve UNDER the repository root for lastSync to be reported (containment + // guard). Create a real file inside the repo and return its realpath from the rev-parse mock, + // matching the realpath'd repositoryRoot that resolveRepository computes. + const fetchHead = join(root, "FETCH_HEAD"); + await writeFile(fetchHead, "0".repeat(40)); + const realFetchHead = await realpath(fetchHead); + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce( + ok(porcelain(["# branch.head main", "# branch.upstream origin/main", "# branch.ab +0 -0"])), + ) + .mockResolvedValueOnce( + ok( + "origin\thttps://example.invalid/repo.git (fetch)\norigin\thttps://example.invalid/repo.git (push)\n", + ), + ) + .mockResolvedValueOnce(ok(`${realFetchHead}\n`)); + + const result = await handleGitSummary( + ctx(`/api/git/summary?root=${encodeURIComponent(root)}`), + deps(runner, (value) => value), + ); + + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ + schemaVersion: "1", + state: "available", + available: true, + branch: "main", + detached: false, + upstream: { ref: "origin/main", remote: "origin", branch: "main" }, + ahead: 0, + behind: 0, + clean: true, + remotes: [ + { + name: "origin", + }, + ], + }); + expect(JSON.stringify(result.body)).not.toContain("https://example.invalid/repo.git"); + // The in-repo FETCH_HEAD exists and is contained, so lastSync is reported. + expect((result.body as { lastSync?: unknown }).lastSync).toBeDefined(); + }); + + it("omits lastSync when the FETCH_HEAD path resolves outside the repository root", async () => { + // A FETCH_HEAD path outside the repo (e.g. a manipulated rev-parse result) must be rejected by + // the containment guard even though the file exists and is stat-able. The file lives in its own + // temp dir that is a sibling of the repo root, so it is genuinely out of tree. + const outsideDir = await mkdtemp(join(tmpdir(), "keiko-git-reads-outside-")); + const outside = join(outsideDir, "FETCH_HEAD"); + await writeFile(outside, "0".repeat(40)); + const realOutside = await realpath(outside); + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce(ok(porcelain(["# branch.head main"]))) + .mockResolvedValueOnce(ok("")) + .mockResolvedValueOnce(ok(`${realOutside}\n`)); + + try { + const result = await handleGitSummary( + ctx(`/api/git/summary?root=${encodeURIComponent(root)}`), + deps(runner, (value) => value), + ); + expect((result.body as { lastSync?: unknown }).lastSync).toBeUndefined(); + } finally { + await rm(outsideDir, { recursive: true, force: true }); + } + }); + + it("counts staged, unstaged, untracked, and conflicted entries on a dirty worktree", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce( + ok( + porcelain([ + "# branch.head main", + "# branch.ab +1 -2", + "1 M. N... 100644 100644 100644 aaa bbb src/staged.ts", + "1 .M N... 100644 100644 100644 ccc ddd src/dirty.ts", + "u UU N... 100644 100644 100644 100644 eee fff ggg src/conflict.ts", + "? src/new.ts", + ]), + ), + ) + .mockResolvedValueOnce(ok("")) + .mockResolvedValueOnce(fail("missing", 1)); + + const result = await handleGitSummary( + ctx(`/api/git/summary?root=${encodeURIComponent(root)}`), + deps(runner, (value) => value), + ); + + expect(result.body).toMatchObject({ + available: true, + clean: false, + ahead: 1, + behind: 2, + stagedCount: 1, + unstagedCount: 1, + untrackedCount: 1, + conflictedCount: 1, + remotes: [], + }); + }); + + it("reports no upstream as zeroed ahead/behind and undefined upstream", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce(ok(porcelain(["# branch.head feature"]))) + .mockResolvedValueOnce(ok("")) + .mockResolvedValueOnce(fail("missing", 1)); + + const result = await handleGitSummary( + ctx(`/api/git/summary?root=${encodeURIComponent(root)}`), + deps(runner, (value) => value), + ); + + expect(result.body).toMatchObject({ branch: "feature", ahead: 0, behind: 0 }); + expect((result.body as { upstream?: unknown }).upstream).toBeUndefined(); + }); + + it("reports a detached HEAD", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce(ok(porcelain(["# branch.head (detached)"]))) + .mockResolvedValueOnce(ok("")) + .mockResolvedValueOnce(fail("missing", 1)); + + const result = await handleGitSummary( + ctx(`/api/git/summary?root=${encodeURIComponent(root)}`), + deps(runner, (value) => value), + ); + + expect(result.body).toMatchObject({ available: true, detached: true }); + expect((result.body as { branch?: unknown }).branch).toBeUndefined(); + }); + + it("returns an empty remotes list when none are configured", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce(ok(porcelain(["# branch.head main"]))) + .mockResolvedValueOnce(ok("")) + .mockResolvedValueOnce(fail("missing", 1)); + + const result = await handleGitSummary( + ctx(`/api/git/summary?root=${encodeURIComponent(root)}`), + deps(runner, (value) => value), + ); + + expect(result.body).toMatchObject({ remotes: [] }); + }); + + it("surfaces an unsafe repository without exposing the secret path", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(fail("fatal: detected dubious ownership in repository at '/secret'")); + + const result = await handleGitSummary( + ctx(`/api/git/summary?root=${encodeURIComponent(root)}`), + deps(runner), + ); + + expect(result.body).toMatchObject({ + state: "unsafe", + available: false, + reason: "unsafe-repository", + remotes: [], + ahead: 0, + behind: 0, + }); + expect(JSON.stringify(result.body)).not.toContain("/secret"); + }); + + it("maps a missing Git executable to a git-missing unavailable summary", async () => { + const runner = vi.fn().mockResolvedValueOnce(fail("spawn git ENOENT", 127)); + + const result = await handleGitSummary( + ctx(`/api/git/summary?root=${encodeURIComponent(root)}`), + deps(runner), + ); + + expect(result.body).toMatchObject({ + state: "unavailable", + available: false, + reason: "git-missing", + }); + }); + + it("maps a status command failure to an unavailable summary", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce(fail("fatal: detected dubious ownership")); + + const result = await handleGitSummary( + ctx(`/api/git/summary?root=${encodeURIComponent(root)}`), + deps(runner), + ); + + expect(result.body).toMatchObject({ + state: "unsafe", + available: false, + reason: "unsafe-repository", + }); + }); + + it("marks process-truncated status output as truncated", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce({ ...ok(porcelain(["# branch.head main"])), truncated: true }) + .mockResolvedValueOnce(ok("")) + .mockResolvedValueOnce(fail("missing", 1)); + + const result = await handleGitSummary( + ctx(`/api/git/summary?root=${encodeURIComponent(root)}`), + deps(runner, (value) => value), + ); + + expect(result.body).toMatchObject({ truncated: true }); + }); + + it("omits lastSync when FETCH_HEAD cannot be stat-ed", async () => { + const missing = join(root, "does-not-exist", "FETCH_HEAD"); + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce(ok(porcelain(["# branch.head main"]))) + .mockResolvedValueOnce(ok("")) + .mockResolvedValueOnce(ok(`${missing}\n`)); + + const result = await handleGitSummary( + ctx(`/api/git/summary?root=${encodeURIComponent(root)}`), + deps(runner, (value) => value), + ); + + expect((result.body as { lastSync?: unknown }).lastSync).toBeUndefined(); + }); +}); + +describe("GET /api/git/history", () => { + function logRecord(fields: { + sha: string; + short: string; + parents: string; + author: string; + date: string; + refs: string; + subject: string; + shortstat?: string; + }): string { + const head = [ + fields.sha, + fields.short, + fields.parents, + fields.author, + fields.date, + fields.refs, + fields.subject, + ].join("\x1f"); + return `\x1e${head}\n${fields.shortstat ?? ""}`; + } + + it("parses commit entries with refs, parents, and changed-file counts", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce( + ok( + logRecord({ + sha: "1111111111111111111111111111111111111111", + short: "1111111", + parents: "2222222", + author: "Ada Lovelace", + date: "2026-06-27T10:00:00+00:00", + refs: "HEAD -> main, origin/main", + subject: "Add feature", + shortstat: " 3 files changed, 10 insertions(+), 2 deletions(-)\n", + }), + ), + ); + + const result = await handleGitHistory( + ctx(`/api/git/history?root=${encodeURIComponent(root)}`), + deps(runner, (value) => value), + ); + + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ + available: true, + limit: 50, + skip: 0, + entries: [ + { + sha: "1111111111111111111111111111111111111111", + shortSha: "1111111", + subject: "Add feature", + author: "Ada Lovelace", + date: "2026-06-27T10:00:00+00:00", + refs: ["HEAD -> main", "origin/main"], + parentCount: 1, + changedFileCount: 3, + }, + ], + }); + }); + + it("treats a merge commit with no shortstat as parentCount 2 and zero changed files", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce( + ok( + logRecord({ + sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + short: "aaaaaaa", + parents: "bbbbbbb ccccccc", + author: "Merger", + date: "2026-06-27T11:00:00+00:00", + refs: "", + subject: "Merge branch 'feature'", + }), + ), + ); + + const result = await handleGitHistory( + ctx(`/api/git/history?root=${encodeURIComponent(root)}`), + deps(runner, (value) => value), + ); + + expect(result.body).toMatchObject({ + entries: [{ parentCount: 2, changedFileCount: 0, refs: [] }], + }); + }); + + it("returns empty history for a repository with no commits", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce( + fail("fatal: your current branch 'main' does not have any commits yet"), + ); + + const result = await handleGitHistory( + ctx(`/api/git/history?root=${encodeURIComponent(root)}`), + deps(runner), + ); + + expect(result.body).toMatchObject({ available: true, entries: [] }); + }); + + it("rejects a non-integer limit with a 400", async () => { + const runner = vi.fn(); + + const result = await handleGitHistory( + ctx(`/api/git/history?root=${encodeURIComponent(root)}&limit=abc`), + deps(runner), + ); + + expect(result.status).toBe(400); + expect(result.body).toMatchObject({ error: { code: "BAD_REQUEST" } }); + expect(runner).not.toHaveBeenCalled(); + }); + + it("rejects a non-integer skip with a 400", async () => { + const result = await handleGitHistory( + ctx(`/api/git/history?root=${encodeURIComponent(root)}&skip=1.5`), + deps(vi.fn()), + ); + + expect(result.status).toBe(400); + expect(result.body).toMatchObject({ error: { code: "BAD_REQUEST" } }); + }); + + it("clamps an out-of-range limit and reflects it in the response and git args", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce(ok("")); + + const result = await handleGitHistory( + ctx(`/api/git/history?root=${encodeURIComponent(root)}&limit=9999`), + deps(runner, (value) => value), + ); + + expect(result.body).toMatchObject({ limit: 200, entries: [] }); + expect(runner.mock.calls[1]?.[0]).toEqual(expect.arrayContaining(["--max-count=200"])); + }); + + it("marks truncated when the returned entry count equals the limit", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce( + ok( + [ + logRecord({ + sha: "1111111111111111111111111111111111111111", + short: "1111111", + parents: "", + author: "A", + date: "2026-06-27T10:00:00+00:00", + refs: "", + subject: "first", + }), + logRecord({ + sha: "2222222222222222222222222222222222222222", + short: "2222222", + parents: "1111111", + author: "B", + date: "2026-06-27T11:00:00+00:00", + refs: "", + subject: "second", + }), + ].join(""), + ), + ); + + const result = await handleGitHistory( + ctx(`/api/git/history?root=${encodeURIComponent(root)}&limit=2`), + deps(runner, (value) => value), + ); + + expect(result.body).toMatchObject({ truncated: true }); + expect((result.body as { entries: unknown[] }).entries).toHaveLength(2); + }); + + it("returns an unavailable history for an unsafe repository", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(fail("fatal: detected dubious ownership")); + + const result = await handleGitHistory( + ctx(`/api/git/history?root=${encodeURIComponent(root)}`), + deps(runner), + ); + + expect(result.body).toMatchObject({ + state: "unsafe", + available: false, + reason: "unsafe-repository", + entries: [], + }); + }); + + it("maps a missing Git executable to a git-missing unavailable history", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce(fail("git executable unavailable", 127)); + + const result = await handleGitHistory( + ctx(`/api/git/history?root=${encodeURIComponent(root)}`), + deps(runner), + ); + + expect(result.body).toMatchObject({ available: false, reason: "git-missing", entries: [] }); + }); + + it("marks process-truncated history output as truncated", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce({ ...ok(""), truncated: true }); + + const result = await handleGitHistory( + ctx(`/api/git/history?root=${encodeURIComponent(root)}`), + deps(runner, (value) => value), + ); + + expect(result.body).toMatchObject({ truncated: true }); + }); +}); + +describe("GET /api/git/remotes", () => { + it("lists multiple remotes with deduplicated fetch/push URLs", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce( + ok( + [ + "origin\thttps://example.invalid/origin.git (fetch)", + "origin\thttps://example.invalid/origin.git (push)", + "upstream\thttps://example.invalid/upstream.git (fetch)", + "upstream\thttps://example.invalid/upstream.git (push)", + "", + ].join("\n"), + ), + ); + + const result = await handleGitRemotes( + ctx(`/api/git/remotes?root=${encodeURIComponent(root)}`), + deps(runner, (value) => value), + ); + + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ + available: true, + remotes: [ + { + name: "origin", + fetchUrl: "https://example.invalid/origin.git", + pushUrl: "https://example.invalid/origin.git", + }, + { + name: "upstream", + fetchUrl: "https://example.invalid/upstream.git", + pushUrl: "https://example.invalid/upstream.git", + }, + ], + }); + }); + + it("returns an empty remotes list when none are configured", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce(ok("")); + + const result = await handleGitRemotes( + ctx(`/api/git/remotes?root=${encodeURIComponent(root)}`), + deps(runner, (value) => value), + ); + + expect(result.body).toMatchObject({ available: true, remotes: [] }); + }); + + it("surfaces an unsafe repository as unavailable", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(fail("fatal: detected dubious ownership")); + + const result = await handleGitRemotes( + ctx(`/api/git/remotes?root=${encodeURIComponent(root)}`), + deps(runner), + ); + + expect(result.body).toMatchObject({ + state: "unsafe", + available: false, + reason: "unsafe-repository", + remotes: [], + }); + }); + + it("maps a missing Git executable to a git-missing unavailable remotes response", async () => { + const runner = vi.fn().mockResolvedValueOnce(fail("spawn git ENOENT", 127)); + + const result = await handleGitRemotes( + ctx(`/api/git/remotes?root=${encodeURIComponent(root)}`), + deps(runner), + ); + + expect(result.body).toMatchObject({ + available: false, + reason: "git-missing", + remotes: [], + }); + }); +}); + +// The local-read env is config-isolated (no user gitconfig / credential helper / SSH identity); the +// network-sync env inherits the real environment so a fetch/pull can authenticate, but never prompts. +describe("git process env factories", () => { + it("hardens the local-read env: HOME and global config point at the null device", () => { + const env = gitEnv(); + expect(env.GIT_TERMINAL_PROMPT).toBe("0"); + expect(env.GIT_CONFIG_NOSYSTEM).toBe("1"); + if (process.platform === "win32") { + expect(env.GIT_CONFIG_GLOBAL).toBe("NUL"); + } else { + expect(env.HOME).toBe("/nonexistent"); + expect(env.GIT_CONFIG_GLOBAL).toBe("/dev/null"); + } + }); + + it("keeps the network-sync env credential-capable but non-interactive and fail-closed", () => { + // Stub a sentinel real-env var and HOME so the assertion holds regardless of ambient HOME and a + // mutation dropping the `...process.env` inheritance is caught deterministically. + const sentinelKey = "KEIKO_NETWORK_ENV_SENTINEL"; + vi.stubEnv(sentinelKey, "inherited-value"); + vi.stubEnv("HOME", "/home/keiko-test-user"); + try { + const env = networkGitEnv(); + expect(env.GIT_TERMINAL_PROMPT).toBe("0"); + // Inherits the real environment (credential.helper + SSH identities are discoverable) — it is + // NOT the hardened "/nonexistent" sentinel the local reads use. + expect(env[sentinelKey]).toBe("inherited-value"); + expect(env.HOME).toBe("/home/keiko-test-user"); + expect(env.HOME).not.toBe("/nonexistent"); + // SSH runs in BatchMode and requires known host keys, so it fails closed instead of prompting + // or silently trusting a first-use host. + expect(env.GIT_SSH_COMMAND).toContain("BatchMode=yes"); + expect(env.GIT_SSH_COMMAND).toContain("StrictHostKeyChecking=yes"); + expect(env.GIT_SSH_COMMAND).not.toContain("accept-new"); + // The network env does NOT isolate the user's global git config (that would hide credentials). + expect(env.GIT_CONFIG_GLOBAL).toBeUndefined(); + } finally { + vi.unstubAllEnvs(); + } + }); +}); + +// Read routes are local and never authenticate. An auth-shaped failure therefore has no dedicated +// outcome here — it falls through classifyFailure to "git-error" (the auth-failed OUTCOME is exercised +// end-to-end only by the sync routes). These tests lock that mapping across the three handlers. +describe("read routes — auth-shaped status failure maps to git-error", () => { + const authStderr = + "fatal: could not read Username for 'https://example.invalid': terminal prompts disabled"; + + async function summaryWithStatus( + statusResult: Awaited>, + ): Promise { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce(statusResult); + const result = await handleGitSummary( + ctx(`/api/git/summary?root=${encodeURIComponent(root)}`), + deps(runner), + ); + return result.body; + } + + it("summary maps an auth-shaped status failure to git-error", async () => { + expect(await summaryWithStatus(fail(authStderr, 128))).toMatchObject({ + available: false, + reason: "git-error", + }); + }); + + it("history maps an auth-shaped log failure to git-error", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce(fail(authStderr, 128)); + const result = await handleGitHistory( + ctx(`/api/git/history?root=${encodeURIComponent(root)}`), + deps(runner), + ); + expect(result.body).toMatchObject({ available: false, reason: "git-error", entries: [] }); + }); + + it("remotes maps an auth-shaped read failure to git-error", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce(fail(authStderr, 128)); + const result = await handleGitRemotes( + ctx(`/api/git/remotes?root=${encodeURIComponent(root)}`), + deps(runner), + ); + expect(result.body).toMatchObject({ available: false, reason: "git-error", remotes: [] }); + }); +}); + +// When `rev-parse --show-toplevel` resolves a repository root that does NOT contain the selected +// root, resolveRepository surfaces "repository-root-outside-root" and every read short-circuits to an +// unavailable envelope (a single rev-parse mock drives all three handlers). +describe("read routes — repository root outside the selected root", () => { + function revParseOutside(): GitProcessRunner { + return vi.fn().mockResolvedValueOnce(ok("/totally/unrelated/repository\n")); + } + + it("summary reports repository-root-outside-root", async () => { + const result = await handleGitSummary( + ctx(`/api/git/summary?root=${encodeURIComponent(root)}`), + deps(revParseOutside()), + ); + expect(result.body).toMatchObject({ + available: false, + reason: "repository-root-outside-root", + }); + }); + + it("history reports repository-root-outside-root", async () => { + const result = await handleGitHistory( + ctx(`/api/git/history?root=${encodeURIComponent(root)}`), + deps(revParseOutside()), + ); + expect(result.body).toMatchObject({ + available: false, + reason: "repository-root-outside-root", + entries: [], + }); + }); + + it("remotes reports repository-root-outside-root", async () => { + const result = await handleGitRemotes( + ctx(`/api/git/remotes?root=${encodeURIComponent(root)}`), + deps(revParseOutside()), + ); + expect(result.body).toMatchObject({ + available: false, + reason: "repository-root-outside-root", + remotes: [], + }); + }); +}); diff --git a/packages/keiko-server/src/gitRepositoryReads.ts b/packages/keiko-server/src/gitRepositoryReads.ts new file mode 100644 index 000000000..526c5262f --- /dev/null +++ b/packages/keiko-server/src/gitRepositoryReads.ts @@ -0,0 +1,408 @@ +// Read-only Git repository summary / history / remotes BFF (Issue #1573, Epic #1572). Git execution +// stays server-side with fixed args/env, selected-root containment, unsafe-owner surfacing, and +// bounded output. Reuses the hardened runner + containment from gitRoutes.ts and the shared +// porcelain-v2 parser; every response body is content-free (counts, typed codes, branch/remote +// names, ISO dates) and passes through `deps.redactor`. + +import { stat } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; +import type { + GitHistoryEntry, + GitHistoryResponse, + GitLastSyncMetadata, + GitRemoteSummary, + GitRepositorySummary, + GitRemotesResponse, + GitRepositoryState, + GitRepositoryStatusResponse, + GitUnavailableReason, +} from "@oscharko-dev/keiko-contracts"; +import { + GIT_HISTORY_SCHEMA_VERSION, + GIT_REPOSITORY_SUMMARY_SCHEMA_VERSION, +} from "@oscharko-dev/keiko-contracts"; +import { + classifyFailure, + isContained, + optionsWithDefaults, + resolveRepository, + type GitProcessResult, + type GitRouteOptions, + type NormalizedGitRouteOptions, + type RepositoryContext, +} from "./gitRoutes.js"; +import { parsePorcelainV2Branch } from "./gitPorcelainStatus.js"; +import { FilesError, runFilesHandler } from "./files.js"; +import type { RouteContext, RouteResult } from "./routes.js"; +import type { UiHandlerDeps } from "./deps.js"; + +type UnavailableReason = GitUnavailableReason | "unsafe-repository" | "git-error"; + +const HISTORY_RECORD_SEP = "\x1e"; +const HISTORY_FIELD_SEP = "\x1f"; +const HISTORY_PRETTY_FORMAT = `format:${HISTORY_RECORD_SEP}%H${HISTORY_FIELD_SEP}%h${HISTORY_FIELD_SEP}%P${HISTORY_FIELD_SEP}%an${HISTORY_FIELD_SEP}%aI${HISTORY_FIELD_SEP}%D${HISTORY_FIELD_SEP}%s`; +const HISTORY_LIMIT_DEFAULT = 50; +const HISTORY_LIMIT_MAX = 200; +const HISTORY_SKIP_MAX = 100_000; + +function redacted(deps: UiHandlerDeps, value: T): T { + return deps.redactor(value) as T; +} + +function unavailableState(reason: UnavailableReason): GitRepositoryState { + return reason === "unsafe-repository" ? "unsafe" : "unavailable"; +} + +function isUnavailable( + repo: RepositoryContext | GitRepositoryStatusResponse, +): repo is GitRepositoryStatusResponse { + return "available" in repo; +} + +// --- remotes ---------------------------------------------------------------- + +// `git remote -v` emits `name\turl (fetch|push)` lines; fetch/push URLs are deduplicated by name. +export function parseRemotes(stdout: string): readonly GitRemoteSummary[] { + const byName = new Map(); + const order: string[] = []; + for (const line of stdout.split(/\r?\n/u)) { + const match = /^(\S+)\t(.+?)\s+\((fetch|push)\)$/u.exec(line.trim()); + if (match === null) continue; + const [, name, url, kind] = match; + if (name === undefined || url === undefined) continue; + if (!byName.has(name)) { + byName.set(name, { name }); + order.push(name); + } + const entry = byName.get(name); + if (entry === undefined) continue; + if (kind === "fetch") entry.fetchUrl = url; + else entry.pushUrl = url; + } + return order.map((name) => { + const entry = byName.get(name) ?? { name }; + return { name: entry.name, fetchUrl: entry.fetchUrl, pushUrl: entry.pushUrl }; + }); +} + +function runGit( + repo: RepositoryContext, + options: NormalizedGitRouteOptions, + args: readonly string[], +): Promise { + return options.runner(["--no-pager", "--no-optional-locks", "-C", repo.repositoryRoot, ...args], { + cwd: repo.repositoryRoot, + maxBytes: options.maxStatusBytes, + timeoutMs: options.timeoutMs, + }); +} + +// --- last sync (FETCH_HEAD mtime) ------------------------------------------- + +async function readLastSync( + repo: RepositoryContext, + options: NormalizedGitRouteOptions, +): Promise { + try { + const result = await runGit(repo, options, ["rev-parse", "--git-path", "FETCH_HEAD"]); + if (result.exitCode !== 0) return undefined; + const rawPath = result.stdout.split(/\r?\n/u)[0]?.trim(); + if (rawPath === undefined || rawPath.length === 0) return undefined; + // Defence-in-depth: `--git-path` resolves relative to the repository root, but verify the + // resolved path is contained under it before stat-ing so a manipulated rev-parse result can + // never stat an out-of-tree file. Containment failure ⇒ omit lastSync (metadata stays optional). + const path = isAbsolute(rawPath) ? rawPath : resolve(repo.repositoryRoot, rawPath); + if (!isContained(repo.repositoryRoot, path)) return undefined; + const info = await stat(path); + return { lastFetchAtMs: Math.round(info.mtimeMs) }; + } catch { + return undefined; + } +} + +// --- summary ---------------------------------------------------------------- + +// `GitRemotesResponse` IS the shared unavailable envelope head; the summary spreads it and adds the +// zeroed status fields, so both responses stay byte-identical for the common fields. +function unavailableRemotes( + root: string, + repositoryRoot: string | undefined, + reason: UnavailableReason | undefined, +): GitRemotesResponse { + return { + schemaVersion: GIT_REPOSITORY_SUMMARY_SCHEMA_VERSION, + root, + repositoryRoot, + state: reason === undefined ? "unavailable" : unavailableState(reason), + available: false, + reason, + remotes: [], + truncated: false, + }; +} + +function unavailableSummary( + root: string, + repositoryRoot: string | undefined, + reason: UnavailableReason | undefined, +): GitRepositorySummary { + return { + ...unavailableRemotes(root, repositoryRoot, reason), + detached: false, + ahead: 0, + behind: 0, + stagedCount: 0, + unstagedCount: 0, + untrackedCount: 0, + conflictedCount: 0, + clean: true, + }; +} + +function buildSummary( + repo: RepositoryContext, + status: GitProcessResult, + remotes: readonly GitRemoteSummary[], + lastSync: GitLastSyncMetadata | undefined, +): GitRepositorySummary { + const parsed = parsePorcelainV2Branch(status.stdout); + const remoteAliases = remotes.map((remote) => ({ name: remote.name })); + return { + schemaVersion: GIT_REPOSITORY_SUMMARY_SCHEMA_VERSION, + root: repo.root, + repositoryRoot: repo.repositoryRoot, + state: "available", + available: true, + branch: parsed.branch, + detached: parsed.detached, + upstream: parsed.upstream, + ahead: parsed.ahead, + behind: parsed.behind, + stagedCount: parsed.stagedCount, + unstagedCount: parsed.unstagedCount, + untrackedCount: parsed.untrackedCount, + conflictedCount: parsed.conflictedCount, + clean: !parsed.dirty, + remotes: remoteAliases, + lastSync, + truncated: status.truncated, + }; +} + +export async function handleGitSummary( + ctx: RouteContext, + deps: UiHandlerDeps, + rawOptions?: GitRouteOptions, +): Promise { + return runFilesHandler(async () => { + const options = optionsWithDefaults(rawOptions ?? deps.gitRouteOptions); + const repo = await resolveRepository(ctx, deps, options); + if (isUnavailable(repo)) { + const body = unavailableSummary(repo.root, repo.repositoryRoot, repo.reason); + return { status: 200, body: redacted(deps, body) }; + } + const status = await runGit(repo, options, [ + "status", + "--porcelain=v2", + "--branch", + "-z", + "--untracked-files=all", + ]); + if (status.exitCode !== 0) { + const reason = classifyFailure(status); + return { + status: 200, + body: redacted(deps, unavailableSummary(repo.root, repo.repositoryRoot, reason)), + }; + } + const remotesResult = await runGit(repo, options, ["remote", "-v"]); + const remotes = remotesResult.exitCode === 0 ? parseRemotes(remotesResult.stdout) : []; + const lastSync = await readLastSync(repo, options); + return { status: 200, body: redacted(deps, buildSummary(repo, status, remotes, lastSync)) }; + }); +} + +// --- remotes route ---------------------------------------------------------- + +export async function handleGitRemotes( + ctx: RouteContext, + deps: UiHandlerDeps, + rawOptions?: GitRouteOptions, +): Promise { + return runFilesHandler(async () => { + const options = optionsWithDefaults(rawOptions ?? deps.gitRouteOptions); + const repo = await resolveRepository(ctx, deps, options); + if (isUnavailable(repo)) { + const body = unavailableRemotes(repo.root, repo.repositoryRoot, repo.reason); + return { status: 200, body: redacted(deps, body) }; + } + const result = await runGit(repo, options, ["remote", "-v"]); + if (result.exitCode !== 0) { + const reason = classifyFailure(result); + const body = unavailableRemotes(repo.root, repo.repositoryRoot, reason); + return { status: 200, body: redacted(deps, { ...body, truncated: result.truncated }) }; + } + return { + status: 200, + body: redacted(deps, { + schemaVersion: GIT_REPOSITORY_SUMMARY_SCHEMA_VERSION, + root: repo.root, + repositoryRoot: repo.repositoryRoot, + state: "available", + available: true, + remotes: parseRemotes(result.stdout), + truncated: result.truncated, + } satisfies GitRemotesResponse), + }; + }); +} + +// --- history ---------------------------------------------------------------- + +function parseInteger(raw: string | null, fallback: number, min: number, max: number): number { + if (raw === null || raw.trim().length === 0) return fallback; + if (!/^-?\d+$/u.test(raw.trim())) { + throw new FilesError(400, "BAD_REQUEST", "The limit and skip parameters must be integers."); + } + const value = Number.parseInt(raw.trim(), 10); + if (value < min) return min; + if (value > max) return max; + return value; +} + +// The `--shortstat` summary line carries "N files changed"; merge/empty commits omit it (⇒ 0). +function parseChangedFileCount(remainder: string): number { + const match = /(\d+)\s+files?\s+changed/u.exec(remainder); + return match?.[1] === undefined ? 0 : Number.parseInt(match[1], 10); +} + +function parseHistoryEntry(record: string): GitHistoryEntry | undefined { + const newline = record.indexOf("\n"); + const head = newline === -1 ? record : record.slice(0, newline); + const remainder = newline === -1 ? "" : record.slice(newline + 1); + const fields = head.split(HISTORY_FIELD_SEP); + const [sha, shortSha, parents, author, date, refs] = fields; + if (sha === undefined || shortSha === undefined || sha.length === 0) return undefined; + const subject = fields.slice(6).join(HISTORY_FIELD_SEP); + return { + sha, + shortSha, + subject, + author: author ?? "", + date: date ?? "", + refs: (refs ?? "") + .split(", ") + .map((ref) => ref.trim()) + .filter((ref) => ref.length > 0), + parentCount: (parents ?? "").split(/\s+/u).filter((entry) => entry.length > 0).length, + changedFileCount: parseChangedFileCount(remainder), + }; +} + +export function parseHistory(stdout: string): readonly GitHistoryEntry[] { + return stdout + .split(HISTORY_RECORD_SEP) + .filter((record) => record.length > 0) + .map(parseHistoryEntry) + .filter((entry): entry is GitHistoryEntry => entry !== undefined); +} + +// An empty repository (no commits yet) makes `git log` exit non-zero; treat that as empty history. +function isEmptyRepository(result: GitProcessResult): boolean { + const text = `${result.stdout}\n${result.stderr}`.toLowerCase(); + return ( + text.includes("does not have any commits yet") || + text.includes("bad default revision 'head'") || + text.includes("bad revision 'head'") + ); +} + +function unavailableHistory( + root: string, + repositoryRoot: string | undefined, + reason: UnavailableReason | undefined, + limit: number, + skip: number, +): GitHistoryResponse { + return { + schemaVersion: GIT_HISTORY_SCHEMA_VERSION, + root, + repositoryRoot, + state: reason === undefined ? "unavailable" : unavailableState(reason), + available: false, + reason, + entries: [], + limit, + skip, + truncated: false, + }; +} + +function availableHistory( + repo: RepositoryContext, + entries: readonly GitHistoryEntry[], + limit: number, + skip: number, + truncated: boolean, +): GitHistoryResponse { + return { + schemaVersion: GIT_HISTORY_SCHEMA_VERSION, + root: repo.root, + repositoryRoot: repo.repositoryRoot, + state: "available", + available: true, + entries, + limit, + skip, + truncated, + }; +} + +function historyArgs(limit: number, skip: number): readonly string[] { + return [ + "log", + "--no-color", + `--max-count=${String(limit)}`, + `--skip=${String(skip)}`, + `--pretty=${HISTORY_PRETTY_FORMAT}`, + "--shortstat", + ]; +} + +export async function handleGitHistory( + ctx: RouteContext, + deps: UiHandlerDeps, + rawOptions?: GitRouteOptions, +): Promise { + return runFilesHandler(async () => { + const options = optionsWithDefaults(rawOptions ?? deps.gitRouteOptions); + const limit = parseInteger( + ctx.url.searchParams.get("limit"), + HISTORY_LIMIT_DEFAULT, + 1, + HISTORY_LIMIT_MAX, + ); + const skip = parseInteger(ctx.url.searchParams.get("skip"), 0, 0, HISTORY_SKIP_MAX); + const repo = await resolveRepository(ctx, deps, options); + if (isUnavailable(repo)) { + const body = unavailableHistory(repo.root, repo.repositoryRoot, repo.reason, limit, skip); + return { status: 200, body: redacted(deps, body) }; + } + const result = await runGit(repo, options, historyArgs(limit, skip)); + if (result.exitCode !== 0) { + if (isEmptyRepository(result)) { + return { + status: 200, + body: redacted(deps, availableHistory(repo, [], limit, skip, false)), + }; + } + const reason = classifyFailure(result); + const body = unavailableHistory(repo.root, repo.repositoryRoot, reason, limit, skip); + return { status: 200, body: redacted(deps, body) }; + } + const entries = parseHistory(result.stdout); + const truncated = result.truncated || entries.length === limit; + const body = availableHistory(repo, entries, limit, skip, truncated); + return { status: 200, body: redacted(deps, body) }; + }); +} diff --git a/packages/keiko-server/src/gitRoutes.test.ts b/packages/keiko-server/src/gitRoutes.test.ts index acf530622..d220835d5 100644 --- a/packages/keiko-server/src/gitRoutes.test.ts +++ b/packages/keiko-server/src/gitRoutes.test.ts @@ -180,7 +180,9 @@ describe("GET /api/git/status", () => { }, ], }); - expect(runner.mock.calls[1]?.[0]).toEqual(expect.arrayContaining(["--", "workspace"])); + expect(runner.mock.calls[1]?.[0]).toEqual( + expect.arrayContaining(["--", ":(literal)workspace"]), + ); }); it("marks process-truncated status output as truncated even when change count is below cap", async () => { @@ -430,6 +432,27 @@ describe("GET /api/git/diff", () => { expect(diffArgs).not.toContain("--textconv"); }); + it("literalizes selected diff paths so Git pathspec magic cannot expand", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(ok(`${root}\n`)) + .mockResolvedValueOnce(ok("diff --git a/:(top)* b/:(top)*\n+literal\n")); + + const result = await handleGitDiff( + ctx( + `/api/git/diff?root=${encodeURIComponent(root)}&path=${encodeURIComponent( + ":(top)*", + )}&scope=worktree`, + ), + deps(runner), + ); + + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ path: ":(top)*", scope: "worktree" }); + const diffArgs = runner.mock.calls[1]?.[0] ?? []; + expect(diffArgs.slice(-2)).toEqual(["--", ":(literal):(top)*"]); + }); + it("returns staged diffs with cached Git args and no path when only a nested root is selected", async () => { const selectedRoot = join(root, "workspace"); await mkdir(selectedRoot); diff --git a/packages/keiko-server/src/gitRoutes.ts b/packages/keiko-server/src/gitRoutes.ts index d3ffbcba3..9e87a88a7 100644 --- a/packages/keiko-server/src/gitRoutes.ts +++ b/packages/keiko-server/src/gitRoutes.ts @@ -49,7 +49,7 @@ export interface GitRouteOptions { readonly timeoutMs?: number | undefined; } -interface NormalizedGitRouteOptions { +export interface NormalizedGitRouteOptions { readonly runner: GitProcessRunner; readonly maxStatusBytes: number; readonly maxDiffBytes: number; @@ -57,7 +57,7 @@ interface NormalizedGitRouteOptions { readonly timeoutMs: number; } -interface RepositoryContext { +export interface RepositoryContext { readonly root: string; readonly realRoot: string; readonly repositoryRoot: string; @@ -86,7 +86,10 @@ function devNullPath(): string { return process.platform === "win32" ? "NUL" : "/dev/null"; } -function gitEnv(): NodeJS.ProcessEnv { +// Local-read env: fully config-isolated. HOME/XDG/global+system config are neutralized so a read +// can never load a user `~/.gitconfig`, a credential helper, or an SSH identity. Correct for the +// local status/diff/branches/summary/history/remotes reads, which never authenticate to a remote. +export function gitEnv(): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { PATH: process.env.PATH ?? "", GIT_TERMINAL_PROMPT: "0", @@ -106,79 +109,110 @@ function gitEnv(): NodeJS.ProcessEnv { return env; } -// The runner owns process lifecycle state, byte caps, timeout, and spawn-error mapping together. +// Network-sync env: a fetch/pull MUST be able to authenticate to a private/SSH remote, so it +// inherits the real environment (the user's global `~/.gitconfig` credential.helper, the macOS +// osxkeychain helper, and the real `~/.ssh` identities). It still never prompts — GIT_TERMINAL_PROMPT +// is forced off and SSH runs in BatchMode — so it fails closed if no stored credential satisfies the +// remote rather than hanging on an interactive prompt. Used ONLY for the actual fetch/pull command; +// local reads keep the hardened, config-isolated `gitEnv` above. +export function networkGitEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + GIT_TERMINAL_PROMPT: "0", // never prompt — fail closed + GIT_PAGER: "cat", + PAGER: "cat", + GIT_OPTIONAL_LOCKS: "0", + // No SSH credential prompt and no implicit first-use trust. Unknown or changed host keys fail + // closed and are surfaced by the sync outcome classifier. + GIT_SSH_COMMAND: "ssh -oBatchMode=yes -oStrictHostKeyChecking=yes", + }; +} + +// Factory: the runner owns process lifecycle state, byte caps, timeout, and spawn-error mapping +// together. `buildEnv` is the only seam — the local reads pass the hardened `gitEnv`, network sync +// passes the credential-capable `networkGitEnv`; everything else is identical. // eslint-disable-next-line max-lines-per-function -export const defaultGitProcessRunner: GitProcessRunner = (args, options) => +export function createGitProcessRunner(buildEnv: () => NodeJS.ProcessEnv): GitProcessRunner { // eslint-disable-next-line max-lines-per-function - new Promise((resolveResult) => { - const child = spawn("git", args, { - cwd: options.cwd, - env: gitEnv(), - shell: false, - windowsHide: true, - stdio: ["ignore", "pipe", "pipe"], - }); - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - let stdoutBytes = 0; - let stderrBytes = 0; - let truncated = false; - let settled = false; - const timer = setTimeout(() => { - truncated = true; - child.kill("SIGTERM"); - }, options.timeoutMs); - - const capture = (chunks: Buffer[], currentBytes: number, chunk: Buffer): number => { - const remaining = options.maxBytes - currentBytes; - if (remaining <= 0) { - truncated = true; - child.kill("SIGTERM"); - return currentBytes; - } - if (chunk.byteLength > remaining) { - chunks.push(chunk.subarray(0, remaining)); + return (args, options) => + // eslint-disable-next-line max-lines-per-function + new Promise((resolveResult) => { + const child = spawn("git", args, { + cwd: options.cwd, + env: buildEnv(), + shell: false, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let truncated = false; + let settled = false; + const timer = setTimeout(() => { truncated = true; child.kill("SIGTERM"); - return options.maxBytes; - } - chunks.push(chunk); - return currentBytes + chunk.byteLength; - }; + }, options.timeoutMs); + + const capture = (chunks: Buffer[], currentBytes: number, chunk: Buffer): number => { + const remaining = options.maxBytes - currentBytes; + if (remaining <= 0) { + truncated = true; + child.kill("SIGTERM"); + return currentBytes; + } + if (chunk.byteLength > remaining) { + chunks.push(chunk.subarray(0, remaining)); + truncated = true; + child.kill("SIGTERM"); + return options.maxBytes; + } + chunks.push(chunk); + return currentBytes + chunk.byteLength; + }; - child.stdout.on("data", (chunk: Buffer) => { - stdoutBytes = capture(stdoutChunks, stdoutBytes, chunk); - }); - child.stderr.on("data", (chunk: Buffer) => { - stderrBytes = capture(stderrChunks, stderrBytes, chunk); - }); - child.on("error", () => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolveResult({ - exitCode: 127, - signal: null, - stdout: "", - stderr: "git executable unavailable", - truncated, + child.stdout.on("data", (chunk: Buffer) => { + stdoutBytes = capture(stdoutChunks, stdoutBytes, chunk); }); - }); - child.on("close", (exitCode, signal) => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolveResult({ - exitCode, - signal, - stdout: Buffer.concat(stdoutChunks).toString("utf8"), - stderr: Buffer.concat(stderrChunks).toString("utf8"), - truncated, + child.stderr.on("data", (chunk: Buffer) => { + stderrBytes = capture(stderrChunks, stderrBytes, chunk); + }); + child.on("error", () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolveResult({ + exitCode: 127, + signal: null, + stdout: "", + stderr: "git executable unavailable", + truncated, + }); + }); + child.on("close", (exitCode, signal) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolveResult({ + exitCode, + signal, + stdout: Buffer.concat(stdoutChunks).toString("utf8"), + stderr: Buffer.concat(stderrChunks).toString("utf8"), + truncated, + }); }); }); - }); +} + +// Local reads use the hardened, config-isolated env; network sync needs the user's credential +// configuration but must still never prompt (fail-closed) — see networkGitEnv. +export const defaultGitProcessRunner: GitProcessRunner = createGitProcessRunner(gitEnv); -function isContained(root: string, target: string): boolean { +export const defaultGitNetworkProcessRunner: GitProcessRunner = + createGitProcessRunner(networkGitEnv); + +export function isContained(root: string, target: string): boolean { const rootCmp = process.platform === "win32" ? root.toLowerCase() : root; const targetCmp = process.platform === "win32" ? target.toLowerCase() : target; const rel = relative(rootCmp, targetCmp); @@ -213,7 +247,7 @@ function genericUnavailable( }; } -function classifyFailure(result: GitProcessResult): GitRepositoryStatusResponse["reason"] { +export function classifyFailure(result: GitProcessResult): GitRepositoryStatusResponse["reason"] { const text = `${result.stdout}\n${result.stderr}`.toLowerCase(); if (result.exitCode === 127) return "git-missing"; if (text.includes("dubious ownership") || text.includes("safe.directory")) { @@ -226,7 +260,9 @@ function classifyFailure(result: GitProcessResult): GitRepositoryStatusResponse[ } // eslint-disable-next-line complexity -function optionsWithDefaults(options: GitRouteOptions | undefined): NormalizedGitRouteOptions { +export function optionsWithDefaults( + options: GitRouteOptions | undefined, +): NormalizedGitRouteOptions { return { runner: options?.runner ?? defaultGitProcessRunner, maxStatusBytes: options?.maxStatusBytes ?? DEFAULT_STATUS_MAX_BYTES, @@ -236,7 +272,7 @@ function optionsWithDefaults(options: GitRouteOptions | undefined): NormalizedGi }; } -async function resolveRepository( +export async function resolveRepository( ctx: RouteContext, deps: UiHandlerDeps, options: NormalizedGitRouteOptions, @@ -494,6 +530,10 @@ function gitPath(prefix: string, path: string | undefined): string | undefined { return normalizedPrefix.length > 0 ? `${normalizedPrefix}/${path}` : path; } +function literalGitPathspec(path: string): string { + return `:(literal)${path}`; +} + function parseScope(input: string | null): GitDiffScope { if (input === null || input.length === 0) return "all"; if (input === "all" || input === "worktree" || input === "staged") return input; @@ -518,7 +558,7 @@ async function runDiff( ]; if (staged) args.push("--cached"); const relativePath = gitPath(repo.selectedRootPrefix, path); - if (relativePath !== undefined) args.push("--", relativePath); + if (relativePath !== undefined) args.push("--", literalGitPathspec(relativePath)); return options.runner(args, { cwd: repo.repositoryRoot, maxBytes: options.maxDiffBytes, @@ -552,7 +592,7 @@ export async function handleGitStatus( "--untracked-files=all", "--", ...(repo.selectedRootPrefix.length > 0 && repo.selectedRootPrefix !== "." - ? [repo.selectedRootPrefix] + ? [literalGitPathspec(repo.selectedRootPrefix)] : []), ], { cwd: repo.repositoryRoot, maxBytes: options.maxStatusBytes, timeoutMs: options.timeoutMs }, diff --git a/packages/keiko-server/src/routes.ts b/packages/keiko-server/src/routes.ts index 2ca512a1a..fe9b5df69 100644 --- a/packages/keiko-server/src/routes.ts +++ b/packages/keiko-server/src/routes.ts @@ -132,6 +132,7 @@ import { handleFilesTree, } from "./files.js"; import { handleGitBranches, handleGitDiff, handleGitStatus } from "./gitRoutes.js"; +import { handleGitHistory, handleGitRemotes, handleGitSummary } from "./gitRepositoryReads.js"; import { handleEditorLanguage, handleEditorLanguageCapabilitiesForRoute, @@ -231,6 +232,8 @@ import { GIT_DELIVERY_COMMIT_ROUTE_GROUP } from "./gitDelivery/commitRoutes.js"; import { GIT_DELIVERY_PUSH_ROUTE_GROUP } from "./gitDelivery/pushRoutes.js"; import { GIT_DELIVERY_PR_ROUTE_GROUP } from "./gitDelivery/prRoutes.js"; import { GIT_DELIVERY_MERGE_ROUTE_GROUP } from "./gitDelivery/mergeRoutes.js"; +import { GIT_DELIVERY_SYNC_ROUTE_GROUP } from "./gitDelivery/syncRoutes.js"; +import { GIT_AGENT_OPERATION_ROUTE_GROUP } from "./gitDelivery/agentOperationsRoutes.js"; export interface ApiError { readonly error: { readonly code: string; readonly message: string }; @@ -374,6 +377,24 @@ export const API_ROUTES: readonly RouteDefinition[] = [ pattern: "/api/git/branches", handler: (ctx, deps) => handleGitBranches(ctx, deps, deps.gitRouteOptions), }, + // Issue #1573 — read-only Git repository summary, history, and remotes BFF. Reuses the hardened + // runner + selected-root containment from the #1386 reads; responses are content-free (counts, + // typed codes, branch/remote names, ISO dates) and pass through the live-payload redactor. + { + method: "GET", + pattern: "/api/git/summary", + handler: (ctx, deps) => handleGitSummary(ctx, deps, deps.gitRouteOptions), + }, + { + method: "GET", + pattern: "/api/git/history", + handler: (ctx, deps) => handleGitHistory(ctx, deps, deps.gitRouteOptions), + }, + { + method: "GET", + pattern: "/api/git/remotes", + handler: (ctx, deps) => handleGitRemotes(ctx, deps, deps.gitRouteOptions), + }, // Issue #1387 — controlled test/build/run command executor. Tasks are discovered from package // scripts and run through the single governed spawn boundary (keiko-tools runCommand): allowlisted // task ids only (never free-form argv), workspace-contained cwd, output cap, timeout, cancellation, @@ -874,6 +895,14 @@ export const API_ROUTES: readonly RouteDefinition[] = [ // execute through the SEPARATE merge gateway (dedicated `gh api` merge allowlist, readiness gate, final // approval) + #474 evidence ledger; same capability flag and CSRF. ...GIT_DELIVERY_MERGE_ROUTE_GROUP, + // #1573 governed fetch/pull sync: sync preview (read-only readiness + executable gate) + execute + // through a preflight-gated credential-capable runner (NOT the #472 kernel — fetch/pull have no + // GitDeliveryActionKind) + a dedicated content-free sync evidence ledger; same central CSRF + JSON + // content-type gate. + ...GIT_DELIVERY_SYNC_ROUTE_GROUP, + // #1577 agent repository operations: typed facade over existing Git read and governed delivery + // handlers. No shell/provider authority is introduced; command-shaped payloads are denied first. + ...GIT_AGENT_OPERATION_ROUTE_GROUP, ]; // Matches a concrete path against a route pattern, capturing `:name` params. Returns the captured diff --git a/packages/keiko-tools/src/git-mutation-adapter.test.ts b/packages/keiko-tools/src/git-mutation-adapter.test.ts index ab41ef409..ef5cab61d 100644 --- a/packages/keiko-tools/src/git-mutation-adapter.test.ts +++ b/packages/keiko-tools/src/git-mutation-adapter.test.ts @@ -33,15 +33,21 @@ describe("argv builders — fixed, governed argument vectors", () => { expect(() => buildBranchSwitchArgv({ branchName: "" })).toThrow(GitMutationArgvError); }); - it("stage places pathspecs after the `--` sentinel", () => { + it("stage literalizes pathspecs after the `--` sentinel", () => { expect(buildStageArgv({ pathspecs: ["src/a.ts", "src/b.ts"] })).toEqual([ - ["add", "--", "src/a.ts", "src/b.ts"], + ["add", "--", ":(literal)src/a.ts", ":(literal)src/b.ts"], ]); }); - it("unstage uses restore --staged after `--`", () => { + it("unstage uses literal pathspecs with restore --staged after `--`", () => { expect(buildUnstageArgv({ pathspecs: ["src/a.ts"] })).toEqual([ - ["restore", "--staged", "--", "src/a.ts"], + ["restore", "--staged", "--", ":(literal)src/a.ts"], + ]); + }); + + it("literalizes Git pathspec-magic filenames", () => { + expect(buildStageArgv({ pathspecs: [":(top)*", ":(glob)**"] })).toEqual([ + ["add", "--", ":(literal):(top)*", ":(literal):(glob)**"], ]); }); @@ -101,7 +107,7 @@ describe("argv builders — fixed, governed argument vectors", () => { targetRefHash: "abc", pathspecs: ["a.ts"], }), - ).toEqual([["restore", "--source", "abc", "--staged", "--", "a.ts"]]); + ).toEqual([["restore", "--source", "abc", "--staged", "--", ":(literal)a.ts"]]); }); }); diff --git a/packages/keiko-tools/src/git-mutation-adapter.ts b/packages/keiko-tools/src/git-mutation-adapter.ts index cad41c7bc..19bf87469 100644 --- a/packages/keiko-tools/src/git-mutation-adapter.ts +++ b/packages/keiko-tools/src/git-mutation-adapter.ts @@ -12,8 +12,9 @@ // and it permits only the governed mutation subcommands. // 3. The pure argv builders — each maps validated, typed operands to a fixed argument vector whose // first token is always one of the allowed subcommands. Operands are validated (no NUL, no -// flag-injection via a leading "-" on refs) and file pathspecs are placed after a "--" sentinel -// so a path can never be reinterpreted as an option. +// flag-injection via a leading "-" on refs) and file operands are literalized before they are +// placed after a "--" sentinel so a repository-controlled filename cannot be reinterpreted as an +// option or Git pathspec magic. // // Pure module: types, frozen tables, and total pure functions. No IO, no spawn, no child_process — // the actual execution adapter is git-mutation-node.ts on the `./internal/git-mutation` subpath. @@ -173,8 +174,13 @@ function assertMessage(value: string): string { return value; } -// File pathspecs placed after the "--" sentinel. May start with "-" (the sentinel disarms option -// parsing), but must be non-empty and NUL-free, and the list must be non-empty. +function literalPathspec(value: string): string { + return `:(literal)${value}`; +} + +// File operands placed after the "--" sentinel. May start with "-" (the sentinel disarms option +// parsing), but must be non-empty and NUL-free, and the list must be non-empty. Returned argv tokens +// are literal pathspecs so names such as ":(top)*" cannot expand beyond the selected file. function assertPathspecs(values: readonly string[]): readonly string[] { if (values.length === 0) { throw new GitMutationArgvError("at least one pathspec is required"); @@ -187,7 +193,7 @@ function assertPathspecs(values: readonly string[]): readonly string[] { throw new GitMutationArgvError("pathspec must not contain a NUL byte"); } } - return values; + return values.map(literalPathspec); } // ─── Pure argv builders ─────────────────────────────────────────────────────────────────── diff --git a/packages/keiko-tools/src/git-mutation-node.integration.test.ts b/packages/keiko-tools/src/git-mutation-node.integration.test.ts index 3a2fd46e7..30bff0614 100644 --- a/packages/keiko-tools/src/git-mutation-node.integration.test.ts +++ b/packages/keiko-tools/src/git-mutation-node.integration.test.ts @@ -127,6 +127,20 @@ describe("node git mutation adapter — successful local mutations", () => { expect(unstaged.outcome).toBe("succeeded"); expect(git(["diff", "--cached", "--name-only"]).trim()).toBe(""); }); + + it("treats pathspec-magic-looking filenames as literal files (#1575)", async () => { + writeFileSync(join(root, "base.txt"), "base\n", "utf8"); + git(["add", "base.txt"]); + git(["commit", "-m", "base"]); + writeFileSync(join(root, ":(top)*"), "magic-looking name\n", "utf8"); + writeFileSync(join(root, "another.txt"), "must stay unstaged\n", "utf8"); + + const staged = await adapter().stage({ pathspecs: [":(top)*"] }); + + expect(staged.outcome).toBe("succeeded"); + expect(git(["diff", "--cached", "--name-only"]).trim()).toBe(":(top)*"); + expect(git(["status", "--short"])).toContain("?? another.txt"); + }); }); describe("node git mutation adapter — structured failure classification", () => { diff --git a/packages/keiko-tools/src/git-mutation-node.test.ts b/packages/keiko-tools/src/git-mutation-node.test.ts index c935fde41..dea6423f6 100644 --- a/packages/keiko-tools/src/git-mutation-node.test.ts +++ b/packages/keiko-tools/src/git-mutation-node.test.ts @@ -26,7 +26,7 @@ function makeAdapter(rec: SpawnRecorder, signal?: AbortSignal): GitLocalMutation } describe("node git mutation adapter — governed argv reaches the spawn boundary", () => { - it("spawns exactly the governed `git add -- ` and reports success on exit 0", async () => { + it("spawns exactly the governed literalized `git add -- ` and reports success on exit 0", async () => { const rec = recordingSpawn(); const ad = makeAdapter(rec); const pending = ad.stage({ pathspecs: ["src/x.ts"] }); @@ -35,7 +35,7 @@ describe("node git mutation adapter — governed argv reaches the spawn boundary expect(result.outcome).toBe("succeeded"); expect(rec.calls()).toHaveLength(1); expect(rec.calls()[0]?.command).toBe("git"); - expect(rec.calls()[0]?.args).toEqual(["add", "--", "src/x.ts"]); + expect(rec.calls()[0]?.args).toEqual(["add", "--", ":(literal)src/x.ts"]); // The spawn is shell-less by construction. expect(rec.calls()[0]?.options.shell).toBe(false); }); diff --git a/packages/keiko-ui/src/app/components/desktop/KeikoSelect.test.tsx b/packages/keiko-ui/src/app/components/desktop/KeikoSelect.test.tsx index 33770cc68..11bac58c7 100644 --- a/packages/keiko-ui/src/app/components/desktop/KeikoSelect.test.tsx +++ b/packages/keiko-ui/src/app/components/desktop/KeikoSelect.test.tsx @@ -39,6 +39,8 @@ describe("KeikoSelect menu geometry", () => { await user.click(trigger); + expect(screen.getByRole("listbox", { name: "Policy profile" })).toBeInTheDocument(); + const menu = document.querySelector(".ksel-menu"); expect(menu).not.toBeNull(); expect(menu).toHaveStyle({ diff --git a/packages/keiko-ui/src/app/components/desktop/KeikoSelect.tsx b/packages/keiko-ui/src/app/components/desktop/KeikoSelect.tsx index d09292095..d4d3a0f6b 100644 --- a/packages/keiko-ui/src/app/components/desktop/KeikoSelect.tsx +++ b/packages/keiko-ui/src/app/components/desktop/KeikoSelect.tsx @@ -265,6 +265,7 @@ export default function KeikoSelect({ const selectedOption = selectedIndex === -1 ? null : flatOptions[selectedIndex]!; const visibleLabel = selectedOption?.label ?? placeholder; const visibleDescription = selectedOption?.description ?? null; + const menuLabel = menuTitle ?? ariaLabel ?? placeholder; const closeMenu = (): void => { setOpen(false); @@ -534,6 +535,7 @@ export default function KeikoSelect({ className="ksel-menu-scroll" role="listbox" id={menuId} + aria-label={menuLabel} style={{ maxHeight: `${position.maxHeight.toString()}px` }} > {sections.map((section, sectionIndex) => ( diff --git a/packages/keiko-ui/src/app/components/desktop/widgets/cards/GovernedGitFlowCard.a11y.test.tsx b/packages/keiko-ui/src/app/components/desktop/widgets/cards/GovernedGitFlowCard.a11y.test.tsx deleted file mode 100644 index 0dd0b97a6..000000000 --- a/packages/keiko-ui/src/app/components/desktop/widgets/cards/GovernedGitFlowCard.a11y.test.tsx +++ /dev/null @@ -1,161 +0,0 @@ -// a11y smoke tests for the governed local Git flow surface (Issue #475, AC parity with #473). jest-axe -// scans the empty state, the default flow, the loaded-preview state, and an outcome banner; asserts the -// polite live region exists for async outcome announcements; and asserts severity / outcome are -// conveyed by TEXT, not colour alone. - -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { axe } from "jest-axe"; -import { describe, expect, it, vi } from "vitest"; -import { GovernedGitFlowCard, type GovernedGitFlowClient } from "./GovernedGitFlowCard"; -import type { - GitBranchListResponse, - GitDeliveryCommitPreviewResponse, - GitDeliveryMutationResponse, - GitDeliveryPushPreviewResponse, -} from "@/lib/api"; - -const PUSH_PREVIEW: GitDeliveryPushPreviewResponse = { - schemaVersion: "1", - remoteAlias: "origin", - remoteBranchName: "feat/x", - sourceBranchName: "feat/x", - riskClass: "publish", - wouldCreateRemoteBranch: true, - wouldTriggerChecks: true, - forceBlocked: false, - preflightBlockingCodes: [], - preflightAdvisoryCodes: [], - policyOutcome: "constrained", -}; - -const PREVIEW: GitDeliveryCommitPreviewResponse = { - schemaVersion: "1", - summary: { stagedFileCount: 2, areaCount: 1, areas: ["src"], touchesTests: false }, - intent: { - warnings: ["large-change"], - mixedScope: false, - isWip: false, - suggestedSubjectPrefix: "feat(src): ", - }, - messageValidation: { ok: false, violations: ["subject-too-long"] }, - preflightFindingCodes: ["detached-head"], - policyOutcome: "approval-gated", -}; - -const OK: GitDeliveryMutationResponse = { - schemaVersion: "1", - status: "blocked", - actionKind: "commit", - blockReason: "message-policy", - messageViolations: ["missing-conventional-prefix"], -}; - -function makeClient(): GovernedGitFlowClient { - return { - listRepositories: vi.fn(async () => ({ - projects: [ - { - path: PROJECT, - name: "repo", - favorite: false, - createdAt: 1, - lastOpenedAt: 1, - available: true, - }, - ], - })), - registerRepository: vi.fn(async (input) => ({ - project: { - path: input.path, - name: input.name ?? "repo", - favorite: false, - createdAt: 1, - lastOpenedAt: 1, - available: true, - }, - })), - cloneRepository: vi.fn(async (input) => ({ - project: { - path: input.destinationPath, - name: "repo", - favorite: false, - createdAt: 1, - lastOpenedAt: 1, - available: true, - }, - })), - listBranches: vi.fn(async (): Promise => ({ - schemaVersion: "1", - root: PROJECT, - available: true, - state: "available", - branches: [ - { name: "main", headRefHash: "main-ref", current: true }, - { name: "release", headRefHash: "release-ref", current: false }, - ], - truncated: false, - })), - branchCreate: vi.fn(async () => OK), - branchSwitch: vi.fn(async () => OK), - stage: vi.fn(async () => OK), - unstage: vi.fn(async () => OK), - commitPreview: vi.fn(async () => PREVIEW), - commitExecute: vi.fn(async () => OK), - pushPreview: vi.fn(async () => PUSH_PREVIEW), - pushExecute: vi.fn(async () => ({ ...OK, actionKind: "push" })), - }; -} - -const PROJECT = "/home/me/repo"; - -describe("GovernedGitFlowCard — a11y (WCAG 2.2 AA)", () => { - it("has no violations in the empty state", async () => { - const { container } = render(); - expect(await axe(container)).toHaveNoViolations(); - }); - - it("has no violations in the default flow state", async () => { - const { container } = render(); - expect(await axe(container)).toHaveNoViolations(); - }); - - it("has no violations once the preview is loaded", async () => { - const { container } = render(); - fireEvent.change(screen.getByLabelText("Commit message"), { target: { value: "x" } }); - fireEvent.click(screen.getByRole("button", { name: "Preview" })); - await waitFor(() => expect(screen.getByTestId("ggit-preview")).toBeInTheDocument()); - expect(await axe(container)).toHaveNoViolations(); - }); - - it("has no violations with a rendered outcome banner", async () => { - const { container } = render(); - fireEvent.change(screen.getByLabelText("Commit message"), { target: { value: "x" } }); - fireEvent.click(screen.getByRole("button", { name: "Commit" })); - await waitFor(() => expect(screen.getByTestId("ggit-outcome")).toBeInTheDocument()); - expect(await axe(container)).toHaveNoViolations(); - }); - - it("exposes a polite live region for async outcome announcements", () => { - render(); - const live = screen.getByTestId("ggit-live"); - expect(live).toHaveAttribute("aria-live", "polite"); - expect(live).toHaveAttribute("role", "status"); - }); - - it("conveys the outcome status with a text label, not colour alone", async () => { - render(); - fireEvent.change(screen.getByLabelText("Commit message"), { target: { value: "x" } }); - fireEvent.click(screen.getByRole("button", { name: "Commit" })); - await waitFor(() => expect(screen.getByTestId("ggit-outcome")).toBeInTheDocument()); - expect(screen.getByTestId("ggit-outcome")).toHaveTextContent("commit: Blocked"); - }); - - it("names the suggested prefix and policy decision in text", async () => { - render(); - fireEvent.change(screen.getByLabelText("Commit message"), { target: { value: "x" } }); - fireEvent.click(screen.getByRole("button", { name: "Preview" })); - await waitFor(() => expect(screen.getByTestId("ggit-policy")).toBeInTheDocument()); - expect(screen.getByTestId("ggit-suggested-prefix")).toHaveTextContent("feat(src):"); - expect(screen.getByTestId("ggit-policy")).toHaveTextContent("Policy: approval-gated"); - }); -}); diff --git a/packages/keiko-ui/src/app/components/desktop/widgets/cards/GovernedGitFlowCard.test.tsx b/packages/keiko-ui/src/app/components/desktop/widgets/cards/GovernedGitFlowCard.test.tsx deleted file mode 100644 index 3a9745d14..000000000 --- a/packages/keiko-ui/src/app/components/desktop/widgets/cards/GovernedGitFlowCard.test.tsx +++ /dev/null @@ -1,410 +0,0 @@ -// Behavioural tests for the governed local Git flow surface (Issue #475, Epic #470). Exercises the -// branch / staging / commit walk with a fully mocked api client: empty state, mutation dispatch with -// the right payloads, live preview rendering (warnings / violations / findings / suggested prefix / -// policy), and the readable outcome banner for succeeded / blocked / approval-required outcomes. - -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { GovernedGitFlowCard, type GovernedGitFlowClient } from "./GovernedGitFlowCard"; -import type { - GitBranchListResponse, - GitDeliveryCommitPreviewResponse, - GitDeliveryMutationResponse, - GitDeliveryPushPreviewResponse, -} from "@/lib/api"; - -function makeClient(overrides: Partial = {}): GovernedGitFlowClient { - const ok: GitDeliveryMutationResponse = { - schemaVersion: "1", - status: "succeeded", - actionKind: "branch-create", - }; - return { - listRepositories: vi.fn(async () => ({ - projects: [ - { - path: PROJECT, - name: "repo", - favorite: false, - createdAt: 1, - lastOpenedAt: 1, - available: true, - }, - ], - })), - registerRepository: vi.fn(async (input) => ({ - project: { - path: input.path, - name: input.name ?? "repo", - favorite: false, - createdAt: 1, - lastOpenedAt: 1, - available: true, - }, - })), - cloneRepository: vi.fn(async (input) => ({ - project: { - path: input.destinationPath, - name: "repo", - favorite: false, - createdAt: 1, - lastOpenedAt: 1, - available: true, - }, - })), - listBranches: vi.fn(async (): Promise => ({ - schemaVersion: "1", - root: PROJECT, - available: true, - state: "available", - branches: [ - { name: "main", headRefHash: "main-ref", current: true }, - { name: "release", headRefHash: "release-ref", current: false }, - ], - truncated: false, - })), - branchCreate: vi.fn(async () => ok), - branchSwitch: vi.fn(async () => ({ ...ok, actionKind: "branch-switch" })), - stage: vi.fn(async () => ({ ...ok, actionKind: "stage" })), - unstage: vi.fn(async () => ({ ...ok, actionKind: "unstage" })), - commitPreview: vi.fn(async () => makePreview()), - commitExecute: vi.fn(async () => ({ ...ok, actionKind: "commit" })), - pushPreview: vi.fn(async () => makePushPreview()), - pushExecute: vi.fn(async () => ({ ...ok, actionKind: "push" })), - ...overrides, - }; -} - -function makePushPreview( - overrides: Partial = {}, -): GitDeliveryPushPreviewResponse { - return { - schemaVersion: "1", - remoteAlias: "origin", - remoteBranchName: "feat/x", - sourceBranchName: "feat/x", - riskClass: "publish", - wouldCreateRemoteBranch: true, - wouldTriggerChecks: true, - forceBlocked: false, - preflightBlockingCodes: [], - preflightAdvisoryCodes: [], - policyOutcome: "constrained", - ...overrides, - }; -} - -function makePreview( - overrides: Partial = {}, -): GitDeliveryCommitPreviewResponse { - return { - schemaVersion: "1", - summary: { stagedFileCount: 3, areaCount: 2, areas: ["src", "docs"], touchesTests: true }, - intent: { - warnings: ["mixed-scope", "wip-marker"], - mixedScope: true, - isWip: true, - suggestedSubjectPrefix: "feat(src): ", - }, - messageValidation: { ok: false, violations: ["missing-conventional-prefix"] }, - preflightFindingCodes: ["uncommitted-changes"], - policyOutcome: "allowed", - ...overrides, - }; -} - -const PROJECT = "/home/me/repo"; - -describe("GovernedGitFlowCard", () => { - it("renders the repository manager when no repository is selected", async () => { - render(); - expect(screen.getByRole("heading", { name: "Repository Manager" })).toBeInTheDocument(); - await waitFor(() => expect(screen.getByText("Working copies")).toBeInTheDocument()); - expect(screen.queryByLabelText("Branch")).not.toBeInTheDocument(); - }); - - it("registers an existing repository and offers follow-up open actions", async () => { - const onOpenFiles = vi.fn(); - const onOpenEditor = vi.fn(); - const client = makeClient(); - render( - , - ); - fireEvent.change(screen.getByLabelText("Existing working copy"), { - target: { value: "/work/new-repo" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Register repository" })); - await waitFor(() => - expect(client.registerRepository).toHaveBeenCalledWith({ - path: "/work/new-repo", - name: "new-repo", - }), - ); - fireEvent.click(screen.getByRole("button", { name: "Open in Files" })); - expect(onOpenFiles).toHaveBeenCalledWith("/work/new-repo"); - fireEvent.click(screen.getByRole("button", { name: "Open in Editor" })); - expect(onOpenEditor).toHaveBeenCalledWith("/work/new-repo"); - }); - - it("clones a repository into a chosen destination and selects it", async () => { - const client = makeClient(); - render(); - fireEvent.change(screen.getByLabelText("Repository URL"), { - target: { value: "https://github.com/acme/app.git" }, - }); - fireEvent.change(screen.getByLabelText("Clone to folder"), { - target: { value: "/work/app" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Clone repository" })); - await waitFor(() => - expect(client.cloneRepository).toHaveBeenCalledWith({ - repositoryUrl: "https://github.com/acme/app.git", - destinationPath: "/work/app", - }), - ); - expect(await screen.findByText(/Repository ready/i)).toBeInTheDocument(); - }); - - it("renders the three flow sections for a project", () => { - render(); - expect(screen.getByLabelText("Branch")).toBeInTheDocument(); - expect(screen.getByLabelText("Staging")).toBeInTheDocument(); - expect(screen.getByLabelText("Commit")).toBeInTheDocument(); - }); - - it("dispatches branch-create with the typed payload and shows the outcome", async () => { - const client = makeClient(); - render(); - await waitFor(() => expect(client.listBranches).toHaveBeenCalledWith(PROJECT)); - fireEvent.change(screen.getByLabelText("New branch name"), { target: { value: "feat/x" } }); - fireEvent.change(screen.getByLabelText("Base branch"), { target: { value: "release" } }); - fireEvent.click(screen.getByRole("button", { name: "Create branch" })); - await waitFor(() => expect(screen.getByTestId("ggit-outcome")).toBeInTheDocument()); - expect(client.branchCreate).toHaveBeenCalledWith({ - projectId: PROJECT, - branchName: "feat/x", - baseBranchName: "release", - startPointRefHash: "release-ref", - }); - expect(screen.queryByLabelText("Start-point ref hash")).not.toBeInTheDocument(); - expect(screen.getByTestId("ggit-outcome")).toHaveTextContent("branch-create: Succeeded"); - }); - - it("dispatches branch-switch with the typed payload", async () => { - const client = makeClient(); - render(); - await waitFor(() => expect(client.listBranches).toHaveBeenCalledWith(PROJECT)); - fireEvent.change(screen.getByLabelText("Switch to branch"), { target: { value: "release" } }); - fireEvent.click(screen.getByRole("button", { name: "Switch branch" })); - await waitFor(() => expect(client.branchSwitch).toHaveBeenCalled()); - expect(client.branchSwitch).toHaveBeenCalledWith({ projectId: PROJECT, branchName: "release" }); - }); - - it("dispatches stage with parsed pathspecs and includeUntracked", async () => { - const client = makeClient(); - render(); - fireEvent.change(screen.getByLabelText("Pathspecs (one per line)"), { - target: { value: "src/a.ts\n src/b.ts \n" }, - }); - fireEvent.click(screen.getByLabelText("Include untracked files")); - fireEvent.click(screen.getByRole("button", { name: "Stage" })); - await waitFor(() => expect(client.stage).toHaveBeenCalled()); - expect(client.stage).toHaveBeenCalledWith({ - projectId: PROJECT, - pathspecs: ["src/a.ts", "src/b.ts"], - includeUntracked: true, - }); - }); - - it("dispatches unstage with parsed pathspecs", async () => { - const client = makeClient(); - render(); - fireEvent.change(screen.getByLabelText("Pathspecs (one per line)"), { - target: { value: "src/a.ts" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Unstage" })); - await waitFor(() => expect(client.unstage).toHaveBeenCalled()); - expect(client.unstage).toHaveBeenCalledWith({ - projectId: PROJECT, - pathspecs: ["src/a.ts"], - }); - }); - - it("renders the live preview: counts, suggested prefix, warnings, violations, findings, policy", async () => { - render(); - fireEvent.change(screen.getByLabelText("Commit message"), { target: { value: "wip changes" } }); - fireEvent.click(screen.getByRole("button", { name: "Preview" })); - await waitFor(() => expect(screen.getByTestId("ggit-preview")).toBeInTheDocument()); - expect(screen.getByTestId("ggit-preview")).toHaveTextContent("3 staged files across 2 areas"); - expect(screen.getByTestId("ggit-suggested-prefix")).toHaveTextContent("feat(src):"); - expect(screen.getByTestId("ggit-warnings")).toHaveTextContent("Mixed scope"); - expect(screen.getByTestId("ggit-violations")).toHaveTextContent( - "Missing a conventional-commit type prefix", - ); - expect(screen.getByTestId("ggit-findings")).toHaveTextContent("uncommitted-changes"); - expect(screen.getByTestId("ggit-policy")).toHaveTextContent("Policy: allowed"); - }); - - it("surfaces a message-policy block from commit/execute as readable text", async () => { - const client = makeClient({ - commitExecute: vi.fn(async () => ({ - schemaVersion: "1" as const, - status: "blocked" as const, - actionKind: "commit", - blockReason: "message-policy", - messageViolations: ["missing-conventional-prefix" as const], - })), - }); - render(); - fireEvent.change(screen.getByLabelText("Commit message"), { target: { value: "bad" } }); - fireEvent.click(screen.getByRole("button", { name: "Commit" })); - await waitFor(() => expect(screen.getByTestId("ggit-outcome")).toBeInTheDocument()); - const outcome = screen.getByTestId("ggit-outcome"); - expect(outcome).toHaveTextContent("commit: Blocked"); - expect(outcome).toHaveTextContent("reason: message-policy"); - expect(outcome).toHaveTextContent("Missing a conventional-commit type prefix"); - }); - - it("surfaces an approval-required outcome with the required approvers", async () => { - const client = makeClient({ - commitExecute: vi.fn(async () => ({ - schemaVersion: "1" as const, - status: "approval-required" as const, - actionKind: "commit", - requiredApprovers: ["release-captain"], - })), - }); - render(); - fireEvent.change(screen.getByLabelText("Commit message"), { - target: { value: "feat: ok" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Commit" })); - await waitFor(() => expect(screen.getByTestId("ggit-outcome")).toBeInTheDocument()); - expect(screen.getByTestId("ggit-outcome")).toHaveTextContent("commit: Approval required"); - expect(screen.getByTestId("ggit-outcome")).toHaveTextContent("approver: release-captain"); - }); - - it("renders a preflight-block outcome with the finding codes", async () => { - const client = makeClient({ - stage: vi.fn(async () => ({ - schemaVersion: "1" as const, - status: "blocked" as const, - actionKind: "stage", - preflightFindingCodes: ["nothing-to-stage"], - })), - }); - render(); - fireEvent.change(screen.getByLabelText("Pathspecs (one per line)"), { - target: { value: "src/a.ts" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Stage" })); - await waitFor(() => expect(screen.getByTestId("ggit-outcome")).toBeInTheDocument()); - expect(screen.getByTestId("ggit-outcome")).toHaveTextContent("preflight: nothing-to-stage"); - }); - - it("shows a readable error when a mutation rejects", async () => { - const client = makeClient({ - branchSwitch: vi.fn(async () => { - throw new Error("worktree unavailable"); - }), - }); - render(); - await waitFor(() => expect(client.listBranches).toHaveBeenCalledWith(PROJECT)); - fireEvent.change(screen.getByLabelText("Switch to branch"), { target: { value: "release" } }); - fireEvent.click(screen.getByRole("button", { name: "Switch branch" })); - await waitFor(() => - expect(screen.getByRole("alert")).toHaveTextContent("worktree unavailable"), - ); - }); - - it("renders an ok preview without violations or warnings cleanly", async () => { - const client = makeClient({ - commitPreview: vi.fn(async () => - makePreview({ - intent: { warnings: [], mixedScope: false, isWip: false }, - messageValidation: { ok: true }, - preflightFindingCodes: [], - }), - ), - }); - render(); - fireEvent.change(screen.getByLabelText("Commit message"), { - target: { value: "feat: ok" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Preview" })); - await waitFor(() => expect(screen.getByTestId("ggit-preview")).toBeInTheDocument()); - expect(screen.queryByTestId("ggit-warnings")).not.toBeInTheDocument(); - expect(screen.queryByTestId("ggit-violations")).not.toBeInTheDocument(); - expect(screen.queryByTestId("ggit-findings")).not.toBeInTheDocument(); - }); - - it("renders the publish section and shows the push preview risk summary", async () => { - render(); - expect(screen.getByLabelText("Publish")).toBeInTheDocument(); - fireEvent.change(screen.getByLabelText("Source branch"), { target: { value: "feat/x" } }); - fireEvent.change(screen.getByLabelText("Remote branch"), { target: { value: "feat/x" } }); - fireEvent.click(screen.getByRole("button", { name: "Preview push" })); - await waitFor(() => expect(screen.getByTestId("ggit-push-preview")).toBeInTheDocument()); - expect(screen.getByTestId("ggit-push-preview")).toHaveTextContent("origin"); - expect(screen.getByTestId("ggit-push-policy")).toHaveTextContent("Policy: constrained"); - }); - - it("dispatches a governed push with the typed payload", async () => { - const client = makeClient(); - render(); - fireEvent.change(screen.getByLabelText("Source branch"), { target: { value: "feat/x" } }); - fireEvent.change(screen.getByLabelText("Remote branch"), { target: { value: "feat/x" } }); - fireEvent.click(screen.getByLabelText("Set upstream tracking")); - fireEvent.click(screen.getByRole("button", { name: "Push" })); - await waitFor(() => expect(client.pushExecute).toHaveBeenCalled()); - expect(client.pushExecute).toHaveBeenCalledWith({ - projectId: PROJECT, - remoteAlias: "origin", - remoteBranchName: "feat/x", - sourceBranchName: "feat/x", - setUpstreamTracking: true, - }); - expect(screen.getByTestId("ggit-outcome")).toHaveTextContent("push: Succeeded"); - }); - - it("surfaces a protected-target policy block from push/execute as readable text", async () => { - const client = makeClient({ - pushExecute: vi.fn(async () => ({ - schemaVersion: "1" as const, - status: "blocked" as const, - actionKind: "push", - blockReason: "policy-pack-blocked", - })), - }); - render(); - fireEvent.change(screen.getByLabelText("Source branch"), { target: { value: "feat/x" } }); - fireEvent.change(screen.getByLabelText("Remote branch"), { target: { value: "dev" } }); - fireEvent.click(screen.getByRole("button", { name: "Push" })); - await waitFor(() => expect(screen.getByTestId("ggit-outcome")).toBeInTheDocument()); - expect(screen.getByTestId("ggit-outcome")).toHaveTextContent("push: Blocked"); - expect(screen.getByTestId("ggit-outcome")).toHaveTextContent("reason: policy-pack-blocked"); - }); - - it("surfaces a non-fast-forward rejection with its recovery hint as readable text", async () => { - const client = makeClient({ - pushExecute: vi.fn(async () => ({ - schemaVersion: "1" as const, - status: "failed" as const, - actionKind: "push", - executionErrorCode: "precondition-failed", - publishRejectionReason: "non-fast-forward", - recoveryDisposition: "user-fixable", - recoveryActionHint: "resolve-conflicts", - })), - }); - render(); - fireEvent.change(screen.getByLabelText("Source branch"), { target: { value: "feat/x" } }); - fireEvent.change(screen.getByLabelText("Remote branch"), { target: { value: "feat/x" } }); - fireEvent.click(screen.getByRole("button", { name: "Push" })); - await waitFor(() => expect(screen.getByTestId("ggit-outcome")).toBeInTheDocument()); - const outcome = screen.getByTestId("ggit-outcome"); - expect(outcome).toHaveTextContent("push: Failed"); - expect(outcome).toHaveTextContent("publish rejected: non-fast-forward"); - expect(outcome).toHaveTextContent("recover: resolve-conflicts"); - }); -}); diff --git a/packages/keiko-ui/src/app/components/desktop/widgets/cards/GovernedGitFlowCard.tsx b/packages/keiko-ui/src/app/components/desktop/widgets/cards/GovernedGitFlowCard.tsx deleted file mode 100644 index 2dc7fd729..000000000 --- a/packages/keiko-ui/src/app/components/desktop/widgets/cards/GovernedGitFlowCard.tsx +++ /dev/null @@ -1,1797 +0,0 @@ -"use client"; - -// Governed local Git flow surface (Issue #475, Epic #470). A per-project card that walks -// Branch (create / switch) → Staging (stage / unstage) → Commit composer (live preview + execute) -// entirely through the governed mutation kernel exposed by the BFF. Every mutation is content-free: -// the surface shows counts, structural area tokens, branch names, and typed warning / violation / -// finding codes — never diff content, raw paths, secrets, or the commit-message body. -// -// Severity and outcome are conveyed by TEXT + icon, never colour alone (WCAG 2.2 AA). Styling uses -// inline styles backed by existing CSS custom properties so globals.css is untouched (ADR-0051 gate). - -import { useCallback, useEffect, useId, useRef, useState } from "react"; -import type { CSSProperties, ReactNode } from "react"; -import type { - GitCommitMessageViolationCode, - GitCommitQualityWarningCode, -} from "@oscharko-dev/keiko-contracts"; -import { - ApiError, - cloneRepository as fetchCloneRepository, - createProject, - fetchGitBranches, - fetchGitDeliveryCommitExecute, - fetchGitDeliveryCommitPreview, - fetchGitDeliveryLocalBranchCreate, - fetchGitDeliveryLocalBranchSwitch, - fetchGitDeliveryPushExecute, - fetchGitDeliveryPushPreview, - fetchGitDeliveryStage, - fetchGitDeliveryUnstage, - fetchProjects, - type GitDeliveryCommitPreviewResponse, - type GitBranchListEntry, - type GitDeliveryMutationResponse, - type GitDeliveryPushPreviewResponse, -} from "@/lib/api"; -import type { ProjectWithAvailability } from "@/lib/types"; -import { Icons } from "../../Icons"; - -// The outcome of any governed action. Push execute adds the publish-rejection / recovery fields; they -// are optional so a branch/staging/commit outcome (which omits them) is assignable. -type GovernedGitOutcome = GitDeliveryMutationResponse & { - readonly publishRejectionReason?: string; - readonly recoveryDisposition?: string; - readonly recoveryActionHint?: string; -}; - -// ─── Injected client (DI seam for tests) ──────────────────────────────────────────────────────── - -export interface GovernedGitFlowClient { - readonly listRepositories: typeof fetchProjects; - readonly registerRepository: typeof createProject; - readonly cloneRepository: typeof fetchCloneRepository; - readonly listBranches: typeof fetchGitBranches; - readonly branchCreate: typeof fetchGitDeliveryLocalBranchCreate; - readonly branchSwitch: typeof fetchGitDeliveryLocalBranchSwitch; - readonly stage: typeof fetchGitDeliveryStage; - readonly unstage: typeof fetchGitDeliveryUnstage; - readonly commitPreview: typeof fetchGitDeliveryCommitPreview; - readonly commitExecute: typeof fetchGitDeliveryCommitExecute; - readonly pushPreview: typeof fetchGitDeliveryPushPreview; - readonly pushExecute: typeof fetchGitDeliveryPushExecute; -} - -const DEFAULT_CLIENT: GovernedGitFlowClient = { - listRepositories: fetchProjects, - registerRepository: createProject, - cloneRepository: fetchCloneRepository, - listBranches: fetchGitBranches, - branchCreate: fetchGitDeliveryLocalBranchCreate, - branchSwitch: fetchGitDeliveryLocalBranchSwitch, - stage: fetchGitDeliveryStage, - unstage: fetchGitDeliveryUnstage, - commitPreview: fetchGitDeliveryCommitPreview, - commitExecute: fetchGitDeliveryCommitExecute, - pushPreview: fetchGitDeliveryPushPreview, - pushExecute: fetchGitDeliveryPushExecute, -}; - -// ─── Label maps (typed codes → human text; never colour-alone) ────────────────────────────────── - -const WARNING_LABEL: Readonly> = { - "mixed-scope": "Mixed scope — changes span several areas", - "wip-marker": "Work-in-progress marker in the subject", - "large-change": "Large change — many files staged", - "empty-body": "No commit body", - "non-conventional-subject": "Subject is not a conventional-commit type", -}; - -const VIOLATION_LABEL: Readonly> = { - "empty-subject": "The subject line is empty", - "missing-conventional-prefix": "Missing a conventional-commit type prefix", - "disallowed-type": "The commit type is not allowed", - "subject-too-long": "The subject line is too long", - "missing-issue-key": "Missing the required issue key", - "missing-signoff": "Missing the Signed-off-by trailer", -}; - -const STATUS_LABEL: Readonly> = { - succeeded: "Succeeded", - blocked: "Blocked", - "approval-required": "Approval required", - failed: "Failed", - "recovery-required": "Recovery required", -}; - -function warningLabel(code: GitCommitQualityWarningCode): string { - return WARNING_LABEL[code]; -} - -function violationLabel(code: GitCommitMessageViolationCode): string { - return VIOLATION_LABEL[code]; -} - -function formatError(err: unknown): string { - if (err instanceof ApiError) return `${err.message} (${err.code})`; - if (err instanceof Error) return err.message; - return "An unexpected error occurred."; -} - -// ─── Shared inline-style tokens (CSS custom properties — globals.css untouched) ────────────────── - -const SHELL_STYLE: CSSProperties = { - display: "flex", - flexDirection: "column", - gap: "var(--space-5)", - minHeight: "100%", - padding: "var(--space-5)", - overflow: "auto", - background: "var(--surface-primary)", -}; - -const TOPBAR_STYLE: CSSProperties = { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - gap: "var(--space-5)", - flexWrap: "wrap", - minWidth: 0, -}; - -const EYEBROW_STYLE: CSSProperties = { - margin: "0 0 var(--space-2)", - font: "var(--weight-semibold) var(--text-caption) var(--font-mono)", - letterSpacing: "0.04em", - textTransform: "uppercase", - color: "var(--text-accent)", -}; - -const HEADING_STYLE: CSSProperties = { - margin: 0, - font: "var(--weight-semibold) var(--text-heading) var(--font-ui)", - color: "var(--text-primary)", -}; - -const SUBTLE_TEXT_STYLE: CSSProperties = { - margin: 0, - font: "var(--text-body-sm) var(--font-ui)", - color: "var(--text-secondary)", -}; - -const PANEL_STYLE: CSSProperties = { - display: "flex", - flexDirection: "column", - gap: "var(--space-5)", - padding: "var(--space-5)", - border: "1px solid var(--border-subtle)", - borderRadius: "var(--radius-surface)", - background: "var(--surface-primary)", -}; - -const PANEL_HEADER_STYLE: CSSProperties = { - display: "flex", - justifyContent: "space-between", - gap: "var(--space-5)", - alignItems: "flex-start", -}; - -const PANEL_TITLE_STYLE: CSSProperties = { - display: "flex", - alignItems: "center", - gap: "var(--space-3)", - margin: 0, - font: "var(--weight-semibold) var(--text-body) var(--font-ui)", - color: "var(--text-primary)", -}; - -const PANEL_COPY_STYLE: CSSProperties = { - margin: "var(--space-2) 0 0", - font: "var(--text-caption) var(--font-ui)", - color: "var(--text-secondary)", -}; - -const FIELD_STYLE: CSSProperties = { - width: "100%", - minHeight: "var(--control-height)", - padding: "0 var(--control-pad-x)", - border: "1px solid var(--input-border)", - borderRadius: "var(--radius-control)", - background: "var(--input-surface)", - color: "var(--input-text)", - font: "var(--text-body-sm) var(--font-ui)", - outline: "none", -}; - -const TEXTAREA_STYLE: CSSProperties = { - ...FIELD_STYLE, - minHeight: 96, - padding: "var(--space-5) var(--control-pad-x)", - resize: "vertical", -}; - -const ROW_STYLE: CSSProperties = { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 220px), 1fr))", - gap: "var(--space-5)", -}; - -const ACTION_ROW_STYLE: CSSProperties = { - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: "var(--space-4)", - flexWrap: "wrap", -}; - -const BUTTON_BASE: CSSProperties = { - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - gap: "var(--space-3)", - minHeight: "var(--control-height)", - padding: "0 var(--control-pad-x)", - borderRadius: "var(--radius-control)", - font: "var(--weight-medium) var(--text-body-sm) var(--font-ui)", - cursor: "pointer", -}; - -const PRIMARY_BTN: CSSProperties = { - ...BUTTON_BASE, - border: "1px solid transparent", - background: "var(--button-primary-surface)", - color: "var(--button-primary-text)", - fontWeight: "var(--weight-semibold)", -}; - -const SECONDARY_BTN: CSSProperties = { - ...BUTTON_BASE, - border: "1px solid var(--button-secondary-border)", - background: "var(--button-secondary-surface)", - color: "var(--button-secondary-text)", -}; - -const LABEL_STYLE: CSSProperties = { - display: "flex", - flexDirection: "column", - gap: "var(--space-3)", - minWidth: 0, - font: "var(--weight-semibold) var(--text-caption) var(--font-ui)", - letterSpacing: "0.02em", - textTransform: "uppercase", - color: "var(--text-faint)", -}; - -const LABEL_TEXT_STYLE: CSSProperties = { - display: "flex", - alignItems: "center", - gap: "var(--space-3)", -}; - -const CHECKBOX_LABEL_STYLE: CSSProperties = { - display: "flex", - alignItems: "center", - gap: "var(--space-4)", - width: "fit-content", - font: "var(--text-body-sm) var(--font-ui)", - color: "var(--text-secondary)", - cursor: "pointer", -}; - -const PREVIEW_STYLE: CSSProperties = { - display: "flex", - flexDirection: "column", - gap: "var(--space-4)", - padding: "var(--space-5)", - border: "1px solid var(--border-subtle)", - borderRadius: "var(--radius-control)", - background: "var(--surface-inset)", -}; - -const MONO_STYLE: CSSProperties = { - fontFamily: "var(--font-mono)", - fontSize: "var(--text-caption)", - color: "var(--text-accent)", -}; - -const VISUALLY_HIDDEN_STYLE: CSSProperties = { - position: "absolute", - width: 1, - height: 1, - overflow: "hidden", - clip: "rect(0 0 0 0)", -}; - -function disabledStyle(disabled: boolean): CSSProperties { - return disabled ? { opacity: "var(--opacity-disabled)", cursor: "not-allowed" } : {}; -} - -function PrimaryButton({ - disabled = false, - onClick, - children, -}: { - readonly disabled?: boolean; - readonly onClick: () => void; - readonly children: ReactNode; -}): ReactNode { - return ( - - ); -} - -function SecondaryButton({ - disabled = false, - onClick, - children, -}: { - readonly disabled?: boolean; - readonly onClick: () => void; - readonly children: ReactNode; -}): ReactNode { - return ( - - ); -} - -function StatusPill({ - children, - tone = "neutral", -}: { - readonly children: ReactNode; - readonly tone?: "neutral" | "accent" | "success" | "warning" | "danger" | "info"; -}): ReactNode { - const toneColor = - tone === "success" - ? "var(--feedback-success)" - : tone === "warning" - ? "var(--feedback-warning)" - : tone === "danger" - ? "var(--feedback-danger)" - : tone === "info" - ? "var(--feedback-info)" - : tone === "accent" - ? "var(--text-accent)" - : "var(--text-secondary)"; - return ( - - - ); -} - -function Panel({ - icon, - title, - description, - badge, - children, -}: { - readonly icon: ReactNode; - readonly title: string; - readonly description: string; - readonly badge?: ReactNode; - readonly children: ReactNode; -}): ReactNode { - return ( -
-
-
-

- {icon} - {title} -

-

{description}

-
- {badge} -
- {children} -
- ); -} - -function FieldLabel({ - label, - children, -}: { - readonly label: string; - readonly children: ReactNode; -}): ReactNode { - return ( - - ); -} - -// ─── Outcome banner (text + icon; never colour alone) ──────────────────────────────────────────── - -function MutationOutcome({ - outcome, - error, -}: { - readonly outcome: GovernedGitOutcome | null; - readonly error: string | null; -}): ReactNode { - if (error !== null) { - return ( -
- - Error - -

{error}

-
- ); - } - if (outcome === null) return null; - const tone = - outcome.status === "succeeded" - ? "success" - : outcome.status === "approval-required" || outcome.status === "recovery-required" - ? "warning" - : outcome.status === "blocked" || outcome.status === "failed" - ? "danger" - : "neutral"; - const codes = [ - ...(outcome.blockReason !== undefined ? [`reason: ${outcome.blockReason}`] : []), - ...(outcome.preflightFindingCodes ?? []).map((c) => `preflight: ${c}`), - ...(outcome.requiredApprovers ?? []).map((a) => `approver: ${a}`), - ...(outcome.executionErrorCode !== undefined ? [`error: ${outcome.executionErrorCode}`] : []), - ...(outcome.publishRejectionReason !== undefined - ? [`publish rejected: ${outcome.publishRejectionReason}`] - : []), - ...(outcome.recoveryActionHint !== undefined ? [`recover: ${outcome.recoveryActionHint}`] : []), - ...(outcome.messageViolations ?? []).map((v) => violationLabel(v)), - ]; - return ( -
- - {STATUS_LABEL[outcome.status]} - -

- {outcome.actionKind}: {STATUS_LABEL[outcome.status]} -

- {codes.length > 0 ? ( -
    - {codes.map((code) => ( -
  • - {code} -
  • - ))} -
- ) : null} -
- ); -} - -// ─── Branch section (create / switch) ──────────────────────────────────────────────────────────── - -interface BranchSectionProps { - readonly busy: boolean; - readonly branches: readonly GitBranchListEntry[]; - readonly branchesLoading: boolean; - readonly branchesError: string | null; - readonly onCreate: ( - branchName: string, - baseBranchName: string, - startPointRefHash: string, - ) => void; - readonly onSwitch: (branchName: string) => void; -} - -function BranchSection({ - busy, - branches, - branchesLoading, - branchesError, - onCreate, - onSwitch, -}: BranchSectionProps): ReactNode { - const [newBranch, setNewBranch] = useState(""); - const [baseBranch, setBaseBranch] = useState(""); - const [switchTo, setSwitchTo] = useState(""); - const currentBranch = branches.find((branch) => branch.current) ?? branches[0]; - const selectedBase = branches.find((branch) => branch.name === baseBranch) ?? currentBranch; - const selectedSwitch = branches.find((branch) => branch.name === switchTo) ?? currentBranch; - const canCreate = newBranch.trim() !== "" && selectedBase !== undefined; - const canSwitch = selectedSwitch !== undefined; - - useEffect(() => { - if (currentBranch === undefined) return; - setBaseBranch((value) => (value === "" ? currentBranch.name : value)); - setSwitchTo((value) => (value === "" ? currentBranch.name : value)); - }, [currentBranch]); - - return ( - } - title="Branch" - description="Create a reviewable branch from a selected local base branch, or switch the worktree to an existing local branch." - badge={ - 0 ? "accent" : "neutral"}> - {branchesLoading ? "Loading branches" : `${branches.length.toString()} local`} - - } - > - {branchesError !== null ? ( -
- - Branch list unavailable - -

{branchesError}

-
- ) : null} -
- - setNewBranch(e.target.value)} - aria-label="New branch name" - placeholder="feat/change-flow" - /> - - - - -
-
- - - -
-
-

- New branches start from the selected base branch; the technical start point is resolved - automatically. -

-
- { - if (selectedSwitch !== undefined) onSwitch(selectedSwitch.name); - }} - > - Switch branch - - { - if (selectedBase !== undefined) { - onCreate(newBranch.trim(), selectedBase.name, selectedBase.headRefHash); - } - }} - > - Create branch - -
-
-
- ); -} - -// ─── Staging section (stage / unstage) ──────────────────────────────────────────────────────────── - -interface StagingSectionProps { - readonly busy: boolean; - readonly onStage: (pathspecs: readonly string[], includeUntracked: boolean) => void; - readonly onUnstage: (pathspecs: readonly string[]) => void; -} - -function parsePathspecs(raw: string): readonly string[] { - return raw - .split("\n") - .map((line) => line.trim()) - .filter((line) => line !== ""); -} - -function StagingSection({ busy, onStage, onUnstage }: StagingSectionProps): ReactNode { - const [pathspecs, setPathspecs] = useState(""); - const [includeUntracked, setIncludeUntracked] = useState(false); - const parsed = parsePathspecs(pathspecs); - const hint = `${parsed.length.toString()} pathspec${parsed.length === 1 ? "" : "s"}`; - return ( - } - title="Staging" - description="Stage or unstage explicit pathspecs without exposing file content in the Git surface." - badge={ 0 ? "accent" : "neutral"}>{hint}} - > - -