takt: add-sync-with-root (#325)

This commit is contained in:
nrs 2026-02-20 11:58:48 +09:00 committed by GitHub
parent dec77e069e
commit b9dfe93d85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 394 additions and 1 deletions

View File

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

View File

@ -12,6 +12,7 @@ import {
tryMergeBranch, tryMergeBranch,
mergeBranch, mergeBranch,
instructBranch, instructBranch,
syncBranchWithRoot,
} from './taskActions.js'; } from './taskActions.js';
import { deletePendingTask, deleteFailedTask, deleteCompletedTask, deleteAllTasks } from './taskDeleteActions.js'; import { deletePendingTask, deleteFailedTask, deleteCompletedTask, deleteAllTasks } from './taskDeleteActions.js';
import { retryFailedTask } from './taskRetryActions.js'; import { retryFailedTask } from './taskRetryActions.js';
@ -167,6 +168,9 @@ export async function listTasks(
case 'instruct': case 'instruct':
await instructBranch(cwd, task); await instructBranch(cwd, task);
break; break;
case 'sync':
await syncBranchWithRoot(cwd, task, options);
break;
case 'try': case 'try':
tryMergeBranch(cwd, task); tryMergeBranch(cwd, task);
break; break;

View File

@ -1,6 +1,6 @@
import type { BranchListItem, TaskListItem } from '../../../infra/task/index.js'; 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<BranchListItem, 'info' | 'originalInstruction'>; export type BranchActionTarget = TaskListItem | Pick<BranchListItem, 'info' | 'originalInstruction'>;

View File

@ -17,3 +17,5 @@ export {
} from './taskBranchLifecycleActions.js'; } from './taskBranchLifecycleActions.js';
export { instructBranch } from './taskInstructionActions.js'; export { instructBranch } from './taskInstructionActions.js';
export { syncBranchWithRoot } from './taskSyncAction.js';

View File

@ -66,6 +66,7 @@ export async function showDiffAndPromptActionForTask(
[ [
{ label: 'View diff', value: 'diff', description: 'Show full diff in pager' }, { label: 'View diff', value: 'diff', description: 'Show full diff in pager' },
{ label: 'Instruct', value: 'instruct', description: 'Craft additional instructions and requeue this task' }, { 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: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' },
{ label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' }, { label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' },
{ label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' }, { label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' },

View File

@ -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 <file>\`
4. Complete the merge: \`git commit\`
Original task:
${originalInstruction}`;
}
export async function syncBranchWithRoot(
projectDir: string,
target: BranchActionTarget,
options?: TaskExecutionOptions,
): Promise<boolean> {
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) });
}
}