Add a directory-created fast path for worktree creation so session and send flows can continue once the target directory exists while Git attachment and bootstrap finish in the background. Track bootstrap status explicitly in shared UI contracts, including pending, ready, and failed states. Background watchers now surface failures and timeouts, update stored worktree metadata, and keep web and VS Code runtime behavior in parity. Move GitHub issue/PR worktree sessions and assistant-answer fork sessions onto the unified send path so provider, model, agent, and variant selections are preserved. The assistant-answer fork dialog can optionally create a worktree outside VS Code. Make worktree deletion dialogs close after linked-session cleanup while removing the worktree in the background, and clean up failed fast-create artifacts safely without recursively deleting user or agent-written files. Validation: bun test packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts packages/ui/src/lib/worktrees/worktreeManager.test.ts; bun run type-check; bun run lint.
480 lines
18 KiB
TypeScript
480 lines
18 KiB
TypeScript
/**
|
|
* VS Code Git API implementation
|
|
* Uses bridge messages to communicate with the extension host
|
|
*/
|
|
|
|
import { sendBridgeMessage } from './bridge';
|
|
import type {
|
|
GitAPI,
|
|
GitStatus,
|
|
GitDiffResponse,
|
|
GetGitDiffOptions,
|
|
GitFileDiffResponse,
|
|
GetGitFileDiffOptions,
|
|
GitBranch,
|
|
GitDeleteBranchPayload,
|
|
GitDeleteRemoteBranchPayload,
|
|
GitRemoveRemotePayload,
|
|
GeneratedCommitMessage,
|
|
GeneratedPullRequestDescription,
|
|
GitWorktreeInfo,
|
|
CreateGitWorktreePayload,
|
|
GitWorktreeValidationResult,
|
|
GitWorktreeCreateResult,
|
|
RemoveGitWorktreePayload,
|
|
GitCommitResult,
|
|
CreateGitCommitOptions,
|
|
GitPushResult,
|
|
GitPullResult,
|
|
GitLogResponse,
|
|
GitLogOptions,
|
|
GitCommitFilesResponse,
|
|
CommitFileDiffResponse,
|
|
GitIdentitySummary,
|
|
GitIdentityProfile,
|
|
GitRemote,
|
|
GitRebaseResult,
|
|
GitMergeResult,
|
|
CheckoutCommitResponse,
|
|
CherryPickResponse,
|
|
RevertCommitResponse,
|
|
ResetToCommitResponse,
|
|
} from '@openchamber/ui/lib/api/types';
|
|
|
|
export const createVSCodeGitAPI = (): GitAPI => ({
|
|
checkIsGitRepository: async (directory: string): Promise<boolean> => {
|
|
return sendBridgeMessage<boolean>('api:git/check', { directory });
|
|
},
|
|
|
|
getGitStatus: async (directory: string, options?: { mode?: 'light' }): Promise<GitStatus> => {
|
|
return sendBridgeMessage<GitStatus>('api:git/status', { directory, mode: options?.mode });
|
|
},
|
|
|
|
getGitDiff: async (directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse> => {
|
|
return sendBridgeMessage<GitDiffResponse>('api:git/diff', {
|
|
directory,
|
|
path: options.path,
|
|
staged: options.staged,
|
|
contextLines: options.contextLines,
|
|
});
|
|
},
|
|
|
|
getGitFileDiff: async (directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse> => {
|
|
return sendBridgeMessage<GitFileDiffResponse>('api:git/file-diff', {
|
|
directory,
|
|
path: options.path,
|
|
staged: options.staged,
|
|
});
|
|
},
|
|
|
|
revertGitFile: async (directory: string, filePath: string, options?: { scope?: 'all' | 'working' }): Promise<void> => {
|
|
await sendBridgeMessage('api:git/revert', { directory, path: filePath, scope: options?.scope });
|
|
},
|
|
|
|
stageGitFile: async (directory: string, filePath: string): Promise<void> => {
|
|
await sendBridgeMessage('api:git/stage', { directory, path: filePath });
|
|
},
|
|
|
|
stageGitFiles: async (directory: string, filePaths: string[]): Promise<void> => {
|
|
await sendBridgeMessage('api:git/stage', { directory, paths: filePaths });
|
|
},
|
|
|
|
unstageGitFile: async (directory: string, filePath: string): Promise<void> => {
|
|
await sendBridgeMessage('api:git/unstage', { directory, path: filePath });
|
|
},
|
|
|
|
unstageGitFiles: async (directory: string, filePaths: string[]): Promise<void> => {
|
|
await sendBridgeMessage('api:git/unstage', { directory, paths: filePaths });
|
|
},
|
|
|
|
isLinkedWorktree: async (directory: string): Promise<boolean> => {
|
|
return sendBridgeMessage<boolean>('api:git/worktree-type', { directory });
|
|
},
|
|
|
|
getGitBranches: async (directory: string): Promise<GitBranch> => {
|
|
return sendBridgeMessage<GitBranch>('api:git/branches', { directory, method: 'GET' });
|
|
},
|
|
|
|
deleteGitBranch: async (directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> => {
|
|
return sendBridgeMessage<{ success: boolean }>('api:git/branches', {
|
|
directory,
|
|
method: 'DELETE',
|
|
name: payload.branch,
|
|
force: payload.force,
|
|
});
|
|
},
|
|
|
|
deleteRemoteBranch: async (directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> => {
|
|
return sendBridgeMessage<{ success: boolean }>('api:git/remote-branches', {
|
|
directory,
|
|
branch: payload.branch,
|
|
remote: payload.remote,
|
|
});
|
|
},
|
|
|
|
removeRemote: async (directory: string, payload: GitRemoveRemotePayload): Promise<{ success: boolean }> => {
|
|
return sendBridgeMessage<{ success: boolean }>('api:git/remotes', {
|
|
directory,
|
|
method: 'DELETE',
|
|
remote: payload.remote,
|
|
});
|
|
},
|
|
|
|
generateCommitMessage: async (
|
|
directory: string,
|
|
files: string[],
|
|
options?: { zenModel?: string; providerId?: string; modelId?: string }
|
|
): Promise<{ message: GeneratedCommitMessage }> => {
|
|
// This requires AI integration - stubbed for now
|
|
void directory; // Unused for now
|
|
void files; // Unused for now
|
|
void options; // Unused for now
|
|
return {
|
|
message: {
|
|
subject: '',
|
|
highlights: [],
|
|
},
|
|
};
|
|
},
|
|
|
|
generatePullRequestDescription: async (
|
|
directory: string,
|
|
payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string }
|
|
): Promise<GeneratedPullRequestDescription> => {
|
|
return sendBridgeMessage<GeneratedPullRequestDescription>('api:git/pr-description', {
|
|
directory,
|
|
base: payload.base,
|
|
head: payload.head,
|
|
context: payload.context,
|
|
zenModel: payload.zenModel,
|
|
providerId: payload.providerId,
|
|
modelId: payload.modelId,
|
|
});
|
|
},
|
|
|
|
listGitWorktrees: async (directory: string): Promise<GitWorktreeInfo[]> => {
|
|
return sendBridgeMessage<GitWorktreeInfo[]>('api:git/worktrees', { directory, method: 'GET' });
|
|
},
|
|
|
|
validateGitWorktree: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult> => {
|
|
return sendBridgeMessage<GitWorktreeValidationResult>('api:git/worktrees/validate', {
|
|
directory,
|
|
...(payload || {}),
|
|
});
|
|
},
|
|
|
|
getGitWorktreeBootstrapStatus: async (directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> => {
|
|
return sendBridgeMessage<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }>('api:git/worktrees/bootstrap-status', {
|
|
directory,
|
|
});
|
|
},
|
|
|
|
previewGitWorktree: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> => {
|
|
return sendBridgeMessage<GitWorktreeCreateResult>('api:git/worktrees/preview', {
|
|
directory,
|
|
method: 'POST',
|
|
...(payload || {}),
|
|
});
|
|
},
|
|
|
|
createGitWorktree: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> => {
|
|
return sendBridgeMessage<GitWorktreeCreateResult>('api:git/worktrees', {
|
|
directory,
|
|
method: 'POST',
|
|
...(payload || {}),
|
|
});
|
|
},
|
|
|
|
deleteGitWorktree: async (directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }> => {
|
|
return sendBridgeMessage<{ success: boolean }>('api:git/worktrees', {
|
|
directory,
|
|
method: 'DELETE',
|
|
body: {
|
|
directory: payload.directory,
|
|
deleteLocalBranch: payload.deleteLocalBranch === true,
|
|
},
|
|
});
|
|
},
|
|
|
|
createGitCommit: async (directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult> => {
|
|
return sendBridgeMessage<GitCommitResult>('api:git/commit', {
|
|
directory,
|
|
message,
|
|
addAll: options?.addAll,
|
|
files: options?.files,
|
|
stageFiles: options?.stageFiles,
|
|
});
|
|
},
|
|
|
|
gitPush: async (directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> }): Promise<GitPushResult> => {
|
|
return sendBridgeMessage<GitPushResult>('api:git/push', {
|
|
directory,
|
|
remote: options?.remote,
|
|
branch: options?.branch,
|
|
options: options?.options,
|
|
});
|
|
},
|
|
|
|
gitPull: async (directory: string, options?: { remote?: string; branch?: string; rebase?: boolean }): Promise<GitPullResult> => {
|
|
return sendBridgeMessage<GitPullResult>('api:git/pull', {
|
|
directory,
|
|
remote: options?.remote,
|
|
branch: options?.branch,
|
|
rebase: options?.rebase,
|
|
});
|
|
},
|
|
|
|
gitFetch: async (directory: string, options?: { remote?: string; branch?: string }): Promise<{ success: boolean }> => {
|
|
return sendBridgeMessage<{ success: boolean }>('api:git/fetch', {
|
|
directory,
|
|
remote: options?.remote,
|
|
branch: options?.branch,
|
|
});
|
|
},
|
|
|
|
listGitStashes: async (directory: string) => sendBridgeMessage('api:git/stashes', { directory }),
|
|
countGitStashFiles: async (directory: string, refs: string[]) => sendBridgeMessage('api:git/stashes/file-counts', { directory, refs }),
|
|
stashGitChanges: async (directory: string, options?: { message?: string }) => sendBridgeMessage('api:git/stash', { directory, message: options?.message }),
|
|
applyGitStash: async (directory: string, options: { ref: string }) => sendBridgeMessage('api:git/stash/apply', { directory, ref: options.ref }),
|
|
popGitStash: async (directory: string, options: { ref: string }) => sendBridgeMessage('api:git/stash/pop', { directory, ref: options.ref }),
|
|
dropGitStash: async (directory: string, options: { ref: string }) => sendBridgeMessage('api:git/stash/drop', { directory, ref: options.ref }),
|
|
|
|
checkoutBranch: async (directory: string, branch: string): Promise<{ success: boolean; branch: string }> => {
|
|
return sendBridgeMessage<{ success: boolean; branch: string }>('api:git/checkout', {
|
|
directory,
|
|
branch,
|
|
});
|
|
},
|
|
|
|
createBranch: async (directory: string, name: string, startPoint?: string): Promise<{ success: boolean; branch: string }> => {
|
|
return sendBridgeMessage<{ success: boolean; branch: string }>('api:git/branches', {
|
|
directory,
|
|
method: 'POST',
|
|
name,
|
|
startPoint,
|
|
});
|
|
},
|
|
|
|
renameBranch: async (directory: string, oldName: string, newName: string): Promise<{ success: boolean; branch: string }> => {
|
|
return sendBridgeMessage<{ success: boolean; branch: string }>('api:git/branches/rename', {
|
|
directory,
|
|
method: 'PUT',
|
|
oldName,
|
|
newName,
|
|
});
|
|
},
|
|
|
|
getGitLog: async (directory: string, options?: GitLogOptions): Promise<GitLogResponse> => {
|
|
return sendBridgeMessage<GitLogResponse>('api:git/log', {
|
|
directory,
|
|
maxCount: options?.maxCount,
|
|
from: options?.from,
|
|
to: options?.to,
|
|
file: options?.file,
|
|
all: options?.all,
|
|
});
|
|
},
|
|
|
|
getCommitFiles: async (directory: string, hash: string): Promise<GitCommitFilesResponse> => {
|
|
return sendBridgeMessage<GitCommitFilesResponse>('api:git/commit-files', {
|
|
directory,
|
|
hash,
|
|
});
|
|
},
|
|
|
|
getCommitFileDiff: async (directory: string, hash: string, filePath: string, isBinary: boolean): Promise<CommitFileDiffResponse> => {
|
|
return sendBridgeMessage<CommitFileDiffResponse>('api:git/commit-file-diff', {
|
|
directory,
|
|
hash,
|
|
path: filePath,
|
|
binary: isBinary,
|
|
});
|
|
},
|
|
|
|
getCurrentGitIdentity: async (directory: string): Promise<GitIdentitySummary | null> => {
|
|
return sendBridgeMessage<GitIdentitySummary | null>('api:git/identity', {
|
|
directory,
|
|
method: 'GET',
|
|
});
|
|
},
|
|
|
|
setGitIdentity: async (directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }> => {
|
|
// For VS Code, we need to resolve the profile from the store
|
|
// This is a simplified implementation - the full implementation would need profile lookup
|
|
return {
|
|
success: false,
|
|
profile: { id: profileId, name: '', userName: '', userEmail: '' },
|
|
};
|
|
},
|
|
|
|
// Git identity profile management - these are stored in extension settings
|
|
// For simplicity, return empty arrays/objects as these are managed through the settings UI
|
|
getGitIdentities: async (): Promise<GitIdentityProfile[]> => {
|
|
return [];
|
|
},
|
|
|
|
createGitIdentity: async (profile: GitIdentityProfile): Promise<GitIdentityProfile> => {
|
|
return profile;
|
|
},
|
|
|
|
updateGitIdentity: async (id: string, profile: GitIdentityProfile): Promise<GitIdentityProfile> => {
|
|
void id; // Unused for now
|
|
return profile;
|
|
},
|
|
|
|
deleteGitIdentity: async (id: string): Promise<void> => {
|
|
void id; // Unused for now
|
|
},
|
|
|
|
getRemotes: async (directory: string): Promise<GitRemote[]> => {
|
|
return sendBridgeMessage<GitRemote[]>('api:git/remotes', { directory });
|
|
},
|
|
|
|
rebase: async (directory: string, options: { onto: string }): Promise<GitRebaseResult> => {
|
|
return sendBridgeMessage<GitRebaseResult>('api:git/rebase', {
|
|
directory,
|
|
onto: options.onto,
|
|
});
|
|
},
|
|
|
|
abortRebase: async (directory: string): Promise<{ success: boolean }> => {
|
|
return sendBridgeMessage<{ success: boolean }>('api:git/rebase/abort', { directory });
|
|
},
|
|
|
|
merge: async (directory: string, options: { branch: string }): Promise<GitMergeResult> => {
|
|
return sendBridgeMessage<GitMergeResult>('api:git/merge', {
|
|
directory,
|
|
branch: options.branch,
|
|
});
|
|
},
|
|
|
|
abortMerge: async (directory: string): Promise<{ success: boolean }> => {
|
|
return sendBridgeMessage<{ success: boolean }>('api:git/merge/abort', { directory });
|
|
},
|
|
|
|
continueRebase: async (directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> => {
|
|
return sendBridgeMessage<{ success: boolean; conflict: boolean; conflictFiles?: string[] }>('api:git/rebase/continue', { directory });
|
|
},
|
|
|
|
continueMerge: async (directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> => {
|
|
return sendBridgeMessage<{ success: boolean; conflict: boolean; conflictFiles?: string[] }>('api:git/merge/continue', { directory });
|
|
},
|
|
|
|
checkoutCommit: async (directory: string, hash: string): Promise<CheckoutCommitResponse> => {
|
|
return sendBridgeMessage<CheckoutCommitResponse>('api:git/checkout-commit', { directory, hash });
|
|
},
|
|
|
|
cherryPick: async (directory: string, hash: string): Promise<CherryPickResponse> => {
|
|
return sendBridgeMessage<CherryPickResponse>('api:git/cherry-pick', { directory, hash });
|
|
},
|
|
|
|
revertCommit: async (directory: string, hash: string): Promise<RevertCommitResponse> => {
|
|
return sendBridgeMessage<RevertCommitResponse>('api:git/revert-commit', { directory, hash });
|
|
},
|
|
|
|
resetToCommit: async (directory: string, hash: string, mode: 'soft' | 'mixed' | 'hard', force?: boolean): Promise<ResetToCommitResponse> => {
|
|
return sendBridgeMessage<ResetToCommitResponse>('api:git/reset-to-commit', { directory, hash, mode, force });
|
|
},
|
|
|
|
stash: async (
|
|
directory: string,
|
|
options?: { message?: string; includeUntracked?: boolean }
|
|
): Promise<{ success: boolean }> => {
|
|
return sendBridgeMessage<{ success: boolean }>('api:git/stash', {
|
|
directory,
|
|
...options,
|
|
});
|
|
},
|
|
|
|
stashPop: async (directory: string): Promise<{ success: boolean }> => {
|
|
return sendBridgeMessage<{ success: boolean }>('api:git/stash/pop', { directory });
|
|
},
|
|
|
|
getConflictDetails: async (directory: string) => {
|
|
return sendBridgeMessage<{
|
|
statusPorcelain: string;
|
|
unmergedFiles: string[];
|
|
diff: string;
|
|
headInfo: string;
|
|
operation: 'merge' | 'rebase';
|
|
}>('api:git/conflict-details', { directory });
|
|
},
|
|
|
|
validateWorktreeDirectory: async (directory: string, worktreeRoot: string): Promise<{
|
|
valid: boolean;
|
|
insideWorktreeRoot: boolean;
|
|
resolvedWorktreeRoot: string | null;
|
|
resolvedCwd: string | null;
|
|
}> => {
|
|
return sendBridgeMessage<{
|
|
valid: boolean;
|
|
insideWorktreeRoot: boolean;
|
|
resolvedWorktreeRoot: string | null;
|
|
resolvedCwd: string | null;
|
|
}>('api:git/validate-directory', { directory, worktreeRoot });
|
|
},
|
|
|
|
canonicalizeWorktreeState: async (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;
|
|
}> => {
|
|
return sendBridgeMessage<{
|
|
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;
|
|
}>('api:git/canonicalize-worktree-state', { directory });
|
|
},
|
|
|
|
worktree: {
|
|
list: async (directory: string): Promise<GitWorktreeInfo[]> => {
|
|
return sendBridgeMessage<GitWorktreeInfo[]>('api:git/worktrees', { directory, method: 'GET' });
|
|
},
|
|
validate: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult> => {
|
|
return sendBridgeMessage<GitWorktreeValidationResult>('api:git/worktrees/validate', {
|
|
directory,
|
|
...(payload || {}),
|
|
});
|
|
},
|
|
bootstrapStatus: async (directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> => {
|
|
return sendBridgeMessage<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }>('api:git/worktrees/bootstrap-status', {
|
|
directory,
|
|
});
|
|
},
|
|
preview: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> => {
|
|
return sendBridgeMessage<GitWorktreeCreateResult>('api:git/worktrees/preview', {
|
|
directory,
|
|
method: 'POST',
|
|
...(payload || {}),
|
|
});
|
|
},
|
|
create: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> => {
|
|
return sendBridgeMessage<GitWorktreeCreateResult>('api:git/worktrees', {
|
|
directory,
|
|
method: 'POST',
|
|
...(payload || {}),
|
|
});
|
|
},
|
|
remove: async (directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }> => {
|
|
return sendBridgeMessage<{ success: boolean }>('api:git/worktrees', {
|
|
directory,
|
|
method: 'DELETE',
|
|
body: {
|
|
directory: payload.directory,
|
|
deleteLocalBranch: payload.deleteLocalBranch === true,
|
|
},
|
|
});
|
|
},
|
|
},
|
|
});
|