takt: add-sync-with-root (#325)
This commit is contained in:
parent
dec77e069e
commit
b9dfe93d85
271
src/__tests__/taskSyncAction.test.ts
Normal file
271
src/__tests__/taskSyncAction.test.ts
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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<BranchListItem, 'info' | 'originalInstruction'>;
|
||||
|
||||
|
||||
@ -17,3 +17,5 @@ export {
|
||||
} from './taskBranchLifecycleActions.js';
|
||||
|
||||
export { instructBranch } from './taskInstructionActions.js';
|
||||
|
||||
export { syncBranchWithRoot } from './taskSyncAction.js';
|
||||
|
||||
@ -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' },
|
||||
|
||||
115
src/features/tasks/list/taskSyncAction.ts
Normal file
115
src/features/tasks/list/taskSyncAction.ts
Normal 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) });
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user