takt/src/task/worktree.ts

330 lines
9.0 KiB
TypeScript

/**
* Git shared clone management
*
* Creates and removes git shared clones for task isolation.
* Uses `git clone --shared` instead of worktrees so each clone
* has an independent .git directory, preventing Claude Code from
* traversing gitdir back to the main repository.
*/
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 { loadGlobalConfig } from '../config/globalConfig.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 clone */
path: string;
/** Branch name used */
branch: string;
}
/** Branch info from `git branch --list` */
export interface BranchInfo {
branch: string;
commit: string;
}
/** Branch with review metadata */
export interface BranchReviewItem {
info: BranchInfo;
filesChanged: number;
taskSlug: string;
}
/**
* Generate a timestamp string for paths/branches
*/
function generateTimestamp(): string {
return new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
}
/**
* Resolve the clone path based on options and global config.
*
* Priority:
* 1. Custom path in options.worktree (string)
* 2. worktree_dir from config.yaml (if set)
* 3. Default: ../{dir-name}
*/
function resolveClonePath(projectDir: string, options: WorktreeOptions): string {
const timestamp = generateTimestamp();
const slug = slugify(options.taskSlug);
const dirName = slug ? `${timestamp}-${slug}` : timestamp;
if (typeof options.worktree === 'string') {
return path.isAbsolute(options.worktree)
? options.worktree
: path.resolve(projectDir, options.worktree);
}
const globalConfig = loadGlobalConfig();
if (globalConfig.worktreeDir) {
const baseDir = path.isAbsolute(globalConfig.worktreeDir)
? globalConfig.worktreeDir
: path.resolve(projectDir, globalConfig.worktreeDir);
return path.join(baseDir, dirName);
}
return path.join(projectDir, '..', 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 shared clone for a task.
*
* Uses `git clone --shared` to create a lightweight clone with
* an independent .git directory. Then checks out a new branch.
*
* @returns WorktreeResult with path and branch
* @throws Error if git clone creation fails
*/
export function createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult {
const clonePath = resolveClonePath(projectDir, options);
const branch = resolveBranchName(options);
log.info('Creating shared clone', { path: clonePath, branch });
// Ensure parent directory exists
fs.mkdirSync(path.dirname(clonePath), { recursive: true });
// Create shared clone
execFileSync('git', ['clone', '--shared', projectDir, clonePath], {
cwd: projectDir,
stdio: 'pipe',
});
// Checkout branch
if (branchExists(clonePath, branch)) {
execFileSync('git', ['checkout', branch], {
cwd: clonePath,
stdio: 'pipe',
});
} else {
execFileSync('git', ['checkout', '-b', branch], {
cwd: clonePath,
stdio: 'pipe',
});
}
log.info('Shared clone created', { path: clonePath, branch });
return { path: clonePath, branch };
}
/**
* Create a temporary shared clone for an existing branch.
* Used by review/instruct to work on a branch that was previously pushed.
*
* @returns WorktreeResult with path and branch
* @throws Error if git clone creation fails
*/
export function createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult {
const timestamp = generateTimestamp();
const globalConfig = loadGlobalConfig();
let clonePath: string;
if (globalConfig.worktreeDir) {
const baseDir = path.isAbsolute(globalConfig.worktreeDir)
? globalConfig.worktreeDir
: path.resolve(projectDir, globalConfig.worktreeDir);
clonePath = path.join(baseDir, `tmp-${timestamp}`);
} else {
clonePath = path.join(projectDir, '..', `tmp-${timestamp}`);
}
log.info('Creating temp clone for branch', { path: clonePath, branch });
fs.mkdirSync(path.dirname(clonePath), { recursive: true });
execFileSync('git', ['clone', '--shared', projectDir, clonePath], {
cwd: projectDir,
stdio: 'pipe',
});
execFileSync('git', ['checkout', branch], {
cwd: clonePath,
stdio: 'pipe',
});
log.info('Temp clone created', { path: clonePath, branch });
return { path: clonePath, branch };
}
/**
* Remove a clone directory
*/
export function removeClone(clonePath: string): void {
log.info('Removing clone', { path: clonePath });
try {
fs.rmSync(clonePath, { recursive: true, force: true });
log.info('Clone removed', { path: clonePath });
} catch (err) {
log.error('Failed to remove clone', { path: clonePath, error: String(err) });
}
}
// --- Review-related types and helpers ---
const TAKT_BRANCH_PREFIX = 'takt/';
/**
* 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';
}
}
}
}
/**
* List all takt-managed branches.
* Uses `git branch --list 'takt/*'` instead of worktree list.
*/
export function listTaktBranches(projectDir: string): BranchInfo[] {
try {
const output = execFileSync(
'git', ['branch', '--list', 'takt/*', '--format=%(refname:short) %(objectname:short)'],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
);
return parseTaktBranches(output);
} catch (err) {
log.error('Failed to list takt branches', { error: String(err) });
return [];
}
}
/**
* Parse `git branch --list` formatted output into BranchInfo entries.
*/
export function parseTaktBranches(output: string): BranchInfo[] {
const entries: BranchInfo[] = [];
const lines = output.trim().split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Format: "takt/20260128-fix-auth abc1234"
const spaceIdx = trimmed.lastIndexOf(' ');
if (spaceIdx === -1) continue;
const branch = trimmed.slice(0, spaceIdx);
const commit = trimmed.slice(spaceIdx + 1);
if (branch.startsWith(TAKT_BRANCH_PREFIX)) {
entries.push({ branch, commit });
}
}
return entries;
}
/**
* 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 branch list, enriching with diff stats.
*/
export function buildReviewItems(
projectDir: string,
branches: BranchInfo[],
defaultBranch: string,
): BranchReviewItem[] {
return branches.map(br => ({
info: br,
filesChanged: getFilesChanged(projectDir, defaultBranch, br.branch),
taskSlug: extractTaskSlug(br.branch),
}));
}