From fabf4bcd27f6524f10add00945fe4cabff3b3a2f Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 3 Feb 2026 18:39:35 +0900 Subject: [PATCH] =?UTF-8?q?takt=20worktree=E3=81=AEBugFix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/it-worktree-delete.test.ts | 185 +++++++++++++++++++++ src/__tests__/it-worktree-sessions.test.ts | 131 +++++++++++++++ src/features/tasks/list/taskActions.ts | 28 +++- src/infra/task/branchList.ts | 113 ++++++++++++- src/infra/task/types.ts | 1 + 5 files changed, 449 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/it-worktree-delete.test.ts create mode 100644 src/__tests__/it-worktree-sessions.test.ts diff --git a/src/__tests__/it-worktree-delete.test.ts b/src/__tests__/it-worktree-delete.test.ts new file mode 100644 index 0000000..09a3c0c --- /dev/null +++ b/src/__tests__/it-worktree-delete.test.ts @@ -0,0 +1,185 @@ +/** + * Integration test for worktree branch deletion + * + * Tests that worktree branches can be properly deleted, + * including cleanup of worktree directory and session file. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { listTaktBranches } from '../infra/task/branchList.js'; +import { deleteBranch } from '../features/tasks/list/taskActions.js'; + +describe('worktree branch deletion', () => { + let testDir: string; + let worktreeDir: string; + + beforeEach(() => { + // Create temporary git repository + testDir = join(tmpdir(), `takt-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + + // Initialize git repo + execFileSync('git', ['init'], { cwd: testDir }); + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: testDir }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: testDir }); + + // Create initial commit + writeFileSync(join(testDir, 'README.md'), '# Test'); + execFileSync('git', ['add', '.'], { cwd: testDir }); + execFileSync('git', ['commit', '-m', 'Initial commit'], { cwd: testDir }); + + // Create .takt directory structure + const taktDir = join(testDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + mkdirSync(join(taktDir, 'worktree-sessions'), { recursive: true }); + }); + + afterEach(() => { + // Cleanup + if (worktreeDir && existsSync(worktreeDir)) { + rmSync(worktreeDir, { recursive: true, force: true }); + } + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should delete worktree branch and cleanup files', () => { + // Create worktree + const branchSlug = '20260203T1000-test-deletion'; + worktreeDir = join(tmpdir(), branchSlug); + execFileSync('git', ['clone', '--shared', testDir, worktreeDir]); + + const branchName = `takt/${branchSlug}`; + execFileSync('git', ['checkout', '-b', branchName], { cwd: worktreeDir }); + + // Make a change + writeFileSync(join(worktreeDir, 'test.txt'), 'test content'); + execFileSync('git', ['add', 'test.txt'], { cwd: worktreeDir }); + execFileSync('git', ['commit', '-m', 'Test change'], { cwd: worktreeDir }); + + // Create worktree-session file + const resolvedPath = resolve(worktreeDir); + const sessionFilename = resolvedPath.replace(/[/\\:]/g, '-') + '.json'; + const sessionPath = join(testDir, '.takt', 'worktree-sessions', sessionFilename); + const sessionData = { + agentSessions: {}, + updatedAt: new Date().toISOString(), + provider: 'claude', + }; + writeFileSync(sessionPath, JSON.stringify(sessionData, null, 2)); + + // Verify branch is listed + const branchesBefore = listTaktBranches(testDir); + const foundBefore = branchesBefore.find(b => b.branch === branchName); + expect(foundBefore).toBeDefined(); + expect(foundBefore?.worktreePath).toBe(worktreeDir); + + // Verify worktree directory and session file exist + expect(existsSync(worktreeDir)).toBe(true); + expect(existsSync(sessionPath)).toBe(true); + + // Delete branch + const result = deleteBranch(testDir, { + info: foundBefore!, + filesChanged: 1, + taskSlug: branchSlug, + originalInstruction: 'Test instruction', + }); + + // Verify deletion succeeded + expect(result).toBe(true); + + // Verify worktree directory was removed + expect(existsSync(worktreeDir)).toBe(false); + + // Verify session file was removed + expect(existsSync(sessionPath)).toBe(false); + + // Verify branch is no longer listed + const branchesAfter = listTaktBranches(testDir); + const foundAfter = branchesAfter.find(b => b.branch === branchName); + expect(foundAfter).toBeUndefined(); + }); + + it('should handle deletion when worktree directory is already deleted', () => { + // Create worktree + const branchSlug = '20260203T1001-already-deleted'; + worktreeDir = join(tmpdir(), branchSlug); + execFileSync('git', ['clone', '--shared', testDir, worktreeDir]); + + const branchName = `takt/${branchSlug}`; + execFileSync('git', ['checkout', '-b', branchName], { cwd: worktreeDir }); + + // Create worktree-session file + const resolvedPath = resolve(worktreeDir); + const sessionFilename = resolvedPath.replace(/[/\\:]/g, '-') + '.json'; + const sessionPath = join(testDir, '.takt', 'worktree-sessions', sessionFilename); + const sessionData = { + agentSessions: {}, + updatedAt: new Date().toISOString(), + }; + writeFileSync(sessionPath, JSON.stringify(sessionData, null, 2)); + + // Manually delete worktree directory before deletion + rmSync(worktreeDir, { recursive: true, force: true }); + + // Delete branch (should not fail even though worktree is gone) + const result = deleteBranch(testDir, { + info: { + branch: branchName, + commit: 'worktree', + worktreePath: worktreeDir, + }, + filesChanged: 0, + taskSlug: branchSlug, + originalInstruction: 'Test instruction', + }); + + // Verify deletion succeeded + expect(result).toBe(true); + + // Verify session file was still removed + expect(existsSync(sessionPath)).toBe(false); + }); + + it('should delete regular (non-worktree) branches normally', () => { + // Create a regular local branch + const branchName = 'takt/20260203T1002-regular-branch'; + execFileSync('git', ['checkout', '-b', branchName], { cwd: testDir }); + + // Make a change + writeFileSync(join(testDir, 'test.txt'), 'test content'); + execFileSync('git', ['add', 'test.txt'], { cwd: testDir }); + execFileSync('git', ['commit', '-m', 'Test change'], { cwd: testDir }); + + // Switch back to main + execFileSync('git', ['checkout', 'master'], { cwd: testDir }); + + // Verify branch exists + const branchesBefore = listTaktBranches(testDir); + const foundBefore = branchesBefore.find(b => b.branch === branchName); + expect(foundBefore).toBeDefined(); + expect(foundBefore?.worktreePath).toBeUndefined(); + + // Delete branch + const result = deleteBranch(testDir, { + info: foundBefore!, + filesChanged: 1, + taskSlug: '20260203T1002-regular-branch', + originalInstruction: 'Test instruction', + }); + + // Verify deletion succeeded + expect(result).toBe(true); + + // Verify branch is no longer listed + const branchesAfter = listTaktBranches(testDir); + const foundAfter = branchesAfter.find(b => b.branch === branchName); + expect(foundAfter).toBeUndefined(); + }); +}); diff --git a/src/__tests__/it-worktree-sessions.test.ts b/src/__tests__/it-worktree-sessions.test.ts new file mode 100644 index 0000000..f7fa25e --- /dev/null +++ b/src/__tests__/it-worktree-sessions.test.ts @@ -0,0 +1,131 @@ +/** + * Integration test for worktree-sessions recognition in takt list + * + * Tests that branches created in isolated worktrees (shared clones) + * are properly recognized by `takt list` through worktree-sessions tracking. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { listTaktBranches } from '../infra/task/branchList.js'; + +describe('worktree-sessions recognition', () => { + let testDir: string; + let worktreeDir: string; + + beforeEach(() => { + // Create temporary git repository + testDir = join(tmpdir(), `takt-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + + // Initialize git repo + execFileSync('git', ['init'], { cwd: testDir }); + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: testDir }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: testDir }); + + // Create initial commit + writeFileSync(join(testDir, 'README.md'), '# Test'); + execFileSync('git', ['add', '.'], { cwd: testDir }); + execFileSync('git', ['commit', '-m', 'Initial commit'], { cwd: testDir }); + + // Create .takt directory structure + const taktDir = join(testDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + mkdirSync(join(taktDir, 'worktree-sessions'), { recursive: true }); + }); + + afterEach(() => { + // Cleanup + if (worktreeDir && existsSync(worktreeDir)) { + rmSync(worktreeDir, { recursive: true, force: true }); + } + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should recognize branches from worktree-sessions', () => { + // Simulate worktree creation (directory name includes timestamp-slug) + const branchSlug = '20260203T0900-test-feature'; + worktreeDir = join(tmpdir(), branchSlug); + + // Create shared clone + execFileSync('git', ['clone', '--shared', testDir, worktreeDir]); + + // Create and checkout takt branch in worktree + const branchName = `takt/${branchSlug}`; + execFileSync('git', ['checkout', '-b', branchName], { cwd: worktreeDir }); + + // Make a change + writeFileSync(join(worktreeDir, 'test.txt'), 'test content'); + execFileSync('git', ['add', 'test.txt'], { cwd: worktreeDir }); + execFileSync('git', ['commit', '-m', 'Test change'], { cwd: worktreeDir }); + + // Create worktree-session file (using same encoding as encodeWorktreePath) + const resolvedPath = resolve(worktreeDir); + const sessionFilename = resolvedPath.replace(/[/\\:]/g, '-') + '.json'; + const sessionPath = join(testDir, '.takt', 'worktree-sessions', sessionFilename); + const sessionData = { + agentSessions: {}, + updatedAt: new Date().toISOString(), + provider: 'claude', + }; + writeFileSync(sessionPath, JSON.stringify(sessionData, null, 2)); + + // Test: listTaktBranches should find the worktree branch + const branches = listTaktBranches(testDir); + + expect(branches.length).toBeGreaterThan(0); + const found = branches.find(b => b.branch === branchName); + expect(found).toBeDefined(); + expect(found?.worktreePath).toBe(worktreeDir); + }); + + it('should skip worktree-sessions when worktree directory is deleted', () => { + // Create worktree-session file for non-existent directory + worktreeDir = '/nonexistent/path/20260203T0900-test'; + const resolvedPath = resolve(worktreeDir); + const sessionFilename = resolvedPath.replace(/[/\\:]/g, '-') + '.json'; + const sessionPath = join(testDir, '.takt', 'worktree-sessions', sessionFilename); + const sessionData = { + agentSessions: {}, + updatedAt: new Date().toISOString(), + }; + writeFileSync(sessionPath, JSON.stringify(sessionData, null, 2)); + + // Test: listTaktBranches should not include the non-existent worktree + const branches = listTaktBranches(testDir); + + const found = branches.find(b => b.worktreePath === worktreeDir); + expect(found).toBeUndefined(); + }); + + it('should extract correct branch name from session filename', () => { + // Create worktree (directory name includes timestamp-slug) + const branchSlug = '20260203T0851-unify-debug-log'; + worktreeDir = join(tmpdir(), branchSlug); + execFileSync('git', ['clone', '--shared', testDir, worktreeDir]); + + const branchName = `takt/${branchSlug}`; + execFileSync('git', ['checkout', '-b', branchName], { cwd: worktreeDir }); + + // Create session file with proper path encoding + const resolvedPath = resolve(worktreeDir); + const sessionFilename = resolvedPath.replace(/[/\\:]/g, '-') + '.json'; + const sessionPath = join(testDir, '.takt', 'worktree-sessions', sessionFilename); + const sessionData = { + agentSessions: {}, + updatedAt: new Date().toISOString(), + }; + writeFileSync(sessionPath, JSON.stringify(sessionData, null, 2)); + + const branches = listTaktBranches(testDir); + + const found = branches.find(b => b.branch === branchName); + expect(found).toBeDefined(); + expect(found?.worktreePath).toBe(worktreeDir); + }); +}); diff --git a/src/features/tasks/list/taskActions.ts b/src/features/tasks/list/taskActions.ts index 3a9f332..f78df0c 100644 --- a/src/features/tasks/list/taskActions.ts +++ b/src/features/tasks/list/taskActions.ts @@ -6,6 +6,8 @@ */ import { execFileSync, spawnSync } from 'node:child_process'; +import { rmSync, existsSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; import chalk from 'chalk'; import { createTempCloneForBranch, @@ -25,6 +27,7 @@ import { executeTask } from '../execute/taskExecution.js'; import type { TaskExecutionOptions } from '../execute/types.js'; import { listWorkflows, getCurrentWorkflow } from '../../../infra/config/index.js'; import { DEFAULT_WORKFLOW_NAME } from '../../../shared/constants.js'; +import { encodeWorktreePath } from '../../../infra/config/project/sessionStore.js'; const log = createLogger('list-tasks'); @@ -178,11 +181,34 @@ export function mergeBranch(projectDir: string, item: BranchListItem): boolean { /** * Delete a branch (discard changes). + * For worktree branches, removes the worktree directory and session file. */ export function deleteBranch(projectDir: string, item: BranchListItem): boolean { - const { branch } = item.info; + const { branch, worktreePath } = item.info; try { + // If this is a worktree branch, remove the worktree directory and session file + if (worktreePath) { + // Remove worktree directory if it exists + if (existsSync(worktreePath)) { + rmSync(worktreePath, { recursive: true, force: true }); + log.info('Removed worktree directory', { worktreePath }); + } + + // Remove worktree-session file + const encodedPath = encodeWorktreePath(worktreePath); + const sessionFile = join(projectDir, '.takt', 'worktree-sessions', `${encodedPath}.json`); + if (existsSync(sessionFile)) { + unlinkSync(sessionFile); + log.info('Removed worktree-session file', { sessionFile }); + } + + success(`Deleted worktree ${branch}`); + log.info('Worktree branch deleted', { branch, worktreePath }); + return true; + } + + // For regular branches, use git branch -D execFileSync('git', ['branch', '-D', branch], { cwd: projectDir, encoding: 'utf-8', diff --git a/src/infra/task/branchList.ts b/src/infra/task/branchList.ts index f99c88d..0d1c37c 100644 --- a/src/infra/task/branchList.ts +++ b/src/infra/task/branchList.ts @@ -7,6 +7,8 @@ */ import { execFileSync } from 'node:child_process'; +import { readdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; import { createLogger } from '../../shared/utils/index.js'; import type { BranchInfo, BranchListItem } from './types.js'; @@ -49,20 +51,105 @@ export class BranchManager { } } - /** List all takt-managed branches */ + /** List all takt-managed branches (local + remote + worktree-sessions) */ listTaktBranches(projectDir: string): BranchInfo[] { try { - const output = execFileSync( + // Get local branches + const localOutput = execFileSync( 'git', ['branch', '--list', 'takt/*', '--format=%(refname:short) %(objectname:short)'], { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, ); - return BranchManager.parseTaktBranches(output); + const localBranches = BranchManager.parseTaktBranches(localOutput); + + // Get remote branches + const remoteOutput = execFileSync( + 'git', ['branch', '-r', '--list', 'origin/takt/*', '--format=%(refname:short) %(objectname:short)'], + { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, + ); + const remoteBranches = BranchManager.parseTaktBranches(remoteOutput) + .map(info => ({ + ...info, + branch: info.branch.replace(/^origin\//, ''), // Strip origin/ prefix + })); + + // Get branches from worktree-sessions (for isolated worktrees without remote) + const worktreeBranches = this.listWorktreeSessions(projectDir); + + // Merge and deduplicate (local > remote > worktree-sessions) + const branchMap = new Map(); + for (const info of worktreeBranches) { + branchMap.set(info.branch, info); + } + for (const info of remoteBranches) { + branchMap.set(info.branch, info); + } + for (const info of localBranches) { + branchMap.set(info.branch, info); + } + + return Array.from(branchMap.values()); } catch (err) { log.error('Failed to list takt branches', { error: String(err) }); return []; } } + /** List branches from worktree-sessions directory */ + private listWorktreeSessions(projectDir: string): BranchInfo[] { + const sessionsDir = join(projectDir, '.takt', 'worktree-sessions'); + if (!existsSync(sessionsDir)) { + return []; + } + + try { + const files = readdirSync(sessionsDir); + const branches: BranchInfo[] = []; + + for (const file of files) { + if (!file.endsWith('.json')) continue; + + // Extract branch slug from filename using timestamp pattern + // Filename format: -path-to-parent-dir-{timestamp-slug}.json + const nameWithoutExt = file.slice(0, -5); // Remove .json + const match = nameWithoutExt.match(/(\d{8}T\d{4}-.+)$/); + if (!match || match.index === undefined || !match[1]) continue; + + const branchSlug = match[1]; + const branch = `${TAKT_BRANCH_PREFIX}${branchSlug}`; + + // Extract parent directory path (everything before the branch slug) + // Remove trailing dash before converting dashes to slashes + let encodedPath = nameWithoutExt.slice(0, match.index); + if (encodedPath.endsWith('-')) { + encodedPath = encodedPath.slice(0, -1); + } + + // Decode parent directory path (dashes back to slashes) + const parentPath = encodedPath.replace(/-/g, '/'); + + // Construct full worktree path + const worktreePath = join(parentPath, branchSlug); + + // Check if worktree directory still exists + if (!existsSync(worktreePath)) { + continue; // Skip if worktree was deleted + } + + // Use placeholder commit hash (worktree sessions don't track commit) + branches.push({ + branch, + commit: 'worktree', + worktreePath, + }); + } + + return branches; + } catch (err) { + log.error('Failed to list worktree sessions', { error: String(err) }); + return []; + } + } + /** Parse `git branch --list` formatted output into BranchInfo entries */ static parseTaktBranches(output: string): BranchInfo[] { const entries: BranchInfo[] = []; @@ -87,14 +174,24 @@ export class BranchManager { } /** Get the number of files changed between the default branch and a given branch */ - getFilesChanged(cwd: string, defaultBranch: string, branch: string): number { + getFilesChanged(cwd: string, defaultBranch: string, branch: string, worktreePath?: string): number { try { + // If worktreePath is provided, use it for git diff (for worktree-sessions branches) + const gitCwd = worktreePath && existsSync(worktreePath) ? worktreePath : cwd; + + log.debug('getFilesChanged', { gitCwd, defaultBranch, branch, worktreePath }); + const output = execFileSync( 'git', ['diff', '--numstat', `${defaultBranch}...${branch}`], - { cwd, encoding: 'utf-8', stdio: 'pipe' }, + { cwd: gitCwd, encoding: 'utf-8', stdio: 'pipe' }, ); - return output.trim().split('\n').filter(l => l.length > 0).length; - } catch { + + const fileCount = output.trim().split('\n').filter(l => l.length > 0).length; + log.debug('getFilesChanged result', { fileCount, outputLength: output.length }); + + return fileCount; + } catch (err) { + log.error('getFilesChanged failed', { error: String(err), branch, worktreePath }); return 0; } } @@ -144,7 +241,7 @@ export class BranchManager { ): BranchListItem[] { return branches.map(br => ({ info: br, - filesChanged: this.getFilesChanged(projectDir, defaultBranch, br.branch), + filesChanged: this.getFilesChanged(projectDir, defaultBranch, br.branch, br.worktreePath), taskSlug: BranchManager.extractTaskSlug(br.branch), originalInstruction: this.getOriginalInstruction(projectDir, defaultBranch, br.branch), })); diff --git a/src/infra/task/types.ts b/src/infra/task/types.ts index b4f6cdd..1cb038e 100644 --- a/src/infra/task/types.ts +++ b/src/infra/task/types.ts @@ -46,6 +46,7 @@ export interface WorktreeResult { export interface BranchInfo { branch: string; commit: string; + worktreePath?: string; // Path to worktree directory (for worktree-sessions branches) } /** Branch with list metadata */