worktree.ts を clone.ts + branchReview.ts に分割(300行超解消)

This commit is contained in:
nrslib 2026-01-29 13:18:47 +09:00
parent 0cdec9afce
commit 84b5ad7d17
11 changed files with 441 additions and 399 deletions

View File

@ -10,7 +10,7 @@ vi.mock('../prompt/index.js', () => ({
selectOptionWithDefault: vi.fn(), selectOptionWithDefault: vi.fn(),
})); }));
vi.mock('../task/worktree.js', () => ({ vi.mock('../task/clone.js', () => ({
createSharedClone: vi.fn(), createSharedClone: vi.fn(),
removeClone: vi.fn(), removeClone: vi.fn(),
})); }));
@ -77,7 +77,7 @@ vi.mock('../constants.js', () => ({
})); }));
import { confirm } from '../prompt/index.js'; import { confirm } from '../prompt/index.js';
import { createSharedClone } from '../task/worktree.js'; import { createSharedClone } from '../task/clone.js';
import { summarizeTaskName } from '../task/summarize.js'; import { summarizeTaskName } from '../task/summarize.js';
import { info } from '../utils/ui.js'; import { info } from '../utils/ui.js';
import { confirmAndCreateWorktree } from '../cli.js'; import { confirmAndCreateWorktree } from '../cli.js';

View File

@ -12,7 +12,7 @@ vi.mock('node:child_process', () => ({
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
const mockExecFileSync = vi.mocked(execFileSync); const mockExecFileSync = vi.mocked(execFileSync);
import { getOriginalInstruction } from '../task/worktree.js'; import { getOriginalInstruction } from '../task/branchReview.js';
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@ -8,7 +8,7 @@ import {
extractTaskSlug, extractTaskSlug,
buildReviewItems, buildReviewItems,
type BranchInfo, type BranchInfo,
} from '../task/worktree.js'; } from '../task/branchReview.js';
import { isBranchMerged, showFullDiff, type ReviewAction } from '../commands/reviewTasks.js'; import { isBranchMerged, showFullDiff, type ReviewAction } from '../commands/reviewTasks.js';
describe('parseTaktBranches', () => { describe('parseTaktBranches', () => {

View File

@ -14,7 +14,7 @@ vi.mock('../task/index.js', () => ({
TaskRunner: vi.fn(), TaskRunner: vi.fn(),
})); }));
vi.mock('../task/worktree.js', () => ({ vi.mock('../task/clone.js', () => ({
createSharedClone: vi.fn(), createSharedClone: vi.fn(),
removeClone: vi.fn(), removeClone: vi.fn(),
})); }));
@ -56,7 +56,7 @@ vi.mock('../constants.js', () => ({
DEFAULT_LANGUAGE: 'en', DEFAULT_LANGUAGE: 'en',
})); }));
import { createSharedClone } from '../task/worktree.js'; import { createSharedClone } from '../task/clone.js';
import { summarizeTaskName } from '../task/summarize.js'; import { summarizeTaskName } from '../task/summarize.js';
import { info } from '../utils/ui.js'; import { info } from '../utils/ui.js';
import { resolveTaskExecution } from '../commands/taskExecution.js'; import { resolveTaskExecution } from '../commands/taskExecution.js';
@ -143,6 +143,7 @@ describe('resolveTaskExecution', () => {
execCwd: '/project/../20260128T0504-add-auth', execCwd: '/project/../20260128T0504-add-auth',
execWorkflow: 'default', execWorkflow: 'default',
isWorktree: true, isWorktree: true,
branch: 'takt/20260128T0504-add-auth',
}); });
}); });

View File

