12.8 KB392 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {RepoPath} from 'shared/types/common';
9import type {CommitStackState} from './commitStackState';
10import type {CommitRev, FileFlag, FileRev} from './common';
11import type {DiffCommit, DiffFile, DiffLine, PartiallySelectedDiffCommit} from './diffSplitTypes';
12
13import {Set as ImSet, List, Range} from 'immutable';
14import type {Repository} from 'isl-server/src/Repository';
15import type {RepositoryContext} from 'isl-server/src/serverTypes';
16import {readableDiffBlocks as diffBlocks, splitLines} from 'shared/diff';
17import {nullthrows} from 'shared/utils';
18import {FlattenLine} from '../linelog';
19import {ABSENT_FLAG, FileState} from './common';
20import {FileStackState} from './fileStackState';
21import {next} from './revMath';
22
23/** Parameters used by `diffFile()`. */
24type DiffFileProps = {
25 aContent: string;
26 bContent: string;
27 aPath: RepoPath;
28 bPath: RepoPath;
29 aFlag: FileFlag;
30 bFlag: FileFlag;
31};
32
33/**
34 * Calculate the diff for a commit. Returns a JSON-friendly format.
35 * NOTE:
36 * - This is not a lossless representation. Certain files (non-utf8, large) are
37 * silently ignored.
38 * - Renaming x to y has 2 changes: delete x, edit y (diff against x).
39 */
40export function diffCommit(stack: CommitStackState, rev: CommitRev): DiffCommit {
41 const commit = nullthrows(stack.get(rev));
42 const aRev = commit.parents.first() ?? (-1 as CommitRev);
43 const files = stack.getPaths(rev, {text: true}).flatMap(bPath => {
44 const bFile = stack.getFile(rev, bPath);
45 const aPath = bFile.copyFrom ?? bPath;
46 const aFile = stack.getFile(aRev, aPath);
47 const aContent = stack.getUtf8DataOptional(aFile);
48 const bContent = stack.getUtf8DataOptional(bFile);
49 if (aContent === null || bContent === null) {
50 // Not utf-8.
51 return [];
52 }
53 const aFlag = aFile.flags ?? '';
54 const bFlag = bFile.flags ?? '';
55 if (aContent === bContent && aFlag === bFlag) {
56 // Not changed.
57 return [];
58 }
59 return [diffFile({aContent, bContent, aPath, bPath, aFlag, bFlag})];
60 });
61 return {
62 message: commit.text,
63 files,
64 };
65}
66
67/**
68 * Split the `rev` into `len(selections)`. Each `newDiff` specifies a subset of
69 * line changes originally from `diffCommit(stack, rev)`.
70 *
71 * Designed to be robust about "bad" input of `selections`:
72 * - If `selections` contains line references not present in
73 * `diffCommit(stack, rev)`, they will be ignored.
74 * - The last diff's line selection is ignored so we can force match
75 * the content of the original commit.
76 *
77 * Binary or large files that are not part of `diffCommit(stack, rev)`
78 * will be moved to the last split commit.
79 */
80export function applyDiffSplit(
81 stack: CommitStackState,
82 rev: CommitRev,
83 selections: ReadonlyArray<PartiallySelectedDiffCommit>,
84): CommitStackState {
85 const originalDiff = diffCommit(stack, rev);
86
87 // Drop the last diff since its content is forced to match `rev`.
88 const len = selections.length - 1;
89 if (len < 0) {
90 return stack;
91 }
92
93 // Calculate the file contents.
94 const affectedFiles = new Map(originalDiff.files.map(f => [f.bPath, f]));
95 const diffFiles: Array<Map<RepoPath, [Set<number>, Set<number>]>> = selections
96 .slice(0, len)
97 .map(d => new Map(d.files.map(f => [f.bPath, [new Set(f.aLines), new Set(f.bLines)]])));
98 const allRevs = ImSet(Range(0, len));
99 const noneRevs = ImSet<number>();
100 const fileStacks: Map<RepoPath, FileStackState> = new Map(
101 [...affectedFiles.entries()].map(([path, file]) => {
102 const lines = file.lines.map(({a, b, content}) => {
103 let revs = allRevs;
104 if (a == null && b != null) {
105 // Figure out which rev adds (selects) the line.
106 const rev = diffFiles.findIndex(map => map.get(path)?.[1]?.has(b));
107 revs = rev == -1 ? noneRevs : ImSet(Range(rev, len));
108 } else if (b == null && a != null) {
109 // Figure out which rev removes (selects) the line.
110 const rev = diffFiles.findIndex(map => map.get(path)?.[0]?.has(a));
111 revs = rev == -1 ? allRevs : ImSet(Range(0, rev));
112 }
113 return new FlattenLine({revs, data: content});
114 });
115 const fileStack = new FileStackState([]);
116 return [path, fileStack.fromFlattenLines(List(lines), len)];
117 }),
118 );
119
120 // Create new commits and populate their content.
121 const copyFromMap = new Map(
122 [...affectedFiles.values()].map(file => [
123 file.bPath,
124 file.aPath === file.bPath ? undefined : file.aPath,
125 ]),
126 );
127 let newStack = stack;
128 selections.slice(0, len).forEach((selection, i) => {
129 const currentRev = next(rev, i);
130 newStack = newStack.insertEmpty(currentRev, selection.message, currentRev);
131 selection.files.forEach(file => {
132 const content = fileStacks.get(file.bPath)?.getRev(i as FileRev);
133 if (content != null) {
134 // copyFrom is set when the file is first modified.
135 const copyFrom: string | undefined =
136 file.bFlag === ABSENT_FLAG ? undefined : copyFromMap.get(file.bPath);
137 newStack = newStack.setFile(currentRev, file.bPath, _f =>
138 FileState({data: content, copyFrom, flags: file.bFlag ?? ''}),
139 );
140 copyFromMap.delete(file.bPath);
141 }
142 });
143 });
144
145 // Update commit message of the last commit.
146 newStack = newStack.editCommitMessage(next(rev, len), selections[len].message);
147
148 return newStack;
149}
150
151/** Produce a readable diff for debugging or testing purpose. */
152export function displayDiff(diff: DiffCommit): string {
153 const output = [diff.message.trimEnd(), '\n'];
154 diff.files.forEach(file => {
155 output.push(`diff a/${file.aPath} b/${file.bPath}\n`);
156 if (file.aFlag !== file.bFlag) {
157 if (file.bFlag === ABSENT_FLAG) {
158 output.push(`deleted file mode ${flagToMode(file.aFlag)}\n`);
159 } else if (file.aFlag === ABSENT_FLAG) {
160 output.push(`new file mode ${flagToMode(file.bFlag)}\n`);
161 } else {
162 output.push(`old mode ${flagToMode(file.aFlag)}\n`);
163 output.push(`new mode ${flagToMode(file.bFlag)}\n`);
164 }
165 }
166 if (file.aPath !== file.bPath) {
167 output.push(`copy from ${file.aPath}\n`);
168 output.push(`copy to ${file.bPath}\n`);
169 }
170 file.lines.forEach(line => {
171 const sign = line.a == null ? '+' : line.b == null ? '-' : ' ';
172 output.push(`${sign}${line.content}`);
173 if (!line.content.includes('\n')) {
174 output.push('\n\\ No newline at end of file');
175 }
176 });
177 });
178 return output.join('');
179}
180
181function flagToMode(flag: FileFlag): string {
182 switch (flag) {
183 case '':
184 return '100644';
185 case 'x':
186 return '100755';
187 case 'l':
188 return '120000';
189 case 'm':
190 return '160000';
191 default:
192 return '100644';
193 }
194}
195
196/** Produce `DiffFile` based on contents of both sides. */
197export function diffFile({
198 aContent,
199 bContent,
200 aPath,
201 bPath,
202 aFlag,
203 bFlag,
204}: DiffFileProps): DiffFile {
205 const aLines = splitLines(aContent);
206 const bLines = splitLines(bContent);
207 const lines: DiffLine[] = [];
208 diffBlocks(aLines, bLines).forEach(([sign, [a1, a2, b1, b2]]) => {
209 if (sign === '=') {
210 for (let ai = a1; ai < a2; ++ai) {
211 lines.push({a: ai, b: ai + b1 - a1, content: aLines[ai]});
212 }
213 } else {
214 for (let ai = a1; ai < a2; ++ai) {
215 lines.push({a: ai, b: null, content: aLines[ai]});
216 }
217 for (let bi = b1; bi < b2; ++bi) {
218 lines.push({a: null, b: bi, content: bLines[bi]});
219 }
220 }
221 });
222 return {
223 aPath,
224 bPath,
225 aFlag,
226 bFlag,
227 lines,
228 };
229}
230/**
231 * Calculate the diff between two commits using `sl debugexport stack`.
232 * This is similar to `diffCommit` but works with commit hashes instead of CommitStackState.
233 *
234 * @param runSlCommand - Function to run sl commands, typically from SaplingRepository.runSlCommand
235 * @param commitHash - The commit hash to diff
236 * @param parentHash - The parent commit hash to diff against
237 * @returns DiffCommit containing the message and file diffs
238 */
239export async function diffCurrentCommit(
240 repo: Repository,
241 ctx: RepositoryContext,
242): Promise<DiffCommit> {
243 // Export both commits
244 const results = await repo.runCommand(
245 ['debugexportstack', '-r', '.|.^'],
246 'ExportStackCommand',
247 ctx,
248 );
249
250 if (results.exitCode !== 0) {
251 throw new Error(`Failed to export commit . ${results.stderr}`);
252 }
253
254 // Parse the exported stacks
255 const stack: Array<{
256 node: string;
257 text: string;
258 requested: boolean;
259 files?: {[path: string]: {data?: string; flags?: FileFlag; copyFrom?: RepoPath} | null};
260 relevantFiles?: {[path: string]: {data?: string; flags?: FileFlag; copyFrom?: RepoPath} | null};
261 }> = JSON.parse(results.stdout);
262 const requestedCommits = stack.filter(commit => commit.requested);
263
264 if (requestedCommits.length !== 2) {
265 throw new Error(`Expected 2 commits from debugexportstack, got ${requestedCommits.length}`);
266 }
267
268 // The second requested commit is the current one (.), the first is parent (.^)
269 // because debugexportstack sorts topologically (ancestors first, descendants last)
270 const parentCommit = requestedCommits[0];
271 const currentCommit = requestedCommits[1];
272
273 // Get all file paths from the commit
274 const commitFiles = currentCommit.files ?? {};
275 const parentFiles = parentCommit.files ?? {};
276 const parentRelevantFiles = parentCommit.relevantFiles ?? {};
277
278 // Collect all paths that changed
279 const allPaths = new Set([...Object.keys(commitFiles)]);
280
281 const files = [];
282 for (const bPath of allPaths) {
283 const bFile = commitFiles[bPath];
284 const aPath = bFile?.copyFrom ?? bPath;
285 // Get parent file from either files or relevantFiles
286 const aFile =
287 aPath === bPath
288 ? (parentFiles[bPath] ?? parentRelevantFiles[bPath])
289 : (parentFiles[aPath] ?? parentRelevantFiles[aPath]);
290
291 const aContent = aFile?.data ?? '';
292 const bContent = bFile?.data ?? '';
293
294 // Skip if both are null (shouldn't happen, but be safe)
295 if (aFile === null && bFile === null) {
296 continue;
297 }
298
299 const aFlag = aFile?.flags ?? '';
300 const bFlag = bFile?.flags ?? '';
301
302 // Skip if content and flags are unchanged
303 if (aContent === bContent && aFlag === bFlag) {
304 continue;
305 }
306
307 const diff = diffFile({aContent, bContent, aPath, bPath, aFlag, bFlag});
308 const reducedLines = reduceContextualLines(diff.lines, 10);
309 files.push({...diff, lines: reducedLines});
310 }
311
312 return {
313 message: currentCommit.text,
314 files,
315 };
316}
317
318export type PhabricatorAiDiffSplitCommitDiffFileLine = {
319 a: number | null;
320 b: number | null;
321 content: string;
322};
323
324/**
325 * Reduces the number of lines in a diff by keeping only the lines that are within
326 * a specified number of lines from a changed line.
327 *
328 * @param lines The lines to filter
329 * @param maxContextLines The maximum number of lines to keep around each changed line
330 * @returns A new array with only the lines that are within the specified number of lines from a changed line
331 */
332export function reduceContextualLines(
333 lines: ReadonlyArray<PhabricatorAiDiffSplitCommitDiffFileLine>,
334 maxContextLines: number = 3,
335): Array<PhabricatorAiDiffSplitCommitDiffFileLine> {
336 const distanceToLastClosestChangedLine: number[] = [];
337 let lastClosestChangedLineIndex = -1;
338
339 for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
340 const line = lines[lineIndex];
341
342 const a = line.a;
343 const b = line.b;
344 if ((a == null && b != null) || (a != null && b == null)) {
345 // line was added or removed
346 lastClosestChangedLineIndex = lineIndex;
347 }
348
349 if (lastClosestChangedLineIndex === -1) {
350 distanceToLastClosestChangedLine.push(Number.MAX_SAFE_INTEGER);
351 } else {
352 distanceToLastClosestChangedLine.push(lineIndex - lastClosestChangedLineIndex);
353 }
354 }
355
356 const distanceToNextClosestChangedLine: number[] = [];
357 let nextClosestChangedLineIndex = -1;
358
359 for (let lineIndex = lines.length - 1; lineIndex >= 0; lineIndex--) {
360 const line = lines[lineIndex];
361
362 const a = line.a;
363 const b = line.b;
364 if ((a == null && b != null) || (a != null && b == null)) {
365 // line was added or removed
366 nextClosestChangedLineIndex = lineIndex;
367 }
368
369 if (nextClosestChangedLineIndex === -1) {
370 distanceToNextClosestChangedLine.push(Number.MAX_SAFE_INTEGER);
371 } else {
372 distanceToNextClosestChangedLine.push(nextClosestChangedLineIndex - lineIndex);
373 }
374 }
375
376 // Reverse the array since we built it backwards
377 distanceToNextClosestChangedLine.reverse();
378
379 const newLines: Array<PhabricatorAiDiffSplitCommitDiffFileLine> = [];
380
381 for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
382 if (
383 distanceToLastClosestChangedLine[lineIndex] <= maxContextLines ||
384 distanceToNextClosestChangedLine[lineIndex] <= maxContextLines
385 ) {
386 newLines.push(lines[lineIndex]);
387 }
388 }
389
390 return newLines;
391}
392