When the project root is not itself a git repository, discover nested repositories (depth- and visit-capped readdir walk via a new /api/fs/git-dirs route), auto-select the first one, and show a repository picker next to the branch dropdown to switch. Selections persist per runtime and root; discovery failure is a distinct marker with a retry action, never an empty success.
1234 lines
41 KiB
TypeScript
1234 lines
41 KiB
TypeScript
import type {
|
|
GitStatus,
|
|
GitDiffResponse,
|
|
GetGitDiffOptions,
|
|
GetGitRangeDiffOptions,
|
|
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';
|
|
import { getRuntimeKey } from './runtime-switch';
|
|
|
|
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 gitStatusCacheVersions = new Map<string, number>();
|
|
const gitRepoCache = new Map<string, { value: boolean; expiresAt: number }>();
|
|
const gitRepoInFlight = new Map<string, Promise<boolean>>();
|
|
|
|
const normalizeDirectoryKey = (directory: string): string => directory.trim();
|
|
const getDirectoryCacheKey = (runtimeKey: string, directory: string): string =>
|
|
JSON.stringify([runtimeKey, normalizeDirectoryKey(directory)]);
|
|
const getStatusCacheKey = (runtimeKey: string, directory: string, mode?: 'light'): string =>
|
|
JSON.stringify([runtimeKey, normalizeDirectoryKey(directory), mode ?? 'full']);
|
|
|
|
const getStatusCacheVersion = (runtimeKey: string, directory: string): number =>
|
|
gitStatusCacheVersions.get(getDirectoryCacheKey(runtimeKey, directory)) ?? 0;
|
|
|
|
const invalidateGitStatusCache = (directory: string): void => {
|
|
const runtimeKey = getRuntimeKey();
|
|
const key = getDirectoryCacheKey(runtimeKey, directory);
|
|
gitStatusCacheVersions.set(key, getStatusCacheVersion(runtimeKey, directory) + 1);
|
|
for (const mode of [undefined, 'light'] as const) {
|
|
const statusKey = getStatusCacheKey(runtimeKey, directory, mode);
|
|
gitStatusCache.delete(statusKey);
|
|
gitStatusInFlight.delete(statusKey);
|
|
}
|
|
};
|
|
|
|
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 = getDirectoryCacheKey(getRuntimeKey(), 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 listGitDirectories(root: string): Promise<string[]> {
|
|
const response = await runtimeFetch('/api/fs/git-dirs', { query: { path: root } });
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to list git directories: ${response.statusText}`);
|
|
}
|
|
const data = await response.json();
|
|
if (!data || !Array.isArray(data.repositories)) {
|
|
throw new Error('Unexpected git directories response');
|
|
}
|
|
return data.repositories
|
|
.map((entry: unknown) => {
|
|
const path = entry && typeof entry === 'object' && 'path' in entry ? (entry as { path?: unknown }).path : undefined;
|
|
return typeof path === 'string' && path.trim() ? path.trim() : null;
|
|
})
|
|
.filter((path: string | null): path is string => path !== null);
|
|
}
|
|
|
|
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus> {
|
|
const mode = options?.mode;
|
|
const runtimeKey = getRuntimeKey();
|
|
const key = getStatusCacheKey(runtimeKey, directory, mode);
|
|
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 cacheVersion = getStatusCacheVersion(runtimeKey, directory);
|
|
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;
|
|
if (getStatusCacheVersion(runtimeKey, directory) === cacheVersion) {
|
|
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 resolveGitPrimaryRoot(directory: string): Promise<{ root: string }> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/primary-root`, directory));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to resolve git primary root: ${response.statusText}`);
|
|
}
|
|
const payload = await response.json().catch(() => ({})) as { root?: string };
|
|
return { root: typeof payload.root === 'string' && payload.root ? payload.root : directory };
|
|
}
|
|
|
|
export async function resolveGitTopLevel(directory: string): Promise<{ root: string }> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/toplevel`, directory));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to resolve git toplevel: ${response.statusText}`);
|
|
}
|
|
const payload = await response.json().catch(() => ({})) as { root?: string };
|
|
return { root: typeof payload.root === 'string' && payload.root ? payload.root : directory };
|
|
}
|
|
|
|
export async function getGitCommitSummaries(
|
|
directory: string,
|
|
shas: string[]
|
|
): Promise<{ commits: Array<{ sha: string; short: string; subject: string }> }> {
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/commit-summaries`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ shas }),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get git commit summaries: ${response.statusText}`);
|
|
}
|
|
const payload = await response.json().catch(() => ({})) as {
|
|
commits?: Array<{ sha?: string; short?: string; subject?: string }>;
|
|
};
|
|
return {
|
|
commits: Array.isArray(payload.commits)
|
|
? payload.commits
|
|
.map((entry) => ({
|
|
sha: typeof entry.sha === 'string' ? entry.sha : '',
|
|
short: typeof entry.short === 'string' ? entry.short : '',
|
|
subject: typeof entry.subject === 'string' ? entry.subject : '',
|
|
}))
|
|
.filter((entry) => entry.sha && entry.short)
|
|
: [],
|
|
};
|
|
}
|
|
|
|
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 getGitRangeDiff(
|
|
directory: string,
|
|
options: GetGitRangeDiffOptions
|
|
): Promise<GitDiffResponse> {
|
|
const { base, head, path, contextLines } = options;
|
|
if (!base || !head) {
|
|
throw new Error('base and head are required to fetch git range diff');
|
|
}
|
|
|
|
const response = await runtimeFetch(
|
|
buildUrl(`${API_BASE}/range-diff`, directory, {
|
|
base,
|
|
head,
|
|
path: path || undefined,
|
|
context: contextLines,
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get git range 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');
|
|
}
|
|
|
|
invalidateGitStatusCache(directory);
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
invalidateGitStatusCache(directory);
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
invalidateGitStatusCache(directory);
|
|
}
|
|
|
|
export async function stageGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
|
|
await applyGitHunk(directory, filePath, patch, 'stage');
|
|
}
|
|
|
|
export async function unstageGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
|
|
await applyGitHunk(directory, filePath, patch, 'unstage');
|
|
}
|
|
|
|
export async function revertGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
|
|
await applyGitHunk(directory, filePath, patch, 'discard');
|
|
}
|
|
|
|
async function applyGitHunk(
|
|
directory: string,
|
|
filePath: string,
|
|
patch: string,
|
|
action: 'stage' | 'unstage' | 'discard',
|
|
): Promise<void> {
|
|
if (!filePath) {
|
|
throw new Error('path is required to apply a git hunk');
|
|
}
|
|
if (typeof patch !== 'string' || !patch.trim()) {
|
|
throw new Error('patch is required to apply a git hunk');
|
|
}
|
|
|
|
const response = await runtimeFetch(buildUrl(`${API_BASE}/apply-hunk`, directory), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path: filePath, patch, action }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const message = await response.json().catch(() => ({ error: response.statusText }));
|
|
throw new Error(message.error || 'Failed to apply git hunk');
|
|
}
|
|
|
|
invalidateGitStatusCache(directory);
|
|
}
|
|
|
|
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');
|
|
}
|
|
const result = await response.json();
|
|
invalidateGitStatusCache(directory);
|
|
return result;
|
|
}
|
|
|
|
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');
|
|
}
|
|
const result = await response.json();
|
|
invalidateGitStatusCache(directory);
|
|
return result;
|
|
}
|
|
|
|
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');
|
|
}
|
|
const result = await response.json();
|
|
invalidateGitStatusCache(directory);
|
|
return result;
|
|
}
|
|
|
|
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');
|
|
}
|
|
const result = await response.json();
|
|
invalidateGitStatusCache(directory);
|
|
return result;
|
|
}
|
|
|
|
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: 'pending' | '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();
|
|
}
|