From b9dfe93d854a94a493162062110d3841d47449cd Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:58:48 +0900 Subject: [PATCH] takt: add-sync-with-root (#325) --- src/__tests__/taskSyncAction.test.ts | 271 ++++++++++++++++++++ src/features/tasks/list/index.ts | 4 + src/features/tasks/list/taskActionTarget.ts | 2 +- src/features/tasks/list/taskActions.ts | 2 + src/features/tasks/list/taskDiffActions.ts | 1 + src/features/tasks/list/taskSyncAction.ts | 115 +++++++++ 6 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/taskSyncAction.test.ts create mode 100644 src/features/tasks/list/taskSyncAction.ts diff --git a/src/__tests__/taskSyncAction.test.ts b/src/__tests__/taskSyncAction.test.ts new file mode 100644 index 0000000..e0bb695 --- /dev/null +++ b/src/__tests__/taskSyncAction.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})); + +vi.mock('../shared/ui/index.js', () => ({ + success: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../shared/utils/index.js', () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), + getErrorMessage: vi.fn((err) => String(err)), +})); + +vi.mock('../features/tasks/execute/taskExecution.js', () => ({ + executeTask: vi.fn(), +})); + +vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({ + determinePiece: vi.fn(), +})); + +vi.mock('../shared/constants.js', () => ({ + DEFAULT_PIECE_NAME: 'default', +})); + +import * as fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { error as logError, success } from '../shared/ui/index.js'; +import { executeTask } from '../features/tasks/execute/taskExecution.js'; +import { determinePiece } from '../features/tasks/execute/selectAndExecute.js'; +import { syncBranchWithRoot } from '../features/tasks/list/taskSyncAction.js'; +import type { TaskListItem } from '../infra/task/index.js'; + +const mockExistsSync = vi.mocked(fs.existsSync); +const mockExecFileSync = vi.mocked(execFileSync); +const mockExecuteTask = vi.mocked(executeTask); +const mockDeterminePiece = vi.mocked(determinePiece); +const mockLogError = vi.mocked(logError); +const mockSuccess = vi.mocked(success); + +function makeTask(overrides: Partial = {}): TaskListItem { + return { + kind: 'completed', + name: 'test-task', + createdAt: '2026-01-01T00:00:00Z', + filePath: '/project/.takt/tasks.yaml', + content: 'Implement feature X', + worktreePath: '/project-worktrees/test-task', + ...overrides, + }; +} + +const PROJECT_DIR = '/project'; + +describe('syncBranchWithRoot', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockExistsSync.mockReturnValue(true); + mockDeterminePiece.mockResolvedValue('default'); + }); + + it('throws when called with a non-task BranchActionTarget', async () => { + const branchTarget = { + info: { branch: 'some-branch', commit: 'abc123' }, + originalInstruction: 'Do something', + }; + + await expect( + syncBranchWithRoot(PROJECT_DIR, branchTarget as never), + ).rejects.toThrow('Sync requires a task target.'); + }); + + it('returns false and logs error when worktreePath is missing', async () => { + const task = makeTask({ worktreePath: undefined }); + + const result = await syncBranchWithRoot(PROJECT_DIR, task); + + expect(result).toBe(false); + expect(mockLogError).toHaveBeenCalledWith( + expect.stringContaining('Worktree directory does not exist'), + ); + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); + + it('returns false and logs error when worktreePath does not exist on disk', async () => { + const task = makeTask(); + mockExistsSync.mockReturnValue(false); + + const result = await syncBranchWithRoot(PROJECT_DIR, task); + + expect(result).toBe(false); + expect(mockLogError).toHaveBeenCalledWith( + expect.stringContaining('Worktree directory does not exist'), + ); + }); + + it('returns false and logs error when git fetch fails', async () => { + const task = makeTask(); + mockExecFileSync.mockImplementationOnce(() => { throw new Error('fetch error'); }); + + const result = await syncBranchWithRoot(PROJECT_DIR, task); + + expect(result).toBe(false); + expect(mockLogError).toHaveBeenCalledWith(expect.stringContaining('Failed to fetch from root')); + expect(mockExecuteTask).not.toHaveBeenCalled(); + }); + + it('returns true and shows "Synced." when merge succeeds without conflicts', async () => { + const task = makeTask(); + mockExecFileSync.mockReturnValue('' as never); + + const result = await syncBranchWithRoot(PROJECT_DIR, task); + + expect(result).toBe(true); + expect(mockSuccess).toHaveBeenCalledWith('Synced.'); + expect(mockExecuteTask).not.toHaveBeenCalled(); + }); + + it('calls executeTask with conflict resolution instruction when merge has conflicts', async () => { + const task = makeTask(); + mockExecFileSync + .mockReturnValueOnce('' as never) + .mockImplementationOnce(() => { throw new Error('CONFLICT'); }); + + mockExecuteTask.mockResolvedValue(true); + + const result = await syncBranchWithRoot(PROJECT_DIR, task); + + expect(result).toBe(true); + expect(mockSuccess).toHaveBeenCalledWith('Conflicts resolved.'); + expect(mockExecuteTask).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: task.worktreePath, + projectCwd: PROJECT_DIR, + pieceIdentifier: 'default', + task: expect.stringContaining('Git merge has stopped due to merge conflicts.'), + }), + ); + }); + + it('includes original task content in conflict resolution instruction', async () => { + const task = makeTask({ content: 'Implement feature X' }); + mockExecFileSync + .mockReturnValueOnce('' as never) + .mockImplementationOnce(() => { throw new Error('CONFLICT'); }); + mockExecuteTask.mockResolvedValue(true); + + await syncBranchWithRoot(PROJECT_DIR, task); + + expect(mockExecuteTask).toHaveBeenCalledWith( + expect.objectContaining({ + task: expect.stringContaining('Implement feature X'), + }), + ); + }); + + it('uses task piece when available for AI resolution', async () => { + const task = makeTask({ data: { piece: 'custom-piece' } }); + mockExecFileSync + .mockReturnValueOnce('' as never) + .mockImplementationOnce(() => { throw new Error('CONFLICT'); }); + mockDeterminePiece.mockResolvedValue('custom-piece'); + mockExecuteTask.mockResolvedValue(true); + + await syncBranchWithRoot(PROJECT_DIR, task); + + expect(mockDeterminePiece).toHaveBeenCalledWith(PROJECT_DIR, 'custom-piece'); + }); + + it('uses DEFAULT_PIECE_NAME when task.data.piece is not set', async () => { + const task = makeTask({ data: undefined }); + mockExecFileSync + .mockReturnValueOnce('' as never) + .mockImplementationOnce(() => { throw new Error('CONFLICT'); }); + mockExecuteTask.mockResolvedValue(true); + + await syncBranchWithRoot(PROJECT_DIR, task); + + expect(mockDeterminePiece).toHaveBeenCalledWith(PROJECT_DIR, 'default'); + }); + + it('aborts merge and returns false when AI resolution fails', async () => { + const task = makeTask(); + mockExecFileSync + .mockReturnValueOnce('' as never) + .mockImplementationOnce(() => { throw new Error('CONFLICT'); }) + .mockReturnValueOnce('' as never); + mockExecuteTask.mockResolvedValue(false); + + const result = await syncBranchWithRoot(PROJECT_DIR, task); + + expect(result).toBe(false); + expect(mockLogError).toHaveBeenCalledWith( + expect.stringContaining('Failed to resolve conflicts'), + ); + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', ['merge', '--abort'], + expect.objectContaining({ cwd: task.worktreePath }), + ); + }); + + it('aborts merge and returns false when determinePiece returns null', async () => { + const task = makeTask(); + mockExecFileSync + .mockReturnValueOnce('' as never) + .mockImplementationOnce(() => { throw new Error('CONFLICT'); }) + .mockReturnValueOnce('' as never); + mockDeterminePiece.mockResolvedValue(null); + + const result = await syncBranchWithRoot(PROJECT_DIR, task); + + expect(result).toBe(false); + expect(mockExecuteTask).not.toHaveBeenCalled(); + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', ['merge', '--abort'], + expect.objectContaining({ cwd: task.worktreePath }), + ); + }); + + it('does not throw when git merge --abort itself fails', async () => { + const task = makeTask(); + mockExecFileSync + .mockReturnValueOnce('' as never) + .mockImplementationOnce(() => { throw new Error('CONFLICT'); }) + .mockImplementationOnce(() => { throw new Error('abort failed'); }); + mockDeterminePiece.mockResolvedValue(null); + + const result = await syncBranchWithRoot(PROJECT_DIR, task); + + expect(result).toBe(false); + }); + + it('fetches from projectDir using local path ref', async () => { + const task = makeTask(); + mockExecFileSync.mockReturnValue('' as never); + + await syncBranchWithRoot(PROJECT_DIR, task); + + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['fetch', PROJECT_DIR, 'HEAD:refs/remotes/root/sync-target'], + expect.objectContaining({ cwd: task.worktreePath }), + ); + }); + + it('passes agentOverrides to executeTask', async () => { + const task = makeTask(); + mockExecFileSync + .mockReturnValueOnce('' as never) + .mockImplementationOnce(() => { throw new Error('CONFLICT'); }); + mockExecuteTask.mockResolvedValue(true); + const options = { provider: 'anthropic' as never }; + + await syncBranchWithRoot(PROJECT_DIR, task, options); + + expect(mockExecuteTask).toHaveBeenCalledWith( + expect.objectContaining({ agentOverrides: options }), + ); + }); +}); diff --git a/src/features/tasks/list/index.ts b/src/features/tasks/list/index.ts index 5ef5cbe..d01c680 100644 --- a/src/features/tasks/list/index.ts +++ b/src/features/tasks/list/index.ts @@ -12,6 +12,7 @@ import { tryMergeBranch, mergeBranch, instructBranch, + syncBranchWithRoot, } from './taskActions.js'; import { deletePendingTask, deleteFailedTask, deleteCompletedTask, deleteAllTasks } from './taskDeleteActions.js'; import { retryFailedTask } from './taskRetryActions.js'; @@ -167,6 +168,9 @@ export async function listTasks( case 'instruct': await instructBranch(cwd, task); break; + case 'sync': + await syncBranchWithRoot(cwd, task, options); + break; case 'try': tryMergeBranch(cwd, task); break; diff --git a/src/features/tasks/list/taskActionTarget.ts b/src/features/tasks/list/taskActionTarget.ts index 2e2f0c4..9f1a160 100644 --- a/src/features/tasks/list/taskActionTarget.ts +++ b/src/features/tasks/list/taskActionTarget.ts @@ -1,6 +1,6 @@ import type { BranchListItem, TaskListItem } from '../../../infra/task/index.js'; -export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete'; +export type ListAction = 'diff' | 'instruct' | 'sync' | 'try' | 'merge' | 'delete'; export type BranchActionTarget = TaskListItem | Pick; diff --git a/src/features/tasks/list/taskActions.ts b/src/features/tasks/list/taskActions.ts index ef94343..655c42f 100644 --- a/src/features/tasks/list/taskActions.ts +++ b/src/features/tasks/list/taskActions.ts @@ -17,3 +17,5 @@ export { } from './taskBranchLifecycleActions.js'; export { instructBranch } from './taskInstructionActions.js'; + +export { syncBranchWithRoot } from './taskSyncAction.js'; diff --git a/src/features/tasks/list/taskDiffActions.ts b/src/features/tasks/list/taskDiffActions.ts index ef9d0cd..13b4886 100644 --- a/src/features/tasks/list/taskDiffActions.ts +++ b/src/features/tasks/list/taskDiffActions.ts @@ -66,6 +66,7 @@ export async function showDiffAndPromptActionForTask( [ { label: 'View diff', value: 'diff', description: 'Show full diff in pager' }, { label: 'Instruct', value: 'instruct', description: 'Craft additional instructions and requeue this task' }, + { label: 'Sync with root', value: 'sync', description: 'Merge root HEAD into worktree branch; auto-resolve conflicts with AI' }, { label: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' }, { label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' }, { label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' }, diff --git a/src/features/tasks/list/taskSyncAction.ts b/src/features/tasks/list/taskSyncAction.ts new file mode 100644 index 0000000..ec14f4f --- /dev/null +++ b/src/features/tasks/list/taskSyncAction.ts @@ -0,0 +1,115 @@ +import * as fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { success, error as logError } from '../../../shared/ui/index.js'; +import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; +import { executeTask } from '../execute/taskExecution.js'; +import { determinePiece } from '../execute/selectAndExecute.js'; +import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; +import { type BranchActionTarget, resolveTargetInstruction } from './taskActionTarget.js'; +import type { TaskExecutionOptions } from '../execute/types.js'; + +const log = createLogger('list-tasks'); + +const SYNC_REF = 'refs/remotes/root/sync-target'; + +function buildConflictResolutionInstruction(originalInstruction: string): string { + return `Git merge has stopped due to merge conflicts. + +Resolve all conflicts to complete the merge: +1. Run \`git status\` to identify conflicted files +2. For each conflicted file, resolve the conflict markers + (<<<<<<< HEAD / ======= / >>>>>>> lines) + Preserve changes that align with the original task intent +3. Stage each resolved file: \`git add \` +4. Complete the merge: \`git commit\` + +Original task: +${originalInstruction}`; +} + +export async function syncBranchWithRoot( + projectDir: string, + target: BranchActionTarget, + options?: TaskExecutionOptions, +): Promise { + if (!('kind' in target)) { + throw new Error('Sync requires a task target.'); + } + + if (!target.worktreePath || !fs.existsSync(target.worktreePath)) { + logError(`Worktree directory does not exist for task: ${target.name}`); + return false; + } + const worktreePath = target.worktreePath; + + // origin is removed in worktrees; pass the project path directly as the remote + try { + execFileSync('git', ['fetch', projectDir, `HEAD:${SYNC_REF}`], { + cwd: worktreePath, + encoding: 'utf-8', + stdio: 'pipe', + }); + log.info('Fetched root HEAD into sync-target ref', { worktreePath, projectDir }); + } catch (err) { + const msg = getErrorMessage(err); + logError(`Failed to fetch from root: ${msg}`); + log.error('git fetch failed', { worktreePath, projectDir, error: msg }); + return false; + } + + try { + execFileSync('git', ['merge', SYNC_REF], { + cwd: worktreePath, + encoding: 'utf-8', + stdio: 'pipe', + }); + success('Synced.'); + log.info('Merge succeeded without conflicts', { worktreePath }); + return true; + } catch (err) { + log.info('Merge conflict detected, attempting AI resolution', { + worktreePath, + error: getErrorMessage(err), + }); + } + + const pieceIdentifier = await determinePiece(projectDir, target.data?.piece ?? DEFAULT_PIECE_NAME); + if (!pieceIdentifier) { + abortMerge(worktreePath); + return false; + } + + const originalInstruction = resolveTargetInstruction(target); + const conflictInstruction = buildConflictResolutionInstruction(originalInstruction); + + const aiSuccess = await executeTask({ + task: conflictInstruction, + cwd: worktreePath, + pieceIdentifier, + projectCwd: projectDir, + agentOverrides: options, + }); + + if (aiSuccess) { + success('Conflicts resolved.'); + log.info('AI conflict resolution succeeded', { worktreePath }); + return true; + } + + abortMerge(worktreePath); + logError('Failed to resolve conflicts. Merge aborted.'); + return false; +} + +function abortMerge(worktreePath: string): void { + try { + execFileSync('git', ['merge', '--abort'], { + cwd: worktreePath, + encoding: 'utf-8', + stdio: 'pipe', + }); + log.info('git merge --abort completed', { worktreePath }); + } catch (err) { + log.error('git merge --abort failed', { worktreePath, error: getErrorMessage(err) }); + } +}