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(),
|
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';
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
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,
|
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';
|
||||||
|
|||||||
@ -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