takt: optimize-base-commit-cache (#186)

This commit is contained in:
nrs 2026-02-09 23:29:48 +09:00 committed by GitHub
parent 4ca414be6b
commit f8bcc4ce7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 765 additions and 208 deletions

View File

@ -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);
});
});

View File

@ -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<string, string[]>(),
repositoryKeyByGitCwd: new Map<string, string>(),
})),
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' }),
);
});
});

View File

@ -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');

View File

@ -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');

View File

@ -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;
}

View File

@ -0,0 +1,56 @@
import { isAbsolute, resolve } from 'node:path';
import { runGit } from './branchGitCommands.js';
export type BranchBaseResolutionCache = {
allCandidateRefsByRepositoryKey: Map<string, string[]>;
repositoryKeyByGitCwd: Map<string, string>;
};
export function createBranchBaseResolutionCache(): BranchBaseResolutionCache {
return {
allCandidateRefsByRepositoryKey: new Map<string, string[]>(),
repositoryKeyByGitCwd: new Map<string, string>(),
};
}
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}`));
}

View File

@ -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]);
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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,
),
};
});
}
}