Add a packaged-client runtime boundary so the shared UI can talk to local, desktop, remote, and VS Code runtimes through the right transport instead of assuming one same-origin web server. Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and runtime URL helpers, while keeping official OpenCode traffic on the SDK path. Support runtime switching, remote host selection, desktop client credentials, and headless connection links for pairing packaged clients with remote OpenChamber servers. Harden the new auth model by moving long-lived client tokens out of browser URLs, introducing short-lived scoped URL tokens for browser-owned transports, restricting URL-token access to explicit readable/realtime routes, and making client-token management session-scoped or self-scoped as appropriate. Update browser-owned assets and preview proxy flows to work with the split runtime model, including authenticated project icons, preview token propagation, CSP-safe preview bridge injection, and preview proxy auth that survives short-lived URL-token expiry. Tighten Electron security boundaries for packaged clients by gating privileged preload state to trusted origins and requiring explicit confirmation before connect deep-links import or switch remote runtimes. Also refresh agent guidance and project skills so future runtime/API, auth, preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new architecture.
1070 lines
35 KiB
TypeScript
1070 lines
35 KiB
TypeScript
|
|
|
|
import type {
|
|
GitStatus,
|
|
GitDiffResponse,
|
|
GetGitDiffOptions,
|
|
GitFileDiffResponse,
|
|
GetGitFileDiffOptions,
|
|
GitBranch,
|
|
GitDeleteBranchPayload,
|
|
GitDeleteRemoteBranchPayload,
|
|
GitRemoveRemotePayload,
|
|
GeneratedCommitMessage,
|
|
GitWorktreeInfo,
|
|
CreateGitWorktreePayload,
|
|
GitWorktreeCreateResult,
|
|
RemoveGitWorktreePayload,
|
|
GitWorktreeValidationResult,
|
|
CreateGitCommitOptions,
|
|
GitCommitResult,
|
|
GitPushResult,
|
|
GitPullResult,
|
|
GitPullOptions,
|
|
GitStashEntry,
|
|
GitLogOptions,
|
|
GitLogResponse,
|
|
GitCommitFilesResponse,
|
|
CommitFileDiffResponse,
|
|
GitIdentityProfile,
|
|
GitIdentitySummary,
|
|
DiscoveredGitCredential,
|
|
MergeConflictDetails,
|
|
CheckoutCommitResponse,
|
|
CherryPickResponse,
|
|
RevertCommitResponse,
|
|
ResetToCommitResponse,
|
|
} from './api/types';
|
|
import { runtimeFetch } from './runtime-fetch';
|
|
import { getRuntimeUrlResolver } from './runtime-url';
|
|
|
|
const API_BASE = '/api/git';
|
|
const GIT_STATUS_CACHE_TTL_MS = 1200;
|
|
const GIT_REPO_CHECK_CACHE_TTL_MS = 5000;
|
|
const gitStatusCache = new Map<string, { value: GitStatus; expiresAt: number }>();
|
|
const gitStatusInFlight = new Map<string, Promise<GitStatus>>();
|
|
const gitRepoCache = new Map<string, { value: boolean; expiresAt: number }>();
|
|
const gitRepoInFlight = new Map<string, Promise<boolean>>();
|
|
|
|
const normalizeDirectoryKey = (directory: string): string => directory.trim();
|
|
|
|
function buildUrl(
|
|
path: string,
|
|
directory: string | null | undefined,
|
|
params?: Record<string, string | number | boolean | undefined>
|
|
): string {
|
|
const query: Record<string, string | number | boolean | undefined> = { ...params };
|
|
if (directory) query.directory = directory;
|
|
|
|
return getRuntimeUrlResolver().api(path, query);
|
|
}
|
|
|
|
export async function checkIsGitRepository(directory: string): Promise<boolean> {
|
|
const key = normalizeDirectoryKey(directory);
|
|
const now = Date.now();
|
|
const cached = gitRepoCache.get(key);
|
|
if (cached && cached.expiresAt > now) {
|
|
return cached.value;
|
|
}
|
|
|
|
const inFlight = gitRepoInFlight.get(key);
|
|
if (inFlight) {
|
|
return inFlight;
|
|
}
|
|
|
|
const task = (async () => {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/check`, directory));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to check git repository: ${response.statusText}`);
|
|
}
|
|
const data = await response.json();
|
|
const isGitRepository = Boolean(data.isGitRepository);
|
|
gitRepoCache.set(key, {
|
|
value: isGitRepository,
|
|
expiresAt: Date.now() + GIT_REPO_CHECK_CACHE_TTL_MS,
|
|
});
|
|
return isGitRepository;
|
|
})();
|
|
|
|
gitRepoInFlight.set(key, task);
|
|
try {
|
|
return await task;
|
|
} finally {
|
|
if (gitRepoInFlight.get(key) === task) {
|
|
gitRepoInFlight.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus> {
|
|
const mode = options?.mode;
|
|
const key = mode === 'light' ? `${normalizeDirectoryKey(directory)}::light` : normalizeDirectoryKey(directory);
|
|
const now = Date.now();
|
|
const cached = gitStatusCache.get(key);
|
|
if (cached && cached.expiresAt > now) {
|
|
return cached.value;
|
|
}
|
|
|
|
const inFlight = gitStatusInFlight.get(key);
|
|
if (inFlight) {
|
|
return inFlight;
|
|
}
|
|
|
|
const task = (async () => {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/status`, directory, mode ? { mode } : undefined));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get git status: ${response.statusText}`);
|
|
}
|
|
const payload = await response.json() as GitStatus;
|
|
gitStatusCache.set(key, {
|
|
value: payload,
|
|
expiresAt: Date.now() + GIT_STATUS_CACHE_TTL_MS,
|
|
});
|
|
return payload;
|
|
})();
|
|
|
|
gitStatusInFlight.set(key, task);
|
|
try {
|
|
return await task;
|
|
} finally {
|
|
if (gitStatusInFlight.get(key) === task) {
|
|
gitStatusInFlight.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse> {
|
|
const { path, staged, contextLines } = options;
|
|
if (!path) {
|
|
throw new Error('path is required to fetch git diff');
|
|
}
|
|
|
|
const response = await runtimeFetch(
|
|
buildUrl(`${API_BASE}/diff`, directory, {
|
|
path,
|
|
staged: staged ? 'true' : undefined,
|
|
context: contextLines,
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get git diff: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export async function getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse> {
|
|
const { path, staged } = options;
|
|
if (!path) {
|
|
throw new Error('path is required to fetch git file diff');
|
|
}
|
|
|
|
const response = await runtimeFetch(
|
|
buildUrl(`${API_BASE}/file-diff`, directory, {
|
|
path,
|
|
staged: staged ? 'true' : undefined,
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get git file diff: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export async function revertGitFile(
|
|
directory: string,
|
|
filePath: string,
|
|
options?: { scope?: 'all' | 'working' }
|
|
): Promise<void> {
|
|
if (!filePath) {
|
|
throw new Error('path is required to revert git changes');
|
|
}
|
|
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/revert`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path: filePath, scope: options?.scope }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const message = await response
|
|
.json()
|
|
.catch(() => ({ error: response.statusText }));
|
|
throw new Error(message.error || 'Failed to revert git changes');
|
|
}
|
|
}
|
|
|
|
export async function stageGitFile(directory: string, filePath: string): Promise<void> {
|
|
await stageGitFiles(directory, [filePath]);
|
|
}
|
|
|
|
export async function stageGitFiles(directory: string, filePaths: string[]): Promise<void> {
|
|
const paths = filePaths.map((path) => path.trim()).filter(Boolean);
|
|
|
|
if (paths.length === 0) {
|
|
throw new Error('path is required to stage git changes');
|
|
}
|
|
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/stage`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ paths }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const message = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(message.error || 'Failed to stage git changes');
|
|
}
|
|
}
|
|
|
|
export async function unstageGitFile(directory: string, filePath: string): Promise<void> {
|
|
await unstageGitFiles(directory, [filePath]);
|
|
}
|
|
|
|
export async function unstageGitFiles(directory: string, filePaths: string[]): Promise<void> {
|
|
const paths = filePaths.map((path) => path.trim()).filter(Boolean);
|
|
|
|
if (paths.length === 0) {
|
|
throw new Error('path is required to unstage git changes');
|
|
}
|
|
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/unstage`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ paths }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const message = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(message.error || 'Failed to unstage git changes');
|
|
}
|
|
}
|
|
|
|
export async function isLinkedWorktree(directory: string): Promise<boolean> {
|
|
if (!directory) {
|
|
return false;
|
|
}
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktree-type`, directory));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to detect worktree type: ${response.statusText}`);
|
|
}
|
|
const data = await response.json();
|
|
return Boolean(data.linked);
|
|
}
|
|
|
|
export async function getGitBranches(directory: string): Promise<GitBranch> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/branches`, directory));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get branches: ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> {
|
|
if (!payload?.branch) {
|
|
throw new Error('branch is required to delete a branch');
|
|
}
|
|
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/branches`, directory), {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to delete branch');
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export async function deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> {
|
|
if (!payload?.branch) {
|
|
throw new Error('branch is required to delete remote branch');
|
|
}
|
|
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/remote-branches`, directory), {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to delete remote branch');
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export async function removeRemote(directory: string, payload: GitRemoveRemotePayload): Promise<{ success: boolean }> {
|
|
const remote = payload?.remote?.trim();
|
|
if (!remote) {
|
|
throw new Error('remote is required to remove a remote');
|
|
}
|
|
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/remotes`, directory), {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ remote }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to remove remote');
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export async function generateCommitMessage(
|
|
directory: string,
|
|
files: string[],
|
|
options?: { zenModel?: string; providerId?: string; modelId?: string }
|
|
): Promise<{ message: GeneratedCommitMessage }> {
|
|
if (!Array.isArray(files) || files.length === 0) {
|
|
throw new Error('No files provided to generate commit message');
|
|
}
|
|
|
|
const body: Record<string, unknown> = { files };
|
|
if (options?.zenModel) {
|
|
body.zenModel = options.zenModel;
|
|
}
|
|
if (options?.providerId) {
|
|
body.providerId = options.providerId;
|
|
}
|
|
if (options?.modelId) {
|
|
body.modelId = options.modelId;
|
|
}
|
|
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/commit-message`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
console.error('[git-generation][browser] http error', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
error,
|
|
});
|
|
const traceSuffix = typeof error?.traceId === 'string' && error.traceId
|
|
? ` (traceId: ${error.traceId})`
|
|
: '';
|
|
throw new Error(`${error.error || 'Failed to generate commit message'}${traceSuffix}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (!data?.message || typeof data.message !== 'object') {
|
|
throw new Error('Malformed commit generation response');
|
|
}
|
|
|
|
const subject =
|
|
typeof data.message.subject === 'string' && data.message.subject.trim().length > 0
|
|
? data.message.subject.trim()
|
|
: '';
|
|
|
|
const highlights: string[] = Array.isArray(data.message.highlights)
|
|
? (data.message.highlights as unknown[])
|
|
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
|
.map((item) => (item as string).trim())
|
|
: [];
|
|
|
|
return {
|
|
message: {
|
|
subject,
|
|
highlights,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function generatePullRequestDescription(
|
|
directory: string,
|
|
payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string }
|
|
): Promise<{ title: string; body: string }> {
|
|
const { base, head, context, zenModel, providerId, modelId } = payload;
|
|
if (!base || !head) {
|
|
throw new Error('base and head are required');
|
|
}
|
|
|
|
const requestBody: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } = { base, head };
|
|
if (context?.trim()) {
|
|
requestBody.context = context.trim();
|
|
}
|
|
if (zenModel) {
|
|
requestBody.zenModel = zenModel;
|
|
}
|
|
if (providerId) {
|
|
requestBody.providerId = providerId;
|
|
}
|
|
if (modelId) {
|
|
requestBody.modelId = modelId;
|
|
}
|
|
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/pr-description`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to generate PR description');
|
|
}
|
|
|
|
const data = await response.json().catch(() => null);
|
|
const title = typeof data?.title === 'string' ? data.title : '';
|
|
const body = typeof data?.body === 'string' ? data.body : '';
|
|
if (!title && !body) {
|
|
throw new Error('Malformed PR description response');
|
|
}
|
|
return { title, body };
|
|
}
|
|
|
|
export async function listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees`, directory));
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to list worktrees');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function validateGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees/validate`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload ?? {}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to validate worktree');
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export async function getGitWorktreeBootstrapStatus(directory: string): Promise<import('./api/types').GitWorktreeBootstrapStatus> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees/bootstrap-status`, directory));
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to get worktree bootstrap status');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function previewGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees/preview`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload ?? {}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to preview worktree');
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export async function createGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload ?? {}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to create worktree');
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export async function deleteGitWorktree(directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees`, directory), {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload ?? {}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to delete worktree');
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export async function createGitCommit(
|
|
directory: string,
|
|
message: string,
|
|
options: CreateGitCommitOptions = {}
|
|
): Promise<GitCommitResult> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/commit`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
message,
|
|
addAll: options.addAll ?? false,
|
|
files: options.files,
|
|
stageFiles: options.stageFiles,
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to create commit');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function gitPush(
|
|
directory: string,
|
|
options: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> } = {}
|
|
): Promise<GitPushResult> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/push`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(options),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to push');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function gitPull(
|
|
directory: string,
|
|
options: GitPullOptions = {}
|
|
): Promise<GitPullResult> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/pull`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(options),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to pull');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function gitFetch(
|
|
directory: string,
|
|
options: { remote?: string; branch?: string } = {}
|
|
): Promise<{ success: boolean }> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/fetch`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(options),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to fetch');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function listGitStashes(directory: string): Promise<{ stashes: GitStashEntry[] }> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/stashes`, directory));
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to list stashes');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function countGitStashFiles(directory: string, refs: string[]): Promise<{ counts: Record<string, number> }> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/stashes/file-counts`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ refs }),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to count stash files');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function stashGitChanges(directory: string, options: { message?: string } = {}): Promise<{ success: boolean; created: boolean; message: string; output: string }> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/stash`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(options),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to stash changes');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
const postStashRef = async (directory: string, path: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> => {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/${path}`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(options),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || `Failed to ${path}`);
|
|
}
|
|
return response.json();
|
|
};
|
|
|
|
export const applyGitStash = (directory: string, options: { ref: string }) => postStashRef(directory, 'stash/apply', options);
|
|
export const popGitStash = (directory: string, options: { ref: string }) => postStashRef(directory, 'stash/pop', options);
|
|
export const dropGitStash = (directory: string, options: { ref: string }) => postStashRef(directory, 'stash/drop', options);
|
|
|
|
export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/checkout`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ branch }),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to checkout branch');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function createBranch(
|
|
directory: string,
|
|
name: string,
|
|
startPoint?: string
|
|
): Promise<{ success: boolean; branch: string }> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/branches`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, startPoint }),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to create branch');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function renameBranch(
|
|
directory: string,
|
|
oldName: string,
|
|
newName: string
|
|
): Promise<{ success: boolean; branch: string }> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/branches/rename`, directory), {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ oldName, newName }),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to rename branch');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function getGitLog(
|
|
directory: string,
|
|
options: GitLogOptions = {}
|
|
): Promise<GitLogResponse> {
|
|
const response = await runtimeFetch(
|
|
buildUrl(`${API_BASE}/log`, directory, {
|
|
maxCount: options.maxCount,
|
|
from: options.from,
|
|
to: options.to,
|
|
file: options.file,
|
|
all: options.all ? 'true' : undefined,
|
|
})
|
|
);
|
|
if (!response.ok) {
|
|
const errorBody = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(`Failed to get git log: ${errorBody.error || response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function getCommitFiles(
|
|
directory: string,
|
|
hash: string
|
|
): Promise<GitCommitFilesResponse> {
|
|
const response = await runtimeFetch(
|
|
buildUrl(`${API_BASE}/commit-files`, directory, { hash })
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get commit files: ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function getCommitFileDiff(
|
|
directory: string,
|
|
hash: string,
|
|
filePath: string,
|
|
isBinary: boolean
|
|
): Promise<CommitFileDiffResponse> {
|
|
const response = await runtimeFetch(
|
|
buildUrl(`${API_BASE}/commit-file-diff`, directory, {
|
|
hash,
|
|
path: filePath,
|
|
binary: isBinary ? 'true' : undefined,
|
|
})
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get commit file diff: ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function getGitIdentities(): Promise<GitIdentityProfile[]> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/identities`, undefined));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get git identities: ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function createGitIdentity(profile: GitIdentityProfile): Promise<GitIdentityProfile> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/identities`, undefined), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(profile),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to create git identity');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function updateGitIdentity(id: string, updates: GitIdentityProfile): Promise<GitIdentityProfile> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/identities/${id}`, undefined), {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(updates),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to update git identity');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function deleteGitIdentity(id: string): Promise<void> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/identities/${id}`, undefined), {
|
|
method: 'DELETE',
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to delete git identity');
|
|
}
|
|
}
|
|
|
|
export async function getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null> {
|
|
if (!directory) {
|
|
return null;
|
|
}
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/current-identity`, directory));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get current git identity: ${response.statusText}`);
|
|
}
|
|
const data = await response.json();
|
|
if (!data) {
|
|
return null;
|
|
}
|
|
return {
|
|
userName: data.userName ?? null,
|
|
userEmail: data.userEmail ?? null,
|
|
sshCommand: data.sshCommand ?? null,
|
|
};
|
|
}
|
|
|
|
export async function hasLocalIdentity(directory: string): Promise<boolean> {
|
|
if (!directory) {
|
|
return false;
|
|
}
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/has-local-identity`, directory));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to check local identity: ${response.statusText}`);
|
|
}
|
|
const data = await response.json().catch(() => null);
|
|
return data?.hasLocalIdentity === true;
|
|
}
|
|
|
|
export async function getGlobalGitIdentity(): Promise<GitIdentitySummary | null> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/global-identity`, undefined));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get global git identity: ${response.statusText}`);
|
|
}
|
|
const data = await response.json();
|
|
if (!data || (!data.userName && !data.userEmail)) {
|
|
return null;
|
|
}
|
|
return {
|
|
userName: data.userName ?? null,
|
|
userEmail: data.userEmail ?? null,
|
|
sshCommand: data.sshCommand ?? null,
|
|
};
|
|
}
|
|
|
|
export async function setGitIdentity(
|
|
directory: string,
|
|
profileId: string
|
|
): Promise<{ success: boolean; profile: GitIdentityProfile }> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/set-identity`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ profileId }),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to set git identity');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function discoverGitCredentials(): Promise<DiscoveredGitCredential[]> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/discover-credentials`, undefined));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to discover git credentials: ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function getRemoteUrl(directory: string, remote?: string): Promise<string | null> {
|
|
if (!directory) {
|
|
return null;
|
|
}
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/remote-url`, directory, { remote }));
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
const data = await response.json();
|
|
return data.url ?? null;
|
|
}
|
|
|
|
export async function getRemotes(directory: string): Promise<Array<{ name: string; fetchUrl: string; pushUrl: string }>> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/remotes`, directory));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get remotes: ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function rebase(
|
|
directory: string,
|
|
options: { onto: string }
|
|
): Promise<{ success: boolean; conflict?: boolean; conflictFiles?: string[] }> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/rebase`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(options),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to rebase');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function abortRebase(directory: string): Promise<{ success: boolean }> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/rebase/abort`, directory), {
|
|
method: 'POST',
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to abort rebase');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function merge(
|
|
directory: string,
|
|
options: { branch: string }
|
|
): Promise<{ success: boolean; conflict?: boolean; conflictFiles?: string[] }> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/merge`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(options),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to merge');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function checkoutCommit(
|
|
directory: string,
|
|
hash: string
|
|
): Promise<CheckoutCommitResponse> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/checkout-commit`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ hash }),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to checkout commit');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function cherryPick(
|
|
directory: string,
|
|
hash: string
|
|
): Promise<CherryPickResponse> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/cherry-pick`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ hash }),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to cherry-pick');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function revertCommit(
|
|
directory: string,
|
|
hash: string
|
|
): Promise<RevertCommitResponse> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/revert-commit`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ hash }),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to revert commit');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function resetToCommit(
|
|
directory: string,
|
|
hash: string,
|
|
mode: 'soft' | 'mixed' | 'hard',
|
|
force?: boolean
|
|
): Promise<ResetToCommitResponse> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/reset-to-commit`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ hash, mode, force }),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to reset');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function abortMerge(directory: string): Promise<{ success: boolean }> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/merge/abort`, directory), {
|
|
method: 'POST',
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to abort merge');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/rebase/continue`, directory), {
|
|
method: 'POST',
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to continue rebase');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/merge/continue`, directory), {
|
|
method: 'POST',
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(error.error || 'Failed to continue merge');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function stash(
|
|
directory: string,
|
|
options?: { message?: string; includeUntracked?: boolean }
|
|
): Promise<{ success: boolean }> {
|
|
await stashGitChanges(directory, { message: options?.message });
|
|
return { success: true };
|
|
}
|
|
|
|
export async function stashPop(directory: string): Promise<{ success: boolean }> {
|
|
await popGitStash(directory, { ref: 'stash@{0}' });
|
|
return { success: true };
|
|
}
|
|
|
|
export async function getConflictDetails(directory: string): Promise<MergeConflictDetails> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/conflict-details`, directory));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get conflict details: ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function validateWorktreeDirectory(
|
|
directory: string,
|
|
worktreeRoot: string
|
|
): Promise<{
|
|
valid: boolean;
|
|
insideWorktreeRoot: boolean;
|
|
resolvedWorktreeRoot: string | null;
|
|
resolvedCwd: string | null;
|
|
}> {
|
|
const response = await runtimeFetch(`${API_BASE}/validate-directory`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ directory, worktreeRoot }),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to validate worktree directory: ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export async function canonicalizeWorktreeState(
|
|
directory: string
|
|
): Promise<{
|
|
worktreeRoot: string | null;
|
|
cwd: string | null;
|
|
branch: string | null;
|
|
headState: 'branch' | 'detached' | 'unborn';
|
|
worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo';
|
|
legacy: boolean;
|
|
degraded: boolean;
|
|
attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null;
|
|
}> {
|
|
const response = await runtimeFetch(`${API_BASE}/canonicalize-worktree-state`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ directory }),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to canonicalize worktree state: ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|