/** * 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 for cross-platform compatibility */ function normalizePath(p: string): string { let normalized = p; // Handle tilde expansion first (before converting slashes) if (normalized.startsWith('~')) { normalized = path.join(os.homedir(), normalized.slice(1)); } // Convert backslashes to forward slashes for consistent path handling normalized = normalized.replace(/\\/g, '/'); 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, // Note: shell: true is intentionally omitted. Node.js spawn can find // executables in PATH on Windows without shell mode, and using shell mode // can cause issues when the git path contains spaces (e.g., "C:\Program Files\Git\...") 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); } }