From 2e732c2481d17dab97cc791e41cab6b877404c88 Mon Sep 17 00:00:00 2001 From: wienans <40465543+wienans@users.noreply.github.com> Date: Thu, 1 Jan 2026 20:39:05 +0100 Subject: [PATCH] feat(vscode): Implement Git Backend via VS Code Git extension (#92) --- CHANGELOG.md | 3 + .../ui/src/components/layout/VSCodeLayout.tsx | 1 + .../src/components/session/SessionSidebar.tsx | 6 +- packages/vscode/src/bridge.ts | 282 ++++ packages/vscode/src/git.d.ts | 349 +++++ packages/vscode/src/gitService.ts | 1250 +++++++++++++++++ packages/vscode/webview/api/git.ts | 227 +++ packages/vscode/webview/api/index.ts | 36 +- 8 files changed, 2119 insertions(+), 35 deletions(-) create mode 100644 packages/vscode/src/git.d.ts create mode 100644 packages/vscode/src/gitService.ts create mode 100644 packages/vscode/webview/api/git.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c03b508e..21fbad57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- VS Code extension: added git backend integration for UI to access +- VS Code extension: Only show the main Worktree in the Chat Sidebar + ## [1.4.0] - 2026-01-01 - Added the ability to run multiple agents from a single prompt, with each agent working in an isolated worktree. diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index 44af1d72..b2966aee 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -159,6 +159,7 @@ export const VSCodeLayout: React.FC = () => { allowReselect onSessionSelected={() => setCurrentView('chat')} hideDirectoryControls + showOnlyMainWorkspace /> diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index f9ea0040..198865bd 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -109,6 +109,7 @@ interface SessionSidebarProps { onSessionSelected?: (sessionId: string) => void; allowReselect?: boolean; hideDirectoryControls?: boolean; + showOnlyMainWorkspace?: boolean; } export const SessionSidebar: React.FC = ({ @@ -116,6 +117,7 @@ export const SessionSidebar: React.FC = ({ onSessionSelected, allowReselect = false, hideDirectoryControls = false, + showOnlyMainWorkspace = false, }) => { const [editingId, setEditingId] = React.useState(null); const [editTitle, setEditTitle] = React.useState(''); @@ -1061,10 +1063,10 @@ export const SessionSidebar: React.FC = ({ > {groupedSessions.length === 0 ? ( emptyState - ) : hideDirectoryControls && groupedSessions.length === 1 && groupedSessions[0].isMain ? ( + ) : (hideDirectoryControls && groupedSessions.length === 1 && groupedSessions[0].isMain) || showOnlyMainWorkspace ? (
{(() => { - const group = groupedSessions[0]; + const group = groupedSessions.find(g => g.isMain) ?? groupedSessions[0]; const maxVisible = hideDirectoryControls ? 10 : 7; const totalSessions = group.sessions.length; const isExpanded = expandedSessionGroups.has(group.id); diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index 94848e30..25d9cba5 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -4,6 +4,7 @@ import * as path from 'path'; import type { OpenCodeManager } from './opencode'; import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, SKILL_SCOPE } from './opencodeConfig'; import { removeProviderAuth } from './opencodeAuth'; +import * as gitService from './gitService'; import { getSkillsCatalog, scanSkillsRepository as scanSkillsRepositoryFromGit, @@ -1047,6 +1048,287 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo } } + // ============== Git Operations ============== + + case 'api:git/check': { + const { directory } = (payload || {}) as { directory?: string }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + const isRepo = await gitService.checkIsGitRepository(directory); + return { id, type, success: true, data: isRepo }; + } + + case 'api:git/worktree-type': { + const { directory } = (payload || {}) as { directory?: string }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + const isLinked = await gitService.isLinkedWorktree(directory); + return { id, type, success: true, data: isLinked }; + } + + case 'api:git/status': { + const { directory } = (payload || {}) as { directory?: string }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + const status = await gitService.getGitStatus(directory); + return { id, type, success: true, data: status }; + } + + case 'api:git/branches': { + const { directory, method, name, startPoint, force } = (payload || {}) as { + directory?: string; + method?: string; + name?: string; + startPoint?: string; + force?: boolean; + }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + + const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; + + if (normalizedMethod === 'GET') { + const branches = await gitService.getGitBranches(directory); + return { id, type, success: true, data: branches }; + } + + if (normalizedMethod === 'POST') { + if (!name) { + return { id, type, success: false, error: 'Branch name is required' }; + } + const result = await gitService.createBranch(directory, name, startPoint); + return { id, type, success: true, data: result }; + } + + if (normalizedMethod === 'DELETE') { + if (!name) { + return { id, type, success: false, error: 'Branch name is required' }; + } + const result = await gitService.deleteGitBranch(directory, name, force); + return { id, type, success: true, data: result }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:git/remote-branches': { + const { directory, branch, remote } = (payload || {}) as { + directory?: string; + branch?: string; + remote?: string; + }; + if (!directory || !branch) { + return { id, type, success: false, error: 'Directory and branch are required' }; + } + const result = await gitService.deleteRemoteBranch(directory, branch, remote); + return { id, type, success: true, data: result }; + } + + case 'api:git/checkout': { + const { directory, branch } = (payload || {}) as { directory?: string; branch?: string }; + if (!directory || !branch) { + return { id, type, success: false, error: 'Directory and branch are required' }; + } + const result = await gitService.checkoutBranch(directory, branch); + return { id, type, success: true, data: result }; + } + + case 'api:git/worktrees': { + const { directory, method, path: worktreePath, branch, createBranch, force } = (payload || {}) as { + directory?: string; + method?: string; + path?: string; + branch?: string; + createBranch?: boolean; + force?: boolean; + }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + + const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; + + if (normalizedMethod === 'GET') { + const worktrees = await gitService.listGitWorktrees(directory); + return { id, type, success: true, data: worktrees }; + } + + if (normalizedMethod === 'POST') { + if (!worktreePath || !branch) { + return { id, type, success: false, error: 'Path and branch are required' }; + } + const result = await gitService.addGitWorktree(directory, worktreePath, branch, createBranch); + return { id, type, success: true, data: result }; + } + + if (normalizedMethod === 'DELETE') { + if (!worktreePath) { + return { id, type, success: false, error: 'Path is required' }; + } + const result = await gitService.removeGitWorktree(directory, worktreePath, force); + return { id, type, success: true, data: result }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:git/diff': { + const { directory, path: filePath, staged, contextLines } = (payload || {}) as { + directory?: string; + path?: string; + staged?: boolean; + contextLines?: number; + }; + if (!directory || !filePath) { + return { id, type, success: false, error: 'Directory and path are required' }; + } + const result = await gitService.getGitDiff(directory, filePath, staged, contextLines); + return { id, type, success: true, data: result }; + } + + case 'api:git/file-diff': { + const { directory, path: filePath, staged } = (payload || {}) as { + directory?: string; + path?: string; + staged?: boolean; + }; + if (!directory || !filePath) { + return { id, type, success: false, error: 'Directory and path are required' }; + } + const result = await gitService.getGitFileDiff(directory, filePath, staged); + return { id, type, success: true, data: result }; + } + + case 'api:git/revert': { + const { directory, path: filePath } = (payload || {}) as { directory?: string; path?: string }; + if (!directory || !filePath) { + return { id, type, success: false, error: 'Directory and path are required' }; + } + await gitService.revertGitFile(directory, filePath); + return { id, type, success: true, data: { success: true } }; + } + + case 'api:git/commit': { + const { directory, message, addAll, files } = (payload || {}) as { + directory?: string; + message?: string; + addAll?: boolean; + files?: string[]; + }; + if (!directory || !message) { + return { id, type, success: false, error: 'Directory and message are required' }; + } + const result = await gitService.createGitCommit(directory, message, { addAll, files }); + return { id, type, success: true, data: result }; + } + + case 'api:git/push': { + const { directory, remote, branch, options } = (payload || {}) as { + directory?: string; + remote?: string; + branch?: string; + options?: string[] | Record; + }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + const result = await gitService.gitPush(directory, { remote, branch, options }); + return { id, type, success: true, data: result }; + } + + case 'api:git/pull': { + const { directory, remote, branch } = (payload || {}) as { + directory?: string; + remote?: string; + branch?: string; + }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + const result = await gitService.gitPull(directory, { remote, branch }); + return { id, type, success: true, data: result }; + } + + case 'api:git/fetch': { + const { directory, remote, branch } = (payload || {}) as { + directory?: string; + remote?: string; + branch?: string; + }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + const result = await gitService.gitFetch(directory, { remote, branch }); + return { id, type, success: true, data: result }; + } + + case 'api:git/log': { + const { directory, maxCount, from, to, file } = (payload || {}) as { + directory?: string; + maxCount?: number; + from?: string; + to?: string; + file?: string; + }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + const result = await gitService.getGitLog(directory, { maxCount, from, to, file }); + return { id, type, success: true, data: result }; + } + + case 'api:git/commit-files': { + const { directory, hash } = (payload || {}) as { directory?: string; hash?: string }; + if (!directory || !hash) { + return { id, type, success: false, error: 'Directory and hash are required' }; + } + const result = await gitService.getCommitFiles(directory, hash); + return { id, type, success: true, data: result }; + } + + case 'api:git/identity': { + const { directory, method, userName, userEmail, sshKey } = (payload || {}) as { + directory?: string; + method?: string; + userName?: string; + userEmail?: string; + sshKey?: string | null; + }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + + const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; + + if (normalizedMethod === 'GET') { + const identity = await gitService.getCurrentGitIdentity(directory); + return { id, type, success: true, data: identity }; + } + + if (normalizedMethod === 'POST') { + if (!userName || !userEmail) { + return { id, type, success: false, error: 'userName and userEmail are required' }; + } + const result = await gitService.setGitIdentity(directory, userName, userEmail, sshKey); + return { id, type, success: true, data: result }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:git/ignore-openchamber': { + const { directory } = (payload || {}) as { directory?: string }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + await gitService.ensureOpenChamberIgnored(directory); + return { id, type, success: true, data: { success: true } }; + } + default: return { id, type, success: false, error: `Unknown message type: ${type}` }; } diff --git a/packages/vscode/src/git.d.ts b/packages/vscode/src/git.d.ts new file mode 100644 index 00000000..1b186dd0 --- /dev/null +++ b/packages/vscode/src/git.d.ts @@ -0,0 +1,349 @@ +/** + * VS Code Git Extension API type definitions + * Based on the vscode.git extension API + * @see https://github.com/microsoft/vscode/blob/main/extensions/git/src/api/git.d.ts + */ + +import type { Uri, Event, Disposable } from 'vscode'; + +export interface Git { + readonly path: string; +} + +export interface InputBox { + value: string; +} + +export const enum ForcePushMode { + Force, + ForceWithLease, + ForceWithLeaseIfIncludes, +} + +export const enum RefType { + Head, + RemoteHead, + Tag, +} + +export interface Ref { + readonly type: RefType; + readonly name?: string; + readonly commit?: string; + readonly remote?: string; +} + +export interface UpstreamRef { + readonly remote: string; + readonly name: string; +} + +export interface Branch extends Ref { + readonly upstream?: UpstreamRef; + readonly ahead?: number; + readonly behind?: number; +} + +export interface Commit { + readonly hash: string; + readonly message: string; + readonly parents: string[]; + readonly authorDate?: Date; + readonly authorName?: string; + readonly authorEmail?: string; + readonly commitDate?: Date; +} + +export interface Submodule { + readonly name: string; + readonly path: string; + readonly url: string; +} + +export interface Remote { + readonly name: string; + readonly fetchUrl?: string; + readonly pushUrl?: string; + readonly isReadOnly: boolean; +} + +export const enum Status { + INDEX_MODIFIED, + INDEX_ADDED, + INDEX_DELETED, + INDEX_RENAMED, + INDEX_COPIED, + MODIFIED, + DELETED, + UNTRACKED, + IGNORED, + INTENT_TO_ADD, + INTENT_TO_RENAME, + TYPE_CHANGED, + ADDED_BY_US, + ADDED_BY_THEM, + DELETED_BY_US, + DELETED_BY_THEM, + BOTH_ADDED, + BOTH_DELETED, + BOTH_MODIFIED, +} + +export interface Change { + readonly uri: Uri; + readonly originalUri: Uri; + readonly renameUri: Uri | undefined; + readonly status: Status; +} + +export interface RepositoryState { + readonly HEAD: Branch | undefined; + readonly refs: Ref[]; + readonly remotes: Remote[]; + readonly submodules: Submodule[]; + readonly rebaseCommit: Commit | undefined; + readonly mergeChanges: Change[]; + readonly indexChanges: Change[]; + readonly workingTreeChanges: Change[]; + readonly onDidChange: Event; +} + +export interface RepositoryUIState { + readonly selected: boolean; + readonly onDidChange: Event; +} + +export interface CommitOptions { + all?: boolean | 'tracked'; + amend?: boolean; + signoff?: boolean; + signCommit?: boolean; + empty?: boolean; + noVerify?: boolean; + requireUserConfig?: boolean; + useEditor?: boolean; + verbose?: boolean; + postCommitCommand?: string | null; +} + +export interface FetchOptions { + remote?: string; + ref?: string; + all?: boolean; + prune?: boolean; + depth?: number; +} + +export interface BranchQuery { + readonly remote?: boolean; + readonly pattern?: string; + readonly count?: number; + readonly contains?: string; + readonly sort?: 'alphabetically' | 'committerdate'; +} + +export interface LogOptions { + readonly maxEntries?: number; + readonly path?: string; + readonly reverse?: boolean; + readonly sortByAuthorDate?: boolean; + readonly shortStats?: boolean; + readonly range?: string; +} + +export interface Repository { + readonly rootUri: Uri; + readonly inputBox: InputBox; + readonly state: RepositoryState; + readonly ui: RepositoryUIState; + + getConfigs(): Promise<{ key: string; value: string }[]>; + getConfig(key: string): Promise; + setConfig(key: string, value: string): Promise; + getGlobalConfig(key: string): Promise; + + getObjectDetails(treeish: string, path: string): Promise<{ mode: string; object: string; size: number }>; + detectObjectType(object: string): Promise<{ mimetype: string; encoding?: string }>; + buffer(ref: string, path: string): Promise; + show(ref: string, path: string): Promise; + getCommit(ref: string): Promise; + + add(paths: string[]): Promise; + revert(paths: string[]): Promise; + clean(paths: string[]): Promise; + + apply(patch: string, reverse?: boolean): Promise; + diff(cached?: boolean): Promise; + diffWithHEAD(): Promise; + diffWithHEAD(path: string): Promise; + diffWith(ref: string): Promise; + diffWith(ref: string, path: string): Promise; + diffIndexWithHEAD(): Promise; + diffIndexWithHEAD(path: string): Promise; + diffIndexWith(ref: string): Promise; + diffIndexWith(ref: string, path: string): Promise; + diffBlobs(object1: string, object2: string): Promise; + diffBetween(ref1: string, ref2: string): Promise; + diffBetween(ref1: string, ref2: string, path: string): Promise; + + hashObject(data: string): Promise; + + createBranch(name: string, checkout: boolean, ref?: string): Promise; + deleteBranch(name: string, force?: boolean): Promise; + getBranch(name: string): Promise; + getBranches(query: BranchQuery): Promise; + getBranchBase(name: string): Promise; + setBranchUpstream(name: string, upstream: string): Promise; + + getRefs(query: { contains?: string; count?: number; pattern?: string; sort?: 'alphabetically' | 'committerdate' }): Promise; + + getMergeBase(ref1: string, ref2: string): Promise; + + tag(name: string, upstream: string): Promise; + deleteTag(name: string): Promise; + + status(): Promise; + checkout(treeish: string): Promise; + + addRemote(name: string, url: string): Promise; + removeRemote(name: string): Promise; + renameRemote(name: string, newName: string): Promise; + + fetch(options?: FetchOptions): Promise; + fetch(remote?: string, ref?: string, depth?: number): Promise; + pull(unshallow?: boolean): Promise; + push(remoteName?: string, branchName?: string, setUpstream?: boolean, force?: ForcePushMode): Promise; + + blame(path: string): Promise; + log(options?: LogOptions): Promise; + + commit(message: string, opts?: CommitOptions): Promise; +} + +export interface RemoteSource { + readonly name: string; + readonly description?: string; + readonly url: string | string[]; +} + +export interface RemoteSourceProvider { + readonly name: string; + readonly icon?: string; + readonly supportsQuery?: boolean; + getRemoteSources(query?: string): Promise; + getBranches?(url: string): Promise; + publishRepository?(repository: Repository): Promise; +} + +export interface RemoteSourcePublisher { + readonly name: string; + readonly icon?: string; + publishRepository(repository: Repository): Promise; +} + +export interface Credentials { + readonly username: string; + readonly password: string; +} + +export interface CredentialsProvider { + getCredentials(host: Uri): Promise; +} + +export interface PostCommitCommandsProvider { + getCommands(repository: Repository): Promise>; +} + +export interface PushErrorHandler { + handlePushError(repository: Repository, remote: Remote, refspec: string, error: Error & { gitErrorCode?: string }): Promise; +} + +export interface BranchProtection { + readonly remote: string; + readonly rules: BranchProtectionRule[]; +} + +export interface BranchProtectionRule { + readonly include?: string[]; + readonly exclude?: string[]; +} + +export interface BranchProtectionProvider { + onDidChangeBranchProtection: Event; + provideBranchProtection(): Promise; +} + +export type APIState = 'uninitialized' | 'initialized'; + +export interface PublishEvent { + repository: Repository; + branch?: string; +} + +export interface API { + readonly state: APIState; + readonly onDidChangeState: Event; + readonly onDidPublish: Event; + readonly git: Git; + readonly repositories: Repository[]; + readonly onDidOpenRepository: Event; + readonly onDidCloseRepository: Event; + + toGitUri(uri: Uri, ref: string): Uri; + getRepository(uri: Uri): Repository | null; + init(root: Uri): Promise; + openRepository(root: Uri): Promise; + registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; + registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable; + registerCredentialsProvider(provider: CredentialsProvider): Disposable; + registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): Disposable; + registerPushErrorHandler(handler: PushErrorHandler): Disposable; + registerBranchProtectionProvider(root: Uri, provider: BranchProtectionProvider): Disposable; +} + +export interface GitExtension { + readonly enabled: boolean; + readonly onDidChangeEnablement: Event; + getAPI(version: 1): API; +} + +export const enum GitErrorCodes { + BadConfigFile = 'BadConfigFile', + AuthenticationFailed = 'AuthenticationFailed', + NoUserNameConfigured = 'NoUserNameConfigured', + NoUserEmailConfigured = 'NoUserEmailConfigured', + NoRemoteRepositorySpecified = 'NoRemoteRepositorySpecified', + NotAGitRepository = 'NotAGitRepository', + NotAtRepositoryRoot = 'NotAtRepositoryRoot', + Conflict = 'Conflict', + StashConflict = 'StashConflict', + UnmergedChanges = 'UnmergedChanges', + PushRejected = 'PushRejected', + RemoteConnectionError = 'RemoteConnectionError', + DirtyWorkTree = 'DirtyWorkTree', + CantOpenResource = 'CantOpenResource', + GitNotFound = 'GitNotFound', + CantCreatePipe = 'CantCreatePipe', + PermissionDenied = 'PermissionDenied', + CantAccessRemote = 'CantAccessRemote', + RepositoryNotFound = 'RepositoryNotFound', + RepositoryIsLocked = 'RepositoryIsLocked', + BranchNotFullyMerged = 'BranchNotFullyMerged', + NoRemoteReference = 'NoRemoteReference', + InvalidBranchName = 'InvalidBranchName', + BranchAlreadyExists = 'BranchAlreadyExists', + NoLocalChanges = 'NoLocalChanges', + NoStashFound = 'NoStashFound', + LocalChangesOverwritten = 'LocalChangesOverwritten', + NoUpstreamBranch = 'NoUpstreamBranch', + IsInSubmodule = 'IsInSubmodule', + WrongCase = 'WrongCase', + CantLockRef = 'CantLockRef', + CantRebaseMultipleBranches = 'CantRebaseMultipleBranches', + PatchDoesNotApply = 'PatchDoesNotApply', + NoPathFound = 'NoPathFound', + UnknownPath = 'UnknownPath', + EmptyCommitMessage = 'EmptyCommitMessage', + BranchFastForwardRejected = 'BranchFastForwardRejected', + TagConflict = 'TagConflict', +} diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts new file mode 100644 index 00000000..17443b2d --- /dev/null +++ b/packages/vscode/src/gitService.ts @@ -0,0 +1,1250 @@ +/** + * Git service for VS Code extension + * Uses VS Code's built-in git extension API for repository operations + * and raw git commands via child_process for worktree operations + */ + +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as os from 'os'; +import { spawn } from 'child_process'; +import type { API as GitAPI, Repository, GitExtension, Status } from './git.d'; + +let gitApi: GitAPI | null = null; +let gitExtensionEnabled = false; + +/** + * Initialize the git extension API + */ +export async function initGitExtension(): Promise { + if (gitApi && gitExtensionEnabled) { + return gitApi; + } + + try { + const gitExtension = vscode.extensions.getExtension('vscode.git'); + if (!gitExtension) { + console.warn('[GitService] Git extension not found'); + return null; + } + + if (!gitExtension.isActive) { + await gitExtension.activate(); + } + + const extension = gitExtension.exports; + if (!extension.enabled) { + console.warn('[GitService] Git extension is disabled'); + return null; + } + + gitApi = extension.getAPI(1); + gitExtensionEnabled = true; + + // Listen for enablement changes + extension.onDidChangeEnablement((enabled) => { + gitExtensionEnabled = enabled; + if (!enabled) { + gitApi = null; + } + }); + + return gitApi; + } catch (error) { + console.error('[GitService] Failed to initialize git extension:', error); + return null; + } +} + +/** + * Get the git API, initializing if necessary + */ +export async function getGitApi(): Promise { + if (gitApi && gitExtensionEnabled) { + return gitApi; + } + return initGitExtension(); +} + +/** + * Get repository for a given directory + */ +export async function getRepository(directory: string): Promise { + const api = await getGitApi(); + if (!api) return null; + + const normalizedDir = normalizePath(directory); + const uri = vscode.Uri.file(normalizedDir); + + // Try to find an existing repository + let repo = api.getRepository(uri); + if (repo) return repo; + + // Try to open the repository + repo = await api.openRepository(uri); + return repo; +} + +/** + * Normalize a file path + */ +function normalizePath(p: string): string { + let normalized = p.replace(/\\/g, '/'); + if (normalized.startsWith('~')) { + normalized = path.join(os.homedir(), normalized.slice(1)); + } + return normalized; +} + +/** + * Execute a raw git command and return the output + */ +async function execGit(args: string[], cwd: string): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return new Promise((resolve) => { + const normalizedCwd = normalizePath(cwd); + const gitPath = gitApi?.git.path || 'git'; + + const proc = spawn(gitPath, args, { + cwd: normalizedCwd, + shell: process.platform === 'win32', + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }); + + let stdout = ''; + let stderr = ''; + + proc.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + proc.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('close', (exitCode) => { + resolve({ stdout, stderr, exitCode: exitCode ?? 0 }); + }); + + proc.on('error', (error) => { + resolve({ stdout: '', stderr: error.message, exitCode: 1 }); + }); + }); +} + +// ============== Repository Operations ============== + +/** + * Check if a directory is a git repository + */ +export async function checkIsGitRepository(directory: string): Promise { + const result = await execGit(['rev-parse', '--is-inside-work-tree'], directory); + return result.exitCode === 0 && result.stdout.trim() === 'true'; +} + +/** + * Check if a directory is a linked worktree (not the main worktree) + */ +export async function isLinkedWorktree(directory: string): Promise { + const gitDir = await execGit(['rev-parse', '--git-dir'], directory); + const commonDir = await execGit(['rev-parse', '--git-common-dir'], directory); + + if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) { + return false; + } + + const gitDirPath = path.resolve(directory, gitDir.stdout.trim()); + const commonDirPath = path.resolve(directory, commonDir.stdout.trim()); + + return gitDirPath !== commonDirPath; +} + +// ============== Status Operations ============== + +export interface GitStatusFile { + path: string; + index: string; + working_dir: string; +} + +export interface GitStatusResult { + current: string; + tracking: string | null; + ahead: number; + behind: number; + files: GitStatusFile[]; + isClean: boolean; + diffStats?: Record; +} + +/** + * Map VS Code git status to our status codes + */ +function mapStatus(status: Status): string { + // Status enum values + const statusMap: Record = { + 0: 'M', // INDEX_MODIFIED + 1: 'A', // INDEX_ADDED + 2: 'D', // INDEX_DELETED + 3: 'R', // INDEX_RENAMED + 4: 'C', // INDEX_COPIED + 5: 'M', // MODIFIED + 6: 'D', // DELETED + 7: '?', // UNTRACKED + 8: '!', // IGNORED + 9: 'A', // INTENT_TO_ADD + 10: 'R', // INTENT_TO_RENAME + 11: 'T', // TYPE_CHANGED + 12: 'U', // ADDED_BY_US + 13: 'U', // ADDED_BY_THEM + 14: 'U', // DELETED_BY_US + 15: 'U', // DELETED_BY_THEM + 16: 'U', // BOTH_ADDED + 17: 'U', // BOTH_DELETED + 18: 'U', // BOTH_MODIFIED + }; + return statusMap[status] || ' '; +} + +/** + * Get git status for a directory + */ +export async function getGitStatus(directory: string): Promise { + const repo = await getRepository(directory); + + if (!repo) { + // Fallback to raw git + return getGitStatusRaw(directory); + } + + const state = repo.state; + const head = state.HEAD; + + const files: GitStatusFile[] = []; + + // Process index changes (staged) + for (const change of state.indexChanges) { + const relativePath = vscode.workspace.asRelativePath(change.uri, false); + files.push({ + path: relativePath, + index: mapStatus(change.status), + working_dir: ' ', + }); + } + + // Process working tree changes (unstaged) + for (const change of state.workingTreeChanges) { + const relativePath = vscode.workspace.asRelativePath(change.uri, false); + const existing = files.find(f => f.path === relativePath); + if (existing) { + existing.working_dir = mapStatus(change.status); + } else { + files.push({ + path: relativePath, + index: ' ', + working_dir: mapStatus(change.status), + }); + } + } + + return { + current: head?.name || '', + tracking: head?.upstream ? `${head.upstream.remote}/${head.upstream.name}` : null, + ahead: head?.ahead || 0, + behind: head?.behind || 0, + files, + isClean: files.length === 0, + }; +} + +/** + * Fallback: Get git status using raw git commands + */ +async function getGitStatusRaw(directory: string): Promise { + const statusResult = await execGit(['status', '--porcelain=v1', '-b', '-uall'], directory); + + if (statusResult.exitCode !== 0) { + return { + current: '', + tracking: null, + ahead: 0, + behind: 0, + files: [], + isClean: true, + }; + } + + const lines = statusResult.stdout.trim().split('\n').filter(Boolean); + const files: GitStatusFile[] = []; + let current = ''; + let tracking: string | null = null; + let ahead = 0; + let behind = 0; + + for (const line of lines) { + if (line.startsWith('##')) { + // Parse branch info + const branchMatch = line.match(/^## (.+?)(?:\.\.\.(.+?))?(?:\s+\[(.+)\])?$/); + if (branchMatch) { + current = branchMatch[1] || ''; + tracking = branchMatch[2] || null; + const trackingInfo = branchMatch[3] || ''; + const aheadMatch = trackingInfo.match(/ahead (\d+)/); + const behindMatch = trackingInfo.match(/behind (\d+)/); + ahead = aheadMatch ? parseInt(aheadMatch[1], 10) : 0; + behind = behindMatch ? parseInt(behindMatch[1], 10) : 0; + } + } else { + // Parse file status + const index = line[0] || ' '; + const workingDir = line[1] || ' '; + const filePath = line.slice(3).trim(); + files.push({ + path: filePath, + index, + working_dir: workingDir, + }); + } + } + + return { + current, + tracking, + ahead, + behind, + files, + isClean: files.length === 0, + }; +} + +// ============== Branch Operations ============== + +export interface GitBranchDetails { + current: boolean; + name: string; + commit: string; + label: string; + tracking?: string; + ahead?: number; + behind?: number; +} + +export interface GitBranchResult { + all: string[]; + current: string; + branches: Record; +} + +/** + * Get all branches for a directory + */ +export async function getGitBranches(directory: string): Promise { + const repo = await getRepository(directory); + + if (!repo) { + return getGitBranchesRaw(directory); + } + + const state = repo.state; + const currentBranch = state.HEAD?.name || ''; + const branches: Record = {}; + const all: string[] = []; + + // Get local branches + const localRefs = await repo.getBranches({ remote: false }); + for (const ref of localRefs) { + if (ref.name) { + all.push(ref.name); + branches[ref.name] = { + current: ref.name === currentBranch, + name: ref.name, + commit: ref.commit || '', + label: ref.name, + }; + } + } + + // Get remote branches + const remoteRefs = await repo.getBranches({ remote: true }); + for (const ref of remoteRefs) { + if (ref.name) { + const remoteBranchName = `remotes/${ref.name}`; + all.push(remoteBranchName); + branches[remoteBranchName] = { + current: false, + name: remoteBranchName, + commit: ref.commit || '', + label: ref.name, + }; + } + } + + // Add upstream info for HEAD + if (state.HEAD?.name && state.HEAD?.upstream) { + const branchInfo = branches[state.HEAD.name]; + if (branchInfo) { + branchInfo.tracking = `${state.HEAD.upstream.remote}/${state.HEAD.upstream.name}`; + branchInfo.ahead = state.HEAD.ahead; + branchInfo.behind = state.HEAD.behind; + } + } + + return { all, current: currentBranch, branches }; +} + +/** + * Fallback: Get branches using raw git commands + */ +async function getGitBranchesRaw(directory: string): Promise { + const result = await execGit(['branch', '-a', '-v', '--format=%(refname:short)|%(objectname:short)|%(upstream:short)|%(HEAD)'], directory); + + if (result.exitCode !== 0) { + return { all: [], current: '', branches: {} }; + } + + const lines = result.stdout.trim().split('\n').filter(Boolean); + const branches: Record = {}; + const all: string[] = []; + let current = ''; + + for (const line of lines) { + const [name, commit, tracking, head] = line.split('|'); + if (name) { + all.push(name); + const isCurrent = head === '*'; + if (isCurrent) current = name; + + branches[name] = { + current: isCurrent, + name, + commit: commit || '', + label: name.replace(/^remotes\//, ''), + tracking: tracking || undefined, + }; + } + } + + return { all, current, branches }; +} + +/** + * Checkout a branch + */ +export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> { + const repo = await getRepository(directory); + + if (repo) { + try { + await repo.checkout(branch); + return { success: true, branch }; + } catch (error) { + console.error('[GitService] Failed to checkout branch:', error); + } + } + + // Fallback to raw git + const result = await execGit(['checkout', branch], directory); + return { success: result.exitCode === 0, branch }; +} + +/** + * Detach HEAD at current commit + * This allows the current branch to be used in a worktree + */ +export async function detachHead(directory: string): Promise<{ success: boolean; commit: string }> { + // Get current HEAD commit + const headResult = await execGit(['rev-parse', 'HEAD'], directory); + if (headResult.exitCode !== 0) { + return { success: false, commit: '' }; + } + + const commit = headResult.stdout.trim(); + + // Checkout the commit directly to detach HEAD + const result = await execGit(['checkout', '--detach', 'HEAD'], directory); + return { success: result.exitCode === 0, commit }; +} + +/** + * Get the current HEAD branch name (null if detached) + */ +export async function getCurrentBranch(directory: string): Promise { + const repo = await getRepository(directory); + + if (repo) { + const head = repo.state.HEAD; + return head?.name || null; + } + + // Fallback to raw git + const result = await execGit(['symbolic-ref', '--short', 'HEAD'], directory); + if (result.exitCode === 0) { + return result.stdout.trim(); + } + return null; // Detached HEAD +} + +/** + * Create a new branch + */ +export async function createBranch(directory: string, name: string, startPoint?: string): Promise<{ success: boolean; branch: string }> { + const repo = await getRepository(directory); + + if (repo) { + try { + await repo.createBranch(name, false, startPoint); + return { success: true, branch: name }; + } catch (error) { + console.error('[GitService] Failed to create branch:', error); + } + } + + // Fallback to raw git + const args = ['branch', name]; + if (startPoint) args.push(startPoint); + const result = await execGit(args, directory); + return { success: result.exitCode === 0, branch: name }; +} + +/** + * Delete a local branch + */ +export async function deleteGitBranch(directory: string, branch: string, force = false): Promise<{ success: boolean }> { + const repo = await getRepository(directory); + + if (repo) { + try { + await repo.deleteBranch(branch, force); + return { success: true }; + } catch (error) { + console.error('[GitService] Failed to delete branch:', error); + } + } + + // Fallback to raw git + const flag = force ? '-D' : '-d'; + const result = await execGit(['branch', flag, branch], directory); + return { success: result.exitCode === 0 }; +} + +/** + * Delete a remote branch + */ +export async function deleteRemoteBranch(directory: string, branch: string, remote = 'origin'): Promise<{ success: boolean }> { + const result = await execGit(['push', remote, '--delete', branch], directory); + return { success: result.exitCode === 0 }; +} + +// ============== Worktree Operations ============== + +export interface GitWorktreeInfo { + worktree: string; + head?: string; + branch?: string; +} + +/** + * List all worktrees for a repository + */ +export async function listGitWorktrees(directory: string): Promise { + const result = await execGit(['worktree', 'list', '--porcelain'], directory); + + if (result.exitCode !== 0) { + return []; + } + + const worktrees: GitWorktreeInfo[] = []; + let current: Partial = {}; + + for (const line of result.stdout.split('\n')) { + if (line.startsWith('worktree ')) { + if (current.worktree) { + worktrees.push(current as GitWorktreeInfo); + } + current = { worktree: line.slice(9).trim() }; + } else if (line.startsWith('HEAD ')) { + current.head = line.slice(5).trim(); + } else if (line.startsWith('branch ')) { + current.branch = line.slice(7).trim(); + } else if (line === '' && current.worktree) { + worktrees.push(current as GitWorktreeInfo); + current = {}; + } + } + + if (current.worktree) { + worktrees.push(current as GitWorktreeInfo); + } + + return worktrees; +} + +/** + * Get branches that are available for worktree checkout + * (branches not already checked out in any worktree) + */ +export async function getAvailableBranchesForWorktree(directory: string): Promise { + const [branches, worktrees] = await Promise.all([ + getGitBranches(directory), + listGitWorktrees(directory), + ]); + + // Get set of branches already checked out in worktrees + const checkedOutBranches = new Set(); + for (const wt of worktrees) { + if (wt.branch) { + // Normalize branch name (remove refs/heads/ prefix) + const branchName = wt.branch.replace(/^refs\/heads\//, ''); + checkedOutBranches.add(branchName); + } + } + + // Filter out branches that are already checked out + const availableBranches: GitBranchDetails[] = []; + for (const name of branches.all) { + // Skip remote branches for worktree creation + if (name.startsWith('remotes/')) { + continue; + } + + if (!checkedOutBranches.has(name)) { + const details = branches.branches[name]; + if (details) { + availableBranches.push(details); + } + } + } + + return availableBranches; +} + +/** + * Add a new worktree + */ +export async function addGitWorktree( + directory: string, + worktreePath: string, + branch: string, + createBranch = false +): Promise<{ success: boolean; path: string; branch: string }> { + const args = ['worktree', 'add']; + + if (createBranch) { + args.push('-b', branch, worktreePath); + } else { + args.push(worktreePath, branch); + } + + const result = await execGit(args, directory); + + return { + success: result.exitCode === 0, + path: worktreePath, + branch, + }; +} + +/** + * Remove a worktree + */ +export async function removeGitWorktree( + directory: string, + worktreePath: string, + force = false +): Promise<{ success: boolean }> { + const args = ['worktree', 'remove']; + if (force) args.push('--force'); + args.push(worktreePath); + + const result = await execGit(args, directory); + return { success: result.exitCode === 0 }; +} + +// ============== Diff Operations ============== + +/** + * Get diff for a file + */ +export async function getGitDiff( + directory: string, + filePath: string, + staged = false, + contextLines?: number +): Promise<{ diff: string }> { + const args = ['diff']; + if (staged) args.push('--cached'); + if (typeof contextLines === 'number') args.push(`-U${contextLines}`); + args.push('--', filePath); + + const result = await execGit(args, directory); + return { diff: result.stdout }; +} + +/** + * Get file diff with original and modified content + */ +export async function getGitFileDiff( + directory: string, + filePath: string, + staged = false +): Promise<{ original: string; modified: string; path: string }> { + const repo = await getRepository(directory); + + if (repo) { + try { + // For staged files, get content from HEAD + // For unstaged files, get content from index (staged) or HEAD + let original: string; + if (staged) { + original = await repo.show('HEAD', filePath); + } else { + try { + // Try to get from index first + original = await repo.show(':0:' + filePath, filePath); + } catch { + // Fall back to HEAD + original = await repo.show('HEAD', filePath); + } + } + + // Read the current file content + const fileUri = vscode.Uri.file(path.join(directory, filePath)); + const modifiedBytes = await vscode.workspace.fs.readFile(fileUri); + const modified = Buffer.from(modifiedBytes).toString('utf8'); + + return { original, modified, path: filePath }; + } catch (error) { + console.error('[GitService] Failed to get file diff:', error); + } + } + + // Fallback: return empty content + return { original: '', modified: '', path: filePath }; +} + +/** + * Revert a file to its last committed state + */ +export async function revertGitFile(directory: string, filePath: string): Promise { + const repo = await getRepository(directory); + + if (repo) { + try { + await repo.revert([filePath]); + return; + } catch (error) { + console.error('[GitService] Failed to revert via API:', error); + } + } + + // Fallback to raw git + await execGit(['checkout', '--', filePath], directory); +} + +// ============== Commit Operations ============== + +export interface GitCommitResult { + success: boolean; + commit: string; + branch: string; + summary: { + changes: number; + insertions: number; + deletions: number; + }; +} + +/** + * Create a git commit + */ +export async function createGitCommit( + directory: string, + message: string, + options?: { addAll?: boolean; files?: string[] } +): Promise { + const repo = await getRepository(directory); + + if (repo) { + try { + if (options?.addAll) { + await repo.add(['.']); + } else if (options?.files?.length) { + await repo.add(options.files); + } + + await repo.commit(message); + + const head = repo.state.HEAD; + return { + success: true, + commit: head?.commit || '', + branch: head?.name || '', + summary: { changes: 0, insertions: 0, deletions: 0 }, + }; + } catch (error) { + console.error('[GitService] Failed to commit:', error); + } + } + + // Fallback to raw git + if (options?.addAll) { + await execGit(['add', '-A'], directory); + } else if (options?.files?.length) { + await execGit(['add', ...options.files], directory); + } + + const result = await execGit(['commit', '-m', message], directory); + + if (result.exitCode !== 0) { + return { + success: false, + commit: '', + branch: '', + summary: { changes: 0, insertions: 0, deletions: 0 }, + }; + } + + // Get commit info + const hashResult = await execGit(['rev-parse', 'HEAD'], directory); + const branchResult = await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], directory); + + return { + success: true, + commit: hashResult.stdout.trim(), + branch: branchResult.stdout.trim(), + summary: { changes: 0, insertions: 0, deletions: 0 }, + }; +} + +// ============== Remote Operations ============== + +/** + * Convert options to an array of git arguments. + * Supports both array format ['--set-upstream', '--force'] and + * object format { '--set-upstream': null, '--force': true } + */ +function normalizeGitOptions(options?: string[] | Record): string[] { + if (!options) return []; + + if (Array.isArray(options)) { + return options; + } + + // Object format: { '--set-upstream': null, '--force': true, '--remote': 'origin' } + const args: string[] = []; + for (const [key, value] of Object.entries(options)) { + if (value === null || value === true) { + args.push(key); + } else if (value !== false && value !== undefined) { + args.push(key, String(value)); + } + } + return args; +} + +/** + * Check if options contain a specific flag + */ +function hasOption(options: string[] | Record | undefined, flag: string): boolean { + if (!options) return false; + + if (Array.isArray(options)) { + return options.includes(flag); + } + + return flag in options && options[flag] !== false; +} + +/** + * Push to remote + */ +export async function gitPush( + directory: string, + options?: { remote?: string; branch?: string; options?: string[] | Record } +): Promise<{ success: boolean; pushed: Array<{ local: string; remote: string }>; repo: string; ref: unknown }> { + const repo = await getRepository(directory); + const remote = options?.remote || 'origin'; + const branch = options?.branch; + const gitOptions = options?.options; + + // Determine if we should set upstream (default true if no options specified) + const setUpstream = gitOptions + ? hasOption(gitOptions, '--set-upstream') || hasOption(gitOptions, '-u') + : true; + + if (repo) { + try { + await repo.push(remote, branch, setUpstream); + + return { + success: true, + pushed: [{ local: branch || '', remote }], + repo: directory, + ref: null, + }; + } catch (error) { + console.error('[GitService] Failed to push via VS Code API:', error); + } + } + + // Fallback to raw git - use full options here + const args = ['push']; + + // Add normalized options + const normalizedOptions = normalizeGitOptions(gitOptions); + + // If no options provided, default to -u for upstream + if (normalizedOptions.length === 0) { + args.push('-u'); + } else { + args.push(...normalizedOptions); + } + + // Add remote and branch + args.push(remote); + if (branch) args.push(branch); + + const result = await execGit(args, directory); + + return { + success: result.exitCode === 0, + pushed: result.exitCode === 0 ? [{ local: branch || '', remote }] : [], + repo: directory, + ref: null, + }; +} + +/** + * Pull from remote + */ +export async function gitPull( + directory: string, + options?: { remote?: string; branch?: string } +): Promise<{ success: boolean; summary: { changes: number; insertions: number; deletions: number }; files: string[]; insertions: number; deletions: number }> { + const repo = await getRepository(directory); + + if (repo) { + try { + await repo.pull(); + return { + success: true, + summary: { changes: 0, insertions: 0, deletions: 0 }, + files: [], + insertions: 0, + deletions: 0, + }; + } catch (error) { + console.error('[GitService] Failed to pull:', error); + } + } + + // Fallback to raw git + const args = ['pull']; + if (options?.remote) args.push(options.remote); + if (options?.branch) args.push(options.branch); + + const result = await execGit(args, directory); + + return { + success: result.exitCode === 0, + summary: { changes: 0, insertions: 0, deletions: 0 }, + files: [], + insertions: 0, + deletions: 0, + }; +} + +/** + * Fetch from remote + */ +export async function gitFetch( + directory: string, + options?: { remote?: string; branch?: string } +): Promise<{ success: boolean }> { + const repo = await getRepository(directory); + + if (repo) { + try { + await repo.fetch({ remote: options?.remote, ref: options?.branch }); + return { success: true }; + } catch (error) { + console.error('[GitService] Failed to fetch:', error); + } + } + + // Fallback to raw git + const args = ['fetch']; + if (options?.remote) args.push(options.remote); + if (options?.branch) args.push(options.branch); + + const result = await execGit(args, directory); + return { success: result.exitCode === 0 }; +} + +// ============== Log Operations ============== + +export interface GitLogEntry { + hash: string; + date: string; + message: string; + refs: string; + body: string; + author_name: string; + author_email: string; + filesChanged: number; + insertions: number; + deletions: number; +} + +/** + * Get git log + */ +export async function getGitLog( + directory: string, + options?: { maxCount?: number; from?: string; to?: string; file?: string } +): Promise<{ all: GitLogEntry[]; latest: GitLogEntry | null; total: number }> { + const maxCount = options?.maxCount || 50; + const args = [ + 'log', + `--max-count=${maxCount}`, + '--format=%H|%aI|%s|%D|%b|%an|%ae', + '--shortstat', + ]; + + if (options?.from && options?.to) { + args.push(`${options.from}..${options.to}`); + } + + if (options?.file) { + args.push('--', options.file); + } + + const result = await execGit(args, directory); + + if (result.exitCode !== 0) { + return { all: [], latest: null, total: 0 }; + } + + const entries: GitLogEntry[] = []; + const lines = result.stdout.split('\n'); + let current: Partial | null = null; + + for (const line of lines) { + if (line.includes('|') && !line.startsWith(' ')) { + if (current?.hash) { + entries.push(current as GitLogEntry); + } + const parts = line.split('|'); + current = { + hash: parts[0] || '', + date: parts[1] || '', + message: parts[2] || '', + refs: parts[3] || '', + body: parts[4] || '', + author_name: parts[5] || '', + author_email: parts[6] || '', + filesChanged: 0, + insertions: 0, + deletions: 0, + }; + } else if (current && line.includes('file')) { + const statsMatch = line.match(/(\d+)\s+files?\s+changed(?:,\s+(\d+)\s+insertions?)?(?:,\s+(\d+)\s+deletions?)?/); + if (statsMatch) { + current.filesChanged = parseInt(statsMatch[1] || '0', 10); + current.insertions = parseInt(statsMatch[2] || '0', 10); + current.deletions = parseInt(statsMatch[3] || '0', 10); + } + } + } + + if (current?.hash) { + entries.push(current as GitLogEntry); + } + + return { + all: entries, + latest: entries[0] || null, + total: entries.length, + }; +} + +/** + * Get files changed in a commit + */ +export async function getCommitFiles( + directory: string, + hash: string +): Promise<{ files: Array<{ path: string; insertions: number; deletions: number; isBinary: boolean; changeType: string }> }> { + const result = await execGit(['show', '--numstat', '--format=', hash], directory); + + if (result.exitCode !== 0) { + return { files: [] }; + } + + const files: Array<{ path: string; insertions: number; deletions: number; isBinary: boolean; changeType: string }> = []; + + for (const line of result.stdout.trim().split('\n').filter(Boolean)) { + const parts = line.split('\t'); + if (parts.length >= 3) { + const isBinary = parts[0] === '-' && parts[1] === '-'; + files.push({ + path: parts[2] || '', + insertions: isBinary ? 0 : parseInt(parts[0] || '0', 10), + deletions: isBinary ? 0 : parseInt(parts[1] || '0', 10), + isBinary, + changeType: 'M', // Would need additional parsing for actual change type + }); + } + } + + return { files }; +} + +// ============== Git Identity Operations ============== + +export interface GitIdentitySummary { + userName: string | null; + userEmail: string | null; + sshCommand: string | null; +} + +/** + * Get current git identity for a directory + */ +export async function getCurrentGitIdentity(directory: string): Promise { + const repo = await getRepository(directory); + + if (repo) { + try { + const userName = await repo.getConfig('user.name').catch(() => ''); + const userEmail = await repo.getConfig('user.email').catch(() => ''); + const sshCommand = await repo.getConfig('core.sshCommand').catch(() => ''); + + return { + userName: userName || null, + userEmail: userEmail || null, + sshCommand: sshCommand || null, + }; + } catch (error) { + console.error('[GitService] Failed to get identity:', error); + } + } + + // Fallback to raw git + const nameResult = await execGit(['config', 'user.name'], directory); + const emailResult = await execGit(['config', 'user.email'], directory); + const sshResult = await execGit(['config', 'core.sshCommand'], directory); + + return { + userName: nameResult.exitCode === 0 ? nameResult.stdout.trim() : null, + userEmail: emailResult.exitCode === 0 ? emailResult.stdout.trim() : null, + sshCommand: sshResult.exitCode === 0 ? sshResult.stdout.trim() : null, + }; +} + +/** + * Escape an SSH key path for use in core.sshCommand. + * Handles Windows/Unix differences and prevents command injection. + */ +function escapeSshKeyPath(sshKeyPath: string): string { + // Validate: reject paths with characters that could enable injection + // Allow only alphanumeric, path separators, dots, dashes, underscores, spaces, and colons (for Windows drives) + const dangerousChars = /[`$\\!"';&|<>(){}[\]*?#~]/; + if (dangerousChars.test(sshKeyPath)) { + throw new Error(`SSH key path contains invalid characters: ${sshKeyPath}`); + } + + const isWindows = process.platform === 'win32'; + + if (isWindows) { + // On Windows, Git (via MSYS/MinGW) expects Unix-style paths + // Convert backslashes to forward slashes and handle drive letters + let unixPath = sshKeyPath.replace(/\\/g, '/'); + + // Convert "C:/path" to "/c/path" for MSYS compatibility + const driveMatch = unixPath.match(/^([A-Za-z]):\//); + if (driveMatch) { + unixPath = `/${driveMatch[1].toLowerCase()}${unixPath.slice(2)}`; + } + + // Use single quotes for the path (prevents shell interpretation) + return `'${unixPath}'`; + } else { + // On Unix, use single quotes and escape any single quotes in the path + // Single quotes prevent all shell interpretation except for single quotes themselves + const escaped = sshKeyPath.replace(/'/g, "'\\''"); + return `'${escaped}'`; + } +} + +/** + * Build the SSH command string for git config + */ +function buildSshCommand(sshKeyPath: string): string { + const escapedPath = escapeSshKeyPath(sshKeyPath); + return `ssh -i ${escapedPath} -o IdentitiesOnly=yes`; +} + +/** + * Set git identity for a directory + */ +export async function setGitIdentity( + directory: string, + userName: string, + userEmail: string, + sshKey?: string | null +): Promise<{ success: boolean }> { + const repo = await getRepository(directory); + + // Build SSH command once if needed + const sshCommand = sshKey ? buildSshCommand(sshKey) : null; + + if (repo) { + try { + await repo.setConfig('user.name', userName); + await repo.setConfig('user.email', userEmail); + if (sshCommand) { + await repo.setConfig('core.sshCommand', sshCommand); + } + return { success: true }; + } catch (error) { + console.error('[GitService] Failed to set identity:', error); + } + } + + // Fallback to raw git + await execGit(['config', 'user.name', userName], directory); + await execGit(['config', 'user.email', userEmail], directory); + if (sshCommand) { + await execGit(['config', 'core.sshCommand', sshCommand], directory); + } + + return { success: true }; +} + +// ============== Utility Operations ============== + +/** + * Ensure .openchamber is in git exclude + */ +export async function ensureOpenChamberIgnored(directory: string): Promise { + const excludeFile = path.join(directory, '.git', 'info', 'exclude'); + + try { + const uri = vscode.Uri.file(excludeFile); + let content = ''; + + try { + const bytes = await vscode.workspace.fs.readFile(uri); + content = Buffer.from(bytes).toString('utf8'); + } catch { + // File doesn't exist, we'll create it + } + + if (!content.includes('.openchamber')) { + const newContent = content.trimEnd() + '\n.openchamber\n'; + await vscode.workspace.fs.writeFile(uri, Buffer.from(newContent, 'utf8')); + } + } catch (error) { + console.warn('[GitService] Failed to update git exclude:', error); + } +} diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts new file mode 100644 index 00000000..276ccf96 --- /dev/null +++ b/packages/vscode/webview/api/git.ts @@ -0,0 +1,227 @@ +/** + * 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, + GeneratedCommitMessage, + GitWorktreeInfo, + GitAddWorktreePayload, + GitRemoveWorktreePayload, + GitCommitResult, + CreateGitCommitOptions, + GitPushResult, + GitPullResult, + GitLogResponse, + GitLogOptions, + GitCommitFilesResponse, + GitIdentitySummary, + GitIdentityProfile, +} from '@openchamber/ui/lib/api/types'; + +export const createVSCodeGitAPI = (): GitAPI => ({ + checkIsGitRepository: async (directory: string): Promise => { + return sendBridgeMessage('api:git/check', { directory }); + }, + + getGitStatus: async (directory: string): Promise => { + return sendBridgeMessage('api:git/status', { directory }); + }, + + getGitDiff: async (directory: string, options: GetGitDiffOptions): Promise => { + return sendBridgeMessage('api:git/diff', { + directory, + path: options.path, + staged: options.staged, + contextLines: options.contextLines, + }); + }, + + getGitFileDiff: async (directory: string, options: GetGitFileDiffOptions): Promise => { + return sendBridgeMessage('api:git/file-diff', { + directory, + path: options.path, + staged: options.staged, + }); + }, + + revertGitFile: async (directory: string, filePath: string): Promise => { + await sendBridgeMessage('api:git/revert', { directory, path: filePath }); + }, + + isLinkedWorktree: async (directory: string): Promise => { + return sendBridgeMessage('api:git/worktree-type', { directory }); + }, + + getGitBranches: async (directory: string): Promise => { + return sendBridgeMessage('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, + }); + }, + + generateCommitMessage: async (directory: string, files: string[]): Promise<{ message: GeneratedCommitMessage }> => { + // This requires AI integration - stubbed for now + void directory; // Unused for now + void files; // Unused for now + return { + message: { + subject: '', + highlights: [], + }, + }; + }, + + listGitWorktrees: async (directory: string): Promise => { + return sendBridgeMessage('api:git/worktrees', { directory, method: 'GET' }); + }, + + addGitWorktree: async (directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }> => { + return sendBridgeMessage<{ success: boolean; path: string; branch: string }>('api:git/worktrees', { + directory, + method: 'POST', + path: payload.path, + branch: payload.branch, + createBranch: payload.createBranch, + }); + }, + + removeGitWorktree: async (directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }> => { + return sendBridgeMessage<{ success: boolean }>('api:git/worktrees', { + directory, + method: 'DELETE', + path: payload.path, + force: payload.force, + }); + }, + + ensureOpenChamberIgnored: async (directory: string): Promise => { + await sendBridgeMessage('api:git/ignore-openchamber', { directory }); + }, + + createGitCommit: async (directory: string, message: string, options?: CreateGitCommitOptions): Promise => { + return sendBridgeMessage('api:git/commit', { + directory, + message, + addAll: options?.addAll, + files: options?.files, + }); + }, + + gitPush: async (directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record }): Promise => { + return sendBridgeMessage('api:git/push', { + directory, + remote: options?.remote, + branch: options?.branch, + options: options?.options, + }); + }, + + gitPull: async (directory: string, options?: { remote?: string; branch?: string }): Promise => { + return sendBridgeMessage('api:git/pull', { + directory, + remote: options?.remote, + branch: options?.branch, + }); + }, + + 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, + }); + }, + + 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, + }); + }, + + getGitLog: async (directory: string, options?: GitLogOptions): Promise => { + return sendBridgeMessage('api:git/log', { + directory, + maxCount: options?.maxCount, + from: options?.from, + to: options?.to, + file: options?.file, + }); + }, + + getCommitFiles: async (directory: string, hash: string): Promise => { + return sendBridgeMessage('api:git/commit-files', { + directory, + hash, + }); + }, + + getCurrentGitIdentity: async (directory: string): Promise => { + return sendBridgeMessage('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 => { + return []; + }, + + createGitIdentity: async (profile: GitIdentityProfile): Promise => { + return profile; + }, + + updateGitIdentity: async (id: string, profile: GitIdentityProfile): Promise => { + void id; // Unused for now + return profile; + }, + + deleteGitIdentity: async (id: string): Promise => { + void id; // Unused for now + }, +}); diff --git a/packages/vscode/webview/api/index.ts b/packages/vscode/webview/api/index.ts index a5628647..2604edc8 100644 --- a/packages/vscode/webview/api/index.ts +++ b/packages/vscode/webview/api/index.ts @@ -1,9 +1,10 @@ -import type { RuntimeAPIs, TerminalAPI, GitAPI, NotificationsAPI, GitIdentityProfile } from '../../../ui/src/lib/api/types'; +import type { RuntimeAPIs, TerminalAPI, NotificationsAPI } from '@openchamber/ui/lib/api/types'; import { createVSCodeFilesAPI } from './files'; import { createVSCodeSettingsAPI } from './settings'; import { createVSCodePermissionsAPI } from './permissions'; import { createVSCodeToolsAPI } from './tools'; import { createVSCodeEditorAPI } from './editor'; +import { createVSCodeGitAPI } from './git'; // Stub APIs return sensible defaults instead of throwing const createStubTerminalAPI = (): TerminalAPI => ({ @@ -14,37 +15,6 @@ const createStubTerminalAPI = (): TerminalAPI => ({ close: async () => {}, }); -const createStubGitAPI = (): GitAPI => ({ - checkIsGitRepository: async () => false, - getGitStatus: async () => ({ current: '', tracking: null, ahead: 0, behind: 0, files: [], isClean: true }), - getGitDiff: async () => ({ diff: '' }), - getGitFileDiff: async () => ({ original: '', modified: '', path: '' }), - revertGitFile: async () => {}, - isLinkedWorktree: async () => false, - getGitBranches: async () => ({ all: [], current: '', branches: {} }), - deleteGitBranch: async () => ({ success: false }), - deleteRemoteBranch: async () => ({ success: false }), - generateCommitMessage: async () => ({ message: { subject: '', highlights: [] } }), - listGitWorktrees: async () => [], - addGitWorktree: async () => ({ success: false, path: '', branch: '' }), - removeGitWorktree: async () => ({ success: false }), - ensureOpenChamberIgnored: async () => {}, - createGitCommit: async () => ({ success: false, commit: '', branch: '', summary: { changes: 0, insertions: 0, deletions: 0 } }), - gitPush: async () => ({ success: false, pushed: [], repo: '', ref: null }), - gitPull: async () => ({ success: false, summary: { changes: 0, insertions: 0, deletions: 0 }, files: [], insertions: 0, deletions: 0 }), - gitFetch: async () => ({ success: false }), - checkoutBranch: async () => ({ success: false, branch: '' }), - createBranch: async () => ({ success: false, branch: '' }), - getGitLog: async () => ({ all: [], latest: null, total: 0 }), - getCommitFiles: async () => ({ files: [] }), - getCurrentGitIdentity: async () => null, - setGitIdentity: async () => ({ success: false, profile: { id: '', name: '', userName: '', userEmail: '' } }), - getGitIdentities: async () => [], - createGitIdentity: async (profile: GitIdentityProfile) => profile, - updateGitIdentity: async (_id: string, profile: GitIdentityProfile) => profile, - deleteGitIdentity: async () => {}, -}); - const createStubNotificationsAPI = (): NotificationsAPI => ({ notifyAgentCompletion: async () => true, canNotify: () => true, @@ -53,7 +23,7 @@ const createStubNotificationsAPI = (): NotificationsAPI => ({ export const createVSCodeAPIs = (): RuntimeAPIs => ({ runtime: { platform: 'vscode', isDesktop: false, isVSCode: true, label: 'VS Code Extension' }, terminal: createStubTerminalAPI(), - git: createStubGitAPI(), + git: createVSCodeGitAPI(), files: createVSCodeFilesAPI(), settings: createVSCodeSettingsAPI(), permissions: createVSCodePermissionsAPI(),