From fd01bb7a7d48b0de5b6168f60907cd753564342f Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 19 Aug 2026 03:29:18 +0000 Subject: [PATCH 1/3] feat: @devframes/service-git wire service + git plugin refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the git plugin's git utilities into a new @devframes/service-git wire service and refactor the plugin to consume it. - services/git: createGitService({ cwd? }) → node API + devframes:service:git:* RPC (status/log/show/diff/branches/stage/unstage/commit) + advertisement. Single repo fixed at install (defaults to ctx.cwd, root discovered once). Exec wrapper + parsers stay internal; typed ops only, no raw passthrough. Write ops always exposed — authorization is the host's connection-trust boundary. Defines no dump/snapshot of its own. - core: DevframeDefinition.snapshotRpc lets a devframe bake an RPC it does not own (a service's) into its static build — string (no-arg) or { method, inputs: tuples | async (ctx) => tuples }. createBuild applies it before collecting; exported applySnapshotRpc; DF0072 warns on a missing target. - plugins/git: drops its node/git.ts + rpc layer and all git logic; declares the service (cwd from repoRoot at factory time) and opts the read ops into snapshotRpc (show enumerated at build time via the service node API). The React SPA calls devframes:service:git:* directly and imports types from @devframes/service-git. Drops --write — writes are always available, protected by the connection trust boundary. - docs: service-git in the built-in services list; snapshotRpc definition field; git plugin README. --- alias.ts | 1 + docs/guide/devframe-definition.md | 1 + docs/guide/services.md | 2 + .../src/adapters/__tests__/build.test.ts | 55 ++- packages/devframe/src/adapters/build.ts | 39 +- packages/devframe/src/node/diagnostics.ts | 5 + packages/devframe/src/types/devframe.ts | 28 ++ plugins/git/README.md | 44 +- plugins/git/package.json | 1 + .../components/commit-details-panel.tsx | 4 +- .../git/src/client/components/dashboard.tsx | 4 +- .../git/src/client/components/log-panel.tsx | 8 +- .../src/client/components/status-panel.tsx | 10 +- .../src/client/components/ui/status-mark.tsx | 2 +- .../views/branches-panel-view.stories.tsx | 2 +- .../components/views/branches-panel-view.tsx | 2 +- .../views/commit-details-view.stories.tsx | 2 +- .../components/views/commit-details-view.tsx | 2 +- .../views/diff-panel-view.stories.tsx | 4 +- .../components/views/diff-panel-view.tsx | 2 +- .../views/log-panel-view.stories.tsx | 4 +- .../components/views/log-panel-view.tsx | 2 +- .../views/status-panel-view.stories.tsx | 2 +- .../components/views/status-panel-view.tsx | 2 +- plugins/git/src/index.ts | 74 +-- plugins/git/src/rpc/context.ts | 60 --- plugins/git/src/rpc/functions/branches.ts | 91 ---- plugins/git/src/rpc/functions/commit.ts | 49 -- plugins/git/src/rpc/functions/diff.ts | 134 ------ plugins/git/src/rpc/functions/log.ts | 155 ------ plugins/git/src/rpc/functions/show.ts | 306 ------------ plugins/git/src/rpc/functions/stage.ts | 28 -- plugins/git/src/rpc/functions/status.ts | 181 ------- plugins/git/src/rpc/functions/unstage.ts | 28 -- plugins/git/src/rpc/index.ts | 19 - plugins/git/test/_repo.ts | 9 - plugins/git/test/_utils.ts | 8 + plugins/git/test/git.test.ts | 289 ++--------- pnpm-lock.yaml | 22 +- services/git/package.json | 46 ++ .../git/src/node => services/git/src}/git.ts | 2 +- services/git/src/index.ts | 218 +++++++++ services/git/src/operations.ts | 454 ++++++++++++++++++ services/git/src/types.ts | 197 ++++++++ services/git/test/_repo.ts | 75 +++ services/git/test/git.test.ts | 184 +++++++ services/git/tsconfig.json | 9 + services/git/tsdown.config.ts | 8 + .../@devframes/plugin-git/index.snapshot.d.ts | 130 ----- .../service-git/index.snapshot.d.ts | 161 +++++++ .../@devframes/service-git/index.snapshot.js | 16 + .../devframe/adapters/build.snapshot.d.ts | 1 + .../devframe/adapters/build.snapshot.js | 1 + .../tsnapi/devframe/index.snapshot.d.ts | 6 + .../tsnapi/devframe/internal.snapshot.d.ts | 6 + .../tsnapi/devframe/types.snapshot.d.ts | 2 + tsconfig.base.json | 3 + turbo.json | 5 + vitest.config.ts | 1 + 59 files changed, 1687 insertions(+), 1519 deletions(-) delete mode 100644 plugins/git/src/rpc/context.ts delete mode 100644 plugins/git/src/rpc/functions/branches.ts delete mode 100644 plugins/git/src/rpc/functions/commit.ts delete mode 100644 plugins/git/src/rpc/functions/diff.ts delete mode 100644 plugins/git/src/rpc/functions/log.ts delete mode 100644 plugins/git/src/rpc/functions/show.ts delete mode 100644 plugins/git/src/rpc/functions/stage.ts delete mode 100644 plugins/git/src/rpc/functions/status.ts delete mode 100644 plugins/git/src/rpc/functions/unstage.ts delete mode 100644 plugins/git/src/rpc/index.ts create mode 100644 services/git/package.json rename {plugins/git/src/node => services/git/src}/git.ts (97%) create mode 100644 services/git/src/index.ts create mode 100644 services/git/src/operations.ts create mode 100644 services/git/src/types.ts create mode 100644 services/git/test/_repo.ts create mode 100644 services/git/test/git.test.ts create mode 100644 services/git/tsconfig.json create mode 100644 services/git/tsdown.config.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/service-git/index.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/service-git/index.snapshot.js diff --git a/alias.ts b/alias.ts index 70c63c3b..a9e454a4 100644 --- a/alias.ts +++ b/alias.ts @@ -132,6 +132,7 @@ export const alias = { '@devframes/plugin-assets/cli': p('assets/src/cli.ts'), '@devframes/plugin-assets/vite': p('assets/src/vite.ts'), '@devframes/plugin-assets': p('assets/src/index.ts'), + '@devframes/service-git': s('git/src/index.ts'), '@devframes/service-open': s('open/src/index.ts'), '@devframes/service-shiki': s('shiki/src/index.ts'), } diff --git a/docs/guide/devframe-definition.md b/docs/guide/devframe-definition.md index 6057795b..e38f9422 100644 --- a/docs/guide/devframe-definition.md +++ b/docs/guide/devframe-definition.md @@ -54,6 +54,7 @@ export default defineDevframe({ | `duplicationStrategy` | `'warn' \| 'silent' \| 'throw' \| 'duplicate'` | How a hub reacts when another devframe sharing this `id` is mounted onto the same hub. Defaults to `'warn'`. See [Hub](./hub). Hub adapters consult it; standalone adapters ignore it. | | `capabilities` | `{ dev?, build? }` | Per-runtime feature flags. A `boolean` applies to the runtime as a whole; an object enables individual features. | | `services` | `DevframeServiceInput[]` | Wire services this devframe consumes — descriptors (`{ package, version?, required?, options? }`) the adapter imports against the plugin's own dependencies, or ready definitions. See [Cross-Plugin Services](./services#wire-services). | +| `snapshotRpc` | `(string \| { method, inputs })[]` | Opt an RPC function this devframe doesn't own (e.g. a wire service's) into the static build's dump. A bare method id bakes the no-argument call; `{ method, inputs }` bakes one record per argument-tuple, where `inputs` is a list of tuples or an async `(ctx) => tuples` provider (so it can enumerate at build time via the service's node API). The first tuple's result becomes the fallback. | | `setup` | `(ctx, info?) => void \| Promise` | **Required.** Server-side entry point. Runs in every runtime. The optional second argument carries runtime metadata — most notably the parsed CLI `flags` when running under `createCac`. | | `cli` | `DevframeCliOptions` | Defaults for the CLI adapter. See [CLI options](#cli-options) below. | diff --git a/docs/guide/services.md b/docs/guide/services.md index 3bd5c18c..c7bc3980 100644 --- a/docs/guide/services.md +++ b/docs/guide/services.md @@ -155,6 +155,8 @@ state.on('updated', render) **`@devframes/service-open`** (`devframes:service:open`) opens files in the user's editor (`open-in-editor`, with optional `line`/`column`) or reveals them in the OS file explorer (`open-in-finder`). Paths may be absolute or relative to the workspace root (so a client with only a workspace-relative path — a message's file position, say — calls it directly); the service refuses anything outside the workspace root and the configured extra `roots` (`DS_OPEN_0002`), and gates editor commands to the `KNOWN_EDITORS` picklist. Options: `{ editor?, roots? }` — the preferred editor (later installer wins) and additional openable directories (merged as a union). It supersedes the per-plugin `devframe/recipes/common-rpc-functions` registrations, now deprecated. +**`@devframes/service-git`** (`devframes:service:git`) runs read/write git operations over RPC — `status`, `log`, `show`, `diff`, `branches`, `stage`, `unstage`, `commit` — with parsed, typed results, so a devframe (the git plugin, or any tool) consumes git without shelling out itself. It operates on a single repo fixed at install (`{ cwd? }`, defaulting to the context cwd; root discovered once). Write ops are always exposed — authorization is the host's connection-trust boundary. The service defines no `dump`/`snapshot`; a devframe bakes the read ops it wants into a static build via [`snapshotRpc`](./devframe-definition). Client-supplied revisions are guarded against option injection. + **`@devframes/service-shiki`** (`devframes:service:shiki`) renders [Shiki](https://shiki.style) syntax highlighting on the server, so plugin bundles stop shipping grammars and themes. Three RPC queries — `highlight` (dual-theme HTML), `code-to-hast`, and `code-to-tokens` (for renderers that own their DOM, e.g. diff views) — all client-`cacheable` and LRU-cached server-side per `(code, lang, themes)`. Unknown languages degrade to plain text. Options: `{ themes?, langs? }` — the default light/dark pair (defaults `vitesse-light`/`vitesse-dark`, matching the design system; later installer wins) and languages to eagerly load (merged as a union). ## Services, RPC, or shared state? diff --git a/packages/devframe/src/adapters/__tests__/build.test.ts b/packages/devframe/src/adapters/__tests__/build.test.ts index 9701afad..13a80e20 100644 --- a/packages/devframe/src/adapters/__tests__/build.test.ts +++ b/packages/devframe/src/adapters/__tests__/build.test.ts @@ -1,7 +1,8 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { defineDevframe } from 'devframe' +import { s } from 'devframe/utils/simple-schema' import { describe, expect, it } from 'vitest' import { createBuild } from '../build' @@ -60,4 +61,56 @@ describe('adapters/build', () => { rmSync(outDir, { recursive: true, force: true }) } }) + + it('bakes snapshotRpc methods a devframe does not own into the dump', async () => { + const outDir = mkdtempSync(join(tmpdir(), 'devframe-build-test-out-')) + // A dump-less query RPC (as a wire service would register) that the + // devframe opts into baking via `snapshotRpc` — string (no-arg), static + // inputs, and an async provider. + const def = baseDevframe({ + setup: (ctx) => { + ctx.rpc.register({ name: 'demo:ping', type: 'query', jsonSerializable: true, handler: () => 'pong' }) + ctx.rpc.register({ + name: 'demo:echo', + type: 'query', + jsonSerializable: true, + args: [s.object({ value: s.string() })], + returns: s.object({ value: s.string() }), + handler: (input: { value: string }) => input, + }) + }, + snapshotRpc: [ + 'demo:ping', + { method: 'demo:echo', inputs: [[{ value: 'a' }]] }, + { method: 'demo:echo', inputs: async () => [[{ value: 'b' }]] }, + ], + }) + try { + await createBuild(def, { outDir }) + const manifest = JSON.parse(readFileSync(join(outDir, '__rpc-dump/index.json'), 'utf-8')) + // `demo:ping` baked its no-arg call + fallback (string form). + expect(manifest['demo:ping']?.type).toBe('query') + expect(manifest['demo:ping'].fallback).toBeTruthy() + // `demo:echo` baked a record per provided tuple (static + provider merged + // — the last snapshotRpc entry for a method wins). + expect(manifest['demo:echo']?.type).toBe('query') + expect(Object.keys(manifest['demo:echo'].records).length).toBeGreaterThanOrEqual(1) + } + finally { + rmSync(outDir, { recursive: true, force: true }) + } + }) + + it('warns (DF0072) when snapshotRpc names an unregistered method', async () => { + const outDir = mkdtempSync(join(tmpdir(), 'devframe-build-test-out-')) + try { + // Does not throw — a missing target is a warning, the build proceeds. + await expect( + createBuild(baseDevframe({ snapshotRpc: ['does:not:exist'] }), { outDir }), + ).resolves.toBeUndefined() + } + finally { + rmSync(outDir, { recursive: true, force: true }) + } + }) }) diff --git a/packages/devframe/src/adapters/build.ts b/packages/devframe/src/adapters/build.ts index 047f5c58..ae7b0702 100644 --- a/packages/devframe/src/adapters/build.ts +++ b/packages/devframe/src/adapters/build.ts @@ -1,5 +1,6 @@ /* eslint-disable no-console */ -import type { DevframeDefinition } from '../types/devframe' +import type { DevframeNodeContext } from '../types/context' +import type { DevframeDefinition, DevframeSnapshotRpcEntry } from '../types/devframe' import type { StaticAssetsSource } from '../types/remote-assets' import { existsSync } from 'node:fs' import fs from 'node:fs/promises' @@ -95,6 +96,11 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt await ctx.services.ready() await d.setup(ctx) + // Bake declared `snapshotRpc` methods (typically a wire service's RPC the + // devframe doesn't own) into the static dump by attaching a `dump` to their + // registered definitions — the service itself defines none. + applySnapshotRpc(ctx, d.snapshotRpc) + await fs.mkdir(resolve(outDir, DEVFRAME_RPC_DUMP_DIRNAME), { recursive: true }) const jsonSerializableMethods: string[] = [] @@ -134,3 +140,34 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt console.log(c.green`[devframe] built "${d.id}" -> ${outDir}`) } + +/** + * Attach a `dump` to each {@link DevframeDefinition.snapshotRpc} target so the + * static collector bakes it, even though the (service-owned) definition + * declares no dump of its own. A bare method id becomes `snapshot: true` + * (bakes the no-arg call); `{ method, inputs }` bakes one record per resolved + * argument-tuple by running the target's own handler, with the first tuple's + * output as the fallback. + */ +export function applySnapshotRpc(ctx: DevframeNodeContext, entries: readonly DevframeSnapshotRpcEntry[] | undefined): void { + for (const entry of entries ?? []) { + const method = typeof entry === 'string' ? entry : entry.method + const def = ctx.rpc.definitions.get(method) + if (!def) { + diagnostics.DF0072({ method }) + continue + } + if (typeof entry === 'string') { + def.snapshot = true + continue + } + const inputsSpec = entry.inputs + def.dump = async (dumpCtx: DevframeNodeContext, handler: (...args: any[]) => any) => { + const tuples = typeof inputsSpec === 'function' ? await inputsSpec(dumpCtx) : inputsSpec + const records = [] + for (const input of tuples) + records.push({ inputs: [...input] as any[], output: await handler(...input) }) + return { records, fallback: records[0]?.output } + } + } +} diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index 70c15d7e..afb913f5 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -194,5 +194,10 @@ export const diagnostics = defineDiagnostics({ `Invalid service "${p.package}": ${p.reason}`, fix: 'A service package\'s default export must be a factory returning a `DevframeServiceDefinition` — an object with `package`, `version`, `scope`, and a `setup` function.', }, + DF0072: { + why: (p: { method: string }) => + `\`snapshotRpc\` names "${p.method}", but no RPC function is registered under that id — nothing to bake into the static build.`, + fix: 'Check the method id, and ensure the service/plugin that registers it is installed (e.g. declared in `services`) before the build collects the dump.', + }, }, }) diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 2117c247..e4186c4c 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -363,7 +363,35 @@ export interface DevframeDefinition { * `client.services.has(pkg)` and degrade. */ services?: DevframeServiceInput[] + /** + * Opt an RPC function into the static-build snapshot **without owning its + * definition** — the mechanism a devframe uses to bake a wire service's + * RPC (e.g. `@devframes/service-git`'s `status`/`log`/`show`) into its + * `build` export, since the service itself defines no `dump`/`snapshot`. + * + * Each entry is either a bare method id (bakes the no-argument call, like + * `snapshot: true`) or `{ method, inputs }` where `inputs` is the list of + * argument-tuples to bake — or an async provider given the node context + * (so it can enumerate at build time, e.g. read commit hashes via the + * service's node API). `createBuild` resolves these after setup and + * executes the target's own handler per tuple; the first tuple's result + * becomes the fallback so any call variant resolves to a baked value. + */ + snapshotRpc?: DevframeSnapshotRpcEntry[] /** Server-side setup — the primary entrypoint. Runs in every runtime. */ setup: (ctx: DevframeNodeContext, info?: DevframeSetupInfo) => void | Promise cli?: DevframeCliOptions } + +/** Argument-tuples to bake for a {@link DevframeSnapshotRpcEntry}, or a provider that computes them at build time. */ +export type DevframeSnapshotRpcInputs + = readonly (readonly unknown[])[] + | ((ctx: DevframeNodeContext) => readonly (readonly unknown[])[] | Promise) + +/** + * One {@link DevframeDefinition.snapshotRpc} entry: a bare method id (bakes + * the no-argument call) or a method plus the argument-tuples to bake. + */ +export type DevframeSnapshotRpcEntry + = string + | { method: string, inputs: DevframeSnapshotRpcInputs } diff --git a/plugins/git/README.md b/plugins/git/README.md index 546c4ebf..3628e714 100644 --- a/plugins/git/README.md +++ b/plugins/git/README.md @@ -9,9 +9,10 @@ repository dashboard with a **Next.js App Router + shadcn/ui** SPA over type-safe RPC. The host process shells out to `git` and exposes the repository; the same bundle runs as a live dev server or a fully static deployment. -Status, a SourceTree-style **commit graph**, branches, and diffs are read-only; -staging, unstaging, and committing are available when write mode is enabled. The -UI follows the system **light/dark** preference with a manual toggle. +Status, a SourceTree-style **commit graph**, branches, and diffs, plus staging, +unstaging, and committing — all through the shared +[`@devframes/service-git`](../../services/git) wire service. The UI follows the +system **light/dark** preference with a manual toggle. ## Install @@ -25,7 +26,6 @@ Run the dashboard against the current repository: ```sh pnpx @devframes/plugin-git # dev server (live RPC over WebSocket) -pnpx @devframes/plugin-git --write # also enable staging / committing from the UI pnpx @devframes/plugin-git build # static deploy → dist-static/ pnpx @devframes/plugin-git --port 4000 ``` @@ -48,30 +48,32 @@ await createCac(createGitDevframe({ repoRoot: process.cwd() })).parse() | `basePath` | adapter-resolved | Mount path (`/` standalone, `/__git/` hosted). | | `distDir` | bundled SPA | Override the served SPA directory. | | `port` | `9710` | Preferred dev-server port. | -| `write` | `false` | Enable staging, unstaging, and committing from the UI. | ## RPC surface -The read functions are each a `query` with `snapshot: true`: resolved live over -WebSocket in dev, and served from a snapshot baked at build time for static -deploys. Each degrades to an empty, `isRepo: false` result outside a git -repository. - -- `devframes:plugin:git:status` — branch, upstream tracking (ahead/behind), staged / unstaged / - untracked files, parsed from `git status --porcelain=v2`. Reports `canWrite`. -- `devframes:plugin:git:log` — paginated commit history (`limit` / `skip`) including parent +All git work runs through the [`@devframes/service-git`](../../services/git) +wire service, which this devframe declares (`services`) and its SPA calls +directly over `devframes:service:git:*`. The read functions are `query` +functions that degrade to an empty, `isRepo: false` result outside a git +repository; the definition opts them into the static build via `snapshotRpc` +(resolved live over WebSocket in dev, served from a build-time snapshot for +static deploys). + +- `devframes:service:git:status` — branch, upstream tracking (ahead/behind), staged / unstaged / + untracked files, parsed from `git status --porcelain=v2`. +- `devframes:service:git:log` — paginated commit history (`limit` / `skip`) including parent hashes, which drive the commit graph. -- `devframes:plugin:git:branches` — local branches with SHA, upstream, ahead/behind, tip subject. -- `devframes:plugin:git:diff` — per-file added/deleted counts for the working tree or index, plus +- `devframes:service:git:branches` — local branches with SHA, upstream, ahead/behind, tip subject. +- `devframes:service:git:diff` — per-file added/deleted counts for the working tree or index, plus a unified patch for a selected file. -Write actions are `action` functions, registered only when write mode is enabled -(`createGitDevframe({ write: true })` or the `--write` flag) and gated behind -`status.canWrite` in the UI. Each returns fresh status (commit returns a result): +Write actions are `action` functions — always exposed by the service, with +write authorization governed by the host's connection-trust boundary. Each +returns fresh status (commit returns a result): -- `devframes:plugin:git:stage` — `git add` the given paths. -- `devframes:plugin:git:unstage` — `git restore --staged` the given paths. -- `devframes:plugin:git:commit` — commit the staged changes with a message. +- `devframes:service:git:stage` — `git add` the given paths. +- `devframes:service:git:unstage` — `git restore --staged` the given paths. +- `devframes:service:git:commit` — commit the staged changes with a message. ## Develop diff --git a/plugins/git/package.json b/plugins/git/package.json index b8b571e9..5725c635 100644 --- a/plugins/git/package.json +++ b/plugins/git/package.json @@ -55,6 +55,7 @@ } }, "dependencies": { + "@devframes/service-git": "workspace:*", "cac": "catalog:deps", "devframe": "workspace:*", "pathe": "catalog:deps" diff --git a/plugins/git/src/client/components/commit-details-panel.tsx b/plugins/git/src/client/components/commit-details-panel.tsx index d8860794..5527bc87 100644 --- a/plugins/git/src/client/components/commit-details-panel.tsx +++ b/plugins/git/src/client/components/commit-details-panel.tsx @@ -1,7 +1,7 @@ 'use client' +import type { CommitDetail } from '@devframes/service-git' import type { DevframeRpcClient } from 'devframe/client' -import type { CommitDetail } from '../../index' import { useCallback } from 'react' import { useRpcResource } from './use-rpc-resource' import { CommitDetailsView } from './views/commit-details-view' @@ -13,7 +13,7 @@ export interface CommitDetailsPanelProps { export function CommitDetailsPanel({ hash, onClose }: CommitDetailsPanelProps) { const loader = useCallback( - (rpc: DevframeRpcClient): Promise => rpc.call('devframes:plugin:git:show', { hash }), + (rpc: DevframeRpcClient): Promise => rpc.call('devframes:service:git:show', { hash }), [hash], ) const { data, loading, error } = useRpcResource(loader) diff --git a/plugins/git/src/client/components/dashboard.tsx b/plugins/git/src/client/components/dashboard.tsx index 47f672ce..77ac2d0d 100644 --- a/plugins/git/src/client/components/dashboard.tsx +++ b/plugins/git/src/client/components/dashboard.tsx @@ -1,8 +1,8 @@ 'use client' +import type { GitBranches } from '@devframes/service-git' import type { DevframeRpcClient } from 'devframe/client' import type { PointerEvent as ReactPointerEvent } from 'react' -import type { GitBranches } from '../../index' import { useCallback, useEffect, useRef, useState } from 'react' import { connectionIndicator, nav as navBar, navBrand, tab as tabClass, tabsList } from '../lib/design' import { CommitDetailsPanel } from './commit-details-panel' @@ -154,7 +154,7 @@ function DashboardBody() { const rightRail = useRailWidth('devframe-git:rail-right', 480, 340, 760, -1) - const branchesLoader = useCallback((rpc: DevframeRpcClient) => rpc.call('devframes:plugin:git:branches'), []) + const branchesLoader = useCallback((rpc: DevframeRpcClient) => rpc.call('devframes:service:git:branches'), []) const { data: branches, loading: branchesLoading, diff --git a/plugins/git/src/client/components/log-panel.tsx b/plugins/git/src/client/components/log-panel.tsx index ef684f3f..764481a9 100644 --- a/plugins/git/src/client/components/log-panel.tsx +++ b/plugins/git/src/client/components/log-panel.tsx @@ -1,7 +1,7 @@ 'use client' +import type { Commit } from '@devframes/service-git' import type { DevframeRpcClient } from 'devframe/client' -import type { Commit } from '../../index' import { useCallback, useEffect, useRef, useState } from 'react' import { useRpc } from './rpc-provider' import { LogPanelView } from './views/log-panel-view' @@ -40,7 +40,7 @@ export function LogPanel({ branch, selectedHash, onSelectCommit }: LogPanelProps setLoading(true) setError(null) try { - const page = await client.call('devframes:plugin:git:log', { + const page = await client.call('devframes:service:git:log', { limit: PAGE, skip: nextSkip, ref: branch ?? undefined, @@ -78,7 +78,7 @@ export function LogPanel({ branch, selectedHash, onSelectCommit }: LogPanelProps const loadStatus = useCallback(async (client: DevframeRpcClient) => { try { - const status = await client.call('devframes:plugin:git:status') + const status = await client.call('devframes:service:git:status') setCurrentBranch(status.branch) setWorkingChanges( status.staged.length + status.unstaged.length + status.untracked.length, @@ -116,7 +116,7 @@ export function LogPanel({ branch, selectedHash, onSelectCommit }: LogPanelProps (hash: string) => { if (!rpc) return Promise.reject(new Error('rpc unavailable')) - return rpc.call('devframes:plugin:git:show', { hash, patch: false }) + return rpc.call('devframes:service:git:show', { hash, patch: false }) }, [rpc], ) diff --git a/plugins/git/src/client/components/status-panel.tsx b/plugins/git/src/client/components/status-panel.tsx index af34a45e..a5e4f200 100644 --- a/plugins/git/src/client/components/status-panel.tsx +++ b/plugins/git/src/client/components/status-panel.tsx @@ -9,7 +9,7 @@ import { StatusPanelView } from './views/status-panel-view' function PatchViewer({ staged, path }: { staged: boolean, path: string }) { const loader = useCallback( - (rpc: DevframeRpcClient) => rpc.call('devframes:plugin:git:diff', { staged, path }), + (rpc: DevframeRpcClient) => rpc.call('devframes:service:git:diff', { staged, path }), [staged, path], ) const { data, loading } = useRpcResource(loader) @@ -30,7 +30,7 @@ function PatchViewer({ staged, path }: { staged: boolean, path: string }) { */ export function StatusPanel() { const { rpc } = useRpc() - const loader = useCallback((r: DevframeRpcClient) => r.call('devframes:plugin:git:status'), []) + const loader = useCallback((r: DevframeRpcClient) => r.call('devframes:service:git:status'), []) const { data, loading, refresh, setData } = useRpcResource(loader) const [busy, setBusy] = useState(false) const [message, setMessage] = useState('') @@ -45,7 +45,7 @@ export function StatusPanel() { setBusy(true) setNote(null) try { - setData(await rpc.call('devframes:plugin:git:stage', { paths })) + setData(await rpc.call('devframes:service:git:stage', { paths })) } finally { setBusy(false) @@ -58,7 +58,7 @@ export function StatusPanel() { setBusy(true) setNote(null) try { - setData(await rpc.call('devframes:plugin:git:unstage', { paths })) + setData(await rpc.call('devframes:service:git:unstage', { paths })) } finally { setBusy(false) @@ -71,7 +71,7 @@ export function StatusPanel() { setBusy(true) setNote(null) try { - const result = await rpc.call('devframes:plugin:git:commit', { message }) + const result = await rpc.call('devframes:service:git:commit', { message }) setData(result.status) if (result.ok) setMessage('') diff --git a/plugins/git/src/client/components/ui/status-mark.tsx b/plugins/git/src/client/components/ui/status-mark.tsx index 55f66f74..4ccf365f 100644 --- a/plugins/git/src/client/components/ui/status-mark.tsx +++ b/plugins/git/src/client/components/ui/status-mark.tsx @@ -1,4 +1,4 @@ -import type { FileStatusCode } from '../../../index' +import type { FileStatusCode } from '@devframes/service-git' import { cn } from '../../lib/utils' // A single-letter git status mark (A / M / D / R …), tinted by change kind — diff --git a/plugins/git/src/client/components/views/branches-panel-view.stories.tsx b/plugins/git/src/client/components/views/branches-panel-view.stories.tsx index 143da6e4..3f02fe3e 100644 --- a/plugins/git/src/client/components/views/branches-panel-view.stories.tsx +++ b/plugins/git/src/client/components/views/branches-panel-view.stories.tsx @@ -1,5 +1,5 @@ +import type { GitBranches } from '@devframes/service-git' import type { Meta, StoryObj } from '@storybook/react-vite' -import type { GitBranches } from '../../../index' import { BranchesPanelView } from './branches-panel-view' const data: GitBranches = { diff --git a/plugins/git/src/client/components/views/branches-panel-view.tsx b/plugins/git/src/client/components/views/branches-panel-view.tsx index 12a1b1ba..82e8e708 100644 --- a/plugins/git/src/client/components/views/branches-panel-view.tsx +++ b/plugins/git/src/client/components/views/branches-panel-view.tsx @@ -1,6 +1,6 @@ 'use client' -import type { Branch, GitBranches } from '../../../index' +import type { Branch, GitBranches } from '@devframes/service-git' import { Badge } from '../ui/badge' import { IconButton } from '../ui/button' import { Icon } from '../ui/icon' diff --git a/plugins/git/src/client/components/views/commit-details-view.stories.tsx b/plugins/git/src/client/components/views/commit-details-view.stories.tsx index 9803ce10..db2d7ad3 100644 --- a/plugins/git/src/client/components/views/commit-details-view.stories.tsx +++ b/plugins/git/src/client/components/views/commit-details-view.stories.tsx @@ -1,5 +1,5 @@ +import type { CommitDetail } from '@devframes/service-git' import type { Meta, StoryObj } from '@storybook/react-vite' -import type { CommitDetail } from '../../../index' import { CommitDetailsView } from './commit-details-view' const now = Date.now() diff --git a/plugins/git/src/client/components/views/commit-details-view.tsx b/plugins/git/src/client/components/views/commit-details-view.tsx index 0fe0e9ef..a91555ae 100644 --- a/plugins/git/src/client/components/views/commit-details-view.tsx +++ b/plugins/git/src/client/components/views/commit-details-view.tsx @@ -1,6 +1,6 @@ 'use client' -import type { CommitDetail } from '../../../index' +import type { CommitDetail } from '@devframes/service-git' import { Badge } from '../ui/badge' import { IconButton } from '../ui/button' import { FileIcon } from '../ui/file-icon' diff --git a/plugins/git/src/client/components/views/diff-panel-view.stories.tsx b/plugins/git/src/client/components/views/diff-panel-view.stories.tsx index cb64ef6a..510367da 100644 --- a/plugins/git/src/client/components/views/diff-panel-view.stories.tsx +++ b/plugins/git/src/client/components/views/diff-panel-view.stories.tsx @@ -1,5 +1,5 @@ +import type { GitDiff } from '@devframes/service-git' import type { Meta, StoryObj } from '@storybook/react-vite' -import type { GitDiff } from '../../../index' import { useState } from 'react' import { DiffPanelView, DiffPatchView } from './diff-panel-view' @@ -8,7 +8,7 @@ index 1234567..89abcde 100644 --- a/src/rpc/functions/log.ts +++ b/src/rpc/functions/log.ts @@ -72,7 +72,7 @@ export const log = defineRpcFunction({ - name: 'devframes:plugin:git:log', + name: 'devframes:service:git:log', type: 'query', - snapshot: true, + dump: async (_ctx, handler) => { /* bake head of history */ }, diff --git a/plugins/git/src/client/components/views/diff-panel-view.tsx b/plugins/git/src/client/components/views/diff-panel-view.tsx index 8cddb39c..b4856af0 100644 --- a/plugins/git/src/client/components/views/diff-panel-view.tsx +++ b/plugins/git/src/client/components/views/diff-panel-view.tsx @@ -1,8 +1,8 @@ 'use client' +import type { FileStatusCode, GitDiff } from '@devframes/service-git' import type { FileDiffMetadata, FileDiffOptions } from '@pierre/diffs' import type { ReactNode } from 'react' -import type { FileStatusCode, GitDiff } from '../../../index' import { parsePatchFiles } from '@pierre/diffs' import { FileDiff } from '@pierre/diffs/react' import { useMemo, useState } from 'react' diff --git a/plugins/git/src/client/components/views/log-panel-view.stories.tsx b/plugins/git/src/client/components/views/log-panel-view.stories.tsx index c6c29768..3aa55f2c 100644 --- a/plugins/git/src/client/components/views/log-panel-view.stories.tsx +++ b/plugins/git/src/client/components/views/log-panel-view.stories.tsx @@ -1,5 +1,5 @@ +import type { Commit, CommitDetail } from '@devframes/service-git' import type { Meta, StoryObj } from '@storybook/react-vite' -import type { Commit, CommitDetail } from '../../../index' import { LogPanelView } from './log-panel-view' const now = Date.now() @@ -25,7 +25,7 @@ const commits: Commit[] = [ { hash: 'c12', shortHash: 'c12f2cd', parents: [], author: 'Ada Lovelace', email: 'ada@example.dev', date: at(9000), subject: 'Add type safety to date ordering', body: 'Types the comparator so bad inputs fail at compile time.', refs: ['tag: v0.1.0'] }, ] -// Stand-in for the `devframes:plugin:git:show` call the live dashboard makes to fill the hover +// Stand-in for the `devframes:service:git:show` call the live dashboard makes to fill the hover // card. Derives plausible changed-file stats from the hash so each commit reads // distinctly. async function loadDetail(hash: string): Promise { diff --git a/plugins/git/src/client/components/views/log-panel-view.tsx b/plugins/git/src/client/components/views/log-panel-view.tsx index 1ddb141f..f10d47f4 100644 --- a/plugins/git/src/client/components/views/log-panel-view.tsx +++ b/plugins/git/src/client/components/views/log-panel-view.tsx @@ -1,6 +1,6 @@ 'use client' -import type { Commit, CommitDetail } from '../../../index' +import type { Commit, CommitDetail } from '@devframes/service-git' import type { GraphRow } from '../../lib/commit-graph' import type { GitRef } from '../../lib/refs' import { diff --git a/plugins/git/src/client/components/views/status-panel-view.stories.tsx b/plugins/git/src/client/components/views/status-panel-view.stories.tsx index 2677fc68..a5fa3cba 100644 --- a/plugins/git/src/client/components/views/status-panel-view.stories.tsx +++ b/plugins/git/src/client/components/views/status-panel-view.stories.tsx @@ -1,5 +1,5 @@ +import type { GitStatus } from '@devframes/service-git' import type { Meta, StoryObj } from '@storybook/react-vite' -import type { GitStatus } from '../../../index' import { useState } from 'react' import { StatusPanelView } from './status-panel-view' diff --git a/plugins/git/src/client/components/views/status-panel-view.tsx b/plugins/git/src/client/components/views/status-panel-view.tsx index a57609ef..29829552 100644 --- a/plugins/git/src/client/components/views/status-panel-view.tsx +++ b/plugins/git/src/client/components/views/status-panel-view.tsx @@ -1,7 +1,7 @@ 'use client' +import type { GitStatus, StatusFileEntry } from '@devframes/service-git' import type { ReactNode } from 'react' -import type { GitStatus, StatusFileEntry } from '../../../index' import { cn } from '../../lib/utils' import { Badge } from '../ui/badge' import { Button, IconButton } from '../ui/button' diff --git a/plugins/git/src/index.ts b/plugins/git/src/index.ts index 7fe26038..622090d3 100644 --- a/plugins/git/src/index.ts +++ b/plugins/git/src/index.ts @@ -1,18 +1,11 @@ +// Types-only: loads service-git's registry augmentation so +// `ctx.services.get('@devframes/service-git')` is typed in `snapshotRpc`. +import type {} from '@devframes/service-git' import type { DevframeDefinition, RemoteAssets } from 'devframe' +import process from 'node:process' import { defineDevframe } from 'devframe' import { resolve } from 'pathe' import pkg from '../package.json' with { type: 'json' } -import { configureGit } from './rpc/context.ts' -import { readFunctions, writeFunctions } from './rpc/index.ts' - -export type { Branch, GitBranches } from './rpc/functions/branches.ts' -export type { CommitArgs, CommitResult } from './rpc/functions/commit.ts' -export type { DiffArgs, DiffFile, GitDiff } from './rpc/functions/diff.ts' -export type { Commit, GitLog, LogArgs } from './rpc/functions/log.ts' -export type { CommitDetail, CommitFile, ShowArgs } from './rpc/functions/show.ts' -export type { StageArgs } from './rpc/functions/stage.ts' -export type { FileStatusCode, GitStatus, StatusFileEntry } from './rpc/functions/status.ts' -export type { UnstageArgs } from './rpc/functions/unstage.ts' // The Next.js static-export SPA ships in the lockstep // `@devframes/plugin-git--assets` package, served on demand through devframe's @@ -24,6 +17,8 @@ const remoteAssets: RemoteAssets = { version: pkg.version, } +const GIT_SERVICE = '@devframes/service-git' + export interface GitDevframeOptions { /** Repository directory to inspect. Defaults to the devframe `cwd`. */ repoRoot?: string @@ -36,11 +31,6 @@ export interface GitDevframeOptions { distDir?: string /** Preferred dev-server port (default 9710). */ port?: number - /** - * Enable staging, unstaging, and committing from the UI. Read-only by - * default; the standalone CLI also accepts a `--write` flag. - */ - write?: boolean /** * Require the trust handshake on the standalone server. Enabled by * default — `--open` embeds the current OTP in the opened URL, so the @@ -51,14 +41,22 @@ export interface GitDevframeOptions { } /** - * Create the Git dashboard devframe. Mount it into any host via devframe's - * adapters, or run it standalone with the bundled CLI (`devframe-git`). + * Create the Git dashboard devframe. All git work runs through the + * `@devframes/service-git` wire service (declared below); the SPA calls its + * `devframes:service:git:*` RPC directly. Mount it into any host via + * devframe's adapters, or run it standalone with the bundled CLI + * (`devframe-git`). * * @experimental This plugin is experimental and may change without a major * version bump until it stabilizes. */ export function createGitDevframe(options: GitDevframeOptions = {}): DevframeDefinition { const distDir = options.distDir ?? remoteAssets + // Resolved at factory time (process.cwd() here equals the adapter's ctx.cwd) + // so it can ride the declarative service descriptor; omit to let the service + // default to the context cwd. + const cwd = options.repoRoot ? resolve(process.cwd(), options.repoRoot) : undefined + return defineDevframe({ id: 'devframes_plugin_git', name: 'Git', @@ -76,23 +74,31 @@ export function createGitDevframe(options: GitDevframeOptions = {}): DevframeDef // Gate the standalone server by default; `maybeOpenBrowser` folds the // current OTP into the `--open` URL so the tab lands already trusted. auth: options.auth ?? true, - configure(cli) { - cli.option('--write', 'Enable staging, unstaging, and committing from the UI') - }, - }, - setup(ctx, info) { - const write = options.write ?? info?.flags?.write === true - configureGit(ctx, { - cwd: options.repoRoot ? resolve(options.repoRoot) : ctx.cwd, - write, - }) - for (const fn of readFunctions) - ctx.rpc.register(fn) - if (write) { - for (const fn of writeFunctions) - ctx.rpc.register(fn) - } }, + // The git service backs every panel; the SPA calls it directly. + services: [{ package: GIT_SERVICE, ...(cwd ? { options: { cwd } } : {}) }], + // Bake repo state into the static build. The service defines no dump of + // its own, so the read ops are opted in here: status/branches/diff bake + // their no-arg call; log bakes the 200-commit head; show bakes one + // (patch-less) record per commit, enumerated at build time via the + // service's node API. + snapshotRpc: [ + 'devframes:service:git:status', + 'devframes:service:git:branches', + 'devframes:service:git:diff', + { method: 'devframes:service:git:log', inputs: [[{ limit: 200 }]] }, + { + method: 'devframes:service:git:show', + inputs: async (ctx) => { + const git = ctx.services.get(GIT_SERVICE) + if (!git) + return [] + const { commits } = await git.log({ limit: 200 }) + return commits.map(commit => [{ hash: commit.hash, patch: false }]) + }, + }, + ], + setup() {}, }) } diff --git a/plugins/git/src/rpc/context.ts b/plugins/git/src/rpc/context.ts deleted file mode 100644 index 3958b9a7..00000000 --- a/plugins/git/src/rpc/context.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { DevframeNodeContext } from 'devframe' -import { resolveRepoRoot } from '../node/git.ts' - -export interface GitConfig { - /** Directory the dashboard inspects. */ - cwd: string - /** Whether staging / unstaging / committing actions are enabled. */ - write?: boolean -} - -export interface GitContext { - /** Directory the dashboard inspects. */ - readonly cwd: string - /** Whether write actions (stage / unstage / commit) are enabled. */ - readonly write: boolean - /** - * Resolve the repository root, memoized for the lifetime of the context. - * Resolves to `null` when `cwd` is not inside a git repository. - */ - resolveRoot: () => Promise -} - -const configs = new WeakMap() -const contexts = new WeakMap() - -/** - * Record the working directory for a context. Called from the devframe - * `setup` before any RPC handler runs, so {@link getGitContext} can honor a - * `repoRoot` override instead of the raw `ctx.cwd`. - */ -export function configureGit(ctx: DevframeNodeContext, config: GitConfig): void { - configs.set(ctx, config) -} - -/** - * Per-`DevframeNodeContext` git state. Each RPC function file pulls its - * working directory and (memoized) repo-root lookup from here instead of - * re-resolving on every call. - */ -export function getGitContext(ctx: DevframeNodeContext): GitContext { - let existing = contexts.get(ctx) - if (existing) - return existing - - const config = configs.get(ctx) - const cwd = config?.cwd ?? ctx.cwd - const write = config?.write ?? false - let rootPromise: Promise | undefined - - existing = { - cwd, - write, - resolveRoot: () => { - rootPromise ??= resolveRepoRoot(cwd) - return rootPromise - }, - } - contexts.set(ctx, existing) - return existing -} diff --git a/plugins/git/src/rpc/functions/branches.ts b/plugins/git/src/rpc/functions/branches.ts deleted file mode 100644 index fdafc724..00000000 --- a/plugins/git/src/rpc/functions/branches.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { defineRpcFunction } from 'devframe' -import { splitClean, tryGit, UNIT } from '../../node/git.ts' -import { getGitContext } from '../context.ts' - -export interface Branch { - name: string - current: boolean - sha: string - upstream: string | null - subject: string - ahead: number - behind: number - /** `true` when the upstream branch no longer exists. */ - gone: boolean -} - -export interface GitBranches { - isRepo: boolean - current: string | null - branches: Branch[] -} - -const FORMAT = [ - '%(refname:short)', - '%(objectname:short)', - '%(HEAD)', // '*' on the checked-out branch, ' ' otherwise - '%(upstream:short)', - '%(upstream:track)', // e.g. "[ahead 2, behind 1]", "[gone]", or "" - '%(contents:subject)', -].join(UNIT) - -function parseTrack(track: string): { ahead: number, behind: number, gone: boolean } { - if (track.includes('gone')) - return { ahead: 0, behind: 0, gone: true } - const ahead = track.match(/ahead (\d+)/) - const behind = track.match(/behind (\d+)/) - return { - ahead: ahead ? Number(ahead[1]) : 0, - behind: behind ? Number(behind[1]) : 0, - gone: false, - } -} - -export const branches = defineRpcFunction({ - name: 'devframes:plugin:git:branches', - type: 'query', - snapshot: true, - jsonSerializable: true, - agent: { - description: 'List local and remote branches of the inspected repository with tracking state (ahead/behind, gone upstreams) and the current branch. Safe to call freely.', - title: 'Git branches', - }, - setup: (ctx) => { - const git = getGitContext(ctx) - return { - handler: async (): Promise => { - const root = await git.resolveRoot() - if (!root) - return { isRepo: false, current: null, branches: [] } - - const raw = await tryGit(git.cwd, [ - 'for-each-ref', - `--format=${FORMAT}`, - 'refs/heads', - ]) - if (!raw) - return { isRepo: true, current: null, branches: [] } - - let current: string | null = null - const branches: Branch[] = splitClean(raw, '\n').map((line) => { - const [name, sha, head, upstream, track, subject] = line.split(UNIT) - const isCurrent = head === '*' - if (isCurrent) - current = name - return { - name, - current: isCurrent, - sha, - upstream: upstream || null, - subject: subject ?? '', - ...parseTrack(track ?? ''), - } - }) - - // Surface the current branch first, then the rest in ref order. - branches.sort((a, b) => Number(b.current) - Number(a.current)) - return { isRepo: true, current, branches } - }, - } - }, -}) diff --git a/plugins/git/src/rpc/functions/commit.ts b/plugins/git/src/rpc/functions/commit.ts deleted file mode 100644 index d2bcf4eb..00000000 --- a/plugins/git/src/rpc/functions/commit.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { GitStatus } from './status.ts' -import { defineRpcFunction } from 'devframe' -import { gitErrorMessage, runGit, tryGit } from '../../node/git.ts' -import { getGitContext } from '../context.ts' -import { readStatus } from './status.ts' - -export interface CommitArgs { - /** Commit message. */ - message: string -} - -export interface CommitResult { - /** `true` when the commit succeeded. */ - ok: boolean - /** Short hash of the new commit, or `null` on failure. */ - hash: string | null - /** Human-readable outcome (e.g. "nothing to commit"). */ - message: string - /** Working-tree status after the attempt. */ - status: GitStatus -} - -export const commit = defineRpcFunction({ - name: 'devframes:plugin:git:commit', - type: 'action', - jsonSerializable: true, - setup: (ctx) => { - const git = getGitContext(ctx) - return { - handler: async (args: CommitArgs): Promise => { - const message = (args?.message ?? '').trim() - const root = await git.resolveRoot() - if (!root) - return { ok: false, hash: null, message: 'Not a git repository.', status: await readStatus(git) } - if (!message) - return { ok: false, hash: null, message: 'Commit message is required.', status: await readStatus(git) } - - try { - await runGit(git.cwd, ['commit', '-m', message]) - const hash = await tryGit(git.cwd, ['rev-parse', '--short', 'HEAD']) - return { ok: true, hash, message: 'Committed.', status: await readStatus(git) } - } - catch (error) { - return { ok: false, hash: null, message: gitErrorMessage(error), status: await readStatus(git) } - } - }, - } - }, -}) diff --git a/plugins/git/src/rpc/functions/diff.ts b/plugins/git/src/rpc/functions/diff.ts deleted file mode 100644 index f1de519e..00000000 --- a/plugins/git/src/rpc/functions/diff.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { defineRpcFunction } from 'devframe' -import { s } from 'devframe/utils/simple-schema' -import { runGit, splitClean, tryGit } from '../../node/git.ts' -import { getGitContext } from '../context.ts' - -/** Hard cap on the returned patch text to keep payloads bounded. */ -const PATCH_CHAR_LIMIT = 200_000 - -export interface DiffFile { - path: string - additions: number - deletions: number - binary: boolean -} - -export interface GitDiff { - isRepo: boolean - staged: boolean - path: string | null - files: DiffFile[] - totalAdditions: number - totalDeletions: number - /** Unified patch text — populated when `path` targets a single file. */ - patch: string | null - /** `true` when `patch` was clipped to {@link PATCH_CHAR_LIMIT}. */ - truncated: boolean -} - -const diffFileSchema = s.object({ - path: s.string(), - additions: s.number(), - deletions: s.number(), - binary: s.boolean(), -}) - -const gitDiffSchema = s.object({ - isRepo: s.boolean(), - staged: s.boolean(), - path: s.nullable(s.string()), - files: s.array(diffFileSchema), - totalAdditions: s.number(), - totalDeletions: s.number(), - patch: s.nullable(s.string()), - truncated: s.boolean(), -}) - -export interface DiffArgs { - /** Limit the diff to a single path; omit for the whole tree. */ - path?: string - /** Diff the index against HEAD instead of the working tree. */ - staged?: boolean -} - -function parseNumstat(raw: string): DiffFile[] { - return splitClean(raw, '\n').map((line) => { - const [add, del, ...rest] = line.split('\t') - const binary = add === '-' || del === '-' - return { - path: rest.join('\t'), - additions: binary ? 0 : Number(add), - deletions: binary ? 0 : Number(del), - binary, - } - }) -} - -export const diff = defineRpcFunction({ - name: 'devframes:plugin:git:diff', - type: 'query', - snapshot: true, - jsonSerializable: true, - args: [s.object({ - path: s.optional(s.string()), - staged: s.optional(s.boolean()), - })], - returns: gitDiffSchema, - agent: { - description: 'Unified diff of uncommitted changes in the inspected repository — the working tree by default, the index with staged: true, one file with path. Call before summarizing or reviewing in-progress work. Safe to call freely.', - title: 'Git diff', - }, - setup: (ctx) => { - const git = getGitContext(ctx) - return { - handler: async (args: DiffArgs = {}): Promise => { - const { path, staged = false } = args - const root = await git.resolveRoot() - if (!root) { - return { - isRepo: false, - staged, - path: path ?? null, - files: [], - totalAdditions: 0, - totalDeletions: 0, - patch: null, - truncated: false, - } - } - - const base = staged ? ['diff', '--cached'] : ['diff'] - const scope = path ? ['--', path] : [] - - const numstatRaw = await tryGit(git.cwd, [...base, '--numstat', ...scope]) - const files = numstatRaw ? parseNumstat(numstatRaw) : [] - const totalAdditions = files.reduce((sum, f) => sum + f.additions, 0) - const totalDeletions = files.reduce((sum, f) => sum + f.deletions, 0) - - let patch: string | null = null - let truncated = false - if (path) { - const { stdout } = await runGit(git.cwd, [...base, ...scope]) - if (stdout.length > PATCH_CHAR_LIMIT) { - patch = stdout.slice(0, PATCH_CHAR_LIMIT) - truncated = true - } - else { - patch = stdout - } - } - - return { - isRepo: true, - staged, - path: path ?? null, - files, - totalAdditions, - totalDeletions, - patch, - truncated, - } - }, - } - }, -}) diff --git a/plugins/git/src/rpc/functions/log.ts b/plugins/git/src/rpc/functions/log.ts deleted file mode 100644 index dd4360b0..00000000 --- a/plugins/git/src/rpc/functions/log.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { defineRpcFunction } from 'devframe' -import { s } from 'devframe/utils/simple-schema' -import { isSafeRevision, RECORD, splitClean, tryGit, UNIT } from '../../node/git.ts' -import { getGitContext } from '../context.ts' - -export interface Commit { - hash: string - shortHash: string - author: string - email: string - /** Author date as epoch milliseconds. */ - date: number - subject: string - body: string - /** Ref names pointing at this commit (branches, tags, HEAD). */ - refs: string[] - /** Full parent hashes — drives the commit graph. */ - parents: string[] -} - -export interface GitLog { - isRepo: boolean - commits: Commit[] - limit: number - skip: number - /** `true` when the page filled to `limit`, hinting at further history. */ - hasMore: boolean -} - -const commitSchema = s.object({ - hash: s.string(), - shortHash: s.string(), - author: s.string(), - email: s.string(), - date: s.number(), - subject: s.string(), - body: s.string(), - refs: s.array(s.string()), - parents: s.array(s.string()), -}) - -const gitLogSchema = s.object({ - isRepo: s.boolean(), - commits: s.array(commitSchema), - limit: s.number(), - skip: s.number(), - hasMore: s.boolean(), -}) - -export interface LogArgs { - /** Number of commits to return (clamped to 1–200, default 30). */ - limit?: number - /** Commits to skip from the tip, for pagination (default 0). */ - skip?: number - /** Optional ref/branch to read history from (default: current HEAD). */ - ref?: string -} - -// Stable, parseable format: unit-separated fields, record-separated commits. -const FORMAT = [ - '%H', // full hash - '%h', // short hash - '%P', // parent hashes (space-separated) - '%an', // author name - '%ae', // author email - '%aI', // author date, strict ISO 8601 - '%D', // ref names - '%s', // subject - '%b', // body -].join(UNIT) + RECORD - -function clamp(value: number, min: number, max: number): number { - return Math.min(Math.max(value, min), max) -} - -function parseLog(raw: string): Commit[] { - return splitClean(raw, RECORD).map((record) => { - const [hash, shortHash, parents, author, email, isoDate, refs, subject, body] = record - .replace(/^\n/, '') - .split(UNIT) - return { - hash, - shortHash, - author, - email, - date: Date.parse(isoDate), - subject, - body: (body ?? '').trim(), - refs: refs ? refs.split(', ').map(r => r.trim()).filter(Boolean) : [], - parents: parents ? parents.split(' ').filter(Boolean) : [], - } - }) -} - -// Largest page git's handler will return (`limit` is clamped to 1–200). Static -// builds bake this many commits so the dashboard shows real history offline. -const SNAPSHOT_LIMIT = 200 - -export const log = defineRpcFunction({ - name: 'devframes:plugin:git:log', - type: 'query', - jsonSerializable: true, - args: [s.object({ - limit: s.optional(s.number()), - skip: s.optional(s.number()), - ref: s.optional(s.string()), - })], - returns: gitLogSchema, - agent: { - description: 'Commit history of the inspected repository, newest first. Paginate with limit (1-200, default 30) and skip; pass ref to read another branch. Call before reasoning about recent changes. Safe to call freely.', - title: 'Git log', - }, - // A static build can't run git on demand, so bake the head of history (up to - // `SNAPSHOT_LIMIT`) as the snapshot. Every client call resolves to this baked - // page via the fallback; since a static bundle has no further page to fetch, - // it reports `hasMore: false` so the UI shows everything it has in one shot. - dump: async (_ctx, handler: (args: LogArgs) => GitLog | Promise) => { - const output = await handler({ limit: SNAPSHOT_LIMIT, skip: 0 }) - const baked: GitLog = { ...output, hasMore: false } - // `RETURN` carries the handler's `Promise`, while dump records hold - // the already-resolved value — assert past that wrapper mismatch. - return { records: [{ inputs: [], output: baked }], fallback: baked } as any - }, - setup: (ctx) => { - const git = getGitContext(ctx) - return { - handler: async (args: LogArgs = {}): Promise => { - const limit = clamp(Math.trunc(args.limit ?? 30), 1, 200) - const skip = Math.max(0, Math.trunc(args.skip ?? 0)) - const ref = args.ref?.trim() || undefined - const root = await git.resolveRoot() - if (!root) - return { isRepo: false, commits: [], limit, skip, hasMore: false } - - const command = [ - 'log', - '--topo-order', - `--max-count=${limit}`, - `--skip=${skip}`, - `--pretty=format:${FORMAT}`, - ] - if (ref) { - if (!isSafeRevision(ref)) - return { isRepo: true, commits: [], limit, skip, hasMore: false } - command.push('--end-of-options', ref) - } - - const raw = await tryGit(git.cwd, command) - // `null` happens on a repo with no commits yet — treat as empty. - const commits = raw ? parseLog(raw) : [] - return { isRepo: true, commits, limit, skip, hasMore: commits.length === limit } - }, - } - }, -}) diff --git a/plugins/git/src/rpc/functions/show.ts b/plugins/git/src/rpc/functions/show.ts deleted file mode 100644 index d4117ff6..00000000 --- a/plugins/git/src/rpc/functions/show.ts +++ /dev/null @@ -1,306 +0,0 @@ -import type { GitContext } from '../context.ts' -import type { FileStatusCode } from './status.ts' -import { defineRpcFunction } from 'devframe' -import { s } from 'devframe/utils/simple-schema' -import { isSafeRevision, splitClean, tryGit, UNIT } from '../../node/git.ts' -import { getGitContext } from '../context.ts' - -/** Hard cap on the returned patch text to keep payloads bounded. */ -const PATCH_CHAR_LIMIT = 200_000 - -/** Matches the window `devframes:plugin:git:log` bakes, so any visible commit has a snapshot. */ -const SNAPSHOT_LIMIT = 200 - -/** Read-only git detail work can run in parallel without overwhelming builds. */ -const DUMP_CONCURRENCY = 8 - -export interface CommitFile { - path: string - additions: number - deletions: number - binary: boolean - /** Change kind relative to the parent (add / modify / delete / rename …). */ - status: FileStatusCode -} - -export interface CommitDetail { - /** `false` when the working directory is not inside a git repository. */ - isRepo: boolean - /** `false` when the hash does not resolve to a commit. */ - found: boolean - hash: string - shortHash: string - author: string - email: string - /** Author date as epoch milliseconds. */ - date: number - committer: string - committerEmail: string - /** Commit date as epoch milliseconds. */ - commitDate: number - subject: string - body: string - parents: string[] - refs: string[] - files: CommitFile[] - totalAdditions: number - totalDeletions: number - /** Unified patch text for the commit, or `null` when omitted/unavailable. */ - patch: string | null - /** `true` when `patch` was clipped to {@link PATCH_CHAR_LIMIT}. */ - truncated: boolean -} - -const fileStatusCodeSchema = s.picklist([ - 'modified', - 'added', - 'deleted', - 'renamed', - 'copied', - 'type-changed', - 'unmerged', - 'unknown', -]) - -const commitFileSchema = s.object({ - path: s.string(), - additions: s.number(), - deletions: s.number(), - binary: s.boolean(), - status: fileStatusCodeSchema, -}) - -const commitDetailSchema = s.object({ - isRepo: s.boolean(), - found: s.boolean(), - hash: s.string(), - shortHash: s.string(), - author: s.string(), - email: s.string(), - date: s.number(), - committer: s.string(), - committerEmail: s.string(), - commitDate: s.number(), - subject: s.string(), - body: s.string(), - parents: s.array(s.string()), - refs: s.array(s.string()), - files: s.array(commitFileSchema), - totalAdditions: s.number(), - totalDeletions: s.number(), - patch: s.nullable(s.string()), - truncated: s.boolean(), -}) - -export interface ShowArgs { - /** Commit-ish to inspect (full or short hash). */ - hash: string - /** Include the full unified patch (default `true`). */ - patch?: boolean -} - -const EMPTY_DETAIL: CommitDetail = { - isRepo: false, - found: false, - hash: '', - shortHash: '', - author: '', - email: '', - date: 0, - committer: '', - committerEmail: '', - commitDate: 0, - subject: '', - body: '', - parents: [], - refs: [], - files: [], - totalAdditions: 0, - totalDeletions: 0, - patch: null, - truncated: false, -} - -const SHOW_FORMAT = [ - '%H', // full hash - '%h', // short hash - '%P', // parent hashes - '%an', // author name - '%ae', // author email - '%aI', // author date, ISO 8601 - '%cn', // committer name - '%ce', // committer email - '%cI', // committer date, ISO 8601 - '%D', // ref names - '%s', // subject - '%b', // body -].join(UNIT) - -function mapStatusCode(code: string): FileStatusCode { - switch (code[0]) { - case 'M': return 'modified' - case 'A': return 'added' - case 'D': return 'deleted' - case 'R': return 'renamed' - case 'C': return 'copied' - case 'T': return 'type-changed' - case 'U': return 'unmerged' - default: return 'unknown' - } -} - -/** - * Parse `git diff-tree --name-status` into a `path → status` map. Rename/copy - * rows carry a similarity score and both old + new paths; the new path (last - * field) keys the map so it aligns with the numstat entry. - */ -function parseNameStatus(raw: string): Map { - const map = new Map() - for (const line of splitClean(raw, '\n')) { - const [code, ...paths] = line.split('\t') - const path = paths[paths.length - 1] - if (path) - map.set(path, mapStatusCode(code)) - } - return map -} - -function parseNumstat(raw: string, status: Map): CommitFile[] { - return splitClean(raw, '\n').map((line) => { - const [add, del, ...rest] = line.split('\t') - const binary = add === '-' || del === '-' - const path = rest.join('\t') - return { - path, - additions: binary ? 0 : Number(add), - deletions: binary ? 0 : Number(del), - binary, - status: status.get(path) ?? 'modified', - } - }) -} - -async function readCommit(git: GitContext, hash: string, includePatch: boolean): Promise { - if (!isSafeRevision(hash)) - return { ...EMPTY_DETAIL, isRepo: true } - - const meta = await tryGit(git.cwd, ['show', '-s', `--format=${SHOW_FORMAT}`, '--end-of-options', hash]) - if (meta == null) - return { ...EMPTY_DETAIL, isRepo: true } - - const [ - fullHash, - shortHash, - parents, - author, - email, - authorDate, - committer, - committerEmail, - committerDate, - refs, - subject, - body, - ] = meta.split(UNIT) - - // `--root` so the initial commit reports its full tree as additions. - const numstat = await tryGit(git.cwd, ['diff-tree', '--no-commit-id', '--numstat', '-r', '--root', '--end-of-options', hash]) - const nameStatusRaw = await tryGit(git.cwd, ['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', '--end-of-options', hash]) - const files = numstat ? parseNumstat(numstat, nameStatusRaw ? parseNameStatus(nameStatusRaw) : new Map()) : [] - const totalAdditions = files.reduce((sum, f) => sum + f.additions, 0) - const totalDeletions = files.reduce((sum, f) => sum + f.deletions, 0) - - let patch: string | null = null - let truncated = false - if (includePatch) { - const raw = await tryGit(git.cwd, ['diff-tree', '-p', '--no-commit-id', '-r', '--root', '--end-of-options', hash]) - if (raw != null) { - if (raw.length > PATCH_CHAR_LIMIT) { - patch = raw.slice(0, PATCH_CHAR_LIMIT) - truncated = true - } - else { - patch = raw - } - } - } - - return { - isRepo: true, - found: true, - hash: fullHash, - shortHash, - author, - email, - date: Date.parse(authorDate), - committer, - committerEmail, - commitDate: Date.parse(committerDate), - subject, - body: (body ?? '').trim(), - parents: parents ? parents.split(' ').filter(Boolean) : [], - refs: refs ? refs.split(', ').map(r => r.trim()).filter(Boolean) : [], - files, - totalAdditions, - totalDeletions, - patch, - truncated, - } -} - -export const show = defineRpcFunction({ - name: 'devframes:plugin:git:show', - type: 'query', - jsonSerializable: true, - args: [s.object({ - hash: s.string(), - patch: s.optional(s.boolean()), - })], - returns: commitDetailSchema, - agent: { - description: 'Full detail of one commit by hash (from the git log tool): metadata, changed files, and the unified patch (pass patch: false to skip it for large commits). Safe to call freely.', - title: 'Git show', - }, - // Static builds can't run git per click, so bake one record per commit in the - // same window `devframes:plugin:git:log` snapshots. Patches are omitted from the baked records - // to keep the bundle bounded — static detail panels show metadata + files. - dump: async (ctx, _handler: (args: ShowArgs) => CommitDetail | Promise) => { - const git = getGitContext(ctx) - const root = await git.resolveRoot() - if (!root) - return { records: [], fallback: EMPTY_DETAIL } as any - - const raw = await tryGit(git.cwd, [ - 'log', - '--topo-order', - `--max-count=${SNAPSHOT_LIMIT}`, - '--pretty=format:%H', - ]) - const hashes = raw ? raw.split('\n').filter(Boolean) : [] - - const records: { inputs: [{ hash: string }], output: CommitDetail }[] = [] - for (let i = 0; i < hashes.length; i += DUMP_CONCURRENCY) { - const batch = hashes.slice(i, i + DUMP_CONCURRENCY) - const outputs = await Promise.all(batch.map(hash => readCommit(git, hash, false))) - batch.forEach((hash, index) => { - records.push({ inputs: [{ hash }], output: outputs[index] }) - }) - } - - const fallback = records[0]?.output ?? { ...EMPTY_DETAIL, isRepo: true } - return { records, fallback } as any - }, - setup: (ctx) => { - const git = getGitContext(ctx) - return { - handler: async (args: ShowArgs): Promise => { - const hash = (args?.hash ?? '').trim() - const includePatch = args?.patch ?? true - const root = await git.resolveRoot() - if (!root || !hash) - return EMPTY_DETAIL - return readCommit(git, hash, includePatch) - }, - } - }, -}) diff --git a/plugins/git/src/rpc/functions/stage.ts b/plugins/git/src/rpc/functions/stage.ts deleted file mode 100644 index 8c1e11e9..00000000 --- a/plugins/git/src/rpc/functions/stage.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { GitStatus } from './status.ts' -import { defineRpcFunction } from 'devframe' -import { runGit } from '../../node/git.ts' -import { getGitContext } from '../context.ts' -import { readStatus } from './status.ts' - -export interface StageArgs { - /** Paths to stage (`git add`). */ - paths: string[] -} - -export const stage = defineRpcFunction({ - name: 'devframes:plugin:git:stage', - type: 'action', - jsonSerializable: true, - setup: (ctx) => { - const git = getGitContext(ctx) - return { - handler: async (args: StageArgs): Promise => { - const paths = args?.paths ?? [] - const root = await git.resolveRoot() - if (root && paths.length > 0) - await runGit(git.cwd, ['add', '--', ...paths]) - return readStatus(git) - }, - } - }, -}) diff --git a/plugins/git/src/rpc/functions/status.ts b/plugins/git/src/rpc/functions/status.ts deleted file mode 100644 index 6ada1136..00000000 --- a/plugins/git/src/rpc/functions/status.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { GitContext } from '../context.ts' -import { defineRpcFunction } from 'devframe' -import { runGit } from '../../node/git.ts' -import { getGitContext } from '../context.ts' - -export type FileStatusCode - = | 'modified' - | 'added' - | 'deleted' - | 'renamed' - | 'copied' - | 'type-changed' - | 'unmerged' - | 'unknown' - -export interface StatusFileEntry { - path: string - /** Previous path, present for renames and copies. */ - from?: string - status: FileStatusCode -} - -export interface GitStatus { - /** `false` when the working directory is not inside a git repository. */ - isRepo: boolean - root: string | null - /** Current branch name, or `null` when HEAD is detached. */ - branch: string | null - detached: boolean - /** Short HEAD object name. */ - head: string | null - upstream: string | null - ahead: number - behind: number - staged: StatusFileEntry[] - unstaged: StatusFileEntry[] - untracked: string[] - /** `true` when there are no staged, unstaged, or untracked changes. */ - clean: boolean - /** `true` when stage / unstage / commit actions are available. */ - canWrite: boolean -} - -const EMPTY_STATUS: GitStatus = { - isRepo: false, - root: null, - branch: null, - detached: false, - head: null, - upstream: null, - ahead: 0, - behind: 0, - staged: [], - unstaged: [], - untracked: [], - clean: true, - canWrite: false, -} - -function mapCode(code: string): FileStatusCode { - switch (code) { - case 'M': return 'modified' - case 'A': return 'added' - case 'D': return 'deleted' - case 'R': return 'renamed' - case 'C': return 'copied' - case 'T': return 'type-changed' - case 'U': return 'unmerged' - default: return 'unknown' - } -} - -/** - * Parse `git status --porcelain=v2 --branch -z` into a structured snapshot. - * Records are NUL-separated; rename/copy (type `2`) entries consume an extra - * token for the original path. - */ -function parseStatus(root: string, raw: string): GitStatus { - const tokens = raw.split('\0') - const status: GitStatus = { ...EMPTY_STATUS, isRepo: true, root, staged: [], unstaged: [], untracked: [] } - - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i] - if (!token) - continue - - if (token.startsWith('# ')) { - const [, key, ...rest] = token.split(' ') - const value = rest.join(' ') - if (key === 'branch.head') { - if (value === '(detached)') { - status.detached = true - status.branch = null - } - else { - status.branch = value - } - } - else if (key === 'branch.oid' && value !== '(initial)') { - status.head = value.slice(0, 9) - } - else if (key === 'branch.upstream') { - status.upstream = value - } - else if (key === 'branch.ab') { - const match = value.match(/\+(\d+)\s+-(\d+)/) - if (match) { - status.ahead = Number(match[1]) - status.behind = Number(match[2]) - } - } - continue - } - - if (token.startsWith('1 ') || token.startsWith('2 ')) { - const renamed = token.startsWith('2 ') - const fields = token.split(' ') - const xy = fields[1] - const x = xy[0] - const y = xy[1] - // Type 1 path begins at field 8; type 2 inserts the rename score at - // field 8, pushing the path to field 9 and the original to a NUL token. - const path = fields.slice(renamed ? 9 : 8).join(' ') - const from = renamed ? tokens[++i] : undefined - - if (x !== '.') { - status.staged.push(from ? { path, from, status: mapCode(x) } : { path, status: mapCode(x) }) - } - if (y !== '.') { - status.unstaged.push({ path, status: mapCode(y) }) - } - continue - } - - if (token.startsWith('u ')) { - const path = token.split(' ').slice(10).join(' ') - status.unstaged.push({ path, status: 'unmerged' }) - continue - } - - if (token.startsWith('? ')) { - status.untracked.push(token.slice(2)) - } - } - - status.clean = status.staged.length === 0 - && status.unstaged.length === 0 - && status.untracked.length === 0 - return status -} - -/** - * Read the working-tree status for a git context. Shared by the `devframes:plugin:git:status` - * query and the write actions (which return fresh status after mutating). - */ -export async function readStatus(git: GitContext): Promise { - const root = await git.resolveRoot() - if (!root) - return { ...EMPTY_STATUS, canWrite: false } - const { stdout } = await runGit(git.cwd, ['status', '--porcelain=v2', '--branch', '-z']) - const status = parseStatus(root, stdout) - status.canWrite = git.write - return status -} - -export const status = defineRpcFunction({ - name: 'devframes:plugin:git:status', - type: 'query', - snapshot: true, - jsonSerializable: true, - agent: { - description: 'Working-tree status of the inspected repository: current branch, ahead/behind counts, and every staged/unstaged/untracked file. Call this first to orient before reading diffs or history. Safe to call freely.', - title: 'Git status', - }, - setup: (ctx) => { - const git = getGitContext(ctx) - return { - handler: (): Promise => readStatus(git), - } - }, -}) diff --git a/plugins/git/src/rpc/functions/unstage.ts b/plugins/git/src/rpc/functions/unstage.ts deleted file mode 100644 index 1d87130f..00000000 --- a/plugins/git/src/rpc/functions/unstage.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { GitStatus } from './status.ts' -import { defineRpcFunction } from 'devframe' -import { runGit } from '../../node/git.ts' -import { getGitContext } from '../context.ts' -import { readStatus } from './status.ts' - -export interface UnstageArgs { - /** Paths to unstage (`git restore --staged`). */ - paths: string[] -} - -export const unstage = defineRpcFunction({ - name: 'devframes:plugin:git:unstage', - type: 'action', - jsonSerializable: true, - setup: (ctx) => { - const git = getGitContext(ctx) - return { - handler: async (args: UnstageArgs): Promise => { - const paths = args?.paths ?? [] - const root = await git.resolveRoot() - if (root && paths.length > 0) - await runGit(git.cwd, ['restore', '--staged', '--', ...paths]) - return readStatus(git) - }, - } - }, -}) diff --git a/plugins/git/src/rpc/index.ts b/plugins/git/src/rpc/index.ts deleted file mode 100644 index 0a3c4ffd..00000000 --- a/plugins/git/src/rpc/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { RpcDefinitionsToFunctions } from 'devframe/rpc' -import { branches } from './functions/branches.ts' -import { commit } from './functions/commit.ts' -import { diff } from './functions/diff.ts' -import { log } from './functions/log.ts' -import { show } from './functions/show.ts' -import { stage } from './functions/stage.ts' -import { status } from './functions/status.ts' -import { unstage } from './functions/unstage.ts' - -/** Read-only RPC — always registered. */ -export const readFunctions = [status, log, show, branches, diff] as const - -/** Mutating RPC — registered only when write actions are enabled. */ -export const writeFunctions = [stage, unstage, commit] as const - -declare module 'devframe' { - interface DevframeRpcServerFunctions extends RpcDefinitionsToFunctions<[...typeof readFunctions, ...typeof writeFunctions]> {} -} diff --git a/plugins/git/test/_repo.ts b/plugins/git/test/_repo.ts index 944f7270..e749d644 100644 --- a/plugins/git/test/_repo.ts +++ b/plugins/git/test/_repo.ts @@ -64,12 +64,3 @@ export function createTempRepo(): TempRepo { cleanup: () => rmSync(dir, { recursive: true, force: true }), } } - -/** Create an empty (non-git) temp directory. */ -export function createTempDir(): TempRepo { - const dir = mkdtempSync(join(tmpdir(), 'devframe-git-bare-')) - return { - dir, - cleanup: () => rmSync(dir, { recursive: true, force: true }), - } -} diff --git a/plugins/git/test/_utils.ts b/plugins/git/test/_utils.ts index 09e35996..310bf728 100644 --- a/plugins/git/test/_utils.ts +++ b/plugins/git/test/_utils.ts @@ -34,6 +34,11 @@ export async function createDashboardContext( mount: (base, dir) => mountStaticHandler(app, base, dir), }) const ctx = await createHostContext({ cwd, mode, host: h3Host }) + // Mirror the adapters: install the declared wire services and ready them + // before setup, so `devframes:service:git:*` is registered. + for (const input of devframe.services ?? []) + void ctx.services.install(input, { resolveFrom: devframe.importMetaUrl }) + await ctx.services.ready() await devframe.setup(ctx) return ctx } @@ -64,6 +69,9 @@ export async function startDashboardServer( mount: (base, dir) => mountStaticHandler(app, base, dir), }) const ctx = await createHostContext({ cwd, mode: 'dev', host: h3Host }) + for (const input of devframe.services ?? []) + void ctx.services.install(input, { resolveFrom: devframe.importMetaUrl }) + await ctx.services.ready() await devframe.setup(ctx) const metaPath = `${basePath}${DEVFRAME_CONNECTION_META_FILENAME}` diff --git a/plugins/git/test/git.test.ts b/plugins/git/test/git.test.ts index 94c7be29..eba0199b 100644 --- a/plugins/git/test/git.test.ts +++ b/plugins/git/test/git.test.ts @@ -1,12 +1,14 @@ -import type { CommitDetail, CommitResult, GitBranches, GitDiff, GitLog, GitStatus } from '../src/index' +import type { CommitDetail, CommitResult, GitBranches, GitDiff, GitLog, GitStatus } from '@devframes/service-git' import { existsSync } from 'node:fs' import { join } from 'node:path' +import { applySnapshotRpc } from 'devframe/adapters/build' import { createRpcClient } from 'devframe/rpc/client' import { collectStaticRpcDump } from 'devframe/rpc/dump' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WebSocket } from 'ws' -import { createTempDir, createTempRepo } from './_repo' +import createGitDevframe from '../src/index' +import { createTempRepo } from './_repo' import { createDashboardContext, startDashboardServer } from './_utils' vi.stubGlobal('WebSocket', WebSocket) @@ -16,6 +18,9 @@ function bootRpc(port: number) { return createRpcClient({}, { channel }) } +// The plugin now owns no git logic — these are integration checks that it +// declares `@devframes/service-git` and the SPA can reach it over RPC. The +// git behavior itself is covered by `services/git`. describe('@devframes/plugin-git', () => { let repo: ReturnType let server: Awaited> @@ -32,217 +37,61 @@ describe('@devframes/plugin-git', () => { it('serves connection meta pointing at the WS backend', async () => { const res = await fetch(`${server.origin}${server.basePath}__connection.json`) - expect(res.status).toBe(200) const meta = await res.json() as { backend: string, websocket: number } expect(meta.backend).toBe('websocket') expect(meta.websocket).toBe(server.port) }) - it('reports branch, staged, unstaged, and untracked status', async () => { + it('exposes the git service RPC the SPA calls', async () => { const rpc = bootRpc(server.port) - const status = await rpc.$call('devframes:plugin:git:status') as GitStatus + const status = await rpc.$call('devframes:service:git:status') as GitStatus expect(status.isRepo).toBe(true) expect(status.branch).toBe('main') - expect(status.detached).toBe(false) - expect(status.head).toMatch(/^[0-9a-f]+$/) - expect(status.clean).toBe(false) + expect(status.canWrite).toBe(true) - expect(status.staged).toContainEqual({ path: 'staged.txt', status: 'added' }) - expect(status.unstaged).toContainEqual({ path: 'README.md', status: 'modified' }) - expect(status.untracked).toContain('untracked.txt') - }) - - it('returns the commit log newest-first', async () => { - const rpc = bootRpc(server.port) - const log = await rpc.$call('devframes:plugin:git:log', { limit: 30 }) as GitLog - expect(log.isRepo).toBe(true) - expect(log.commits).toHaveLength(2) + const log = await rpc.$call('devframes:service:git:log', { limit: 30 }) as GitLog expect(log.commits[0].subject).toBe('feat: add a.txt') - expect(log.commits[1].subject).toBe('init: add readme') - expect(log.commits[0].author).toBe('Test User') - expect(log.commits[0].email).toBe('test@example.com') - expect(typeof log.commits[0].date).toBe('number') - expect(log.hasMore).toBe(false) - // Parents drive the commit graph: the tip points at the root, which has none. - expect(log.commits[1].parents).toEqual([]) - expect(log.commits[0].parents).toEqual([log.commits[1].hash]) - }) - - it('paginates the log and flags more history', async () => { - const rpc = bootRpc(server.port) - const page = await rpc.$call('devframes:plugin:git:log', { limit: 1 }) as GitLog - expect(page.commits).toHaveLength(1) - expect(page.commits[0].subject).toBe('feat: add a.txt') - expect(page.hasMore).toBe(true) - - const next = await rpc.$call('devframes:plugin:git:log', { limit: 1, skip: 1 }) as GitLog - expect(next.commits).toHaveLength(1) - expect(next.commits[0].subject).toBe('init: add readme') - expect(next.hasMore).toBe(true) - - const tail = await rpc.$call('devframes:plugin:git:log', { limit: 1, skip: 2 }) as GitLog - expect(tail.commits).toHaveLength(0) - expect(tail.hasMore).toBe(false) - }) - - it('treats dashed log refs as invalid revisions instead of Git options', async () => { - const rpc = bootRpc(server.port) - const marker = join(repo.dir, 'log-injected.txt') - const log = await rpc.$call('devframes:plugin:git:log', { ref: `--output=${marker}` }) as GitLog + const branches = await rpc.$call('devframes:service:git:branches', {}) as GitBranches + expect(branches.current).toBe('main') - expect(log.isRepo).toBe(true) - expect(log.commits).toEqual([]) - expect(log.hasMore).toBe(false) - expect(existsSync(marker)).toBe(false) - }) - - it('treats dashed show hashes as invalid revisions instead of Git options', async () => { - const rpc = bootRpc(server.port) - const marker = join(repo.dir, 'show-injected.txt') - - const detail = await rpc.$call('devframes:plugin:git:show', { hash: `--output=${marker}` }) as CommitDetail - - expect(detail.isRepo).toBe(true) - expect(detail.found).toBe(false) - expect(existsSync(marker)).toBe(false) - }) - - it('returns commit details for a valid hash', async () => { - const rpc = bootRpc(server.port) - const log = await rpc.$call('devframes:plugin:git:log', { limit: 1 }) as GitLog - - const detail = await rpc.$call('devframes:plugin:git:show', { hash: log.commits[0].hash }) as CommitDetail - - expect(detail.isRepo).toBe(true) - expect(detail.found).toBe(true) - expect(detail.hash).toBe(log.commits[0].hash) - expect(detail.files.map(file => file.path)).toContain('a.txt') - // Each changed file carries its change kind (add / modify / delete …). - expect(detail.files.find(file => file.path === 'a.txt')?.status).toBe('added') - }) - - it('lists local branches with the current one first', async () => { - const rpc = bootRpc(server.port) - const result = await rpc.$call('devframes:plugin:git:branches', {}) as GitBranches - expect(result.isRepo).toBe(true) - expect(result.current).toBe('main') - expect(result.branches).toHaveLength(2) - expect(result.branches[0].current).toBe(true) - expect(result.branches[0].name).toBe('main') - expect(result.branches.map(b => b.name).sort()).toEqual(['feature/x', 'main']) - }) - - it('summarizes the working-tree diff', async () => { - const rpc = bootRpc(server.port) - const diff = await rpc.$call('devframes:plugin:git:diff', {}) as GitDiff - expect(diff.isRepo).toBe(true) - expect(diff.staged).toBe(false) + const diff = await rpc.$call('devframes:service:git:diff', {}) as GitDiff expect(diff.files.map(f => f.path)).toContain('README.md') - // Staged and untracked files don't appear in the working-tree diff. - expect(diff.files.map(f => f.path)).not.toContain('staged.txt') - expect(diff.totalAdditions).toBeGreaterThan(0) - expect(diff.patch).toBeNull() }) - it('summarizes the staged diff', async () => { + it('stages, unstages, and commits over the service RPC', async () => { const rpc = bootRpc(server.port) - const diff = await rpc.$call('devframes:plugin:git:diff', { staged: true }) as GitDiff - expect(diff.staged).toBe(true) - expect(diff.files.map(f => f.path)).toContain('staged.txt') - }) - - it('returns a unified patch for a single path', async () => { - const rpc = bootRpc(server.port) - const diff = await rpc.$call('devframes:plugin:git:diff', { path: 'README.md' }) as GitDiff - expect(diff.path).toBe('README.md') - expect(diff.files.map(f => f.path)).toEqual(['README.md']) - expect(diff.patch).toContain('+more') - expect(diff.truncated).toBe(false) + await rpc.$call('devframes:service:git:stage', { paths: ['README.md'] }) + const result = await rpc.$call('devframes:service:git:commit', { message: 'test: commit from ui' }) as CommitResult + expect(result.ok).toBe(true) + const log = await rpc.$call('devframes:service:git:log', {}) as GitLog + expect(log.commits[0].subject).toBe('test: commit from ui') }) }) -describe('@devframes/plugin-git (non-repo directory)', () => { - let dir: ReturnType - let server: Awaited> - - beforeEach(async () => { - dir = createTempDir() - server = await startDashboardServer(dir.dir) - }) - - afterEach(async () => { - await server?.close() - dir?.cleanup() - }) - - it('degrades gracefully outside a git repository', async () => { - const rpc = bootRpc(server.port) - const status = await rpc.$call('devframes:plugin:git:status') as GitStatus - expect(status.isRepo).toBe(false) - expect(status.branch).toBeNull() - expect(status.clean).toBe(true) - - const log = await rpc.$call('devframes:plugin:git:log', {}) as GitLog - expect(log.isRepo).toBe(false) - expect(log.commits).toEqual([]) - - const branches = await rpc.$call('devframes:plugin:git:branches', {}) as GitBranches - expect(branches.isRepo).toBe(false) - - const diff = await rpc.$call('devframes:plugin:git:diff', {}) as GitDiff - expect(diff.isRepo).toBe(false) - expect(diff.files).toEqual([]) - }) -}) - -describe('@devframes/plugin-git (build snapshot)', () => { - it('bakes a status snapshot for static deployment', async () => { +describe('@devframes/plugin-git (snapshotRpc build baking)', () => { + it('bakes the git service read ops declared in snapshotRpc', async () => { const repo = createTempRepo() try { const ctx = await createDashboardContext(repo.dir, 'build') + // Mirror `createBuild`: honor the definition's `snapshotRpc` before collecting. + applySnapshotRpc(ctx, createGitDevframe().snapshotRpc) const dump = await collectStaticRpcDump(ctx.rpc.definitions.values(), ctx) - const entry = dump.manifest['devframes:plugin:git:status'] - expect(entry).toBeDefined() - expect(entry.type).toBe('query') - expect(entry.fallback).toBeTruthy() - - // The baked fallback is what a static client returns for any call. - const file = dump.files[entry.fallback] - const status = (file.data as { output: GitStatus }).output - expect(status.isRepo).toBe(true) - expect(status.branch).toBe('main') - } - finally { - repo.cleanup() - } - }) - - it('bakes git:show records for the log snapshot window', async () => { - const repo = createTempRepo() - try { - const ctx = await createDashboardContext(repo.dir, 'build') - const dump = await collectStaticRpcDump(ctx.rpc.definitions.values(), ctx) - - const entry = dump.manifest['devframes:plugin:git:show'] - expect(entry).toBeDefined() - expect(entry.type).toBe('query') - expect(Object.keys(entry.records)).toHaveLength(2) - expect(entry.fallback).toBeTruthy() - - const recordPaths = Object.values(entry.records as Record) - const details = recordPaths.map((path) => { - return (dump.files[path].data as { output: CommitDetail }).output - }) - - expect(details.map(detail => detail.subject)).toEqual([ - 'feat: add a.txt', - 'init: add readme', - ]) - expect(details.every(detail => detail.isRepo && detail.found)).toBe(true) - expect(details.every(detail => detail.patch === null)).toBe(true) - expect(details[0].files.map(file => file.path)).toContain('a.txt') + const status = dump.manifest['devframes:service:git:status'] + expect(status?.type).toBe('query') + expect(status.fallback).toBeTruthy() + const baked = (dump.files[status.fallback].data as { output: GitStatus }).output + expect(baked.isRepo).toBe(true) + expect(baked.branch).toBe('main') + + // `show` is enumerated at build time (one record per commit, patch-less). + const show = dump.manifest['devframes:service:git:show'] + expect(Object.keys(show.records)).toHaveLength(2) + const details = Object.values(show.records as Record) + .map(path => (dump.files[path].data as { output: CommitDetail }).output) + expect(details.map(d => d.subject)).toEqual(['feat: add a.txt', 'init: add readme']) + expect(details.every(d => d.found && d.patch === null)).toBe(true) } finally { repo.cleanup() @@ -250,64 +99,18 @@ describe('@devframes/plugin-git (build snapshot)', () => { }) }) -describe('@devframes/plugin-git (write actions)', () => { - it('stages, unstages, and commits when write is enabled', async () => { - const repo = createTempRepo() - const server = await startDashboardServer(repo.dir, { write: true }) - try { - const rpc = bootRpc(server.port) - - const initial = await rpc.$call('devframes:plugin:git:status') as GitStatus - expect(initial.canWrite).toBe(true) - - // Stage the unstaged + untracked files. - let status = await rpc.$call('devframes:plugin:git:stage', { paths: ['README.md', 'untracked.txt'] }) as GitStatus - expect(status.staged.map(f => f.path)).toEqual( - expect.arrayContaining(['staged.txt', 'README.md', 'untracked.txt']), - ) - expect(status.untracked).not.toContain('untracked.txt') - - // Unstage one of them again. - status = await rpc.$call('devframes:plugin:git:unstage', { paths: ['staged.txt'] }) as GitStatus - expect(status.staged.map(f => f.path)).not.toContain('staged.txt') - - // Commit what's left staged. - const result = await rpc.$call('devframes:plugin:git:commit', { message: 'test: commit from ui' }) as CommitResult - expect(result.ok).toBe(true) - expect(result.hash).toMatch(/^[0-9a-f]+$/) - - const log = await rpc.$call('devframes:plugin:git:log', {}) as GitLog - expect(log.commits[0].subject).toBe('test: commit from ui') - } - finally { - await server.close() - repo.cleanup() - } - }) - - it('rejects an empty commit message', async () => { - const repo = createTempRepo() - const server = await startDashboardServer(repo.dir, { write: true }) - try { - const rpc = bootRpc(server.port) - const result = await rpc.$call('devframes:plugin:git:commit', { message: ' ' }) as CommitResult - expect(result.ok).toBe(false) - expect(result.hash).toBeNull() - } - finally { - await server.close() - repo.cleanup() - } - }) - - it('omits write actions when write is disabled', async () => { +// A dashed-revision marker guard, exercised end-to-end through the plugin's +// service wiring (the service enforces `isSafeRevision`). +describe('@devframes/plugin-git (revision safety)', () => { + it('does not treat a dashed ref as a git option', async () => { const repo = createTempRepo() const server = await startDashboardServer(repo.dir) try { const rpc = bootRpc(server.port) - const status = await rpc.$call('devframes:plugin:git:status') as GitStatus - expect(status.canWrite).toBe(false) - await expect(rpc.$call('devframes:plugin:git:stage', { paths: ['README.md'] })).rejects.toBeDefined() + const marker = join(repo.dir, 'injected.txt') + const log = await rpc.$call('devframes:service:git:log', { ref: `--output=${marker}` }) as GitLog + expect(log.commits).toEqual([]) + expect(existsSync(marker)).toBe(false) } finally { await server.close() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ad1a32b..0b32f924 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1802,6 +1802,9 @@ importers: plugins/git: dependencies: + '@devframes/service-git': + specifier: workspace:* + version: link:../../services/git cac: specifier: catalog:deps version: 7.0.0 @@ -2184,6 +2187,21 @@ importers: plugins/terminals/assets-pkg: {} + services/git: + devDependencies: + '@types/node': + specifier: catalog:types + version: 26.2.0 + devframe: + specifier: workspace:* + version: link:../../packages/devframe + tsdown: + specifier: catalog:build + version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.3))(oxc-resolver@11.24.2)(tsx@4.23.12)(typescript@6.0.3) + vitest: + specifier: catalog:testing + version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0)) + services/open: dependencies: pathe: @@ -18263,7 +18281,7 @@ snapshots: '@vueuse/shared': 14.4.0(vue@3.5.41(typescript@5.9.3)) aria-hidden: 1.2.6 defu: 6.1.7 - ohash: 2.0.11 + ohash: 2.0.12 vue: 3.5.41(typescript@5.9.3) transitivePeerDependencies: - '@vue/composition-api' @@ -18280,7 +18298,7 @@ snapshots: '@vueuse/shared': 14.4.0(vue@3.5.41(typescript@6.0.3)) aria-hidden: 1.2.6 defu: 6.1.7 - ohash: 2.0.11 + ohash: 2.0.12 vue: 3.5.41(typescript@6.0.3) transitivePeerDependencies: - '@vue/composition-api' diff --git a/services/git/package.json b/services/git/package.json new file mode 100644 index 00000000..a3c3a59a --- /dev/null +++ b/services/git/package.json @@ -0,0 +1,46 @@ +{ + "name": "@devframes/service-git", + "type": "module", + "version": "0.9.1", + "description": "Devframe wire service exposing read/write git operations (status, log, show, diff, branches, stage, unstage, commit) over RPC.", + "author": "Anthony Fu ", + "license": "MIT", + "homepage": "https://github.com/devframes/devframe#readme", + "repository": { + "directory": "services/git", + "type": "git", + "url": "git+https://github.com/devframes/devframe.git" + }, + "bugs": "https://github.com/devframes/devframe/issues", + "keywords": [ + "devframe", + "devframe-service", + "devtools", + "git" + ], + "sideEffects": false, + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "watch": "tsdown --watch", + "prepack": "turbo run build --filter=@devframes/service-git", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "devframe": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:types", + "devframe": "workspace:*", + "tsdown": "catalog:build", + "vitest": "catalog:testing" + } +} diff --git a/plugins/git/src/node/git.ts b/services/git/src/git.ts similarity index 97% rename from plugins/git/src/node/git.ts rename to services/git/src/git.ts index 439871f9..0b2f2622 100644 --- a/plugins/git/src/node/git.ts +++ b/services/git/src/git.ts @@ -42,7 +42,7 @@ export async function tryGit(cwd: string, args: string[]): Promise Promise + 'devframes:service:git:log': (args?: LogArgs) => Promise + 'devframes:service:git:show': (args: ShowArgs) => Promise + 'devframes:service:git:diff': (args?: DiffArgs) => Promise + 'devframes:service:git:branches': () => Promise + 'devframes:service:git:stage': (args: StageArgs) => Promise + 'devframes:service:git:unstage': (args: UnstageArgs) => Promise + 'devframes:service:git:commit': (args: CommitArgs) => Promise + } + interface DevframeServicesRegistry { + '@devframes/service-git': GitServiceApi + } + interface DevframeServicesScopeRegistry { + '@devframes/service-git': 'devframes:service:git' + } +} + +/** + * The git wire service — read/write git operations shared over RPC by every + * plugin on the host, generalizing the utilities that used to live inside the + * git plugin. The exec wrapper and output parsers stay internal; consumers get + * the typed {@link GitServiceApi} in-process (`ctx.services.get`) and the same + * ops over `devframes:service:git:*` RPC. Write ops are always exposed — + * authorization is the host's connection-trust boundary. The service defines + * no `dump`/`snapshot`; a devframe bakes what it needs via `snapshotRpc`. + */ +export function createGitService(options?: GitServiceOptions): DevframeServiceDefinition { + return { + package: GIT_SERVICE_PACKAGE, + version: pkg.version, + scope: GIT_SERVICE_SCOPE, + options, + // `cwd` deep-merges as a scalar (later installer wins) across declarers. + setup(ctx, { options }) { + const ops = createGitOps(options?.cwd ?? ctx.cwd) + + // Read ops. + ctx.rpc.register(defineRpcFunction({ + name: 'status', + type: 'query', + jsonSerializable: true, + agent: { title: 'Git status', description: 'Working-tree status of the inspected repository: current branch, ahead/behind counts, and every staged/unstaged/untracked file. Safe to call freely.' }, + handler: (): Promise => ops.status(), + })) + ctx.rpc.register(defineRpcFunction({ + name: 'log', + type: 'query', + jsonSerializable: true, + args: [s.object({ limit: s.optional(s.number()), skip: s.optional(s.number()), ref: s.optional(s.string()) })], + returns: gitLogSchema, + agent: { title: 'Git log', description: 'Commit history of the inspected repository, newest first. Paginate with limit (1-200, default 30) and skip; pass ref to read another branch. Safe to call freely.' }, + handler: (args: LogArgs = {}): Promise => ops.log(args), + })) + ctx.rpc.register(defineRpcFunction({ + name: 'show', + type: 'query', + jsonSerializable: true, + args: [s.object({ hash: s.string(), patch: s.optional(s.boolean()) })], + returns: commitDetailSchema, + agent: { title: 'Git show', description: 'Full detail of one commit by hash: metadata, changed files, and the unified patch (pass patch: false to skip it for large commits). Safe to call freely.' }, + handler: (args: ShowArgs): Promise => ops.show(args), + })) + ctx.rpc.register(defineRpcFunction({ + name: 'diff', + type: 'query', + jsonSerializable: true, + args: [s.object({ path: s.optional(s.string()), staged: s.optional(s.boolean()) })], + returns: gitDiffSchema, + agent: { title: 'Git diff', description: 'Unified diff of uncommitted changes — the working tree by default, the index with staged: true, one file with path. Safe to call freely.' }, + handler: (args: DiffArgs = {}): Promise => ops.diff(args), + })) + ctx.rpc.register(defineRpcFunction({ + name: 'branches', + type: 'query', + jsonSerializable: true, + agent: { title: 'Git branches', description: 'List local branches with tracking state (ahead/behind, gone upstreams) and the current branch. Safe to call freely.' }, + handler: (): Promise => ops.branches(), + })) + + // Write ops (always registered — authorization is the host's concern). + ctx.rpc.register(defineRpcFunction({ + name: 'stage', + type: 'action', + jsonSerializable: true, + handler: (args: StageArgs): Promise => ops.stage(args), + })) + ctx.rpc.register(defineRpcFunction({ + name: 'unstage', + type: 'action', + jsonSerializable: true, + handler: (args: UnstageArgs): Promise => ops.unstage(args), + })) + ctx.rpc.register(defineRpcFunction({ + name: 'commit', + type: 'action', + jsonSerializable: true, + handler: (args: CommitArgs): Promise => ops.commit(args), + })) + + return ops + }, + } +} + +export default createGitService diff --git a/services/git/src/operations.ts b/services/git/src/operations.ts new file mode 100644 index 00000000..203ce3f2 --- /dev/null +++ b/services/git/src/operations.ts @@ -0,0 +1,454 @@ +import type { + Branch, + Commit, + CommitDetail, + CommitFile, + DiffFile, + FileStatusCode, + GitBranches, + GitServiceApi, + GitStatus, + StatusFileEntry, +} from './types' +import { + gitErrorMessage, + isSafeRevision, + RECORD, + resolveRepoRoot, + runGit, + splitClean, + tryGit, + UNIT, +} from './git' + +/** Hard cap on returned patch text to keep payloads bounded. */ +const PATCH_CHAR_LIMIT = 200_000 + +// --- status --------------------------------------------------------------- + +const EMPTY_STATUS: GitStatus = { + isRepo: false, + root: null, + branch: null, + detached: false, + head: null, + upstream: null, + ahead: 0, + behind: 0, + staged: [], + unstaged: [], + untracked: [], + clean: true, + canWrite: false, +} + +function mapCode(code: string): FileStatusCode { + switch (code) { + case 'M': return 'modified' + case 'A': return 'added' + case 'D': return 'deleted' + case 'R': return 'renamed' + case 'C': return 'copied' + case 'T': return 'type-changed' + case 'U': return 'unmerged' + default: return 'unknown' + } +} + +/** + * Parse `git status --porcelain=v2 --branch -z` into a structured snapshot. + * Records are NUL-separated; rename/copy (type `2`) entries consume an extra + * token for the original path. + */ +function parseStatus(root: string, raw: string): GitStatus { + const tokens = raw.split('\0') + const status: GitStatus = { ...EMPTY_STATUS, isRepo: true, root, staged: [], unstaged: [], untracked: [] } + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i] + if (!token) + continue + + if (token.startsWith('# ')) { + const [, key, ...rest] = token.split(' ') + const value = rest.join(' ') + if (key === 'branch.head') { + if (value === '(detached)') { + status.detached = true + status.branch = null + } + else { + status.branch = value + } + } + else if (key === 'branch.oid' && value !== '(initial)') { + status.head = value.slice(0, 9) + } + else if (key === 'branch.upstream') { + status.upstream = value + } + else if (key === 'branch.ab') { + const match = value.match(/\+(\d+)\s+-(\d+)/) + if (match) { + status.ahead = Number(match[1]) + status.behind = Number(match[2]) + } + } + continue + } + + if (token.startsWith('1 ') || token.startsWith('2 ')) { + const renamed = token.startsWith('2 ') + const fields = token.split(' ') + const xy = fields[1] + const x = xy[0] + const y = xy[1] + // Type 1 path begins at field 8; type 2 inserts the rename score at + // field 8, pushing the path to field 9 and the original to a NUL token. + const path = fields.slice(renamed ? 9 : 8).join(' ') + const from = renamed ? tokens[++i] : undefined + + if (x !== '.') + status.staged.push(from ? { path, from, status: mapCode(x) } : { path, status: mapCode(x) }) + if (y !== '.') + status.unstaged.push({ path, status: mapCode(y) }) + continue + } + + if (token.startsWith('u ')) { + const path = token.split(' ').slice(10).join(' ') + status.unstaged.push({ path, status: 'unmerged' } satisfies StatusFileEntry) + continue + } + + if (token.startsWith('? ')) + status.untracked.push(token.slice(2)) + } + + status.clean = status.staged.length === 0 + && status.unstaged.length === 0 + && status.untracked.length === 0 + return status +} + +// --- log ------------------------------------------------------------------- + +const LOG_FORMAT = ['%H', '%h', '%P', '%an', '%ae', '%aI', '%D', '%s', '%b'].join(UNIT) + RECORD + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max) +} + +function parseLog(raw: string): Commit[] { + return splitClean(raw, RECORD).map((record) => { + const [hash, shortHash, parents, author, email, isoDate, refs, subject, body] = record + .replace(/^\n/, '') + .split(UNIT) + return { + hash, + shortHash, + author, + email, + date: Date.parse(isoDate), + subject, + body: (body ?? '').trim(), + refs: refs ? refs.split(', ').map(r => r.trim()).filter(Boolean) : [], + parents: parents ? parents.split(' ').filter(Boolean) : [], + } + }) +} + +// --- branches -------------------------------------------------------------- + +const BRANCH_FORMAT = [ + '%(refname:short)', + '%(objectname:short)', + '%(HEAD)', + '%(upstream:short)', + '%(upstream:track)', + '%(contents:subject)', +].join(UNIT) + +function parseTrack(track: string): { ahead: number, behind: number, gone: boolean } { + if (track.includes('gone')) + return { ahead: 0, behind: 0, gone: true } + const ahead = track.match(/ahead (\d+)/) + const behind = track.match(/behind (\d+)/) + return { + ahead: ahead ? Number(ahead[1]) : 0, + behind: behind ? Number(behind[1]) : 0, + gone: false, + } +} + +// --- diff / show ----------------------------------------------------------- + +function parseNumstat(raw: string): DiffFile[] { + return splitClean(raw, '\n').map((line) => { + const [add, del, ...rest] = line.split('\t') + const binary = add === '-' || del === '-' + return { + path: rest.join('\t'), + additions: binary ? 0 : Number(add), + deletions: binary ? 0 : Number(del), + binary, + } + }) +} + +const SHOW_FORMAT = ['%H', '%h', '%P', '%an', '%ae', '%aI', '%cn', '%ce', '%cI', '%D', '%s', '%b'].join(UNIT) + +const EMPTY_DETAIL: CommitDetail = { + isRepo: false, + found: false, + hash: '', + shortHash: '', + author: '', + email: '', + date: 0, + committer: '', + committerEmail: '', + commitDate: 0, + subject: '', + body: '', + parents: [], + refs: [], + files: [], + totalAdditions: 0, + totalDeletions: 0, + patch: null, + truncated: false, +} + +function mapStatusCode(code: string): FileStatusCode { + switch (code[0]) { + case 'M': return 'modified' + case 'A': return 'added' + case 'D': return 'deleted' + case 'R': return 'renamed' + case 'C': return 'copied' + case 'T': return 'type-changed' + case 'U': return 'unmerged' + default: return 'unknown' + } +} + +function parseNameStatus(raw: string): Map { + const map = new Map() + for (const line of splitClean(raw, '\n')) { + const [code, ...paths] = line.split('\t') + const path = paths[paths.length - 1] + if (path) + map.set(path, mapStatusCode(code)) + } + return map +} + +function parseCommitNumstat(raw: string, status: Map): CommitFile[] { + return splitClean(raw, '\n').map((line) => { + const [add, del, ...rest] = line.split('\t') + const binary = add === '-' || del === '-' + const path = rest.join('\t') + return { + path, + additions: binary ? 0 : Number(add), + deletions: binary ? 0 : Number(del), + binary, + status: status.get(path) ?? 'modified', + } + }) +} + +function clipPatch(raw: string): { patch: string, truncated: boolean } { + return raw.length > PATCH_CHAR_LIMIT + ? { patch: raw.slice(0, PATCH_CHAR_LIMIT), truncated: true } + : { patch: raw, truncated: false } +} + +// --- ops factory ----------------------------------------------------------- + +/** + * Build the git node API bound to a single working directory, with the repo + * root resolved once (memoized). This is the surface returned to in-process + * consumers and wrapped by the service's RPC. + */ +export function createGitOps(cwd: string): GitServiceApi { + let rootPromise: Promise | undefined + const resolveRoot = () => (rootPromise ??= resolveRepoRoot(cwd)) + + async function status(): Promise { + const root = await resolveRoot() + if (!root) + return { ...EMPTY_STATUS } + const { stdout } = await runGit(cwd, ['status', '--porcelain=v2', '--branch', '-z']) + const result = parseStatus(root, stdout) + result.canWrite = true + return result + } + + async function readCommit(hash: string, includePatch: boolean): Promise { + if (!isSafeRevision(hash)) + return { ...EMPTY_DETAIL, isRepo: true } + + const meta = await tryGit(cwd, ['show', '-s', `--format=${SHOW_FORMAT}`, '--end-of-options', hash]) + if (meta == null) + return { ...EMPTY_DETAIL, isRepo: true } + + const [fullHash, shortHash, parents, author, email, authorDate, committer, committerEmail, committerDate, refs, subject, body] = meta.split(UNIT) + + // `--root` so the initial commit reports its full tree as additions. + const numstat = await tryGit(cwd, ['diff-tree', '--no-commit-id', '--numstat', '-r', '--root', '--end-of-options', hash]) + const nameStatusRaw = await tryGit(cwd, ['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', '--end-of-options', hash]) + const files = numstat ? parseCommitNumstat(numstat, nameStatusRaw ? parseNameStatus(nameStatusRaw) : new Map()) : [] + + let patch: string | null = null + let truncated = false + if (includePatch) { + const raw = await tryGit(cwd, ['diff-tree', '-p', '--no-commit-id', '-r', '--root', '--end-of-options', hash]) + if (raw != null) + ({ patch, truncated } = clipPatch(raw)) + } + + return { + isRepo: true, + found: true, + hash: fullHash, + shortHash, + author, + email, + date: Date.parse(authorDate), + committer, + committerEmail, + commitDate: Date.parse(committerDate), + subject, + body: (body ?? '').trim(), + parents: parents ? parents.split(' ').filter(Boolean) : [], + refs: refs ? refs.split(', ').map(r => r.trim()).filter(Boolean) : [], + files, + totalAdditions: files.reduce((sum, f) => sum + f.additions, 0), + totalDeletions: files.reduce((sum, f) => sum + f.deletions, 0), + patch, + truncated, + } + } + + const api: GitServiceApi = { + status, + + async log(args = {}) { + const limit = clamp(Math.trunc(args.limit ?? 30), 1, 200) + const skip = Math.max(0, Math.trunc(args.skip ?? 0)) + const ref = args.ref?.trim() || undefined + const root = await resolveRoot() + if (!root) + return { isRepo: false, commits: [], limit, skip, hasMore: false } + + const command = ['log', '--topo-order', `--max-count=${limit}`, `--skip=${skip}`, `--pretty=format:${LOG_FORMAT}`] + if (ref) { + if (!isSafeRevision(ref)) + return { isRepo: true, commits: [], limit, skip, hasMore: false } + command.push('--end-of-options', ref) + } + + const raw = await tryGit(cwd, command) + const commits = raw ? parseLog(raw) : [] + return { isRepo: true, commits, limit, skip, hasMore: commits.length === limit } + }, + + async show(args) { + const hash = (args?.hash ?? '').trim() + const includePatch = args?.patch ?? true + const root = await resolveRoot() + if (!root || !hash) + return { ...EMPTY_DETAIL } + return readCommit(hash, includePatch) + }, + + async diff(args = {}) { + const { path, staged = false } = args + const root = await resolveRoot() + if (!root) { + return { isRepo: false, staged, path: path ?? null, files: [], totalAdditions: 0, totalDeletions: 0, patch: null, truncated: false } + } + + const base = staged ? ['diff', '--cached'] : ['diff'] + const scope = path ? ['--', path] : [] + const numstatRaw = await tryGit(cwd, [...base, '--numstat', ...scope]) + const files = numstatRaw ? parseNumstat(numstatRaw) : [] + + let patch: string | null = null + let truncated = false + if (path) { + const { stdout } = await runGit(cwd, [...base, ...scope]) + ;({ patch, truncated } = clipPatch(stdout)) + } + + return { + isRepo: true, + staged, + path: path ?? null, + files, + totalAdditions: files.reduce((sum, f) => sum + f.additions, 0), + totalDeletions: files.reduce((sum, f) => sum + f.deletions, 0), + patch, + truncated, + } + }, + + async branches(): Promise { + const root = await resolveRoot() + if (!root) + return { isRepo: false, current: null, branches: [] } + + const raw = await tryGit(cwd, ['for-each-ref', `--format=${BRANCH_FORMAT}`, 'refs/heads']) + if (!raw) + return { isRepo: true, current: null, branches: [] } + + let current: string | null = null + const branches: Branch[] = splitClean(raw, '\n').map((line) => { + const [name, sha, head, upstream, track, subject] = line.split(UNIT) + const isCurrent = head === '*' + if (isCurrent) + current = name + return { name, current: isCurrent, sha, upstream: upstream || null, subject: subject ?? '', ...parseTrack(track ?? '') } + }) + branches.sort((a, b) => Number(b.current) - Number(a.current)) + return { isRepo: true, current, branches } + }, + + async stage(args) { + const paths = args?.paths ?? [] + const root = await resolveRoot() + if (root && paths.length > 0) + await runGit(cwd, ['add', '--', ...paths]) + return status() + }, + + async unstage(args) { + const paths = args?.paths ?? [] + const root = await resolveRoot() + if (root && paths.length > 0) + await runGit(cwd, ['restore', '--staged', '--', ...paths]) + return status() + }, + + async commit(args) { + const message = (args?.message ?? '').trim() + const root = await resolveRoot() + if (!root) + return { ok: false, hash: null, message: 'Not a git repository.', status: await status() } + if (!message) + return { ok: false, hash: null, message: 'Commit message is required.', status: await status() } + try { + await runGit(cwd, ['commit', '-m', message]) + const hash = await tryGit(cwd, ['rev-parse', '--short', 'HEAD']) + return { ok: true, hash, message: 'Committed.', status: await status() } + } + catch (error) { + return { ok: false, hash: null, message: gitErrorMessage(error), status: await status() } + } + }, + } + + return api +} diff --git a/services/git/src/types.ts b/services/git/src/types.ts new file mode 100644 index 00000000..288ba704 --- /dev/null +++ b/services/git/src/types.ts @@ -0,0 +1,197 @@ +export type FileStatusCode + = | 'modified' + | 'added' + | 'deleted' + | 'renamed' + | 'copied' + | 'type-changed' + | 'unmerged' + | 'unknown' + +export interface StatusFileEntry { + path: string + /** Previous path, present for renames and copies. */ + from?: string + status: FileStatusCode +} + +export interface GitStatus { + /** `false` when the working directory is not inside a git repository. */ + isRepo: boolean + root: string | null + /** Current branch name, or `null` when HEAD is detached. */ + branch: string | null + detached: boolean + /** Short HEAD object name. */ + head: string | null + upstream: string | null + ahead: number + behind: number + staged: StatusFileEntry[] + unstaged: StatusFileEntry[] + untracked: string[] + /** `true` when there are no staged, unstaged, or untracked changes. */ + clean: boolean + /** `true` when stage / unstage / commit actions are available. */ + canWrite: boolean +} + +export interface Commit { + hash: string + shortHash: string + author: string + email: string + /** Author date as epoch milliseconds. */ + date: number + subject: string + body: string + /** Ref names pointing at this commit (branches, tags, HEAD). */ + refs: string[] + /** Full parent hashes — drives the commit graph. */ + parents: string[] +} + +export interface GitLog { + isRepo: boolean + commits: Commit[] + limit: number + skip: number + /** `true` when the page filled to `limit`, hinting at further history. */ + hasMore: boolean +} + +export interface LogArgs { + /** Number of commits to return (clamped to 1–200, default 30). */ + limit?: number + /** Commits to skip from the tip, for pagination (default 0). */ + skip?: number + /** Optional ref/branch to read history from (default: current HEAD). */ + ref?: string +} + +export interface Branch { + name: string + current: boolean + sha: string + upstream: string | null + subject: string + ahead: number + behind: number + /** `true` when the upstream branch no longer exists. */ + gone: boolean +} + +export interface GitBranches { + isRepo: boolean + current: string | null + branches: Branch[] +} + +export interface DiffFile { + path: string + additions: number + deletions: number + binary: boolean +} + +export interface GitDiff { + isRepo: boolean + staged: boolean + path: string | null + files: DiffFile[] + totalAdditions: number + totalDeletions: number + /** Unified patch text — populated when `path` targets a single file. */ + patch: string | null + /** `true` when `patch` was clipped to the internal char limit. */ + truncated: boolean +} + +export interface DiffArgs { + /** Limit the diff to a single path; omit for the whole tree. */ + path?: string + /** Diff the index against HEAD instead of the working tree. */ + staged?: boolean +} + +export interface CommitFile { + path: string + additions: number + deletions: number + binary: boolean + /** Change kind relative to the parent (add / modify / delete / rename …). */ + status: FileStatusCode +} + +export interface CommitDetail { + /** `false` when the working directory is not inside a git repository. */ + isRepo: boolean + /** `false` when the hash does not resolve to a commit. */ + found: boolean + hash: string + shortHash: string + author: string + email: string + /** Author date as epoch milliseconds. */ + date: number + committer: string + committerEmail: string + /** Commit date as epoch milliseconds. */ + commitDate: number + subject: string + body: string + parents: string[] + refs: string[] + files: CommitFile[] + totalAdditions: number + totalDeletions: number + /** Unified patch text for the commit, or `null` when omitted/unavailable. */ + patch: string | null + /** `true` when `patch` was clipped to the internal char limit. */ + truncated: boolean +} + +export interface ShowArgs { + /** Commit-ish to inspect (full or short hash). */ + hash: string + /** Include the full unified patch (default `true`). */ + patch?: boolean +} + +export interface StageArgs { + /** Paths to stage (`git add`). */ + paths: string[] +} + +export interface UnstageArgs { + /** Paths to unstage (`git restore --staged`). */ + paths: string[] +} + +export interface CommitArgs { + /** Commit message. */ + message: string +} + +export interface CommitResult { + /** `true` when the commit succeeded. */ + ok: boolean + /** Short hash of the new commit, or `null` on failure. */ + hash: string | null + /** Human-readable outcome (e.g. "nothing to commit"). */ + message: string + /** Working-tree status after the attempt. */ + status: GitStatus +} + +/** The node API a consumer gets from `ctx.services.get('@devframes/service-git')`. */ +export interface GitServiceApi { + status: () => Promise + log: (args?: LogArgs) => Promise + show: (args: ShowArgs) => Promise + diff: (args?: DiffArgs) => Promise + branches: () => Promise + stage: (args: StageArgs) => Promise + unstage: (args: UnstageArgs) => Promise + commit: (args: CommitArgs) => Promise +} diff --git a/services/git/test/_repo.ts b/services/git/test/_repo.ts new file mode 100644 index 00000000..944f7270 --- /dev/null +++ b/services/git/test/_repo.ts @@ -0,0 +1,75 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' + +export interface TempRepo { + dir: string + cleanup: () => void +} + +const GIT_ENV = { + ...process.env, + GIT_AUTHOR_NAME: 'Test User', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test User', + GIT_COMMITTER_EMAIL: 'test@example.com', + GIT_AUTHOR_DATE: '2020-01-01T00:00:00Z', + GIT_COMMITTER_DATE: '2020-01-01T00:00:00Z', + // Ignore the developer's global/system config so commits are deterministic. + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_SYSTEM: '/dev/null', +} + +function git(dir: string, args: string[]): void { + execFileSync('git', args, { cwd: dir, stdio: 'pipe', env: GIT_ENV }) +} + +function write(dir: string, file: string, content: string): void { + writeFileSync(join(dir, file), content) +} + +/** + * Create a throwaway git repository with a known shape: + * - branch `main` with two commits, plus a `feature/x` branch. + * - one staged add (`staged.txt`), one unstaged modification (`README.md`), + * and one untracked file (`untracked.txt`). + */ +export function createTempRepo(): TempRepo { + const dir = mkdtempSync(join(tmpdir(), 'devframe-git-')) + git(dir, ['init', '-b', 'main']) + git(dir, ['config', 'user.name', 'Test User']) + git(dir, ['config', 'user.email', 'test@example.com']) + git(dir, ['config', 'commit.gpgsign', 'false']) + + write(dir, 'README.md', '# Demo\n') + git(dir, ['add', 'README.md']) + git(dir, ['commit', '-m', 'init: add readme']) + + write(dir, 'a.txt', 'hello\n') + git(dir, ['add', 'a.txt']) + git(dir, ['commit', '-m', 'feat: add a.txt']) + + git(dir, ['branch', 'feature/x']) + + // Working-tree state for status/diff assertions. + write(dir, 'README.md', '# Demo\nmore\n') // unstaged modification + write(dir, 'staged.txt', 'staged content\n') + git(dir, ['add', 'staged.txt']) // staged add + write(dir, 'untracked.txt', 'untracked\n') // untracked + + return { + dir, + cleanup: () => rmSync(dir, { recursive: true, force: true }), + } +} + +/** Create an empty (non-git) temp directory. */ +export function createTempDir(): TempRepo { + const dir = mkdtempSync(join(tmpdir(), 'devframe-git-bare-')) + return { + dir, + cleanup: () => rmSync(dir, { recursive: true, force: true }), + } +} diff --git a/services/git/test/git.test.ts b/services/git/test/git.test.ts new file mode 100644 index 00000000..9ddc7ad2 --- /dev/null +++ b/services/git/test/git.test.ts @@ -0,0 +1,184 @@ +import type { DevframeHost } from 'devframe/types' +import type { GitServiceApi } from '../src/index' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { createHostContext } from 'devframe/node' +import { afterEach, describe, expect, it } from 'vitest' +import { createGitService } from '../src/index' +import { createTempDir, createTempRepo } from './_repo' + +const cleanups: (() => void)[] = [] +afterEach(() => { + for (const fn of cleanups.splice(0)) + fn() +}) + +function nullHost(dir: string): DevframeHost { + return { + mountStatic: () => {}, + resolveOrigin: () => 'http://localhost', + getStorageDir: () => join(dir, '.storage'), + } +} + +/** Install the git service against `cwd` and return its node API. */ +async function createGit(cwd: string): Promise { + const ctx = await createHostContext({ cwd, mode: 'dev', host: nullHost(cwd) }) + const install = ctx.services.install(createGitService()) + await ctx.services.ready() + return (await install)! +} + +describe('@devframes/service-git', () => { + it('reports branch, staged, unstaged, and untracked status', async () => { + const repo = createTempRepo() + cleanups.push(repo.cleanup) + const git = await createGit(repo.dir) + + const status = await git.status() + expect(status.isRepo).toBe(true) + expect(status.branch).toBe('main') + expect(status.detached).toBe(false) + expect(status.head).toMatch(/^[0-9a-f]+$/) + expect(status.clean).toBe(false) + expect(status.canWrite).toBe(true) + expect(status.staged).toContainEqual({ path: 'staged.txt', status: 'added' }) + expect(status.unstaged).toContainEqual({ path: 'README.md', status: 'modified' }) + expect(status.untracked).toContain('untracked.txt') + }) + + it('returns the commit log newest-first with parents', async () => { + const repo = createTempRepo() + cleanups.push(repo.cleanup) + const git = await createGit(repo.dir) + + const log = await git.log({ limit: 30 }) + expect(log.commits).toHaveLength(2) + expect(log.commits[0].subject).toBe('feat: add a.txt') + expect(log.commits[1].subject).toBe('init: add readme') + expect(log.commits[0].parents).toEqual([log.commits[1].hash]) + expect(log.hasMore).toBe(false) + }) + + it('paginates the log and flags more history', async () => { + const repo = createTempRepo() + cleanups.push(repo.cleanup) + const git = await createGit(repo.dir) + + const page = await git.log({ limit: 1 }) + expect(page.commits).toHaveLength(1) + expect(page.hasMore).toBe(true) + const tail = await git.log({ limit: 1, skip: 2 }) + expect(tail.commits).toHaveLength(0) + expect(tail.hasMore).toBe(false) + }) + + it('treats dashed revisions as invalid instead of git options (log + show)', async () => { + const repo = createTempRepo() + cleanups.push(repo.cleanup) + const git = await createGit(repo.dir) + + const logMarker = join(repo.dir, 'log-injected.txt') + const log = await git.log({ ref: `--output=${logMarker}` }) + expect(log.commits).toEqual([]) + expect(existsSync(logMarker)).toBe(false) + + const showMarker = join(repo.dir, 'show-injected.txt') + const detail = await git.show({ hash: `--output=${showMarker}` }) + expect(detail.found).toBe(false) + expect(existsSync(showMarker)).toBe(false) + }) + + it('returns commit details with per-file change kinds', async () => { + const repo = createTempRepo() + cleanups.push(repo.cleanup) + const git = await createGit(repo.dir) + + const log = await git.log({ limit: 1 }) + const detail = await git.show({ hash: log.commits[0].hash }) + expect(detail.found).toBe(true) + expect(detail.files.find(f => f.path === 'a.txt')?.status).toBe('added') + }) + + it('lists local branches, current first', async () => { + const repo = createTempRepo() + cleanups.push(repo.cleanup) + const git = await createGit(repo.dir) + + const result = await git.branches() + expect(result.current).toBe('main') + expect(result.branches[0].current).toBe(true) + expect(result.branches.map(b => b.name).sort()).toEqual(['feature/x', 'main']) + }) + + it('summarizes working-tree, staged, and single-path diffs', async () => { + const repo = createTempRepo() + cleanups.push(repo.cleanup) + const git = await createGit(repo.dir) + + const wt = await git.diff() + expect(wt.staged).toBe(false) + expect(wt.files.map(f => f.path)).toContain('README.md') + expect(wt.files.map(f => f.path)).not.toContain('staged.txt') + expect(wt.patch).toBeNull() + + const staged = await git.diff({ staged: true }) + expect(staged.files.map(f => f.path)).toContain('staged.txt') + + const single = await git.diff({ path: 'README.md' }) + expect(single.path).toBe('README.md') + expect(single.patch).toContain('+more') + }) + + it('stages, unstages, and commits (writes always available)', async () => { + const repo = createTempRepo() + cleanups.push(repo.cleanup) + const git = await createGit(repo.dir) + + let status = await git.stage({ paths: ['README.md', 'untracked.txt'] }) + expect(status.staged.map(f => f.path)).toEqual( + expect.arrayContaining(['staged.txt', 'README.md', 'untracked.txt']), + ) + status = await git.unstage({ paths: ['staged.txt'] }) + expect(status.staged.map(f => f.path)).not.toContain('staged.txt') + + const result = await git.commit({ message: 'test: commit from service' }) + expect(result.ok).toBe(true) + expect(result.hash).toMatch(/^[0-9a-f]+$/) + const log = await git.log({}) + expect(log.commits[0].subject).toBe('test: commit from service') + }) + + it('rejects an empty commit message', async () => { + const repo = createTempRepo() + cleanups.push(repo.cleanup) + const git = await createGit(repo.dir) + const result = await git.commit({ message: ' ' }) + expect(result.ok).toBe(false) + expect(result.hash).toBeNull() + }) + + it('degrades gracefully outside a git repository', async () => { + const dir = createTempDir() + cleanups.push(dir.cleanup) + const git = await createGit(dir.dir) + + expect((await git.status()).isRepo).toBe(false) + expect((await git.log({})).isRepo).toBe(false) + expect((await git.branches()).isRepo).toBe(false) + expect((await git.diff()).isRepo).toBe(false) + }) + + it('registers scoped RPC that mirrors the node API', async () => { + const repo = createTempRepo() + cleanups.push(repo.cleanup) + const ctx = await createHostContext({ cwd: repo.dir, mode: 'dev', host: nullHost(repo.dir) }) + void ctx.services.install(createGitService()) + await ctx.services.ready() + + const status = await (ctx.rpc.invokeLocal as (m: string, ...a: unknown[]) => Promise<{ isRepo: boolean }>)( + 'devframes:service:git:status', + ) + expect(status.isRepo).toBe(true) + }) +}) diff --git a/services/git/tsconfig.json b/services/git/tsconfig.json new file mode 100644 index 00000000..25652292 --- /dev/null +++ b/services/git/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext", "dom"], + "types": ["node"] + }, + "include": ["src", "test", "tsdown.config.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/services/git/tsdown.config.ts b/services/git/tsdown.config.ts new file mode 100644 index 00000000..03714bc1 --- /dev/null +++ b/services/git/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + platform: 'node', + tsconfig: '../../tsconfig.base.json', + outExtensions: () => ({ js: '.mjs', dts: '.d.mts' }), + entry: { index: 'src/index.ts' }, +}) diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-git/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-git/index.snapshot.d.ts index ae2f766d..0628aa53 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-git/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-git/index.snapshot.d.ts @@ -2,143 +2,13 @@ * Generated by tsnapi — public API snapshot of `@devframes/plugin-git` */ // #region Interfaces -export interface Branch { - name: string; - current: boolean; - sha: string; - upstream: string | null; - subject: string; - ahead: number; - behind: number; - gone: boolean; -} -export interface Commit { - hash: string; - shortHash: string; - author: string; - email: string; - date: number; - subject: string; - body: string; - refs: string[]; - parents: string[]; -} -export interface CommitArgs { - message: string; -} -export interface CommitDetail { - isRepo: boolean; - found: boolean; - hash: string; - shortHash: string; - author: string; - email: string; - date: number; - committer: string; - committerEmail: string; - commitDate: number; - subject: string; - body: string; - parents: string[]; - refs: string[]; - files: CommitFile[]; - totalAdditions: number; - totalDeletions: number; - patch: string | null; - truncated: boolean; -} -export interface CommitFile { - path: string; - additions: number; - deletions: number; - binary: boolean; - status: FileStatusCode; -} -export interface CommitResult { - ok: boolean; - hash: string | null; - message: string; - status: GitStatus; -} -export interface DiffArgs { - path?: string; - staged?: boolean; -} -export interface DiffFile { - path: string; - additions: number; - deletions: number; - binary: boolean; -} -export interface GitBranches { - isRepo: boolean; - current: string | null; - branches: Branch[]; -} export interface GitDevframeOptions { repoRoot?: string; basePath?: string; distDir?: string; port?: number; - write?: boolean; auth?: boolean; } -export interface GitDiff { - isRepo: boolean; - staged: boolean; - path: string | null; - files: DiffFile[]; - totalAdditions: number; - totalDeletions: number; - patch: string | null; - truncated: boolean; -} -export interface GitLog { - isRepo: boolean; - commits: Commit[]; - limit: number; - skip: number; - hasMore: boolean; -} -export interface GitStatus { - isRepo: boolean; - root: string | null; - branch: string | null; - detached: boolean; - head: string | null; - upstream: string | null; - ahead: number; - behind: number; - staged: StatusFileEntry[]; - unstaged: StatusFileEntry[]; - untracked: string[]; - clean: boolean; - canWrite: boolean; -} -export interface LogArgs { - limit?: number; - skip?: number; - ref?: string; -} -export interface ShowArgs { - hash: string; - patch?: boolean; -} -export interface StageArgs { - paths: string[]; -} -export interface StatusFileEntry { - path: string; - from?: string; - status: FileStatusCode; -} -export interface UnstageArgs { - paths: string[]; -} -// #endregion - -// #region Types -export type FileStatusCode = 'modified' | 'added' | 'deleted' | 'renamed' | 'copied' | 'type-changed' | 'unmerged' | 'unknown'; // #endregion // #region Functions diff --git a/tests/__snapshots__/tsnapi/@devframes/service-git/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/service-git/index.snapshot.d.ts new file mode 100644 index 00000000..b98ec09e --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/service-git/index.snapshot.d.ts @@ -0,0 +1,161 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/service-git` + */ +// #region Interfaces +export interface Branch { + name: string; + current: boolean; + sha: string; + upstream: string | null; + subject: string; + ahead: number; + behind: number; + gone: boolean; +} +export interface Commit { + hash: string; + shortHash: string; + author: string; + email: string; + date: number; + subject: string; + body: string; + refs: string[]; + parents: string[]; +} +export interface CommitArgs { + message: string; +} +export interface CommitDetail { + isRepo: boolean; + found: boolean; + hash: string; + shortHash: string; + author: string; + email: string; + date: number; + committer: string; + committerEmail: string; + commitDate: number; + subject: string; + body: string; + parents: string[]; + refs: string[]; + files: CommitFile[]; + totalAdditions: number; + totalDeletions: number; + patch: string | null; + truncated: boolean; +} +export interface CommitFile { + path: string; + additions: number; + deletions: number; + binary: boolean; + status: FileStatusCode; +} +export interface CommitResult { + ok: boolean; + hash: string | null; + message: string; + status: GitStatus; +} +export interface DiffArgs { + path?: string; + staged?: boolean; +} +export interface DiffFile { + path: string; + additions: number; + deletions: number; + binary: boolean; +} +export interface GitBranches { + isRepo: boolean; + current: string | null; + branches: Branch[]; +} +export interface GitDiff { + isRepo: boolean; + staged: boolean; + path: string | null; + files: DiffFile[]; + totalAdditions: number; + totalDeletions: number; + patch: string | null; + truncated: boolean; +} +export interface GitLog { + isRepo: boolean; + commits: Commit[]; + limit: number; + skip: number; + hasMore: boolean; +} +export interface GitServiceApi { + status: () => Promise; + log: (_?: LogArgs) => Promise; + show: (_: ShowArgs) => Promise; + diff: (_?: DiffArgs) => Promise; + branches: () => Promise; + stage: (_: StageArgs) => Promise; + unstage: (_: UnstageArgs) => Promise; + commit: (_: CommitArgs) => Promise; +} +export interface GitServiceOptions { + cwd?: string; +} +export interface GitStatus { + isRepo: boolean; + root: string | null; + branch: string | null; + detached: boolean; + head: string | null; + upstream: string | null; + ahead: number; + behind: number; + staged: StatusFileEntry[]; + unstaged: StatusFileEntry[]; + untracked: string[]; + clean: boolean; + canWrite: boolean; +} +export interface LogArgs { + limit?: number; + skip?: number; + ref?: string; +} +export interface ShowArgs { + hash: string; + patch?: boolean; +} +export interface StageArgs { + paths: string[]; +} +export interface StatusFileEntry { + path: string; + from?: string; + status: FileStatusCode; +} +export interface UnstageArgs { + paths: string[]; +} +// #endregion + +// #region Types +export type FileStatusCode = 'modified' | 'added' | 'deleted' | 'renamed' | 'copied' | 'type-changed' | 'unmerged' | 'unknown'; +// #endregion + +// #region Functions +export declare function createGitService(_?: GitServiceOptions): DevframeServiceDefinition; +// #endregion + +// #region Variables +export declare const GIT_SERVICE_PACKAGE: string; +export declare const GIT_SERVICE_SCOPE: string; +// #endregion + +// #region Default Export +declare function _default(_?: GitServiceOptions): DevframeServiceDefinition; +export default _default +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/service-git/index.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/service-git/index.snapshot.js new file mode 100644 index 00000000..55b1c167 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/service-git/index.snapshot.js @@ -0,0 +1,16 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/service-git` + */ +// #region Functions +export function createGitService(_) {} +// #endregion + +// #region Variables +export var GIT_SERVICE_PACKAGE /* const */ +export var GIT_SERVICE_SCOPE /* const */ +// #endregion + +// #region Default Export +function _default(_) {} +export default _default +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.d.ts index da300072..b6c139a1 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.d.ts @@ -11,5 +11,6 @@ export interface CreateBuildOptions { // #endregion // #region Functions +export declare function applySnapshotRpc(_: DevframeNodeContext, _: readonly DevframeSnapshotRpcEntry[] | undefined): void; export declare function createBuild(_: DevframeDefinition, _?: CreateBuildOptions): Promise; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.js b/tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.js index c653d577..12804a45 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.js @@ -2,5 +2,6 @@ * Generated by tsnapi — public API snapshot of `devframe/adapters/build` */ // #region Functions +export function applySnapshotRpc(_, _) {} export async function createBuild(_, _) {} // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 3e993db2..2babb34a 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -147,6 +147,7 @@ export interface DevframeDefinition { build?: boolean; }; services?: DevframeServiceInput[]; + snapshotRpc?: DevframeSnapshotRpcEntry[]; setup: (_: DevframeNodeContext, _?: DevframeSetupInfo) => void | Promise; cli?: DevframeCliOptions; } @@ -478,6 +479,11 @@ export type DevframeServiceInput = DevframeService export type DevframeServiceOf = ID extends keyof DevframeServicesRegistry ? DevframeServicesRegistry[ID] : unknown; export type DevframeServiceScopeOf = PKG extends keyof DevframeServicesScopeRegistry ? DevframeServicesScopeRegistry[PKG] & string : string; export type DevframeServicesState = Record; +export type DevframeSnapshotRpcEntry = string | { + method: string; + inputs: DevframeSnapshotRpcInputs; +}; +export type DevframeSnapshotRpcInputs = readonly (readonly unknown[])[] | ((_: DevframeNodeContext) => readonly (readonly unknown[])[] | Promise); export type DevframeStorageScope = 'workspace' | 'project' | 'global'; export type RemoteAssetsProvider = 'jsdelivr' | 'unpkg' | RemoteAssetsProviderCustom; export type RpcFunctionsHost = RpcFunctionsCollectorBase & { diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index c8332b4e..c5217926 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -341,6 +341,12 @@ export declare const diagnostics: import("nostics").Diagnostics<{ }) => string; readonly fix: "A service package's default export must be a factory returning a `DevframeServiceDefinition` — an object with `package`, `version`, `scope`, and a `setup` function."; }; + readonly DF0072: { + readonly why: (p: { + method: string; + }) => string; + readonly fix: "Check the method id, and ensure the service/plugin that registers it is installed (e.g. declared in `services`) before the build collects the dump."; + }; }, readonly [(d: import("nostics").Diagnostic, { method }?: { method?: "log" | "warn" | "error"; }) => void]>; diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index e7d177e4..b72c3ea5 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -54,6 +54,8 @@ export { DevframeSettings } export { DevframeSettingsRegistry } export { DevframeSettingsStore } export { DevframeSetupInfo } +export { DevframeSnapshotRpcEntry } +export { DevframeSnapshotRpcInputs } export { DevframeSseOptions } export { DevframeStorageScope } export { DevframeViewHost } diff --git a/tsconfig.base.json b/tsconfig.base.json index 42c821d2..ff4ea535 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -379,6 +379,9 @@ "@devframes/plugin-assets": [ "./plugins/assets/src/index.ts" ], + "@devframes/service-git": [ + "./services/git/src/index.ts" + ], "@devframes/service-open": [ "./services/open/src/index.ts" ], diff --git a/turbo.json b/turbo.json index 19208643..39859e00 100644 --- a/turbo.json +++ b/turbo.json @@ -52,6 +52,11 @@ "dependsOn": ["@devframes/json-render#build"], "outputs": ["dist/**"] }, + "@devframes/service-git#build": { + "outputLogs": "new-only", + "dependsOn": ["devframe#build"], + "outputs": ["dist/**"] + }, "@devframes/service-open#build": { "outputLogs": "new-only", "dependsOn": ["devframe#build"], diff --git a/vitest.config.ts b/vitest.config.ts index c0f6a16f..05861af3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -29,6 +29,7 @@ export default defineConfig({ 'plugins/a11y', 'plugins/messages', 'plugins/assets', + 'services/git', 'services/open', 'services/shiki', 'examples/hub-next', From bdd357b80ed4aabf73f80c57ad19ee64e03733e5 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 19 Aug 2026 04:03:43 +0000 Subject: [PATCH 2/3] refactor: move snapshotRpc under rpc.snapshot on DevframeDefinition Group the RPC static-build opt-in under a new `rpc` config object: `DevframeDefinition.rpc.snapshot` (new `DevframeRpcOptions`) replaces the top-level `snapshotRpc`. Entry types and the `applySnapshotRpc` build helper are unchanged; the git plugin, build adapter, DF0072 message, tests, and docs are updated to the new shape. --- docs/guide/devframe-definition.md | 2 +- docs/guide/services.md | 2 +- .../src/adapters/__tests__/build.test.ts | 22 ++++++------ packages/devframe/src/adapters/build.ts | 6 ++-- packages/devframe/src/node/diagnostics.ts | 2 +- packages/devframe/src/types/devframe.ts | 15 +++++--- plugins/git/README.md | 2 +- plugins/git/src/index.ts | 34 ++++++++++--------- plugins/git/test/git.test.ts | 8 ++--- .../tsnapi/devframe/index.snapshot.d.ts | 5 ++- .../tsnapi/devframe/types.snapshot.d.ts | 1 + 11 files changed, 56 insertions(+), 43 deletions(-) diff --git a/docs/guide/devframe-definition.md b/docs/guide/devframe-definition.md index e38f9422..94c77f2e 100644 --- a/docs/guide/devframe-definition.md +++ b/docs/guide/devframe-definition.md @@ -54,7 +54,7 @@ export default defineDevframe({ | `duplicationStrategy` | `'warn' \| 'silent' \| 'throw' \| 'duplicate'` | How a hub reacts when another devframe sharing this `id` is mounted onto the same hub. Defaults to `'warn'`. See [Hub](./hub). Hub adapters consult it; standalone adapters ignore it. | | `capabilities` | `{ dev?, build? }` | Per-runtime feature flags. A `boolean` applies to the runtime as a whole; an object enables individual features. | | `services` | `DevframeServiceInput[]` | Wire services this devframe consumes — descriptors (`{ package, version?, required?, options? }`) the adapter imports against the plugin's own dependencies, or ready definitions. See [Cross-Plugin Services](./services#wire-services). | -| `snapshotRpc` | `(string \| { method, inputs })[]` | Opt an RPC function this devframe doesn't own (e.g. a wire service's) into the static build's dump. A bare method id bakes the no-argument call; `{ method, inputs }` bakes one record per argument-tuple, where `inputs` is a list of tuples or an async `(ctx) => tuples` provider (so it can enumerate at build time via the service's node API). The first tuple's result becomes the fallback. | +| `rpc` | `{ snapshot?: (string \| { method, inputs })[] }` | RPC-level config. `rpc.snapshot` opts an RPC function this devframe doesn't own (e.g. a wire service's) into the static build's dump. A bare method id bakes the no-argument call; `{ method, inputs }` bakes one record per argument-tuple, where `inputs` is a list of tuples or an async `(ctx) => tuples` provider (so it can enumerate at build time via the service's node API). The first tuple's result becomes the fallback. | | `setup` | `(ctx, info?) => void \| Promise` | **Required.** Server-side entry point. Runs in every runtime. The optional second argument carries runtime metadata — most notably the parsed CLI `flags` when running under `createCac`. | | `cli` | `DevframeCliOptions` | Defaults for the CLI adapter. See [CLI options](#cli-options) below. | diff --git a/docs/guide/services.md b/docs/guide/services.md index c7bc3980..1926c34d 100644 --- a/docs/guide/services.md +++ b/docs/guide/services.md @@ -155,7 +155,7 @@ state.on('updated', render) **`@devframes/service-open`** (`devframes:service:open`) opens files in the user's editor (`open-in-editor`, with optional `line`/`column`) or reveals them in the OS file explorer (`open-in-finder`). Paths may be absolute or relative to the workspace root (so a client with only a workspace-relative path — a message's file position, say — calls it directly); the service refuses anything outside the workspace root and the configured extra `roots` (`DS_OPEN_0002`), and gates editor commands to the `KNOWN_EDITORS` picklist. Options: `{ editor?, roots? }` — the preferred editor (later installer wins) and additional openable directories (merged as a union). It supersedes the per-plugin `devframe/recipes/common-rpc-functions` registrations, now deprecated. -**`@devframes/service-git`** (`devframes:service:git`) runs read/write git operations over RPC — `status`, `log`, `show`, `diff`, `branches`, `stage`, `unstage`, `commit` — with parsed, typed results, so a devframe (the git plugin, or any tool) consumes git without shelling out itself. It operates on a single repo fixed at install (`{ cwd? }`, defaulting to the context cwd; root discovered once). Write ops are always exposed — authorization is the host's connection-trust boundary. The service defines no `dump`/`snapshot`; a devframe bakes the read ops it wants into a static build via [`snapshotRpc`](./devframe-definition). Client-supplied revisions are guarded against option injection. +**`@devframes/service-git`** (`devframes:service:git`) runs read/write git operations over RPC — `status`, `log`, `show`, `diff`, `branches`, `stage`, `unstage`, `commit` — with parsed, typed results, so a devframe (the git plugin, or any tool) consumes git without shelling out itself. It operates on a single repo fixed at install (`{ cwd? }`, defaulting to the context cwd; root discovered once). Write ops are always exposed — authorization is the host's connection-trust boundary. The service defines no `dump`/`snapshot`; a devframe bakes the read ops it wants into a static build via [`rpc.snapshot`](./devframe-definition). Client-supplied revisions are guarded against option injection. **`@devframes/service-shiki`** (`devframes:service:shiki`) renders [Shiki](https://shiki.style) syntax highlighting on the server, so plugin bundles stop shipping grammars and themes. Three RPC queries — `highlight` (dual-theme HTML), `code-to-hast`, and `code-to-tokens` (for renderers that own their DOM, e.g. diff views) — all client-`cacheable` and LRU-cached server-side per `(code, lang, themes)`. Unknown languages degrade to plain text. Options: `{ themes?, langs? }` — the default light/dark pair (defaults `vitesse-light`/`vitesse-dark`, matching the design system; later installer wins) and languages to eagerly load (merged as a union). diff --git a/packages/devframe/src/adapters/__tests__/build.test.ts b/packages/devframe/src/adapters/__tests__/build.test.ts index 13a80e20..620982d5 100644 --- a/packages/devframe/src/adapters/__tests__/build.test.ts +++ b/packages/devframe/src/adapters/__tests__/build.test.ts @@ -62,10 +62,10 @@ describe('adapters/build', () => { } }) - it('bakes snapshotRpc methods a devframe does not own into the dump', async () => { + it('bakes rpc.snapshot methods a devframe does not own into the dump', async () => { const outDir = mkdtempSync(join(tmpdir(), 'devframe-build-test-out-')) // A dump-less query RPC (as a wire service would register) that the - // devframe opts into baking via `snapshotRpc` — string (no-arg), static + // devframe opts into baking via `rpc.snapshot` — string (no-arg), static // inputs, and an async provider. const def = baseDevframe({ setup: (ctx) => { @@ -79,11 +79,13 @@ describe('adapters/build', () => { handler: (input: { value: string }) => input, }) }, - snapshotRpc: [ - 'demo:ping', - { method: 'demo:echo', inputs: [[{ value: 'a' }]] }, - { method: 'demo:echo', inputs: async () => [[{ value: 'b' }]] }, - ], + rpc: { + snapshot: [ + 'demo:ping', + { method: 'demo:echo', inputs: [[{ value: 'a' }]] }, + { method: 'demo:echo', inputs: async () => [[{ value: 'b' }]] }, + ], + }, }) try { await createBuild(def, { outDir }) @@ -92,7 +94,7 @@ describe('adapters/build', () => { expect(manifest['demo:ping']?.type).toBe('query') expect(manifest['demo:ping'].fallback).toBeTruthy() // `demo:echo` baked a record per provided tuple (static + provider merged - // — the last snapshotRpc entry for a method wins). + // — the last rpc.snapshot entry for a method wins). expect(manifest['demo:echo']?.type).toBe('query') expect(Object.keys(manifest['demo:echo'].records).length).toBeGreaterThanOrEqual(1) } @@ -101,12 +103,12 @@ describe('adapters/build', () => { } }) - it('warns (DF0072) when snapshotRpc names an unregistered method', async () => { + it('warns (DF0072) when rpc.snapshot names an unregistered method', async () => { const outDir = mkdtempSync(join(tmpdir(), 'devframe-build-test-out-')) try { // Does not throw — a missing target is a warning, the build proceeds. await expect( - createBuild(baseDevframe({ snapshotRpc: ['does:not:exist'] }), { outDir }), + createBuild(baseDevframe({ rpc: { snapshot: ['does:not:exist'] } }), { outDir }), ).resolves.toBeUndefined() } finally { diff --git a/packages/devframe/src/adapters/build.ts b/packages/devframe/src/adapters/build.ts index ae7b0702..4debfc7a 100644 --- a/packages/devframe/src/adapters/build.ts +++ b/packages/devframe/src/adapters/build.ts @@ -96,10 +96,10 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt await ctx.services.ready() await d.setup(ctx) - // Bake declared `snapshotRpc` methods (typically a wire service's RPC the + // Bake declared `rpc.snapshot` methods (typically a wire service's RPC the // devframe doesn't own) into the static dump by attaching a `dump` to their // registered definitions — the service itself defines none. - applySnapshotRpc(ctx, d.snapshotRpc) + applySnapshotRpc(ctx, d.rpc?.snapshot) await fs.mkdir(resolve(outDir, DEVFRAME_RPC_DUMP_DIRNAME), { recursive: true }) @@ -142,7 +142,7 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt } /** - * Attach a `dump` to each {@link DevframeDefinition.snapshotRpc} target so the + * Attach a `dump` to each {@link DevframeRpcOptions.snapshot} target so the * static collector bakes it, even though the (service-owned) definition * declares no dump of its own. A bare method id becomes `snapshot: true` * (bakes the no-arg call); `{ method, inputs }` bakes one record per resolved diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index afb913f5..0e98f313 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -196,7 +196,7 @@ export const diagnostics = defineDiagnostics({ }, DF0072: { why: (p: { method: string }) => - `\`snapshotRpc\` names "${p.method}", but no RPC function is registered under that id — nothing to bake into the static build.`, + `\`rpc.snapshot\` names "${p.method}", but no RPC function is registered under that id — nothing to bake into the static build.`, fix: 'Check the method id, and ensure the service/plugin that registers it is installed (e.g. declared in `services`) before the build collects the dump.', }, }, diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index e4186c4c..df3f59b2 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -363,6 +363,14 @@ export interface DevframeDefinition { * `client.services.has(pkg)` and degrade. */ services?: DevframeServiceInput[] + /** RPC-level configuration for this devframe (see {@link DevframeRpcOptions}). */ + rpc?: DevframeRpcOptions + /** Server-side setup — the primary entrypoint. Runs in every runtime. */ + setup: (ctx: DevframeNodeContext, info?: DevframeSetupInfo) => void | Promise + cli?: DevframeCliOptions +} + +export interface DevframeRpcOptions { /** * Opt an RPC function into the static-build snapshot **without owning its * definition** — the mechanism a devframe uses to bake a wire service's @@ -377,10 +385,7 @@ export interface DevframeDefinition { * executes the target's own handler per tuple; the first tuple's result * becomes the fallback so any call variant resolves to a baked value. */ - snapshotRpc?: DevframeSnapshotRpcEntry[] - /** Server-side setup — the primary entrypoint. Runs in every runtime. */ - setup: (ctx: DevframeNodeContext, info?: DevframeSetupInfo) => void | Promise - cli?: DevframeCliOptions + snapshot?: DevframeSnapshotRpcEntry[] } /** Argument-tuples to bake for a {@link DevframeSnapshotRpcEntry}, or a provider that computes them at build time. */ @@ -389,7 +394,7 @@ export type DevframeSnapshotRpcInputs | ((ctx: DevframeNodeContext) => readonly (readonly unknown[])[] | Promise) /** - * One {@link DevframeDefinition.snapshotRpc} entry: a bare method id (bakes + * One {@link DevframeRpcOptions.snapshot} entry: a bare method id (bakes * the no-argument call) or a method plus the argument-tuples to bake. */ export type DevframeSnapshotRpcEntry diff --git a/plugins/git/README.md b/plugins/git/README.md index 3628e714..64d40f41 100644 --- a/plugins/git/README.md +++ b/plugins/git/README.md @@ -55,7 +55,7 @@ All git work runs through the [`@devframes/service-git`](../../services/git) wire service, which this devframe declares (`services`) and its SPA calls directly over `devframes:service:git:*`. The read functions are `query` functions that degrade to an empty, `isRepo: false` result outside a git -repository; the definition opts them into the static build via `snapshotRpc` +repository; the definition opts them into the static build via `rpc.snapshot` (resolved live over WebSocket in dev, served from a build-time snapshot for static deploys). diff --git a/plugins/git/src/index.ts b/plugins/git/src/index.ts index 622090d3..771f9987 100644 --- a/plugins/git/src/index.ts +++ b/plugins/git/src/index.ts @@ -1,5 +1,5 @@ // Types-only: loads service-git's registry augmentation so -// `ctx.services.get('@devframes/service-git')` is typed in `snapshotRpc`. +// `ctx.services.get('@devframes/service-git')` is typed in `rpc.snapshot`. import type {} from '@devframes/service-git' import type { DevframeDefinition, RemoteAssets } from 'devframe' import process from 'node:process' @@ -82,22 +82,24 @@ export function createGitDevframe(options: GitDevframeOptions = {}): DevframeDef // their no-arg call; log bakes the 200-commit head; show bakes one // (patch-less) record per commit, enumerated at build time via the // service's node API. - snapshotRpc: [ - 'devframes:service:git:status', - 'devframes:service:git:branches', - 'devframes:service:git:diff', - { method: 'devframes:service:git:log', inputs: [[{ limit: 200 }]] }, - { - method: 'devframes:service:git:show', - inputs: async (ctx) => { - const git = ctx.services.get(GIT_SERVICE) - if (!git) - return [] - const { commits } = await git.log({ limit: 200 }) - return commits.map(commit => [{ hash: commit.hash, patch: false }]) + rpc: { + snapshot: [ + 'devframes:service:git:status', + 'devframes:service:git:branches', + 'devframes:service:git:diff', + { method: 'devframes:service:git:log', inputs: [[{ limit: 200 }]] }, + { + method: 'devframes:service:git:show', + inputs: async (ctx) => { + const git = ctx.services.get(GIT_SERVICE) + if (!git) + return [] + const { commits } = await git.log({ limit: 200 }) + return commits.map(commit => [{ hash: commit.hash, patch: false }]) + }, }, - }, - ], + ], + }, setup() {}, }) } diff --git a/plugins/git/test/git.test.ts b/plugins/git/test/git.test.ts index eba0199b..8f436f91 100644 --- a/plugins/git/test/git.test.ts +++ b/plugins/git/test/git.test.ts @@ -69,13 +69,13 @@ describe('@devframes/plugin-git', () => { }) }) -describe('@devframes/plugin-git (snapshotRpc build baking)', () => { - it('bakes the git service read ops declared in snapshotRpc', async () => { +describe('@devframes/plugin-git (rpc.snapshot build baking)', () => { + it('bakes the git service read ops declared in rpc.snapshot', async () => { const repo = createTempRepo() try { const ctx = await createDashboardContext(repo.dir, 'build') - // Mirror `createBuild`: honor the definition's `snapshotRpc` before collecting. - applySnapshotRpc(ctx, createGitDevframe().snapshotRpc) + // Mirror `createBuild`: honor the definition's `rpc.snapshot` before collecting. + applySnapshotRpc(ctx, createGitDevframe().rpc?.snapshot) const dump = await collectStaticRpcDump(ctx.rpc.definitions.values(), ctx) const status = dump.manifest['devframes:service:git:status'] diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 2babb34a..58acf736 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -147,7 +147,7 @@ export interface DevframeDefinition { build?: boolean; }; services?: DevframeServiceInput[]; - snapshotRpc?: DevframeSnapshotRpcEntry[]; + rpc?: DevframeRpcOptions; setup: (_: DevframeNodeContext, _?: DevframeSetupInfo) => void | Promise; cli?: DevframeCliOptions; } @@ -229,6 +229,9 @@ export interface DevframeRpcConnectionRequest { get: (_: string) => string | null | undefined; }; } +export interface DevframeRpcOptions { + snapshot?: DevframeSnapshotRpcEntry[]; +} export interface DevframeRpcServerFunctions { 'anonymous:devframe:auth': (_: { authToken: string; diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index b72c3ea5..99be3647 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -33,6 +33,7 @@ export { DevframeNodeRpcSessionMeta } export { DevframeRpcClientFunctions } export { DevframeRpcConnection } export { DevframeRpcConnectionRequest } +export { DevframeRpcOptions } export { DevframeRpcServerFunctions } export { DevframeRpcSharedStates } export { DevframeRpcTransportKind } From d0b2b7c882cc75cfb2edccabb8a6eed1a5236915 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 19 Aug 2026 04:32:12 +0000 Subject: [PATCH 3/3] test: update e2e agent-tool name for the git service The next-devframe-hub e2e asserted `devframes_plugin_git_status`; git's agent-flagged reads now live on `@devframes/service-git`, so the hub MCP surfaces `devframes_service_git_status`. Also assert the service exposes its agent-flagged status op on the agent surface. --- services/git/test/git.test.ts | 13 +++++++++++++ tests/e2e/next-devframe-hub-dev.spec.ts | 5 +++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/services/git/test/git.test.ts b/services/git/test/git.test.ts index 9ddc7ad2..1cc8a567 100644 --- a/services/git/test/git.test.ts +++ b/services/git/test/git.test.ts @@ -181,4 +181,17 @@ describe('@devframes/service-git', () => { ) expect(status.isRepo).toBe(true) }) + + it('exposes agent-flagged read ops on the agent surface', async () => { + const repo = createTempRepo() + cleanups.push(repo.cleanup) + const ctx = await createHostContext({ cwd: repo.dir, mode: 'dev', host: nullHost(repo.dir) }) + void ctx.services.install(createGitService()) + await ctx.services.ready() + + // Auto-discovered from the RPC `agent` field — the hub's MCP surfaces this + // as `devframes_service_git_status` (the e2e asserts that name). + const tool = ctx.agent.getTool('devframes:service:git:status') + expect(tool?.title).toBe('Git status') + }) }) diff --git a/tests/e2e/next-devframe-hub-dev.spec.ts b/tests/e2e/next-devframe-hub-dev.spec.ts index 6e86152f..6c83a81c 100644 --- a/tests/e2e/next-devframe-hub-dev.spec.ts +++ b/tests/e2e/next-devframe-hub-dev.spec.ts @@ -39,11 +39,12 @@ test.describe('devframe connect (next-devframe-hub)', () => { expect(hub.mcp.url).toMatch(/^http:\/\/(?:localhost|127\.0\.0\.1):9878\/__devframes\/__mcp$/) // The hub's agent surface flows through: the agent-flagged hub command, - // the built-in devframe_state_read, and the git plugin's agent-flagged reads. + // the built-in devframe_state_read, and the git service's agent-flagged + // reads (the git plugin now exposes git through `@devframes/service-git`). const toolNames = hub.mcp.tools.map((t: any) => t.name) expect(toolNames).toContain('example_next-devframe-hub_ping') expect(toolNames).toContain('devframe_state_read') - expect(toolNames).toContain('devframes_plugin_git_status') + expect(toolNames).toContain('devframes_service_git_status') // Call the agent-flagged hub command through the connector. const ping = parseToolText(await client.callTool({