From 65d889f8470913678788d2f3f9dc4c614983c0cf Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 20 Aug 2026 04:10:51 +0000 Subject: [PATCH] feat(service-git): add readFile, tags, and path-scoped log for read-only history browsing --- docs/guide/services.md | 2 +- services/git/src/index.ts | 35 ++++++- services/git/src/operations.ts | 91 ++++++++++++++++++- services/git/src/types.ts | 49 ++++++++++ services/git/test/_repo.ts | 51 ++++++++++- services/git/test/git.test.ts | 74 ++++++++++++++- .../service-git/index.snapshot.d.ts | 27 ++++++ 7 files changed, 321 insertions(+), 8 deletions(-) diff --git a/docs/guide/services.md b/docs/guide/services.md index 1926c34d..eb9af27d 100644 --- a/docs/guide/services.md +++ b/docs/guide/services.md @@ -155,7 +155,7 @@ state.on('updated', render) **`@devframes/service-open`** (`devframes:service:open`) opens files in the user's editor (`open-in-editor`, with optional `line`/`column`) or reveals them in the OS file explorer (`open-in-finder`). Paths may be absolute or relative to the workspace root (so a client with only a workspace-relative path — a message's file position, say — calls it directly); the service refuses anything outside the workspace root and the configured extra `roots` (`DS_OPEN_0002`), and gates editor commands to the `KNOWN_EDITORS` picklist. Options: `{ editor?, roots? }` — the preferred editor (later installer wins) and additional openable directories (merged as a union). It supersedes the per-plugin `devframe/recipes/common-rpc-functions` registrations, now deprecated. -**`@devframes/service-git`** (`devframes:service:git`) runs read/write git operations over RPC — `status`, `log`, `show`, `diff`, `branches`, `stage`, `unstage`, `commit` — with parsed, typed results, so a devframe (the git plugin, or any tool) consumes git without shelling out itself. It operates on a single repo fixed at install (`{ cwd? }`, defaulting to the context cwd; root discovered once). Write ops are always exposed — authorization is the host's connection-trust boundary. The service defines no `dump`/`snapshot`; a devframe bakes the read ops it wants into a static build via [`rpc.snapshot`](./devframe-definition). Client-supplied revisions are guarded against option injection. +**`@devframes/service-git`** (`devframes:service:git`) runs read/write git operations over RPC — `status`, `log` (optionally path-scoped via `paths`), `show`, `readFile` (raw contents of a file at a commit-ish), `diff`, `branches`, `tags`, `stage`, `unstage`, `commit` — with parsed, typed results, so a devframe (the git plugin, or any tool) consumes git without shelling out itself. It operates on a single repo fixed at install (`{ cwd? }`, defaulting to the context cwd; root discovered once). Write ops are always exposed — authorization is the host's connection-trust boundary. The service defines no `dump`/`snapshot`; a devframe bakes the read ops it wants into a static build via [`rpc.snapshot`](./devframe-definition). Client-supplied revisions are guarded against option injection. **`@devframes/service-shiki`** (`devframes:service:shiki`) renders [Shiki](https://shiki.style) syntax highlighting on the server, so plugin bundles stop shipping grammars and themes. Three RPC queries — `highlight` (dual-theme HTML), `code-to-hast`, and `code-to-tokens` (for renderers that own their DOM, e.g. diff views) — all client-`cacheable` and LRU-cached server-side per `(code, lang, themes)`. Unknown languages degrade to plain text. Options: `{ themes?, langs? }` — the default light/dark pair (defaults `vitesse-light`/`vitesse-dark`, matching the design system; later installer wins) and languages to eagerly load (merged as a union). diff --git a/services/git/src/index.ts b/services/git/src/index.ts index 768904e4..ec7f9fb2 100644 --- a/services/git/src/index.ts +++ b/services/git/src/index.ts @@ -6,10 +6,13 @@ import type { DiffArgs, GitBranches, GitDiff, + GitFile, GitLog, GitServiceApi, GitStatus, + GitTags, LogArgs, + ReadFileArgs, ShowArgs, StageArgs, UnstageArgs, @@ -93,6 +96,16 @@ const commitDetailSchema = s.object({ truncated: s.boolean(), }) +const gitFileSchema = s.object({ + isRepo: s.boolean(), + found: s.boolean(), + ref: s.string(), + path: s.string(), + content: s.nullable(s.string()), + binary: s.boolean(), + truncated: s.boolean(), +}) + const gitDiffSchema = s.object({ isRepo: s.boolean(), staged: s.boolean(), @@ -114,8 +127,10 @@ declare module 'devframe' { 'devframes:service:git:status': () => Promise 'devframes:service:git:log': (args?: LogArgs) => Promise 'devframes:service:git:show': (args: ShowArgs) => Promise + 'devframes:service:git:readFile': (args: ReadFileArgs) => Promise 'devframes:service:git:diff': (args?: DiffArgs) => Promise 'devframes:service:git:branches': () => Promise + 'devframes:service:git:tags': () => Promise 'devframes:service:git:stage': (args: StageArgs) => Promise 'devframes:service:git:unstage': (args: UnstageArgs) => Promise 'devframes:service:git:commit': (args: CommitArgs) => Promise @@ -159,9 +174,9 @@ export function createGitService(options?: GitServiceOptions): DevframeServiceDe name: 'log', type: 'query', jsonSerializable: true, - args: [s.object({ limit: s.optional(s.number()), skip: s.optional(s.number()), ref: s.optional(s.string()) })], + args: [s.object({ limit: s.optional(s.number()), skip: s.optional(s.number()), ref: s.optional(s.string()), paths: s.optional(s.array(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.' }, + 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, or paths to list only commits that touched those files/directories. Safe to call freely.' }, handler: (args: LogArgs = {}): Promise => ops.log(args), })) ctx.rpc.register(defineRpcFunction({ @@ -173,6 +188,15 @@ export function createGitService(options?: GitServiceOptions): DevframeServiceDe 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: 'readFile', + type: 'query', + jsonSerializable: true, + args: [s.object({ path: s.string(), ref: s.optional(s.string()) })], + returns: gitFileSchema, + agent: { title: 'Git read file', description: 'Read the contents of a single file at a commit-ish (default HEAD) — the raw text of a versioned file without checking it out. found is false when no such file exists at the ref; binary blobs return with content omitted. Safe to call freely.' }, + handler: (args: ReadFileArgs): Promise => ops.readFile(args), + })) ctx.rpc.register(defineRpcFunction({ name: 'diff', type: 'query', @@ -189,6 +213,13 @@ export function createGitService(options?: GitServiceOptions): DevframeServiceDe 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(), })) + ctx.rpc.register(defineRpcFunction({ + name: 'tags', + type: 'query', + jsonSerializable: true, + agent: { title: 'Git tags', description: 'List tags (newest first) with the target commit SHA, creation date, and message subject; annotated tags are flagged. Safe to call freely.' }, + handler: (): Promise => ops.tags(), + })) // Write ops (always registered — authorization is the host's concern). ctx.rpc.register(defineRpcFunction({ diff --git a/services/git/src/operations.ts b/services/git/src/operations.ts index 203ce3f2..18411d97 100644 --- a/services/git/src/operations.ts +++ b/services/git/src/operations.ts @@ -6,9 +6,12 @@ import type { DiffFile, FileStatusCode, GitBranches, + GitFile, GitServiceApi, GitStatus, + GitTags, StatusFileEntry, + Tag, } from './types' import { gitErrorMessage, @@ -24,6 +27,9 @@ import { /** Hard cap on returned patch text to keep payloads bounded. */ const PATCH_CHAR_LIMIT = 200_000 +/** Hard cap on returned raw file content to keep payloads bounded. */ +const FILE_CHAR_LIMIT = 500_000 + // --- status --------------------------------------------------------------- const EMPTY_STATUS: GitStatus = { @@ -169,6 +175,20 @@ const BRANCH_FORMAT = [ '%(contents:subject)', ].join(UNIT) +// --- tags ------------------------------------------------------------------ + +// `creatordate` so annotated tags report their own date (a naive +// `committerdate` is empty for annotated tags). `*objectname`/`*subject` +// dereference annotated tags to their target commit. +const TAG_FORMAT = [ + '%(refname:short)', + '%(objecttype)', + '%(objectname:short)', + '%(*objectname:short)', + '%(creatordate:iso-strict)', + '%(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 } @@ -259,10 +279,15 @@ function parseCommitNumstat(raw: string, status: Map): C }) } +function clipText(raw: string, limit: number): { text: string, truncated: boolean } { + return raw.length > limit + ? { text: raw.slice(0, limit), truncated: true } + : { text: raw, truncated: false } +} + 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 } + const { text, truncated } = clipText(raw, PATCH_CHAR_LIMIT) + return { patch: text, truncated } } // --- ops factory ----------------------------------------------------------- @@ -349,6 +374,11 @@ export function createGitOps(cwd: string): GitServiceApi { return { isRepo: true, commits: [], limit, skip, hasMore: false } command.push('--end-of-options', ref) } + // Pathspec after `--` — everything past it is treated as a path, never + // an option, so client paths need no dash guard here. + const paths = (args.paths ?? []).map(p => p.trim()).filter(Boolean) + if (paths.length > 0) + command.push('--', ...paths) const raw = await tryGit(cwd, command) const commits = raw ? parseLog(raw) : [] @@ -364,6 +394,34 @@ export function createGitOps(cwd: string): GitServiceApi { return readCommit(hash, includePatch) }, + async readFile(args) { + const path = (args?.path ?? '').trim() + const ref = args?.ref?.trim() || 'HEAD' + const root = await resolveRoot() + const base: GitFile = { isRepo: !!root, found: false, ref, path, content: null, binary: false, truncated: false } + if (!root || !path) + return base + // The spec is one `:` token; guarding the ref against a + // leading dash keeps the whole token from being read as an option. + if (!isSafeRevision(ref)) + return base + + // `runGit` (not `tryGit`) preserves the blob's exact bytes, including a + // trailing newline; a missing path exits non-zero and lands in `catch`. + let raw: string + try { + ;({ stdout: raw } = await runGit(cwd, ['show', '--end-of-options', `${ref}:${path}`])) + } + catch { + return base + } + // A NUL byte marks binary content — omit it rather than return garbage. + if (raw.includes('\0')) + return { ...base, found: true, binary: true } + const { text: content, truncated } = clipText(raw, FILE_CHAR_LIMIT) + return { ...base, found: true, content, truncated } + }, + async diff(args = {}) { const { path, staged = false } = args const root = await resolveRoot() @@ -416,6 +474,33 @@ export function createGitOps(cwd: string): GitServiceApi { return { isRepo: true, current, branches } }, + async tags(): Promise { + const root = await resolveRoot() + if (!root) + return { isRepo: false, tags: [] } + + const raw = await tryGit(cwd, ['for-each-ref', `--format=${TAG_FORMAT}`, 'refs/tags']) + if (!raw) + return { isRepo: true, tags: [] } + + const tags: Tag[] = splitClean(raw, '\n').map((line) => { + const [name, objectType, objectSha, targetSha, isoDate, subject] = line.split(UNIT) + const annotated = objectType === 'tag' + const parsed = Date.parse(isoDate) + return { + name, + // Annotated tags dereference to their target commit; lightweight + // tags point straight at it. + sha: targetSha || objectSha, + date: Number.isNaN(parsed) ? 0 : parsed, + subject: subject ?? '', + annotated, + } + }) + tags.sort((a, b) => b.date - a.date) + return { isRepo: true, tags } + }, + async stage(args) { const paths = args?.paths ?? [] const root = await resolveRoot() diff --git a/services/git/src/types.ts b/services/git/src/types.ts index 288ba704..768b396e 100644 --- a/services/git/src/types.ts +++ b/services/git/src/types.ts @@ -67,6 +67,8 @@ export interface LogArgs { skip?: number /** Optional ref/branch to read history from (default: current HEAD). */ ref?: string + /** Restrict history to commits that touched these repo-relative path(s). */ + paths?: string[] } export interface Branch { @@ -87,6 +89,51 @@ export interface GitBranches { branches: Branch[] } +export interface Tag { + name: string + /** Short SHA of the commit the tag ultimately points to. */ + sha: string + /** + * Tag creation date as epoch milliseconds — the tag's own date for + * annotated tags, the target commit's date for lightweight tags. `0` when + * the date can't be parsed. + */ + date: number + /** Tag message subject (annotated) or target commit subject (lightweight). */ + subject: string + /** `true` for annotated tags, which carry their own message and date. */ + annotated: boolean +} + +export interface GitTags { + isRepo: boolean + /** Tags, newest creation date first. */ + tags: Tag[] +} + +export interface ReadFileArgs { + /** Repo-relative path to the file. */ + path: string + /** Commit-ish to read the file from (default: current HEAD). */ + ref?: string +} + +export interface GitFile { + /** `false` when the working directory is not inside a git repository. */ + isRepo: boolean + /** `false` when no blob exists at `path` for `ref`. */ + found: boolean + /** The resolved ref the file was read from. */ + ref: string + path: string + /** File text, or `null` when not found or binary. */ + content: string | null + /** `true` when the blob is binary (its `content` is omitted). */ + binary: boolean + /** `true` when `content` was clipped to the internal char limit. */ + truncated: boolean +} + export interface DiffFile { path: string additions: number @@ -189,8 +236,10 @@ export interface GitServiceApi { status: () => Promise log: (args?: LogArgs) => Promise show: (args: ShowArgs) => Promise + readFile: (args: ReadFileArgs) => Promise diff: (args?: DiffArgs) => Promise branches: () => Promise + tags: () => 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 index 944f7270..dd1fba21 100644 --- a/services/git/test/_repo.ts +++ b/services/git/test/_repo.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' @@ -26,6 +26,15 @@ function git(dir: string, args: string[]): void { execFileSync('git', args, { cwd: dir, stdio: 'pipe', env: GIT_ENV }) } +/** Run git with an overridden author/committer date, for deterministic tags. */ +function gitAt(dir: string, args: string[], date: string): void { + execFileSync('git', args, { + cwd: dir, + stdio: 'pipe', + env: { ...GIT_ENV, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date }, + }) +} + function write(dir: string, file: string, content: string): void { writeFileSync(join(dir, file), content) } @@ -53,6 +62,12 @@ export function createTempRepo(): TempRepo { git(dir, ['branch', 'feature/x']) + // Tags: a lightweight tag on the initial commit, and an annotated tag (with + // its own, later tagger date) on HEAD — exercises `creatordate`, which + // populates for annotated tags where `committerdate` would be empty. + git(dir, ['tag', 'v0.0.1', 'HEAD~1']) + gitAt(dir, ['tag', '-a', 'v1.0.0', '-m', 'release one'], '2021-06-01T00:00:00Z') + // Working-tree state for status/diff assertions. write(dir, 'README.md', '# Demo\nmore\n') // unstaged modification write(dir, 'staged.txt', 'staged content\n') @@ -65,6 +80,40 @@ export function createTempRepo(): TempRepo { } } +/** + * Create a repo whose commits touch distinct paths, for path-scoped log: + * 1. `feat: src a` — adds `src/a.ts` + * 2. `docs: b` — adds `docs/b.md` + * 3. `fix: src a` — modifies `src/a.ts` + */ +export function createPathRepo(): TempRepo { + const dir = mkdtempSync(join(tmpdir(), 'devframe-git-paths-')) + 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']) + + mkdirSync(join(dir, 'src'), { recursive: true }) + mkdirSync(join(dir, 'docs'), { recursive: true }) + + write(dir, 'src/a.ts', 'export const a = 1\n') + git(dir, ['add', 'src/a.ts']) + git(dir, ['commit', '-m', 'feat: src a']) + + write(dir, 'docs/b.md', '# B\n') + git(dir, ['add', 'docs/b.md']) + git(dir, ['commit', '-m', 'docs: b']) + + write(dir, 'src/a.ts', 'export const a = 2\n') + git(dir, ['add', 'src/a.ts']) + git(dir, ['commit', '-m', 'fix: src a']) + + 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-')) diff --git a/services/git/test/git.test.ts b/services/git/test/git.test.ts index 1cc8a567..b5642802 100644 --- a/services/git/test/git.test.ts +++ b/services/git/test/git.test.ts @@ -5,7 +5,7 @@ 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' +import { createPathRepo, createTempDir, createTempRepo } from './_repo' const cleanups: (() => void)[] = [] afterEach(() => { @@ -100,6 +100,76 @@ describe('@devframes/service-git', () => { expect(detail.files.find(f => f.path === 'a.txt')?.status).toBe('added') }) + it('reads a file at a ref, signalling absence with found: false', async () => { + const repo = createTempRepo() + cleanups.push(repo.cleanup) + const git = await createGit(repo.dir) + + // HEAD holds the committed README, not the modified working tree. + const readme = await git.readFile({ path: 'README.md' }) + expect(readme.found).toBe(true) + expect(readme.binary).toBe(false) + expect(readme.ref).toBe('HEAD') + expect(readme.content).toBe('# Demo\n') + + // `a.txt` only exists from the second commit — absent at the initial one. + const log = await git.log({}) + const initHash = log.commits[1].hash + const atInit = await git.readFile({ path: 'a.txt', ref: initHash }) + expect(atInit.found).toBe(false) + expect(atInit.content).toBeNull() + expect(atInit.ref).toBe(initHash) + + const atHead = await git.readFile({ path: 'a.txt' }) + expect(atHead.found).toBe(true) + expect(atHead.content).toBe('hello\n') + }) + + it('treats a dashed ref as invalid when reading a file', async () => { + const repo = createTempRepo() + cleanups.push(repo.cleanup) + const git = await createGit(repo.dir) + + const marker = join(repo.dir, 'readfile-injected.txt') + const file = await git.readFile({ path: 'README.md', ref: `--output=${marker}` }) + expect(file.found).toBe(false) + expect(existsSync(marker)).toBe(false) + }) + + it('lists tags newest-first with target sha, date, and annotation flag', async () => { + const repo = createTempRepo() + cleanups.push(repo.cleanup) + const git = await createGit(repo.dir) + + const { tags } = await git.tags() + expect(tags.map(t => t.name)).toEqual(['v1.0.0', 'v0.0.1']) + + const annotated = tags[0] + expect(annotated.annotated).toBe(true) + expect(annotated.subject).toBe('release one') + // `creatordate` populates for annotated tags (a naive committerdate is empty). + expect(annotated.date).toBe(Date.parse('2021-06-01T00:00:00Z')) + + const lightweight = tags[1] + expect(lightweight.annotated).toBe(false) + expect(lightweight.date).toBeGreaterThan(0) + expect(lightweight.sha).toMatch(/^[0-9a-f]+$/) + }) + + it('scopes the log to commits touching given paths', async () => { + const repo = createPathRepo() + cleanups.push(repo.cleanup) + const git = await createGit(repo.dir) + + expect((await git.log({})).commits).toHaveLength(3) + + const src = await git.log({ paths: ['src'] }) + expect(src.commits.map(c => c.subject)).toEqual(['fix: src a', 'feat: src a']) + + const docs = await git.log({ paths: ['docs/b.md'] }) + expect(docs.commits.map(c => c.subject)).toEqual(['docs: b']) + }) + it('lists local branches, current first', async () => { const repo = createTempRepo() cleanups.push(repo.cleanup) @@ -166,7 +236,9 @@ describe('@devframes/service-git', () => { expect((await git.status()).isRepo).toBe(false) expect((await git.log({})).isRepo).toBe(false) expect((await git.branches()).isRepo).toBe(false) + expect((await git.tags()).isRepo).toBe(false) expect((await git.diff()).isRepo).toBe(false) + expect((await git.readFile({ path: 'README.md' })).isRepo).toBe(false) }) it('registers scoped RPC that mirrors the node API', async () => { diff --git a/tests/__snapshots__/tsnapi/@devframes/service-git/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/service-git/index.snapshot.d.ts index b98ec09e..b430addf 100644 --- a/tests/__snapshots__/tsnapi/@devframes/service-git/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/service-git/index.snapshot.d.ts @@ -85,6 +85,15 @@ export interface GitDiff { patch: string | null; truncated: boolean; } +export interface GitFile { + isRepo: boolean; + found: boolean; + ref: string; + path: string; + content: string | null; + binary: boolean; + truncated: boolean; +} export interface GitLog { isRepo: boolean; commits: Commit[]; @@ -96,8 +105,10 @@ export interface GitServiceApi { status: () => Promise; log: (_?: LogArgs) => Promise; show: (_: ShowArgs) => Promise; + readFile: (_: ReadFileArgs) => Promise; diff: (_?: DiffArgs) => Promise; branches: () => Promise; + tags: () => Promise; stage: (_: StageArgs) => Promise; unstage: (_: UnstageArgs) => Promise; commit: (_: CommitArgs) => Promise; @@ -120,10 +131,19 @@ export interface GitStatus { clean: boolean; canWrite: boolean; } +export interface GitTags { + isRepo: boolean; + tags: Tag[]; +} export interface LogArgs { limit?: number; skip?: number; ref?: string; + paths?: string[]; +} +export interface ReadFileArgs { + path: string; + ref?: string; } export interface ShowArgs { hash: string; @@ -137,6 +157,13 @@ export interface StatusFileEntry { from?: string; status: FileStatusCode; } +export interface Tag { + name: string; + sha: string; + date: number; + subject: string; + annotated: boolean; +} export interface UnstageArgs { paths: string[]; }