auto PR のベースブランチをブランチ作成前の現在ブランチに設定

createPullRequest の全呼び出し箇所で base が未指定だったため、
PR が常にリポジトリデフォルトブランチ(main)向けに作成されていた。
ブランチ作成/clone作成の直前に getCurrentBranch() で元ブランチを
取得し、PR作成時に base として渡すように修正。
This commit is contained in:
nrslib 2026-02-08 07:51:03 +09:00
parent 7e01260196
commit b9a2a0329b
12 changed files with 180 additions and 9 deletions

View File

@ -10,6 +10,11 @@ vi.mock('../shared/prompt/index.js', () => ({
selectOptionWithDefault: vi.fn(),
}));
vi.mock('../infra/task/git.js', () => ({
stageAndCommit: vi.fn(),
getCurrentBranch: vi.fn(() => 'main'),
}));
vi.mock('../infra/task/clone.js', () => ({
createSharedClone: vi.fn(),
removeClone: vi.fn(),

View File

@ -0,0 +1,57 @@
/**
* Tests for getCurrentBranch
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { execFileSync } from 'node:child_process';
vi.mock('node:child_process', () => ({
execFileSync: vi.fn(),
}));
const mockExecFileSync = vi.mocked(execFileSync);
import { getCurrentBranch } from '../infra/task/git.js';
beforeEach(() => {
vi.clearAllMocks();
});
describe('getCurrentBranch', () => {
it('should return the current branch name', () => {
// Given
mockExecFileSync.mockReturnValue('feature/my-branch\n');
// When
const result = getCurrentBranch('/project');
// Then
expect(result).toBe('feature/my-branch');
expect(mockExecFileSync).toHaveBeenCalledWith(
'git',
['rev-parse', '--abbrev-ref', 'HEAD'],
{ cwd: '/project', encoding: 'utf-8', stdio: 'pipe' },
);
});
it('should trim whitespace from output', () => {
// Given
mockExecFileSync.mockReturnValue(' main \n');
// When
const result = getCurrentBranch('/project');
// Then
expect(result).toBe('main');
});
it('should propagate errors from git', () => {
// Given
mockExecFileSync.mockImplementation(() => {
throw new Error('not a git repository');
});
// When / Then
expect(() => getCurrentBranch('/not-a-repo')).toThrow('not a git repository');
});
});

View File

@ -65,6 +65,7 @@ vi.mock('../infra/github/pr.js', () => ({
vi.mock('../infra/task/git.js', () => ({
stageAndCommit: vi.fn().mockReturnValue('abc1234'),
getCurrentBranch: vi.fn().mockReturnValue('main'),
}));
vi.mock('../shared/ui/index.js', () => ({

View File

@ -218,6 +218,37 @@ describe('executePipeline', () => {
);
});
it('should pass baseBranch as base to createPullRequest', async () => {
// Given: getCurrentBranch returns 'develop' before branch creation
mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
return 'develop\n';
}
return 'abc1234\n';
});
mockExecuteTask.mockResolvedValueOnce(true);
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' });
// When
const exitCode = await executePipeline({
task: 'Fix the bug',
piece: 'default',
branch: 'fix/my-branch',
autoPr: true,
cwd: '/tmp/test',
});
// Then
expect(exitCode).toBe(0);
expect(mockCreatePullRequest).toHaveBeenCalledWith(
'/tmp/test',
expect.objectContaining({
branch: 'fix/my-branch',
base: 'develop',
}),
);
});
it('should use --task when both --task and positional task are provided', async () => {
mockExecuteTask.mockResolvedValueOnce(true);

View File

@ -23,6 +23,7 @@ vi.mock('../infra/task/index.js', () => ({
createSharedClone: vi.fn(),
autoCommitAndPush: vi.fn(),
summarizeTaskName: vi.fn(),
getCurrentBranch: vi.fn(() => 'main'),
}));
vi.mock('../shared/ui/index.js', () => ({

View File

@ -25,6 +25,11 @@ vi.mock('../infra/task/clone.js', async (importOriginal) => ({
removeClone: vi.fn(),
}));
vi.mock('../infra/task/git.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getCurrentBranch: vi.fn(() => 'main'),
}));
vi.mock('../infra/task/autoCommit.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
autoCommitAndPush: vi.fn(),
@ -68,12 +73,14 @@ vi.mock('../shared/constants.js', () => ({
}));
import { createSharedClone } from '../infra/task/clone.js';
import { getCurrentBranch } from '../infra/task/git.js';
import { summarizeTaskName } from '../infra/task/summarize.js';
import { info } from '../shared/ui/index.js';
import { resolveTaskExecution } from '../features/tasks/index.js';
import type { TaskInfo } from '../infra/task/index.js';
const mockCreateSharedClone = vi.mocked(createSharedClone);
const mockGetCurrentBranch = vi.mocked(getCurrentBranch);
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
const mockInfo = vi.mocked(info);
@ -150,11 +157,13 @@ describe('resolveTaskExecution', () => {
branch: undefined,
taskSlug: 'add-auth',
});
expect(mockGetCurrentBranch).toHaveBeenCalledWith('/project');
expect(result).toEqual({
execCwd: '/project/../20260128T0504-add-auth',
execPiece: 'default',
isWorktree: true,
branch: 'takt/20260128T0504-add-auth',
baseBranch: 'main',
});
});
@ -396,4 +405,50 @@ describe('resolveTaskExecution', () => {
// Then
expect(result.autoPr).toBe(false);
});
it('should capture baseBranch from getCurrentBranch when worktree is used', async () => {
// Given: Task with worktree, on 'develop' branch
mockGetCurrentBranch.mockReturnValue('develop');
const task: TaskInfo = {
name: 'task-on-develop',
content: 'Task on develop branch',
filePath: '/tasks/task.yaml',
data: {
task: 'Task on develop branch',
worktree: true,
},
};
mockSummarizeTaskName.mockResolvedValue('task-develop');
mockCreateSharedClone.mockReturnValue({
path: '/project/../task-develop',
branch: 'takt/task-develop',
});
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(mockGetCurrentBranch).toHaveBeenCalledWith('/project');
expect(result.baseBranch).toBe('develop');
});
it('should not set baseBranch when worktree is not used', async () => {
// Given: Task without worktree
const task: TaskInfo = {
name: 'task-no-worktree',
content: 'Task without worktree',
filePath: '/tasks/task.yaml',
data: {
task: 'Task without worktree',
},
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(mockGetCurrentBranch).not.toHaveBeenCalled();
expect(result.baseBranch).toBeUndefined();
});
});

View File

@ -19,7 +19,7 @@ import {
buildPrBody,
type GitHubIssue,
} from '../../infra/github/index.js';
import { stageAndCommit } from '../../infra/task/index.js';
import { stageAndCommit, getCurrentBranch } from '../../infra/task/index.js';
import { executeTask, type TaskExecutionOptions, type PipelineExecutionOptions } from '../tasks/index.js';
import { loadGlobalConfig } from '../../infra/config/index.js';
import { info, error, success, status, blankLine } from '../../shared/ui/index.js';
@ -136,7 +136,9 @@ export async function executePipeline(options: PipelineExecutionOptions): Promis
// --- Step 2: Create branch (skip if --skip-git) ---
let branch: string | undefined;
let baseBranch: string | undefined;
if (!skipGit) {
baseBranch = getCurrentBranch(cwd);
branch = options.branch ?? generatePipelineBranchName(pipelineConfig, options.issueNumber);
info(`Creating branch: ${branch}`);
try {
@ -206,6 +208,7 @@ export async function executePipeline(options: PipelineExecutionOptions): Promis
branch,
title: prTitle,
body: prBody,
base: baseBranch,
repo: options.repo,
});

View File

@ -17,7 +17,7 @@ import {
loadGlobalConfig,
} from '../../../infra/config/index.js';
import { confirm } from '../../../shared/prompt/index.js';
import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../../../infra/task/index.js';
import { createSharedClone, autoCommitAndPush, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js';
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
import { info, error, success } from '../../../shared/ui/index.js';
import { createLogger } from '../../../shared/utils/index.js';
@ -111,6 +111,8 @@ export async function confirmAndCreateWorktree(
return { execCwd: cwd, isWorktree: false };
}
const baseBranch = getCurrentBranch(cwd);
info('Generating branch name...');
const taskSlug = await summarizeTaskName(task, { cwd });
@ -121,7 +123,7 @@ export async function confirmAndCreateWorktree(
});
info(`Clone created: ${result.path} (branch: ${result.branch})`);
return { execCwd: result.path, isWorktree: true, branch: result.branch };
return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch };
}
/**
@ -161,7 +163,7 @@ export async function selectAndExecuteTask(
return;
}
const { execCwd, isWorktree, branch } = await confirmAndCreateWorktree(
const { execCwd, isWorktree, branch, baseBranch } = await confirmAndCreateWorktree(
cwd,
task,
options?.createWorktree,
@ -206,6 +208,7 @@ export async function selectAndExecuteTask(
branch,
title: task.length > 100 ? `${task.slice(0, 97)}...` : task,
body: prBody,
base: baseBranch,
repo: options?.repo,
});
if (prResult.success) {

View File

@ -3,7 +3,7 @@
*/
import { loadPieceByIdentifier, isPiecePath, loadGlobalConfig } from '../../../infra/config/index.js';
import { TaskRunner, type TaskInfo, createSharedClone, autoCommitAndPush, summarizeTaskName } from '../../../infra/task/index.js';
import { TaskRunner, type TaskInfo, createSharedClone, autoCommitAndPush, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js';
import {
header,
info,
@ -78,7 +78,7 @@ export async function executeAndCompleteTask(
const executionLog: string[] = [];
try {
const { execCwd, execPiece, isWorktree, branch, startMovement, retryNote, autoPr } = await resolveTaskExecution(task, cwd, pieceName);
const { execCwd, execPiece, isWorktree, branch, baseBranch, startMovement, retryNote, autoPr } = await resolveTaskExecution(task, cwd, pieceName);
// cwd is always the project root; pass it as projectCwd so reports/sessions go there
const taskSuccess = await executeTask({
@ -115,6 +115,7 @@ export async function executeAndCompleteTask(
branch,
title: task.name.length > 100 ? `${task.name.slice(0, 97)}...` : task.name,
body: prBody,
base: baseBranch,
});
if (prResult.success) {
success(`PR created: ${prResult.url}`);
@ -222,7 +223,7 @@ export async function resolveTaskExecution(
task: TaskInfo,
defaultCwd: string,
defaultPiece: string
): Promise<{ execCwd: string; execPiece: string; isWorktree: boolean; branch?: string; startMovement?: string; retryNote?: string; autoPr?: boolean }> {
): Promise<{ execCwd: string; execPiece: string; isWorktree: boolean; branch?: string; baseBranch?: string; startMovement?: string; retryNote?: string; autoPr?: boolean }> {
const data = task.data;
// No structured data: use defaults
@ -233,9 +234,11 @@ export async function resolveTaskExecution(
let execCwd = defaultCwd;
let isWorktree = false;
let branch: string | undefined;
let baseBranch: string | undefined;
// Handle worktree (now creates a shared clone)
if (data.worktree) {
baseBranch = getCurrentBranch(defaultCwd);
// Summarize task content to English slug using AI
info('Generating branch name...');
const taskSlug = await summarizeTaskName(task.content, { cwd: defaultCwd });
@ -271,5 +274,5 @@ export async function resolveTaskExecution(
autoPr = globalConfig.autoPr;
}
return { execCwd, execPiece, isWorktree, branch, startMovement, retryNote, autoPr };
return { execCwd, execPiece, isWorktree, branch, baseBranch, startMovement, retryNote, autoPr };
}

View File

@ -91,6 +91,7 @@ export interface WorktreeConfirmationResult {
execCwd: string;
isWorktree: boolean;
branch?: string;
baseBranch?: string;
}
export interface SelectAndExecuteOptions {

View File

@ -4,6 +4,17 @@
import { execFileSync } from 'node:child_process';
/**
* Get the current branch name.
*/
export function getCurrentBranch(cwd: string): string {
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd,
encoding: 'utf-8',
stdio: 'pipe',
}).trim();
}
/**
* Stage all changes and create a commit.
* Returns the short commit hash if changes were committed, undefined if no changes.

View File

@ -43,7 +43,7 @@ export {
getOriginalInstruction,
buildListItems,
} from './branchList.js';
export { stageAndCommit } from './git.js';
export { stageAndCommit, getCurrentBranch } from './git.js';
export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js';
export { summarizeTaskName } from './summarize.js';
export { TaskWatcher, type TaskWatcherOptions } from './watcher.js';