@ -36,7 +36,7 @@ import {
} from './commands/index.js'; } from './commands/index.js';
import { listWorkflows } from './config/workflowLoader.js'; import { listWorkflows } from './config/workflowLoader.js';
import { selectOptionWithDefault, confirm } from './prompt/index.js'; import { selectOptionWithDefault, confirm } from './prompt/index.js';
import { createSharedClone, removeClone } from './task/worktree.js'; import { createSharedClone, removeClone, removeCloneMeta } from './task/clone.js';
import { autoCommitAndPush } from './task/autoCommit.js'; import { autoCommitAndPush } from './task/autoCommit.js';
import { summarizeTaskName } from './task/summarize.js'; import { summarizeTaskName } from './task/summarize.js';
import { DEFAULT_WORKFLOW_NAME } from './constants.js'; import { DEFAULT_WORKFLOW_NAME } from './constants.js';
@ -46,6 +46,7 @@ const log = createLogger('cli');
export interface WorktreeConfirmationResult { export interface WorktreeConfirmationResult {
execCwd: string; execCwd: string;
isWorktree: boolean; isWorktree: boolean;
branch?: string;
} }
/** /**
@ -73,7 +74,7 @@ export async function confirmAndCreateWorktree(
}); });
info(`Clone created: ${result.path} (branch: ${result.branch})`); info(`Clone created: ${result.path} (branch: ${result.branch})`);
return { execCwd: result.path, isWorktree: true }; return { execCwd: result.path, isWorktree: true, branch: result.branch };
} }
const program = new Command(); const program = new Command();
@ -222,7 +223,7 @@ program
} }
// Ask whether to create a worktree // Ask whether to create a worktree
const { execCwd, isWorktree } = await confirmAndCreateWorktree(cwd, task); const { execCwd, isWorktree, branch } = await confirmAndCreateWorktree(cwd, task);
log.info('Starting task execution', { task, workflow: selectedWorkflow, worktree: isWorktree }); log.info('Starting task execution', { task, workflow: selectedWorkflow, worktree: isWorktree });
const taskSuccess = await executeTask(task, execCwd, selectedWorkflow, cwd); const taskSuccess = await executeTask(task, execCwd, selectedWorkflow, cwd);
@ -239,6 +240,7 @@ program
// Remove clone after task completion (success or failure) // Remove clone after task completion (success or failure)
if (isWorktree) { if (isWorktree) {
removeClone(execCwd); removeClone(execCwd);
if (branch) removeCloneMeta(cwd, branch);
} }
if (!taskSuccess) { if (!taskSuccess) {

View File

@ -8,14 +8,18 @@
import { execFileSync, spawnSync } from 'node:child_process'; import { execFileSync, spawnSync } from 'node:child_process';
import chalk from 'chalk'; import chalk from 'chalk';
import {
createTempCloneForBranch,
removeClone,
removeCloneMeta,
cleanupOrphanedClone,
} from '../task/clone.js';
import { import {
detectDefaultBranch, detectDefaultBranch,
listTaktBranches, listTaktBranches,
buildReviewItems, buildReviewItems,
createTempCloneForBranch,
removeClone,
type BranchReviewItem, type BranchReviewItem,
} from '../task/worktree.js'; } from '../task/branchReview.js';
import { autoCommitAndPush } from '../task/autoCommit.js'; import { autoCommitAndPush } from '../task/autoCommit.js';
import { selectOption, confirm, promptInput } from '../prompt/index.js'; import { selectOption, confirm, promptInput } from '../prompt/index.js';
import { info, success, error as logError, warn } from '../utils/ui.js'; import { info, success, error as logError, warn } from '../utils/ui.js';
@ -169,6 +173,9 @@ export function mergeBranch(projectDir: string, item: BranchReviewItem): boolean
warn(`Could not delete branch ${branch}. You may delete it manually.`); warn(`Could not delete branch ${branch}. You may delete it manually.`);
} }
// Clean up orphaned clone directory if it still exists
cleanupOrphanedClone(projectDir, branch);
success(`Merged & cleaned up ${branch}`); success(`Merged & cleaned up ${branch}`);
log.info('Branch merged & cleaned up', { branch, alreadyMerged }); log.info('Branch merged & cleaned up', { branch, alreadyMerged });
return true; return true;
@ -196,6 +203,9 @@ export function deleteBranch(projectDir: string, item: BranchReviewItem): boolea
stdio: 'pipe', stdio: 'pipe',
}); });
// Clean up orphaned clone directory if it still exists
cleanupOrphanedClone(projectDir, branch);
success(`Deleted ${branch}`); success(`Deleted ${branch}`);
log.info('Branch deleted', { branch }); log.info('Branch deleted', { branch });
return true; return true;
@ -332,8 +342,9 @@ export async function instructBranch(
return taskSuccess; return taskSuccess;
} finally { } finally {
// 7. Always remove temp clone // 7. Always remove temp clone and metadata
removeClone(clone.path); removeClone(clone.path);
removeCloneMeta(projectDir, branch);
} }
} }

View File

@ -4,7 +4,7 @@
import { loadWorkflow, loadGlobalConfig } from '../config/index.js'; import { loadWorkflow, loadGlobalConfig } from '../config/index.js';
import { TaskRunner, type TaskInfo } from '../task/index.js'; import { TaskRunner, type TaskInfo } from '../task/index.js';
import { createSharedClone, removeClone } from '../task/worktree.js'; import { createSharedClone, removeClone, removeCloneMeta } from '../task/clone.js';
import { autoCommitAndPush } from '../task/autoCommit.js'; import { autoCommitAndPush } from '../task/autoCommit.js';
import { summarizeTaskName } from '../task/summarize.js'; import { summarizeTaskName } from '../task/summarize.js';
import { import {
@ -74,7 +74,7 @@ export async function executeAndCompleteTask(
const executionLog: string[] = []; const executionLog: string[] = [];
try { try {
const { execCwd, execWorkflow, isWorktree } = await resolveTaskExecution(task, cwd, workflowName); const { execCwd, execWorkflow, isWorktree, branch } = await resolveTaskExecution(task, cwd, workflowName);
// cwd is always the project root; pass it as projectCwd so reports/sessions go there // cwd is always the project root; pass it as projectCwd so reports/sessions go there
const taskSuccess = await executeTask(task.content, execCwd, execWorkflow, cwd); const taskSuccess = await executeTask(task.content, execCwd, execWorkflow, cwd);
@ -92,6 +92,7 @@ export async function executeAndCompleteTask(
// Remove clone after task completion (success or failure) // Remove clone after task completion (success or failure)
if (isWorktree) { if (isWorktree) {
removeClone(execCwd); removeClone(execCwd);
if (branch) removeCloneMeta(cwd, branch);
} }
const taskResult = { const taskResult = {
@ -191,7 +192,7 @@ export async function resolveTaskExecution(
task: TaskInfo, task: TaskInfo,
defaultCwd: string, defaultCwd: string,
defaultWorkflow: string defaultWorkflow: string
): Promise<{ execCwd: string; execWorkflow: string; isWorktree: boolean }> { ): Promise<{ execCwd: string; execWorkflow: string; isWorktree: boolean; branch?: string }> {
const data = task.data; const data = task.data;
// No structured data: use defaults // No structured data: use defaults
@ -201,6 +202,7 @@ export async function resolveTaskExecution(
let execCwd = defaultCwd; let execCwd = defaultCwd;
let isWorktree = false; let isWorktree = false;
let branch: string | undefined;
// Handle worktree (now creates a shared clone) // Handle worktree (now creates a shared clone)
if (data.worktree) { if (data.worktree) {
@ -214,6 +216,7 @@ export async function resolveTaskExecution(
taskSlug, taskSlug,
}); });
execCwd = result.path; execCwd = result.path;
branch = result.branch;
isWorktree = true; isWorktree = true;
info(`Clone created: ${result.path} (branch: ${result.branch})`); info(`Clone created: ${result.path} (branch: ${result.branch})`);
} }
@ -221,5 +224,5 @@ export async function resolveTaskExecution(
// Handle workflow override // Handle workflow override
const execWorkflow = data.workflow || defaultWorkflow; const execWorkflow = data.workflow || defaultWorkflow;
return { execCwd, execWorkflow, isWorktree }; return { execCwd, execWorkflow, isWorktree, branch };
} }

175
src/task/branchReview.ts Normal file
View File

@ -0,0 +1,175 @@
/**
* Branch review helpers
*
* Functions for listing, parsing, and enriching takt-managed branches
* with metadata (diff stats, original instruction, task slug).
* Used by the /review command.
*/
import { execFileSync } from 'node:child_process';
import { createLogger } from '../utils/debug.js';
const log = createLogger('branchReview');
/** 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;
/** Original task instruction extracted from first commit message */
originalInstruction: string;
}
const TAKT_BRANCH_PREFIX = 'takt/';
/**
* Detect the default branch name (main or master).
* Checks local branch refs directly. 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();
const parts = ref.split('/');
return parts[parts.length - 1] || 'main';
} catch {
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.
*/
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;
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, '');
const withoutTimestamp = name.replace(/^\d{8,}T?\d{0,6}-?/, '');
return withoutTimestamp || name;
}
/**
* Extract the original task instruction from the first commit message on a branch.
*
* The first commit on a takt branch has the format: "takt: {original instruction}".
* Strips the "takt: " prefix and returns the instruction text.
* Returns empty string if extraction fails.
*/
export function getOriginalInstruction(
cwd: string,
defaultBranch: string,
branch: string,
): string {
try {
const output = execFileSync(
'git',
['log', '--format=%s', '--reverse', `${defaultBranch}..${branch}`],
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
).trim();
if (!output) return '';
const firstLine = output.split('\n')[0] || '';
const TAKT_COMMIT_PREFIX = 'takt:';
if (firstLine.startsWith(TAKT_COMMIT_PREFIX)) {
return firstLine.slice(TAKT_COMMIT_PREFIX.length).trim();
}
return firstLine;
} catch {
return '';
}
}
/**
* 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),
originalInstruction: getOriginalInstruction(projectDir, defaultBranch, br.branch),
}));
}

223
src/task/clone.ts Normal file
View File

@ -0,0 +1,223 @@
/**
* 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);
}

View File

@ -16,16 +16,22 @@ export {
createSharedClone, createSharedClone,
removeClone, removeClone,
createTempCloneForBranch, createTempCloneForBranch,
saveCloneMeta,
removeCloneMeta,
cleanupOrphanedClone,
type WorktreeOptions,
type WorktreeResult,
} from './clone.js';
export {
detectDefaultBranch, detectDefaultBranch,
listTaktBranches, listTaktBranches,
parseTaktBranches, parseTaktBranches,
getFilesChanged, getFilesChanged,
extractTaskSlug, extractTaskSlug,
getOriginalInstruction,
buildReviewItems, buildReviewItems,
type WorktreeOptions,
type WorktreeResult,
type BranchInfo, type BranchInfo,
type BranchReviewItem, type BranchReviewItem,
} from './worktree.js'; } from './branchReview.js';
export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js'; export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js';
export { TaskWatcher, type TaskWatcherOptions } from './watcher.js'; export { TaskWatcher, type TaskWatcherOptions } from './watcher.js';

View File

@ -1,379 +0,0 @@
/**
* Git clone management
*
* Creates and removes git clones for task isolation.
* Uses `git clone --reference --dissociate` instead of worktrees so
* each clone has a fully independent .git directory with no alternates
* link, preventing Claude Code 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('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;
/** Original task instruction extracted from first commit message */
originalInstruction: string;
}
/**
* Generate a timestamp string for paths/branches
*/
function generateTimestamp(): string {
return new Date().toISOString().replace(/[-:.]/g, '').slice(0, 13);
}
/**
* 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 independent clone (--reference + --dissociate = no alternates link back)
execFileSync('git', ['clone', '--reference', projectDir, '--dissociate', projectDir, clonePath], {
cwd: projectDir,
stdio: 'pipe',
});
// Remove origin remote so Claude Code SDK won't follow it back to the main repo
execFileSync('git', ['remote', 'remove', 'origin'], {
cwd: clonePath,
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('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', '--reference', projectDir, '--dissociate', projectDir, clonePath], {
cwd: projectDir,
stdio: 'pipe',
});
// Remove origin remote so Claude Code SDK won't follow it back to the main repo
execFileSync('git', ['remote', 'remove', 'origin'], {
cwd: clonePath,
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;
}
/**
* Extract the original task instruction from the first commit message on a branch.
*
* The first commit on a takt branch has the format: "takt: {original instruction}".
* This function retrieves the first commit's message and strips the "takt: " prefix.
* Returns empty string if extraction fails.
*/
export function getOriginalInstruction(
cwd: string,
defaultBranch: string,
branch: string,
): string {
try {
// Get the first commit message on the branch (oldest first)
const output = execFileSync(
'git',
['log', '--format=%s', '--reverse', `${defaultBranch}..${branch}`],
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
).trim();
if (!output) return '';
const firstLine = output.split('\n')[0] || '';
// Strip "takt: " prefix if present
const TAKT_COMMIT_PREFIX = 'takt:';
if (firstLine.startsWith(TAKT_COMMIT_PREFIX)) {
return firstLine.slice(TAKT_COMMIT_PREFIX.length).trim();
}
return firstLine;
} catch {
return '';
}
}
/**
* 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),
originalInstruction: getOriginalInstruction(projectDir, defaultBranch, br.branch),
}));
}