From 120815e8486b2ff156d5c5366cd0c262cf266f38 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 28 Jan 2026 20:06:02 +0900 Subject: [PATCH] =?UTF-8?q?takt:=20review=E3=82=B3=E3=83=9E=E3=83=B3?= =?UTF-8?q?=E3=83=89=E3=81=AB=E3=81=A6=E3=80=81=E5=B7=AE=E5=88=86=E3=82=92?= =?UTF-8?q?=E7=A2=BA=E8=AA=8D=E3=81=99=E3=82=8B=E3=83=A1=E3=83=8B=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=82=92=E7=94=A8=E6=84=8F=E3=81=97=E3=81=A6=E3=81=BB?= =?UTF-8?q?=E3=81=97=E3=81=84=E3=80=82=E3=81=9D=E3=81=AE=E5=A0=B4=E3=81=A7?= =?UTF-8?q?gitdiff=E3=82=92=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E3=81=AA?= =?UTF-8?q?=E5=BD=A2=E3=81=8B=E3=81=AA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/reviewTasks.test.ts | 31 +++++++++++++++++++++----- src/commands/reviewTasks.ts | 37 ++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/__tests__/reviewTasks.test.ts b/src/__tests__/reviewTasks.test.ts index 81745c0..5be0082 100644 --- a/src/__tests__/reviewTasks.test.ts +++ b/src/__tests__/reviewTasks.test.ts @@ -2,14 +2,14 @@ * Tests for review-tasks command */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { parseTaktWorktrees, extractTaskSlug, buildReviewItems, type WorktreeInfo, } from '../task/worktree.js'; -import { isBranchMerged, type ReviewAction } from '../commands/reviewTasks.js'; +import { isBranchMerged, showFullDiff, type ReviewAction } from '../commands/reviewTasks.js'; describe('parseTaktWorktrees', () => { it('should parse takt/ branches from porcelain output', () => { @@ -170,14 +170,35 @@ describe('buildReviewItems', () => { }); describe('ReviewAction type', () => { - it('should include try, merge, delete (no skip)', () => { - const actions: ReviewAction[] = ['try', 'merge', 'delete']; - expect(actions).toHaveLength(3); + it('should include diff, try, merge, delete', () => { + const actions: ReviewAction[] = ['diff', 'try', 'merge', 'delete']; + expect(actions).toHaveLength(4); + expect(actions).toContain('diff'); expect(actions).toContain('try'); + expect(actions).toContain('merge'); + expect(actions).toContain('delete'); expect(actions).not.toContain('skip'); }); }); +describe('showFullDiff', () => { + it('should not throw for non-existent project dir', () => { + // spawnSync will fail gracefully; showFullDiff catches errors + expect(() => showFullDiff('/non-existent-dir', 'main', 'some-branch')).not.toThrow(); + }); + + it('should not throw for non-existent branch', () => { + expect(() => showFullDiff('/tmp', 'main', 'non-existent-branch-xyz')).not.toThrow(); + }); + + it('should warn when diff fails', () => { + const warnSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + showFullDiff('/non-existent-dir', 'main', 'some-branch'); + warnSpy.mockRestore(); + // No assertion needed — the test verifies it doesn't throw + }); +}); + describe('isBranchMerged', () => { it('should return false for non-existent project dir', () => { // git merge-base will fail on non-existent dir diff --git a/src/commands/reviewTasks.ts b/src/commands/reviewTasks.ts index 9a80625..9f93db5 100644 --- a/src/commands/reviewTasks.ts +++ b/src/commands/reviewTasks.ts @@ -5,7 +5,7 @@ * try merge, merge & cleanup, or delete actions. */ -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import chalk from 'chalk'; import { removeWorktree, @@ -21,7 +21,7 @@ import { createLogger } from '../utils/debug.js'; const log = createLogger('review-tasks'); /** Actions available for a reviewed worktree */ -export type ReviewAction = 'try' | 'merge' | 'delete'; +export type ReviewAction = 'diff' | 'try' | 'merge' | 'delete'; /** * Check if a branch has already been merged into HEAD. @@ -39,6 +39,28 @@ export function isBranchMerged(projectDir: string, branch: string): boolean { } } +/** + * Show full diff in an interactive pager (less). + * Falls back to direct output if pager is unavailable. + */ +export function showFullDiff( + cwd: string, + defaultBranch: string, + branch: string, +): void { + try { + const result = spawnSync( + 'git', ['diff', '--color=always', `${defaultBranch}...${branch}`], + { cwd, stdio: ['inherit', 'inherit', 'inherit'], env: { ...process.env, GIT_PAGER: 'less -R' } }, + ); + if (result.status !== 0) { + warn('Could not display diff'); + } + } catch { + warn('Could not display diff'); + } +} + /** * Show diff stat for a branch and prompt for an action. */ @@ -66,6 +88,7 @@ async function showDiffAndPromptAction( const action = await selectOption( `Action for ${item.info.branch}:`, [ + { label: 'View diff', value: 'diff', description: 'Show full diff in pager' }, { label: 'Try merge', value: 'try', description: 'Merge without cleanup (keep worktree & branch)' }, { label: 'Merge & cleanup', value: 'merge', description: 'Merge (if needed) and remove worktree & branch' }, { label: 'Delete', value: 'delete', description: 'Discard changes, remove worktree and branch' }, @@ -214,7 +237,15 @@ export async function reviewTasks(cwd: string): Promise { const item = items[selectedIdx]; if (!item) continue; - const action = await showDiffAndPromptAction(cwd, defaultBranch, item); + // Action loop: re-show menu after viewing diff + let action: ReviewAction | null; + do { + action = await showDiffAndPromptAction(cwd, defaultBranch, item); + + if (action === 'diff') { + showFullDiff(cwd, defaultBranch, item.info.branch); + } + } while (action === 'diff'); if (action === null) continue;