feat(device): share remote-device gateway RPC between desktop and CLI (#15780)

*  feat(device): share remote-device gateway RPC between desktop and CLI

Extract the desktop's remote-device gateway RPC surface into a shared
`@lobechat/device-control` package and wire it into the CLI so `lh connect`
serves the same git / workspace / file device RPCs as the desktop app.

- local-file-shell: relocate all git operations (branches, working-tree
  patches, branch diff, checkout/rename/delete/pull/push/revert) from the
  desktop GitCtr into the shared package as pure functions
- device-control (new): the `executeDeviceRpc` dispatch + workspace scan +
  portable file-preview / file-index defaults, with platform hooks injected
- desktop: GitCtr / WorkspaceCtr / GatewayConnectionCtr become thin wrappers
  delegating to the shared package (local IPC path unchanged)
- cli: handle `rpc_request` over the gateway via the shared dispatcher

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  test(device): cover git branch ops and device-control portable defaults

- local-file-shell: real-git integration tests for branch checkout / rename /
  delete (+ validation), working-tree files & patches, revert, branch-diff with
  no remote, and push / pull / ahead-behind against a bare origin
- device-control: defaultGetLocalFilePreview (text / image / accept filter /
  workspace containment / missing file) and defaultGetProjectFileIndex (git
  ls-files path + glob fallback)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(device): preserve directory entries in the glob project-file index

The CLI `getProjectFileIndex` glob fallback used `globLocalFiles`, which returns
only non-hidden file paths and no directory entries — so the Files tree builder
flattened nested files to the root and dropped dot-directories.

Walk with fast-glob (`dot: true`) and synthesize directory entries via the same
`collectProjectDirectories` path the git branch uses, so nesting and dot-dirs
(e.g. `.agents`) render correctly. Extracted a shared `buildEntries` helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-06-14 00:56:53 +08:00
committed by GitHub
parent 39bce329fd
commit 99411041b9
25 changed files with 2534 additions and 1467 deletions
+1
View File
@@ -29,6 +29,7 @@
},
"devDependencies": {
"@lobechat/agent-gateway-client": "workspace:*",
"@lobechat/device-control": "workspace:*",
"@lobechat/device-gateway-client": "workspace:*",
"@lobechat/device-identity": "workspace:*",
"@lobechat/heterogeneous-agents": "workspace:*",
+1
View File
@@ -1,5 +1,6 @@
packages:
- '../../packages/agent-gateway-client'
- '../../packages/device-control'
- '../../packages/device-gateway-client'
- '../../packages/device-identity'
- '../../packages/heterogeneous-agents'
+32
View File
@@ -2,9 +2,16 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
defaultGetLocalFilePreview,
defaultGetProjectFileIndex,
type DeviceControlDeps,
executeDeviceRpc,
} from '@lobechat/device-control';
import type {
AgentRunRequestMessage,
DeviceSystemInfo,
RpcRequestMessage,
SystemInfoRequestMessage,
ToolCallRequestMessage,
} from '@lobechat/device-gateway-client';
@@ -292,6 +299,31 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
});
});
// Handle generic server-internal device RPCs (git / workspace / file ops).
// Shares the `@lobechat/device-control` dispatcher with the desktop app so the
// CLI exposes the same remote-device control surface. File preview / index use
// the package's portable defaults (no preview-protocol approval on the CLI).
const deviceControlDeps: DeviceControlDeps = {
getLocalFilePreview: defaultGetLocalFilePreview,
getProjectFileIndex: defaultGetProjectFileIndex,
};
client.on('rpc_request', async (request: RpcRequestMessage) => {
const { method, params, requestId } = request;
if (isDaemonChild) appendLog(`[RPC] ${method} (${requestId})`);
else info(`Received rpc_request: method=${method} (${requestId})`);
try {
const data = await executeDeviceRpc(method, params, deviceControlDeps);
client.sendRpcResponse({ requestId, result: { data, success: true } });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (isDaemonChild) appendLog(`[RPC ERROR] ${method}: ${message} (${requestId})`);
else error(`rpc_request method=${method} failed: ${message}`);
client.sendRpcResponse({ requestId, result: { error: message, success: false } });
}
});
// Handle gateway-dispatched agent runs (heterogeneous agents, e.g. Claude
// Code). Mirrors the desktop app: spawn `lh hetero exec`, which owns the full
// execution + server-ingest pipeline. Ack with the spawn outcome — `accepted`
+1
View File
@@ -56,6 +56,7 @@
"@electron-toolkit/utils": "^4.0.0",
"@lobechat/chat-adapter-imessage": "workspace:*",
"@lobechat/desktop-bridge": "workspace:*",
"@lobechat/device-control": "workspace:*",
"@lobechat/device-gateway-client": "workspace:*",
"@lobechat/device-identity": "workspace:*",
"@lobechat/electron-client-ipc": "workspace:*",
+1
View File
@@ -8,6 +8,7 @@ packages:
- '../../packages/electron-client-ipc'
- '../../packages/file-loaders'
- '../../packages/desktop-bridge'
- '../../packages/device-control'
- '../../packages/device-gateway-client'
- '../../packages/device-identity'
- '../../packages/local-file-shell'
@@ -3,6 +3,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { type DeviceControlDeps, executeDeviceRpc as runDeviceRpc } from '@lobechat/device-control';
import type {
AgentRunRequestMessage,
GatewayMcpStdioParams,
@@ -13,11 +14,8 @@ import type {
GetCommandOutputParams,
GlobFilesParams,
GrepContentParams,
InitWorkspaceParams,
KillCommandParams,
ListLocalFileParams,
ListProjectSkillsParams,
LocalFilePreviewUrlParams,
LocalReadFileParams,
LocalReadFilesParams,
LocalSearchFilesParams,
@@ -30,15 +28,16 @@ import { type ILocalSystemService, LocalSystemExecutionRuntime } from '@lobechat
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import ImessageBridgeService from '@/services/imessageBridgeSrv';
import { createLogger } from '@/utils/logger';
import GitCtr from './GitCtr';
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
import { ControllerModule, IpcMethod } from './index';
import LocalFileCtr from './LocalFileCtr';
import McpCtr from './McpCtr';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
import ShellCommandCtr from './ShellCommandCtr';
import WorkspaceCtr from './WorkspaceCtr';
const logger = createLogger('controllers:GatewayConnectionCtr');
/**
* Inject the lh-notify protocol into the first turn of a new hetero-agent session.
@@ -167,14 +166,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
return this.app.getController(LocalFileCtr);
}
private get workspaceCtr() {
return this.app.getController(WorkspaceCtr);
}
private get gitCtr() {
return this.app.getController(GitCtr);
}
private get shellCommandCtr() {
return this.app.getController(ShellCommandCtr);
}
@@ -353,99 +344,33 @@ export default class GatewayConnectionCtr extends ControllerModule {
return this.localSystemRuntime;
}
/**
* Platform-specific handlers the shared `@lobechat/device-control` dispatcher
* delegates to. Git + workspace-scan methods run inside device-control over
* `@lobechat/local-file-shell`; only file preview / index (and preview
* approval) are desktop-specific and routed back to the controllers here.
*/
private get deviceControlDeps(): DeviceControlDeps {
return {
approveProjectRoot: async (root) => {
try {
await this.app.localFileProtocolManager.approveIndexedProjectRoot(root);
} catch (error) {
logger.error(`Failed to approve project preview root ${root}:`, error);
}
},
getLocalFilePreview: (params) => this.localFileCtr.getLocalFilePreview(params),
getProjectFileIndex: (params) => this.localFileCtr.getProjectFileIndex(params),
};
}
/**
* Dispatch a generic server-internal device RPC (not an agent tool call) by
* method name. Currently only `initWorkspace` (scan the bound project root for
* skills + AGENTS.md); add new server-only device methods here.
* method name. The dispatch logic lives in `@lobechat/device-control` so the
* desktop main process and the CLI daemon share one device RPC surface.
*/
private async executeDeviceRpc(method: string, params: unknown): Promise<unknown> {
switch (method) {
case 'initWorkspace': {
return this.workspaceCtr.initWorkspace(params as InitWorkspaceParams);
}
case 'getGitBranch': {
return this.gitCtr.getGitBranch((params as { path: string }).path);
}
case 'getLinkedPullRequest': {
return this.gitCtr.getLinkedPullRequest(params as { branch: string; path: string });
}
case 'getGitWorkingTreeStatus': {
return this.gitCtr.getGitWorkingTreeStatus((params as { path: string }).path);
}
case 'getGitAheadBehind': {
return this.gitCtr.getGitAheadBehind((params as { path: string }).path);
}
case 'listGitBranches': {
return this.gitCtr.listGitBranches((params as { path: string }).path);
}
case 'checkoutGitBranch': {
return this.gitCtr.checkoutGitBranch(
params as { branch: string; create?: boolean; path: string },
);
}
case 'renameGitBranch': {
return this.gitCtr.renameGitBranch(params as { from: string; path: string; to: string });
}
case 'deleteGitBranch': {
return this.gitCtr.deleteGitBranch(params as { branch: string; path: string });
}
case 'pullGitBranch': {
return this.gitCtr.pullGitBranch(params as { path: string });
}
case 'pushGitBranch': {
return this.gitCtr.pushGitBranch(params as { path: string });
}
case 'getGitWorkingTreePatches': {
return this.gitCtr.getGitWorkingTreePatches((params as { path: string }).path);
}
case 'getGitWorkingTreeFiles': {
return this.gitCtr.getGitWorkingTreeFiles((params as { path: string }).path);
}
case 'getProjectFileIndex': {
return this.localFileCtr.getProjectFileIndex(params as { scope?: string });
}
case 'getLocalFilePreview': {
return this.localFileCtr.getLocalFilePreview(params as LocalFilePreviewUrlParams);
}
case 'listProjectSkills': {
return this.workspaceCtr.listProjectSkills(params as ListProjectSkillsParams);
}
case 'getGitBranchDiff': {
return this.gitCtr.getGitBranchDiff(params as { baseRef?: string; path: string });
}
case 'listGitRemoteBranches': {
return this.gitCtr.listGitRemoteBranches((params as { path: string }).path);
}
case 'revertGitFile': {
return this.gitCtr.revertGitFile(params as { filePath: string; path: string });
}
case 'statPath': {
return this.workspaceCtr.statPath(params as { path: string });
}
default: {
throw new Error(`Unknown device RPC method: ${method}`);
}
}
return runDeviceRpc(method, params, this.deviceControlDeps);
}
private async executeToolCall(
File diff suppressed because it is too large Load Diff
+16 -207
View File
@@ -1,244 +1,53 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import {
initWorkspace as runInitWorkspace,
listProjectSkills as runListProjectSkills,
statPath as runStatPath,
type WorkspaceScanDeps,
} from '@lobechat/device-control';
import {
type InitWorkspaceParams,
type InitWorkspaceResult,
type ListProjectSkillsParams,
type ListProjectSkillsResult,
type ProjectSkillItem,
} from '@lobechat/electron-client-ipc';
import { detectRepoType } from '@/utils/git';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:WorkspaceCtr');
const SKILL_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
// Cap recursion to guard against pathological directory trees.
const MAX_SKILL_FILE_COUNT = 1000;
const toPosixRelativePath = (filePath: string) => filePath.split(path.sep).join('/');
const listSkillFilesRecursive = async (dir: string): Promise<string[]> => {
const results: string[] = [];
const stack: string[] = [dir];
while (stack.length > 0 && results.length < MAX_SKILL_FILE_COUNT) {
const current = stack.pop()!;
let entries;
try {
entries = await readdir(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (entry.isFile()) {
results.push(toPosixRelativePath(path.relative(dir, full)));
if (results.length >= MAX_SKILL_FILE_COUNT) break;
}
}
}
return results.sort();
};
// Parse a minimal YAML frontmatter block for SKILL.md files.
// Only handles `key: value` lines; multi-line block scalars fall back to the first line.
const parseSkillFrontmatter = (raw: string): Record<string, string> => {
const match = raw.match(SKILL_FRONTMATTER_RE);
if (!match) return {};
const fields: Record<string, string> = {};
for (const line of match[1].split(/\r?\n/)) {
const colonIdx = line.indexOf(':');
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
if (!key || key.startsWith('#')) continue;
let value = line.slice(colonIdx + 1).trim();
if (value.startsWith('|') || value.startsWith('>')) continue;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
fields[key] = value;
}
return fields;
};
/**
* WorkspaceCtr
*
* Owns "project workspace" scanning: discovering agent skills (`.agents/skills`
* / `.claude/skills`) and project-root instructions (`AGENTS.md` / `CLAUDE.md`)
* under a bound project directory. Split out of LocalFileCtr so the
* workspace/agent-config concern is distinct from generic local file ops.
* Thin IPC layer over `@lobechat/device-control`'s workspace-scan helpers
* (skills discovery under `.agents/skills` / `.claude/skills` + project-root
* instructions). The scan logic is shared with the device-control RPC dispatch
* so the local desktop IPC path, the remote device RPC, and the CLI all run
* identical scans; the desktop-only preview-protocol approval is injected here.
*/
export default class WorkspaceCtr extends ControllerModule {
static override readonly groupName = 'workspace';
/**
* Scan one skill source directory (e.g. `.agents/skills`) under `root` and
* return parsed frontmatter for each `SKILL.md`. Returns `[]` when the source
* directory is absent or unreadable. Unsorted — callers sort/merge.
*/
private async scanSkillsInSource(
root: string,
source: ProjectSkillItem['source'],
): Promise<ProjectSkillItem[]> {
const dir = path.join(root, source);
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
// Directory does not exist or is not readable.
return [];
}
const skills = await Promise.all(
entries
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
.map(async (entry) => {
const skillDir = path.join(dir, entry.name);
const skillFile = path.join(skillDir, 'SKILL.md');
try {
const raw = await readFile(skillFile, 'utf8');
const fields = parseSkillFrontmatter(raw);
const files = await listSkillFilesRecursive(skillDir);
return {
description: fields.description || undefined,
fileCount: files.length,
files,
name: fields.name || entry.name,
path: skillFile,
skillDir,
source,
};
} catch {
return null;
}
}),
);
return skills.filter((skill): skill is ProjectSkillItem => skill !== null);
private get scanDeps(): WorkspaceScanDeps {
return { approveProjectRoot: (root) => this.approveProjectRootForPreview(root) };
}
/**
* Scan agent skill directories under the project root and return parsed
* frontmatter for each SKILL.md. Used by the hetero agent's working sidebar
* to surface skills available in the current project. Returns the first
* source directory that yields any skills (`.agents/skills` wins).
*/
@IpcMethod()
async listProjectSkills(params: ListProjectSkillsParams): Promise<ListProjectSkillsResult> {
const root = params.scope;
const sources = ['.agents/skills', '.claude/skills'] as const;
for (const source of sources) {
const skills = (await this.scanSkillsInSource(root, source)).sort((a, b) =>
a.name.localeCompare(b.name),
);
if (skills.length > 0) {
await this.approveProjectRootForPreview(root);
return { root, skills, source };
}
}
return { root, skills: [], source: null };
return runListProjectSkills(params, this.scanDeps);
}
/**
* One-call "workspace init" scan of a bound project directory: merge the
* project skills from BOTH `.agents/skills` and `.claude/skills` (deduped by
* name, `.agents/skills` winning) and read the project-root agent
* instructions file (`AGENTS.md`, else `CLAUDE.md`). Driven server-side at run
* start via the generic device RPC (not an LLM-visible tool) and cached onto
* `devices.workingDirs[].workspace`.
*
* Approves the root for the `lobe-file://` preview protocol (same as
* `listProjectSkills`) so the user can later click through to the scanned
* skills / instructions in the UI.
*/
@IpcMethod()
async initWorkspace(params: InitWorkspaceParams): Promise<InitWorkspaceResult> {
const root = params.scope;
const sources = ['.agents/skills', '.claude/skills'] as const;
const seen = new Set<string>();
const skills: ProjectSkillItem[] = [];
for (const source of sources) {
for (const skill of await this.scanSkillsInSource(root, source)) {
if (seen.has(skill.name)) continue;
seen.add(skill.name);
skills.push(skill);
}
}
skills.sort((a, b) => a.name.localeCompare(b.name));
const instructions = await this.readWorkspaceInstructions(root);
// Approve regardless of what was found — the run is now bound to this root,
// so any later click-through to it should resolve through the preview
// protocol even if the project carries neither skills nor instructions.
await this.approveProjectRootForPreview(root);
return { instructions, root, skills };
return runInitWorkspace(params, this.scanDeps);
}
/**
* Check whether a path exists on this device and is a directory, plus its git
* repo type (`git` / `github` / none). Used to validate a manually-entered
* working directory from a web / remote client (which can't browse this
* device's filesystem) before binding it, and to render the right dir icon.
*/
@IpcMethod()
async statPath(params: {
path: string;
}): Promise<{ exists: boolean; isDirectory: boolean; repoType?: 'git' | 'github' }> {
try {
const stats = await stat(params.path);
if (!stats.isDirectory()) return { exists: true, isDirectory: false };
const repoType = await detectRepoType(params.path);
return { exists: true, isDirectory: true, repoType };
} catch {
return { exists: false, isDirectory: false };
}
}
/**
* Read the project-root agent instructions files. Collects every present
* candidate (`AGENTS.md`, then `CLAUDE.md`) rather than first-match, since both
* can coexist. Each body is capped so a pathologically large file can't bloat
* the cached `workingDirs` payload or the injected system role.
*/
private async readWorkspaceInstructions(
root: string,
): Promise<InitWorkspaceResult['instructions']> {
const MAX_INSTRUCTIONS_BYTES = 64 * 1024;
const candidates = ['AGENTS.md', 'CLAUDE.md'] as const;
const instructions: InitWorkspaceResult['instructions'] = [];
for (const source of candidates) {
try {
const raw = await readFile(path.join(root, source), 'utf8');
const content =
raw.length > MAX_INSTRUCTIONS_BYTES ? raw.slice(0, MAX_INSTRUCTIONS_BYTES) : raw;
instructions.push({ content, source });
} catch {
// File absent or unreadable; skip it.
}
}
return instructions;
return runStatPath(params);
}
private async approveProjectRootForPreview(root: string) {
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@lobechat/device-control",
"version": "1.0.0",
"private": true,
"sideEffects": false,
"exports": {
".": "./src/index.ts"
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"test": "bunx vitest run --silent='passed-only'",
"test:coverage": "bunx vitest run --coverage"
},
"dependencies": {
"@lobechat/local-file-shell": "workspace:*",
"fast-glob": "^3.3.3"
},
"devDependencies": {
"vitest": "^3.2.6"
}
}
@@ -0,0 +1,88 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { executeDeviceRpc } from '../dispatch';
import type { DeviceControlDeps } from '../types';
let root: string;
beforeAll(async () => {
root = await mkdtemp(path.join(tmpdir(), 'device-control-'));
await mkdir(path.join(root, '.agents', 'skills', 'spa-routes'), { recursive: true });
await writeFile(
path.join(root, '.agents', 'skills', 'spa-routes', 'SKILL.md'),
'---\nname: spa-routes\ndescription: SPA routing\n---\nbody',
);
await writeFile(path.join(root, 'AGENTS.md'), '# Agents');
});
afterAll(async () => {
await rm(root, { force: true, recursive: true });
});
const makeDeps = (): DeviceControlDeps => ({
approveProjectRoot: vi.fn(async () => {}),
getLocalFilePreview: vi.fn(async () => ({ success: true })),
getProjectFileIndex: vi.fn(async () => ({
entries: [],
indexedAt: '',
root: '',
source: 'glob' as const,
totalCount: 0,
})),
});
describe('executeDeviceRpc', () => {
it('throws on an unknown method', async () => {
await expect(executeDeviceRpc('nope', {}, makeDeps())).rejects.toThrow(
'Unknown device RPC method: nope',
);
});
it('routes initWorkspace through the shared workspace scan and approves the root', async () => {
const deps = makeDeps();
const result = (await executeDeviceRpc('initWorkspace', { scope: root }, deps)) as {
instructions: { content: string; source: string }[];
skills: { name: string }[];
};
expect(result.skills.map((s) => s.name)).toEqual(['spa-routes']);
expect(result.instructions).toEqual([{ content: '# Agents', source: 'AGENTS.md' }]);
expect(deps.approveProjectRoot).toHaveBeenCalledWith(root);
});
it('routes listProjectSkills to the .agents/skills source', async () => {
const result = (await executeDeviceRpc('listProjectSkills', { scope: root }, makeDeps())) as {
source: string | null;
};
expect(result.source).toBe('.agents/skills');
});
it('routes statPath and reports a directory + repo type', async () => {
const result = (await executeDeviceRpc('statPath', { path: root }, makeDeps())) as {
exists: boolean;
isDirectory: boolean;
};
expect(result.exists).toBe(true);
expect(result.isDirectory).toBe(true);
});
it('delegates getProjectFileIndex and getLocalFilePreview to injected deps', async () => {
const deps = makeDeps();
await executeDeviceRpc('getProjectFileIndex', { scope: root }, deps);
expect(deps.getProjectFileIndex).toHaveBeenCalledWith({ scope: root });
const previewParams = { path: path.join(root, 'AGENTS.md'), workingDirectory: root };
await executeDeviceRpc('getLocalFilePreview', previewParams, deps);
expect(deps.getLocalFilePreview).toHaveBeenCalledWith(previewParams);
});
it('routes a git method (listGitBranches) without touching deps', async () => {
// Not a git repo → the shared local-file-shell impl returns an empty list.
const result = await executeDeviceRpc('listGitBranches', { path: root }, makeDeps());
expect(Array.isArray(result)).toBe(true);
});
});
@@ -0,0 +1,77 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { defaultGetLocalFilePreview } from '../filePreview';
let root: string;
let outside: string;
beforeAll(async () => {
root = await mkdtemp(path.join(tmpdir(), 'dc-preview-'));
outside = await mkdtemp(path.join(tmpdir(), 'dc-outside-'));
await writeFile(path.join(root, 'note.txt'), 'hello preview\n');
await writeFile(path.join(root, 'pic.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]));
await writeFile(path.join(outside, 'secret.txt'), 'do not read\n');
});
afterAll(async () => {
await Promise.all([root, outside].map((dir) => rm(dir, { force: true, recursive: true })));
});
describe('defaultGetLocalFilePreview', () => {
it('reads a text file inside the working directory', async () => {
const result = await defaultGetLocalFilePreview({
path: path.join(root, 'note.txt'),
workingDirectory: root,
});
expect(result.success).toBe(true);
expect(result.preview).toMatchObject({ content: 'hello preview\n', type: 'text' });
});
it('reads an image file as base64', async () => {
const result = await defaultGetLocalFilePreview({
path: path.join(root, 'pic.png'),
workingDirectory: root,
});
expect(result.success).toBe(true);
expect(result.preview?.type).toBe('image');
expect((result.preview as { base64: string }).base64).toBeTruthy();
expect((result.preview as { contentType: string }).contentType).toBe('image/png');
});
it('rejects a non-image when accept is "image"', async () => {
const result = await defaultGetLocalFilePreview({
accept: 'image',
path: path.join(root, 'note.txt'),
workingDirectory: root,
});
expect(result).toEqual({ error: 'File is not an image', success: false });
});
it('refuses to read a file outside the working directory', async () => {
const result = await defaultGetLocalFilePreview({
path: path.join(outside, 'secret.txt'),
workingDirectory: root,
});
expect(result).toEqual({ error: 'File is outside the approved workspace', success: false });
});
it('errors when the working directory is missing', async () => {
const result = await defaultGetLocalFilePreview({
path: path.join(root, 'note.txt'),
workingDirectory: '',
});
expect(result).toEqual({ error: 'Missing working directory', success: false });
});
it('fails gracefully for a non-existent file', async () => {
const result = await defaultGetLocalFilePreview({
path: path.join(root, 'ghost.txt'),
workingDirectory: root,
});
expect(result.success).toBe(false);
});
});
@@ -0,0 +1,69 @@
import { execFileSync } from 'node:child_process';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { defaultGetProjectFileIndex } from '../projectFileIndex';
const cleanup: string[] = [];
afterEach(async () => {
await Promise.all(cleanup.splice(0).map((dir) => rm(dir, { force: true, recursive: true })));
});
describe('defaultGetProjectFileIndex', () => {
it('indexes a git repo via ls-files (tracked + untracked) with directory entries', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'dc-index-git-'));
cleanup.push(dir);
execFileSync('git', ['-c', 'init.defaultBranch=main', 'init'], { cwd: dir });
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir });
execFileSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: dir });
await mkdir(path.join(dir, 'src'), { recursive: true });
await writeFile(path.join(dir, 'src', 'index.ts'), 'export const a = 1;\n');
await writeFile(path.join(dir, 'README.md'), '# hi\n');
execFileSync('git', ['add', '.'], { cwd: dir });
execFileSync('git', ['commit', '-m', 'init'], { cwd: dir });
// Untracked-but-not-ignored file is included via ls-files --others.
await writeFile(path.join(dir, 'scratch.txt'), 'tmp\n');
const result = await defaultGetProjectFileIndex({ scope: dir });
expect(result.source).toBe('git');
const rels = result.entries.map((e) => e.relativePath);
expect(rels).toContain('src/index.ts');
expect(rels).toContain('README.md');
expect(rels).toContain('scratch.txt');
// The intermediate directory is surfaced as its own entry.
expect(result.entries.find((e) => e.relativePath === 'src/')?.isDirectory).toBe(true);
expect(result.totalCount).toBe(result.entries.length);
});
it('falls back to a glob walk when the scope is not a git repo', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'dc-index-glob-'));
cleanup.push(dir);
await mkdir(path.join(dir, 'nested', 'deep'), { recursive: true });
await mkdir(path.join(dir, '.agents'), { recursive: true });
await writeFile(path.join(dir, 'one.txt'), '1\n');
await writeFile(path.join(dir, 'nested', 'deep', 'two.txt'), '2\n');
await writeFile(path.join(dir, '.agents', 'config.md'), '# cfg\n');
const result = await defaultGetProjectFileIndex({ scope: dir });
expect(result.source).toBe('glob');
const byRel = Object.fromEntries(result.entries.map((e) => [e.relativePath, e]));
// Nested files are present and attached to synthesized directory entries.
expect(byRel['nested/deep/two.txt']?.isDirectory).toBe(false);
expect(byRel['nested/']?.isDirectory).toBe(true);
expect(byRel['nested/deep/']?.isDirectory).toBe(true);
// Dot-directories (and their files) are preserved, matching the git path.
expect(byRel['.agents/']?.isDirectory).toBe(true);
expect(byRel['.agents/config.md']?.isDirectory).toBe(false);
expect(result.totalCount).toBe(result.entries.length);
});
});
+160
View File
@@ -0,0 +1,160 @@
import {
checkoutGitBranch,
deleteGitBranch,
getGitAheadBehind,
getGitBranch,
getGitBranchDiff,
getGitWorkingTreeFiles,
getGitWorkingTreePatches,
getGitWorkingTreeStatus,
getLinkedPullRequest,
listGitBranches,
listGitRemoteBranches,
pullGitBranch,
pushGitBranch,
renameGitBranch,
revertGitFile,
} from '@lobechat/local-file-shell';
import type {
DeviceControlDeps,
InitWorkspaceParams,
ListProjectSkillsParams,
LocalFilePreviewUrlParams,
ProjectFileIndexParams,
} from './types';
import { initWorkspace, listProjectSkills, statPath } from './workspace';
/**
* Every method name the device-control RPC dispatcher understands. Mirrors the
* gateway's server-internal RPC surface — the gateway routes any `rpc_request`
* by `method` here, so adding a device capability means one entry below plus its
* handler, with no per-method gateway route.
*/
export const DEVICE_RPC_METHODS = [
'initWorkspace',
'listProjectSkills',
'statPath',
'getProjectFileIndex',
'getLocalFilePreview',
'getGitBranch',
'getLinkedPullRequest',
'getGitWorkingTreeStatus',
'getGitWorkingTreeFiles',
'getGitWorkingTreePatches',
'getGitBranchDiff',
'getGitAheadBehind',
'listGitBranches',
'listGitRemoteBranches',
'checkoutGitBranch',
'renameGitBranch',
'deleteGitBranch',
'pullGitBranch',
'pushGitBranch',
'revertGitFile',
] as const;
export type DeviceRpcMethod = (typeof DEVICE_RPC_METHODS)[number];
/**
* Dispatch a generic server-internal device RPC by method name. This is the
* single device-control entry point shared by the desktop main process
* (`GatewayConnectionCtr`) and the CLI daemon (`lh connect`); both hand it the
* raw `(method, params)` off the gateway WebSocket and inject their own
* platform-specific `deps`.
*
* Git and workspace-scan methods run identical shared logic on every host; only
* `getProjectFileIndex` / `getLocalFilePreview` (and the workspace-scan preview
* approval) vary per host and come from `deps`.
*/
export const executeDeviceRpc = async (
method: string,
params: unknown,
deps: DeviceControlDeps,
): Promise<unknown> => {
switch (method) {
case 'initWorkspace': {
return initWorkspace(params as InitWorkspaceParams, deps);
}
case 'listProjectSkills': {
return listProjectSkills(params as ListProjectSkillsParams, deps);
}
case 'statPath': {
return statPath(params as { path: string });
}
case 'getProjectFileIndex': {
return deps.getProjectFileIndex(params as ProjectFileIndexParams);
}
case 'getLocalFilePreview': {
return deps.getLocalFilePreview(params as LocalFilePreviewUrlParams);
}
case 'getGitBranch': {
return getGitBranch((params as { path: string }).path);
}
case 'getLinkedPullRequest': {
return getLinkedPullRequest(params as { branch: string; path: string });
}
case 'getGitWorkingTreeStatus': {
return getGitWorkingTreeStatus((params as { path: string }).path);
}
case 'getGitWorkingTreeFiles': {
return getGitWorkingTreeFiles((params as { path: string }).path);
}
case 'getGitWorkingTreePatches': {
return getGitWorkingTreePatches((params as { path: string }).path);
}
case 'getGitBranchDiff': {
return getGitBranchDiff(params as { baseRef?: string; path: string });
}
case 'getGitAheadBehind': {
return getGitAheadBehind((params as { path: string }).path);
}
case 'listGitBranches': {
return listGitBranches((params as { path: string }).path);
}
case 'listGitRemoteBranches': {
return listGitRemoteBranches((params as { path: string }).path);
}
case 'checkoutGitBranch': {
return checkoutGitBranch(params as { branch: string; create?: boolean; path: string });
}
case 'renameGitBranch': {
return renameGitBranch(params as { from: string; path: string; to: string });
}
case 'deleteGitBranch': {
return deleteGitBranch(params as { branch: string; path: string });
}
case 'pullGitBranch': {
return pullGitBranch(params as { path: string });
}
case 'pushGitBranch': {
return pushGitBranch(params as { path: string });
}
case 'revertGitFile': {
return revertGitFile(params as { filePath: string; path: string });
}
default: {
throw new Error(`Unknown device RPC method: ${method}`);
}
}
};
+126
View File
@@ -0,0 +1,126 @@
import { readFile, realpath, stat } from 'node:fs/promises';
import path from 'node:path';
import type { LocalFilePreview, LocalFilePreviewResult, LocalFilePreviewUrlParams } from './types';
const TEXT_PREVIEW_MIME_TYPES = new Set([
'application/graphql',
'application/javascript',
'application/json',
'application/markdown',
'application/toml',
'application/xml',
'application/yaml',
'text/markdown',
'text/mdx',
'text/x-markdown',
]);
/** Minimal extension → MIME map for preview content-type inference. */
const EXT_MIME: Record<string, string> = {
avif: 'image/avif',
bmp: 'image/bmp',
css: 'text/css',
csv: 'text/csv',
gif: 'image/gif',
htm: 'text/html',
html: 'text/html',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
js: 'application/javascript',
json: 'application/json',
jsx: 'text/javascript',
log: 'text/plain',
md: 'text/markdown',
mdx: 'text/mdx',
mjs: 'application/javascript',
mov: 'video/quicktime',
mp4: 'video/mp4',
pdf: 'application/pdf',
png: 'image/png',
svg: 'image/svg+xml',
toml: 'application/toml',
ts: 'text/typescript',
tsx: 'text/typescript',
txt: 'text/plain',
webm: 'video/webm',
webp: 'image/webp',
xml: 'application/xml',
yaml: 'application/yaml',
yml: 'application/yaml',
};
const inferContentType = (filePath: string): string => {
const ext = path.extname(filePath).toLowerCase().slice(1);
return EXT_MIME[ext] || 'application/octet-stream';
};
const isTextPreviewMimeType = (mimeType: string): boolean =>
mimeType.startsWith('text/') || TEXT_PREVIEW_MIME_TYPES.has(mimeType);
const serializePreviewFile = (buffer: Buffer, contentType: string): LocalFilePreview => {
if (contentType.startsWith('image/')) {
return { base64: buffer.toString('base64'), contentType, type: 'image' };
}
if (isTextPreviewMimeType(contentType)) {
return { content: buffer.toString('utf8'), contentType, type: 'text' };
}
if (contentType === 'application/pdf') {
return { contentType, type: 'pdf' };
}
if (contentType.startsWith('video/')) {
return { contentType, type: 'video' };
}
return { contentType, type: 'binary' };
};
/** Resolve the real path, tolerating non-existent targets. */
const safeRealpath = async (target: string): Promise<string> => {
try {
return await realpath(target);
} catch {
return path.resolve(target);
}
};
/**
* Portable file preview for the CLI (and any non-desktop device): read the file
* from disk and serialize it. The file must resolve inside `workingDirectory` —
* the same containment guarantee the desktop's preview-protocol manager
* enforces — so a remote caller can't read arbitrary paths on the device.
*
* `accept: 'image'` restricts the preview to image content types.
*/
export const defaultGetLocalFilePreview = async (
params: LocalFilePreviewUrlParams,
): Promise<LocalFilePreviewResult> => {
const { accept, path: filePath, workingDirectory } = params;
try {
if (!workingDirectory) {
return { error: 'Missing working directory', success: false };
}
const realRoot = await safeRealpath(workingDirectory);
const realFile = await safeRealpath(filePath);
const withinRoot = realFile === realRoot || realFile.startsWith(`${realRoot}${path.sep}`);
if (!withinRoot) {
return { error: 'File is outside the approved workspace', success: false };
}
const stats = await stat(realFile);
if (!stats.isFile()) {
return { error: 'Path is not a file', success: false };
}
const contentType = inferContentType(realFile);
if (accept === 'image' && !contentType.startsWith('image/')) {
return { error: 'File is not an image', success: false };
}
const buffer = await readFile(realFile);
return { preview: serializePreviewFile(buffer, contentType), success: true };
} catch (error) {
return { error: (error as Error).message, success: false };
}
};
+5
View File
@@ -0,0 +1,5 @@
export { DEVICE_RPC_METHODS, type DeviceRpcMethod, executeDeviceRpc } from './dispatch';
export { defaultGetLocalFilePreview } from './filePreview';
export { defaultGetProjectFileIndex } from './projectFileIndex';
export * from './types';
export { initWorkspace, listProjectSkills, statPath } from './workspace';
@@ -0,0 +1,136 @@
import { execFile } from 'node:child_process';
import path from 'node:path';
import { promisify } from 'node:util';
import fg from 'fast-glob';
import type {
ProjectFileIndexEntry,
ProjectFileIndexParams,
ProjectFileIndexResult,
} from './types';
const execFileAsync = promisify(execFile);
const toPosixRelativePath = (filePath: string) => filePath.split(path.sep).join('/');
const createProjectFileEntry = (
root: string,
absolutePath: string,
isDirectory: boolean,
): ProjectFileIndexEntry => {
const relativePath = toPosixRelativePath(path.relative(root, absolutePath));
return {
isDirectory,
name: path.basename(absolutePath),
path: absolutePath,
relativePath: isDirectory ? `${relativePath}/` : relativePath,
};
};
const collectProjectDirectories = (files: string[], root: string): ProjectFileIndexEntry[] => {
const directories = new Set<string>();
for (const filePath of files) {
let current = path.dirname(filePath);
while (current && current !== root && current.startsWith(`${root}${path.sep}`)) {
if (directories.has(current)) break;
directories.add(current);
current = path.dirname(current);
}
}
return [...directories].map((directory) => createProjectFileEntry(root, directory, true));
};
/**
* Build the entry list (synthesized directories first, then files) from a flat
* list of absolute file paths — the shared shape the Files tree builder expects,
* so nested files attach to explicit parent directory entries instead of
* flattening to the root.
*/
const buildEntries = (files: string[], root: string): ProjectFileIndexEntry[] => {
const seen = new Set<string>();
const fileEntries = files
.filter((filePath) => {
if (seen.has(filePath)) return false;
seen.add(filePath);
return true;
})
.map((filePath) => createProjectFileEntry(root, filePath, false));
return [...collectProjectDirectories(files, root), ...fileEntries];
};
/**
* Portable project file index for the CLI (and any non-desktop device). Prefers
* `git ls-files` (tracked + untracked, submodule-aware) to enumerate the repo,
* falling back to a `fast-glob` walk when the scope is not a git repo. Mirrors
* the desktop `LocalFileCtr.getProjectFileIndex` output shape.
*/
export const defaultGetProjectFileIndex = async (
params: ProjectFileIndexParams = {},
): Promise<ProjectFileIndexResult> => {
const requestedScope = params.scope || process.cwd();
try {
const rootResult = await execFileAsync(
'git',
['-C', requestedScope, 'rev-parse', '--show-toplevel'],
{ timeout: 5000 },
).catch((error) => error);
const exitCode = rootResult?.code ?? rootResult?.exitCode;
const root =
rootResult?.stdout && !exitCode ? rootResult.stdout.trim() || requestedScope : requestedScope;
if (rootResult?.stdout && !exitCode) {
const [trackedResult, untrackedResult] = await Promise.all([
execFileAsync(
'git',
['-C', root, '-c', 'core.quotepath=false', 'ls-files', '--recurse-submodules'],
{ maxBuffer: 64 * 1024 * 1024, timeout: 10_000 },
),
execFileAsync(
'git',
['-C', root, '-c', 'core.quotepath=false', 'ls-files', '--others', '--exclude-standard'],
{ maxBuffer: 64 * 1024 * 1024, timeout: 10_000 },
).catch(() => ({ stdout: '' })),
]);
const files = [...trackedResult.stdout.split('\n'), ...untrackedResult.stdout.split('\n')]
.map((item) => item.trim())
.filter(Boolean)
.map((relativePath) => path.resolve(root, relativePath));
const entries = buildEntries(files, root);
return {
entries,
indexedAt: new Date().toISOString(),
root,
source: 'git',
totalCount: entries.length,
};
}
} catch {
// fall through to glob
}
// Non-git scope: walk with fast-glob. `dot: true` keeps dot-directories (e.g.
// `.agents`) that the git path would surface via `ls-files`, and `onlyFiles`
// leaves directory entries to `buildEntries` so nesting matches the git path.
const relFiles = await fg('**/*', {
cwd: requestedScope,
dot: true,
ignore: ['**/node_modules/**', '**/.git/**'],
onlyFiles: true,
});
const files = relFiles.map((relativePath) => path.resolve(requestedScope, relativePath));
const entries = buildEntries(files, requestedScope);
return {
entries,
indexedAt: new Date().toISOString(),
root: requestedScope,
source: 'glob',
totalCount: entries.length,
};
};
+148
View File
@@ -0,0 +1,148 @@
/**
* Types for the device-control RPC surface. These mirror the shapes in
* `@lobechat/electron-client-ipc` (desktop) and `@lobechat/types` (server), but
* are re-declared here so this package stays a leaf with no UI / server
* dependency. They are structurally compatible with their counterparts, so the
* desktop wiring can pass its own IPC-typed implementations directly.
*/
// ─── Workspace scan ───
export interface ProjectSkillItem {
description?: string;
/** Total number of regular files under `skillDir` (recursive, including `SKILL.md`). */
fileCount: number;
/** Relative paths (within `skillDir`) of all regular files, sorted, capped. */
files: string[];
name: string;
/** Absolute path to the SKILL.md file. */
path: string;
/** Directory containing the SKILL.md. */
skillDir: string;
/** Source directory the skill was discovered in. */
source: '.agents/skills' | '.claude/skills';
}
export interface InitWorkspaceParams {
/** Working directory used to resolve the project root. */
scope: string;
}
export interface WorkspaceInstructionsItem {
content: string;
source: 'AGENTS.md' | 'CLAUDE.md';
}
export interface InitWorkspaceResult {
instructions: WorkspaceInstructionsItem[];
root: string;
skills: ProjectSkillItem[];
}
export interface ListProjectSkillsParams {
/** Working directory used to resolve the project root. */
scope: string;
}
export interface ListProjectSkillsResult {
root: string;
skills: ProjectSkillItem[];
/** Source directory actually scanned (after fallback resolution). */
source: ProjectSkillItem['source'] | null;
}
export interface StatPathResult {
exists: boolean;
isDirectory: boolean;
repoType?: 'git' | 'github';
}
// ─── File preview ───
export type LocalFilePreviewAccept = 'image';
export interface LocalFilePreviewUrlParams {
accept?: LocalFilePreviewAccept;
path: string;
workingDirectory: string;
}
export interface LocalFilePreviewText {
content: string;
contentType: string;
type: 'text';
}
export interface LocalFilePreviewImage {
base64: string;
contentType: string;
type: 'image';
}
export interface LocalFilePreviewUnsupported {
contentType: string;
type: 'binary' | 'pdf' | 'video';
}
export type LocalFilePreview =
| LocalFilePreviewImage
| LocalFilePreviewText
| LocalFilePreviewUnsupported;
export interface LocalFilePreviewResult {
error?: string;
preview?: LocalFilePreview;
success: boolean;
}
// ─── Project file index ───
export interface ProjectFileIndexEntry {
isDirectory: boolean;
name: string;
path: string;
relativePath: string;
}
export interface ProjectFileIndexParams {
/** Working directory used to resolve the project root. */
scope?: string;
}
export interface ProjectFileIndexResult {
entries: ProjectFileIndexEntry[];
indexedAt: string;
root: string;
source: 'git' | 'glob';
totalCount: number;
}
/**
* The subset of platform hooks the workspace-scan helpers need. Kept narrow so
* the desktop's local-IPC `WorkspaceCtr` can reuse `initWorkspace` /
* `listProjectSkills` without supplying the file preview / index handlers.
*/
export interface WorkspaceScanDeps {
/**
* Approve a resolved project root for the host's file-preview protocol. Called
* after workspace scans so a later click-through resolves. No-op on the CLI.
*/
approveProjectRoot?: (root: string) => Promise<void>;
}
/**
* Platform-specific handlers the device-control dispatcher delegates to. Git and
* workspace-scan methods are implemented inside this package (over
* `@lobechat/local-file-shell`); the handlers below differ per host:
*
* - Desktop injects implementations backed by its `localFileProtocolManager`
* (preview-protocol approval, secure file reads).
* - The CLI uses the portable defaults exported from this package
* (`defaultGetLocalFilePreview`, `defaultGetProjectFileIndex`).
*/
export interface DeviceControlDeps extends WorkspaceScanDeps {
/** Read a local file preview (host-gated on desktop; disk read on CLI). */
getLocalFilePreview: (params: LocalFilePreviewUrlParams) => Promise<LocalFilePreviewResult>;
/** Build the project file index. */
getProjectFileIndex: (params: ProjectFileIndexParams) => Promise<ProjectFileIndexResult>;
}
+219
View File
@@ -0,0 +1,219 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { detectRepoType } from '@lobechat/local-file-shell';
import type {
InitWorkspaceParams,
InitWorkspaceResult,
ListProjectSkillsParams,
ListProjectSkillsResult,
ProjectSkillItem,
StatPathResult,
WorkspaceInstructionsItem,
WorkspaceScanDeps,
} from './types';
const SKILL_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
// Cap recursion to guard against pathological directory trees.
const MAX_SKILL_FILE_COUNT = 1000;
const SKILL_SOURCES = ['.agents/skills', '.claude/skills'] as const;
const toPosixRelativePath = (filePath: string) => filePath.split(path.sep).join('/');
const listSkillFilesRecursive = async (dir: string): Promise<string[]> => {
const results: string[] = [];
const stack: string[] = [dir];
while (stack.length > 0 && results.length < MAX_SKILL_FILE_COUNT) {
const current = stack.pop()!;
let entries;
try {
entries = await readdir(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (entry.isFile()) {
results.push(toPosixRelativePath(path.relative(dir, full)));
if (results.length >= MAX_SKILL_FILE_COUNT) break;
}
}
}
return results.sort();
};
/**
* Parse a minimal YAML frontmatter block for SKILL.md files. Only handles
* `key: value` lines; multi-line block scalars fall back to the first line.
*/
const parseSkillFrontmatter = (raw: string): Record<string, string> => {
const match = raw.match(SKILL_FRONTMATTER_RE);
if (!match) return {};
const fields: Record<string, string> = {};
for (const line of match[1].split(/\r?\n/)) {
const colonIdx = line.indexOf(':');
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
if (!key || key.startsWith('#')) continue;
let value = line.slice(colonIdx + 1).trim();
if (value.startsWith('|') || value.startsWith('>')) continue;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
fields[key] = value;
}
return fields;
};
/**
* Scan one skill source directory (e.g. `.agents/skills`) under `root` and
* return parsed frontmatter for each `SKILL.md`. Returns `[]` when the source
* directory is absent or unreadable. Unsorted — callers sort/merge.
*/
const scanSkillsInSource = async (
root: string,
source: ProjectSkillItem['source'],
): Promise<ProjectSkillItem[]> => {
const dir = path.join(root, source);
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return [];
}
const skills = await Promise.all(
entries
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
.map(async (entry): Promise<ProjectSkillItem | null> => {
const skillDir = path.join(dir, entry.name);
const skillFile = path.join(skillDir, 'SKILL.md');
try {
const raw = await readFile(skillFile, 'utf8');
const fields = parseSkillFrontmatter(raw);
const files = await listSkillFilesRecursive(skillDir);
return {
description: fields.description || undefined,
fileCount: files.length,
files,
name: fields.name || entry.name,
path: skillFile,
skillDir,
source,
};
} catch {
return null;
}
}),
);
return skills.filter((skill): skill is ProjectSkillItem => skill !== null);
};
/**
* Read the project-root agent instructions files (`AGENTS.md`, then `CLAUDE.md`).
* Collects every present candidate rather than first-match, since both can
* coexist. Each body is capped so a pathologically large file can't bloat the
* cached payload or the injected system role.
*/
const readWorkspaceInstructions = async (root: string): Promise<WorkspaceInstructionsItem[]> => {
const MAX_INSTRUCTIONS_BYTES = 64 * 1024;
const candidates = ['AGENTS.md', 'CLAUDE.md'] as const;
const instructions: WorkspaceInstructionsItem[] = [];
for (const source of candidates) {
try {
const raw = await readFile(path.join(root, source), 'utf8');
const content =
raw.length > MAX_INSTRUCTIONS_BYTES ? raw.slice(0, MAX_INSTRUCTIONS_BYTES) : raw;
instructions.push({ content, source });
} catch {
// File absent or unreadable; skip it.
}
}
return instructions;
};
/**
* Scan agent skill directories under the project root. Returns the first source
* directory that yields any skills (`.agents/skills` wins). Approves the root
* for the host preview protocol when any skills are found.
*/
export const listProjectSkills = async (
params: ListProjectSkillsParams,
deps: WorkspaceScanDeps = {},
): Promise<ListProjectSkillsResult> => {
const root = params.scope;
for (const source of SKILL_SOURCES) {
const skills = (await scanSkillsInSource(root, source)).sort((a, b) =>
a.name.localeCompare(b.name),
);
if (skills.length > 0) {
await deps.approveProjectRoot?.(root);
return { root, skills, source };
}
}
return { root, skills: [], source: null };
};
/**
* One-call "workspace init" scan: merge project skills from BOTH
* `.agents/skills` and `.claude/skills` (deduped by name, `.agents/skills`
* winning) and read the project-root agent instructions. Approves the root for
* the host preview protocol regardless of what was found, since the run is now
* bound to this root.
*/
export const initWorkspace = async (
params: InitWorkspaceParams,
deps: WorkspaceScanDeps = {},
): Promise<InitWorkspaceResult> => {
const root = params.scope;
const seen = new Set<string>();
const skills: ProjectSkillItem[] = [];
for (const source of SKILL_SOURCES) {
for (const skill of await scanSkillsInSource(root, source)) {
if (seen.has(skill.name)) continue;
seen.add(skill.name);
skills.push(skill);
}
}
skills.sort((a, b) => a.name.localeCompare(b.name));
const instructions = await readWorkspaceInstructions(root);
await deps.approveProjectRoot?.(root);
return { instructions, root, skills };
};
/**
* Check whether a path exists on this device and is a directory, plus its git
* repo type. Used to validate a manually-entered working directory from a web /
* remote client before binding it, and to render the right dir icon.
*/
export const statPath = async (params: { path: string }): Promise<StatPathResult> => {
try {
const stats = await stat(params.path);
if (!stats.isDirectory()) return { exists: true, isDirectory: false };
const repoType = await detectRepoType(params.path);
return { exists: true, isDirectory: true, repoType };
} catch {
return { exists: false, isDirectory: false };
}
};
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});
@@ -0,0 +1,207 @@
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
checkoutGitBranch,
deleteGitBranch,
listGitBranches,
listGitRemoteBranches,
pullGitBranch,
pushGitBranch,
renameGitBranch,
revertGitFile,
} from '../branches';
import { getGitAheadBehind, getGitBranch } from '../info';
import { getGitBranchDiff, getGitWorkingTreeFiles, getGitWorkingTreePatches } from '../workingTree';
const git = (cwd: string, ...args: string[]): string =>
execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
/** Create an isolated temp repo on `main` with a single committed file. */
const initRepo = async (): Promise<string> => {
const dir = await mkdtemp(path.join(tmpdir(), 'lfs-git-'));
execFileSync('git', ['-c', 'init.defaultBranch=main', 'init'], { cwd: dir });
git(dir, 'config', 'user.email', 'test@example.com');
git(dir, 'config', 'user.name', 'Test');
git(dir, 'config', 'commit.gpgsign', 'false');
await writeFile(path.join(dir, 'a.txt'), 'hello\n');
git(dir, 'add', 'a.txt');
git(dir, 'commit', '-m', 'init');
return dir;
};
let repo: string;
const cleanup: string[] = [];
beforeEach(async () => {
repo = await initRepo();
cleanup.push(repo);
});
afterEach(async () => {
await Promise.all(cleanup.splice(0).map((dir) => rm(dir, { force: true, recursive: true })));
});
describe('branch read operations', () => {
it('getGitBranch returns the current branch', async () => {
expect(await getGitBranch(repo)).toEqual({ branch: 'main' });
});
it('listGitBranches lists branches with the current one flagged', async () => {
git(repo, 'branch', 'feature');
const branches = await listGitBranches(repo);
expect(branches).toContainEqual({ current: true, name: 'main', upstream: undefined });
expect(branches.map((b) => b.name).sort()).toEqual(['feature', 'main']);
});
it('listGitRemoteBranches returns [] when there is no origin', async () => {
expect(await listGitRemoteBranches(repo)).toEqual([]);
});
});
describe('checkoutGitBranch', () => {
it('creates and switches to a new branch', async () => {
const result = await checkoutGitBranch({ branch: 'feature', create: true, path: repo });
expect(result).toEqual({ success: true });
expect(await getGitBranch(repo)).toEqual({ branch: 'feature' });
});
it('switches to an existing branch', async () => {
git(repo, 'branch', 'feature');
expect(await checkoutGitBranch({ branch: 'feature', path: repo })).toEqual({ success: true });
expect(await getGitBranch(repo)).toEqual({ branch: 'feature' });
});
it('rejects an invalid branch name without invoking git', async () => {
const result = await checkoutGitBranch({ branch: 'bad name', create: true, path: repo });
expect(result).toEqual({ error: 'Invalid branch name: bad name', success: false });
});
it('surfaces git stderr for a missing branch', async () => {
const result = await checkoutGitBranch({ branch: 'nope', path: repo });
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
});
});
describe('renameGitBranch', () => {
it('renames the current branch', async () => {
expect(await renameGitBranch({ from: 'main', path: repo, to: 'trunk' })).toEqual({
success: true,
});
expect(await getGitBranch(repo)).toEqual({ branch: 'trunk' });
});
it('rejects an invalid target name', async () => {
const result = await renameGitBranch({ from: 'main', path: repo, to: 'bad~name' });
expect(result).toEqual({ error: 'Invalid branch name: bad~name', success: false });
});
it('fails (non-force) when the target already exists', async () => {
git(repo, 'branch', 'taken');
const result = await renameGitBranch({ from: 'main', path: repo, to: 'taken' });
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
});
});
describe('deleteGitBranch', () => {
it('force-deletes a non-current branch', async () => {
git(repo, 'branch', 'stale');
expect(await deleteGitBranch({ branch: 'stale', path: repo })).toEqual({ success: true });
expect((await listGitBranches(repo)).map((b) => b.name)).not.toContain('stale');
});
it('refuses to delete the checked-out branch', async () => {
const result = await deleteGitBranch({ branch: 'main', path: repo });
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
});
it('rejects an invalid branch name', async () => {
expect(await deleteGitBranch({ branch: 'bad name', path: repo })).toEqual({
error: 'Invalid branch name: bad name',
success: false,
});
});
});
describe('working tree status / files / patches', () => {
beforeEach(async () => {
await writeFile(path.join(repo, 'a.txt'), 'hello\nworld\n'); // modify tracked
await writeFile(path.join(repo, 'new.txt'), 'fresh\n'); // untracked add
});
it('getGitWorkingTreeFiles buckets dirty paths', async () => {
const files = await getGitWorkingTreeFiles(repo);
expect(files.modified).toContain('a.txt');
expect(files.added).toContain('new.txt');
expect(files.deleted).toEqual([]);
});
it('getGitWorkingTreePatches returns per-file patches ordered added → modified', async () => {
const { patches } = await getGitWorkingTreePatches(repo);
const byPath = Object.fromEntries(patches.map((p) => [p.filePath, p]));
expect(byPath['new.txt'].status).toBe('added');
expect(byPath['new.txt'].patch).toContain('+fresh');
expect(byPath['a.txt'].status).toBe('modified');
expect(byPath['a.txt'].patch).toContain('+world');
// added entries sort before modified entries
expect(patches.findIndex((p) => p.filePath === 'new.txt')).toBeLessThan(
patches.findIndex((p) => p.filePath === 'a.txt'),
);
});
});
describe('revertGitFile', () => {
it('restores a modified tracked file to HEAD', async () => {
await writeFile(path.join(repo, 'a.txt'), 'tampered\n');
expect(await revertGitFile({ filePath: 'a.txt', path: repo })).toEqual({ success: true });
expect(await readFile(path.join(repo, 'a.txt'), 'utf8')).toBe('hello\n');
});
it('deletes an untracked file from disk', async () => {
await writeFile(path.join(repo, 'junk.txt'), 'x\n');
expect(await revertGitFile({ filePath: 'junk.txt', path: repo })).toEqual({ success: true });
expect(existsSync(path.join(repo, 'junk.txt'))).toBe(false);
});
it('rejects a path traversal payload', async () => {
const result = await revertGitFile({ filePath: '../escape.txt', path: repo });
expect(result).toEqual({ error: 'Invalid file path: ../escape.txt', success: false });
});
});
describe('getGitBranchDiff', () => {
it('returns headRef + empty patches when no remote default branch exists', async () => {
const result = await getGitBranchDiff({ path: repo });
expect(result).toEqual({ headRef: 'main', patches: [] });
});
});
describe('remote operations (push / pull / ahead-behind)', () => {
it('pushes to a bare origin, then reports up-to-date on pull', async () => {
const bare = await mkdtemp(path.join(tmpdir(), 'lfs-bare-'));
cleanup.push(bare);
execFileSync('git', ['init', '--bare', bare], { cwd: bare });
git(repo, 'remote', 'add', 'origin', bare);
const pushed = await pushGitBranch({ path: repo });
expect(pushed.success).toBe(true);
// The branch now exists on the bare remote.
expect(git(bare, 'branch', '--list', 'main')).toContain('main');
const ahead = await getGitAheadBehind(repo);
expect(ahead).toMatchObject({ ahead: 0, behind: 0, hasUpstream: true });
const pulled = await pullGitBranch({ path: repo });
expect(pulled).toMatchObject({ noop: true, success: true });
});
});
@@ -1,15 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import { dequoteGitPath, quoteGitPath } from '../GitCtr';
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
import { dequoteGitPath, quoteGitPath } from '../workingTree';
describe('quoteGitPath', () => {
it('leaves plain ASCII paths unquoted (including spaces)', () => {
@@ -33,8 +24,6 @@ describe('quoteGitPath', () => {
});
it('puts the prefix inside the quotes', () => {
// Real git output for `git diff` of a tab-containing file:
// diff --git "a/with\there" "b/with\there"
expect(quoteGitPath('a/', 'with\there')).toBe('"a/with\\there"');
expect(quoteGitPath('b/', 'with\there')).toBe('"b/with\\there"');
});
@@ -51,7 +40,6 @@ describe('quoteGitPath', () => {
];
for (const original of cases) {
const quoted = quoteGitPath('b/', original);
// Strip the surrounding quotes + b/ prefix, then de-escape.
expect(quoted.startsWith('"b/')).toBe(true);
expect(quoted.endsWith('"')).toBe(true);
const stripped = quoted.slice(1, -1).slice('b/'.length);
@@ -0,0 +1,294 @@
import { execFile } from 'node:child_process';
import { rm } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import { createLogger } from '../logger';
import type {
GitBranchListItem,
GitCheckoutResult,
GitDeleteBranchResult,
GitFileRevertResult,
GitPullResult,
GitPushResult,
GitRemoteBranchListItem,
GitRenameBranchResult,
} from './types';
const log = createLogger('local-file-shell:git');
const execFileAsync = promisify(execFile);
/** Reject obviously invalid branch refs early to avoid a confusing git error. */
const isInvalidBranchRef = (name: string): boolean =>
/[\s~^:?*[\\]/.test(name) || name.startsWith('-') || name.includes('..');
/**
* List local git branches ordered by most recent commit. `current` is true for
* the checked-out branch.
*/
export const listGitBranches = async (dirPath: string): Promise<GitBranchListItem[]> => {
try {
const { stdout } = await execFileAsync(
'git',
[
'for-each-ref',
'--sort=-committerdate',
'--format=%(HEAD)%09%(refname:short)%09%(upstream:short)',
'refs/heads',
],
{ cwd: dirPath, timeout: 5000 },
);
return stdout
.replaceAll('\r', '')
.split('\n')
.filter((line) => line.length > 0)
.map((line) => {
// Line format: "<HEAD-marker>\t<branch>\t<upstream>" where HEAD-marker is '*' or ' '
const [head, name, upstream] = line.split('\t');
return {
current: head === '*',
name: name ?? '',
upstream: upstream || undefined,
};
})
.filter((b) => b.name);
} catch (error: any) {
log.warn('[listGitBranches] git command failed', {
code: error?.code,
cwd: dirPath,
message: error?.message,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
return [];
}
};
/**
* List remote branches under `refs/remotes/origin/*`, ordered by most recent
* commit. The `HEAD` symref is filtered out and the resolved default branch is
* flagged via `isDefault`.
*/
export const listGitRemoteBranches = async (
dirPath: string,
): Promise<GitRemoteBranchListItem[]> => {
let defaultRef: string | undefined;
try {
const { stdout } = await execFileAsync(
'git',
['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'],
{ cwd: dirPath, timeout: 5000 },
);
defaultRef = stdout.trim() || undefined;
} catch {
defaultRef = undefined;
}
try {
const { stdout } = await execFileAsync(
'git',
['for-each-ref', '--sort=-committerdate', '--format=%(refname:short)', 'refs/remotes/origin'],
{ cwd: dirPath, timeout: 5000 },
);
return stdout
.replaceAll('\r', '')
.split('\n')
.map((line) => line.trim())
.filter((name) => name.length > 0 && name !== 'origin/HEAD' && !name.endsWith('/HEAD'))
.map((name) => ({ isDefault: name === defaultRef, name }));
} catch (error: any) {
log.warn('[listGitRemoteBranches] git command failed', {
code: error?.code,
cwd: dirPath,
message: error?.message,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
return [];
}
};
/**
* Check out (or create + check out) a branch. Relies on git itself to reject
* unsafe checkouts (dirty tree, non-fast-forward, etc.) and surfaces git's
* stderr so the UI can display a meaningful error.
*/
export const checkoutGitBranch = async (payload: {
branch: string;
create?: boolean;
path: string;
}): Promise<GitCheckoutResult> => {
const { path: dirPath, branch, create } = payload;
if (!branch?.trim()) {
return { error: 'Branch name is required', success: false };
}
if (isInvalidBranchRef(branch)) {
return { error: `Invalid branch name: ${branch}`, success: false };
}
const args = create ? ['checkout', '-b', branch] : ['checkout', branch];
try {
await execFileAsync('git', args, { cwd: dirPath, timeout: 10_000 });
return { success: true };
} catch (error: any) {
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
log.debug('[checkoutGitBranch] failed', { args, stderr });
return { error: stderr || 'git checkout failed', success: false };
}
};
/**
* Rename a local branch (`git branch -m <from> <to>`). Works on the current
* branch too. Uses the non-force `-m`, so git rejects (and we surface) a rename
* onto an existing branch name.
*/
export const renameGitBranch = async (payload: {
from: string;
path: string;
to: string;
}): Promise<GitRenameBranchResult> => {
const { path: dirPath, from, to } = payload;
if (!from?.trim() || !to?.trim()) {
return { error: 'Branch name is required', success: false };
}
if (isInvalidBranchRef(to)) {
return { error: `Invalid branch name: ${to}`, success: false };
}
try {
await execFileAsync('git', ['branch', '-m', from, to], { cwd: dirPath, timeout: 10_000 });
return { success: true };
} catch (error: any) {
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
log.debug('[renameGitBranch] failed', { from, stderr, to });
return { error: stderr || 'git branch rename failed', success: false };
}
};
/**
* Delete a local branch (`git branch -D <branch>`). Force delete (`-D`) is
* intentional: the UI gates this behind an explicit confirm. git still refuses
* to delete the currently checked-out branch, and that error is surfaced.
*/
export const deleteGitBranch = async (payload: {
branch: string;
path: string;
}): Promise<GitDeleteBranchResult> => {
const { path: dirPath, branch } = payload;
if (!branch?.trim()) {
return { error: 'Branch name is required', success: false };
}
if (isInvalidBranchRef(branch)) {
return { error: `Invalid branch name: ${branch}`, success: false };
}
try {
await execFileAsync('git', ['branch', '-D', branch], { cwd: dirPath, timeout: 10_000 });
return { success: true };
} catch (error: any) {
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
log.debug('[deleteGitBranch] failed', { branch, stderr });
return { error: stderr || 'git branch delete failed', success: false };
}
};
/**
* Pull the current branch's upstream via fast-forward only. `--ff-only` avoids
* accidental merge commits when the local branch has diverged.
*/
export const pullGitBranch = async (payload: { path: string }): Promise<GitPullResult> => {
const { path: dirPath } = payload;
try {
const { stdout } = await execFileAsync('git', ['pull', '--ff-only'], {
cwd: dirPath,
timeout: 60_000,
});
const noop = /Already up to date/i.test(stdout);
return { noop, success: true };
} catch (error: any) {
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
log.debug('[pullGitBranch] failed', { stderr });
return { error: stderr || 'git pull failed', success: false };
}
};
/**
* Push the current branch to its same-named remote on `origin`. Uses
* `git push -u origin HEAD` so the action works even when the local branch name
* differs from the configured upstream.
*/
export const pushGitBranch = async (payload: { path: string }): Promise<GitPushResult> => {
const { path: dirPath } = payload;
try {
const { stderr } = await execFileAsync('git', ['push', '-u', 'origin', 'HEAD'], {
cwd: dirPath,
timeout: 60_000,
});
// git push writes progress/status to stderr even on success
const noop = /Everything up-to-date/i.test(stderr);
return { noop, success: true };
} catch (error: any) {
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
log.debug('[pushGitBranch] failed', { stderr });
return { error: stderr || 'git push failed', success: false };
}
};
/**
* Revert a single working-tree change. Mirrors "Discard changes" in GitHub
* Desktop / VSCode SCM: restore the file to its HEAD state, dropping any
* unstaged / staged edits — and physically delete the file when it doesn't
* exist at HEAD (untracked or staged-add).
*
* Branch logic by HEAD presence:
* - present at HEAD → `git checkout HEAD -- <file>`
* - absent at HEAD → `git rm --cached` (unstage if staged-A) + `fs.rm`
*
* filePath is the repo-relative path from `git status`. Absolute paths and `..`
* traversal are rejected so a tampered payload can't poke outside the repo.
*/
export const revertGitFile = async (payload: {
filePath: string;
path: string;
}): Promise<GitFileRevertResult> => {
const { path: dirPath, filePath } = payload;
if (!filePath?.trim()) return { error: 'File path is required', success: false };
if (path.isAbsolute(filePath) || filePath.split(/[/\\]/).includes('..')) {
return { error: `Invalid file path: ${filePath}`, success: false };
}
// Probe HEAD via cat-file -e — exit 0 means the blob exists at HEAD.
let existsAtHead: boolean;
try {
await execFileAsync('git', ['cat-file', '-e', `HEAD:${filePath}`], {
cwd: dirPath,
timeout: 5000,
});
existsAtHead = true;
} catch {
existsAtHead = false;
}
try {
if (existsAtHead) {
await execFileAsync('git', ['checkout', 'HEAD', '--', filePath], {
cwd: dirPath,
timeout: 15_000,
});
} else {
// Unstage if the file is in the index (staged-add). `git rm --cached`
// exits non-zero on untracked paths, which is fine — swallow it.
try {
await execFileAsync('git', ['rm', '--cached', '--quiet', '--', filePath], {
cwd: dirPath,
timeout: 5000,
});
} catch {
// not staged — fall through to the disk-delete
}
await rm(path.resolve(dirPath, filePath), { force: true, recursive: false });
}
return { success: true };
} catch (error: any) {
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
log.debug('[revertGitFile] failed', { filePath, stderr });
return { error: stderr || 'git revert failed', success: false };
}
};
@@ -1,3 +1,5 @@
export * from './branches';
export * from './info';
export * from './repoType';
export * from './types';
export * from './workingTree';
+139
View File
@@ -53,3 +53,142 @@ export interface DeviceGitInfo {
};
workingStatus: GitWorkingTreeStatus;
}
export interface GitBranchListItem {
current: boolean;
name: string;
upstream?: string;
}
export interface GitRemoteBranchListItem {
/** Whether this ref is the resolved default branch (origin/HEAD target). */
isDefault: boolean;
/** Short ref name, e.g. `origin/canary`. */
name: string;
}
export interface GitWorkingTreeFiles {
/** Repo-relative paths for untracked + staged-as-added files */
added: string[];
/** Repo-relative paths for files marked deleted in either index or working tree */
deleted: string[];
/** Repo-relative paths for modified / renamed / copied / type-changed / unmerged files */
modified: string[];
}
export type GitFileDiffStatus = 'added' | 'modified' | 'deleted';
export interface GitWorkingTreePatch {
/** Number of `+` lines in the patch (excluding the `+++ b/...` header). */
additions: number;
/** Number of `-` lines in the patch (excluding the `--- a/...` header). */
deletions: number;
/** Repo-relative path of the file. */
filePath: string;
/**
* True when git reported `Binary files … differ` for this entry — the UI
* should show a placeholder instead of a textual diff.
*/
isBinary: boolean;
/**
* Unified diff patch text exactly as `git diff` produced it (including the
* `diff --git` header line). Empty when isBinary or truncated.
*/
patch: string;
/** Same status bucket as GitWorkingTreeFiles. */
status: GitFileDiffStatus;
/** Patch was elided because it exceeded the per-file size cap. */
truncated: boolean;
}
/**
* Patches collected from a dirty submodule of the parent repo. The submodule
* itself is a self-contained git repo, so its patches use the same
* `GitWorkingTreePatch` shape; we only add metadata the renderer needs to tag
* the group and route per-file ops (revert, etc.) into the right working dir.
*/
export interface SubmoduleWorkingTreePatches {
/** Absolute path on disk — used as the `cwd` for revert / branch operations. */
absolutePath: string;
/** Current branch short name inside the submodule, or short SHA when detached. */
branch?: string;
/** True when the submodule's HEAD is detached (no branch ref). */
detached?: boolean;
/** Display name — the submodule's directory basename. */
name: string;
/** Per-file diff blocks inside this submodule, same ordering as the parent's `patches`. */
patches: GitWorkingTreePatch[];
/** Path relative to the parent repo root (e.g. `lobehub` or `packages/foo`). */
relativePath: string;
}
export interface GitWorkingTreePatches {
/**
* All dirty file patches in the parent repo, ordered added → modified →
* deleted. Submodule directories are filtered out of this list — their
* internal diffs live under `submodules[]` instead.
*/
patches: GitWorkingTreePatch[];
/**
* One group per dirty submodule (pointer bumped, content changed, or both).
* Undefined when the parent has no submodules with pending changes.
*/
submodules?: SubmoduleWorkingTreePatches[];
}
export interface GetGitBranchDiffPayload {
/**
* Override the comparison base. When omitted, the resolver uses
* `refs/remotes/origin/HEAD`.
*/
baseRef?: string;
path: string;
}
export interface GitBranchDiffPatches {
/**
* Resolved base ref the diff was taken against (e.g. `origin/canary`).
* Undefined when no remote default branch could be resolved.
*/
baseRef?: string;
/** Current branch short name, or short SHA when HEAD is detached. */
headRef?: string;
/** Per-file diff blocks for the parent repo, ordered added → modified → deleted. */
patches: GitWorkingTreePatch[];
/** One group per submodule whose pointer differs between the parent's base and HEAD. */
submodules?: SubmoduleWorkingTreePatches[];
}
export interface GitCheckoutResult {
error?: string;
success: boolean;
}
export interface GitFileRevertResult {
error?: string;
success: boolean;
}
export interface GitRenameBranchResult {
error?: string;
success: boolean;
}
export interface GitDeleteBranchResult {
error?: string;
success: boolean;
}
export interface GitPullResult {
error?: string;
/** True when `git pull` reported the branch was already up-to-date */
noop?: boolean;
success: boolean;
}
export interface GitPushResult {
error?: string;
/** True when `git push` reported everything is already up-to-date */
noop?: boolean;
success: boolean;
}
@@ -0,0 +1,727 @@
import { execFile, spawn } from 'node:child_process';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import { createLogger } from '../logger';
import { getGitBranch } from './info';
import type {
GitBranchDiffPatches,
GitFileDiffStatus,
GitWorkingTreeFiles,
GitWorkingTreePatch,
GitWorkingTreePatches,
SubmoduleWorkingTreePatches,
} from './types';
const log = createLogger('local-file-shell:git');
const execFileAsync = promisify(execFile);
const MAX_PATCH_BYTES = 256 * 1024;
interface DirtyEntry {
filePath: string;
status: GitFileDiffStatus;
}
interface DiffBlock {
isBinary: boolean;
patch: string;
/** Destination path (or source path for deleted files). */
path: string;
}
/**
* Split the output of `git diff HEAD --` into one block per file. Each block
* starts at a `^diff --git ` line and runs to just before the next one (or EOF).
*/
const splitBulkDiff = (diffText: string): DiffBlock[] => {
if (!diffText) return [];
const blocks: DiffBlock[] = [];
const headerRe = /^diff --git /gm;
const starts: number[] = [];
let m: RegExpExecArray | null;
while ((m = headerRe.exec(diffText)) !== null) starts.push(m.index);
for (let i = 0; i < starts.length; i++) {
const start = starts[i];
const end = i + 1 < starts.length ? starts[i + 1] : diffText.length;
const block = diffText.slice(start, end);
const filePath = extractPathFromDiffBlock(block);
if (!filePath) continue;
blocks.push({
isBinary: /^Binary files .* differ$/m.test(block),
path: filePath,
patch: block,
});
}
return blocks;
};
/**
* Pull the file path out of a per-file diff block. Looks at the `+++ b/<path>`
* line first; falls back to `--- a/<path>` for deletes; final fallback is the
* `diff --git a/x b/y` header line.
*/
const extractPathFromDiffBlock = (block: string): string | null => {
let plusPath: string | null = null;
let minusPath: string | null = null;
for (const line of block.split('\n')) {
if (line.startsWith('+++ ')) {
plusPath = parseDiffPathLine(line.slice(4), 'b/');
} else if (line.startsWith('--- ')) {
minusPath = parseDiffPathLine(line.slice(4), 'a/');
}
// The file headers always come before the first hunk / binary marker.
if (line.startsWith('@@') || line.startsWith('Binary files ')) break;
}
if (plusPath) return plusPath;
if (minusPath) return minusPath;
const header = block.split('\n', 1)[0];
const match = /^diff --git a\/.+? b\/(.+)$/.exec(header);
return match ? match[1] : null;
};
/**
* Strip the `a/` or `b/` prefix off a `+++` / `---` line, drop the optional
* trailing tab+timestamp, and de-quote git's C-style escaping. Returns null for
* `/dev/null`.
*/
const parseDiffPathLine = (raw: string, prefix: 'a/' | 'b/'): string | null => {
const tabIdx = raw.indexOf('\t');
let p = tabIdx >= 0 ? raw.slice(0, tabIdx) : raw;
if (p === '/dev/null') return null;
if (p.startsWith('"') && p.endsWith('"')) {
p = dequoteGitPath(p.slice(1, -1));
}
return p.startsWith(prefix) ? p.slice(prefix.length) : p;
};
export const dequoteGitPath = (s: string): string =>
s.replaceAll(/\\(["\\trn]|[0-7]{3})/g, (_, esc: string) => {
if (esc === '"') return '"';
if (esc === '\\') return '\\';
if (esc === 't') return '\t';
if (esc === 'r') return '\r';
if (esc === 'n') return '\n';
return String.fromCodePoint(Number.parseInt(esc, 8));
});
/**
* Inverse of {@link dequoteGitPath} — returns either `<prefix><path>` (when no
* escaping is needed) or git's C-style quoted form `"<prefix><escaped>"`. The
* prefix lives inside the quotes so the output matches real `git diff`. Plain
* spaces are not quoted.
*/
// eslint-disable-next-line no-control-regex
const NEEDS_QUOTING = /["\\\x00-\x1F\x7F]/;
export const quoteGitPath = (prefix: 'a/' | 'b/', filePath: string): string => {
const combined = prefix + filePath;
if (!NEEDS_QUOTING.test(combined)) return combined;
let out = '"';
for (const ch of combined) {
if (ch === '\\') out += '\\\\';
else if (ch === '"') out += '\\"';
else if (ch === '\t') out += '\\t';
else if (ch === '\n') out += '\\n';
else if (ch === '\r') out += '\\r';
else {
const code = ch.codePointAt(0)!;
if (code < 0x20 || code === 0x7f) {
out += '\\' + code.toString(8).padStart(3, '0');
} else {
out += ch;
}
}
}
return out + '"';
};
/**
* Status from a single diff block's preamble: `new file mode` → added,
* `deleted file mode` → deleted, otherwise modified.
*/
const detectDiffBlockStatus = (block: string): GitFileDiffStatus => {
for (const line of block.split('\n')) {
if (line.startsWith('new file mode ')) return 'added';
if (line.startsWith('deleted file mode ')) return 'deleted';
if (line.startsWith('@@') || line.startsWith('Binary files ')) break;
}
return 'modified';
};
/** Walk a patch counting `+`/`-` lines while skipping `+++`/`---` headers. */
const countAddDel = (patch: string): { additions: number; deletions: number } => {
let additions = 0;
let deletions = 0;
for (const line of patch.split('\n')) {
if (line.startsWith('+++') || line.startsWith('---')) continue;
if (line.startsWith('+')) additions++;
else if (line.startsWith('-')) deletions++;
}
return { additions, deletions };
};
const emptyPatch = (entry: DirtyEntry): GitWorkingTreePatch => ({
additions: 0,
deletions: 0,
filePath: entry.filePath,
isBinary: false,
patch: '',
status: entry.status,
truncated: false,
});
const buildTrackedPatch = (
entry: DirtyEntry,
block: DiffBlock,
maxBytes: number,
): GitWorkingTreePatch => {
if (block.isBinary) {
return { ...emptyPatch(entry), isBinary: true };
}
if (block.patch.length > maxBytes) {
return { ...emptyPatch(entry), truncated: true };
}
const { additions, deletions } = countAddDel(block.patch);
return {
additions,
deletions,
filePath: entry.filePath,
isBinary: false,
patch: block.patch,
status: entry.status,
truncated: false,
};
};
/**
* Build a synthetic add-only patch for an untracked file by reading it from
* disk. Binary detection uses a NUL-byte sniff over the first 8 KB.
*/
const readUntrackedAsPatch = async (
cwd: string,
entry: DirtyEntry,
maxBytes: number,
): Promise<GitWorkingTreePatch> => {
const absolute = path.resolve(cwd, entry.filePath);
let size: number;
try {
const s = await stat(absolute);
if (!s.isFile()) return emptyPatch(entry);
size = s.size;
} catch (error: any) {
log.debug('[readUntrackedAsPatch] stat failed', {
filePath: entry.filePath,
message: error?.message,
});
return emptyPatch(entry);
}
const aPath = quoteGitPath('a/', entry.filePath);
const bPath = quoteGitPath('b/', entry.filePath);
if (size === 0) {
return {
...emptyPatch(entry),
patch:
[
`diff --git ${aPath} ${bPath}`,
'new file mode 100644',
'--- /dev/null',
`+++ ${bPath}`,
].join('\n') + '\n',
};
}
if (size > maxBytes) {
return { ...emptyPatch(entry), truncated: true };
}
let buf: Buffer;
try {
buf = await readFile(absolute);
} catch (error: any) {
log.debug('[readUntrackedAsPatch] read failed', {
filePath: entry.filePath,
message: error?.message,
});
return emptyPatch(entry);
}
const sniffEnd = Math.min(buf.length, 8192);
for (let i = 0; i < sniffEnd; i++) {
if (buf[i] === 0) return { ...emptyPatch(entry), isBinary: true };
}
const text = buf.toString('utf8');
const rawLines = text.split('\n');
const trailingEmpty = rawLines.length > 0 && rawLines.at(-1) === '';
const lineCount = trailingEmpty ? rawLines.length - 1 : rawLines.length;
if (lineCount === 0) {
return { ...emptyPatch(entry), patch: '' };
}
const body = rawLines
.slice(0, lineCount)
.map((line) => '+' + line)
.join('\n');
const noNewlineFooter = trailingEmpty ? '' : '\n\\ No newline at end of file';
const patch =
[
`diff --git ${aPath} ${bPath}`,
'new file mode 100644',
'--- /dev/null',
`+++ ${bPath}`,
`@@ -0,0 +1,${lineCount} @@`,
body,
].join('\n') +
noNewlineFooter +
'\n';
return {
additions: lineCount,
deletions: 0,
filePath: entry.filePath,
isBinary: false,
patch,
status: entry.status,
truncated: false,
};
};
/**
* Stream a git invocation's stdout via `spawn` instead of `execFile`'s
* fixed-size buffer. Resolves with the full stdout string; rejects with an Error
* carrying `stderr` and `partialStdout` fields so callers can salvage partial
* output (or fall back) on failure.
*/
const runGitCaptureStream = (cwd: string, args: string[], timeoutMs: number): Promise<string> =>
new Promise((resolve, reject) => {
const child = spawn('git', args, { cwd });
const stdoutChunks: Buffer[] = [];
let stderrBuf = '';
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
}, timeoutMs);
child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
child.stderr.on('data', (chunk: Buffer) => {
stderrBuf += chunk.toString('utf8');
});
child.on('error', (err) => {
clearTimeout(timer);
reject(Object.assign(err, { stderr: stderrBuf }));
});
child.on('close', (code) => {
clearTimeout(timer);
const stdout = Buffer.concat(stdoutChunks).toString('utf8');
if (timedOut) {
const err: any = new Error('git command timed out');
err.stderr = stderrBuf;
err.partialStdout = stdout;
return reject(err);
}
if (code !== 0) {
const err: any = new Error(`git exited with code ${code}`);
err.code = code;
err.stderr = stderrBuf;
err.partialStdout = stdout;
return reject(err);
}
resolve(stdout);
});
});
/**
* Last-resort per-file diff for tracked entries the bulk diff didn't cover.
*/
const fetchTrackedPatchPerFile = async (
cwd: string,
entry: DirtyEntry,
maxBytes: number,
): Promise<GitWorkingTreePatch> => {
let text: string;
try {
const { stdout } = await execFileAsync(
'git',
['-c', 'core.quotepath=off', 'diff', '--no-color', 'HEAD', '--', entry.filePath],
{
cwd,
encoding: 'utf8',
maxBuffer: maxBytes * 4,
timeout: 10_000,
},
);
text = stdout as string;
} catch (error: any) {
log.debug('[fetchTrackedPatchPerFile] diff failed', {
filePath: entry.filePath,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
return emptyPatch(entry);
}
if (text.length > maxBytes) return { ...emptyPatch(entry), truncated: true };
if (/^Binary files .* differ$/m.test(text)) return { ...emptyPatch(entry), isBinary: true };
if (!text) return emptyPatch(entry);
const { additions, deletions } = countAddDel(text);
return {
additions,
deletions,
filePath: entry.filePath,
isBinary: false,
patch: text,
status: entry.status,
truncated: false,
};
};
/**
* Bounded `Promise.all` — runs at most `limit` async tasks at a time.
*/
const mapWithConcurrency = async <T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> => {
const results: R[] = Array.from({ length: items.length });
let cursor = 0;
const workerCount = Math.min(limit, items.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (true) {
const idx = cursor++;
if (idx >= items.length) return;
results[idx] = await fn(items[idx]);
}
}),
);
return results;
};
/**
* List paths of initialized submodules registered in `dirPath`. Uninitialized
* entries (`-` prefix) are skipped. Only direct submodules are listed.
*/
const listSubmodulePaths = async (dirPath: string): Promise<Set<string>> => {
try {
const { stdout } = await execFileAsync('git', ['submodule', 'status'], {
cwd: dirPath,
timeout: 5000,
});
const paths = new Set<string>();
for (const line of stdout.split('\n')) {
if (line.length < 2) continue;
if (line[0] === '-') continue;
const rest = line.slice(1);
const firstSpace = rest.indexOf(' ');
if (firstSpace < 0) continue;
const sha = rest.slice(0, firstSpace);
if (!/^[\da-f]{7,40}$/.test(sha)) continue;
let p = rest.slice(firstSpace + 1);
if (p.endsWith(')')) {
const describeStart = p.lastIndexOf(' (');
if (describeStart > 0) p = p.slice(0, describeStart);
}
if (p) paths.add(p);
}
return paths;
} catch (error: any) {
log.debug('[listSubmodulePaths] failed', {
cwd: dirPath,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
return new Set();
}
};
/**
* Return dirty file paths bucketed into added / modified / deleted. Uses
* `git status --porcelain -z` so paths are NUL-terminated and never C-quoted.
*/
export const getGitWorkingTreeFiles = async (dirPath: string): Promise<GitWorkingTreeFiles> => {
const added: string[] = [];
const modified: string[] = [];
const deleted: string[] = [];
try {
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-u', '-z'], {
cwd: dirPath,
timeout: 5000,
});
const tokens = stdout.split('\0');
let i = 0;
while (i < tokens.length) {
const entry = tokens[i];
i++;
if (entry.length < 3) continue;
const x = entry[0];
const y = entry[1];
const filePath = entry.slice(3);
if (x === 'R' || x === 'C') i++;
if (!filePath) continue;
if (x === '?' && y === '?') {
added.push(filePath);
} else if (x === '!' && y === '!') {
// ignored — skip
} else if (x === 'D' || y === 'D') {
deleted.push(filePath);
} else if (x === 'A' || y === 'A') {
added.push(filePath);
} else {
modified.push(filePath);
}
}
return { added, deleted, modified };
} catch {
return { added: [], deleted: [], modified: [] };
}
};
interface PatchEntry {
filePath: string;
isUntracked: boolean;
status: GitFileDiffStatus;
}
/**
* Shared implementation for working-tree patch collection. The public entry
* passes `recurseSubmodules: true`; recursive calls into each submodule pass
* `false` to avoid traversing nested submodules.
*/
const collectWorkingTreePatches = async (
dirPath: string,
recurseSubmodules: boolean,
): Promise<GitWorkingTreePatches> => {
const submodulePaths = recurseSubmodules ? await listSubmodulePaths(dirPath) : new Set<string>();
const entries: PatchEntry[] = [];
const submoduleDirtyEntries: PatchEntry[] = [];
try {
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-u', '-z'], {
cwd: dirPath,
timeout: 5000,
});
const tokens = stdout.split('\0');
let i = 0;
while (i < tokens.length) {
const entry = tokens[i];
i++;
if (entry.length < 3) continue;
const x = entry[0];
const y = entry[1];
const filePath = entry.slice(3);
if (x === 'R' || x === 'C') i++;
if (!filePath) continue;
let parsed: PatchEntry | null = null;
if (x === '?' && y === '?') {
parsed = { filePath, isUntracked: true, status: 'added' };
} else if (x === '!' && y === '!') {
// ignored
} else if (x === 'D' || y === 'D') {
parsed = { filePath, isUntracked: false, status: 'deleted' };
} else if (x === 'A' || y === 'A') {
parsed = { filePath, isUntracked: false, status: 'added' };
} else {
parsed = { filePath, isUntracked: false, status: 'modified' };
}
if (!parsed) continue;
if (submodulePaths.has(filePath)) {
submoduleDirtyEntries.push(parsed);
} else {
entries.push(parsed);
}
}
} catch (error: any) {
log.warn('[collectWorkingTreePatches] status failed', {
cwd: dirPath,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
return { patches: [] };
}
const trackedEntries = entries.filter((e) => !e.isUntracked);
const trackedByPath = new Map(trackedEntries.map((e) => [e.filePath, e]));
const trackedPatches = new Map<string, GitWorkingTreePatch>();
if (trackedEntries.length > 0) {
let bulkDiff = '';
try {
bulkDiff = await runGitCaptureStream(
dirPath,
[
'-c',
'core.quotepath=off',
'diff',
'--no-color',
'HEAD',
'--',
...trackedEntries.map((e) => e.filePath),
],
30_000,
);
} catch (error: any) {
log.warn('[collectWorkingTreePatches] bulk diff failed; per-file fallback', {
cwd: dirPath,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
if (typeof error?.partialStdout === 'string') bulkDiff = error.partialStdout;
}
for (const block of splitBulkDiff(bulkDiff)) {
const entry = trackedByPath.get(block.path);
if (!entry) continue;
trackedPatches.set(entry.filePath, buildTrackedPatch(entry, block, MAX_PATCH_BYTES));
}
const stragglers = trackedEntries.filter((e) => !trackedPatches.has(e.filePath));
if (stragglers.length > 0) {
const recovered = await mapWithConcurrency(stragglers, 8, (entry) =>
fetchTrackedPatchPerFile(dirPath, entry, MAX_PATCH_BYTES),
);
for (const patch of recovered) trackedPatches.set(patch.filePath, patch);
}
}
const untrackedEntries = entries.filter((e) => e.isUntracked);
const untrackedPatches = await Promise.all(
untrackedEntries.map((entry) => readUntrackedAsPatch(dirPath, entry, MAX_PATCH_BYTES)),
);
const order: Record<GitFileDiffStatus, number> = { added: 0, modified: 1, deleted: 2 };
const allPatches: GitWorkingTreePatch[] = [...trackedPatches.values(), ...untrackedPatches];
allPatches.sort((a, b) => order[a.status] - order[b.status]);
let submodules: SubmoduleWorkingTreePatches[] | undefined;
if (submoduleDirtyEntries.length > 0) {
submodules = await Promise.all(
submoduleDirtyEntries.map(async (entry) => {
const absolutePath = path.resolve(dirPath, entry.filePath);
const [sub, branchInfo] = await Promise.all([
collectWorkingTreePatches(absolutePath, false),
getGitBranch(absolutePath),
]);
return {
absolutePath,
branch: branchInfo.branch,
detached: branchInfo.detached,
name: path.basename(entry.filePath),
patches: sub.patches,
relativePath: entry.filePath,
};
}),
);
}
return { patches: allPatches, submodules };
};
/**
* Pull every dirty file's unified diff in one shot. Tracked changes come from a
* single `git diff HEAD --` invocation split per-file in JS; untracked files are
* read directly with `fs.readFile`. Per-file patches are capped at 256 KB.
* Dirty submodules are surfaced as grouped `submodules[]` entries.
*/
export const getGitWorkingTreePatches = (dirPath: string): Promise<GitWorkingTreePatches> =>
collectWorkingTreePatches(dirPath, true);
/**
* Shared implementation for branch-diff collection. Each submodule's base ref is
* resolved independently.
*/
const collectBranchDiff = async (
dirPath: string,
baseRefOverride: string | undefined,
recurseSubmodules: boolean,
): Promise<GitBranchDiffPatches> => {
// Step 1 — best-effort fetch so origin/<default> reflects remote HEAD.
try {
await execFileAsync('git', ['fetch', '--no-tags', '--quiet', 'origin'], {
cwd: dirPath,
timeout: 10_000,
});
} catch {
// swallow — fall through to cached refs
}
// Step 2 — pick the comparison base.
let baseRef: string | undefined = baseRefOverride;
if (!baseRef) {
try {
const { stdout } = await execFileAsync(
'git',
['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'],
{ cwd: dirPath, timeout: 5000 },
);
baseRef = stdout.trim() || undefined;
} catch {
baseRef = undefined;
}
}
const headRef = (await getGitBranch(dirPath)).branch;
if (!baseRef) {
return { headRef, patches: [] };
}
// Step 3 — single bulk diff against the merge base (`base...HEAD`).
let bulkDiff = '';
try {
bulkDiff = await runGitCaptureStream(
dirPath,
['-c', 'core.quotepath=off', 'diff', '--no-color', `${baseRef}...HEAD`],
30_000,
);
} catch (error: any) {
log.warn('[collectBranchDiff] diff failed', {
baseRef,
cwd: dirPath,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
if (typeof error?.partialStdout === 'string') bulkDiff = error.partialStdout;
}
// Step 4 — split per-file, peeling out submodule pointer bumps.
const submodulePaths = recurseSubmodules ? await listSubmodulePaths(dirPath) : new Set<string>();
const patches: GitWorkingTreePatch[] = [];
const pointerBumpPaths = new Set<string>();
for (const block of splitBulkDiff(bulkDiff)) {
if (submodulePaths.has(block.path)) {
pointerBumpPaths.add(block.path);
continue;
}
const status = detectDiffBlockStatus(block.patch);
patches.push(buildTrackedPatch({ filePath: block.path, status }, block, MAX_PATCH_BYTES));
}
const order: Record<GitFileDiffStatus, number> = { added: 0, modified: 1, deleted: 2 };
patches.sort((a, b) => order[a.status] - order[b.status]);
// Step 5 — recurse for EVERY registered submodule (single-level only).
let submodules: SubmoduleWorkingTreePatches[] | undefined;
if (submodulePaths.size > 0) {
const candidates = await Promise.all(
Array.from(submodulePaths).map(async (relativePath) => {
const absolutePath = path.resolve(dirPath, relativePath);
const [sub, branchInfo] = await Promise.all([
collectBranchDiff(absolutePath, undefined, false),
getGitBranch(absolutePath),
]);
return {
group: {
absolutePath,
branch: branchInfo.branch,
detached: branchInfo.detached,
name: path.basename(relativePath),
patches: sub.patches,
relativePath,
},
keep: pointerBumpPaths.has(relativePath) || sub.patches.length > 0,
};
}),
);
const filtered = candidates.filter((c) => c.keep).map((c) => c.group);
if (filtered.length > 0) submodules = filtered;
}
return { baseRef, headRef, patches, submodules };
};
/**
* Diff every changed file between the current HEAD and the remote default branch
* (resolved via `refs/remotes/origin/HEAD`, or an explicit `baseRef` override).
* Uses `<base>...HEAD` three-dot semantics. Best-effort `git fetch` first.
*/
export const getGitBranchDiff = (payload: {
baseRef?: string;
path: string;
}): Promise<GitBranchDiffPatches> => collectBranchDiff(payload.path, payload.baseRef, true);