Skip to content

Commit edcbbf2

Browse files
authored
feat(service-git): add readFile, tags, and path-scoped log for read-only history browsing (#269)
1 parent 77d699a commit edcbbf2

7 files changed

Lines changed: 321 additions & 8 deletions

File tree

docs/guide/services.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ state.on('updated', render)
155155

156156
**`@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.
157157

158-
**`@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.
158+
**`@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.
159159

160160
**`@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).
161161

services/git/src/index.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ import type {
66
DiffArgs,
77
GitBranches,
88
GitDiff,
9+
GitFile,
910
GitLog,
1011
GitServiceApi,
1112
GitStatus,
13+
GitTags,
1214
LogArgs,
15+
ReadFileArgs,
1316
ShowArgs,
1417
StageArgs,
1518
UnstageArgs,
@@ -93,6 +96,16 @@ const commitDetailSchema = s.object({
9396
truncated: s.boolean(),
9497
})
9598

99+
const gitFileSchema = s.object({
100+
isRepo: s.boolean(),
101+
found: s.boolean(),
102+
ref: s.string(),
103+
path: s.string(),
104+
content: s.nullable(s.string()),
105+
binary: s.boolean(),
106+
truncated: s.boolean(),
107+
})
108+
96109
const gitDiffSchema = s.object({
97110
isRepo: s.boolean(),
98111
staged: s.boolean(),
@@ -114,8 +127,10 @@ declare module 'devframe' {
114127
'devframes:service:git:status': () => Promise<GitStatus>
115128
'devframes:service:git:log': (args?: LogArgs) => Promise<GitLog>
116129
'devframes:service:git:show': (args: ShowArgs) => Promise<CommitDetail>
130+
'devframes:service:git:readFile': (args: ReadFileArgs) => Promise<GitFile>
117131
'devframes:service:git:diff': (args?: DiffArgs) => Promise<GitDiff>
118132
'devframes:service:git:branches': () => Promise<GitBranches>
133+
'devframes:service:git:tags': () => Promise<GitTags>
119134
'devframes:service:git:stage': (args: StageArgs) => Promise<GitStatus>
120135
'devframes:service:git:unstage': (args: UnstageArgs) => Promise<GitStatus>
121136
'devframes:service:git:commit': (args: CommitArgs) => Promise<CommitResult>
@@ -159,9 +174,9 @@ export function createGitService(options?: GitServiceOptions): DevframeServiceDe
159174
name: 'log',
160175
type: 'query',
161176
jsonSerializable: true,
162-
args: [s.object({ limit: s.optional(s.number()), skip: s.optional(s.number()), ref: s.optional(s.string()) })],
177+
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())) })],
163178
returns: gitLogSchema,
164-
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.' },
179+
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.' },
165180
handler: (args: LogArgs = {}): Promise<GitLog> => ops.log(args),
166181
}))
167182
ctx.rpc.register(defineRpcFunction({
@@ -173,6 +188,15 @@ export function createGitService(options?: GitServiceOptions): DevframeServiceDe
173188
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.' },
174189
handler: (args: ShowArgs): Promise<CommitDetail> => ops.show(args),
175190
}))
191+
ctx.rpc.register(defineRpcFunction({
192+
name: 'readFile',
193+
type: 'query',
194+
jsonSerializable: true,
195+
args: [s.object({ path: s.string(), ref: s.optional(s.string()) })],
196+
returns: gitFileSchema,
197+
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.' },
198+
handler: (args: ReadFileArgs): Promise<GitFile> => ops.readFile(args),
199+
}))
176200
ctx.rpc.register(defineRpcFunction({
177201
name: 'diff',
178202
type: 'query',
@@ -189,6 +213,13 @@ export function createGitService(options?: GitServiceOptions): DevframeServiceDe
189213
agent: { title: 'Git branches', description: 'List local branches with tracking state (ahead/behind, gone upstreams) and the current branch. Safe to call freely.' },
190214
handler: (): Promise<GitBranches> => ops.branches(),
191215
}))
216+
ctx.rpc.register(defineRpcFunction({
217+
name: 'tags',
218+
type: 'query',
219+
jsonSerializable: true,
220+
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.' },
221+
handler: (): Promise<GitTags> => ops.tags(),
222+
}))
192223

