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..94c77f2e 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). | +| `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 3bd5c18c..1926c34d 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 [`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). ## 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..620982d5 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,58 @@ describe('adapters/build', () => { rmSync(outDir, { recursive: true, force: true }) } }) + + 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 `rpc.snapshot` — 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, + }) + }, + rpc: { + snapshot: [ + '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 rpc.snapshot 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 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({ rpc: { snapshot: ['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..4debfc7a 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 `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.rpc?.snapshot) + 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 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 + * 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..0e98f313 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 }) => + `\`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 2117c247..df3f59b2 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -363,7 +363,40 @@ 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 + * 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. + */ + snapshot?: DevframeSnapshotRpcEntry[] +} + +/** 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 DevframeRpcOptions.snapshot} 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..64d40f41 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 `rpc.snapshot` +(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..771f9987 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 `rpc.snapshot`. +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,33 @@ 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. + 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/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..8f436f91 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 (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 `rpc.snapshot` before collecting. + applySnapshotRpc(ctx, createGitDevframe().rpc?.snapshot) 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..1cc8a567 --- /dev/null +++ b/services/git/test/git.test.ts @@ -0,0 +1,197 @@ +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) + }) + + 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/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..58acf736 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[]; + rpc?: DevframeRpcOptions; setup: (_: DevframeNodeContext, _?: DevframeSetupInfo) => void | Promise; cli?: DevframeCliOptions; } @@ -228,6 +229,9 @@ export interface DevframeRpcConnectionRequest { get: (_: string) => string | null | undefined; }; } +export interface DevframeRpcOptions { + snapshot?: DevframeSnapshotRpcEntry[]; +} export interface DevframeRpcServerFunctions { 'anonymous:devframe:auth': (_: { authToken: string; @@ -478,6 +482,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..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 } @@ -54,6 +55,8 @@ export { DevframeSettings } export { DevframeSettingsRegistry } export { DevframeSettingsStore } export { DevframeSetupInfo } +export { DevframeSnapshotRpcEntry } +export { DevframeSnapshotRpcInputs } export { DevframeSseOptions } export { DevframeStorageScope } export { DevframeViewHost } 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({ 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',