takt: optimize-base-commit-cache (#186)
This commit is contained in:
parent
4ca414be6b
commit
f8bcc4ce7d
245
src/__tests__/branchGitResolver.performance.test.ts
Normal file
245
src/__tests__/branchGitResolver.performance.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
67
src/__tests__/buildListItems.performance.test.ts
Normal file
67
src/__tests__/buildListItems.performance.test.ts
Normal 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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
133
src/infra/task/branchBaseCandidateResolver.ts
Normal file
133
src/infra/task/branchBaseCandidateResolver.ts
Normal 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;
|
||||
}
|
||||
56
src/infra/task/branchBaseRefCache.ts
Normal file
56
src/infra/task/branchBaseRefCache.ts
Normal 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}`));
|
||||
}
|
||||
31
src/infra/task/branchEntryPointResolver.ts
Normal file
31
src/infra/task/branchEntryPointResolver.ts
Normal 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]);
|
||||
}
|
||||
25
src/infra/task/branchGitCommands.ts
Normal file
25
src/infra/task/branchGitCommands.ts
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user