worktree.ts を clone.ts + branchReview.ts に分割(300行超解消)
This commit is contained in:
parent
0cdec9afce
commit
84b5ad7d17
@ -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';
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
175
src/task/branchReview.ts
Normal file
175
src/task/branchReview.ts
Normal 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
223
src/task/clone.ts
Normal 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);
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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),
|
||||
}));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user