193224
// Write ops (always registered — authorization is the host's concern).
194225
ctx.rpc.register(defineRpcFunction({

services/git/src/operations.ts

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ import type {
66
DiffFile,
77
FileStatusCode,
88
GitBranches,
9+
GitFile,
910
GitServiceApi,
1011
GitStatus,
12+
GitTags,
1113
StatusFileEntry,
14+
Tag,
1215
} from './types'
1316
import {
1417
gitErrorMessage,
@@ -24,6 +27,9 @@ import {
2427
/** Hard cap on returned patch text to keep payloads bounded. */
2528
const PATCH_CHAR_LIMIT = 200_000
2629

30+
/** Hard cap on returned raw file content to keep payloads bounded. */
31+
const FILE_CHAR_LIMIT = 500_000
32+
2733
// --- status ---------------------------------------------------------------
2834

2935
const EMPTY_STATUS: GitStatus = {
@@ -169,6 +175,20 @@ const BRANCH_FORMAT = [
169175
'%(contents:subject)',
170176
].join(UNIT)
171177

178+
// --- tags ------------------------------------------------------------------
179+
180+
// `creatordate` so annotated tags report their own date (a naive
181+
// `committerdate` is empty for annotated tags). `*objectname`/`*subject`
182+
// dereference annotated tags to their target commit.
183+
const TAG_FORMAT = [
184+
'%(refname:short)',
185+
'%(objecttype)',
186+
'%(objectname:short)',
187+
'%(*objectname:short)',
188+
'%(creatordate:iso-strict)',
189+
'%(contents:subject)',
190+
].join(UNIT)
191+
172192
function parseTrack(track: string): { ahead: number, behind: number, gone: boolean } {
173193
if (track.includes('gone'))
174194
return { ahead: 0, behind: 0, gone: true }
@@ -259,10 +279,15 @@ function parseCommitNumstat(raw: string, status: Map<string, FileStatusCode>): C
259279
})
260280
}
261281

282+
function clipText(raw: string, limit: number): { text: string, truncated: boolean } {
283+
return raw.length > limit
284+
? { text: raw.slice(0, limit), truncated: true }
285+
: { text: raw, truncated: false }
286+
}
287+
262288
function clipPatch(raw: string): { patch: string, truncated: boolean } {
263-
return raw.length > PATCH_CHAR_LIMIT
264-
? { patch: raw.slice(0, PATCH_CHAR_LIMIT), truncated: true }
265-
: { patch: raw, truncated: false }
289+
const { text, truncated } = clipText(raw, PATCH_CHAR_LIMIT)
290+
return { patch: text, truncated }
266291
}
267292

268293
// --- ops factory -----------------------------------------------------------
@@ -349,6 +374,11 @@ export function createGitOps(cwd: string): GitServiceApi {
349374
return { isRepo: true, commits: [], limit, skip, hasMore: false }
350375
command.push('--end-of-options', ref)
351376
}
377+
// Pathspec after `--` — everything past it is treated as a path, never
378+
// an option, so client paths need no dash guard here.
379+
const paths = (args.paths ?? []).map(p => p.trim()).filter(Boolean)
380+
if (paths.length > 0)
381+
command.push('--', ...paths)
352382

353383
const raw = await tryGit(cwd, command)
354384
const commits = raw ? parseLog(raw) : []
@@ -364,6 +394,34 @@ export function createGitOps(cwd: string): GitServiceApi {
364394
return readCommit(hash, includePatch)
365395
},
366396

397+
async readFile(args) {
398+
const path = (args?.path ?? '').trim()
399+
const ref = args?.ref?.trim() || 'HEAD'
400+
const root = await resolveRoot()
401+
const base: GitFile = { isRepo: !!root, found: false, ref, path, content: null, binary: false, truncated: false }
402+
if (!root || !path)
403+
return base
404+
// The spec is one `<ref>:<path>` token; guarding the ref against a
405+
// leading dash keeps the whole token from being read as an option.
406+
if (!isSafeRevision(ref))
407+
return base
408+
409+
// `runGit` (not `tryGit`) preserves the blob's exact bytes, including a
410+
// trailing newline; a missing path exits non-zero and lands in `catch`.
411+
let raw: string
412+
try {
413+
;({ stdout: raw } = await runGit(cwd, ['show', '--end-of-options', `${ref}:${path}`]))
414+
}
415+
catch {
416+
return base
417+
}
418+
// A NUL byte marks binary content — omit it rather than return garbage.
419+
if (raw.includes('\0'))
420+
return { ...base, found: true, binary: true }
421+
const { text: content, truncated } = clipText(raw, FILE_CHAR_LIMIT)
422+
return { ...base, found: true, content, truncated }
423+
},
424+
367425
async diff(args = {}) {
368426
const { path, staged = false } = args
369427
const root = await resolveRoot()
@@ -416,6 +474,33 @@ export function createGitOps(cwd: string): GitServiceApi {
416474
return { isRepo: true, current, branches }
417475
},
418476

477+
async tags(): Promise<GitTags> {
478+
const root = await resolveRoot()
479+
if (!root)
480+
return { isRepo: false, tags: [] }
481+
482+
const raw = await tryGit(cwd, ['for-each-ref', `--format=${TAG_FORMAT}`, 'refs/tags'])
483+
if (!raw)
484+
return { isRepo: true, tags: [] }
485+
486+
const tags: Tag[] = splitClean(raw, '\n').map((line) => {
487+
const [name, objectType, objectSha, targetSha, isoDate, subject] = line.split(UNIT)
488+
const annotated = objectType === 'tag'
489+
const parsed = Date.parse(isoDate)
490+
return {
491+
name,
492+
// Annotated tags dereference to their target commit; lightweight
493+
// tags point straight at it.
494+
sha: targetSha || objectSha,
495+
date: Number.isNaN(parsed) ? 0 : parsed,
496+
subject: subject ?? '',
497+
annotated,
498+
}
499+
})
500+
tags.sort((a, b) => b.date - a.date)
501+
return { isRepo: true, tags }
502+
},
503+
419504
async stage(args) {
420505
const paths = args?.paths ?? []
421506
const root = await resolveRoot()

services/git/src/types.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ export interface LogArgs {
6767
skip?: number
6868
/** Optional ref/branch to read history from (default: current HEAD). */
6969
ref?: string
70+
/** Restrict history to commits that touched these repo-relative path(s). */
71+
paths?: string[]
7072
}
7173

7274
export interface Branch {
@@ -87,6 +89,51 @@ export interface GitBranches {
8789
branches: Branch[]
8890
}
8991

92+
export interface Tag {
93+
name: string
94+
/** Short SHA of the commit the tag ultimately points to. */
95+
sha: string
96+
/**
97+
* Tag creation date as epoch milliseconds — the tag's own date for
98+
* annotated tags, the target commit's date for lightweight tags. `0` when
99+
* the date can't be parsed.
100+
*/
101+
date: number
102+
/** Tag message subject (annotated) or target commit subject (lightweight). */
103+
subject: string
104+
/** `true` for annotated tags, which carry their own message and date. */
105+
annotated: boolean
106+
}
107+
108+
export interface GitTags {
109+
isRepo: boolean
110+
/** Tags, newest creation date first. */
111+
tags: Tag[]
112+
}
113+
114+
export interface ReadFileArgs {
115+
/** Repo-relative path to the file. */
116+
path: string
117+
/** Commit-ish to read the file from (default: current HEAD). */
118+
ref?: string
119+
}
120+
121+
export interface GitFile {
122+
/** `false` when the working directory is not inside a git repository. */
123+
isRepo: boolean
124+
/** `false` when no blob exists at `path` for `ref`. */
125+
found: boolean
126+
/** The resolved ref the file was read from. */
127+
ref: string
128+
path: string
129+
/** File text, or `null` when not found or binary. */
130+
content: string | null
131+
/** `true` when the blob is binary (its `content` is omitted). */
132+
binary: boolean
133+
/** `true` when `content` was clipped to the internal char limit. */
134+
truncated: boolean
135+
}
136+
90137
export interface DiffFile {
91138
path: string
92139
additions: number
@@ -189,8 +236,10 @@ export interface GitServiceApi {
189236
status: () => Promise<GitStatus>
190237
log: (args?: LogArgs) => Promise<GitLog>
191238
show: (args: ShowArgs) => Promise<CommitDetail>
239+
readFile: (args: ReadFileArgs) => Promise<GitFile>
192240
diff: (args?: DiffArgs) => Promise<GitDiff>
193241
branches: () => Promise<GitBranches>
242+
tags: () => Promise<GitTags>
194243
stage: (args: StageArgs) => Promise<GitStatus>
195244
unstage: (args: UnstageArgs) => Promise<GitStatus>
196245
commit: (args: CommitArgs) => Promise<CommitResult>

services/git/test/_repo.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { execFileSync } from 'node:child_process'
2-
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
33
import { tmpdir } from 'node:os'
44
import { join } from 'node:path'
55
import process from 'node:process'
@@ -26,6 +26,15 @@ function git(dir: string, args: string[]): void {
2626
execFileSync('git', args, { cwd: dir, stdio: 'pipe', env: GIT_ENV })
2727
}
2828

29+
/** Run git with an overridden author/committer date, for deterministic tags. */
30+
function gitAt(dir: string, args: string[], date: string): void {
31+
execFileSync('git', args, {
32+
cwd: dir,
33+
stdio: 'pipe',
34+
env: { ...GIT_ENV, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date },
35+
})
36+
}
37+
2938
function write(dir: string, file: string, content: string): void {
3039
writeFileSync(join(dir, file), content)
3140
}
@@ -53,6 +62,12 @@ export function createTempRepo(): TempRepo {
5362

5463
git(dir, ['branch', 'feature/x'])
5564

65+
// Tags: a lightweight tag on the initial commit, and an annotated tag (with
66+
// its own, later tagger date) on HEAD — exercises `creatordate`, which
67+
// populates for annotated tags where `committerdate` would be empty.
68+
git(dir, ['tag', 'v0.0.1', 'HEAD~1'])
69+
gitAt(dir, ['tag', '-a', 'v1.0.0', '-m', 'release one'], '2021-06-01T00:00:00Z')
70+
5671
// Working-tree state for status/diff assertions.
5772
write(dir, 'README.md', '# Demo\nmore\n') // unstaged modification
5873
write(dir, 'staged.txt', 'staged content\n')
@@ -65,6 +80,40 @@ export function createTempRepo(): TempRepo {
6580
}
6681
}
6782

83+
/**
84+
* Create a repo whose commits touch distinct paths, for path-scoped log:
85+
* 1. `feat: src a` — adds `src/a.ts`
86+
* 2. `docs: b` — adds `docs/b.md`
87+
* 3. `fix: src a` — modifies `src/a.ts`
88+
*/
89+
export function createPathRepo(): TempRepo {
90+
const dir = mkdtempSync(join(tmpdir(), 'devframe-git-paths-'))
91+
git(dir, ['init', '-b', 'main'])
92+
git(dir, ['config', 'user.name', 'Test User'])
93+
git(dir, ['config', 'user.email', 'test@example.com'])
94+
git(dir, ['config', 'commit.gpgsign', 'false'])
95+
96+
mkdirSync(join(dir, 'src'), { recursive: true })
97+
mkdirSync(join(dir, 'docs'), { recursive: true })
98+
99+
write(dir, 'src/a.ts', 'export const a = 1\n')
100+
git(dir, ['add', 'src/a.ts'])
101+
git(dir, ['commit', '-m', 'feat: src a'])
102+
103+
write(dir, 'docs/b.md', '# B\n')
104+
git(dir, ['add', 'docs/b.md'])
105+
git(dir, ['commit', '-m', 'docs: b'])
106+
107+
write(dir, 'src/a.ts', 'export const a = 2\n')
108+
git(dir, ['add', 'src/a.ts'])
109+
git(dir, ['commit', '-m', 'fix: src a'])
110+
111+
return {
112+
dir,
113+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
114+
}
115+
}
116+
68117
/** Create an empty (non-git) temp directory. */
69118
export function createTempDir(): TempRepo {
70119
const dir = mkdtempSync(join(tmpdir(), 'devframe-git-bare-'))

0 commit comments

Comments
 (0)