takt/src/infra/task/branchBaseCandidateResolver.ts

134 lines
3.8 KiB
TypeScript

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) {
return priorityBest.baseCommit;
}
const refs = listCandidateRefs(gitCwd, branch, cache).filter(ref => !priorityRefs.includes(ref));
const candidates: BaseRefCandidate[] = [];
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;
}