Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guide/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
35 changes: 33 additions & 2 deletions services/git/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ import type {
DiffArgs,
GitBranches,
GitDiff,
GitFile,
GitLog,
GitServiceApi,
GitStatus,
GitTags,
LogArgs,
ReadFileArgs,
ShowArgs,
StageArgs,
UnstageArgs,
Expand Down Expand Up @@ -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(),
Expand All @@ -114,8 +127,10 @@ declare module 'devframe' {
'devframes:service:git:status': () => Promise<GitStatus>
'devframes:service:git:log': (args?: LogArgs) => Promise<GitLog>
'devframes:service:git:show': (args: ShowArgs) => Promise<CommitDetail>
'devframes:service:git:readFile': (args: ReadFileArgs) => Promise<GitFile>
'devframes:service:git:diff': (args?: DiffArgs) => Promise<GitDiff>
'devframes:service:git:branches': () => Promise<GitBranches>
'devframes:service:git:tags': () => Promise<GitTags>
'devframes:service:git:stage': (args: StageArgs) => Promise<GitStatus>
'devframes:service:git:unstage': (args: UnstageArgs) => Promise<GitStatus>
'devframes:service:git:commit': (args: CommitArgs) => Promise<CommitResult>
Expand Down Expand Up @@ -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<GitLog> => ops.log(args),
}))
ctx.rpc.register(defineRpcFunction({
Expand All @@ -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<CommitDetail> => 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<GitFile> => ops.readFile(args),
}))
ctx.rpc.register(defineRpcFunction({
name: 'diff',
type: 'query',
Expand All @@ -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<GitBranches> => 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<GitTags> => ops.tags(),
}))

// Write ops (always registered — authorization is the host's concern).
ctx.rpc.register(defineRpcFunction({
Expand Down
91 changes: 88 additions & 3 deletions services/git/src/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ import type {
DiffFile,
FileStatusCode,
GitBranches,
GitFile,
GitServiceApi,
GitStatus,
GitTags,
StatusFileEntry,
Tag,
} from './types'
import {
gitErrorMessage,
Expand All @@ -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 = {
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -259,10 +279,15 @@ function parseCommitNumstat(raw: string, status: Map<string, FileStatusCode>): 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 -----------------------------------------------------------
Expand Down Expand Up @@ -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) : []
Expand All @@ -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 `<ref>:<path>` 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()
Expand Down Expand Up @@ -416,6 +474,33 @@ export function createGitOps(cwd: string): GitServiceApi {
return { isRepo: true, current, branches }
},

async tags(): Promise<GitTags> {
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()
Expand Down
49 changes: 49 additions & 0 deletions services/git/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -189,8 +236,10 @@ export interface GitServiceApi {
status: () => Promise<GitStatus>
log: (args?: LogArgs) => Promise<GitLog>
show: (args: ShowArgs) => Promise<CommitDetail>
readFile: (args: ReadFileArgs) => Promise<GitFile>
diff: (args?: DiffArgs) => Promise<GitDiff>
branches: () => Promise<GitBranches>
tags: () => Promise<GitTags>
stage: (args: StageArgs) => Promise<GitStatus>
unstage: (args: UnstageArgs) => Promise<GitStatus>
commit: (args: CommitArgs) => Promise<CommitResult>
Expand Down
51 changes: 50 additions & 1 deletion services/git/test/_repo.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)
}
Expand Down Expand Up @@ -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')
Expand All @@ -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-'))
Expand Down
Loading
Loading