* takt: github-issue-395-add-pull-from * ci: trigger CI checks * fix: taskDiffActions のコンフリクトマーカーを解消 origin/main でリネームされた「Merge from root」ラベル(PR #394)と、 このPR (#395) で追加した「Pull from remote」行を統合する。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci: trigger CI checks --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: masanobu-naruse <m_naruse@codmon.co.jp> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
271 lines
8.8 KiB
TypeScript
271 lines
8.8 KiB
TypeScript
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(),
|
|
StreamDisplay: vi.fn(() => ({
|
|
createHandler: vi.fn(() => 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)),
|
|
}));
|
|
|
|
const mockAgentCall = vi.fn();
|
|
|
|
vi.mock('../infra/providers/index.js', () => ({
|
|
getProvider: vi.fn(() => ({
|
|
setup: vi.fn(() => ({ call: mockAgentCall })),
|
|
})),
|
|
}));
|
|
|
|
vi.mock('../infra/config/index.js', () => ({
|
|
getLanguage: vi.fn(() => 'en'),
|
|
resolveConfigValues: vi.fn(() => ({ provider: 'claude', model: 'sonnet' })),
|
|
}));
|
|
|
|
vi.mock('../infra/task/index.js', async (importOriginal) => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
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 { execFileSync } from 'node:child_process';
|
|
import { error as logError, success } from '../shared/ui/index.js';
|
|
import { pushBranch } from '../infra/task/index.js';
|
|
import { getProvider } from '../infra/providers/index.js';
|
|
import { syncBranchWithRoot } from '../features/tasks/list/taskSyncAction.js';
|
|
import type { TaskListItem } from '../infra/task/index.js';
|
|
import type { AgentResponse } from '../core/models/index.js';
|
|
|
|
const mockExistsSync = vi.mocked(fs.existsSync);
|
|
const mockExecFileSync = vi.mocked(execFileSync);
|
|
const mockLogError = vi.mocked(logError);
|
|
const mockSuccess = vi.mocked(success);
|
|
const mockPushBranch = vi.mocked(pushBranch);
|
|
const mockGetProvider = vi.mocked(getProvider);
|
|
|
|
function makeTask(overrides: Partial<TaskListItem> = {}): TaskListItem {
|
|
return {
|
|
kind: 'completed',
|
|
name: 'test-task',
|
|
branch: 'task/test-task',
|
|
createdAt: '2026-01-01T00:00:00Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'Implement feature X',
|
|
worktreePath: '/project-worktrees/test-task',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeAgentResponse(overrides: Partial<AgentResponse> = {}): AgentResponse {
|
|
return {
|
|
persona: 'conflict-resolver',
|
|
status: 'done',
|
|
content: 'Conflicts resolved',
|
|
timestamp: new Date(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const PROJECT_DIR = '/project';
|
|
|
|
describe('syncBranchWithRoot', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockExistsSync.mockReturnValue(true);
|
|
mockAgentCall.mockResolvedValue(makeAgentResponse());
|
|
});
|
|
|
|
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(mockAgentCall).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns true and pushes 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 & pushed.');
|
|
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 provider agent when merge has conflicts', async () => {
|
|
const task = makeTask();
|
|
mockExecFileSync
|
|
.mockReturnValueOnce('' as never)
|
|
.mockImplementationOnce(() => { throw new Error('CONFLICT'); });
|
|
|
|
const result = await syncBranchWithRoot(PROJECT_DIR, task);
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockSuccess).toHaveBeenCalledWith('Conflicts resolved & pushed.');
|
|
expect(mockGetProvider).toHaveBeenCalledWith('claude');
|
|
expect(mockAgentCall).toHaveBeenCalledWith(
|
|
expect.stringContaining('Implement feature X'),
|
|
expect.objectContaining({
|
|
cwd: task.worktreePath,
|
|
model: 'sonnet',
|
|
permissionMode: 'edit',
|
|
onPermissionRequest: expect.any(Function),
|
|
onStream: expect.any(Function),
|
|
}),
|
|
);
|
|
});
|
|
|
|
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);
|
|
mockAgentCall.mockResolvedValue(makeAgentResponse({ status: 'error' }));
|
|
|
|
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('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'); });
|
|
mockAgentCall.mockResolvedValue(makeAgentResponse({ status: 'error' }));
|
|
|
|
const result = await syncBranchWithRoot(PROJECT_DIR, task);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('returns false when push fails after successful merge', async () => {
|
|
const task = makeTask();
|
|
mockExecFileSync.mockImplementation((_cmd, args) => {
|
|
const argsArr = args as string[];
|
|
if (argsArr[0] === 'push') throw new Error('push failed');
|
|
return '' as never;
|
|
});
|
|
|
|
const result = await syncBranchWithRoot(PROJECT_DIR, task);
|
|
|
|
expect(result).toBe(false);
|
|
expect(mockLogError).toHaveBeenCalledWith(
|
|
expect.stringContaining('Push failed after sync'),
|
|
);
|
|
expect(mockSuccess).not.toHaveBeenCalledWith('Synced & pushed.');
|
|
});
|
|
|
|
it('returns false when push fails after AI conflict resolution', async () => {
|
|
const task = makeTask();
|
|
mockExecFileSync.mockImplementation((_cmd, args) => {
|
|
const argsArr = args as string[];
|
|
if (argsArr[0] === 'merge' && !argsArr.includes('--abort')) throw new Error('CONFLICT');
|
|
if (argsArr[0] === 'push') throw new Error('push failed');
|
|
return '' as never;
|
|
});
|
|
mockAgentCall.mockResolvedValue(makeAgentResponse({ status: 'done' }));
|
|
|
|
const result = await syncBranchWithRoot(PROJECT_DIR, task);
|
|
|
|
expect(result).toBe(false);
|
|
expect(mockLogError).toHaveBeenCalledWith(
|
|
expect.stringContaining('Push failed after sync'),
|
|
);
|
|
expect(mockSuccess).not.toHaveBeenCalledWith('Conflicts resolved & pushed.');
|
|
});
|
|
|
|
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 }),
|
|
);
|
|
});
|
|
});
|