agent-ecosystem/src/renderer/components/team/review/pathChangeLabels.ts

125 lines
4.7 KiB
TypeScript

import { isWindowsishPath, normalizePathForComparison } from '@shared/utils/platformPath';
import type { FileChangeSummary, FileChangeWithContent } from '@shared/types';
export type PathChangeLabel =
| { kind: 'deleted' }
| { kind: 'copied' | 'moved' | 'renamed'; direction: 'from' | 'to'; otherPath: string };
export function buildPathChangeLabels(
files: readonly FileChangeSummary[],
fileContents: Record<string, FileChangeWithContent>
): Record<string, PathChangeLabel> {
const normalizeText = (s: string): string =>
s.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd();
const pathMatches = (candidate: string, relationPath: string): boolean => {
const caseInsensitive = isWindowsReviewPath(candidate) || isWindowsReviewPath(relationPath);
const normalizedCandidate = normalizeLabelComparisonPath(candidate, caseInsensitive);
const normalizedRelationPath = normalizeLabelComparisonPath(relationPath, caseInsensitive);
return (
normalizedCandidate === normalizedRelationPath ||
normalizedCandidate.endsWith(`/${normalizedRelationPath}`)
);
};
const hashFull = (s: string): string => {
// DJB2 (full string), good enough for heuristic rename/move pairing.
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
return (h >>> 0).toString(36);
};
const baseName = (p: string): string => p.split(/[\\/]/).filter(Boolean).pop() ?? p;
const out: Record<string, PathChangeLabel> = {};
for (const file of files) {
const relation =
file.ledgerSummary?.relation ??
file.snippets.find((snippet) => snippet.ledger?.relation)?.ledger?.relation;
if (relation) {
const isOldSide =
pathMatches(file.relativePath, relation.oldPath) ||
pathMatches(file.filePath, relation.oldPath);
const isNewSide =
pathMatches(file.relativePath, relation.newPath) ||
pathMatches(file.filePath, relation.newPath);
if (isOldSide !== isNewSide) {
const direction: 'from' | 'to' = isOldSide ? 'to' : 'from';
out[file.filePath] = {
kind: relation.kind === 'copy' ? 'copied' : 'renamed',
direction,
otherPath: direction === 'from' ? relation.oldPath : relation.newPath,
};
continue;
}
}
if (file.ledgerSummary?.deletedInTask || file.ledgerSummary?.latestOperation === 'delete') {
out[file.filePath] = { kind: 'deleted' };
}
}
const deletedCandidates: { file: FileChangeSummary; hash: string }[] = [];
const newCandidates: { file: FileChangeSummary; hash: string }[] = [];
for (const file of files) {
if (out[file.filePath]) continue;
const content = fileContents[file.filePath];
if (!content) continue;
const modified = content.modifiedFullContent;
const original = content.originalFullContent;
if (!file.isNewFile && modified == null) {
if (original != null) {
deletedCandidates.push({ file, hash: hashFull(normalizeText(original)) });
}
}
if (file.isNewFile && modified != null) {
newCandidates.push({ file, hash: hashFull(normalizeText(modified)) });
}
}
const deletedByHash = new Map<string, { file: FileChangeSummary; count: number }>();
for (const deleted of deletedCandidates) {
const prev = deletedByHash.get(deleted.hash);
deletedByHash.set(deleted.hash, { file: deleted.file, count: (prev?.count ?? 0) + 1 });
}
const usedDeleted = new Set<string>();
for (const nextFile of newCandidates) {
const entry = deletedByHash.get(nextFile.hash);
if (!entry) continue;
if (entry.count !== 1) continue;
const oldFile = entry.file;
if (usedDeleted.has(oldFile.filePath)) continue;
usedDeleted.add(oldFile.filePath);
const oldName = baseName(oldFile.relativePath);
const newName = baseName(nextFile.file.relativePath);
const kind: 'moved' | 'renamed' =
oldName === newName && oldFile.relativePath !== nextFile.file.relativePath
? 'moved'
: 'renamed';
out[nextFile.file.filePath] = { kind, direction: 'from', otherPath: oldFile.relativePath };
out[oldFile.filePath] = { kind, direction: 'to', otherPath: nextFile.file.relativePath };
}
for (const deleted of deletedCandidates) {
if (!usedDeleted.has(deleted.file.filePath) && !(deleted.file.filePath in out)) {
out[deleted.file.filePath] = { kind: 'deleted' };
}
}
return out;
}
function normalizeLabelComparisonPath(filePath: string, caseInsensitive: boolean): string {
const normalized = normalizePathForComparison(filePath);
return caseInsensitive ? normalized.toLowerCase() : normalized;
}
function isWindowsReviewPath(filePath: string): boolean {
return isWindowsishPath(filePath) || filePath.includes('\\');
}