18.3 KB543 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 {RepositoryReference} from 'isl-server/src/RepositoryCache';
9import type {ServerSideTracker} from 'isl-server/src/analytics/serverSideTracker';
10import type {Logger} from 'isl-server/src/logger';
11import type {RepoRelativePath} from 'isl/src/types';
12import {GeneratedStatus, type ChangedFile} from 'isl/src/types';
13import type {Comparison} from 'shared/Comparison';
14import type {Writable} from 'shared/typeUtils';
15import type {
16 SaplingChangedFile,
17 SaplingCommandOutput,
18 SaplingCommitInfo,
19 SaplingComparison,
20 SaplingConflictContext,
21 SaplingCurrentCommitDiff,
22 SaplingRepository,
23} from './api/types';
24import type {EnabledSCMApiFeature} from './types';
25
26import {generatedFilesDetector} from 'isl-server/src/GeneratedFiles';
27import {Repository} from 'isl-server/src/Repository';
28import {repositoryCache} from 'isl-server/src/RepositoryCache';
29import type {TrackEventName} from 'isl-server/src/analytics/eventNames';
30import {getMainFetchTemplate, parseCommitInfoOutput} from 'isl-server/src/templates';
31import {ResolveOperation, ResolveTool} from 'isl/src/operations/ResolveOperation';
32import {diffCurrentCommit} from 'isl/src/stackEdit/diffSplit';
33import type {DiffCommit} from 'isl/src/stackEdit/diffSplitTypes';
34import * as path from 'path';
35import {beforeRevsetForComparison, ComparisonType} from 'shared/Comparison';
36import {filterFilesFromPatch, parsePatch} from 'shared/patch/parse';
37import {notEmpty} from 'shared/utils';
38import * as vscode from 'vscode';
39import {encodeSaplingDiffUri} from './DiffContentProvider';
40import IgnoredFileDecorationProvider from './IgnoredFileDecorationProvider';
41import SaplingFileDecorationProvider from './SaplingFileDecorationProvider';
42import {executeVSCodeCommand} from './commands';
43import {getCLICommand} from './config';
44import {t} from './i18n';
45
46const mergeConflictStartRegex = new RegExp('<{7}|>{7}|[|]{7}');
47
48export class VSCodeReposList {
49 private knownRepos = new Map</* attached folder root */ string, RepositoryReference>();
50 private vscodeRepos = new Map</* repo root path */ string, VSCodeRepo>();
51 private disposables: Array<vscode.Disposable> = [];
52
53 private reposByPath = new Map</* arbitrary subpath of repo */ string, VSCodeRepo>();
54
55 constructor(
56 private logger: Logger,
57 private tracker: ServerSideTracker,
58 private enabledFeatures: Set<EnabledSCMApiFeature>,
59 ) {
60 if (vscode.workspace.workspaceFolders) {
61 this.updateRepos(vscode.workspace.workspaceFolders, []);
62 }
63 this.disposables.push(
64 vscode.workspace.onDidChangeWorkspaceFolders(e => {
65 this.updateRepos(e.added, e.removed);
66 }),
67 );
68 // TODO: consider also listening for vscode.workspace.onDidOpenTextDocument to support repos
69 // for ad-hoc non-workspace-folder files
70 }
71
72 private updateRepos(
73 added: ReadonlyArray<vscode.WorkspaceFolder>,
74 removed: ReadonlyArray<vscode.WorkspaceFolder>,
75 ) {
76 for (const add of added) {
77 const {fsPath} = add.uri;
78 if (this.knownRepos.has(fsPath)) {
79 throw new Error(`Attempted to add workspace folder path twice: ${fsPath}`);
80 }
81 const repoReference = repositoryCache.getOrCreate({
82 cwd: fsPath,
83 cmd: getCLICommand(),
84 logger: this.logger,
85 tracker: this.tracker,
86 });
87 this.knownRepos.set(fsPath, repoReference);
88 repoReference.promise.then(repo => {
89 if (repo instanceof Repository) {
90 const root = repo?.info.repoRoot;
91 const existing = this.vscodeRepos.get(root);
92 if (existing) {
93 return;
94 }
95 const vscodeRepo = new VSCodeRepo(repo, this.logger, this.enabledFeatures);
96 this.vscodeRepos.set(root, vscodeRepo);
97 repo.onDidDispose(() => {
98 vscodeRepo.dispose();
99 this.vscodeRepos.delete(root);
100 });
101
102 this.emitActiveRepos();
103 }
104 });
105 }
106 for (const remove of removed) {
107 const {fsPath} = remove.uri;
108 const repo = this.knownRepos.get(fsPath);
109 repo?.unref();
110 this.knownRepos.delete(fsPath);
111 }
112
113 executeVSCodeCommand('setContext', 'sapling:hasRepo', this.knownRepos.size > 0);
114
115 Promise.all(Array.from(this.knownRepos.values()).map(repo => repo.promise)).then(repos => {
116 const hasRemoteLinkRepo = repos.some(
117 repo => repo instanceof Repository && repo.codeReviewProvider?.getRemoteFileURL,
118 );
119 executeVSCodeCommand('setContext', 'sapling:hasRemoteLinkRepo', hasRemoteLinkRepo);
120 });
121 }
122
123 /** return the VSCodeRepo that contains the given path */
124 public repoForPath(path: string): VSCodeRepo | undefined {
125 if (this.reposByPath.has(path)) {
126 return this.reposByPath.get(path);
127 }
128 for (const value of this.vscodeRepos.values()) {
129 if (path.startsWith(value.rootPath)) {
130 return value;
131 }
132 }
133 return undefined;
134 }
135
136 public repoForPhabricatorCallsign(callsign: string): VSCodeRepo | undefined {
137 for (const repo of this.vscodeRepos.values()) {
138 const system = repo.repo.info.codeReviewSystem;
139 if (system.type === 'phabricator' && system.callsign === callsign) {
140 return repo;
141 }
142 }
143 return undefined;
144 }
145
146 private emitActiveRepos() {
147 for (const cb of this.updateCallbacks) {
148 cb(Array.from(this.vscodeRepos.values()));
149 }
150 }
151
152 private updateCallbacks: Array<(repos: Array<VSCodeRepo>) => void> = [];
153 /** Subscribe to the list of active repositories */
154 public observeActiveRepos(cb: (repos: Array<VSCodeRepo>) => void): vscode.Disposable {
155 this.updateCallbacks.push(cb);
156 return {
157 dispose: () => {
158 this.updateCallbacks = this.updateCallbacks.filter(c => c !== cb);
159 },
160 };
161 }
162
163 public getCurrentActiveRepos(): Array<VSCodeRepo> {
164 return Array.from(this.vscodeRepos.values());
165 }
166
167 public dispose() {
168 for (const disposable of this.disposables) {
169 disposable.dispose();
170 }
171 }
172}
173
174type SaplingResourceState = vscode.SourceControlResourceState & {
175 status?: string;
176};
177export type SaplingResourceGroup = vscode.SourceControlResourceGroup & {
178 resourceStates: SaplingResourceState[];
179};
180/**
181 * vscode-API-compatible repository.
182 * This handles vscode-api integrations, but defers to Repository for any actual work.
183 */
184export class VSCodeRepo implements vscode.QuickDiffProvider, SaplingRepository {
185 private disposables: Array<vscode.Disposable> = [];
186 private sourceControl?: vscode.SourceControl;
187 private resourceGroups?: Record<
188 'changes' | 'untracked' | 'unresolved' | 'resolved',
189 SaplingResourceGroup
190 >;
191 public rootUri: vscode.Uri;
192 public rootPath: string;
193
194 constructor(
195 public repo: Repository,
196 private logger: Logger,
197 private enabledFeatures: Set<EnabledSCMApiFeature>,
198 ) {
199 repo.onDidDispose(() => this.dispose());
200 this.rootUri = vscode.Uri.file(repo.info.repoRoot);
201 this.rootPath = repo.info.repoRoot;
202
203 this.autoResolveFilesOnSave();
204
205 if (!this.enabledFeatures.has('sidebar')) {
206 // if sidebar is not enabled, VSCodeRepo is mostly useless, but still used for checking which paths can be used for ISL and blame.
207 return;
208 }
209
210 this.sourceControl = vscode.scm.createSourceControl(
211 'sapling',
212 t('Sapling'),
213 vscode.Uri.file(repo.info.repoRoot),
214 );
215 this.sourceControl.quickDiffProvider = this;
216 this.sourceControl.inputBox.enabled = false;
217 this.sourceControl.inputBox.visible = false;
218 this.resourceGroups = {
219 changes: this.sourceControl.createResourceGroup('changes', t('Uncommitted Changes')),
220 untracked: this.sourceControl.createResourceGroup('untracked', t('Untracked Changes')),
221 unresolved: this.sourceControl.createResourceGroup(
222 'unresolved',
223 t('Unresolved Merge Conflicts'),
224 ),
225 resolved: this.sourceControl.createResourceGroup('resolved', t('Resolved Merge Conflicts')),
226 };
227 for (const group of Object.values(this.resourceGroups)) {
228 group.hideWhenEmpty = true;
229 }
230
231 const fileDecorationProvider = new SaplingFileDecorationProvider(this, logger);
232 const ignoredFileDecorationProvider = new IgnoredFileDecorationProvider(this, logger);
233 this.disposables.push(
234 repo.subscribeToUncommittedChanges(() => {
235 this.updateResourceGroups();
236 }),
237 repo.onChangeConflictState(() => {
238 this.updateResourceGroups();
239 }),
240 fileDecorationProvider,
241 ignoredFileDecorationProvider,
242 );
243 this.updateResourceGroups();
244 }
245
246 /** If this uri is for file inside the repo or not */
247 public containsUri(uri: vscode.Uri): boolean {
248 return (
249 uri.scheme === this.rootUri.scheme &&
250 uri.authority === this.rootUri.authority &&
251 uri.fsPath.startsWith(this.rootPath)
252 );
253 }
254
255 /** If this uri is for a file inside the repo, return the repo-relative path. Otherwise, return undefined. */
256 public repoRelativeFsPath(uri: vscode.Uri): string | undefined {
257 return this.containsUri(uri) ? path.relative(this.rootPath, uri.fsPath) : undefined;
258 }
259
260 private autoResolveFilesOnSave(): vscode.Disposable {
261 return vscode.workspace.onDidSaveTextDocument(document => {
262 const repoRelativePath = this.repoRelativeFsPath(document.uri);
263 const conflicts = this.repo.getMergeConflicts();
264 if (conflicts == null || repoRelativePath == null) {
265 return;
266 }
267 const filesWithConflicts = conflicts.files?.map(file => file.path);
268 if (filesWithConflicts?.includes(repoRelativePath) !== true) {
269 return;
270 }
271 const autoResolveEnabled = vscode.workspace
272 .getConfiguration('sapling')
273 .get<boolean>('markConflictingFilesResolvedOnSave');
274 if (!autoResolveEnabled) {
275 return;
276 }
277 const allConflictsThisFileResolved = !mergeConflictStartRegex.test(document.getText());
278 if (!allConflictsThisFileResolved) {
279 return;
280 }
281 this.logger.info(
282 'auto marking file with no remaining conflicts as resolved:',
283 repoRelativePath,
284 );
285
286 this.repo.runOrQueueOperation(
287 this.repo.initialConnectionContext,
288 {
289 ...new ResolveOperation(repoRelativePath, ResolveTool.mark).getRunnableOperation(),
290 // Distinguish in analytics from manually resolving
291 trackEventName: 'AutoMarkResolvedOperation',
292 },
293 () => null,
294 );
295 });
296 }
297
298 private updateResourceGroups() {
299 if (this.resourceGroups == null || this.sourceControl == null) {
300 return;
301 }
302 const data = this.repo.getUncommittedChanges();
303 const conflicts = this.repo.getMergeConflicts()?.files;
304
305 // only show merge conflicts if they are given
306 const fileChanges = conflicts ?? data?.files?.value ?? [];
307
308 const changes: Array<SaplingResourceState> = [];
309 const untracked: Array<SaplingResourceState> = [];
310 const unresolved: Array<SaplingResourceState> = [];
311 const resolved: Array<SaplingResourceState> = [];
312
313 for (const change of fileChanges) {
314 const uri = vscode.Uri.joinPath(this.rootUri, change.path);
315 const resource: SaplingResourceState = {
316 command: {
317 command: 'vscode.open',
318 title: 'Open',
319 arguments: [uri],
320 },
321 resourceUri: uri,
322 decorations: this.decorationForChange(change),
323 status: change.status,
324 };
325 switch (change.status) {
326 case '?':
327 case '!':
328 untracked.push(resource);
329 break;
330 case 'U':
331 unresolved.push(resource);
332 break;
333 case 'Resolved':
334 resolved.push(resource);
335 break;
336 default:
337 changes.push(resource);
338 break;
339 }
340 }
341 this.resourceGroups.changes.resourceStates = changes;
342 this.resourceGroups.untracked.resourceStates = untracked;
343 this.resourceGroups.unresolved.resourceStates = unresolved;
344 this.resourceGroups.resolved.resourceStates = resolved;
345
346 // don't include resolved files in count
347 this.sourceControl.count = changes.length + untracked.length + unresolved.length;
348 }
349
350 public getResourceGroups() {
351 return this.resourceGroups;
352 }
353
354 public dispose() {
355 this.disposables.forEach(d => d?.dispose());
356 }
357
358 private decorationForChange(change: ChangedFile): vscode.SourceControlResourceDecorations {
359 const decoration: Writable<vscode.SourceControlResourceDecorations> = {};
360 switch (change.status) {
361 case 'M':
362 decoration.iconPath = new vscode.ThemeIcon('diff-modified', themeColors.modified);
363 break;
364 case 'A':
365 decoration.iconPath = new vscode.ThemeIcon('diff-added', themeColors.added);
366 break;
367 case 'R':
368 decoration.iconPath = new vscode.ThemeIcon('diff-removed', themeColors.deleted);
369 break;
370 case '?':
371 decoration.faded = true;
372 decoration.iconPath = new vscode.ThemeIcon('question', themeColors.untracked);
373 break;
374 case '!':
375 decoration.faded = true;
376 decoration.iconPath = new vscode.ThemeIcon('warning', themeColors.untracked);
377 break;
378 case 'U':
379 decoration.iconPath = new vscode.ThemeIcon('diff-ignored', themeColors.conflicting);
380 break;
381 case 'Resolved':
382 decoration.faded = true;
383 decoration.iconPath = new vscode.ThemeIcon('pass', themeColors.added);
384 break;
385 default:
386 break;
387 }
388 return decoration;
389 }
390
391 /**
392 * Use ContentProvider + encodeSaplingDiffUri
393 */
394 provideOriginalResource(uri: vscode.Uri): vscode.Uri | undefined {
395 if (uri.scheme !== 'file') {
396 return;
397 }
398 // TODO: make this configurable via vscode setting to allow
399 // diff gutters to be either uncommitted changes / head changes / stack changes
400 const comparison = {type: ComparisonType.UncommittedChanges} as Comparison;
401
402 return encodeSaplingDiffUri(uri, beforeRevsetForComparison(comparison));
403 }
404
405 ////////////////////////////////////////////////////////////////////////////////////
406
407 get info() {
408 return this.repo.info;
409 }
410
411 getDotCommit(): SaplingCommitInfo | undefined {
412 return this.repo.getHeadCommit();
413 }
414 onChangeDotCommit(callback: (commit: SaplingCommitInfo | undefined) => void): vscode.Disposable {
415 return this.repo.subscribeToHeadCommit(callback);
416 }
417 getUncommittedChanges(): ReadonlyArray<SaplingChangedFile> {
418 return this.repo.getUncommittedChanges()?.files?.value ?? [];
419 }
420 onChangeUncommittedChanges(
421 callback: (changes: ReadonlyArray<SaplingChangedFile>) => void,
422 ): vscode.Disposable {
423 return this.repo.subscribeToUncommittedChanges(result => {
424 callback(result.files?.value ?? []);
425 });
426 }
427
428 runSlCommand(
429 args: Array<string>,
430 eventName: TrackEventName | undefined = undefined,
431 ): Promise<SaplingCommandOutput> {
432 return this.repo.runCommand(args, eventName, this.repo.initialConnectionContext);
433 }
434
435 async getCurrentStack(): Promise<ReadonlyArray<SaplingCommitInfo>> {
436 const revset = 'sort(draft() and ancestors(.), topo)';
437 const result = await this.runSlCommand(
438 ['log', '--rev', revset, '--template', getMainFetchTemplate(this.info.codeReviewSystem)],
439 'GetCurrentStack',
440 );
441 if (result.exitCode === 0) {
442 return parseCommitInfoOutput(this.logger, result.stdout, this.repo.info.codeReviewSystem);
443 } else {
444 throw new Error(result.stderr);
445 }
446 }
447
448 async getFullFocusedBranch(): Promise<ReadonlyArray<SaplingCommitInfo>> {
449 const revset = 'sort(focusedbranch(.), topo)';
450 const result = await this.runSlCommand(
451 ['log', '--rev', revset, '--template', getMainFetchTemplate(this.info.codeReviewSystem)],
452 'GetFullFocusedBranch',
453 );
454 if (result.exitCode === 0) {
455 return parseCommitInfoOutput(this.logger, result.stdout, this.repo.info.codeReviewSystem);
456 } else {
457 throw new Error(result.stderr);
458 }
459 }
460
461 /** @deprecated - prefer `diff({type: 'Commit', hash: commit || '.'})` */
462 async getDiff(commit?: string): Promise<string> {
463 const result = await this.runSlCommand(['diff', '-c', commit || '.']);
464
465 if (result.exitCode === 0) {
466 return result.stdout;
467 } else {
468 throw new Error(result.stderr);
469 }
470 }
471
472 async diff(
473 comparison: Comparison | SaplingComparison,
474 options?: {excludeGenerated?: boolean},
475 ): Promise<string> {
476 const output = await this.repo.runDiff(
477 this.repo.initialConnectionContext,
478 comparison as Comparison,
479 undefined,
480 );
481
482 if (options?.excludeGenerated === true) {
483 const filenames = parsePatch(output)
484 .map(diff => diff.newFileName ?? diff.oldFileName)
485 .filter(notEmpty);
486 const generatedFiles = await this.getGeneratedPaths(filenames);
487 if (generatedFiles) {
488 return filterFilesFromPatch(output, generatedFiles);
489 }
490 }
491 return output;
492 }
493
494 async getGeneratedPaths(paths: Array<RepoRelativePath>): Promise<Array<RepoRelativePath>> {
495 const generatedMap = await generatedFilesDetector.queryFilesGenerated(
496 this.repo,
497 this.repo.initialConnectionContext,
498 this.repo.info.repoRoot,
499 paths,
500 );
501 return paths.filter(
502 path =>
503 generatedMap[path] === GeneratedStatus.Generated ||
504 generatedMap[path] === GeneratedStatus.PartiallyGenerated,
505 );
506 }
507
508 async commit(title: string, commitMessage: string): Promise<void> {
509 const message = `${title}\n\n${commitMessage}`;
510 const result = await this.runSlCommand(['commit', '-m', message]);
511
512 if (result.exitCode !== 0) {
513 throw new Error(result.stderr);
514 }
515 }
516
517 async getMergeConflictContext(): Promise<SaplingConflictContext[]> {
518 const result = await this.runSlCommand(['debugconflictcontext']);
519 if (result.exitCode !== 0) {
520 throw new Error(result.stderr);
521 }
522
523 return JSON.parse(result.stdout) as SaplingConflictContext[];
524 }
525
526 async getCurrentCommitDiff(): Promise<SaplingCurrentCommitDiff> {
527 const diff = (await diffCurrentCommit(
528 this.repo,
529 this.repo.initialConnectionContext,
530 )) as DiffCommit;
531
532 return {message: diff.message, files: diff.files};
533 }
534}
535
536const themeColors = {
537 deleted: new vscode.ThemeColor('gitDecoration.deletedResourceForeground'),
538 modified: new vscode.ThemeColor('gitDecoration.modifiedResourceForeground'),
539 added: new vscode.ThemeColor('gitDecoration.addedResourceForeground'),
540 untracked: new vscode.ThemeColor('gitDecoration.untrackedResourceForeground'),
541 conflicting: new vscode.ThemeColor('gitDecoration.conflictingResourceForeground'),
542};
543