/ src / features / dreamnode / utils / git-utils.ts
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  }