refactor: sync with root をピースエンジンから単発エージェント呼び出しに変更
- executeTask(フルピースエンジン)→ Provider 抽象経由の単発エージェント呼び出しに簡素化 - Claude 固定の callClaudeCustom → getProvider() による Provider 抽象化 - permissionMode: 'full' → 'edit' + onPermissionRequest で Bash 自動承認 - コンフリクト解決プロンプトをテンプレートファイル(en/ja)に分離 - sync 後に worktree → project → origin の2段階プッシュを追加
This commit is contained in:
parent
a27c55420c
commit
9f15840d63
@ -11,6 +11,9 @@ vi.mock('node:child_process', () => ({
|
|||||||
vi.mock('../shared/ui/index.js', () => ({
|
vi.mock('../shared/ui/index.js', () => ({
|
||||||
success: vi.fn(),
|
success: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
|
StreamDisplay: vi.fn(() => ({
|
||||||
|
createHandler: vi.fn(() => vi.fn()),
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../shared/utils/index.js', () => ({
|
vi.mock('../shared/utils/index.js', () => ({
|
||||||
@ -22,37 +25,52 @@ vi.mock('../shared/utils/index.js', () => ({
|
|||||||
getErrorMessage: vi.fn((err) => String(err)),
|
getErrorMessage: vi.fn((err) => String(err)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../features/tasks/execute/taskExecution.js', () => ({
|
const mockAgentCall = vi.fn();
|
||||||
executeTask: vi.fn(),
|
|
||||||
|
vi.mock('../infra/providers/index.js', () => ({
|
||||||
|
getProvider: vi.fn(() => ({
|
||||||
|
setup: vi.fn(() => ({ call: mockAgentCall })),
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({
|
vi.mock('../infra/config/index.js', () => ({
|
||||||
determinePiece: vi.fn(),
|
getLanguage: vi.fn(() => 'en'),
|
||||||
|
resolveConfigValues: vi.fn(() => ({ provider: 'claude', model: 'sonnet' })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../shared/constants.js', () => ({
|
vi.mock('../infra/github/index.js', () => ({
|
||||||
DEFAULT_PIECE_NAME: 'default',
|
pushBranch: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../shared/prompts/index.js', () => ({
|
||||||
|
loadTemplate: vi.fn((_name: string, _lang: string, vars?: Record<string, string>) => {
|
||||||
|
if (_name === 'sync_conflict_resolver_system_prompt') return 'system-prompt';
|
||||||
|
if (_name === 'sync_conflict_resolver_message') return `message:${vars?.originalInstruction ?? ''}`;
|
||||||
|
return '';
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { error as logError, success } from '../shared/ui/index.js';
|
import { error as logError, success } from '../shared/ui/index.js';
|
||||||
import { executeTask } from '../features/tasks/execute/taskExecution.js';
|
import { pushBranch } from '../infra/github/index.js';
|
||||||
import { determinePiece } from '../features/tasks/execute/selectAndExecute.js';
|
import { getProvider } from '../infra/providers/index.js';
|
||||||
import { syncBranchWithRoot } from '../features/tasks/list/taskSyncAction.js';
|
import { syncBranchWithRoot } from '../features/tasks/list/taskSyncAction.js';
|
||||||
import type { TaskListItem } from '../infra/task/index.js';
|
import type { TaskListItem } from '../infra/task/index.js';
|
||||||
|
import type { AgentResponse } from '../core/models/index.js';
|
||||||
|
|
||||||
const mockExistsSync = vi.mocked(fs.existsSync);
|
const mockExistsSync = vi.mocked(fs.existsSync);
|
||||||
const mockExecFileSync = vi.mocked(execFileSync);
|
const mockExecFileSync = vi.mocked(execFileSync);
|
||||||
const mockExecuteTask = vi.mocked(executeTask);
|
|
||||||
const mockDeterminePiece = vi.mocked(determinePiece);
|
|
||||||
const mockLogError = vi.mocked(logError);
|
const mockLogError = vi.mocked(logError);
|
||||||
const mockSuccess = vi.mocked(success);
|
const mockSuccess = vi.mocked(success);
|
||||||
|
const mockPushBranch = vi.mocked(pushBranch);
|
||||||
|
const mockGetProvider = vi.mocked(getProvider);
|
||||||
|
|
||||||
function makeTask(overrides: Partial<TaskListItem> = {}): TaskListItem {
|
function makeTask(overrides: Partial<TaskListItem> = {}): TaskListItem {
|
||||||
return {
|
return {
|
||||||
kind: 'completed',
|
kind: 'completed',
|
||||||
name: 'test-task',
|
name: 'test-task',
|
||||||
|
branch: 'task/test-task',
|
||||||
createdAt: '2026-01-01T00:00:00Z',
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
filePath: '/project/.takt/tasks.yaml',
|
filePath: '/project/.takt/tasks.yaml',
|
||||||
content: 'Implement feature X',
|
content: 'Implement feature X',
|
||||||
@ -61,13 +79,23 @@ function makeTask(overrides: Partial<TaskListItem> = {}): TaskListItem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeAgentResponse(overrides: Partial<AgentResponse> = {}): AgentResponse {
|
||||||
|
return {
|
||||||
|
persona: 'conflict-resolver',
|
||||||
|
status: 'done',
|
||||||
|
content: 'Conflicts resolved',
|
||||||
|
timestamp: new Date(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const PROJECT_DIR = '/project';
|
const PROJECT_DIR = '/project';
|
||||||
|
|
||||||
describe('syncBranchWithRoot', () => {
|
describe('syncBranchWithRoot', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockExistsSync.mockReturnValue(true);
|
mockExistsSync.mockReturnValue(true);
|
||||||
mockDeterminePiece.mockResolvedValue('default');
|
mockAgentCall.mockResolvedValue(makeAgentResponse());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when called with a non-task BranchActionTarget', async () => {
|
it('throws when called with a non-task BranchActionTarget', async () => {
|
||||||
@ -113,90 +141,57 @@ describe('syncBranchWithRoot', () => {
|
|||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
expect(mockLogError).toHaveBeenCalledWith(expect.stringContaining('Failed to fetch from root'));
|
expect(mockLogError).toHaveBeenCalledWith(expect.stringContaining('Failed to fetch from root'));
|
||||||
expect(mockExecuteTask).not.toHaveBeenCalled();
|
expect(mockAgentCall).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true and shows "Synced." when merge succeeds without conflicts', async () => {
|
it('returns true and pushes when merge succeeds without conflicts', async () => {
|
||||||
const task = makeTask();
|
const task = makeTask();
|
||||||
mockExecFileSync.mockReturnValue('' as never);
|
mockExecFileSync.mockReturnValue('' as never);
|
||||||
|
|
||||||
const result = await syncBranchWithRoot(PROJECT_DIR, task);
|
const result = await syncBranchWithRoot(PROJECT_DIR, task);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(mockSuccess).toHaveBeenCalledWith('Synced.');
|
expect(mockSuccess).toHaveBeenCalledWith('Synced & pushed.');
|
||||||
expect(mockExecuteTask).not.toHaveBeenCalled();
|
expect(mockAgentCall).not.toHaveBeenCalled();
|
||||||
|
// worktree → project push
|
||||||
|
expect(mockExecFileSync).toHaveBeenCalledWith(
|
||||||
|
'git', ['push', PROJECT_DIR, 'HEAD'],
|
||||||
|
expect.objectContaining({ cwd: task.worktreePath }),
|
||||||
|
);
|
||||||
|
// project → origin push
|
||||||
|
expect(mockPushBranch).toHaveBeenCalledWith(PROJECT_DIR, 'task/test-task');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls executeTask with conflict resolution instruction when merge has conflicts', async () => {
|
it('calls provider agent when merge has conflicts', async () => {
|
||||||
const task = makeTask();
|
const task = makeTask();
|
||||||
mockExecFileSync
|
mockExecFileSync
|
||||||
.mockReturnValueOnce('' as never)
|
.mockReturnValueOnce('' as never)
|
||||||
.mockImplementationOnce(() => { throw new Error('CONFLICT'); });
|
.mockImplementationOnce(() => { throw new Error('CONFLICT'); });
|
||||||
|
|
||||||
mockExecuteTask.mockResolvedValue(true);
|
|
||||||
|
|
||||||
const result = await syncBranchWithRoot(PROJECT_DIR, task);
|
const result = await syncBranchWithRoot(PROJECT_DIR, task);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(mockSuccess).toHaveBeenCalledWith('Conflicts resolved.');
|
expect(mockSuccess).toHaveBeenCalledWith('Conflicts resolved & pushed.');
|
||||||
expect(mockExecuteTask).toHaveBeenCalledWith(
|
expect(mockGetProvider).toHaveBeenCalledWith('claude');
|
||||||
|
expect(mockAgentCall).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Implement feature X'),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
cwd: task.worktreePath,
|
cwd: task.worktreePath,
|
||||||
projectCwd: PROJECT_DIR,
|
model: 'sonnet',
|
||||||
pieceIdentifier: 'default',
|
permissionMode: 'edit',
|
||||||
task: expect.stringContaining('Git merge has stopped due to merge conflicts.'),
|
onPermissionRequest: expect.any(Function),
|
||||||
|
onStream: expect.any(Function),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
it('aborts merge and returns false when AI resolution fails', async () => {
|
||||||
const task = makeTask();
|
const task = makeTask();
|
||||||
mockExecFileSync
|
mockExecFileSync
|
||||||
.mockReturnValueOnce('' as never)
|
.mockReturnValueOnce('' as never)
|
||||||
.mockImplementationOnce(() => { throw new Error('CONFLICT'); })
|
.mockImplementationOnce(() => { throw new Error('CONFLICT'); })
|
||||||
.mockReturnValueOnce('' as never);
|
.mockReturnValueOnce('' as never);
|
||||||
mockExecuteTask.mockResolvedValue(false);
|
mockAgentCall.mockResolvedValue(makeAgentResponse({ status: 'error' }));
|
||||||
|
|
||||||
const result = await syncBranchWithRoot(PROJECT_DIR, task);
|
const result = await syncBranchWithRoot(PROJECT_DIR, task);
|
||||||
|
|
||||||
@ -210,31 +205,13 @@ describe('syncBranchWithRoot', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
it('does not throw when git merge --abort itself fails', async () => {
|
||||||
const task = makeTask();
|
const task = makeTask();
|
||||||
mockExecFileSync
|
mockExecFileSync
|
||||||
.mockReturnValueOnce('' as never)
|
.mockReturnValueOnce('' as never)
|
||||||
.mockImplementationOnce(() => { throw new Error('CONFLICT'); })
|
.mockImplementationOnce(() => { throw new Error('CONFLICT'); })
|
||||||
.mockImplementationOnce(() => { throw new Error('abort failed'); });
|
.mockImplementationOnce(() => { throw new Error('abort failed'); });
|
||||||
mockDeterminePiece.mockResolvedValue(null);
|
mockAgentCall.mockResolvedValue(makeAgentResponse({ status: 'error' }));
|
||||||
|
|
||||||
const result = await syncBranchWithRoot(PROJECT_DIR, task);
|
const result = await syncBranchWithRoot(PROJECT_DIR, task);
|
||||||
|
|
||||||
@ -253,19 +230,4 @@ describe('syncBranchWithRoot', () => {
|
|||||||
expect.objectContaining({ cwd: task.worktreePath }),
|
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 }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -169,7 +169,7 @@ export async function listTasks(
|
|||||||
await instructBranch(cwd, task);
|
await instructBranch(cwd, task);
|
||||||
break;
|
break;
|
||||||
case 'sync':
|
case 'sync':
|
||||||
await syncBranchWithRoot(cwd, task, options);
|
await syncBranchWithRoot(cwd, task);
|
||||||
break;
|
break;
|
||||||
case 'try':
|
case 'try':
|
||||||
tryMergeBranch(cwd, task);
|
tryMergeBranch(cwd, task);
|
||||||
|
|||||||
@ -1,36 +1,21 @@
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { success, error as logError } from '../../../shared/ui/index.js';
|
import { success, error as logError, StreamDisplay } from '../../../shared/ui/index.js';
|
||||||
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
|
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
|
||||||
import { executeTask } from '../execute/taskExecution.js';
|
import { getProvider, type ProviderType } from '../../../infra/providers/index.js';
|
||||||
import { determinePiece } from '../execute/selectAndExecute.js';
|
import { resolveConfigValues } from '../../../infra/config/index.js';
|
||||||
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
|
import { pushBranch } from '../../../infra/github/index.js';
|
||||||
import { type BranchActionTarget, resolveTargetInstruction } from './taskActionTarget.js';
|
import { loadTemplate } from '../../../shared/prompts/index.js';
|
||||||
import type { TaskExecutionOptions } from '../execute/types.js';
|
import { getLanguage } from '../../../infra/config/index.js';
|
||||||
|
import { type BranchActionTarget, resolveTargetBranch, resolveTargetInstruction } from './taskActionTarget.js';
|
||||||
|
|
||||||
const log = createLogger('list-tasks');
|
const log = createLogger('list-tasks');
|
||||||
|
|
||||||
const SYNC_REF = 'refs/remotes/root/sync-target';
|
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(
|
export async function syncBranchWithRoot(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
target: BranchActionTarget,
|
target: BranchActionTarget,
|
||||||
options?: TaskExecutionOptions,
|
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!('kind' in target)) {
|
if (!('kind' in target)) {
|
||||||
throw new Error('Sync requires a task target.');
|
throw new Error('Sync requires a task target.');
|
||||||
@ -57,41 +42,49 @@ export async function syncBranchWithRoot(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mergeConflict = false;
|
||||||
try {
|
try {
|
||||||
execFileSync('git', ['merge', SYNC_REF], {
|
execFileSync('git', ['merge', SYNC_REF], {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
});
|
});
|
||||||
success('Synced.');
|
|
||||||
log.info('Merge succeeded without conflicts', { worktreePath });
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
mergeConflict = true;
|
||||||
log.info('Merge conflict detected, attempting AI resolution', {
|
log.info('Merge conflict detected, attempting AI resolution', {
|
||||||
worktreePath,
|
worktreePath,
|
||||||
error: getErrorMessage(err),
|
error: getErrorMessage(err),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const pieceIdentifier = await determinePiece(projectDir, target.data?.piece ?? DEFAULT_PIECE_NAME);
|
if (!mergeConflict) {
|
||||||
if (!pieceIdentifier) {
|
pushSynced(worktreePath, projectDir, target);
|
||||||
abortMerge(worktreePath);
|
success('Synced & pushed.');
|
||||||
return false;
|
log.info('Merge succeeded without conflicts', { worktreePath });
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lang = getLanguage();
|
||||||
const originalInstruction = resolveTargetInstruction(target);
|
const originalInstruction = resolveTargetInstruction(target);
|
||||||
const conflictInstruction = buildConflictResolutionInstruction(originalInstruction);
|
const systemPrompt = loadTemplate('sync_conflict_resolver_system_prompt', lang);
|
||||||
|
const prompt = loadTemplate('sync_conflict_resolver_message', lang, { originalInstruction });
|
||||||
|
|
||||||
const aiSuccess = await executeTask({
|
const config = resolveConfigValues(projectDir, ['provider', 'model']);
|
||||||
task: conflictInstruction,
|
const providerType = (config.provider ?? 'claude') as ProviderType;
|
||||||
|
const provider = getProvider(providerType);
|
||||||
|
const agent = provider.setup({ name: 'conflict-resolver', systemPrompt });
|
||||||
|
|
||||||
|
const response = await agent.call(prompt, {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
pieceIdentifier,
|
model: config.model,
|
||||||
projectCwd: projectDir,
|
permissionMode: 'edit',
|
||||||
agentOverrides: options,
|
onPermissionRequest: autoApproveBash,
|
||||||
|
onStream: new StreamDisplay('conflict-resolver', false).createHandler(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (aiSuccess) {
|
if (response.status === 'done') {
|
||||||
success('Conflicts resolved.');
|
pushSynced(worktreePath, projectDir, target);
|
||||||
|
success('Conflicts resolved & pushed.');
|
||||||
log.info('AI conflict resolution succeeded', { worktreePath });
|
log.info('AI conflict resolution succeeded', { worktreePath });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -101,6 +94,25 @@ export async function syncBranchWithRoot(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Auto-approve all tool invocations (agent runs in isolated worktree) */
|
||||||
|
async function autoApproveBash(request: { toolName: string; input: Record<string, unknown> }) {
|
||||||
|
return { behavior: 'allow' as const, updatedInput: request.input };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push worktree → project dir, then project dir → origin */
|
||||||
|
function pushSynced(worktreePath: string, projectDir: string, target: BranchActionTarget): void {
|
||||||
|
execFileSync('git', ['push', projectDir, 'HEAD'], {
|
||||||
|
cwd: worktreePath,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
log.info('Pushed to main repo', { worktreePath, projectDir });
|
||||||
|
|
||||||
|
const branch = resolveTargetBranch(target);
|
||||||
|
pushBranch(projectDir, branch);
|
||||||
|
log.info('Pushed to origin', { projectDir, branch });
|
||||||
|
}
|
||||||
|
|
||||||
function abortMerge(worktreePath: string): void {
|
function abortMerge(worktreePath: string): void {
|
||||||
try {
|
try {
|
||||||
execFileSync('git', ['merge', '--abort'], {
|
execFileSync('git', ['merge', '--abort'], {
|
||||||
@ -110,6 +122,7 @@ function abortMerge(worktreePath: string): void {
|
|||||||
});
|
});
|
||||||
log.info('git merge --abort completed', { worktreePath });
|
log.info('git merge --abort completed', { worktreePath });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
logError(`Failed to abort merge: ${getErrorMessage(err)}`);
|
||||||
log.error('git merge --abort failed', { worktreePath, error: getErrorMessage(err) });
|
log.error('git merge --abort failed', { worktreePath, error: getErrorMessage(err) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/shared/prompts/en/sync_conflict_resolver_message.md
Normal file
51
src/shared/prompts/en/sync_conflict_resolver_message.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<!--
|
||||||
|
template: sync_conflict_resolver_message
|
||||||
|
role: user message for sync conflict resolver agent
|
||||||
|
vars: originalInstruction
|
||||||
|
caller: features/tasks/list/taskSyncAction.ts
|
||||||
|
-->
|
||||||
|
Git merge has stopped due to merge conflicts.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
### 1. Identify conflicts
|
||||||
|
|
||||||
|
Run `git status` to list unmerged files.
|
||||||
|
|
||||||
|
### 2. Understand context
|
||||||
|
|
||||||
|
Run these in parallel:
|
||||||
|
- `git log --oneline HEAD -5` to see recent HEAD changes
|
||||||
|
- `git log --oneline MERGE_HEAD -5` to see incoming changes (if merge)
|
||||||
|
|
||||||
|
### 3. Analyze each conflicted file
|
||||||
|
|
||||||
|
For each file:
|
||||||
|
1. Read the full file (with conflict markers)
|
||||||
|
2. For each conflict block (`<<<<<<<` to `>>>>>>>`):
|
||||||
|
- Read HEAD side content
|
||||||
|
- Read theirs side content
|
||||||
|
- Determine what the diff means (version bump? refactor? feature addition?)
|
||||||
|
- If unclear, check `git log --oneline -- {file}`
|
||||||
|
3. Write your judgment before resolving
|
||||||
|
|
||||||
|
### 4. Resolve
|
||||||
|
|
||||||
|
- If one side is clearly correct: `git checkout --ours {file}` or `git checkout --theirs {file}`
|
||||||
|
- If both changes need merging: edit the file to combine both sides
|
||||||
|
- Stage each resolved file: `git add {file}`
|
||||||
|
|
||||||
|
After resolving, search for `<<<<<<<` to ensure no markers remain.
|
||||||
|
|
||||||
|
### 5. Verify
|
||||||
|
|
||||||
|
- Run build/test if available
|
||||||
|
- Check that non-conflicted files are consistent with the resolution
|
||||||
|
|
||||||
|
### 6. Complete the merge
|
||||||
|
|
||||||
|
Run `git commit` to finalize.
|
||||||
|
|
||||||
|
## Original task
|
||||||
|
|
||||||
|
{{originalInstruction}}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
<!--
|
||||||
|
template: sync_conflict_resolver_system_prompt
|
||||||
|
role: system prompt for sync conflict resolver agent
|
||||||
|
vars: (none)
|
||||||
|
caller: features/tasks/list/taskSyncAction.ts
|
||||||
|
-->
|
||||||
|
You are a git merge conflict resolver.
|
||||||
|
|
||||||
|
Your only job is to resolve merge conflicts and complete the merge commit.
|
||||||
|
Do not refactor, improve, or change any code beyond what is necessary to resolve conflicts.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
- **Read diffs before resolving.** Never apply `--ours` / `--theirs` without inspecting file contents.
|
||||||
|
- **Do not blindly favor one side.** Even if one branch is "newer", check whether the other side has intentional changes.
|
||||||
|
- **Document your reasoning.** For each conflict, note what each side contains and why you chose the resolution.
|
||||||
|
- **Verify ripple effects.** After resolving, check that non-conflicted files are still consistent.
|
||||||
|
|
||||||
|
## Prohibited
|
||||||
|
|
||||||
|
- Resolving all files with `git checkout --ours .` or `git checkout --theirs .` without analysis
|
||||||
|
- Leaving conflict markers (`<<<<<<<`) in any file
|
||||||
|
- Running `git merge --abort` without user confirmation
|
||||||
51
src/shared/prompts/ja/sync_conflict_resolver_message.md
Normal file
51
src/shared/prompts/ja/sync_conflict_resolver_message.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<!--
|
||||||
|
template: sync_conflict_resolver_message
|
||||||
|
role: sync コンフリクト解決エージェントのユーザーメッセージ
|
||||||
|
vars: originalInstruction
|
||||||
|
caller: features/tasks/list/taskSyncAction.ts
|
||||||
|
-->
|
||||||
|
Git merge がコンフリクトにより停止しました。
|
||||||
|
|
||||||
|
## 手順
|
||||||
|
|
||||||
|
### 1. コンフリクト状態を確認する
|
||||||
|
|
||||||
|
`git status` を実行して未マージファイルを列挙する。
|
||||||
|
|
||||||
|
### 2. コンテキストを把握する
|
||||||
|
|
||||||
|
以下を並列で実行:
|
||||||
|
- `git log --oneline HEAD -5` で HEAD 側の最近の変更を確認
|
||||||
|
- `git log --oneline MERGE_HEAD -5` で取り込み側の最近の変更を確認(merge の場合)
|
||||||
|
|
||||||
|
### 3. 各ファイルを分析する
|
||||||
|
|
||||||
|
ファイルごとに以下を実行:
|
||||||
|
1. ファイル全体を読む(コンフリクトマーカー付きの状態)
|
||||||
|
2. 各コンフリクトブロック(`<<<<<<<` 〜 `>>>>>>>`)について:
|
||||||
|
- HEAD 側の内容を具体的に読む
|
||||||
|
- theirs 側の内容を具体的に読む
|
||||||
|
- 差分が何を意味するか分析する(バージョン番号?リファクタ?機能追加?)
|
||||||
|
- 判断に迷う場合は `git log --oneline -- {file}` で変更履歴を確認する
|
||||||
|
3. 解決前に判断根拠を記述する
|
||||||
|
|
||||||
|
### 4. 解決を実施する
|
||||||
|
|
||||||
|
- 片方採用が明確な場合: `git checkout --ours {file}` / `git checkout --theirs {file}`
|
||||||
|
- 両方の変更を統合する場合: ファイルを編集してコンフリクトマーカーを除去し、両方の内容を結合する
|
||||||
|
- 解決したファイルを `git add {file}` でマークする
|
||||||
|
|
||||||
|
解決後、`<<<<<<<` を検索してマーカーの取り残しがないか確認する。
|
||||||
|
|
||||||
|
### 5. 波及影響を確認する
|
||||||
|
|
||||||
|
- ビルド・テストが利用可能なら実行する
|
||||||
|
- コンフリクト対象外のファイルが、解決した変更と矛盾していないか確認する
|
||||||
|
|
||||||
|
### 6. マージを完了する
|
||||||
|
|
||||||
|
`git commit` を実行して完了する。
|
||||||
|
|
||||||
|
## 元のタスク
|
||||||
|
|
||||||
|
{{originalInstruction}}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
<!--
|
||||||
|
template: sync_conflict_resolver_system_prompt
|
||||||
|
role: sync コンフリクト解決エージェントのシステムプロンプト
|
||||||
|
vars: (none)
|
||||||
|
caller: features/tasks/list/taskSyncAction.ts
|
||||||
|
-->
|
||||||
|
あなたは git merge コンフリクトの解決担当です。
|
||||||
|
|
||||||
|
コンフリクトを解決してマージコミットを完了することだけが仕事です。
|
||||||
|
コンフリクト解決に必要な範囲を超えて、コードのリファクタリングや改善を行わないでください。
|
||||||
|
|
||||||
|
## 原則
|
||||||
|
|
||||||
|
- **差分を読まずに解決しない。** ファイルの中身を確認せずに `--ours` / `--theirs` を適用しない
|
||||||
|
- **「〇〇優先」を盲従しない。** 片方が新しいからという理由だけで判断せず、もう一方に独自の意図がないか必ず確認する
|
||||||
|
- **判断根拠を省略しない。** 各コンフリクトに「何が・なぜ・どちらを」の3点を書く
|
||||||
|
- **波及を確認する。** コンフリクト対象外ファイルもビルド・テストで検証する
|
||||||
|
|
||||||
|
## 禁止事項
|
||||||
|
|
||||||
|
- 分析なしで `git checkout --ours .` / `git checkout --theirs .` を実行しない
|
||||||
|
- 「とりあえず片方」で全ファイルを一括解決しない
|
||||||
|
- コンフリクトマーカー (`<<<<<<<`) が残ったままにしない
|
||||||
|
- `git merge --abort` をユーザーの確認なしに実行しない
|
||||||
Loading…
x
Reference in New Issue
Block a user