git-utils.ts
1 /** 2 * Git Utilities - Stateless git command functions 3 * 4 * All functions take explicit full paths and return results. 5 * No state is maintained - the caller is responsible for path resolution. 6 */ 7 8 const { exec } = require('child_process'); 9 const { promisify } = require('util'); 10 const path = require('path'); 11 const fs = require('fs'); 12 13 const execAsync = promisify(exec); 14 15 import { GitStatus } from '../types/dreamnode'; 16 17 // ============================================================================ 18 // REPOSITORY STATUS 19 // ============================================================================ 20 21 /** 22 * Get comprehensive git status for a repository 23 */ 24 export async function getGitStatus(repoPath: string): Promise<GitStatus> { 25 try { 26 const gitDir = path.join(repoPath, '.git'); 27 if (!fs.existsSync(gitDir)) { 28 return { 29 hasUncommittedChanges: false, 30 hasStashedChanges: false, 31 hasUnpushedChanges: false, 32 lastChecked: Date.now() 33 }; 34 } 35 36 // Get current commit hash 37 let commitHash: string | undefined; 38 try { 39 const hashResult = await execAsync('git rev-parse HEAD', { cwd: repoPath }); 40 commitHash = hashResult.stdout.trim(); 41 } catch { 42 // No commits yet 43 } 44 45 // Check for uncommitted changes 46 const statusResult = await execAsync('git status --porcelain', { cwd: repoPath }); 47 const hasUncommittedChanges = statusResult.stdout.trim().length > 0; 48 49 // Check for stashed changes 50 const stashResult = await execAsync('git stash list', { cwd: repoPath }); 51 const hasStashedChanges = stashResult.stdout.trim().length > 0; 52 53 // Check for unpushed commits 54 let hasUnpushedChanges = false; 55 let aheadCount = 0; 56 try { 57 const statusBranchResult = await execAsync('git status --porcelain=v1 --branch', { cwd: repoPath }); 58 const branchLine = statusBranchResult.stdout.split('\n')[0]; 59 const aheadMatch = branchLine.match(/\[ahead (\d+)/); 60 if (aheadMatch) { 61 aheadCount = parseInt(aheadMatch[1], 10); 62 hasUnpushedChanges = aheadCount > 0; 63 } 64 } catch { 65 // No upstream or git error 66 } 67 68 // Build details if any status flags are set 69 let details; 70 if (hasUncommittedChanges || hasStashedChanges || hasUnpushedChanges || commitHash) { 71 const statusLines = statusResult.stdout.trim().split('\n').filter((line: string) => line.length > 0); 72 const staged = statusLines.filter((line: string) => line.charAt(0) !== ' ' && line.charAt(0) !== '?').length; 73 const unstaged = statusLines.filter((line: string) => line.charAt(1) !== ' ').length; 74 const untracked = statusLines.filter((line: string) => line.startsWith('??')).length; 75 const stashCount = hasStashedChanges ? stashResult.stdout.trim().split('\n').length : 0; 76 77 details = { staged, unstaged, untracked, stashCount, aheadCount, commitHash }; 78 } 79 80 return { 81 hasUncommittedChanges, 82 hasStashedChanges, 83 hasUnpushedChanges, 84 lastChecked: Date.now(), 85 details 86 }; 87 88 } catch (error) { 89 console.warn(`git-utils: Failed to check git status for ${repoPath}:`, error); 90 return { 91 hasUncommittedChanges: false, 92 hasStashedChanges: false, 93 hasUnpushedChanges: false, 94 lastChecked: Date.now() 95 }; 96 } 97 } 98 99 /** 100 * Check if a repository has uncommitted changes 101 */ 102 export async function hasUncommittedChanges(repoPath: string): Promise<boolean> { 103 try { 104 const { stdout } = await execAsync('git status --porcelain', { cwd: repoPath }); 105 return stdout.trim().length > 0; 106 } catch (error) { 107 console.error('git-utils: Failed to check git status:', error); 108 return false; 109 } 110 } 111 112 /** 113 * Check if a repository has any stashes 114 */ 115 export async function hasStashes(repoPath: string): Promise<boolean> { 116 try { 117 const { stdout } = await execAsync('git stash list', { cwd: repoPath }); 118 return stdout.trim().length > 0; 119 } catch (error) { 120 console.error('git-utils: Failed to check stashes:', error); 121 return false; 122 } 123 } 124 125 /** 126 * Check if a repository has unpushed commits 127 */ 128 export async function hasUnpushedCommits(repoPath: string): Promise<boolean> { 129 try { 130 const { stdout: branchInfo } = await execAsync('git branch -vv', { cwd: repoPath }); 131 const currentBranchLine = branchInfo.split('\n').find((line: string) => line.startsWith('*')); 132 133 if (!currentBranchLine || !currentBranchLine.includes('[')) { 134 return false; 135 } 136 137 const { stdout: aheadCount } = await execAsync('git rev-list --count @{upstream}..HEAD', { cwd: repoPath }); 138 return parseInt(aheadCount.trim(), 10) > 0; 139 } catch { 140 return false; 141 } 142 } 143 144 /** 145 * Get count of unpushed commits 146 */ 147 export async function getUnpushedCommitCount(repoPath: string): Promise<number> { 148 try { 149 const { stdout } = await execAsync('git rev-list --count @{upstream}..HEAD', { cwd: repoPath }); 150 return parseInt(stdout.trim(), 10) || 0; 151 } catch { 152 return 0; 153 } 154 } 155 156 // ============================================================================ 157 // STASH OPERATIONS 158 // ============================================================================ 159 160 /** 161 * Stash all uncommitted changes with a message 162 */ 163 export async function stashChanges(repoPath: string, message: string = 'InterBrain creator mode'): Promise<void> { 164 try { 165 const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: repoPath }); 166 if (!statusOutput.trim()) { 167 return; // No changes to stash 168 } 169 170 await execAsync('git add -A', { cwd: repoPath }); 171 await execAsync(`git stash push -m "${message}"`, { cwd: repoPath }); 172 } catch (error) { 173 if (error instanceof Error && ( 174 error.message.includes('No local changes') || 175 error.message.includes('No tracked files') 176 )) { 177 return; // Expected - no changes to stash 178 } 179 throw new Error(`Failed to stash changes: ${error instanceof Error ? error.message : 'Unknown error'}`); 180 } 181 } 182 183 /** 184 * Pop the most recent stash if any exists 185 */ 186 export async function popStash(repoPath: string): Promise<void> { 187 try { 188 const { stdout } = await execAsync('git stash list', { cwd: repoPath }); 189 if (!stdout.trim()) { 190 return; // No stashes to pop 191 } 192 193 await execAsync('git stash pop', { cwd: repoPath }); 194 } catch (error) { 195 throw new Error(`Failed to pop stash: ${error instanceof Error ? error.message : 'Unknown error'}`); 196 } 197 } 198 199 // ============================================================================ 200 // COMMIT OPERATIONS 201 // ============================================================================ 202 203 /** 204 * Commit all uncommitted changes with a message 205 * Returns true if changes were committed, false if nothing to commit 206 */ 207 export async function commitAllChanges(repoPath: string, commitMessage: string): Promise<boolean> { 208 try { 209 const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: repoPath }); 210 if (!statusOutput.trim()) { 211 return false; // No changes to commit 212 } 213 214 await execAsync('git add -A', { cwd: repoPath }); 215 await execAsync(`git commit -m "${commitMessage}"`, { cwd: repoPath }); 216 return true; 217 } catch (error) { 218 if (error instanceof Error && ( 219 error.message.includes('nothing to commit') || 220 error.message.includes('no changes added') 221 )) { 222 return false; 223 } 224 throw new Error(`Failed to commit changes: ${error instanceof Error ? error.message : 'Unknown error'}`); 225 } 226 } 227 228 // ============================================================================ 229 // REPOSITORY INITIALIZATION 230 // ============================================================================ 231 232 /** 233 * Initialize a git repository with a template 234 */ 235 export async function initRepo(repoPath: string, templatePath?: string): Promise<void> { 236 try { 237 if (templatePath) { 238 await execAsync(`git init --template="${templatePath}"`, { cwd: repoPath }); 239 } else { 240 await execAsync('git init', { cwd: repoPath }); 241 } 242 } catch (error) { 243 throw new Error(`Failed to init repo: ${error instanceof Error ? error.message : 'Unknown error'}`); 244 } 245 } 246 247 /** 248 * Check if a directory is a git repository 249 */ 250 export function isGitRepo(dirPath: string): boolean { 251 const gitDir = path.join(dirPath, '.git'); 252 return fs.existsSync(gitDir); 253 } 254 255 // ============================================================================ 256 // SHELL/FINDER OPERATIONS 257 // ============================================================================ 258 259 /** 260 * Open repository folder in Finder (macOS) 261 */ 262 export async function openInFinder(repoPath: string): Promise<void> { 263 try { 264 await execAsync(`open "${repoPath}"`); 265 } catch (error) { 266 throw new Error(`Failed to open in Finder: ${error instanceof Error ? error.message : 'Unknown error'}`); 267 } 268 } 269 270 /** 271 * Open terminal at repository folder and optionally run a command 272 */ 273 export async function openInTerminal(repoPath: string, command?: string): Promise<void> { 274 try { 275 const script = command 276 ? ` 277 tell application "Terminal" 278 set newWindow to do script "cd '${repoPath}'" 279 do script "${command}" in newWindow 280 activate 281 end tell 282 ` 283 : ` 284 tell application "Terminal" 285 do script "cd '${repoPath}'" 286 activate 287 end tell 288 `; 289 290 await execAsync(`osascript -e '${script}'`); 291 } catch (error) { 292 throw new Error(`Failed to open in Terminal: ${error instanceof Error ? error.message : 'Unknown error'}`); 293 } 294 } 295 296 /** 297 * Open a new Terminal tab at repoPath and run claude with a prompt. 298 * Uses double-quote shell escaping + stdin piping to osascript to avoid 299 * triple-nested escaping issues (shell prompt → AppleScript string → osascript -e). 300 */ 301 export async function openInTerminalWithPrompt(repoPath: string, prompt: string): Promise<void> { 302 try { 303 // Escape prompt for shell double-quoting (handle \, ", $, `) 304 const shellPrompt = prompt 305 .replace(/\\/g, '\\\\') 306 .replace(/"/g, '\\"') 307 .replace(/\$/g, '\\$') 308 .replace(/`/g, '\\`'); 309 310 // Build the full bash command 311 const bashCommand = `cd '${repoPath}' && claude --dangerously-skip-permissions "${shellPrompt}"`; 312 313 return runBashInNewTerminalTab(bashCommand); 314 } catch (error) { 315 throw new Error(`Failed to open in Terminal with prompt: ${error instanceof Error ? error.message : 'Unknown error'}`); 316 } 317 } 318 319 /** 320 * Open a new Terminal tab and run a raw bash command. 321 * The caller is responsible for all escaping within the bash command itself. 322 * This function only handles the AppleScript escaping layer. 323 */ 324 export async function runBashInNewTerminalTab(bashCommand: string): Promise<void> { 325 // Escape for AppleScript double-quoted string (handle \ and ") 326 const asCommand = bashCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); 327 328 const script = `tell application "Terminal" 329 activate 330 tell application "System Events" to keystroke "t" using command down 331 delay 0.3 332 do script "${asCommand}" in front window 333 end tell`; 334 335 // Pass script via stdin to osascript — avoids a third layer of shell escaping 336 const { spawn } = require('child_process'); 337 await new Promise<void>((resolve, reject) => { 338 const child = spawn('osascript', []); 339 child.stdin.write(script); 340 child.stdin.end(); 341 child.on('close', (code: number) => { 342 if (code === 0) resolve(); 343 else reject(new Error(`osascript exited with code ${code}`)); 344 }); 345 child.on('error', reject); 346 }); 347 } 348 349 // ============================================================================ 350 // BUILD OPERATIONS 351 // ============================================================================ 352 353 // ============================================================================ 354 // RADICLE-AWARE EXECUTION 355 // ============================================================================ 356 357 /** 358 * Get environment with enhanced PATH for Radicle git-remote-rad helper. 359 * This is required for any git commands that interact with rad:// remotes. 360 */ 361 export function getRadicleEnhancedEnv(): Record<string, string> { 362 const process = require('process'); 363 const os = require('os'); 364 const homeDir = os.homedir(); 365 366 const radicleGitHelperPaths = [ 367 `${homeDir}/.radicle/bin`, 368 '/usr/local/bin', 369 '/opt/homebrew/bin' 370 ]; 371 372 const enhancedPath = radicleGitHelperPaths.join(':') + ':' + (process.env.PATH || ''); 373 374 return { 375 ...process.env, 376 PATH: enhancedPath 377 }; 378 } 379 380 /** 381 * Execute a git command with Radicle-enhanced PATH. 382 * Use this for any git operations involving rad:// remotes. 383 */ 384 export async function execWithRadiclePath( 385 command: string, 386 cwd: string, 387 passphrase?: string 388 ): Promise<{ stdout: string; stderr: string }> { 389 const env = getRadicleEnhancedEnv(); 390 if (passphrase) { 391 env.RAD_PASSPHRASE = passphrase; 392 } 393 394 return execAsync(command, { cwd, env }); 395 } 396 397 /** 398 * Push to a Radicle remote with proper environment. 399 * Returns true if push succeeded, false if failed (non-fatal). 400 */ 401 export async function pushToRadicle( 402 repoPath: string, 403 refSpec: string = 'HEAD:main', 404 remoteName: string = 'rad' 405 ): Promise<boolean> { 406 try { 407 await execWithRadiclePath( 408 `git push ${remoteName} ${refSpec}`, 409 repoPath 410 ); 411 return true; 412 } catch (error) { 413 console.warn(`[git-utils] Push to ${remoteName} failed:`, error); 414 return false; 415 } 416 } 417 418 // ============================================================================ 419 // BUILD OPERATIONS 420 // ============================================================================ 421 422 /** 423 * Run npm build in a repository 424 */ 425 export async function runNpmBuild(repoPath: string): Promise<void> { 426 try { 427 // Find node/npm paths 428 let nodePath = 'node'; 429 let npmPath = 'npm'; 430 431 try { 432 const { stdout: nodeStdout } = await execAsync('which node'); 433 nodePath = nodeStdout.trim() || 'node'; 434 } catch { 435 const commonNodePaths = ['/usr/local/bin/node', '/opt/homebrew/bin/node']; 436 for (const testPath of commonNodePaths) { 437 if (fs.existsSync(testPath)) { 438 nodePath = testPath; 439 break; 440 } 441 } 442 } 443 444 try { 445 const { stdout: npmStdout } = await execAsync('which npm'); 446 npmPath = npmStdout.trim() || 'npm'; 447 } catch { 448 const commonPaths = ['/usr/local/bin/npm', '/opt/homebrew/bin/npm']; 449 for (const testPath of commonPaths) { 450 if (fs.existsSync(testPath)) { 451 npmPath = testPath; 452 break; 453 } 454 } 455 } 456 457 const nodeBinDir = nodePath.substring(0, nodePath.lastIndexOf('/')); 458 const enhancedEnv = { 459 ...(globalThis as any).process.env, 460 PATH: `${nodeBinDir}:${(globalThis as any).process.env.PATH || ''}` 461 }; 462 463 await execAsync(`${npmPath} install`, { cwd: repoPath, env: enhancedEnv }); 464 await execAsync(`${npmPath} run build`, { cwd: repoPath, env: enhancedEnv }); 465 } catch (error) { 466 throw new Error(`Failed to build: ${error instanceof Error ? error.message : 'Unknown error'}`); 467 } 468 }