All files / src/git simple-git-branch-manager.ts

21.15% Statements 11/52
20% Branches 2/10
42.85% Functions 3/7
22.44% Lines 11/49

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120                                9x                                                                                                                                                                   1x 1x 1x 1x             7x 7x 7x 6x 13x     1x        
/**
 * SimpleGit BranchManager Adapter
 *
 * Implementation of BranchManager port using simple-git.
 * Performs branch-level operations (create, merge, diff, delete)
 * on project clones without requiring a worktree.
 */
 
import { join } from 'node:path';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { simpleGit } from 'simple-git';
import type { BranchManager } from './branch-manager.js';
import type { MergeResult } from './types.js';
import { createModuleLogger } from '../logger/index.js';
 
const log = createModuleLogger('branch-manager');
 
export class SimpleGitBranchManager implements BranchManager {
  async ensureBranch(repoPath: string, branch: string, baseBranch: string): Promise<void> {
    const git = simpleGit(repoPath);
 
    const exists = await this.branchExists(repoPath, branch);
    if (exists) {
      log.debug({ repoPath, branch }, 'branch already exists');
      return;
    }
 
    await git.branch([branch, baseBranch]);
    log.info({ repoPath, branch, baseBranch }, 'branch created');
  }
 
  async mergeBranch(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeResult> {
    // Use an ephemeral worktree for merge safety
    const tmpPath = mkdtempSync(join(tmpdir(), 'cw-merge-'));
    const repoGit = simpleGit(repoPath);
 
    try {
      // Create ephemeral worktree on target branch
      await repoGit.raw(['worktree', 'add', tmpPath, targetBranch]);
 
      const wtGit = simpleGit(tmpPath);
 
      try {
        await wtGit.merge([sourceBranch, '--no-edit']);
 
        log.info({ repoPath, sourceBranch, targetBranch }, 'merge completed cleanly');
        return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}` };
      } catch (mergeErr) {
        // Check for merge conflicts
        const status = await wtGit.status();
        const conflicts = status.conflicted;
 
        if (conflicts.length > 0) {
          log.warn({ repoPath, sourceBranch, targetBranch, conflicts }, 'merge conflicts detected');
          await wtGit.merge(['--abort']);
          return {
            success: false,
            conflicts,
            message: `Merge conflicts in ${conflicts.length} file(s)`,
          };
        }
 
        // Non-conflict merge failure
        throw mergeErr;
      }
    } finally {
      // Clean up ephemeral worktree
      try {
        await repoGit.raw(['worktree', 'remove', tmpPath, '--force']);
      } catch {
        // Best-effort cleanup — force-remove the directory if worktree remove fails
        try { rmSync(tmpPath, { recursive: true, force: true }); } catch { /* ignore */ }
        try { await repoGit.raw(['worktree', 'prune']); } catch { /* ignore */ }
      }
    }
  }
 
  async diffBranches(repoPath: string, baseBranch: string, headBranch: string): Promise<string> {
    const git = simpleGit(repoPath);
    const diff = await git.diff([`${baseBranch}...${headBranch}`]);
    return diff;
  }
 
  async deleteBranch(repoPath: string, branch: string): Promise<void> {
    const git = simpleGit(repoPath);
    const exists = await this.branchExists(repoPath, branch);
    if (!exists) return;
 
    try {
      await git.deleteLocalBranch(branch, true);
      log.info({ repoPath, branch }, 'branch deleted');
    } catch (err) {
      log.warn({ repoPath, branch, err: err instanceof Error ? err.message : String(err) }, 'failed to delete branch');
    }
  }
 
  async branchExists(repoPath: string, branch: string): Promise<boolean> {
    const git = simpleGit(repoPath);
    try {
      const branches = await git.branchLocal();
      return branches.all.includes(branch);
    } catch {
      return false;
    }
  }
 
  async remoteBranchExists(repoPath: string, branch: string): Promise<boolean> {
    try {
      const git = simpleGit(repoPath);
      const result = await git.branch(['-r']);
      return result.all.some(
        (ref) => ref === `origin/${branch}` || ref === `remotes/origin/${branch}`,
      );
    } catch {
      return false;
    }
  }
}