* feat(types): add parents to GitLogEntry and new commit action types
* feat(git): add parent hashes and --all flag to getLog
* fix(git): move record separator to start of log format string
* feat(git): add checkoutCommit server function and route
* feat(git): add cherryPick server function and route
* feat(git): add revertCommit server function and route
* feat(git): add resetToCommit server function and route
* fix(tests): make git service tests branch-name portable, add error path tests
* feat(client): add checkoutCommit, cherryPick, revertCommit, resetToCommit API wrappers
* feat(git-graph): add lane assignment algorithm with tests
* feat(git-graph): add GitGraphSegment per-row SVG renderer
* feat(i18n): add locale strings for git graph action buttons
* fix(git-graph): handle lane convergence, fix SVG path coords, add connector tests
* feat(git-graph): add ref badges and action buttons to HistoryCommitRow
* fix(git-graph): add loading guards to reset actions, use theme tokens for ref badges
* fix(git-graph): conditional hooks, stale graph log, conflict handling, i18n
* fix(types): replace toBeDefined with toBeTruthy, fix toast API usage
* fix(lint): remove unused variables
* fix(git-graph): fix SVG height causing 150px row spacing
* fix(git-graph): smooth bezier curves, fill row height, round line caps
* fix(git-graph): non-scaling-stroke fixes bezier white spaces, sort curves on top
* fix(git-graph): remove viewBox scaling, match SVG height to actual row height
* fix(git-graph): ResizeObserver tracks actual row height, eliminates SVG height mismatch
* feat(git-graph): replace SVG with Canvas for graph rendering
* fix(git-graph): isolate canvas from flex layout to prevent replaced-element height leak
* feat(git-graph): align action buttons, add confirmation popups for all actions
* fix(git-graph): address code review findings CR-001 through CR-005
- CR-001: VS Code getGitLog now forwards 'all' option and parses %P parents
- CR-002: VS Code bridge/gitService implement checkoutCommit, cherryPick,
revertCommit, resetToCommit with conflict detection and hard-reset guard
- CR-003: server-side commit hash validated with /^[0-9a-fA-F]{7,40}$/
in both routes.js and service.js; 12 new rejection tests added
- CR-004: cherry-pick/revert conflict path now refreshes fetchStatus/
fetchBranches/fetchLog; conflict toast uses i18n keys in all 7 locales
- CR-005: corrected O(n) comment to O(n x lanes)
* fix(i18n): add zh-TW locale and common.language.traditionalChinese key to all locales
upstream/main added zh-TW.ts after branch diverged; CI type-check fails
when PR is merged because zh-TW.ts was missing all gitView.history.actions.*
keys and loadMore/loadingMore. Also adds common.language.traditionalChinese
to en.ts and all 6 non-English files to match upstream en.ts.
* fix: harden git history actions
* feat: split git history graph view
* chore: remove git graph planning docs
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
468 lines
17 KiB
TypeScript
468 lines
17 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 || {}),
|
|
});
|
|
},
|
|
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,
|
|
},
|
|
});
|
|
},
|
|
},
|
|
});
|