takt/src/task/clone.ts

224 lines
6.8 KiB
TypeScript

/**
* Git clone lifecycle management
*
* Creates, removes, and tracks git clones for task isolation.
* Uses `git clone --reference --dissociate` so each clone has a fully
* independent .git directory, then removes the origin remote to prevent
* Claude Code SDK from traversing 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('clone');
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;
}
function generateTimestamp(): string {
return new Date().toISOString().replace(/[-:.]/g, '').slice(0, 13);
}
/**
* Resolve the base directory for clones from global config.
* Returns the configured worktree_dir (resolved to absolute), or ../
*/
function resolveCloneBaseDir(projectDir: string): string {
const globalConfig = loadGlobalConfig();
if (globalConfig.worktreeDir) {
return path.isAbsolute(globalConfig.worktreeDir)
? globalConfig.worktreeDir
: path.resolve(projectDir, globalConfig.worktreeDir);
}
return path.join(projectDir, '..');
}
/**
* 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);
}
return path.join(resolveCloneBaseDir(projectDir), dirName);
}
function resolveBranchName(options: WorktreeOptions): string {
if (options.branch) {
return options.branch;
}
const timestamp = generateTimestamp();
const slug = slugify(options.taskSlug);
return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`;
}
function branchExists(projectDir: string, branch: string): boolean {
try {
execFileSync('git', ['rev-parse', '--verify', branch], {
cwd: projectDir,
stdio: 'pipe',
});
return true;
} catch {
return false;
}
}
/**
* Clone a repository and remove origin to isolate from the main repo.
*/
function cloneAndIsolate(projectDir: string, clonePath: string): void {
fs.mkdirSync(path.dirname(clonePath), { recursive: true });
execFileSync('git', ['clone', '--reference', projectDir, '--dissociate', projectDir, clonePath], {
cwd: projectDir,
stdio: 'pipe',
});
execFileSync('git', ['remote', 'remove', 'origin'], {
cwd: clonePath,
stdio: 'pipe',
});
}
/**
* Create a git clone for a task.
*
* Uses `git clone --reference --dissociate` to create an independent clone,
* then removes origin and checks out a new branch.
*/
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 });
cloneAndIsolate(projectDir, clonePath);
if (branchExists(clonePath, branch)) {
execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' });
} else {
execFileSync('git', ['checkout', '-b', branch], { cwd: clonePath, stdio: 'pipe' });
}
saveCloneMeta(projectDir, branch, clonePath);
log.info('Clone created', { path: clonePath, branch });
return { path: clonePath, branch };
}
/**
* Create a temporary clone for an existing branch.
* Used by review/instruct to work on a branch that was previously pushed.
*/
export function createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult {
const timestamp = generateTimestamp();
const clonePath = path.join(resolveCloneBaseDir(projectDir), `tmp-${timestamp}`);
log.info('Creating temp clone for branch', { path: clonePath, branch });
cloneAndIsolate(projectDir, clonePath);
execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' });
saveCloneMeta(projectDir, branch, clonePath);
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) });
}
}
// --- Clone metadata ---
const CLONE_META_DIR = 'clone-meta';
function encodeBranchName(branch: string): string {
return branch.replace(/\//g, '--');
}
function getCloneMetaPath(projectDir: string, branch: string): string {
return path.join(projectDir, '.takt', CLONE_META_DIR, `${encodeBranchName(branch)}.json`);
}
/**
* Save clone metadata (branch → clonePath mapping).
* Used to clean up orphaned clone directories on merge/delete.
*/
export function saveCloneMeta(projectDir: string, branch: string, clonePath: string): void {
const filePath = getCloneMetaPath(projectDir, branch);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ branch, clonePath }));
log.info('Clone meta saved', { branch, clonePath });
}
/**
* Remove clone metadata for a branch.
*/
export function removeCloneMeta(projectDir: string, branch: string): void {
try {
fs.unlinkSync(getCloneMetaPath(projectDir, branch));
log.info('Clone meta removed', { branch });
} catch {
// File may not exist — ignore
}
}
/**
* Clean up an orphaned clone directory associated with a branch.
* Reads metadata, removes clone directory if it still exists, then removes metadata.
*/
export function cleanupOrphanedClone(projectDir: string, branch: string): void {
try {
const raw = fs.readFileSync(getCloneMetaPath(projectDir, branch), 'utf-8');
const meta = JSON.parse(raw) as { clonePath: string };
if (fs.existsSync(meta.clonePath)) {
removeClone(meta.clonePath);
log.info('Orphaned clone cleaned up', { branch, clonePath: meta.clonePath });
}
} catch {
// No metadata or parse error — nothing to clean up
}
removeCloneMeta(projectDir, branch);
}