From ea0d04c4feb9909433022feb1f0fe158f21baeac Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:34:12 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E3=81=AB=E5=85=83=E3=81=AE=E3=82=BF=E3=82=B9=E3=82=AF?= =?UTF-8?q?=E6=8C=87=E7=A4=BA=E3=82=92=E8=A1=A8=E7=A4=BA=20&=20=E3=82=BF?= =?UTF-8?q?=E3=82=A4=E3=83=A0=E3=82=B9=E3=82=BF=E3=83=B3=E3=83=97=E7=9F=AD?= =?UTF-8?q?=E7=B8=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getOriginalInstruction: ブランチ最初のコミットから元の指示を抽出 - /review-tasks の選択肢と詳細表示に元の指示を追加 - タイムスタンプのハイフンを除去 (2026-01-29T0225 → 20260129T0225) --- src/__tests__/getOriginalInstruction.test.ts | 97 ++++++++++++++++++++ src/__tests__/reviewTasks.test.ts | 4 +- src/commands/reviewTasks.ts | 19 +++- src/task/worktree.ts | 40 +++++++- 4 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/getOriginalInstruction.test.ts diff --git a/src/__tests__/getOriginalInstruction.test.ts b/src/__tests__/getOriginalInstruction.test.ts new file mode 100644 index 0000000..0b48c6f --- /dev/null +++ b/src/__tests__/getOriginalInstruction.test.ts @@ -0,0 +1,97 @@ +/** + * Tests for getOriginalInstruction + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock child_process.execFileSync +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})); + +import { execFileSync } from 'node:child_process'; +const mockExecFileSync = vi.mocked(execFileSync); + +import { getOriginalInstruction } from '../task/worktree.js'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getOriginalInstruction', () => { + it('should extract instruction from takt-prefixed commit message', () => { + mockExecFileSync.mockReturnValue('takt: 認証機能を追加する\ntakt: fix-auth\n'); + + const result = getOriginalInstruction('/project', 'main', 'takt/20260128-fix-auth'); + + expect(result).toBe('認証機能を追加する'); + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['log', '--format=%s', '--reverse', 'main..takt/20260128-fix-auth'], + expect.objectContaining({ cwd: '/project', encoding: 'utf-8' }), + ); + }); + + it('should return first commit message without takt prefix if not present', () => { + mockExecFileSync.mockReturnValue('Initial implementation\n'); + + const result = getOriginalInstruction('/project', 'main', 'takt/20260128-fix-auth'); + + expect(result).toBe('Initial implementation'); + }); + + it('should return empty string when no commits on branch', () => { + mockExecFileSync.mockReturnValue(''); + + const result = getOriginalInstruction('/project', 'main', 'takt/20260128-fix-auth'); + + expect(result).toBe(''); + }); + + it('should return empty string when git command fails', () => { + mockExecFileSync.mockImplementation(() => { + throw new Error('not a git repository'); + }); + + const result = getOriginalInstruction('/non-existent', 'main', 'takt/20260128-fix-auth'); + + expect(result).toBe(''); + }); + + it('should handle multi-line commit messages (use only first line)', () => { + mockExecFileSync.mockReturnValue('takt: Fix the login bug\ntakt: follow-up fix\n'); + + const result = getOriginalInstruction('/project', 'main', 'takt/20260128-fix-login'); + + expect(result).toBe('Fix the login bug'); + }); + + it('should return empty string when takt prefix has no content', () => { + // "takt: \n" trimmed → "takt:", starts with "takt:" → slice + trim → "" + mockExecFileSync.mockReturnValue('takt: \n'); + + const result = getOriginalInstruction('/project', 'main', 'takt/20260128-task'); + + expect(result).toBe(''); + }); + + it('should return instruction text when takt prefix has content', () => { + mockExecFileSync.mockReturnValue('takt: add search feature\n'); + + const result = getOriginalInstruction('/project', 'main', 'takt/20260128-task'); + + expect(result).toBe('add search feature'); + }); + + it('should use correct git range with custom default branch', () => { + mockExecFileSync.mockReturnValue('takt: Add search feature\n'); + + getOriginalInstruction('/project', 'master', 'takt/20260128-add-search'); + + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['log', '--format=%s', '--reverse', 'master..takt/20260128-add-search'], + expect.objectContaining({ cwd: '/project' }), + ); + }); +}); diff --git a/src/__tests__/reviewTasks.test.ts b/src/__tests__/reviewTasks.test.ts index e65ead7..1e3c76c 100644 --- a/src/__tests__/reviewTasks.test.ts +++ b/src/__tests__/reviewTasks.test.ts @@ -93,7 +93,7 @@ describe('extractTaskSlug', () => { }); describe('buildReviewItems', () => { - it('should build items with correct task slug', () => { + it('should build items with correct task slug and originalInstruction', () => { const branches: BranchInfo[] = [ { branch: 'takt/20260128-fix-auth', @@ -107,6 +107,8 @@ describe('buildReviewItems', () => { expect(items[0]!.info).toBe(branches[0]); // filesChanged will be 0 since we don't have a real git repo expect(items[0]!.filesChanged).toBe(0); + // originalInstruction will be empty since git command fails on non-existent repo + expect(items[0]!.originalInstruction).toBe(''); }); it('should handle multiple branches', () => { diff --git a/src/commands/reviewTasks.ts b/src/commands/reviewTasks.ts index 05d4c4f..a5621b9 100644 --- a/src/commands/reviewTasks.ts +++ b/src/commands/reviewTasks.ts @@ -78,6 +78,9 @@ async function showDiffAndPromptAction( ): Promise { console.log(); console.log(chalk.bold.cyan(`=== ${item.info.branch} ===`)); + if (item.originalInstruction) { + console.log(chalk.dim(` ${item.originalInstruction}`)); + } console.log(); // Show diff stat @@ -353,11 +356,17 @@ export async function reviewTasks(cwd: string): Promise { const items = buildReviewItems(cwd, branches, defaultBranch); // Build selection options - const options = items.map((item, idx) => ({ - label: item.info.branch, - value: String(idx), - description: `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`, - })); + const options = items.map((item, idx) => { + const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`; + const description = item.originalInstruction + ? `${filesSummary} | ${item.originalInstruction}` + : filesSummary; + return { + label: item.info.branch, + value: String(idx), + description, + }; + }); const selected = await selectOption( 'Review Tasks (Branches)', diff --git a/src/task/worktree.ts b/src/task/worktree.ts index dffbf8e..ef2b648 100644 --- a/src/task/worktree.ts +++ b/src/task/worktree.ts @@ -43,13 +43,15 @@ export interface BranchReviewItem { info: BranchInfo; filesChanged: number; taskSlug: string; + /** Original task instruction extracted from first commit message */ + originalInstruction: string; } /** * Generate a timestamp string for paths/branches */ function generateTimestamp(): string { - return new Date().toISOString().replace(/[:.]/g, '').slice(0, 15); + return new Date().toISOString().replace(/[-:.]/g, '').slice(0, 13); } /** @@ -313,6 +315,41 @@ export function extractTaskSlug(branch: string): string { return withoutTimestamp || name; } +/** + * Extract the original task instruction from the first commit message on a branch. + * + * The first commit on a takt branch has the format: "takt: {original instruction}". + * This function retrieves the first commit's message and strips the "takt: " prefix. + * Returns empty string if extraction fails. + */ +export function getOriginalInstruction( + cwd: string, + defaultBranch: string, + branch: string, +): string { + try { + // Get the first commit message on the branch (oldest first) + const output = execFileSync( + 'git', + ['log', '--format=%s', '--reverse', `${defaultBranch}..${branch}`], + { cwd, encoding: 'utf-8', stdio: 'pipe' }, + ).trim(); + + if (!output) return ''; + + const firstLine = output.split('\n')[0] || ''; + // Strip "takt: " prefix if present + const TAKT_COMMIT_PREFIX = 'takt:'; + if (firstLine.startsWith(TAKT_COMMIT_PREFIX)) { + return firstLine.slice(TAKT_COMMIT_PREFIX.length).trim(); + } + + return firstLine; + } catch { + return ''; + } +} + /** * Build review items from branch list, enriching with diff stats. */ @@ -325,5 +362,6 @@ export function buildReviewItems( info: br, filesChanged: getFilesChanged(projectDir, defaultBranch, br.branch), taskSlug: extractTaskSlug(br.branch), + originalInstruction: getOriginalInstruction(projectDir, defaultBranch, br.branch), })); }