65.1 KB1927 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 {
9 AbsolutePath,
10 Alert,
11 ChangedFile,
12 CodeReviewSystem,
13 CommitCloudSyncState,
14 CommitInfo,
15 ConfigName,
16 CwdInfo,
17 DiffId,
18 Disposable,
19 FetchedCommits,
20 FetchedUncommittedChanges,
21 Hash,
22 MergeConflicts,
23 OperationCommandProgressReporter,
24 OperationProgress,
25 PageVisibility,
26 PreferredSubmitCommand,
27 RepoInfo,
28 RepoRelativePath,
29 Revset,
30 RunnableOperation,
31 SettableConfigName,
32 ShelvedChange,
33 StableInfo,
34 Submodule,
35 SubmodulesByRoot,
36 UncommittedChanges,
37 ValidatedRepoInfo,
38} from 'isl/src/types';
39import type {Comparison} from 'shared/Comparison';
40import type {EjecaChildProcess, EjecaOptions} from 'shared/ejeca';
41import type {CodeReviewProvider} from './CodeReviewProvider';
42import type {KindOfChange, PollKind} from './WatchForChanges';
43import type {TrackEventName} from './analytics/eventNames';
44import type {ConfigLevel, ResolveCommandConflictOutput} from './commands';
45import type {RepositoryContext} from './serverTypes';
46
47import {Set as ImSet} from 'immutable';
48import {
49 CommandRunner,
50 CommitCloudBackupStatus,
51 allConfigNames,
52 settableConfigNames,
53} from 'isl/src/types';
54import fs from 'node:fs';
55import path from 'node:path';
56import {revsetArgsForComparison} from 'shared/Comparison';
57import {LRU} from 'shared/LRU';
58import {RateLimiter} from 'shared/RateLimiter';
59import {TypedEventEmitter} from 'shared/TypedEventEmitter';
60import {ejeca, simplifyEjecaError} from 'shared/ejeca';
61import {exists} from 'shared/fs';
62import {removeLeadingPathSep} from 'shared/pathUtils';
63import {notEmpty, nullthrows, randomId} from 'shared/utils';
64import {Internal} from './Internal';
65import {OperationQueue} from './OperationQueue';
66import {PageFocusTracker} from './PageFocusTracker';
67import {WatchForChanges} from './WatchForChanges';
68import {parseAlerts} from './alerts';
69import {
70 MAX_SIMULTANEOUS_CAT_CALLS,
71 READ_COMMAND_TIMEOUT_MS,
72 computeNewConflicts,
73 extractRepoInfoFromUrl,
74 findDotDir,
75 findRoot,
76 findRoots,
77 getConfigs,
78 getExecParams,
79 runCommand,
80 setConfig,
81} from './commands';
82import {DEFAULT_DAYS_OF_COMMITS_TO_LOAD, ErrorShortMessages} from './constants';
83import {GitHubCodeReviewProvider} from './github/githubCodeReviewProvider';
84import {isGithubEnterprise} from './github/queryGraphQL';
85import {GroveCodeReviewProvider} from './grove/groveCodeReviewProvider';
86import {
87 CHANGED_FILES_FIELDS,
88 CHANGED_FILES_INDEX,
89 CHANGED_FILES_TEMPLATE,
90 COMMIT_END_MARK,
91 SHELVE_FETCH_TEMPLATE,
92 attachStableLocations,
93 getMainFetchTemplate,
94 parseCommitInfoOutput,
95 parseShelvedCommitsOutput,
96} from './templates';
97import {
98 findPublicAncestor,
99 handleAbortSignalOnProcess,
100 isEjecaError,
101 serializeAsyncCall,
102} from './utils';
103
104/**
105 * This class is responsible for providing information about the working copy
106 * for a Sapling repository.
107 *
108 * A Repository may be reused by multiple connections, not just one ISL window.
109 * This is so we don't duplicate watchman subscriptions and calls to status/log.
110 * A Repository does not have a pre-defined `cwd`, so it may be reused across cwds.
111 *
112 * Prefer using `RepositoryCache.getOrCreate()` to access and dispose `Repository`s.
113 */
114export class Repository {
115 public IGNORE_COMMIT_MESSAGE_LINES_REGEX = /^((?:HG|SL):.*)\n?/gm;
116
117 private mergeConflicts: MergeConflicts | undefined = undefined;
118 private uncommittedChanges: FetchedUncommittedChanges | null = null;
119 private smartlogCommits: FetchedCommits | null = null;
120 private submodulesByRoot: SubmodulesByRoot | undefined = undefined;
121 private submodulePathCache: ImSet<RepoRelativePath> | undefined = undefined;
122
123 private mergeConflictsEmitter = new TypedEventEmitter<'change', MergeConflicts | undefined>();
124 private uncommittedChangesEmitter = new TypedEventEmitter<'change', FetchedUncommittedChanges>();
125 private smartlogCommitsChangesEmitter = new TypedEventEmitter<'change', FetchedCommits>();
126 private submodulesChangesEmitter = new TypedEventEmitter<'change', SubmodulesByRoot>();
127
128 private smartlogCommitsBeginFetchingEmitter = new TypedEventEmitter<'start', undefined>();
129 private uncommittedChangesBeginFetchingEmitter = new TypedEventEmitter<'start', undefined>();
130
131 private disposables: Array<() => void> = [
132 () => this.mergeConflictsEmitter.removeAllListeners(),
133 () => this.uncommittedChangesEmitter.removeAllListeners(),
134 () => this.smartlogCommitsChangesEmitter.removeAllListeners(),
135 () => this.smartlogCommitsBeginFetchingEmitter.removeAllListeners(),
136 () => this.uncommittedChangesBeginFetchingEmitter.removeAllListeners(),
137 ];
138 public onDidDispose(callback: () => unknown): void {
139 this.disposables.push(callback);
140 }
141
142 private operationQueue: OperationQueue;
143 public watchForChanges: WatchForChanges;
144 private pageFocusTracker = new PageFocusTracker();
145 public codeReviewProvider?: CodeReviewProvider;
146
147 /**
148 * Config: milliseconds to hold off log/status refresh during the start of a command.
149 * This is to avoid showing messy indeterminate states (like millions of files changed
150 * during a long distance checkout, or commit graph changed but '.' is out of sync).
151 *
152 * Default: 10 seconds. Can be set by the `isl.hold-off-refresh-ms` setting.
153 */
154 public configHoldOffRefreshMs = 10000;
155
156 private configRateLimiter = new RateLimiter(1);
157
158 private currentVisibleCommitRangeIndex = 0;
159 private visibleCommitRanges: Array<number | undefined> = [
160 DEFAULT_DAYS_OF_COMMITS_TO_LOAD,
161 60,
162 undefined,
163 ];
164
165 /**
166 * Additional commits to include in batched `log` fetch,
167 * used for additional remote bookmarks / known stable commit hashes.
168 * After fetching commits, stable names will be added to commits in "stableCommitMetadata"
169 */
170 public stableLocations: Array<StableInfo> = [];
171
172 /**
173 * Recommended remote bookmarks to be include in batched `log` fetch.
174 * If a bookmark is not in the subscriptions list yet, then it will be pulled explicitly.
175 * Undefined means not yet fetched.
176 */
177 public recommendedBookmarks: Array<string> | undefined;
178
179 /**
180 * The context used when the repository was created.
181 * This is needed for subscriptions to have access to ANY logger, etc.
182 * Avoid using this, and prefer using the correct context for a given connection.
183 */
184 public initialConnectionContext: RepositoryContext;
185
186 public fullRepoBranchModule = Internal.RepositoryFullRepoBranchModule?.create(
187 this,
188 this.smartlogCommitsChangesEmitter,
189 );
190
191 /** Prefer using `RepositoryCache.getOrCreate()` to access and dispose `Repository`s. */
192 constructor(
193 public info: ValidatedRepoInfo,
194 ctx: RepositoryContext,
195 ) {
196 this.initialConnectionContext = ctx;
197
198 const remote = info.codeReviewSystem;
199 if (remote.type === 'github') {
200 this.codeReviewProvider = new GitHubCodeReviewProvider(remote, ctx.logger);
201 }
202
203 if (remote.type === 'grove') {
204 const groveConfig = readGroveConfig(ctx.logger);
205 this.codeReviewProvider = new GroveCodeReviewProvider(remote, ctx.logger, groveConfig.token);
206 }
207
208 if (remote.type === 'phabricator' && Internal?.PhabricatorCodeReviewProvider != null) {
209 this.codeReviewProvider = new Internal.PhabricatorCodeReviewProvider(
210 remote,
211 this.initialConnectionContext,
212 this.info.dotdir,
213 );
214 }
215
216 const shouldWait = (): boolean => {
217 const startTime = this.operationQueue.getRunningOperationStartTime();
218 if (startTime == null) {
219 return false;
220 }
221 // Prevent auto-refresh during the first 10 seconds of a running command.
222 // When a command is running, the intermediate state can be messy:
223 // - status errors out (edenfs), is noisy (long distance goto)
224 // - commit graph and the `.` are updated separately and hard to predict
225 // Let's just rely on optimistic state to provide the "clean" outcome.
226 // In case the command takes a long time to run, allow refresh after
227 // the time period.
228 // Fundamentally, the intermediate states have no choice but have to
229 // be messy because filesystems are not transactional (and reading in
230 // `sl` is designed to be lock-free).
231 const elapsedMs = Date.now() - startTime.valueOf();
232 const result = elapsedMs < this.configHoldOffRefreshMs;
233 return result;
234 };
235 const callback = (kind: KindOfChange, pollKind?: PollKind) => {
236 if (pollKind !== 'force' && shouldWait()) {
237 // Do nothing. This is fine because after the operation
238 // there will be a refresh.
239 ctx.logger.info('polling prevented from shouldWait');
240 return;
241 }
242 if (kind === 'uncommitted changes') {
243 this.fetchUncommittedChanges();
244 } else if (kind === 'commits') {
245 this.fetchSmartlogCommits();
246 } else if (kind === 'merge conflicts') {
247 this.checkForMergeConflicts();
248 } else if (kind === 'everything') {
249 this.fetchUncommittedChanges();
250 this.fetchSmartlogCommits();
251 this.checkForMergeConflicts();
252
253 this.codeReviewProvider?.triggerDiffSummariesFetch(
254 // We could choose to only fetch the diffs that changed (`newDiffs`) rather than all diffs,
255 // but our UI doesn't cache old values, thus all other diffs would appear empty
256 this.getAllDiffIds(),
257 );
258 this.codeReviewProvider?.triggerCanopySignalsFetch?.();
259 this.initialConnectionContext.tracker.track('DiffFetchSource', {
260 extras: {source: 'watch_for_changes', kind, pollKind},
261 });
262 }
263 };
264 this.watchForChanges = new WatchForChanges(info, this.pageFocusTracker, callback, ctx);
265
266 this.operationQueue = new OperationQueue(
267 (
268 ctx: RepositoryContext,
269 operation: RunnableOperation,
270 handleCommandProgress,
271 signal: AbortSignal,
272 ): Promise<unknown> => {
273 const {cwd} = ctx;
274 if (operation.runner === CommandRunner.Sapling) {
275 return this.runOperation(ctx, operation, handleCommandProgress, signal);
276 } else if (operation.runner === CommandRunner.CodeReviewProvider) {
277 if (this.codeReviewProvider?.runExternalCommand == null) {
278 return Promise.reject(
279 Error('CodeReviewProvider does not support running external commands'),
280 );
281 }
282
283 // TODO: support stdin
284 return (
285 this.codeReviewProvider?.runExternalCommand(
286 cwd,
287 operation.args,
288 handleCommandProgress,
289 signal,
290 ) ?? Promise.resolve()
291 );
292 } else if (operation.runner === CommandRunner.Conf) {
293 const {args: normalizedArgs} = this.normalizeOperationArgs(cwd, operation);
294 if (this.codeReviewProvider?.runConfCommand == null) {
295 return Promise.reject(
296 Error('CodeReviewProvider does not support running conf commands'),
297 );
298 }
299
300 return (
301 this.codeReviewProvider?.runConfCommand(
302 cwd,
303 normalizedArgs,
304 handleCommandProgress,
305 signal,
306 ) ?? Promise.resolve()
307 );
308 } else if (operation.runner === CommandRunner.InternalArcanist) {
309 // TODO: support stdin
310 const {args: normalizedArgs} = this.normalizeOperationArgs(cwd, operation);
311 if (Internal.runArcanistCommand == null) {
312 return Promise.reject(Error('InternalArcanist runner is not supported'));
313 }
314 ctx.logger.info('running arcanist command:', normalizedArgs);
315 return Internal.runArcanistCommand(cwd, normalizedArgs, handleCommandProgress, signal);
316 }
317 return Promise.resolve();
318 },
319 );
320
321 // refetch summaries whenever we see new diffIds
322 const seenDiffs = new Set();
323 const subscription = this.subscribeToSmartlogCommitsChanges(fetched => {
324 if (fetched.commits.value) {
325 const newDiffs = [];
326 const diffIds = fetched.commits.value
327 .filter(commit => commit.diffId != null)
328 .map(commit => commit.diffId);
329 for (const diffId of diffIds) {
330 if (!seenDiffs.has(diffId)) {
331 newDiffs.push(diffId);
332 seenDiffs.add(diffId);
333 }
334 }
335 if (newDiffs.length > 0) {
336 this.codeReviewProvider?.triggerDiffSummariesFetch(
337 // We could choose to only fetch the diffs that changed (`newDiffs`) rather than all diffs,
338 // but our UI doesn't cache old values, thus all other diffs would appear empty
339 this.getAllDiffIds(),
340 );
341 this.initialConnectionContext.tracker.track('DiffFetchSource', {
342 extras: {source: 'saw_new_diffs'},
343 });
344 }
345 }
346 });
347
348 // the repo may already be in a conflict state on startup
349 this.checkForMergeConflicts();
350
351 this.disposables.push(() => subscription.dispose());
352
353 this.applyConfigInBackground(ctx);
354
355 const headTracker = this.subscribeToHeadCommit(head => {
356 const allCommits = this.getSmartlogCommits();
357 const ancestor = findPublicAncestor(allCommits?.commits.value, head);
358 this.initialConnectionContext.tracker.track('HeadCommitChanged', {
359 extras: {
360 hash: head.hash,
361 public: ancestor?.hash,
362 bookmarks: ancestor?.remoteBookmarks,
363 },
364 });
365 });
366 this.disposables.push(headTracker.dispose);
367
368 if (this.fullRepoBranchModule != null) {
369 this.disposables.push(() => this.fullRepoBranchModule?.dispose());
370 }
371 }
372
373 public nextVisibleCommitRangeInDays(): number | undefined {
374 if (this.currentVisibleCommitRangeIndex + 1 < this.visibleCommitRanges.length) {
375 this.currentVisibleCommitRangeIndex++;
376 }
377 return this.visibleCommitRanges[this.currentVisibleCommitRangeIndex];
378 }
379
380 public isPathInsideRepo(p: AbsolutePath): boolean {
381 return path.normalize(p).startsWith(this.info.repoRoot);
382 }
383
384 /**
385 * Typically, disposing is handled by `RepositoryCache` and not used directly.
386 */
387 public dispose() {
388 this.disposables.forEach(dispose => dispose());
389 this.codeReviewProvider?.dispose();
390 this.watchForChanges.dispose();
391 }
392
393 public onChangeConflictState(
394 callback: (conflicts: MergeConflicts | undefined) => unknown,
395 ): Disposable {
396 this.mergeConflictsEmitter.on('change', callback);
397
398 if (this.mergeConflicts) {
399 // if we're already in merge conflicts, let the client know right away
400 callback(this.mergeConflicts);
401 }
402
403 return {dispose: () => this.mergeConflictsEmitter.off('change', callback)};
404 }
405
406 public checkForMergeConflicts = serializeAsyncCall(async () => {
407 this.initialConnectionContext.logger.info('checking for merge conflicts');
408 // Fast path: check if .sl/merge dir changed
409 const wasAlreadyInConflicts = this.mergeConflicts != null;
410 if (!wasAlreadyInConflicts) {
411 const mergeDirExists = await exists(path.join(this.info.dotdir, 'merge'));
412 if (!mergeDirExists) {
413 // Not in a conflict
414 this.initialConnectionContext.logger.info(
415 `conflict state still the same (${
416 wasAlreadyInConflicts ? 'IN merge conflict' : 'NOT in conflict'
417 })`,
418 );
419 return;
420 }
421 }
422
423 if (this.mergeConflicts == null) {
424 // notify UI that merge conflicts were detected and full details are loading
425 this.mergeConflicts = {state: 'loading'};
426 this.mergeConflictsEmitter.emit('change', this.mergeConflicts);
427 }
428
429 // More expensive full check for conflicts. Necessary if we see .sl/merge change, or if
430 // we're already in a conflict and need to re-check if a conflict was resolved.
431
432 let output: ResolveCommandConflictOutput;
433 const fetchStartTimestamp = Date.now();
434 try {
435 // TODO: is this command fast on large files? it includes full conflicting file contents!
436 // `sl resolve --list --all` does not seem to give any way to disambiguate (all conflicts resolved) and (not in merge)
437 const proc = await this.runCommand(
438 ['resolve', '--tool', 'internal:dumpjson', '--all'],
439 'GetConflictsCommand',
440 this.initialConnectionContext,
441 );
442 output = JSON.parse(proc.stdout) as ResolveCommandConflictOutput;
443 } catch (err) {
444 this.initialConnectionContext.logger.error(`failed to check for merge conflicts: ${err}`);
445 // To avoid being stuck in "loading" state forever, let's pretend there's no conflicts.
446 this.mergeConflicts = undefined;
447 this.mergeConflictsEmitter.emit('change', this.mergeConflicts);
448 return;
449 }
450
451 this.mergeConflicts = computeNewConflicts(this.mergeConflicts, output, fetchStartTimestamp);
452 this.initialConnectionContext.logger.info(
453 `repo ${this.mergeConflicts ? 'IS' : 'IS NOT'} in merge conflicts`,
454 );
455 if (this.mergeConflicts) {
456 const maxConflictsToLog = 20;
457 const remainingConflicts = (this.mergeConflicts.files ?? [])
458 .filter(conflict => conflict.status === 'U')
459 .map(conflict => conflict.path)
460 .slice(0, maxConflictsToLog);
461 this.initialConnectionContext.logger.info(
462 'remaining files with conflicts: ',
463 remainingConflicts,
464 );
465 }
466 this.mergeConflictsEmitter.emit('change', this.mergeConflicts);
467
468 if (!wasAlreadyInConflicts && this.mergeConflicts) {
469 this.initialConnectionContext.tracker.track('EnterMergeConflicts', {
470 extras: {numConflicts: this.mergeConflicts.files?.length ?? 0},
471 });
472 } else if (wasAlreadyInConflicts && !this.mergeConflicts) {
473 this.initialConnectionContext.tracker.track('ExitMergeConflicts', {extras: {}});
474 }
475 });
476
477 public getMergeConflicts(): MergeConflicts | undefined {
478 return this.mergeConflicts;
479 }
480
481 public async getMergeTool(ctx: RepositoryContext): Promise<string | null> {
482 // treat undefined as "not cached", and null as "not configured"/invalid
483 if (ctx.cachedMergeTool !== undefined) {
484 return ctx.cachedMergeTool;
485 }
486 const tool = ctx.knownConfigs?.get('ui.merge') ?? 'internal:merge';
487 let usesCustomMerge = tool !== 'internal:merge';
488
489 if (usesCustomMerge) {
490 // TODO: we could also check merge-tools.${tool}.disabled here
491 const customToolUsesGui =
492 (
493 await this.forceGetConfig(ctx, `merge-tools.${tool}.gui`).catch(() => undefined)
494 )?.toLowerCase() === 'true';
495 if (!customToolUsesGui) {
496 ctx.logger.warn(
497 `configured custom merge tool '${tool}' is not a GUI tool, using :merge3 instead`,
498 );
499 usesCustomMerge = false;
500 } else {
501 ctx.logger.info(`using configured custom GUI merge tool ${tool}`);
502 }
503 ctx.tracker.track('UsingExternalMergeTool', {
504 extras: {
505 tool,
506 isValid: usesCustomMerge,
507 },
508 });
509 } else {
510 ctx.logger.info(`using default :merge3 merge tool`);
511 }
512
513 const mergeTool = usesCustomMerge ? tool : null;
514 ctx.cachedMergeTool = mergeTool;
515 return mergeTool;
516 }
517
518 /**
519 * Determine basic repo info including the root and important config values.
520 * Resulting RepoInfo may have null fields if cwd is not a valid repo root.
521 * Throws if `command` is not found.
522 */
523 static async getRepoInfo(ctx: RepositoryContext): Promise<RepoInfo> {
524 const {cmd, cwd, logger} = ctx;
525 const [repoRoot, repoRoots, dotdir, configs] = await Promise.all([
526 findRoot(ctx).catch((err: Error) => err),
527 findRoots(ctx),
528 findDotDir(ctx),
529 // TODO: This should actually use expanded paths, since the config won't handle custom schemes.
530 // However, `sl debugexpandpaths` is currently too slow and impacts startup time.
531 getConfigs(ctx, [
532 'paths.default',
533 'github.pull_request_domain',
534 'github.preferred_submit_command',
535 'phrevset.callsign',
536 'remotefilelog.reponame',
537 'ui.username',
538 'grove.owner',
539 'grove.api_url',
540 ]),
541 ]);
542 const pathsDefault = configs.get('paths.default') ?? '';
543 const preferredSubmitCommand = configs.get('github.preferred_submit_command');
544
545 if (repoRoot instanceof Error) {
546 // first check that the cwd exists
547 const cwdExists = await exists(cwd);
548 if (!cwdExists) {
549 return {type: 'cwdDoesNotExist', cwd};
550 }
551
552 return {
553 type: 'invalidCommand',
554 command: cmd,
555 path: process.env.PATH,
556 };
557 }
558 if (repoRoot == null || dotdir == null) {
559 // A seemingly invalid repo may just be from EdenFS not running properly
560 if (await isUnhealthyEdenFs(cwd)) {
561 return {type: 'edenFsUnhealthy', cwd};
562 }
563 return {type: 'cwdNotARepository', cwd};
564 }
565
566 const isEdenFs = await isEdenFsRepo(repoRoot as AbsolutePath);
567
568 let codeReviewSystem: CodeReviewSystem;
569 let pullRequestDomain;
570 if (Internal.isMononokePath?.(pathsDefault)) {
571 // TODO: where should we be getting this from? arcconfig instead? do we need this?
572 const repo = pathsDefault.slice(pathsDefault.lastIndexOf('/') + 1);
573 codeReviewSystem = {type: 'phabricator', repo, callsign: configs.get('phrevset.callsign')};
574 } else if (/^mononoke:\/\/grove\.host/.test(pathsDefault) || /^slapi:/.test(pathsDefault)) {
575 // Grove-hosted Mononoke remote — use Grove code review
576 const repo = configs.get('remotefilelog.reponame') ?? pathsDefault.slice(pathsDefault.lastIndexOf('/') + 1);
577 const groveConfig = readGroveConfig(logger);
578 const apiUrl = configs.get('grove.api_url') ?? (groveConfig.hub ? `${groveConfig.hub}/api` : 'https://grove.host/api');
579 // owner: explicit config > look up from API by repo name > fall back to username
580 let owner = configs.get('grove.owner') ?? '';
581 if (!owner) {
582 owner = await resolveGroveRepoOwner(apiUrl, repo, groveConfig.token, logger) ?? groveConfig.username ?? '';
583 }
584 const repoSettings = await fetchGroveRepoSettings(apiUrl, owner, repo, groveConfig.token, logger);
585 codeReviewSystem = {type: 'grove', apiUrl, owner, repo, requireDiffs: repoSettings.requireDiffs};
586 } else if (/^mononoke:\/\//.test(pathsDefault) || /\/edenapi\/?/.test(pathsDefault)) {
587 // Mononoke/EdenAPI remote — no code review provider
588 codeReviewSystem = {type: 'none'};
589 } else if (pathsDefault === '') {
590 codeReviewSystem = {type: 'none'};
591 } else {
592 const repoInfo = extractRepoInfoFromUrl(pathsDefault);
593 if (
594 repoInfo != null &&
595 (repoInfo.hostname === 'github.com' || (await isGithubEnterprise(repoInfo.hostname)))
596 ) {
597 const {owner, repo, hostname} = repoInfo;
598 codeReviewSystem = {
599 type: 'github',
600 owner,
601 repo,
602 hostname,
603 };
604 } else {
605 codeReviewSystem = {type: 'unknown', path: pathsDefault};
606 }
607 pullRequestDomain = configs.get('github.pull_request_domain');
608 }
609
610 const result: RepoInfo = {
611 type: 'success',
612 command: cmd,
613 dotdir,
614 repoRoot,
615 repoRoots,
616 codeReviewSystem,
617 pullRequestDomain,
618 preferredSubmitCommand: preferredSubmitCommand as PreferredSubmitCommand | undefined,
619 isEdenFs,
620 };
621 logger.info('repo info: ', result);
622 return result;
623 }
624
625 /**
626 * Determine basic information about a cwd, without fetching the full RepositoryInfo.
627 * Useful to determine if a cwd is valid and find the repo root without constructing a Repository.
628 */
629
630 static async getCwdInfo(ctx: RepositoryContext): Promise<CwdInfo> {
631 const root = await findRoot(ctx).catch((err: Error) => err);
632
633 if (root instanceof Error || root == null) {
634 return {cwd: ctx.cwd};
635 }
636
637 const [realCwd, realRoot] = await Promise.all([
638 fs.promises.realpath(ctx.cwd),
639 fs.promises.realpath(root),
640 ]);
641 // Since we found `root` for this particular `cwd`, we expect realpath(root) is a prefix of realpath(cwd).
642 // That is, the relative path does not contain any ".." components.
643 const repoRelativeCwd = path.relative(realRoot, realCwd);
644 return {
645 cwd: ctx.cwd,
646 repoRoot: realRoot,
647 repoRelativeCwdLabel: path.normalize(path.join(path.basename(realRoot), repoRelativeCwd)),
648 };
649 }
650
651 /**
652 * Run long-lived command which mutates the repository state.
653 * Progress is streamed back as it comes in.
654 * Operations are run immediately. For queueing, see OperationQueue.
655 * This promise resolves when the operation exits.
656 */
657 async runOrQueueOperation(
658 ctx: RepositoryContext,
659 operation: RunnableOperation,
660 onProgress: (progress: OperationProgress) => void,
661 ): Promise<void> {
662 let exitCode: number | undefined;
663 const result = await this.operationQueue.runOrQueueOperation(ctx, operation, progress => {
664 if (progress.kind === 'exit') {
665 exitCode = progress.exitCode;
666 }
667 onProgress(progress);
668 });
669
670 if (result !== 'skipped') {
671 // After any operation finishes, make sure we poll right away,
672 // so the UI is guaranteed to get the latest data.
673 this.watchForChanges.poll('force');
674
675 // Let the code review provider react to the completed operation (e.g. create a diff after push)
676 if (this.codeReviewProvider?.onPostOperation != null && exitCode != null) {
677 const SEP = '\x00';
678 this.codeReviewProvider.onPostOperation(operation, exitCode, async (rev: string) => {
679 try {
680 const output = await this.runCommand(
681 ['log', '--rev', rev, '--template', `{node}${SEP}{desc|firstline}${SEP}{desc}${SEP}{p1node}`],
682 'PostOperationCommitInfoCommand',
683 ctx,
684 );
685 const parts = output.stdout.split(SEP);
686 if (parts.length < 4) {
687 return undefined;
688 }
689 const [hash, title, fullDesc, parentHash] = parts;
690 // Description is the full commit message minus the title (first line)
691 const descLines = fullDesc.split('\n');
692 const description = descLines.slice(1).join('\n').trim();
693 return {title, description, hash, parentHash};
694 } catch (err) {
695 ctx.logger.error('[grove] failed to fetch commit info for post-operation hook', err);
696 return undefined;
697 }
698 });
699 }
700 }
701 }
702
703 /**
704 * Abort the running operation if it matches the given id.
705 */
706 abortRunningOperation(operationId: string) {
707 this.operationQueue.abortRunningOperation(operationId);
708 }
709
710 /** The currently running operation tracked by the server. */
711 getRunningOperation() {
712 return this.operationQueue.getRunningOperation();
713 }
714
715 private normalizeOperationArgs(
716 cwd: string,
717 operation: RunnableOperation,
718 ): {args: Array<string>; stdin?: string | undefined} {
719 const repoRoot = nullthrows(this.info.repoRoot);
720 const illegalArgs = new Set(['--cwd', '--config', '--insecure', '--repository', '-R']);
721 let stdin = operation.stdin;
722 const args = [];
723 for (const arg of operation.args) {
724 if (typeof arg === 'object') {
725 switch (arg.type) {
726 case 'config':
727 if (!(settableConfigNames as ReadonlyArray<string>).includes(arg.key)) {
728 throw new Error(`config ${arg.key} not allowed`);
729 }
730 args.push('--config', `${arg.key}=${arg.value}`);
731 continue;
732 case 'repo-relative-file':
733 args.push(path.normalize(path.relative(cwd, path.join(repoRoot, arg.path))));
734 continue;
735 case 'repo-relative-file-list':
736 // pass long lists of files as stdin via fileset patterns
737 // this is passed as an arg instead of directly in stdin so that we can do path normalization
738 args.push('listfile0:-');
739 if (stdin != null) {
740 throw new Error('stdin already set when using repo-relative-file-list');
741 }
742 stdin = arg.paths
743 .map(p => path.normalize(path.relative(cwd, path.join(repoRoot, p))))
744 .join('\0');
745 continue;
746 case 'exact-revset':
747 if (arg.revset.startsWith('-')) {
748 // don't allow revsets to be used as flags
749 throw new Error('invalid revset');
750 }
751 args.push(arg.revset);
752 continue;
753 case 'succeedable-revset':
754 args.push(`max(successors(${arg.revset}))`);
755 continue;
756 case 'optimistic-revset':
757 args.push(`max(successors(${arg.revset}))`);
758 continue;
759 }
760 }
761 if (illegalArgs.has(arg)) {
762 throw new Error(`argument '${arg}' is not allowed`);
763 }
764 args.push(arg);
765 }
766 return {args, stdin};
767 }
768
769 private async operationIPC(
770 ctx: RepositoryContext,
771 onProgress: OperationCommandProgressReporter,
772 child: EjecaChildProcess,
773 options: EjecaOptions,
774 ): Promise<void> {
775 if (!options.ipc) {
776 return;
777 }
778
779 interface IpcProgressBar {
780 id: number;
781 topic: string;
782 unit: string;
783 total: number;
784 position: number;
785 parent_id?: number;
786 }
787
788 while (true) {
789 try {
790 // eslint-disable-next-line no-await-in-loop
791 const message = await child.getOneMessage();
792 if (message === null || typeof message !== 'object') {
793 break;
794 }
795 if ('progress_bar_update' in message) {
796 const bars = message.progress_bar_update as IpcProgressBar[];
797 const blen = bars.length;
798 if (blen > 0) {
799 const msg = bars[blen - 1];
800 onProgress('progress', {
801 message: msg.topic,
802 progress: msg.position,
803 progressTotal: msg.total,
804 unit: msg.unit,
805 });
806 }
807 } else if ('warning' in message) {
808 onProgress('warning', message.warning as string);
809 } else {
810 break;
811 }
812 } catch (err) {
813 break;
814 }
815 }
816 }
817
818 /**
819 * Called by this.operationQueue in response to runOrQueueOperation when an operation is ready to actually run.
820 */
821 private async runOperation(
822 ctx: RepositoryContext,
823 operation: RunnableOperation,
824 onProgress: OperationCommandProgressReporter,
825 signal: AbortSignal,
826 ): Promise<void> {
827 const {cwd} = ctx;
828 const {args: cwdRelativeArgs, stdin} = this.normalizeOperationArgs(cwd, operation);
829
830 const env = await Promise.all([
831 Internal.additionalEnvForCommand?.(operation),
832 this.getMergeToolEnvVars(ctx),
833 ]);
834
835 const ipc = (ctx.knownConfigs?.get('isl.sl-progress-enabled') ?? 'false') === 'true';
836 const fullArgs = [...cwdRelativeArgs];
837 if (ctx.debug) {
838 fullArgs.unshift('--debug');
839 }
840 if (ctx.verbose) {
841 fullArgs.unshift('--verbose');
842 }
843 const {command, args, options} = getExecParams(
844 this.info.command,
845 fullArgs,
846 cwd,
847 stdin ? {input: stdin, ipc} : {ipc},
848 {
849 ...env[0],
850 ...env[1],
851 },
852 );
853
854 ctx.logger.log('run operation: ', command, fullArgs.join(' '));
855
856 const commandBlocklist = new Set(['debugshell', 'dbsh', 'debugsh']);
857 if (args.some(arg => commandBlocklist.has(arg))) {
858 throw new Error(`command "${args.join(' ')}" is not allowed`);
859 }
860
861 const execution = ejeca(command, args, options);
862 // It would be more appropriate to call this in response to execution.on('spawn'), but
863 // this seems to be inconsistent about firing in all versions of node.
864 // Just send spawn immediately. Errors during spawn like ENOENT will still be reported by `exit`.
865 onProgress('spawn');
866 execution.stdout?.on('data', data => {
867 onProgress('stdout', data.toString());
868 });
869 execution.stderr?.on('data', data => {
870 onProgress('stderr', data.toString());
871 });
872 signal.addEventListener('abort', () => {
873 ctx.logger.log('kill operation: ', command, fullArgs.join(' '));
874 });
875 handleAbortSignalOnProcess(execution, signal);
876 try {
877 this.operationIPC(ctx, onProgress, execution, options);
878 const result = await execution;
879 onProgress('exit', result.exitCode || 0);
880 } catch (err) {
881 onProgress('exit', isEjecaError(err) ? err.exitCode : -1);
882 throw err;
883 }
884 }
885
886 /**
887 * Get environment variables to set up which merge tool to use during an operation.
888 * If you're using the default merge tool, use :merge3 instead for slightly better merge information.
889 * If you've configured a custom merge tool, make sure we don't overwrite it...
890 * ...unless the custom merge tool is *not* a GUI tool, like vimdiff, which would not be interactable in ISL.
891 */
892 async getMergeToolEnvVars(ctx: RepositoryContext): Promise<Record<string, string> | undefined> {
893 const tool = await this.getMergeTool(ctx);
894 return tool != null
895 ? // allow sl to use the already configured merge tool
896 {}
897 : // otherwise, use 3-way merge
898 {
899 HGMERGE: ':merge3',
900 SL_MERGE: ':merge3',
901 };
902 }
903
904 setPageFocus(page: string, state: PageVisibility) {
905 this.pageFocusTracker.setState(page, state);
906 this.initialConnectionContext.tracker.track('FocusChanged', {extras: {state}});
907 }
908
909 private refcount = 0;
910 ref() {
911 this.refcount++;
912 if (this.refcount === 1) {
913 this.watchForChanges.setupSubscriptions(this.initialConnectionContext);
914 }
915 }
916 unref() {
917 this.refcount--;
918 if (this.refcount === 0) {
919 this.watchForChanges.disposeWatchmanSubscriptions();
920 }
921 }
922
923 /** Return the latest fetched value for UncommittedChanges. */
924 getUncommittedChanges(): FetchedUncommittedChanges | null {
925 return this.uncommittedChanges;
926 }
927
928 subscribeToUncommittedChanges(
929 callback: (result: FetchedUncommittedChanges) => unknown,
930 ): Disposable {
931 this.uncommittedChangesEmitter.on('change', callback);
932 return {
933 dispose: () => {
934 this.uncommittedChangesEmitter.off('change', callback);
935 },
936 };
937 }
938
939 fetchUncommittedChanges = serializeAsyncCall(async () => {
940 const fetchStartTimestamp = Date.now();
941 try {
942 this.uncommittedChangesBeginFetchingEmitter.emit('start');
943 // Note `status -tjson` run with PLAIN are repo-relative
944 const proc = await this.runCommand(
945 ['status', '-Tjson', '--copies'],
946 'StatusCommand',
947 this.initialConnectionContext,
948 );
949 const files = (JSON.parse(proc.stdout) as UncommittedChanges).map(change => ({
950 ...change,
951 path: removeLeadingPathSep(change.path),
952 }));
953
954 this.uncommittedChanges = {
955 fetchStartTimestamp,
956 fetchCompletedTimestamp: Date.now(),
957 files: {value: files},
958 };
959 this.uncommittedChangesEmitter.emit('change', this.uncommittedChanges);
960 } catch (err) {
961 let error = err;
962 if (isEjecaError(error)) {
963 if (error.stderr.includes('checkout is currently in progress')) {
964 this.initialConnectionContext.logger.info(
965 'Ignoring `sl status` error caused by in-progress checkout',
966 );
967 return;
968 }
969 }
970
971 this.initialConnectionContext.logger.error('Error fetching files: ', error);
972 if (isEjecaError(error)) {
973 error = simplifyEjecaError(error);
974 }
975
976 // emit an error, but don't save it to this.uncommittedChanges
977 this.uncommittedChangesEmitter.emit('change', {
978 fetchStartTimestamp,
979 fetchCompletedTimestamp: Date.now(),
980 files: {error: error instanceof Error ? error : new Error(error as string)},
981 });
982 }
983 });
984
985 /** Return the latest fetched value for SmartlogCommits. */
986 getSmartlogCommits(): FetchedCommits | null {
987 return this.smartlogCommits;
988 }
989
990 subscribeToSmartlogCommitsChanges(callback: (result: FetchedCommits) => unknown) {
991 this.smartlogCommitsChangesEmitter.on('change', callback);
992 return {
993 dispose: () => {
994 this.smartlogCommitsChangesEmitter.off('change', callback);
995 },
996 };
997 }
998
999 subscribeToSmartlogCommitsBeginFetching(callback: (isFetching: boolean) => unknown) {
1000 const onStart = () => callback(true);
1001 this.smartlogCommitsBeginFetchingEmitter.on('start', onStart);
1002 return {
1003 dispose: () => {
1004 this.smartlogCommitsBeginFetchingEmitter.off('start', onStart);
1005 },
1006 };
1007 }
1008
1009 subscribeToUncommittedChangesBeginFetching(callback: (isFetching: boolean) => unknown) {
1010 const onStart = () => callback(true);
1011 this.uncommittedChangesBeginFetchingEmitter.on('start', onStart);
1012 return {
1013 dispose: () => {
1014 this.uncommittedChangesBeginFetchingEmitter.off('start', onStart);
1015 },
1016 };
1017 }
1018
1019 subscribeToSubmodulesChanges(callback: (result: SubmodulesByRoot) => unknown) {
1020 this.submodulesChangesEmitter.on('change', callback);
1021 return {
1022 dispose: () => {
1023 this.submodulesChangesEmitter.off('change', callback);
1024 },
1025 };
1026 }
1027
1028 fetchSmartlogCommits = serializeAsyncCall(async () => {
1029 const fetchStartTimestamp = Date.now();
1030 try {
1031 this.smartlogCommitsBeginFetchingEmitter.emit('start');
1032
1033 const visibleCommitDayRange = this.visibleCommitRanges[this.currentVisibleCommitRangeIndex];
1034
1035 const primaryRevset = '(interestingbookmarks() + heads(draft()) + ancestors(., 50))';
1036
1037 // Revset to fetch for commits, e.g.:
1038 // smartlog(interestingbookmarks() + heads(draft()) + .)
1039 // smartlog((interestingbookmarks() + heads(draft()) & date(-14)) + .)
1040 // smartlog((interestingbookmarks() + heads(draft()) & date(-14)) + . + present(a1b2c3d4))
1041 const revset = `smartlog(${[
1042 !visibleCommitDayRange
1043 ? primaryRevset
1044 : // filter default smartlog query by date range
1045 `(${primaryRevset} & date(-${visibleCommitDayRange}))`,
1046 '.', // always include wdir parent
1047 // stable locations hashes may be newer than the repo has, wrap in `present()` to only include if available.
1048 ...this.stableLocations.map(location => `present(${location.hash})`),
1049 ...(this.recommendedBookmarks ?? []).map(bookmark => `present(${bookmark})`),
1050 ...(this.fullRepoBranchModule?.genRevset() ?? []),
1051 ]
1052 .filter(notEmpty)
1053 .join(' + ')})`;
1054
1055 const template = getMainFetchTemplate(this.info.codeReviewSystem);
1056
1057 this.initialConnectionContext.logger.info('[grove] fetchSmartlogCommits revset:', revset);
1058 this.initialConnectionContext.logger.info('[grove] codeReviewSystem:', JSON.stringify(this.info.codeReviewSystem));
1059
1060 const proc = await this.runCommand(
1061 ['log', '--template', template, '--rev', revset],
1062 'LogCommand',
1063 this.initialConnectionContext,
1064 );
1065
1066 this.initialConnectionContext.logger.info('[grove] sl log stdout length:', proc.stdout.length);
1067 this.initialConnectionContext.logger.info('[grove] sl log stderr:', proc.stderr?.trim() || '(empty)');
1068
1069 const commits = parseCommitInfoOutput(
1070 this.initialConnectionContext.logger,
1071 proc.stdout.trim(),
1072 this.info.codeReviewSystem,
1073 );
1074
1075 this.initialConnectionContext.logger.info('[grove] parsed commits count:', commits.length);
1076
1077 if (commits.length === 0) {
1078 throw new Error(ErrorShortMessages.NoCommitsFetched);
1079 }
1080 attachStableLocations(commits, this.stableLocations);
1081
1082 if (this.fullRepoBranchModule) {
1083 this.fullRepoBranchModule.populateSmartlogCommits(commits);
1084 }
1085
1086 this.smartlogCommits = {
1087 fetchStartTimestamp,
1088 fetchCompletedTimestamp: Date.now(),
1089 commits: {value: commits},
1090 };
1091 this.smartlogCommitsChangesEmitter.emit('change', this.smartlogCommits);
1092 } catch (err) {
1093 let error = err;
1094 const internalError = Internal.checkInternalError?.(err);
1095 if (internalError) {
1096 error = internalError;
1097 }
1098 if (isEjecaError(error) && error.stderr.includes('Please check your internet connection')) {
1099 error = Error('Network request failed. Please check your internet connection.');
1100 }
1101
1102 this.initialConnectionContext.logger.error('Error fetching commits: ', error);
1103 if (isEjecaError(error)) {
1104 error = simplifyEjecaError(error);
1105 }
1106
1107 this.smartlogCommitsChangesEmitter.emit('change', {
1108 fetchStartTimestamp,
1109 fetchCompletedTimestamp: Date.now(),
1110 commits: {error: error instanceof Error ? error : new Error(error as string)},
1111 });
1112 }
1113 });
1114
1115 public async fetchAndSetRecommendedBookmarks(onFetched?: (bookmarks: Array<string>) => void) {
1116 if (!Internal.getRecommendedBookmarks) {
1117 return;
1118 }
1119
1120 try {
1121 const bookmarks = await Internal.getRecommendedBookmarks(this.initialConnectionContext);
1122 onFetched?.((this.recommendedBookmarks = bookmarks.map((b: string) => `remote/${b}`)));
1123 void this.pullRecommendedBookmarks(this.initialConnectionContext);
1124 } catch (err) {
1125 this.initialConnectionContext.logger.error('Error fetching recommended bookmarks:', err);
1126 onFetched?.([]);
1127 }
1128 }
1129
1130 public async fetchAndSetHiddenMasterConfig(
1131 onFetched?: (config: Record<string, Array<string>> | null, odType: string | null) => void,
1132 ) {
1133 if (!Internal.fetchHiddenMasterBranchConfig) {
1134 return;
1135 }
1136
1137 try {
1138 const [config, odType] = await Promise.all([
1139 Internal.fetchHiddenMasterBranchConfig(this.initialConnectionContext).catch(
1140 (err: unknown) => {
1141 this.initialConnectionContext.logger.warn(
1142 'Failed to fetch hidden master branch config:',
1143 err,
1144 );
1145 return null;
1146 },
1147 ),
1148 Internal.getDevEnvType?.().catch((err: unknown) => {
1149 this.initialConnectionContext.logger.warn('Failed to fetch OD type:', err);
1150 return null;
1151 }),
1152 ]);
1153
1154 onFetched?.(config ?? {}, odType ?? '');
1155 } catch (err) {
1156 this.initialConnectionContext.logger.error(
1157 'Error fetching hidden master branch config:',
1158 err,
1159 );
1160 onFetched?.({}, '');
1161 }
1162 }
1163
1164 async pullRecommendedBookmarks(ctx: RepositoryContext): Promise<void> {
1165 if (!this.recommendedBookmarks || !this.recommendedBookmarks.length) {
1166 return;
1167 }
1168
1169 try {
1170 const result = await this.runCommand(
1171 ['bookmarks', '--list-subscriptions'],
1172 'BookmarksCommand',
1173 ctx,
1174 );
1175 const subscribed = this.parseSubscribedBookmarks(result.stdout);
1176 const missingBookmarks = this.recommendedBookmarks.filter(
1177 bookmark => !subscribed.has(bookmark),
1178 );
1179
1180 if (missingBookmarks.length > 0) {
1181 // We need to strip to pull the remote names
1182 const missingRemoteNames = missingBookmarks.map(bookmark =>
1183 bookmark.replace(/^remote\//, ''),
1184 );
1185
1186 const pullBookmarkOperation = this.createPullBookmarksOperation(missingRemoteNames);
1187 await this.runOrQueueOperation(ctx, pullBookmarkOperation, () => {});
1188 ctx.logger.info(`Ran pull on new recommended bookmarks: ${missingRemoteNames.join(', ')}`);
1189 } else {
1190 // Fetch again as recommended bookmarks likely would not have been set before the startup fetch
1191 // If bookmarks were pulled, this is automatically called
1192 this.fetchSmartlogCommits();
1193 }
1194 } catch (err) {
1195 let error = err;
1196 if (isEjecaError(error)) {
1197 error = simplifyEjecaError(error);
1198 }
1199
1200 ctx.logger.error('Unable to pull new recommended bookmark(s): ', error);
1201 }
1202 }
1203
1204 /** Get the current head commit if loaded */
1205 getHeadCommit(): CommitInfo | undefined {
1206 return this.smartlogCommits?.commits.value?.find(commit => commit.isDot);
1207 }
1208
1209 /** Watch for changes to the head commit, e.g. from checking out a new commit */
1210 subscribeToHeadCommit(callback: (head: CommitInfo) => unknown) {
1211 let headCommit = this.getHeadCommit();
1212 if (headCommit != null) {
1213 callback(headCommit);
1214 }
1215 const onData = (data: FetchedCommits) => {
1216 const newHead = data?.commits.value?.find(commit => commit.isDot);
1217 if (newHead != null && newHead.hash !== headCommit?.hash) {
1218 callback(newHead);
1219 headCommit = newHead;
1220 }
1221 };
1222 this.smartlogCommitsChangesEmitter.on('change', onData);
1223 return {
1224 dispose: () => {
1225 this.smartlogCommitsChangesEmitter.off('change', onData);
1226 },
1227 };
1228 }
1229
1230 getSubmoduleMap(): SubmodulesByRoot | undefined {
1231 return this.submodulesByRoot;
1232 }
1233
1234 getSubmodulePathCache(): ImSet<RepoRelativePath> | undefined {
1235 if (this.submodulePathCache === undefined) {
1236 const paths = this.submodulesByRoot?.get(this.info.repoRoot)?.value?.map(m => m.path);
1237 this.submodulePathCache = paths ? ImSet(paths) : undefined;
1238 }
1239 return this.submodulePathCache;
1240 }
1241
1242 async fetchSubmoduleMap(): Promise<void> {
1243 if (this.info.repoRoots == null) {
1244 return;
1245 }
1246 const submoduleMap = new Map();
1247 await Promise.all(
1248 this.info.repoRoots?.map(async root => {
1249 try {
1250 const proc = await this.runCommand(
1251 ['debuggitmodules', '--json', '--repo', root],
1252 'LogCommand',
1253 this.initialConnectionContext,
1254 );
1255 const submodules = JSON.parse(proc.stdout) as Submodule[];
1256 submoduleMap.set(root, {value: submodules?.length === 0 ? undefined : submodules});
1257 } catch (err) {
1258 let error = err;
1259 if (isEjecaError(error)) {
1260 // debuggitmodules may not be supported by older versions of Sapling
1261 error = error.stderr.includes('unknown command')
1262 ? Error('debuggitmodules command is not supported by your sapling version.')
1263 : simplifyEjecaError(error);
1264 }
1265 this.initialConnectionContext.logger.error('Error fetching submodules: ', error);
1266
1267 submoduleMap.set(root, {error: new Error(err as string)});
1268 }
1269 }),
1270 );
1271
1272 this.submodulesByRoot = submoduleMap;
1273 this.submodulePathCache = undefined; // Invalidate path cache
1274 this.submodulesChangesEmitter.emit('change', submoduleMap);
1275 }
1276
1277 private catLimiter = new RateLimiter(MAX_SIMULTANEOUS_CAT_CALLS, s =>
1278 this.initialConnectionContext.logger.info('[cat]', s),
1279 );
1280 /** Return file content at a given revset, e.g. hash or `.` */
1281 public cat(ctx: RepositoryContext, file: AbsolutePath, rev: Revset): Promise<string> {
1282 return this.catLimiter.enqueueRun(async () => {
1283 // For `sl cat`, we want the output of the command verbatim.
1284 const options = {stripFinalNewline: false};
1285 return (await this.runCommand(['cat', file, '--rev', rev], 'CatCommand', ctx, options))
1286 .stdout;
1287 });
1288 }
1289
1290 /**
1291 * Returns line-by-line blame information for a file at a given commit.
1292 * Returns the line content and commit info.
1293 * Note: the line will including trailing newline.
1294 */
1295 public async blame(
1296 ctx: RepositoryContext,
1297 filePath: string,
1298 hash: string,
1299 ): Promise<Array<[line: string, info: CommitInfo | undefined]>> {
1300 const t1 = Date.now();
1301 const output = await this.runCommand(
1302 ['blame', filePath, '-Tjson', '--change', '--rev', hash],
1303 'BlameCommand',
1304 ctx,
1305 undefined,
1306 /* don't timeout */ 0,
1307 );
1308 const blame = JSON.parse(output.stdout) as Array<{lines: Array<{line: string; node: string}>}>;
1309 const t2 = Date.now();
1310
1311 if (blame.length === 0) {
1312 // no blame for file, perhaps it was added or untracked
1313 return [];
1314 }
1315
1316 const hashes = new Set<string>();
1317 for (const line of blame[0].lines) {
1318 hashes.add(line.node);
1319 }
1320 // We don't get all the info we need from the blame command, so we run `sl log` on the hashes.
1321 // TODO: we could make the blame command return this directly, which is probably faster.
1322 // TODO: We don't actually need all the fields in FETCH_TEMPLATE for blame. Reducing this template may speed it up as well.
1323 const commits = await this.lookupCommits(ctx, [...hashes]);
1324 const t3 = Date.now();
1325 ctx.logger.info(
1326 `Fetched ${commits.size} commits for blame. Blame took ${(t2 - t1) / 1000}s, commits took ${
1327 (t3 - t2) / 1000
1328 }s`,
1329 );
1330 return blame[0].lines.map(({node, line}) => [line, commits.get(node)]);
1331 }
1332
1333 public async getCommitCloudState(ctx: RepositoryContext): Promise<CommitCloudSyncState> {
1334 const lastChecked = new Date();
1335
1336 const [extension, backupStatuses, cloudStatus] = await Promise.allSettled([
1337 this.forceGetConfig(ctx, 'extensions.commitcloud'),
1338 this.fetchCommitCloudBackupStatuses(ctx),
1339 this.fetchCommitCloudStatus(ctx),
1340 ]);
1341 if (extension.status === 'fulfilled' && extension.value !== '') {
1342 return {
1343 lastChecked,
1344 isDisabled: true,
1345 };
1346 }
1347
1348 if (backupStatuses.status === 'rejected') {
1349 return {
1350 lastChecked,
1351 syncError: backupStatuses.reason,
1352 };
1353 } else if (cloudStatus.status === 'rejected') {
1354 return {
1355 lastChecked,
1356 workspaceError: cloudStatus.reason,
1357 };
1358 }
1359
1360 return {
1361 lastChecked,
1362 ...cloudStatus.value,
1363 commitStatuses: backupStatuses.value,
1364 };
1365 }
1366
1367 private async fetchCommitCloudBackupStatuses(
1368 ctx: RepositoryContext,
1369 ): Promise<Map<Hash, CommitCloudBackupStatus>> {
1370 const revset = 'draft() - backedup()';
1371 const commitCloudBackupStatusTemplate = `{dict(
1372 hash="{node}",
1373 backingup="{backingup}",
1374 date="{date|isodatesec}"
1375 )|json}\n`;
1376
1377 const output = await this.runCommand(
1378 ['log', '--rev', revset, '--template', commitCloudBackupStatusTemplate],
1379 'CommitCloudSyncBackupStatusCommand',
1380 ctx,
1381 );
1382
1383 const rawObjects = output.stdout.trim().split('\n');
1384 const parsedObjects = rawObjects
1385 .map(rawObject => {
1386 try {
1387 return JSON.parse(rawObject) as {hash: Hash; backingup: 'True' | 'False'; date: string};
1388 } catch (err) {
1389 return null;
1390 }
1391 })
1392 .filter(notEmpty);
1393
1394 const now = new Date();
1395 const TEN_MIN = 10 * 60 * 1000;
1396 const statuses = new Map<Hash, CommitCloudBackupStatus>(
1397 parsedObjects.map(obj => [
1398 obj.hash,
1399 obj.backingup === 'True'
1400 ? CommitCloudBackupStatus.InProgress
1401 : now.valueOf() - new Date(obj.date).valueOf() < TEN_MIN
1402 ? CommitCloudBackupStatus.Pending
1403 : CommitCloudBackupStatus.Failed,
1404 ]),
1405 );
1406 return statuses;
1407 }
1408
1409 private async fetchCommitCloudStatus(ctx: RepositoryContext): Promise<{
1410 lastBackup: Date | undefined;
1411 currentWorkspace: string;
1412 workspaceChoices: Array<string>;
1413 }> {
1414 const [cloudStatusOutput, cloudListOutput] = await Promise.all([
1415 this.runCommand(['cloud', 'status'], 'CommitCloudStatusCommand', ctx),
1416 this.runCommand(['cloud', 'list'], 'CommitCloudListCommand', ctx),
1417 ]);
1418
1419 const currentWorkspace =
1420 /Workspace: ([a-zA-Z/0-9._-]+)/.exec(cloudStatusOutput.stdout)?.[1] ?? 'default';
1421 const lastSyncTimeStr = /Last Sync Time: (.*)/.exec(cloudStatusOutput.stdout)?.[1];
1422 const lastBackup = lastSyncTimeStr != null ? new Date(lastSyncTimeStr) : undefined;
1423 const workspaceChoices = cloudListOutput.stdout
1424 .split('\n')
1425 .map(line => /^ {8}([a-zA-Z/0-9._-]+)(?: \(connected\))?/.exec(line)?.[1] as string)
1426 .filter(l => l != null);
1427
1428 return {
1429 lastBackup,
1430 currentWorkspace,
1431 workspaceChoices,
1432 };
1433 }
1434
1435 private commitCache = new LRU<string, CommitInfo>(100); // TODO: normal commits fetched from smartlog() aren't put in this cache---this is mostly for blame right now.
1436 public async lookupCommits(
1437 ctx: RepositoryContext,
1438 hashes: Array<string>,
1439 ): Promise<Map<string, CommitInfo>> {
1440 const hashesToFetch = hashes.filter(hash => this.commitCache.get(hash) == undefined);
1441
1442 const commits =
1443 hashesToFetch.length === 0
1444 ? [] // don't bother running log
1445 : await this.runCommand(
1446 [
1447 'log',
1448 '--template',
1449 getMainFetchTemplate(this.info.codeReviewSystem),
1450 '--rev',
1451 hashesToFetch.join('+'),
1452 ],
1453 'LookupCommitsCommand',
1454 ctx,
1455 ).then(output => {
1456 return parseCommitInfoOutput(
1457 ctx.logger,
1458 output.stdout.trim(),
1459 this.info.codeReviewSystem,
1460 );
1461 });
1462
1463 const result = new Map();
1464 for (const hash of hashes) {
1465 const found = this.commitCache.get(hash);
1466 if (found != undefined) {
1467 result.set(hash, found);
1468 }
1469 }
1470
1471 for (const commit of commits) {
1472 if (commit) {
1473 this.commitCache.set(commit.hash, commit);
1474 result.set(commit.hash, commit);
1475 }
1476 }
1477
1478 return result;
1479 }
1480
1481 public async fetchSignificantLinesOfCode(
1482 ctx: RepositoryContext,
1483 hash: Hash,
1484 excludedFiles: string[],
1485 ): Promise<number | undefined> {
1486 const exclusions = excludedFiles.flatMap(file => [
1487 '-X',
1488 absolutePathForFileInRepo(file, this) ?? file,
1489 ]);
1490
1491 const output = (
1492 await this.runCommand(
1493 ['diff', '--stat', '-B', '-X', '**__generated__**', ...exclusions, '-c', hash],
1494 'SlocCommand',
1495 ctx,
1496 )
1497 ).stdout;
1498
1499 const sloc = this.parseSlocFrom(output);
1500
1501 ctx.logger.info('Fetched SLOC for commit:', hash, output, `SLOC: ${sloc}`);
1502 return sloc;
1503 }
1504
1505 public async fetchPendingAmendSignificantLinesOfCode(
1506 ctx: RepositoryContext,
1507 hash: Hash,
1508 includedFiles: string[],
1509 ): Promise<number | undefined> {
1510 if (includedFiles.length === 0) {
1511 return undefined;
1512 }
1513 const inclusions = includedFiles.flatMap(file => [
1514 '-I',
1515 absolutePathForFileInRepo(file, this) ?? file,
1516 ]);
1517
1518 const output = (
1519 await this.runCommand(
1520 ['diff', '--stat', '-B', '-X', '**__generated__**', ...inclusions, '-r', '.^'],
1521 'PendingSlocCommand',
1522 ctx,
1523 )
1524 ).stdout;
1525
1526 if (output.trim() === '') {
1527 return undefined;
1528 }
1529
1530 const sloc = this.parseSlocFrom(output);
1531
1532 ctx.logger.info('Fetched Pending AMEND SLOC for commit:', hash, output, `SLOC: ${sloc}`);
1533 return sloc;
1534 }
1535
1536 public async fetchPendingSignificantLinesOfCode(
1537 ctx: RepositoryContext,
1538 hash: Hash,
1539 includedFiles: string[],
1540 ): Promise<number | undefined> {
1541 if (includedFiles.length === 0) {
1542 return undefined; // don't bother running sl diff if there are no files to include
1543 }
1544 const inclusions = includedFiles.flatMap(file => [
1545 '-I',
1546 absolutePathForFileInRepo(file, this) ?? file,
1547 ]);
1548
1549 const output = (
1550 await this.runCommand(
1551 ['diff', '--stat', '-B', '-X', '**__generated__**', ...inclusions],
1552 'PendingSlocCommand',
1553 ctx,
1554 )
1555 ).stdout;
1556
1557 const sloc = this.parseSlocFrom(output);
1558
1559 ctx.logger.info('Fetched Pending SLOC for commit:', hash, output, `SLOC: ${sloc}`);
1560 return sloc;
1561 }
1562
1563 private parseSlocFrom(output: string) {
1564 const lines = output.trim().split('\n');
1565 const changes = lines[lines.length - 1];
1566 const diffStatRe = /\d+ files changed, (\d+) insertions\(\+\), (\d+) deletions\(-\)/;
1567 const diffStatMatch = changes.match(diffStatRe);
1568 const insertions = parseInt(diffStatMatch?.[1] ?? '0', 10);
1569 const deletions = parseInt(diffStatMatch?.[2] ?? '0', 10);
1570 const sloc = insertions + deletions;
1571 return sloc;
1572 }
1573
1574 private parseSubscribedBookmarks(output: string): Set<string> {
1575 return new Set(
1576 output
1577 .split('\n')
1578 .filter(line => line.trim())
1579 .map(line => line.trim().split(/\s+/)[0]),
1580 );
1581 }
1582
1583 /**
1584 * Create a runnable operation for pulling bookmarks.
1585 */
1586 private createPullBookmarksOperation(bookmarks: Array<string>): RunnableOperation {
1587 const args = ['pull'];
1588 for (const bookmark of bookmarks) {
1589 args.push('-B', bookmark);
1590 }
1591
1592 return {
1593 args,
1594 id: randomId(),
1595 runner: CommandRunner.Sapling,
1596 trackEventName: 'PullOperation',
1597 };
1598 }
1599
1600 public async getAllChangedFiles(ctx: RepositoryContext, hash: Hash): Promise<Array<ChangedFile>> {
1601 const output = (
1602 await this.runCommand(
1603 ['log', '--template', CHANGED_FILES_TEMPLATE, '--rev', hash],
1604 'LookupAllCommitChangedFilesCommand',
1605 ctx,
1606 )
1607 ).stdout;
1608
1609 const [chunk] = output.split(COMMIT_END_MARK, 1);
1610
1611 const lines = chunk.trim().split('\n');
1612 if (lines.length < Object.keys(CHANGED_FILES_FIELDS).length) {
1613 return [];
1614 }
1615
1616 const files: Array<ChangedFile> = [
1617 ...(JSON.parse(lines[CHANGED_FILES_INDEX.filesModified]) as Array<string>).map(path => ({
1618 path,
1619 status: 'M' as const,
1620 })),
1621 ...(JSON.parse(lines[CHANGED_FILES_INDEX.filesAdded]) as Array<string>).map(path => ({
1622 path,
1623 status: 'A' as const,
1624 })),
1625 ...(JSON.parse(lines[CHANGED_FILES_INDEX.filesRemoved]) as Array<string>).map(path => ({
1626 path,
1627 status: 'R' as const,
1628 })),
1629 ];
1630
1631 return files;
1632 }
1633
1634 public async getShelvedChanges(ctx: RepositoryContext): Promise<Array<ShelvedChange>> {
1635 const output = (
1636 await this.runCommand(
1637 ['log', '--rev', 'shelved()', '--template', SHELVE_FETCH_TEMPLATE],
1638 'GetShelvesCommand',
1639 ctx,
1640 )
1641 ).stdout;
1642
1643 const shelves = parseShelvedCommitsOutput(ctx.logger, output.trim());
1644 // sort by date ascending
1645 shelves.sort((a, b) => b.date.getTime() - a.date.getTime());
1646 return shelves;
1647 }
1648
1649 public getAllDiffIds(): Array<DiffId> {
1650 return (
1651 this.getSmartlogCommits()
1652 ?.commits.value?.map(commit => commit.diffId)
1653 .filter(notEmpty) ?? []
1654 );
1655 }
1656
1657 public async getActiveAlerts(ctx: RepositoryContext): Promise<Array<Alert>> {
1658 const result = await this.runCommand(['config', '-Tjson', 'alerts'], 'GetAlertsCommand', ctx, {
1659 reject: false,
1660 });
1661 if (result.exitCode !== 0 || !result.stdout) {
1662 return [];
1663 }
1664 try {
1665 const configs = JSON.parse(result.stdout) as [{name: string; value: unknown}];
1666 const alerts = parseAlerts(configs);
1667 ctx.logger.info('Found active alerts:', alerts);
1668 return alerts;
1669 } catch (e) {
1670 return [];
1671 }
1672 }
1673
1674 public async getRagePaste(ctx: RepositoryContext): Promise<string> {
1675 const output = await this.runCommand(['rage'], 'RageCommand', ctx, undefined, 90_000);
1676 const match = /P\d{9,}/.exec(output.stdout);
1677 if (match) {
1678 return match[0];
1679 }
1680 throw new Error('No paste found in rage output: ' + output.stdout);
1681 }
1682
1683 public async runDiff(
1684 ctx: RepositoryContext,
1685 comparison: Comparison,
1686 contextLines = 4,
1687 ): Promise<string> {
1688 const output = await this.runCommand(
1689 [
1690 'diff',
1691 ...revsetArgsForComparison(comparison),
1692 // don't include a/ and b/ prefixes on files
1693 '--noprefix',
1694 '--no-binary',
1695 '--nodate',
1696 '--unified',
1697 String(contextLines),
1698 ],
1699 'DiffCommand',
1700 ctx,
1701 );
1702 return output.stdout;
1703 }
1704
1705 public runCommand(
1706 args: Array<string>,
1707 /** Which event name to track for this command. If undefined, generic 'RunCommand' is used. */
1708 eventName: TrackEventName | undefined,
1709 ctx: RepositoryContext,
1710 options?: EjecaOptions,
1711 timeout?: number,
1712 ) {
1713 const id = randomId();
1714 return ctx.tracker.operation(
1715 eventName ?? 'RunCommand',
1716 'RunCommandError',
1717 {
1718 // if we don't specify a specific eventName, provide the command arguments in logging
1719 extras: eventName == null ? {args} : undefined,
1720 operationId: `isl:${id}`,
1721 },
1722 async () =>
1723 runCommand(
1724 ctx,
1725 args,
1726 {
1727 ...options,
1728 env: {
1729 ...options?.env,
1730 ...((await Internal.additionalEnvForCommand?.(id)) ?? {}),
1731 } as NodeJS.ProcessEnv,
1732 },
1733 timeout ?? READ_COMMAND_TIMEOUT_MS,
1734 ),
1735 );
1736 }
1737
1738 /** Read a config. The config name must be part of `allConfigNames`. */
1739 public async getConfig(
1740 ctx: RepositoryContext,
1741 configName: ConfigName,
1742 ): Promise<string | undefined> {
1743 return (await this.getKnownConfigs(ctx)).get(configName);
1744 }
1745
1746 /**
1747 * Read a single config, forcing a new dedicated call to `sl config`.
1748 * Prefer `getConfig` to batch fetches when possible.
1749 */
1750 public async forceGetConfig(
1751 ctx: RepositoryContext,
1752 configName: string,
1753 ): Promise<string | undefined> {
1754 const result = (await runCommand(ctx, ['config', configName])).stdout;
1755 this.initialConnectionContext.logger.info(
1756 `loaded configs from ${ctx.cwd}: ${configName} => ${result}`,
1757 );
1758 return result;
1759 }
1760
1761 /** Load all "known" configs. Cached on `this`. */
1762 public getKnownConfigs(
1763 ctx: RepositoryContext,
1764 ): Promise<ReadonlyMap<ConfigName, string | undefined>> {
1765 if (ctx.knownConfigs != null) {
1766 return Promise.resolve(ctx.knownConfigs);
1767 }
1768 return this.configRateLimiter.enqueueRun(async () => {
1769 if (ctx.knownConfigs == null) {
1770 // Fetch all configs using one command.
1771 const knownConfig = new Map<ConfigName, string>(
1772 await getConfigs<ConfigName>(ctx, allConfigNames),
1773 );
1774 ctx.knownConfigs = knownConfig;
1775 }
1776 return ctx.knownConfigs;
1777 });
1778 }
1779
1780 public setConfig(
1781 ctx: RepositoryContext,
1782 level: ConfigLevel,
1783 configName: SettableConfigName,
1784 configValue: string,
1785 ): Promise<void> {
1786 if (!settableConfigNames.includes(configName)) {
1787 return Promise.reject(
1788 new Error(`config ${configName} not in allowlist for settable configs`),
1789 );
1790 }
1791 // Attempt to avoid racy config read/write.
1792 return this.configRateLimiter.enqueueRun(() => setConfig(ctx, level, configName, configValue));
1793 }
1794
1795 /** Load and apply configs to `this` in background. */
1796 private applyConfigInBackground(ctx: RepositoryContext) {
1797 this.getConfig(ctx, 'isl.hold-off-refresh-ms').then(configValue => {
1798 if (configValue != null) {
1799 const numberValue = parseInt(configValue, 10);
1800 if (numberValue >= 0) {
1801 this.configHoldOffRefreshMs = numberValue;
1802 }
1803 }
1804 });
1805 }
1806}
1807
1808export function repoRelativePathForAbsolutePath(
1809 absolutePath: AbsolutePath,
1810 repo: Repository,
1811 pathMod = path,
1812): RepoRelativePath {
1813 return pathMod.relative(repo.info.repoRoot, absolutePath);
1814}
1815
1816/**
1817 * Returns absolute path for a repo-relative file path.
1818 * If the path "escapes" the repository's root dir, returns null
1819 * Used to validate that a file path does not "escape" the repo, and the file can safely be modified on the filesystem.
1820 * absolutePathForFileInRepo("foo/bar/file.txt", repo) -> /path/to/repo/foo/bar/file.txt
1821 * absolutePathForFileInRepo("../file.txt", repo) -> null
1822 */
1823export function absolutePathForFileInRepo(
1824 filePath: RepoRelativePath,
1825 repo: Repository,
1826 pathMod = path,
1827): AbsolutePath | null {
1828 // Note that resolve() is contractually obligated to return an absolute path.
1829 const fullPath = pathMod.resolve(repo.info.repoRoot, filePath);
1830 // Prefix checks on paths can be footguns on Windows for C:\\ vs c:\\, but since
1831 // we use the same exact path check here and in the resolve, there should be
1832 // no incompatibility here.
1833 if (fullPath.startsWith(repo.info.repoRoot + pathMod.sep)) {
1834 return fullPath;
1835 } else {
1836 return null;
1837 }
1838}
1839
1840function isUnhealthyEdenFs(cwd: string): Promise<boolean> {
1841 return exists(path.join(cwd, 'README_EDEN.txt'));
1842}
1843
1844export type GroveConfig = {hub?: string; token?: string; username?: string};
1845
1846/** Read ~/.grove/config.json and decode the username from the JWT payload */
1847export function readGroveConfig(logger: {error: (...args: unknown[]) => void}): GroveConfig {
1848 try {
1849 const homedir = process.env.HOME ?? process.env.USERPROFILE ?? '';
1850 const raw = fs.readFileSync(path.join(homedir, '.grove', 'config.json'), 'utf8');
1851 const config = JSON.parse(raw) as {hub?: string; token?: string};
1852 let username: string | undefined;
1853 if (config.token) {
1854 const payload = config.token.split('.')[1];
1855 if (payload) {
1856 const decoded = JSON.parse(Buffer.from(payload, 'base64').toString());
1857 username = decoded.username;
1858 }
1859 }
1860 return {hub: config.hub, token: config.token, username};
1861 } catch (e) {
1862 // Not an error — user may not have logged in yet
1863 return {};
1864 }
1865}
1866
1867/** Query the Grove API to find which owner owns a given repo name */
1868async function resolveGroveRepoOwner(
1869 apiUrl: string,
1870 repoName: string,
1871 token: string | undefined,
1872 logger: {info: (...args: unknown[]) => void; error: (...args: unknown[]) => void},
1873): Promise<string | undefined> {
1874 try {
1875 const headers: Record<string, string> = {};
1876 if (token) {
1877 headers['Authorization'] = `Bearer ${token}`;
1878 }
1879 const res = await fetch(`${apiUrl}/repos`, {headers});
1880 if (!res.ok) {
1881 return undefined;
1882 }
1883 const data = (await res.json()) as {repos: Array<{name: string; owner_name: string}>};
1884 const match = data.repos.find(r => r.name === repoName);
1885 if (match) {
1886 logger.info(`[grove] resolved repo "${repoName}" owner to "${match.owner_name}"`);
1887 return match.owner_name;
1888 }
1889 } catch (e) {
1890 // Expected to fail if user hasn't logged in yet
1891 }
1892 return undefined;
1893}
1894
1895/** Fetch repo-level settings from the Grove API */
1896async function fetchGroveRepoSettings(
1897 apiUrl: string,
1898 owner: string,
1899 repoName: string,
1900 token: string | undefined,
1901 logger: {info: (...args: unknown[]) => void; error: (...args: unknown[]) => void},
1902): Promise<{requireDiffs: boolean}> {
1903 try {
1904 const headers: Record<string, string> = {};
1905 if (token) {
1906 headers['Authorization'] = `Bearer ${token}`;
1907 }
1908 const res = await fetch(`${apiUrl}/repos/${owner}/${repoName}`, {headers});
1909 if (!res.ok) {
1910 return {requireDiffs: false};
1911 }
1912 const data = (await res.json()) as {repo: {require_diffs?: number}};
1913 return {requireDiffs: data.repo.require_diffs === 1};
1914 } catch (e) {
1915 logger.error('[grove] failed to fetch repo settings', e);
1916 return {requireDiffs: false};
1917 }
1918}
1919
1920async function isEdenFsRepo(repoRoot: AbsolutePath): Promise<boolean> {
1921 try {
1922 await fs.promises.access(path.join(repoRoot, '.eden'));
1923 return true;
1924 } catch {}
1925 return false;
1926}
1927