Files
openchamber/packages/vscode/webview/api/git.ts
T
Bohdan Triapitsyn 3fd6627196 feat: move sessions to new worktrees
Add a root-session action that creates a generated worktree from the session directory's current branch, transfers uncommitted changes, and moves the parent session plus its descendants through OpenCode's control-plane API.

Reuse existing project/worktree topology and quick-create behavior, keep the UI non-blocking, reconcile live and global session state across directories, and roll back partial moves and failed worktree creation safely.

Split worktree bootstrap readiness into directory-created, git-ready, and setup-ready phases across web and VS Code. Session moves wait for Git readiness while existing setup-aware flows continue waiting for full setup completion, and worktree removal is serialized with active bootstrap tasks.

Expose the move only for idle root sessions, show localized progress and explanatory tooltips in the sidebar, and keep pending/ready worktree metadata synchronized with authoritative session attachments to avoid stale setup indicators.

Add coverage for control-plane payloads, session-state migration, bootstrap phase ordering and compatibility, removal races, progress metadata, and fast-ready attachment races.
2026-07-19 00:00:31 +03:00

549 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' }): 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' });
},
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,
},
});
},
},
});