diff --git a/src/__tests__/branchGitResolver.performance.test.ts b/src/__tests__/branchGitResolver.performance.test.ts new file mode 100644 index 0000000..ae286e9 --- /dev/null +++ b/src/__tests__/branchGitResolver.performance.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})); + +import { execFileSync } from 'node:child_process'; +import { + createBranchBaseResolutionCache, + findFirstTaktCommit, + resolveBranchBaseCommit, +} from '../infra/task/branchGitResolver.js'; + +const mockExecFileSync = vi.mocked(execFileSync); + +describe('branchGitResolver performance', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should skip full ref scan when default branch candidate resolves', () => { + mockExecFileSync.mockImplementation((cmd, args) => { + if (cmd !== 'git') { + throw new Error('unexpected command'); + } + + if (args[0] === 'reflog') { + throw new Error('reflog unavailable'); + } + + if (args[0] === 'merge-base' && args[1] === 'main') { + return 'base-main'; + } + + if (args[0] === 'merge-base' && args[1] === 'origin/main') { + throw new Error('origin/main not available'); + } + + if (args[0] === 'rev-list') { + return '1'; + } + + if (args[0] === 'log' && args[1] === '--format=%s') { + return 'takt: first'; + } + + if (args[0] === 'for-each-ref') { + throw new Error('for-each-ref should not be called'); + } + + throw new Error(`unexpected git args: ${args.join(' ')}`); + }); + + const baseCommit = resolveBranchBaseCommit('/project', 'main', 'takt/feature-a'); + + expect(baseCommit).toBe('base-main'); + expect(mockExecFileSync).not.toHaveBeenCalledWith( + 'git', + ['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes'], + expect.anything(), + ); + }); + + it('should reuse ref list cache across branch resolutions', () => { + mockExecFileSync.mockImplementation((cmd, args) => { + if (cmd !== 'git') { + throw new Error('unexpected command'); + } + + if (args[0] === 'reflog') { + throw new Error('reflog unavailable'); + } + + if (args[0] === 'merge-base') { + const baseRef = args[1]; + const branch = args[2]; + if (baseRef === 'main' || baseRef === 'origin/main') { + throw new Error('priority refs unavailable'); + } + if (baseRef === 'develop' && branch === 'takt/feature-a') { + return 'base-a'; + } + if (baseRef === 'origin/develop' && branch === 'takt/feature-a') { + return 'base-a-remote'; + } + if (baseRef === 'develop' && branch === 'takt/feature-b') { + return 'base-b'; + } + if (baseRef === 'origin/develop' && branch === 'takt/feature-b') { + return 'base-b-remote'; + } + throw new Error(`unexpected merge-base args: ${args.join(' ')}`); + } + + if (args[0] === 'for-each-ref') { + return 'develop\norigin/develop\n'; + } + + if (args[0] === 'rev-parse' && args[1] === '--git-common-dir') { + return '/project/.git'; + } + + if (args[0] === 'rev-list') { + const range = args[3]; + if (range === 'base-a..takt/feature-a') { + return '1'; + } + if (range === 'base-a-remote..takt/feature-a') { + return '5'; + } + if (range === 'base-b..takt/feature-b') { + return '1'; + } + if (range === 'base-b-remote..takt/feature-b') { + return '6'; + } + throw new Error(`unexpected rev-list args: ${args.join(' ')}`); + } + + if (args[0] === 'log' && args[1] === '--format=%s') { + return 'takt: first'; + } + + throw new Error(`unexpected git args: ${args.join(' ')}`); + }); + + const cache = createBranchBaseResolutionCache(); + const baseA = resolveBranchBaseCommit('/project', 'main', 'takt/feature-a', cache); + const baseB = resolveBranchBaseCommit('/project', 'main', 'takt/feature-b', cache); + + expect(baseA).toBe('base-a'); + expect(baseB).toBe('base-b'); + + const forEachRefCalls = mockExecFileSync.mock.calls.filter(([, args]) => + args[0] === 'for-each-ref', + ); + expect(forEachRefCalls).toHaveLength(1); + }); + + it('should skip reflog lookup when baseCommit is provided to findFirstTaktCommit', () => { + mockExecFileSync.mockImplementation((cmd, args) => { + if (cmd !== 'git') { + throw new Error('unexpected command'); + } + + if (args[0] === 'reflog') { + throw new Error('reflog should not be called'); + } + + if (args[0] === 'log' && args[1] === '--format=%H\t%s') { + return 'abc123\ttakt: first instruction\n'; + } + + throw new Error(`unexpected git args: ${args.join(' ')}`); + }); + + const first = findFirstTaktCommit('/project', 'main', 'takt/feature-a', { baseCommit: 'base-a' }); + + expect(first).toEqual({ subject: 'takt: first instruction' }); + expect(mockExecFileSync).not.toHaveBeenCalledWith( + 'git', + ['reflog', 'show', '--format=%H', 'takt/feature-a'], + expect.anything(), + ); + }); + + it('should reuse ref list cache across worktrees in the same repository', () => { + mockExecFileSync.mockImplementation((cmd, args, options) => { + if (cmd !== 'git') { + throw new Error('unexpected command'); + } + + if (args[0] === 'reflog') { + throw new Error('reflog unavailable'); + } + + if (args[0] === 'rev-parse' && args[1] === '--git-common-dir') { + if (options?.cwd === '/repo/worktrees/a' || options?.cwd === '/repo/worktrees/b') { + return '/repo/.git'; + } + throw new Error(`unexpected rev-parse cwd: ${String(options?.cwd)}`); + } + + if (args[0] === 'merge-base') { + const baseRef = args[1]; + const branch = args[2]; + if (baseRef === 'main' || baseRef === 'origin/main') { + throw new Error('priority refs unavailable'); + } + if (baseRef === 'develop' && branch === 'takt/feature-a') { + return 'base-a'; + } + if (baseRef === 'origin/develop' && branch === 'takt/feature-a') { + return 'base-a-remote'; + } + if (baseRef === 'develop' && branch === 'takt/feature-b') { + return 'base-b'; + } + if (baseRef === 'origin/develop' && branch === 'takt/feature-b') { + return 'base-b-remote'; + } + throw new Error(`unexpected merge-base args: ${args.join(' ')}`); + } + + if (args[0] === 'for-each-ref') { + return 'develop\norigin/develop\n'; + } + + if (args[0] === 'rev-list') { + const range = args[3]; + if (range === 'base-a..takt/feature-a') { + return '1'; + } + if (range === 'base-a-remote..takt/feature-a') { + return '5'; + } + if (range === 'base-b..takt/feature-b') { + return '1'; + } + if (range === 'base-b-remote..takt/feature-b') { + return '6'; + } + throw new Error(`unexpected rev-list args: ${args.join(' ')}`); + } + + if (args[0] === 'log' && args[1] === '--format=%s') { + return 'takt: first'; + } + + throw new Error(`unexpected git args: ${args.join(' ')}`); + }); + + const cache = createBranchBaseResolutionCache(); + const baseA = resolveBranchBaseCommit('/repo/worktrees/a', 'main', 'takt/feature-a', cache); + const baseB = resolveBranchBaseCommit('/repo/worktrees/b', 'main', 'takt/feature-b', cache); + + expect(baseA).toBe('base-a'); + expect(baseB).toBe('base-b'); + + const forEachRefCalls = mockExecFileSync.mock.calls.filter(([, args]) => + args[0] === 'for-each-ref', + ); + expect(forEachRefCalls).toHaveLength(1); + }); +}); diff --git a/src/__tests__/buildListItems.performance.test.ts b/src/__tests__/buildListItems.performance.test.ts new file mode 100644 index 0000000..e38b102 --- /dev/null +++ b/src/__tests__/buildListItems.performance.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})); + +vi.mock('../infra/task/branchGitResolver.js', () => ({ + createBranchBaseResolutionCache: vi.fn(() => ({ + allCandidateRefsByRepositoryKey: new Map(), + repositoryKeyByGitCwd: new Map(), + })), + resolveGitCwd: vi.fn((cwd: string, worktreePath?: string) => worktreePath ?? cwd), + resolveBranchBaseCommit: vi.fn((_: string, __: string, branch: string) => `base-${branch}`), + findFirstTaktCommit: vi.fn((_: string, __: string, branch: string) => ({ subject: `takt: instruction-${branch}` })), +})); + +import { execFileSync } from 'node:child_process'; +import { + buildListItems, + type BranchInfo, +} from '../infra/task/branchList.js'; +import { + findFirstTaktCommit, + resolveBranchBaseCommit, +} from '../infra/task/branchGitResolver.js'; + +const mockExecFileSync = vi.mocked(execFileSync); +const mockResolveBranchBaseCommit = vi.mocked(resolveBranchBaseCommit); +const mockFindFirstTaktCommit = vi.mocked(findFirstTaktCommit); + +describe('buildListItems performance', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockExecFileSync.mockImplementation((cmd, args) => { + if (cmd === 'git' && args[0] === 'diff') { + return '1\t0\tfile.ts\n'; + } + throw new Error(`Unexpected git args: ${args.join(' ')}`); + }); + }); + + it('should resolve base commit once per branch and reuse it for files/instruction', () => { + const branches: BranchInfo[] = [ + { branch: 'takt/20260128-task-a', commit: 'abc123' }, + { branch: 'takt/20260128-task-b', commit: 'def456' }, + ]; + + const items = buildListItems('/project', branches, 'main'); + + expect(items).toHaveLength(2); + expect(mockResolveBranchBaseCommit).toHaveBeenCalledTimes(2); + expect(mockFindFirstTaktCommit).toHaveBeenNthCalledWith( + 1, + '/project', + 'main', + 'takt/20260128-task-a', + expect.objectContaining({ baseCommit: 'base-takt/20260128-task-a' }), + ); + expect(mockFindFirstTaktCommit).toHaveBeenNthCalledWith( + 2, + '/project', + 'main', + 'takt/20260128-task-b', + expect.objectContaining({ baseCommit: 'base-takt/20260128-task-b' }), + ); + }); +}); diff --git a/src/__tests__/getFilesChanged.test.ts b/src/__tests__/getFilesChanged.test.ts index cf98b59..1b958d8 100644 --- a/src/__tests__/getFilesChanged.test.ts +++ b/src/__tests__/getFilesChanged.test.ts @@ -31,15 +31,50 @@ describe('getFilesChanged', () => { }); it('should infer base from refs when reflog is unavailable', () => { - mockExecFileSync - .mockImplementationOnce(() => { + let developMergeBaseCalls = 0; + mockExecFileSync.mockImplementation((cmd, args) => { + if (cmd !== 'git') { + throw new Error('unexpected command'); + } + + if (args[0] === 'reflog') { throw new Error('reflog unavailable'); - }) - .mockReturnValueOnce('develop\n') - .mockReturnValueOnce('base999\n') - .mockReturnValueOnce('1\n') - .mockReturnValueOnce('takt: fix auth\n') - .mockReturnValueOnce('1\t0\tfile1.ts\n'); + } + + if (args[0] === 'merge-base' && args[1] === 'develop') { + developMergeBaseCalls += 1; + if (developMergeBaseCalls === 1) { + throw new Error('priority develop failed'); + } + return 'base999\n'; + } + + if (args[0] === 'merge-base' && args[1] === 'origin/develop') { + throw new Error('priority origin/develop failed'); + } + + if (args[0] === 'rev-parse' && args[1] === '--git-common-dir') { + return '.git\n'; + } + + if (args[0] === 'for-each-ref') { + return 'develop\n'; + } + + if (args[0] === 'rev-list') { + return '1\n'; + } + + if (args[0] === 'log' && args[1] === '--format=%s') { + return 'takt: initial\n'; + } + + if (args[0] === 'diff' && args[1] === '--numstat') { + return '1\t0\tfile1.ts\n'; + } + + throw new Error(`Unexpected git args: ${args.join(' ')}`); + }); const result = getFilesChanged('/project', 'develop', 'takt/20260128-fix-auth'); diff --git a/src/__tests__/getOriginalInstruction.test.ts b/src/__tests__/getOriginalInstruction.test.ts index 24ddf09..d85fce2 100644 --- a/src/__tests__/getOriginalInstruction.test.ts +++ b/src/__tests__/getOriginalInstruction.test.ts @@ -40,15 +40,54 @@ describe('getOriginalInstruction', () => { }); it('should infer base from refs when reflog is unavailable', () => { - mockExecFileSync - .mockImplementationOnce(() => { + let developMergeBaseCalls = 0; + mockExecFileSync.mockImplementation((cmd, args) => { + if (cmd !== 'git') { + throw new Error('unexpected command'); + } + + if (args[0] === 'reflog') { throw new Error('reflog unavailable'); - }) - .mockReturnValueOnce('develop\n') - .mockReturnValueOnce('base123\n') - .mockReturnValueOnce('2\n') - .mockReturnValueOnce('takt: Initial implementation\nfollow-up\n') - .mockReturnValueOnce('first456\ttakt: Initial implementation\n'); + } + + if (args[0] === 'merge-base' && args[1] === 'main') { + throw new Error('priority main failed'); + } + + if (args[0] === 'merge-base' && args[1] === 'origin/main') { + throw new Error('priority origin/main failed'); + } + + if (args[0] === 'rev-parse' && args[1] === '--git-common-dir') { + return '.git\n'; + } + + if (args[0] === 'for-each-ref') { + return 'develop\n'; + } + + if (args[0] === 'merge-base' && args[1] === 'develop') { + developMergeBaseCalls += 1; + if (developMergeBaseCalls === 1) { + return 'base123\n'; + } + throw new Error('unexpected second develop merge-base'); + } + + if (args[0] === 'rev-list') { + return '2\n'; + } + + if (args[0] === 'log' && args[1] === '--format=%s') { + return 'takt: Initial implementation\nfollow-up\n'; + } + + if (args[0] === 'log' && args[1] === '--format=%H\t%s') { + return 'first456\ttakt: Initial implementation\n'; + } + + throw new Error(`Unexpected git args: ${args.join(' ')}`); + }); const result = getOriginalInstruction('/project', 'main', 'takt/20260128-fix-auth'); @@ -67,12 +106,7 @@ describe('getOriginalInstruction', () => { it('should return empty string when no commits on branch', () => { mockExecFileSync - .mockImplementationOnce(() => { - throw new Error('reflog unavailable'); - }) - .mockReturnValueOnce('abc123\n') - .mockReturnValueOnce('') - .mockReturnValueOnce('abc123\n') + .mockReturnValueOnce('last789\nfirst456\nbase123\n') .mockReturnValueOnce(''); const result = getOriginalInstruction('/project', 'main', 'takt/20260128-fix-auth'); diff --git a/src/infra/task/branchBaseCandidateResolver.ts b/src/infra/task/branchBaseCandidateResolver.ts new file mode 100644 index 0000000..7af983b --- /dev/null +++ b/src/infra/task/branchBaseCandidateResolver.ts @@ -0,0 +1,133 @@ +import { createLogger } from '../../shared/utils/index.js'; +import { type BranchBaseResolutionCache, listCandidateRefs } from './branchBaseRefCache.js'; +import { runGit } from './branchGitCommands.js'; + +type BaseRefCandidate = { + baseRef: string; + baseCommit: string; + firstSubject: string; + distance: number; +}; + +const TAKT_COMMIT_PREFIX = 'takt:'; +const log = createLogger('branchGitResolver'); + +export function resolveMergeBase(gitCwd: string, baseRef: string, branch: string): string { + return runGit(gitCwd, ['merge-base', baseRef, branch]); +} + +function buildPriorityRefs(defaultBranch: string, branch: string): string[] { + const refs = [defaultBranch, `origin/${defaultBranch}`]; + const distinctRefs: string[] = []; + for (const ref of refs) { + if (!ref || ref === branch || ref.endsWith(`/${branch}`)) { + continue; + } + if (!distinctRefs.includes(ref)) { + distinctRefs.push(ref); + } + } + + return distinctRefs; +} + +function getFirstParentDistance(gitCwd: string, baseCommit: string, branch: string): number { + const output = runGit(gitCwd, ['rev-list', '--count', '--first-parent', `${baseCommit}..${branch}`]); + return Number.parseInt(output, 10); +} + +function getFirstParentFirstSubject(gitCwd: string, baseCommit: string, branch: string): string { + const output = runGit(gitCwd, ['log', '--format=%s', '--reverse', '--first-parent', `${baseCommit}..${branch}`]); + const firstLine = output.split('\n')[0]; + if (!firstLine) { + return ''; + } + return firstLine.trim(); +} + +function resolveBaseCandidate(gitCwd: string, baseRef: string, branch: string): BaseRefCandidate | null { + try { + const baseCommit = resolveMergeBase(gitCwd, baseRef, branch); + if (!baseCommit) { + return null; + } + + const distance = getFirstParentDistance(gitCwd, baseCommit, branch); + if (!Number.isFinite(distance) || distance <= 0) { + return null; + } + + const firstSubject = getFirstParentFirstSubject(gitCwd, baseCommit, branch); + return { baseRef, baseCommit, firstSubject, distance }; + } catch (error) { + log.debug('Failed to resolve base candidate', { error: String(error), gitCwd, baseRef, branch }); + return null; + } +} + +function chooseBestBaseCandidate(candidates: BaseRefCandidate[]): BaseRefCandidate | null { + if (candidates.length === 0) { + return null; + } + + const sorted = [...candidates].sort((a, b) => { + const aTakt = a.firstSubject.startsWith(TAKT_COMMIT_PREFIX); + const bTakt = b.firstSubject.startsWith(TAKT_COMMIT_PREFIX); + if (aTakt !== bTakt) { + return aTakt ? -1 : 1; + } + + if (a.distance !== b.distance) { + return a.distance - b.distance; + } + + const aRemote = a.baseRef.includes('/'); + const bRemote = b.baseRef.includes('/'); + if (aRemote !== bRemote) { + return aRemote ? 1 : -1; + } + + return a.baseRef.localeCompare(b.baseRef); + }); + + const best = sorted[0]; + return best ? best : null; +} + +export function resolveBranchBaseCommitFromRefs( + gitCwd: string, + defaultBranch: string, + branch: string, + cache?: BranchBaseResolutionCache, +): string | null { + const priorityRefs = buildPriorityRefs(defaultBranch, branch); + const priorityCandidates: BaseRefCandidate[] = []; + + for (const ref of priorityRefs) { + const candidate = resolveBaseCandidate(gitCwd, ref, branch); + if (candidate) { + priorityCandidates.push(candidate); + } + } + + const priorityBest = chooseBestBaseCandidate(priorityCandidates); + if (priorityBest && priorityBest.firstSubject.startsWith(TAKT_COMMIT_PREFIX)) { + return priorityBest.baseCommit; + } + + const refs = listCandidateRefs(gitCwd, branch, cache).filter(ref => !priorityRefs.includes(ref)); + const candidates: BaseRefCandidate[] = [...priorityCandidates]; + + for (const ref of refs) { + const candidate = resolveBaseCandidate(gitCwd, ref, branch); + if (candidate) { + candidates.push(candidate); + } + } + + const best = chooseBestBaseCandidate(candidates); + if (!best) { + return null; + } + return best.baseCommit; +} diff --git a/src/infra/task/branchBaseRefCache.ts b/src/infra/task/branchBaseRefCache.ts new file mode 100644 index 0000000..386c796 --- /dev/null +++ b/src/infra/task/branchBaseRefCache.ts @@ -0,0 +1,56 @@ +import { isAbsolute, resolve } from 'node:path'; +import { runGit } from './branchGitCommands.js'; + +export type BranchBaseResolutionCache = { + allCandidateRefsByRepositoryKey: Map; + repositoryKeyByGitCwd: Map; +}; + +export function createBranchBaseResolutionCache(): BranchBaseResolutionCache { + return { + allCandidateRefsByRepositoryKey: new Map(), + repositoryKeyByGitCwd: new Map(), + }; +} + +function resolveRepositoryKey(gitCwd: string, cache?: BranchBaseResolutionCache): string { + const cachedKey = cache?.repositoryKeyByGitCwd.get(gitCwd); + if (cachedKey) { + return cachedKey; + } + + const commonDir = runGit(gitCwd, ['rev-parse', '--git-common-dir']); + const repositoryKey = isAbsolute(commonDir) ? commonDir : resolve(gitCwd, commonDir); + if (cache) { + cache.repositoryKeyByGitCwd.set(gitCwd, repositoryKey); + } + return repositoryKey; +} + +function listAllCandidateRefs(gitCwd: string, cache?: BranchBaseResolutionCache): string[] { + const repositoryKey = resolveRepositoryKey(gitCwd, cache); + const cachedRefs = cache?.allCandidateRefsByRepositoryKey.get(repositoryKey); + if (cachedRefs) { + return cachedRefs; + } + + const output = runGit(gitCwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes']); + const refs = output + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .filter(ref => !ref.endsWith('/HEAD')); + + const distinctRefs = Array.from(new Set(refs)); + if (cache) { + cache.allCandidateRefsByRepositoryKey.set(repositoryKey, distinctRefs); + } + + return distinctRefs; +} + +export function listCandidateRefs(gitCwd: string, branch: string, cache?: BranchBaseResolutionCache): string[] { + return listAllCandidateRefs(gitCwd, cache) + .filter(ref => ref !== branch) + .filter(ref => !ref.endsWith(`/${branch}`)); +} diff --git a/src/infra/task/branchEntryPointResolver.ts b/src/infra/task/branchEntryPointResolver.ts new file mode 100644 index 0000000..0b25e6d --- /dev/null +++ b/src/infra/task/branchEntryPointResolver.ts @@ -0,0 +1,31 @@ +import { createLogger } from '../../shared/utils/index.js'; +import { parseDistinctHashes, runGit } from './branchGitCommands.js'; + +export type BranchEntryPoint = { + baseCommit: string; + firstCommit: string; +}; + +const log = createLogger('branchGitResolver'); + +export function resolveBranchEntryPointFromReflog(gitCwd: string, branch: string): BranchEntryPoint | null { + try { + const output = runGit(gitCwd, ['reflog', 'show', '--format=%H', branch]); + const hashes = parseDistinctHashes(output).reverse(); + if (hashes.length < 2) { + return null; + } + + return { + baseCommit: hashes[0]!, + firstCommit: hashes[1]!, + }; + } catch (error) { + log.debug('Failed to resolve branch entry point from reflog', { error: String(error), gitCwd, branch }); + return null; + } +} + +export function readCommitSubject(gitCwd: string, commit: string): string { + return runGit(gitCwd, ['show', '-s', '--format=%s', commit]); +} diff --git a/src/infra/task/branchGitCommands.ts b/src/infra/task/branchGitCommands.ts new file mode 100644 index 0000000..2c880bc --- /dev/null +++ b/src/infra/task/branchGitCommands.ts @@ -0,0 +1,25 @@ +import { execFileSync } from 'node:child_process'; + +export function runGit(gitCwd: string, args: string[]): string { + return execFileSync('git', args, { + cwd: gitCwd, + encoding: 'utf-8', + stdio: 'pipe', + }).trim(); +} + +export function parseDistinctHashes(output: string): string[] { + const hashes = output + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0); + + const distinct: string[] = []; + for (const hash of hashes) { + if (distinct[distinct.length - 1] !== hash) { + distinct.push(hash); + } + } + + return distinct; +} diff --git a/src/infra/task/branchGitResolver.ts b/src/infra/task/branchGitResolver.ts index 9b2fc1f..98b0df9 100644 --- a/src/infra/task/branchGitResolver.ts +++ b/src/infra/task/branchGitResolver.ts @@ -1,172 +1,27 @@ -import { execFileSync } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { createLogger } from '../../shared/utils/index.js'; - -type BranchEntryPoint = { - baseCommit: string; - firstCommit: string; -}; +import { runGit } from './branchGitCommands.js'; +import { + type BranchBaseResolutionCache, + createBranchBaseResolutionCache, +} from './branchBaseRefCache.js'; +import { + resolveBranchBaseCommitFromRefs, + resolveMergeBase, +} from './branchBaseCandidateResolver.js'; +import { + readCommitSubject, + resolveBranchEntryPointFromReflog, +} from './branchEntryPointResolver.js'; type FirstTaktCommit = { subject: string; }; -type BaseRefCandidate = { - baseRef: string; - baseCommit: string; - firstSubject: string; - distance: number; +type FindFirstTaktCommitOptions = { + baseCommit?: string; + cache?: BranchBaseResolutionCache; }; -const TAKT_COMMIT_PREFIX = 'takt:'; -const log = createLogger('branchGitResolver'); - -function runGit(gitCwd: string, args: string[]): string { - return execFileSync('git', args, { - cwd: gitCwd, - encoding: 'utf-8', - stdio: 'pipe', - }).trim(); -} - -function parseDistinctHashes(output: string): string[] { - const hashes = output - .split('\n') - .map(line => line.trim()) - .filter(line => line.length > 0); - - const distinct: string[] = []; - for (const hash of hashes) { - if (distinct[distinct.length - 1] !== hash) { - distinct.push(hash); - } - } - - return distinct; -} - -export function resolveGitCwd(cwd: string, worktreePath?: string): string { - return worktreePath && existsSync(worktreePath) ? worktreePath : cwd; -} - -export function resolveMergeBase(gitCwd: string, baseRef: string, branch: string): string { - return runGit(gitCwd, ['merge-base', baseRef, branch]); -} - -function listCandidateRefs(gitCwd: string, branch: string): string[] { - const output = runGit(gitCwd, [ - 'for-each-ref', - '--format=%(refname:short)', - 'refs/heads', - 'refs/remotes', - ]); - - const refs = output - .split('\n') - .map(line => line.trim()) - .filter(line => line.length > 0) - .filter(ref => ref !== branch) - .filter(ref => !ref.endsWith(`/${branch}`)) - .filter(ref => !ref.endsWith('/HEAD')); - - return Array.from(new Set(refs)); -} - -function getFirstParentDistance(gitCwd: string, baseCommit: string, branch: string): number { - const output = runGit(gitCwd, ['rev-list', '--count', '--first-parent', `${baseCommit}..${branch}`]); - return Number.parseInt(output, 10); -} - -function getFirstParentFirstSubject(gitCwd: string, baseCommit: string, branch: string): string { - const output = runGit(gitCwd, ['log', '--format=%s', '--reverse', '--first-parent', `${baseCommit}..${branch}`]); - return output.split('\n')[0]?.trim() ?? ''; -} - -function resolveBaseCandidate(gitCwd: string, baseRef: string, branch: string): BaseRefCandidate | null { - try { - const baseCommit = resolveMergeBase(gitCwd, baseRef, branch); - if (!baseCommit) { - return null; - } - - const distance = getFirstParentDistance(gitCwd, baseCommit, branch); - if (!Number.isFinite(distance) || distance <= 0) { - return null; - } - - const firstSubject = getFirstParentFirstSubject(gitCwd, baseCommit, branch); - return { baseRef, baseCommit, firstSubject, distance }; - } catch (error) { - log.debug('Failed to resolve base candidate', { error: String(error), gitCwd, baseRef, branch }); - return null; - } -} - -function chooseBestBaseCandidate(candidates: BaseRefCandidate[]): BaseRefCandidate | null { - if (candidates.length === 0) { - return null; - } - - const sorted = [...candidates].sort((a, b) => { - const aTakt = a.firstSubject.startsWith(TAKT_COMMIT_PREFIX); - const bTakt = b.firstSubject.startsWith(TAKT_COMMIT_PREFIX); - if (aTakt !== bTakt) { - return aTakt ? -1 : 1; - } - - if (a.distance !== b.distance) { - return a.distance - b.distance; - } - - const aRemote = a.baseRef.includes('/'); - const bRemote = b.baseRef.includes('/'); - if (aRemote !== bRemote) { - return aRemote ? 1 : -1; - } - - return a.baseRef.localeCompare(b.baseRef); - }); - - return sorted[0] ?? null; -} - -function resolveBranchBaseCommitFromRefs(gitCwd: string, branch: string): string | null { - const refs = listCandidateRefs(gitCwd, branch); - const candidates: BaseRefCandidate[] = []; - - for (const ref of refs) { - const candidate = resolveBaseCandidate(gitCwd, ref, branch); - if (candidate) { - candidates.push(candidate); - } - } - - const best = chooseBestBaseCandidate(candidates); - return best?.baseCommit ?? null; -} - -function resolveBranchEntryPointFromReflog(gitCwd: string, branch: string): BranchEntryPoint | null { - try { - const output = runGit(gitCwd, ['reflog', 'show', '--format=%H', branch]); - const hashes = parseDistinctHashes(output).reverse(); - if (hashes.length < 2) { - return null; - } - - return { - baseCommit: hashes[0]!, - firstCommit: hashes[1]!, - }; - } catch (error) { - log.debug('Failed to resolve branch entry point from reflog', { error: String(error), gitCwd, branch }); - return null; - } -} - -function readCommitSubject(gitCwd: string, commit: string): string { - return runGit(gitCwd, ['show', '-s', '--format=%s', commit]); -} - function parseFirstCommitLine(output: string): FirstTaktCommit | null { if (!output) { return null; @@ -187,20 +42,35 @@ function parseFirstCommitLine(output: string): FirstTaktCommit | null { }; } +export function resolveGitCwd(cwd: string, worktreePath?: string): string { + return worktreePath && existsSync(worktreePath) ? worktreePath : cwd; +} + +export { createBranchBaseResolutionCache, resolveMergeBase }; +export type { BranchBaseResolutionCache }; + export function findFirstTaktCommit( gitCwd: string, defaultBranch: string, branch: string, + options?: FindFirstTaktCommitOptions, ): FirstTaktCommit | null { - const entryPoint = resolveBranchEntryPointFromReflog(gitCwd, branch); - if (entryPoint) { - const subject = readCommitSubject(gitCwd, entryPoint.firstCommit); - return { - subject, - }; + let baseCommit: string; + if (options?.baseCommit) { + baseCommit = options.baseCommit; + } else { + const entryPoint = resolveBranchEntryPointFromReflog(gitCwd, branch); + if (entryPoint) { + const subject = readCommitSubject(gitCwd, entryPoint.firstCommit); + return { + subject, + }; + } + + const resolvedFromRefs = resolveBranchBaseCommitFromRefs(gitCwd, defaultBranch, branch, options?.cache); + baseCommit = resolvedFromRefs ? resolvedFromRefs : resolveMergeBase(gitCwd, defaultBranch, branch); } - const baseCommit = resolveBranchBaseCommitFromRefs(gitCwd, branch) ?? resolveMergeBase(gitCwd, defaultBranch, branch); const output = runGit(gitCwd, [ 'log', '--format=%H\t%s', @@ -213,11 +83,20 @@ export function findFirstTaktCommit( return parseFirstCommitLine(output); } -export function resolveBranchBaseCommit(gitCwd: string, defaultBranch: string, branch: string): string { +export function resolveBranchBaseCommit( + gitCwd: string, + defaultBranch: string, + branch: string, + cache?: BranchBaseResolutionCache, +): string { const entryPoint = resolveBranchEntryPointFromReflog(gitCwd, branch); if (entryPoint) { return entryPoint.baseCommit; } - return resolveBranchBaseCommitFromRefs(gitCwd, branch) ?? resolveMergeBase(gitCwd, defaultBranch, branch); + const baseCommitFromRefs = resolveBranchBaseCommitFromRefs(gitCwd, defaultBranch, branch, cache); + if (baseCommitFromRefs) { + return baseCommitFromRefs; + } + return resolveMergeBase(gitCwd, defaultBranch, branch); } diff --git a/src/infra/task/branchList.ts b/src/infra/task/branchList.ts index 3fe5eef..5b9ec3f 100644 --- a/src/infra/task/branchList.ts +++ b/src/infra/task/branchList.ts @@ -9,9 +9,11 @@ import { execFileSync } from 'node:child_process'; import { createLogger } from '../../shared/utils/index.js'; import { + createBranchBaseResolutionCache, findFirstTaktCommit, resolveBranchBaseCommit, resolveGitCwd, + type BranchBaseResolutionCache, } from './branchGitResolver.js'; import type { BranchInfo, BranchListItem } from './types.js'; @@ -118,18 +120,34 @@ export class BranchManager { } /** Get the number of files changed between a branch and its inferred base commit */ - getFilesChanged(cwd: string, defaultBranch: string, branch: string, worktreePath?: string): number { + getFilesChanged( + cwd: string, + defaultBranch: string, + branch: string, + worktreePath?: string, + baseCommit?: string | null, + cache?: BranchBaseResolutionCache, + ): number { try { const gitCwd = resolveGitCwd(cwd, worktreePath); - const baseCommit = resolveBranchBaseCommit(gitCwd, defaultBranch, branch); - if (!baseCommit) { + let resolvedBaseCommit: string; + if (baseCommit === null) { + throw new Error(`Failed to resolve base commit for branch: ${branch}`); + } + if (baseCommit) { + resolvedBaseCommit = baseCommit; + } else { + resolvedBaseCommit = resolveBranchBaseCommit(gitCwd, defaultBranch, branch, cache); + } + + if (!resolvedBaseCommit) { throw new Error(`Failed to resolve base commit for branch: ${branch}`); } - log.debug('getFilesChanged', { gitCwd, baseCommit, branch, worktreePath }); + log.debug('getFilesChanged', { gitCwd, baseCommit: resolvedBaseCommit, branch, worktreePath }); const output = execFileSync( - 'git', ['diff', '--numstat', `${baseCommit}..${branch}`], + 'git', ['diff', '--numstat', `${resolvedBaseCommit}..${branch}`], { cwd: gitCwd, encoding: 'utf-8', stdio: 'pipe' }, ); @@ -158,9 +176,21 @@ export class BranchManager { cwd: string, defaultBranch: string, branch: string, + baseCommit?: string | null, + cache?: BranchBaseResolutionCache, + worktreePath?: string, ): string { try { - const firstTaktCommit = findFirstTaktCommit(cwd, defaultBranch, branch); + if (baseCommit === null) { + throw new Error(`Failed to resolve base commit for branch: ${branch}`); + } + + const gitCwd = resolveGitCwd(cwd, worktreePath); + const resolvedBaseCommitOption = baseCommit ? baseCommit : undefined; + const firstTaktCommit = findFirstTaktCommit(gitCwd, defaultBranch, branch, { + baseCommit: resolvedBaseCommitOption, + cache, + }); if (firstTaktCommit) { const TAKT_COMMIT_PREFIX = 'takt:'; if (firstTaktCommit.subject.startsWith(TAKT_COMMIT_PREFIX)) { @@ -169,15 +199,17 @@ export class BranchManager { return firstTaktCommit.subject; } - const baseCommit = resolveBranchBaseCommit(cwd, defaultBranch, branch); - if (!baseCommit) { + const resolvedBaseCommit = baseCommit + ? baseCommit + : resolveBranchBaseCommit(gitCwd, defaultBranch, branch, cache); + if (!resolvedBaseCommit) { throw new Error(`Failed to resolve base commit for branch: ${branch}`); } const output = execFileSync( 'git', - ['log', '--format=%s', '--reverse', `${baseCommit}..${branch}`], - { cwd, encoding: 'utf-8', stdio: 'pipe' }, + ['log', '--format=%s', '--reverse', `${resolvedBaseCommit}..${branch}`], + { cwd: gitCwd, encoding: 'utf-8', stdio: 'pipe' }, ).trim(); if (!output) return ''; @@ -201,12 +233,32 @@ export class BranchManager { branches: BranchInfo[], defaultBranch: string, ): BranchListItem[] { - return branches.map(br => ({ - info: br, - filesChanged: this.getFilesChanged(projectDir, defaultBranch, br.branch, br.worktreePath), - taskSlug: BranchManager.extractTaskSlug(br.branch), - originalInstruction: this.getOriginalInstruction(projectDir, defaultBranch, br.branch), - })); + const cache = createBranchBaseResolutionCache(); + + return branches.map(br => { + const gitCwd = resolveGitCwd(projectDir, br.worktreePath); + let baseCommit: string | null = null; + + try { + baseCommit = resolveBranchBaseCommit(gitCwd, defaultBranch, br.branch, cache); + } catch (error) { + log.debug('buildListItems base commit resolution failed', { error: String(error), branch: br.branch, gitCwd }); + } + + return { + info: br, + filesChanged: this.getFilesChanged(projectDir, defaultBranch, br.branch, br.worktreePath, baseCommit, cache), + taskSlug: BranchManager.extractTaskSlug(br.branch), + originalInstruction: this.getOriginalInstruction( + projectDir, + defaultBranch, + br.branch, + baseCommit, + cache, + br.worktreePath, + ), + }; + }); } }