takt/src/utils/worktree.ts
2026-01-26 11:16:05 +09:00

155 lines
4.4 KiB
TypeScript

/**
* Git worktree management utilities for takt
*/
import { execFileSync } from 'node:child_process';
import { join, resolve } from 'node:path';
import { existsSync, mkdirSync } from 'node:fs';
import { createLogger } from './debug.js';
const log = createLogger('worktree');
export interface WorktreeInfo {
path: string;
branch: string;
baseBranch: string;
}
/** Worktree configuration from Planner output */
export interface WorktreeConfig {
baseBranch: string;
branchName: string;
}
/**
* Parse worktree configuration from Planner output
*/
export function parseWorktreeConfig(content: string): WorktreeConfig | null {
// Match worktree: block with baseBranch and branchName
const worktreeMatch = content.match(/worktree:\s*\n\s*baseBranch:\s*(\S+)\s*\n\s*branchName:\s*(\S+)/);
if (worktreeMatch && worktreeMatch[1] && worktreeMatch[2]) {
return {
baseBranch: worktreeMatch[1],
branchName: worktreeMatch[2],
};
}
return null;
}
/**
* Generate a timestamp string for worktree directory
*/
export function generateTimestamp(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${year}${month}${day}-${hours}${minutes}${seconds}`;
}
/**
* Sanitize branch name for use in directory name
*/
export function sanitizeBranchName(branchName: string): string {
return branchName
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 50);
}
/**
* Get the worktrees directory path
*/
export function getWorktreesDir(cwd: string): string {
return join(resolve(cwd), '.takt', 'worktrees');
}
/**
* Generate worktree path
*/
export function getWorktreePath(cwd: string, timestamp: string, branchName: string): string {
const sanitizedBranch = sanitizeBranchName(branchName);
return join(getWorktreesDir(cwd), `${timestamp}-${sanitizedBranch}`);
}
/**
* Create a new git worktree with a new branch
* @param cwd - Current working directory
* @param branchName - Name of the new branch to create
* @param baseBranch - Base branch to create the worktree from (required, determined by Planner)
*/
export function createWorktree(
cwd: string,
branchName: string,
baseBranch: string
): WorktreeInfo {
const timestamp = generateTimestamp();
const worktreePath = getWorktreePath(cwd, timestamp, branchName);
// Ensure worktrees directory exists
const worktreesDir = getWorktreesDir(cwd);
if (!existsSync(worktreesDir)) {
mkdirSync(worktreesDir, { recursive: true });
}
log.info('Creating worktree', { path: worktreePath, branch: branchName, baseBranch });
// Fetch latest from origin
try {
execFileSync('git', ['fetch', 'origin'], { cwd, stdio: 'pipe' });
} catch {
log.debug('Failed to fetch from origin, continuing with local state');
}
// Create worktree with new branch (using execFileSync to prevent command injection)
try {
const baseRef = `origin/${baseBranch}`;
execFileSync('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], {
cwd,
stdio: 'pipe',
});
} catch (e) {
// If origin/base doesn't exist, try local base
log.debug('Failed to create from origin, trying local branch', { error: e });
execFileSync('git', ['worktree', 'add', '-b', branchName, worktreePath, baseBranch], {
cwd,
stdio: 'pipe',
});
}
log.info('Worktree created successfully', { path: worktreePath });
return {
path: worktreePath,
branch: branchName,
baseBranch,
};
}
/**
* Remove a worktree
*/
export function removeWorktree(cwd: string, worktreePath: string): void {
log.info('Removing worktree', { path: worktreePath });
execFileSync('git', ['worktree', 'remove', worktreePath, '--force'], { cwd, stdio: 'pipe' });
}
/**
* List all worktrees
*/
export function listWorktrees(cwd: string): string[] {
const output = execFileSync('git', ['worktree', 'list', '--porcelain'], { cwd, encoding: 'utf-8' });
const paths: string[] = [];
for (const line of output.split('\n')) {
if (line.startsWith('worktree ')) {
paths.push(line.slice('worktree '.length));
}
}
return paths;
}