From 84b5ad7d17010ebec92ea3306fe8bf56933174a3 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:18:47 +0900 Subject: [PATCH] =?UTF-8?q?worktree.ts=20=E3=82=92=20clone.ts=20+=20branch?= =?UTF-8?q?Review.ts=20=E3=81=AB=E5=88=86=E5=89=B2=EF=BC=88300=E8=A1=8C?= =?UTF-8?q?=E8=B6=85=E8=A7=A3=E6=B6=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/cli-worktree.test.ts | 4 +- src/__tests__/getOriginalInstruction.test.ts | 2 +- src/__tests__/reviewTasks.test.ts | 2 +- src/__tests__/taskExecution.test.ts | 5 +- src/cli.ts | 8 +- src/commands/reviewTasks.ts | 19 +- src/commands/taskExecution.ts | 11 +- src/task/branchReview.ts | 175 +++++++++ src/task/clone.ts | 223 +++++++++++ src/task/index.ts | 12 +- src/task/worktree.ts | 379 ------------------- 11 files changed, 441 insertions(+), 399 deletions(-) create mode 100644 src/task/branchReview.ts create mode 100644 src/task/clone.ts delete mode 100644 src/task/worktree.ts diff --git a/src/__tests__/cli-worktree.test.ts b/src/__tests__/cli-worktree.test.ts index 7af064a..8f55185 100644 --- a/src/__tests__/cli-worktree.test.ts +++ b/src/__tests__/cli-worktree.test.ts @@ -10,7 +10,7 @@ vi.mock('../prompt/index.js', () => ({ selectOptionWithDefault: vi.fn(), })); -vi.mock('../task/worktree.js', () => ({ +vi.mock('../task/clone.js', () => ({ createSharedClone: vi.fn(), removeClone: vi.fn(), })); @@ -77,7 +77,7 @@ vi.mock('../constants.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 { info } from '../utils/ui.js'; import { confirmAndCreateWorktree } from '../cli.js'; diff --git a/src/__tests__/getOriginalInstruction.test.ts b/src/__tests__/getOriginalInstruction.test.ts index 0b48c6f..f00cc1d 100644 --- a/src/__tests__/getOriginalInstruction.test.ts +++ b/src/__tests__/getOriginalInstruction.test.ts @@ -12,7 +12,7 @@ vi.mock('node:child_process', () => ({ import { execFileSync } from 'node:child_process'; const mockExecFileSync = vi.mocked(execFileSync); -import { getOriginalInstruction } from '../task/worktree.js'; +import { getOriginalInstruction } from '../task/branchReview.js'; beforeEach(() => { vi.clearAllMocks(); diff --git a/src/__tests__/reviewTasks.test.ts b/src/__tests__/reviewTasks.test.ts index 1e3c76c..383715b 100644 --- a/src/__tests__/reviewTasks.test.ts +++ b/src/__tests__/reviewTasks.test.ts @@ -8,7 +8,7 @@ import { extractTaskSlug, buildReviewItems, type BranchInfo, -} from '../task/worktree.js'; +} from '../task/branchReview.js'; import { isBranchMerged, showFullDiff, type ReviewAction } from '../commands/reviewTasks.js'; describe('parseTaktBranches', () => { diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index 59bc654..1998dbe 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -14,7 +14,7 @@ vi.mock('../task/index.js', () => ({ TaskRunner: vi.fn(), })); -vi.mock('../task/worktree.js', () => ({ +vi.mock('../task/clone.js', () => ({ createSharedClone: vi.fn(), removeClone: vi.fn(), })); @@ -56,7 +56,7 @@ vi.mock('../constants.js', () => ({ DEFAULT_LANGUAGE: 'en', })); -import { createSharedClone } from '../task/worktree.js'; +import { createSharedClone } from '../task/clone.js'; import { summarizeTaskName } from '../task/summarize.js'; import { info } from '../utils/ui.js'; import { resolveTaskExecution } from '../commands/taskExecution.js'; @@ -143,6 +143,7 @@ describe('resolveTaskExecution', () => { execCwd: '/project/../20260128T0504-add-auth', execWorkflow: 'default', isWorktree: true, + branch: 'takt/20260128T0504-add-auth', }); }); diff --git a/src/cli.ts b/src/cli.ts index 8fba1d9..c9d30d8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,7 +36,7 @@ import { } from './commands/index.js'; import { listWorkflows } from './config/workflowLoader.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 { summarizeTaskName } from './task/summarize.js'; import { DEFAULT_WORKFLOW_NAME } from './constants.js'; @@ -46,6 +46,7 @@ const log = createLogger('cli'); export interface WorktreeConfirmationResult { execCwd: string; isWorktree: boolean; + branch?: string; } /** @@ -73,7 +74,7 @@ export async function confirmAndCreateWorktree( }); 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(); @@ -222,7 +223,7 @@ program } // 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 }); const taskSuccess = await executeTask(task, execCwd, selectedWorkflow, cwd); @@ -239,6 +240,7 @@ program // Remove clone after task completion (success or failure) if (isWorktree) { removeClone(execCwd); + if (branch) removeCloneMeta(cwd, branch); } if (!taskSuccess) { diff --git a/src/commands/reviewTasks.ts b/src/commands/reviewTasks.ts index 56d9551..4965a02 100644 --- a/src/commands/reviewTasks.ts +++ b/src/commands/reviewTasks.ts @@ -8,14 +8,18 @@ import { execFileSync, spawnSync } from 'node:child_process'; import chalk from 'chalk'; +import { + createTempCloneForBranch, + removeClone, + removeCloneMeta, + cleanupOrphanedClone, +} from '../task/clone.js'; import { detectDefaultBranch, listTaktBranches, buildReviewItems, - createTempCloneForBranch, - removeClone, type BranchReviewItem, -} from '../task/worktree.js'; +} from '../task/branchReview.js'; import { autoCommitAndPush } from '../task/autoCommit.js'; import { selectOption, confirm, promptInput } from '../prompt/index.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.`); } + // Clean up orphaned clone directory if it still exists + cleanupOrphanedClone(projectDir, branch); + success(`Merged & cleaned up ${branch}`); log.info('Branch merged & cleaned up', { branch, alreadyMerged }); return true; @@ -196,6 +203,9 @@ export function deleteBranch(projectDir: string, item: BranchReviewItem): boolea stdio: 'pipe', }); + // Clean up orphaned clone directory if it still exists + cleanupOrphanedClone(projectDir, branch); + success(`Deleted ${branch}`); log.info('Branch deleted', { branch }); return true; @@ -332,8 +342,9 @@ export async function instructBranch( return taskSuccess; } finally { - // 7. Always remove temp clone + // 7. Always remove temp clone and metadata removeClone(clone.path); + removeCloneMeta(projectDir, branch); } } diff --git a/src/commands/taskExecution.ts b/src/commands/taskExecution.ts index fe71051..e5281e9 100644 --- a/src/commands/taskExecution.ts +++ b/src/commands/taskExecution.ts @@ -4,7 +4,7 @@ import { loadWorkflow, loadGlobalConfig } from '../config/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 { summarizeTaskName } from '../task/summarize.js'; import { @@ -74,7 +74,7 @@ export async function executeAndCompleteTask( const executionLog: string[] = []; 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 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) if (isWorktree) { removeClone(execCwd); + if (branch) removeCloneMeta(cwd, branch); } const taskResult = { @@ -191,7 +192,7 @@ export async function resolveTaskExecution( task: TaskInfo, defaultCwd: string, defaultWorkflow: string -): Promise<{ execCwd: string; execWorkflow: string; isWorktree: boolean }> { +): Promise<{ execCwd: string; execWorkflow: string; isWorktree: boolean; branch?: string }> { const data = task.data; // No structured data: use defaults @@ -201,6 +202,7 @@ export async function resolveTaskExecution( let execCwd = defaultCwd; let isWorktree = false; + let branch: string | undefined; // Handle worktree (now creates a shared clone) if (data.worktree) { @@ -214,6 +216,7 @@ export async function resolveTaskExecution( taskSlug, }); execCwd = result.path; + branch = result.branch; isWorktree = true; info(`Clone created: ${result.path} (branch: ${result.branch})`); } @@ -221,5 +224,5 @@ export async function resolveTaskExecution( // Handle workflow override const execWorkflow = data.workflow || defaultWorkflow; - return { execCwd, execWorkflow, isWorktree }; + return { execCwd, execWorkflow, isWorktree, branch }; } diff --git a/src/task/branchReview.ts b/src/task/branchReview.ts new file mode 100644 index 0000000..74b147f --- /dev/null +++ b/src/task/branchReview.ts @@ -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), + })); +} diff --git a/src/task/clone.ts b/src/task/clone.ts new file mode 100644 index 0000000..b99e1df --- /dev/null +++ b/src/task/clone.ts @@ -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); +} diff --git a/src/task/index.ts b/src/task/index.ts index 5164261..4857468 100644 --- a/src/task/index.ts +++ b/src/task/index.ts @@ -16,16 +16,22 @@ export { createSharedClone, removeClone, createTempCloneForBranch, + saveCloneMeta, + removeCloneMeta, + cleanupOrphanedClone, + type WorktreeOptions, + type WorktreeResult, +} from './clone.js'; +export { detectDefaultBranch, listTaktBranches, parseTaktBranches, getFilesChanged, extractTaskSlug, + getOriginalInstruction, buildReviewItems, - type WorktreeOptions, - type WorktreeResult, type BranchInfo, type BranchReviewItem, -} from './worktree.js'; +} from './branchReview.js'; export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js'; export { TaskWatcher, type TaskWatcherOptions } from './watcher.js'; diff --git a/src/task/worktree.ts b/src/task/worktree.ts deleted file mode 100644 index b228787..0000000 --- a/src/task/worktree.ts +++ /dev/null @@ -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), - })); -}