mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
✨ 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:
@@ -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,5 +1,6 @@
|
||||
packages:
|
||||
- '../../packages/agent-gateway-client'
|
||||
- '../../packages/device-control'
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/device-identity'
|
||||
- '../../packages/heterogeneous-agents'
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
+2
-14
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user