284 lines
7.7 KiB
TypeScript
284 lines
7.7 KiB
TypeScript
/**
|
|
* Git worktree management
|
|
*
|
|
* Creates and removes git worktrees for task isolation.
|
|
*/
|
|
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { createLogger } from '../utils/debug.js';
|
|
import { slugify } from '../utils/slug.js';
|
|
import { isPathSafe } from '../config/paths.js';
|
|
|
|
const log = createLogger('worktree');
|
|
|
|
export interface WorktreeOptions {
|
|
/** worktree setting: true = auto path, string = custom path */
|
|
worktree: boolean | string;
|
|
/** Branch name (optional, auto-generated if omitted) */
|
|
branch?: string;
|
|
/** Task slug for auto-generated paths/branches */
|
|
taskSlug: string;
|
|
}
|
|
|
|
export interface WorktreeResult {
|
|
/** Absolute path to the worktree */
|
|
path: string;
|
|
/** Branch name used */
|
|
branch: string;
|
|
}
|
|
|
|
/**
|
|
* Generate a timestamp string for paths/branches
|
|
*/
|
|
function generateTimestamp(): string {
|
|
return new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
|
|
}
|
|
|
|
/**
|
|
* Resolve the worktree path based on options.
|
|
* Validates that the resolved path stays within the project directory.
|
|
*
|
|
* @throws Error if the resolved path escapes projectDir (path traversal)
|
|
*/
|
|
function resolveWorktreePath(projectDir: string, options: WorktreeOptions): string {
|
|
if (typeof options.worktree === 'string') {
|
|
const resolved = path.isAbsolute(options.worktree)
|
|
? options.worktree
|
|
: path.resolve(projectDir, options.worktree);
|
|
|
|
if (!isPathSafe(projectDir, resolved)) {
|
|
throw new Error(`Worktree path escapes project directory: ${options.worktree}`);
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
// worktree: true → .takt/worktrees/{timestamp}-{task-slug}/
|
|
const timestamp = generateTimestamp();
|
|
const slug = slugify(options.taskSlug);
|
|
const dirName = slug ? `${timestamp}-${slug}` : timestamp;
|
|
return path.join(projectDir, '.takt', 'worktrees', dirName);
|
|
}
|
|
|
|
/**
|
|
* Resolve the branch name based on options
|
|
*/
|
|
function resolveBranchName(options: WorktreeOptions): string {
|
|
if (options.branch) {
|
|
return options.branch;
|
|
}
|
|
|
|
// Auto-generate: takt/{timestamp}-{task-slug}
|
|
const timestamp = generateTimestamp();
|
|
const slug = slugify(options.taskSlug);
|
|
return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`;
|
|
}
|
|
|
|
/**
|
|
* Check if a git branch exists
|
|
*/
|
|
function branchExists(projectDir: string, branch: string): boolean {
|
|
try {
|
|
execFileSync('git', ['rev-parse', '--verify', branch], {
|
|
cwd: projectDir,
|
|
stdio: 'pipe',
|
|
});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a git worktree for a task
|
|
*
|
|
* @returns WorktreeResult with path and branch
|
|
* @throws Error if git worktree creation fails
|
|
*/
|
|
export function createWorktree(projectDir: string, options: WorktreeOptions): WorktreeResult {
|
|
const worktreePath = resolveWorktreePath(projectDir, options);
|
|
const branch = resolveBranchName(options);
|
|
|
|
log.info('Creating worktree', { path: worktreePath, branch });
|
|
|
|
// Ensure parent directory exists
|
|
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
|
|
|
// Create worktree (use execFileSync to avoid shell injection)
|
|
if (branchExists(projectDir, branch)) {
|
|
execFileSync('git', ['worktree', 'add', worktreePath, branch], {
|
|
cwd: projectDir,
|
|
stdio: 'pipe',
|
|
});
|
|
} else {
|
|
execFileSync('git', ['worktree', 'add', '-b', branch, worktreePath], {
|
|
cwd: projectDir,
|
|
stdio: 'pipe',
|
|
});
|
|
}
|
|
|
|
log.info('Worktree created', { path: worktreePath, branch });
|
|
|
|
return { path: worktreePath, branch };
|
|
}
|
|
|
|
/**
|
|
* Remove a git worktree
|
|
*/
|
|
export function removeWorktree(projectDir: string, worktreePath: string): void {
|
|
log.info('Removing worktree', { path: worktreePath });
|
|
|
|
try {
|
|
execFileSync('git', ['worktree', 'remove', worktreePath, '--force'], {
|
|
cwd: projectDir,
|
|
stdio: 'pipe',
|
|
});
|
|
log.info('Worktree removed', { path: worktreePath });
|
|
} catch (err) {
|
|
log.error('Failed to remove worktree', { path: worktreePath, error: String(err) });
|
|
}
|
|
}
|
|
|
|
// --- Review-related types and helpers ---
|
|
|
|
const TAKT_BRANCH_PREFIX = 'takt/';
|
|
|
|
/** Parsed worktree entry from git worktree list */
|
|
export interface WorktreeInfo {
|
|
path: string;
|
|
branch: string;
|
|
commit: string;
|
|
}
|
|
|
|
/** Worktree with review metadata */
|
|
export interface WorktreeReviewItem {
|
|
info: WorktreeInfo;
|
|
filesChanged: number;
|
|
taskSlug: string;
|
|
}
|
|
|
|
/**
|
|
* Detect the default branch name (main or master).
|
|
* Falls back to 'main'.
|
|
*/
|
|
export function detectDefaultBranch(cwd: string): string {
|
|
try {
|
|
const ref = execFileSync(
|
|
'git', ['symbolic-ref', 'refs/remotes/origin/HEAD'],
|
|
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
|
|
).trim();
|
|
// ref is like "refs/remotes/origin/main"
|
|
const parts = ref.split('/');
|
|
return parts[parts.length - 1] || 'main';
|
|
} catch {
|
|
// Fallback: check if 'main' or 'master' exists
|
|
try {
|
|
execFileSync('git', ['rev-parse', '--verify', 'main'], {
|
|
cwd, encoding: 'utf-8', stdio: 'pipe',
|
|
});
|
|
return 'main';
|
|
} catch {
|
|
try {
|
|
execFileSync('git', ['rev-parse', '--verify', 'master'], {
|
|
cwd, encoding: 'utf-8', stdio: 'pipe',
|
|
});
|
|
return 'master';
|
|
} catch {
|
|
return 'main';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse `git worktree list --porcelain` output into WorktreeInfo entries.
|
|
* Only includes worktrees on branches with the takt/ prefix.
|
|
*/
|
|
export function parseTaktWorktrees(porcelainOutput: string): WorktreeInfo[] {
|
|
const entries: WorktreeInfo[] = [];
|
|
const blocks = porcelainOutput.trim().split('\n\n');
|
|
|
|
for (const block of blocks) {
|
|
const lines = block.split('\n');
|
|
let wtPath = '';
|
|
let commit = '';
|
|
let branch = '';
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('worktree ')) {
|
|
wtPath = line.slice('worktree '.length);
|
|
} else if (line.startsWith('HEAD ')) {
|
|
commit = line.slice('HEAD '.length);
|
|
} else if (line.startsWith('branch ')) {
|
|
const ref = line.slice('branch '.length);
|
|
branch = ref.replace('refs/heads/', '');
|
|
}
|
|
}
|
|
|
|
if (wtPath && branch.startsWith(TAKT_BRANCH_PREFIX)) {
|
|
entries.push({ path: wtPath, branch, commit });
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
/**
|
|
* List all takt-managed worktrees.
|
|
*/
|
|
export function listTaktWorktrees(projectDir: string): WorktreeInfo[] {
|
|
try {
|
|
const output = execFileSync(
|
|
'git', ['worktree', 'list', '--porcelain'],
|
|
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
|
|
);
|
|
return parseTaktWorktrees(output);
|
|
} catch (err) {
|
|
log.error('Failed to list worktrees', { error: String(err) });
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the number of files changed between the default branch and a given branch.
|
|
*/
|
|
export function getFilesChanged(cwd: string, defaultBranch: string, branch: string): number {
|
|
try {
|
|
const output = execFileSync(
|
|
'git', ['diff', '--numstat', `${defaultBranch}...${branch}`],
|
|
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
|
|
);
|
|
return output.trim().split('\n').filter(l => l.length > 0).length;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract a human-readable task slug from a takt branch name.
|
|
* e.g. "takt/20260128T032800-fix-auth" → "fix-auth"
|
|
*/
|
|
export function extractTaskSlug(branch: string): string {
|
|
const name = branch.replace(TAKT_BRANCH_PREFIX, '');
|
|
// Remove timestamp prefix (format: YYYYMMDDTHHmmss- or similar)
|
|
const withoutTimestamp = name.replace(/^\d{8,}T?\d{0,6}-?/, '');
|
|
return withoutTimestamp || name;
|
|
}
|
|
|
|
/**
|
|
* Build review items from worktree list, enriching with diff stats.
|
|
*/
|
|
export function buildReviewItems(
|
|
projectDir: string,
|
|
worktrees: WorktreeInfo[],
|
|
defaultBranch: string,
|
|
): WorktreeReviewItem[] {
|
|
return worktrees.map(wt => ({
|
|
info: wt,
|
|
filesChanged: getFilesChanged(projectDir, defaultBranch, wt.branch),
|
|
taskSlug: extractTaskSlug(wt.branch),
|
|
}));
|
|
}
|