addons/isl/src/stackEdit/diffSplit.tsblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {RepoPath} from 'shared/types/common';
b69ab319import type {CommitStackState} from './commitStackState';
b69ab3110import type {CommitRev, FileFlag, FileRev} from './common';
b69ab3111import type {DiffCommit, DiffFile, DiffLine, PartiallySelectedDiffCommit} from './diffSplitTypes';
b69ab3112
b69ab3113import {Set as ImSet, List, Range} from 'immutable';
b69ab3114import type {Repository} from 'isl-server/src/Repository';
b69ab3115import type {RepositoryContext} from 'isl-server/src/serverTypes';
b69ab3116import {readableDiffBlocks as diffBlocks, splitLines} from 'shared/diff';
b69ab3117import {nullthrows} from 'shared/utils';
b69ab3118import {FlattenLine} from '../linelog';
b69ab3119import {ABSENT_FLAG, FileState} from './common';
b69ab3120import {FileStackState} from './fileStackState';
b69ab3121import {next} from './revMath';
b69ab3122
b69ab3123/** Parameters used by `diffFile()`. */
b69ab3124type DiffFileProps = {
b69ab3125 aContent: string;
b69ab3126 bContent: string;
b69ab3127 aPath: RepoPath;
b69ab3128 bPath: RepoPath;
b69ab3129 aFlag: FileFlag;
b69ab3130 bFlag: FileFlag;
b69ab3131};
b69ab3132
b69ab3133/**
b69ab3134 * Calculate the diff for a commit. Returns a JSON-friendly format.
b69ab3135 * NOTE:
b69ab3136 * - This is not a lossless representation. Certain files (non-utf8, large) are
b69ab3137 * silently ignored.
b69ab3138 * - Renaming x to y has 2 changes: delete x, edit y (diff against x).
b69ab3139 */
b69ab3140export function diffCommit(stack: CommitStackState, rev: CommitRev): DiffCommit {
b69ab3141 const commit = nullthrows(stack.get(rev));
b69ab3142 const aRev = commit.parents.first() ?? (-1 as CommitRev);
b69ab3143 const files = stack.getPaths(rev, {text: true}).flatMap(bPath => {
b69ab3144 const bFile = stack.getFile(rev, bPath);
b69ab3145 const aPath = bFile.copyFrom ?? bPath;
b69ab3146 const aFile = stack.getFile(aRev, aPath);
b69ab3147 const aContent = stack.getUtf8DataOptional(aFile);
b69ab3148 const bContent = stack.getUtf8DataOptional(bFile);
b69ab3149 if (aContent === null || bContent === null) {
b69ab3150 // Not utf-8.
b69ab3151 return [];
b69ab3152 }
b69ab3153 const aFlag = aFile.flags ?? '';
b69ab3154 const bFlag = bFile.flags ?? '';
b69ab3155 if (aContent === bContent && aFlag === bFlag) {
b69ab3156 // Not changed.
b69ab3157 return [];
b69ab3158 }
b69ab3159 return [diffFile({aContent, bContent, aPath, bPath, aFlag, bFlag})];
b69ab3160 });
b69ab3161 return {
b69ab3162 message: commit.text,
b69ab3163 files,
b69ab3164 };
b69ab3165}
b69ab3166
b69ab3167/**
b69ab3168 * Split the `rev` into `len(selections)`. Each `newDiff` specifies a subset of
b69ab3169 * line changes originally from `diffCommit(stack, rev)`.
b69ab3170 *
b69ab3171 * Designed to be robust about "bad" input of `selections`:
b69ab3172 * - If `selections` contains line references not present in
b69ab3173 * `diffCommit(stack, rev)`, they will be ignored.
b69ab3174 * - The last diff's line selection is ignored so we can force match
b69ab3175 * the content of the original commit.
b69ab3176 *
b69ab3177 * Binary or large files that are not part of `diffCommit(stack, rev)`
b69ab3178 * will be moved to the last split commit.
b69ab3179 */
b69ab3180export function applyDiffSplit(
b69ab3181 stack: CommitStackState,
b69ab3182 rev: CommitRev,
b69ab3183 selections: ReadonlyArray<PartiallySelectedDiffCommit>,
b69ab3184): CommitStackState {
b69ab3185 const originalDiff = diffCommit(stack, rev);
b69ab3186
b69ab3187 // Drop the last diff since its content is forced to match `rev`.
b69ab3188 const len = selections.length - 1;
b69ab3189 if (len < 0) {
b69ab3190 return stack;
b69ab3191 }
b69ab3192
b69ab3193 // Calculate the file contents.
b69ab3194 const affectedFiles = new Map(originalDiff.files.map(f => [f.bPath, f]));
b69ab3195 const diffFiles: Array<Map<RepoPath, [Set<number>, Set<number>]>> = selections
b69ab3196 .slice(0, len)
b69ab3197 .map(d => new Map(d.files.map(f => [f.bPath, [new Set(f.aLines), new Set(f.bLines)]])));
b69ab3198 const allRevs = ImSet(Range(0, len));
b69ab3199 const noneRevs = ImSet<number>();
b69ab31100 const fileStacks: Map<RepoPath, FileStackState> = new Map(
b69ab31101 [...affectedFiles.entries()].map(([path, file]) => {
b69ab31102 const lines = file.lines.map(({a, b, content}) => {
b69ab31103 let revs = allRevs;
b69ab31104 if (a == null && b != null) {
b69ab31105 // Figure out which rev adds (selects) the line.
b69ab31106 const rev = diffFiles.findIndex(map => map.get(path)?.[1]?.has(b));
b69ab31107 revs = rev == -1 ? noneRevs : ImSet(Range(rev, len));
b69ab31108 } else if (b == null && a != null) {
b69ab31109 // Figure out which rev removes (selects) the line.
b69ab31110 const rev = diffFiles.findIndex(map => map.get(path)?.[0]?.has(a));
b69ab31111 revs = rev == -1 ? allRevs : ImSet(Range(0, rev));
b69ab31112 }
b69ab31113 return new FlattenLine({revs, data: content});
b69ab31114 });
b69ab31115 const fileStack = new FileStackState([]);
b69ab31116 return [path, fileStack.fromFlattenLines(List(lines), len)];
b69ab31117 }),
b69ab31118 );
b69ab31119
b69ab31120 // Create new commits and populate their content.
b69ab31121 const copyFromMap = new Map(
b69ab31122 [...affectedFiles.values()].map(file => [
b69ab31123 file.bPath,
b69ab31124 file.aPath === file.bPath ? undefined : file.aPath,
b69ab31125 ]),
b69ab31126 );
b69ab31127 let newStack = stack;
b69ab31128 selections.slice(0, len).forEach((selection, i) => {
b69ab31129 const currentRev = next(rev, i);
b69ab31130 newStack = newStack.insertEmpty(currentRev, selection.message, currentRev);
b69ab31131 selection.files.forEach(file => {
b69ab31132 const content = fileStacks.get(file.bPath)?.getRev(i as FileRev);
b69ab31133 if (content != null) {
b69ab31134 // copyFrom is set when the file is first modified.
b69ab31135 const copyFrom: string | undefined =
b69ab31136 file.bFlag === ABSENT_FLAG ? undefined : copyFromMap.get(file.bPath);
b69ab31137 newStack = newStack.setFile(currentRev, file.bPath, _f =>
b69ab31138 FileState({data: content, copyFrom, flags: file.bFlag ?? ''}),
b69ab31139 );
b69ab31140 copyFromMap.delete(file.bPath);
b69ab31141 }
b69ab31142 });
b69ab31143 });
b69ab31144
b69ab31145 // Update commit message of the last commit.
b69ab31146 newStack = newStack.editCommitMessage(next(rev, len), selections[len].message);
b69ab31147
b69ab31148 return newStack;
b69ab31149}
b69ab31150
b69ab31151/** Produce a readable diff for debugging or testing purpose. */
b69ab31152export function displayDiff(diff: DiffCommit): string {
b69ab31153 const output = [diff.message.trimEnd(), '\n'];
b69ab31154 diff.files.forEach(file => {
b69ab31155 output.push(`diff a/${file.aPath} b/${file.bPath}\n`);
b69ab31156 if (file.aFlag !== file.bFlag) {
b69ab31157 if (file.bFlag === ABSENT_FLAG) {
b69ab31158 output.push(`deleted file mode ${flagToMode(file.aFlag)}\n`);
b69ab31159 } else if (file.aFlag === ABSENT_FLAG) {
b69ab31160 output.push(`new file mode ${flagToMode(file.bFlag)}\n`);
b69ab31161 } else {
b69ab31162 output.push(`old mode ${flagToMode(file.aFlag)}\n`);
b69ab31163 output.push(`new mode ${flagToMode(file.bFlag)}\n`);
b69ab31164 }
b69ab31165 }
b69ab31166 if (file.aPath !== file.bPath) {
b69ab31167 output.push(`copy from ${file.aPath}\n`);
b69ab31168 output.push(`copy to ${file.bPath}\n`);
b69ab31169 }
b69ab31170 file.lines.forEach(line => {
b69ab31171 const sign = line.a == null ? '+' : line.b == null ? '-' : ' ';
b69ab31172 output.push(`${sign}${line.content}`);
b69ab31173 if (!line.content.includes('\n')) {
b69ab31174 output.push('\n\\ No newline at end of file');
b69ab31175 }
b69ab31176 });
b69ab31177 });
b69ab31178 return output.join('');
b69ab31179}
b69ab31180
b69ab31181function flagToMode(flag: FileFlag): string {
b69ab31182 switch (flag) {
b69ab31183 case '':
b69ab31184 return '100644';
b69ab31185 case 'x':
b69ab31186 return '100755';
b69ab31187 case 'l':
b69ab31188 return '120000';
b69ab31189 case 'm':
b69ab31190 return '160000';
b69ab31191 default:
b69ab31192 return '100644';
b69ab31193 }
b69ab31194}
b69ab31195
b69ab31196/** Produce `DiffFile` based on contents of both sides. */
b69ab31197export function diffFile({
b69ab31198 aContent,
b69ab31199 bContent,
b69ab31200 aPath,
b69ab31201 bPath,
b69ab31202 aFlag,
b69ab31203 bFlag,
b69ab31204}: DiffFileProps): DiffFile {
b69ab31205 const aLines = splitLines(aContent);
b69ab31206 const bLines = splitLines(bContent);
b69ab31207 const lines: DiffLine[] = [];
b69ab31208 diffBlocks(aLines, bLines).forEach(([sign, [a1, a2, b1, b2]]) => {
b69ab31209 if (sign === '=') {
b69ab31210 for (let ai = a1; ai < a2; ++ai) {
b69ab31211 lines.push({a: ai, b: ai + b1 - a1, content: aLines[ai]});
b69ab31212 }
b69ab31213 } else {
b69ab31214 for (let ai = a1; ai < a2; ++ai) {
b69ab31215 lines.push({a: ai, b: null, content: aLines[ai]});
b69ab31216 }
b69ab31217 for (let bi = b1; bi < b2; ++bi) {
b69ab31218 lines.push({a: null, b: bi, content: bLines[bi]});
b69ab31219 }
b69ab31220 }
b69ab31221 });
b69ab31222 return {
b69ab31223 aPath,
b69ab31224 bPath,
b69ab31225 aFlag,
b69ab31226 bFlag,
b69ab31227 lines,
b69ab31228 };
b69ab31229}
b69ab31230/**
b69ab31231 * Calculate the diff between two commits using `sl debugexport stack`.
b69ab31232 * This is similar to `diffCommit` but works with commit hashes instead of CommitStackState.
b69ab31233 *
b69ab31234 * @param runSlCommand - Function to run sl commands, typically from SaplingRepository.runSlCommand
b69ab31235 * @param commitHash - The commit hash to diff
b69ab31236 * @param parentHash - The parent commit hash to diff against
b69ab31237 * @returns DiffCommit containing the message and file diffs
b69ab31238 */
b69ab31239export async function diffCurrentCommit(
b69ab31240 repo: Repository,
b69ab31241 ctx: RepositoryContext,
b69ab31242): Promise<DiffCommit> {
b69ab31243 // Export both commits
b69ab31244 const results = await repo.runCommand(
b69ab31245 ['debugexportstack', '-r', '.|.^'],
b69ab31246 'ExportStackCommand',
b69ab31247 ctx,
b69ab31248 );
b69ab31249
b69ab31250 if (results.exitCode !== 0) {
b69ab31251 throw new Error(`Failed to export commit . ${results.stderr}`);
b69ab31252 }
b69ab31253
b69ab31254 // Parse the exported stacks
b69ab31255 const stack: Array<{
b69ab31256 node: string;
b69ab31257 text: string;
b69ab31258 requested: boolean;
b69ab31259 files?: {[path: string]: {data?: string; flags?: FileFlag; copyFrom?: RepoPath} | null};
b69ab31260 relevantFiles?: {[path: string]: {data?: string; flags?: FileFlag; copyFrom?: RepoPath} | null};
b69ab31261 }> = JSON.parse(results.stdout);
b69ab31262 const requestedCommits = stack.filter(commit => commit.requested);
b69ab31263
b69ab31264 if (requestedCommits.length !== 2) {
b69ab31265 throw new Error(`Expected 2 commits from debugexportstack, got ${requestedCommits.length}`);
b69ab31266 }
b69ab31267
b69ab31268 // The second requested commit is the current one (.), the first is parent (.^)
b69ab31269 // because debugexportstack sorts topologically (ancestors first, descendants last)
b69ab31270 const parentCommit = requestedCommits[0];
b69ab31271 const currentCommit = requestedCommits[1];
b69ab31272
b69ab31273 // Get all file paths from the commit
b69ab31274 const commitFiles = currentCommit.files ?? {};
b69ab31275 const parentFiles = parentCommit.files ?? {};
b69ab31276 const parentRelevantFiles = parentCommit.relevantFiles ?? {};
b69ab31277
b69ab31278 // Collect all paths that changed
b69ab31279 const allPaths = new Set([...Object.keys(commitFiles)]);
b69ab31280
b69ab31281 const files = [];
b69ab31282 for (const bPath of allPaths) {
b69ab31283 const bFile = commitFiles[bPath];
b69ab31284 const aPath = bFile?.copyFrom ?? bPath;
b69ab31285 // Get parent file from either files or relevantFiles
b69ab31286 const aFile =
b69ab31287 aPath === bPath
b69ab31288 ? (parentFiles[bPath] ?? parentRelevantFiles[bPath])
b69ab31289 : (parentFiles[aPath] ?? parentRelevantFiles[aPath]);
b69ab31290
b69ab31291 const aContent = aFile?.data ?? '';
b69ab31292 const bContent = bFile?.data ?? '';
b69ab31293
b69ab31294 // Skip if both are null (shouldn't happen, but be safe)
b69ab31295 if (aFile === null && bFile === null) {
b69ab31296 continue;
b69ab31297 }
b69ab31298
b69ab31299 const aFlag = aFile?.flags ?? '';
b69ab31300 const bFlag = bFile?.flags ?? '';
b69ab31301
b69ab31302 // Skip if content and flags are unchanged
b69ab31303 if (aContent === bContent && aFlag === bFlag) {
b69ab31304 continue;
b69ab31305 }
b69ab31306
b69ab31307 const diff = diffFile({aContent, bContent, aPath, bPath, aFlag, bFlag});
b69ab31308 const reducedLines = reduceContextualLines(diff.lines, 10);
b69ab31309 files.push({...diff, lines: reducedLines});
b69ab31310 }
b69ab31311
b69ab31312 return {
b69ab31313 message: currentCommit.text,
b69ab31314 files,
b69ab31315 };
b69ab31316}
b69ab31317
b69ab31318export type PhabricatorAiDiffSplitCommitDiffFileLine = {
b69ab31319 a: number | null;
b69ab31320 b: number | null;
b69ab31321 content: string;
b69ab31322};
b69ab31323
b69ab31324/**
b69ab31325 * Reduces the number of lines in a diff by keeping only the lines that are within
b69ab31326 * a specified number of lines from a changed line.
b69ab31327 *
b69ab31328 * @param lines The lines to filter
b69ab31329 * @param maxContextLines The maximum number of lines to keep around each changed line
b69ab31330 * @returns A new array with only the lines that are within the specified number of lines from a changed line
b69ab31331 */
b69ab31332export function reduceContextualLines(
b69ab31333 lines: ReadonlyArray<PhabricatorAiDiffSplitCommitDiffFileLine>,
b69ab31334 maxContextLines: number = 3,
b69ab31335): Array<PhabricatorAiDiffSplitCommitDiffFileLine> {
b69ab31336 const distanceToLastClosestChangedLine: number[] = [];
b69ab31337 let lastClosestChangedLineIndex = -1;
b69ab31338
b69ab31339 for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
b69ab31340 const line = lines[lineIndex];
b69ab31341
b69ab31342 const a = line.a;
b69ab31343 const b = line.b;
b69ab31344 if ((a == null && b != null) || (a != null && b == null)) {
b69ab31345 // line was added or removed
b69ab31346 lastClosestChangedLineIndex = lineIndex;
b69ab31347 }
b69ab31348
b69ab31349 if (lastClosestChangedLineIndex === -1) {
b69ab31350 distanceToLastClosestChangedLine.push(Number.MAX_SAFE_INTEGER);
b69ab31351 } else {
b69ab31352 distanceToLastClosestChangedLine.push(lineIndex - lastClosestChangedLineIndex);
b69ab31353 }
b69ab31354 }
b69ab31355
b69ab31356 const distanceToNextClosestChangedLine: number[] = [];
b69ab31357 let nextClosestChangedLineIndex = -1;
b69ab31358
b69ab31359 for (let lineIndex = lines.length - 1; lineIndex >= 0; lineIndex--) {
b69ab31360 const line = lines[lineIndex];
b69ab31361
b69ab31362 const a = line.a;
b69ab31363 const b = line.b;
b69ab31364 if ((a == null && b != null) || (a != null && b == null)) {
b69ab31365 // line was added or removed
b69ab31366 nextClosestChangedLineIndex = lineIndex;
b69ab31367 }
b69ab31368
b69ab31369 if (nextClosestChangedLineIndex === -1) {
b69ab31370 distanceToNextClosestChangedLine.push(Number.MAX_SAFE_INTEGER);
b69ab31371 } else {
b69ab31372 distanceToNextClosestChangedLine.push(nextClosestChangedLineIndex - lineIndex);
b69ab31373 }
b69ab31374 }
b69ab31375
b69ab31376 // Reverse the array since we built it backwards
b69ab31377 distanceToNextClosestChangedLine.reverse();
b69ab31378
b69ab31379 const newLines: Array<PhabricatorAiDiffSplitCommitDiffFileLine> = [];
b69ab31380
b69ab31381 for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
b69ab31382 if (
b69ab31383 distanceToLastClosestChangedLine[lineIndex] <= maxContextLines ||
b69ab31384 distanceToNextClosestChangedLine[lineIndex] <= maxContextLines
b69ab31385 ) {
b69ab31386 newLines.push(lines[lineIndex]);
b69ab31387 }
b69ab31388 }
b69ab31389
b69ab31390 return newLines;
b69ab31391}