155 lines
4.4 KiB
TypeScript
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;
|
|
}
|