Files
openchamber/packages/vscode/webview/api/git.ts
T

553 lines
20 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,
GitWorktreeBootstrapStatus,
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';
const requestWorktreeBootstrapStatus = (directory: string): Promise<GitWorktreeBootstrapStatus> => {
return sendBridgeMessage<GitWorktreeBootstrapStatus>('api:git/worktrees/bootstrap-status', { directory });
};
type GitIdentityStoreState = {
profiles: GitIdentityProfile[];
};
type GitIdentityStoreApi = {
getState: () => GitIdentityStoreState;
setState: (
nextState: GitIdentityStoreState | ((state: GitIdentityStoreState) => GitIdentityStoreState),
replace?: boolean
) => void;
};
const getGitIdentityStore = (): GitIdentityStoreApi | undefined => (
window as Window & {
__zustand_git_identities_store__?: GitIdentityStoreApi;
}
).__zustand_git_identities_store__;
export const createVSCodeGitAPI = (): GitAPI => ({
checkIsGitRepository: async (directory: string): Promise<boolean> => {
return sendBridgeMessage<boolean>('api:git/check', { directory });
},
getGitStatus: async (directory: string, options?: { mode?: 'light'; fresh?: boolean }): 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 });
},
stageGitHunk: async (directory: string, filePath: string, patch: string): Promise<void> => {
await sendBridgeMessage('api:git/apply-hunk', { directory, path: filePath, patch, action: 'stage' });
},
unstageGitHunk: async (directory: string, filePath: string, patch: string): Promise<void> => {
await sendBridgeMessage('api:git/apply-hunk', { directory, path: filePath, patch, action: 'unstage' });
},
revertGitHunk: async (directory: string, filePath: string, patch: string): Promise<void> => {
await sendBridgeMessage('api:git/apply-hunk', { directory, path: filePath, patch, action: 'discard' });
},
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' });
},
getGitUnpushedBranchCounts: async (directory: string, branches: string[]) => {
return sendBridgeMessage('api:git/branch-push-status', { directory, branches });
},
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<GitWorktreeBootstrapStatus> => {
return requestWorktreeBootstrapStatus(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 }> => {
const store = (window as Window & {
__zustand_git_identities_store__?: {
getState: () => {
getProfileById: (id: string) => GitIdentityProfile | undefined;
};
};
}).__zustand_git_identities_store__;
const profile = store?.getState().getProfileById(profileId);
if (!profile) {
return {
success: false,
profile: { id: profileId, name: '', userName: '', userEmail: '' },
};
}
const result = await sendBridgeMessage<{ success: boolean }>('api:git/identity', {
directory,
method: 'POST',
userName: profile.userName,
userEmail: profile.userEmail,
sshKey: profile.sshKey ?? null,
signCommits: profile.signCommits === true,
signingKey: profile.signingKey ?? null,
});
return {
success: result.success === true,
profile,
};
},
// Git identity profile management is backed by the webview store in VS Code.
getGitIdentities: async (): Promise<GitIdentityProfile[]> => {
return getGitIdentityStore()?.getState().profiles ?? [];
},
createGitIdentity: async (profile: GitIdentityProfile): Promise<GitIdentityProfile> => {
const store = getGitIdentityStore();
if (store) {
store.setState((state) => ({
profiles: [...state.profiles.filter((existing) => existing.id !== profile.id), profile],
}));
}
return profile;
},
updateGitIdentity: async (id: string, profile: GitIdentityProfile): Promise<GitIdentityProfile> => {
const store = getGitIdentityStore();
if (store) {
store.setState((state) => ({
profiles: state.profiles.map((existing) => (existing.id === id ? { ...existing, ...profile, id } : existing)),
}));
}
return profile;
},
deleteGitIdentity: async (id: string): Promise<void> => {
const store = getGitIdentityStore();
if (store) {
store.setState((state) => ({
profiles: state.profiles.filter((existing) => existing.id !== id),
}));
}
},
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<GitWorktreeBootstrapStatus> => {
return requestWorktreeBootstrapStatus(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,
},
});
},
},
});