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', () => {
|
it('should infer base from refs when reflog is unavailable', () => {
|
||||||
mockExecFileSync
|
let developMergeBaseCalls = 0;
|
||||||
.mockImplementationOnce(() => {
|
mockExecFileSync.mockImplementation((cmd, args) => {
|
||||||
|
if (cmd !== 'git') {
|
||||||
|
throw new Error('unexpected command');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args[0] === 'reflog') {
|
||||||
throw new Error('reflog unavailable');
|
throw new Error('reflog unavailable');
|
||||||
})
|
}
|
||||||
.mockReturnValueOnce('develop\n')
|
|
||||||
.mockReturnValueOnce('base999\n')
|
if (args[0] === 'merge-base' && args[1] === 'develop') {
|
||||||
.mockReturnValueOnce('1\n')
|
developMergeBaseCalls += 1;
|
||||||
.mockReturnValueOnce('takt: fix auth\n')
|
if (developMergeBaseCalls === 1) {
|
||||||
.mockReturnValueOnce('1\t0\tfile1.ts\n');
|
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');
|
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', () => {
|
it('should infer base from refs when reflog is unavailable', () => {
|
||||||
mockExecFileSync
|
let developMergeBaseCalls = 0;
|
||||||
.mockImplementationOnce(() => {
|
mockExecFileSync.mockImplementation((cmd, args) => {
|
||||||
|
if (cmd !== 'git') {
|
||||||
|
throw new Error('unexpected command');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args[0] === 'reflog') {
|
||||||
throw new Error('reflog unavailable');
|
throw new Error('reflog unavailable');
|
||||||
})
|
}
|
||||||
.mockReturnValueOnce('develop\n')
|
|
||||||
.mockReturnValueOnce('base123\n')
|
if (args[0] === 'merge-base' && args[1] === 'main') {
|
||||||
.mockReturnValueOnce('2\n')
|
throw new Error('priority main failed');
|
||||||
.mockReturnValueOnce('takt: Initial implementation\nfollow-up\n')
|
}
|
||||||
.mockReturnValueOnce('first456\ttakt: Initial implementation\n');
|
|
||||||
|
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');
|
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', () => {
|
it('should return empty string when no commits on branch', () => {
|
||||||
mockExecFileSync
|
mockExecFileSync
|
||||||
.mockImplementationOnce(() => {
|
.mockReturnValueOnce('last789\nfirst456\nbase123\n')
|
||||||
throw new Error('reflog unavailable');
|
|
||||||
})
|
|
||||||
.mockReturnValueOnce('abc123\n')
|
|
||||||
.mockReturnValueOnce('')
|
|
||||||
.mockReturnValueOnce('abc123\n')
|
|
||||||
.mockReturnValueOnce('');
|
.mockReturnValueOnce('');
|
||||||
|
|
||||||
const result = getOriginalInstruction('/project', 'main', 'takt/20260128-fix-auth');
|
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 { existsSync } from 'node:fs';
|
||||||
import { createLogger } from '../../shared/utils/index.js';
|
import { runGit } from './branchGitCommands.js';
|
||||||
|
import {
|
||||||
type BranchEntryPoint = {
|
type BranchBaseResolutionCache,
|
||||||
baseCommit: string;
|
createBranchBaseResolutionCache,
|
||||||
firstCommit: string;
|
} from './branchBaseRefCache.js';
|
||||||
};
|
import {
|
||||||
|
resolveBranchBaseCommitFromRefs,
|
||||||
|
resolveMergeBase,
|
||||||
|
} from './branchBaseCandidateResolver.js';
|
||||||
|
import {
|
||||||
|
readCommitSubject,
|
||||||
|
resolveBranchEntryPointFromReflog,
|
||||||
|
} from './branchEntryPointResolver.js';
|
||||||
|
|
||||||
type FirstTaktCommit = {
|
type FirstTaktCommit = {
|
||||||
subject: string;
|
subject: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BaseRefCandidate = {
|
type FindFirstTaktCommitOptions = {
|
||||||
baseRef: string;
|
baseCommit?: string;
|
||||||
baseCommit: string;
|
cache?: BranchBaseResolutionCache;
|
||||||
firstSubject: string;
|
|
||||||
distance: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 {
|
function parseFirstCommitLine(output: string): FirstTaktCommit | null {
|
||||||
if (!output) {
|
if (!output) {
|
||||||
return null;
|
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(
|
export function findFirstTaktCommit(
|
||||||
gitCwd: string,
|
gitCwd: string,
|
||||||
defaultBranch: string,
|
defaultBranch: string,
|
||||||
branch: string,
|
branch: string,
|
||||||
|
options?: FindFirstTaktCommitOptions,
|
||||||
): FirstTaktCommit | null {
|
): FirstTaktCommit | null {
|
||||||
const entryPoint = resolveBranchEntryPointFromReflog(gitCwd, branch);
|
let baseCommit: string;
|
||||||
if (entryPoint) {
|
if (options?.baseCommit) {
|
||||||
const subject = readCommitSubject(gitCwd, entryPoint.firstCommit);
|
baseCommit = options.baseCommit;
|
||||||
return {
|
} else {
|
||||||
subject,
|
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, [
|
const output = runGit(gitCwd, [
|
||||||
'log',
|
'log',
|
||||||
'--format=%H\t%s',
|
'--format=%H\t%s',
|
||||||
@ -213,11 +83,20 @@ export function findFirstTaktCommit(
|
|||||||
return parseFirstCommitLine(output);
|
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);
|
const entryPoint = resolveBranchEntryPointFromReflog(gitCwd, branch);
|
||||||
if (entryPoint) {
|
if (entryPoint) {
|
||||||
return entryPoint.baseCommit;
|
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 { execFileSync } from 'node:child_process';
|
||||||
import { createLogger } from '../../shared/utils/index.js';
|
import { createLogger } from '../../shared/utils/index.js';
|
||||||
import {
|
import {
|
||||||
|
createBranchBaseResolutionCache,
|
||||||
findFirstTaktCommit,
|
findFirstTaktCommit,
|
||||||
resolveBranchBaseCommit,
|
resolveBranchBaseCommit,
|
||||||
resolveGitCwd,
|
resolveGitCwd,
|
||||||
|
type BranchBaseResolutionCache,
|
||||||
} from './branchGitResolver.js';
|
} from './branchGitResolver.js';
|
||||||
|
|
||||||
import type { BranchInfo, BranchListItem } from './types.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 */
|
/** 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 {
|
try {
|
||||||
const gitCwd = resolveGitCwd(cwd, worktreePath);
|
const gitCwd = resolveGitCwd(cwd, worktreePath);
|
||||||
const baseCommit = resolveBranchBaseCommit(gitCwd, defaultBranch, branch);
|
let resolvedBaseCommit: string;
|
||||||
if (!baseCommit) {
|
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}`);
|
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(
|
const output = execFileSync(
|
||||||
'git', ['diff', '--numstat', `${baseCommit}..${branch}`],
|
'git', ['diff', '--numstat', `${resolvedBaseCommit}..${branch}`],
|
||||||
{ cwd: gitCwd, encoding: 'utf-8', stdio: 'pipe' },
|
{ cwd: gitCwd, encoding: 'utf-8', stdio: 'pipe' },
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -158,9 +176,21 @@ export class BranchManager {
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
defaultBranch: string,
|
defaultBranch: string,
|
||||||
branch: string,
|
branch: string,
|
||||||
|
baseCommit?: string | null,
|
||||||
|
cache?: BranchBaseResolutionCache,
|
||||||
|
worktreePath?: string,
|
||||||
): string {
|
): string {
|
||||||
try {
|
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) {
|
if (firstTaktCommit) {
|
||||||
const TAKT_COMMIT_PREFIX = 'takt:';
|
const TAKT_COMMIT_PREFIX = 'takt:';
|
||||||
if (firstTaktCommit.subject.startsWith(TAKT_COMMIT_PREFIX)) {
|
if (firstTaktCommit.subject.startsWith(TAKT_COMMIT_PREFIX)) {
|
||||||
@ -169,15 +199,17 @@ export class BranchManager {
|
|||||||
return firstTaktCommit.subject;
|
return firstTaktCommit.subject;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseCommit = resolveBranchBaseCommit(cwd, defaultBranch, branch);
|
const resolvedBaseCommit = baseCommit
|
||||||
if (!baseCommit) {
|
? baseCommit
|
||||||
|
: resolveBranchBaseCommit(gitCwd, defaultBranch, branch, cache);
|
||||||
|
if (!resolvedBaseCommit) {
|
||||||
throw new Error(`Failed to resolve base commit for branch: ${branch}`);
|
throw new Error(`Failed to resolve base commit for branch: ${branch}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const output = execFileSync(
|
const output = execFileSync(
|
||||||
'git',
|
'git',
|
||||||
['log', '--format=%s', '--reverse', `${baseCommit}..${branch}`],
|
['log', '--format=%s', '--reverse', `${resolvedBaseCommit}..${branch}`],
|
||||||
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
|
{ cwd: gitCwd, encoding: 'utf-8', stdio: 'pipe' },
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
if (!output) return '';
|
if (!output) return '';
|
||||||
@ -201,12 +233,32 @@ export class BranchManager {
|
|||||||
branches: BranchInfo[],
|
branches: BranchInfo[],
|
||||||
defaultBranch: string,
|
defaultBranch: string,
|
||||||
): BranchListItem[] {
|
): BranchListItem[] {
|
||||||
return branches.map(br => ({
|
const cache = createBranchBaseResolutionCache();
|
||||||
info: br,
|
|
||||||
filesChanged: this.getFilesChanged(projectDir, defaultBranch, br.branch, br.worktreePath),
|
return branches.map(br => {
|
||||||
taskSlug: BranchManager.extractTaskSlug(br.branch),
|
const gitCwd = resolveGitCwd(projectDir, br.worktreePath);
|
||||||
originalInstruction: this.getOriginalInstruction(projectDir, defaultBranch, br.branch),
|
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