takt worktreeのBugFix

This commit is contained in:
nrslib 2026-02-03 18:39:35 +09:00
parent 91731981d3
commit fabf4bcd27
5 changed files with 449 additions and 9 deletions

View File

@ -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();
});
});

View File

@ -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);
});
});

View File

@ -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',

View File

@ -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<string, BranchInfo>();
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),
}));

View File

@ -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 */