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:
nrslib 2026-02-26 00:33:33 +09:00
parent a27c55420c
commit 9f15840d63
7 changed files with 261 additions and 137 deletions

View File

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

View File

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

View File

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

View 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}}

View File

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

View 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}}

View File

@ -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` をユーザーの確認なしに実行しない