36.6 KB1179 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 {AbsolutePath, RunnableOperation, Submodule} from 'isl/src/types';
9import type {ResolveCommandConflictOutput} from '../commands';
10import type {ServerPlatform} from '../serverPlatform';
11import type {RepositoryContext} from '../serverTypes';
12
13import {CommandRunner, type MergeConflicts, type ValidatedRepoInfo} from 'isl/src/types';
14import fs from 'node:fs';
15import os from 'node:os';
16import path from 'node:path';
17import * as ejeca from 'shared/ejeca';
18import * as fsUtils from 'shared/fs';
19import {clone, mockLogger, nextTick} from 'shared/testUtils';
20import {absolutePathForFileInRepo, Repository} from '../Repository';
21import {makeServerSideTracker} from '../analytics/serverSideTracker';
22import {extractRepoInfoFromUrl, setConfigOverrideForTests} from '../commands';
23
24/* eslint-disable require-await */
25
26jest.mock('../WatchForChanges', () => {
27 class MockWatchForChanges {
28 dispose = jest.fn();
29 poll = jest.fn();
30 }
31 return {WatchForChanges: MockWatchForChanges};
32});
33
34const mockTracker = makeServerSideTracker(
35 mockLogger,
36 {platformName: 'test'} as ServerPlatform,
37 '0.1',
38 jest.fn(),
39);
40
41function mockEjeca(
42 cmds: Array<[RegExp, (() => {stdout: string} | Error) | {stdout: string} | Error]>,
43) {
44 return jest.spyOn(ejeca, 'ejeca').mockImplementation(((cmd: string, args: Array<string>) => {
45 const argStr = cmd + ' ' + args?.join(' ');
46 const ejecaOther = {
47 kill: jest.fn(),
48 on: jest.fn((event, cb) => {
49 // immediately call exit cb to teardown timeout
50 if (event === 'exit') {
51 cb();
52 }
53 }),
54 };
55 for (const [regex, output] of cmds) {
56 if (regex.test(argStr)) {
57 let value = output;
58 if (typeof output === 'function') {
59 value = output();
60 }
61 if (value instanceof Error) {
62 throw value;
63 }
64 return {...ejecaOther, ...value};
65 }
66 }
67 return {...ejecaOther, stdout: ''};
68 }) as unknown as typeof ejeca.ejeca);
69}
70
71function processExitError(code: number, message: string): ejeca.EjecaError {
72 const err = new Error(message) as ejeca.EjecaError;
73 err.exitCode = code;
74 return err;
75}
76
77function setPathsDefault(path: string) {
78 setConfigOverrideForTests([['paths.default', path]], false);
79}
80
81describe('Repository', () => {
82 let ctx: RepositoryContext;
83 beforeEach(() => {
84 ctx = {
85 cmd: 'sl',
86 cwd: '/path/to/cwd',
87 logger: mockLogger,
88 tracker: mockTracker,
89 };
90 });
91
92 it('setting command name', async () => {
93 const ejecaSpy = mockEjeca([]);
94 await Repository.getRepoInfo({...ctx, cmd: 'slb'});
95 expect(ejecaSpy).toHaveBeenCalledWith(
96 'slb',
97 expect.arrayContaining(['root']),
98 expect.anything(),
99 );
100 });
101
102 describe('extracting github repo info', () => {
103 beforeEach(() => {
104 setConfigOverrideForTests([['github.pull_request_domain', 'github.com']]);
105 mockEjeca([
106 [/^sl root --dotdir/, {stdout: '/path/to/myRepo/.sl'}],
107 [/^sl root/, {stdout: '/path/to/myRepo'}],
108 [/^sl debugroots/, {stdout: '/path/to/myRepo'}],
109 [
110 /^gh auth status --hostname gitlab.myCompany.com/,
111 new Error('not authenticated on this hostname'),
112 ],
113 [/^gh auth status --hostname ghe.myCompany.com/, {stdout: ''}],
114 ]);
115 });
116
117 it('extracting github repo info', async () => {
118 setPathsDefault('https://github.com/myUsername/myRepo.git');
119 const info = (await Repository.getRepoInfo(ctx)) as ValidatedRepoInfo;
120 const repo = new Repository(info, ctx);
121 expect(repo.info).toEqual({
122 type: 'success',
123 command: 'sl',
124 repoRoot: '/path/to/myRepo',
125 repoRoots: ['/path/to/myRepo'],
126 dotdir: '/path/to/myRepo/.sl',
127 codeReviewSystem: {
128 type: 'github',
129 owner: 'myUsername',
130 repo: 'myRepo',
131 hostname: 'github.com',
132 },
133 pullRequestDomain: 'github.com',
134 isEdenFs: false,
135 });
136 });
137
138 it('extracting github enterprise repo info', async () => {
139 setPathsDefault('https://ghe.myCompany.com/myUsername/myRepo.git');
140 const info = (await Repository.getRepoInfo(ctx)) as ValidatedRepoInfo;
141 const repo = new Repository(info, ctx);
142 expect(repo.info).toEqual({
143 type: 'success',
144 command: 'sl',
145 repoRoot: '/path/to/myRepo',
146 repoRoots: ['/path/to/myRepo'],
147 dotdir: '/path/to/myRepo/.sl',
148 codeReviewSystem: {
149 type: 'github',
150 owner: 'myUsername',
151 repo: 'myRepo',
152 hostname: 'ghe.myCompany.com',
153 },
154 pullRequestDomain: 'github.com',
155 isEdenFs: false,
156 });
157 });
158
159 it('handles non-github-enterprise unknown code review providers', async () => {
160 setPathsDefault('https://gitlab.myCompany.com/myUsername/myRepo.git');
161 const info = (await Repository.getRepoInfo(ctx)) as ValidatedRepoInfo;
162 const repo = new Repository(info, ctx);
163 expect(repo.info).toEqual({
164 type: 'success',
165 command: 'sl',
166 repoRoot: '/path/to/myRepo',
167 repoRoots: ['/path/to/myRepo'],
168 dotdir: '/path/to/myRepo/.sl',
169 codeReviewSystem: {
170 type: 'unknown',
171 path: 'https://gitlab.myCompany.com/myUsername/myRepo.git',
172 },
173 pullRequestDomain: 'github.com',
174 isEdenFs: false,
175 });
176 });
177 });
178
179 it('applies isl.hold-off-refresh-ms config', async () => {
180 setConfigOverrideForTests([['isl.hold-off-refresh-ms', '12345']], false);
181 const info = (await Repository.getRepoInfo(ctx)) as ValidatedRepoInfo;
182 const repo = new Repository(info, ctx);
183 await new Promise(process.nextTick);
184 expect(repo.configHoldOffRefreshMs).toBe(12345);
185 });
186
187 it('extracting repo info', async () => {
188 setConfigOverrideForTests([]);
189 setPathsDefault('mononoke://0.0.0.0/fbsource');
190 mockEjeca([
191 [/^sl root --dotdir/, {stdout: '/path/to/myRepo/.sl'}],
192 [/^sl root/, {stdout: '/path/to/myRepo'}],
193 [/^sl debugroots/, {stdout: '/path/to/myRepo/submodule\n/path/to/myRepo'}],
194 ]);
195 const info = (await Repository.getRepoInfo(ctx)) as ValidatedRepoInfo;
196 const repo = new Repository(info, ctx);
197 expect(repo.info).toEqual({
198 type: 'success',
199 command: 'sl',
200 repoRoot: '/path/to/myRepo',
201 repoRoots: ['/path/to/myRepo', '/path/to/myRepo/submodule'],
202 dotdir: '/path/to/myRepo/.sl',
203 codeReviewSystem: expect.anything(),
204 pullRequestDomain: undefined,
205 isEdenFs: false,
206 });
207 });
208
209 it('handles cwd not exists', async () => {
210 const err = new Error('cwd does not exist') as Error & {code: string};
211 err.code = 'ENOENT';
212 mockEjeca([[/^sl root/, err]]);
213 const info = (await Repository.getRepoInfo(ctx)) as ValidatedRepoInfo;
214 expect(info).toEqual({
215 type: 'cwdDoesNotExist',
216 cwd: '/path/to/cwd',
217 });
218 });
219
220 it('handles missing executables on windows', async () => {
221 const osSpy = jest.spyOn(os, 'platform').mockImplementation(() => 'win32');
222 mockEjeca([
223 [
224 /^sl root/,
225 processExitError(
226 /* code */ 1,
227 `'sl' is not recognized as an internal or external command, operable program or batch file.`,
228 ),
229 ],
230 ]);
231 jest.spyOn(fsUtils, 'exists').mockImplementation(async () => true);
232 const info = (await Repository.getRepoInfo(ctx)) as ValidatedRepoInfo;
233 expect(info).toEqual({
234 type: 'invalidCommand',
235 command: 'sl',
236 path: expect.anything(),
237 });
238 osSpy.mockRestore();
239 });
240
241 it('prevents setting configs not in the allowlist', async () => {
242 setConfigOverrideForTests([]);
243 setPathsDefault('mononoke://0.0.0.0/fbsource');
244 mockEjeca([
245 [/^sl root --dotdir/, {stdout: '/path/to/myRepo/.sl'}],
246 [/^sl root/, {stdout: '/path/to/myRepo'}],
247 ]);
248 const info = (await Repository.getRepoInfo(ctx)) as ValidatedRepoInfo;
249 const repo = new Repository(info, ctx);
250 // @ts-expect-error We expect a type error in addition to runtime validation
251 await expect(repo.setConfig(ctx, 'user', 'some-random-config', 'hi')).rejects.toEqual(
252 new Error('config some-random-config not in allowlist for settable configs'),
253 );
254 });
255
256 describe('running operations', () => {
257 const repoInfo: ValidatedRepoInfo = {
258 type: 'success',
259 command: 'sl',
260 dotdir: '/path/to/repo/.sl',
261 repoRoot: '/path/to/repo',
262 codeReviewSystem: {type: 'unknown'},
263 pullRequestDomain: undefined,
264 isEdenFs: false,
265 };
266
267 let ejecaSpy: ReturnType<typeof mockEjeca>;
268 beforeEach(() => {
269 ejecaSpy = mockEjeca([]);
270 });
271
272 async function runOperation(op: Partial<RunnableOperation>) {
273 const repo = new Repository(repoInfo, ctx);
274 const progressSpy = jest.fn();
275
276 await repo.runOrQueueOperation(
277 ctx,
278 {
279 id: '1',
280 trackEventName: 'CommitOperation',
281 args: [],
282 runner: CommandRunner.Sapling,
283 ...op,
284 },
285 progressSpy,
286 );
287 }
288
289 it('runs operations', async () => {
290 await runOperation({
291 args: ['commit', '--message', 'hi'],
292 });
293
294 expect(ejecaSpy).toHaveBeenCalledWith(
295 'sl',
296 ['commit', '--message', 'hi', '--noninteractive'],
297 expect.anything(),
298 );
299 });
300
301 it('handles succeedable revsets', async () => {
302 await runOperation({
303 args: ['rebase', '--rev', {type: 'succeedable-revset', revset: 'aaa'}],
304 });
305
306 expect(ejecaSpy).toHaveBeenCalledWith(
307 'sl',
308 ['rebase', '--rev', 'max(successors(aaa))', '--noninteractive'],
309 expect.anything(),
310 );
311 });
312
313 it('handles exact revsets', async () => {
314 await runOperation({
315 args: ['rebase', '--rev', {type: 'exact-revset', revset: 'aaa'}],
316 });
317
318 expect(ejecaSpy).toHaveBeenCalledWith(
319 'sl',
320 ['rebase', '--rev', 'aaa', '--noninteractive'],
321 expect.anything(),
322 );
323 });
324
325 it('handles repo-relative files', async () => {
326 await runOperation({
327 args: ['add', {type: 'repo-relative-file', path: 'path/to/file.txt'}],
328 });
329
330 expect(ejecaSpy).toHaveBeenCalledWith(
331 'sl',
332 ['add', '../repo/path/to/file.txt', '--noninteractive'],
333 expect.anything(),
334 );
335 });
336
337 it('handles allowed configs', async () => {
338 await runOperation({
339 args: ['commit', {type: 'config', key: 'ui.allowemptycommit', value: 'True'}],
340 });
341
342 expect(ejecaSpy).toHaveBeenCalledWith(
343 'sl',
344 ['commit', '--config', 'ui.allowemptycommit=True', '--noninteractive'],
345 expect.anything(),
346 );
347 });
348
349 it('disallows some commands', async () => {
350 await runOperation({
351 args: ['debugsh'],
352 });
353
354 expect(ejecaSpy).not.toHaveBeenCalledWith(
355 'sl',
356 ['debugsh', '--noninteractive'],
357 expect.anything(),
358 );
359 });
360
361 it('disallows unknown configs', async () => {
362 await runOperation({
363 args: ['commit', {type: 'config', key: 'foo.bar', value: '1'}],
364 });
365
366 expect(ejecaSpy).not.toHaveBeenCalledWith(
367 'sl',
368 expect.arrayContaining(['commit', '--config', 'foo.bar=1']),
369 expect.anything(),
370 );
371 });
372
373 it('disallows unstructured --config flag', async () => {
374 await runOperation({
375 args: ['commit', '--config', 'foo.bar=1'],
376 });
377
378 expect(ejecaSpy).not.toHaveBeenCalledWith(
379 'sl',
380 expect.arrayContaining(['commit', '--config', 'foo.bar=1']),
381 expect.anything(),
382 );
383 });
384 });
385
386 describe('fetchSloc', () => {
387 const repoInfo: ValidatedRepoInfo = {
388 type: 'success',
389 command: 'sl',
390 dotdir: '/path/to/repo/.sl',
391 repoRoot: '/path/to/repo',
392 codeReviewSystem: {type: 'unknown'},
393 pullRequestDomain: undefined,
394 isEdenFs: false,
395 };
396
397 const EXAMPLE_DIFFSTAT = `
398| 34 ++++++++++
399www/flib/intern/entity/diff/EntPhabricatorDiffSchema.php | 11 +++
4002 files changed, 45 insertions(+), 0 deletions(-)\n`;
401
402 it('parses sloc', async () => {
403 const repo = new Repository(repoInfo, ctx);
404
405 const ejecaSpy = mockEjeca([[/^sl diff/, () => ({stdout: EXAMPLE_DIFFSTAT})]]);
406 const results = repo.fetchSignificantLinesOfCode(ctx, 'abcdef', ['generated.file']);
407 await expect(results).resolves.toEqual(45);
408 expect(ejecaSpy).toHaveBeenCalledWith(
409 'sl',
410 expect.arrayContaining([
411 'diff',
412 '-B',
413 '-X',
414 '**__generated__**',
415 '-X',
416 '/path/to/repo/generated.file',
417 '-c',
418 'abcdef',
419 ]),
420 expect.anything(),
421 );
422 });
423
424 it('handles empty generated list', async () => {
425 const repo = new Repository(repoInfo, ctx);
426 const ejecaSpy = mockEjeca([[/^sl diff/, () => ({stdout: EXAMPLE_DIFFSTAT})]]);
427 repo.fetchSignificantLinesOfCode(ctx, 'abcdef', []);
428 expect(ejecaSpy).toHaveBeenCalledWith(
429 'sl',
430 expect.arrayContaining(['diff', '-B', '-X', '**__generated__**', '-c', 'abcdef']),
431 expect.anything(),
432 );
433 });
434
435 it('handles multiple generated files', async () => {
436 const repo = new Repository(repoInfo, ctx);
437 const ejecaSpy = mockEjeca([[/^sl diff/, () => ({stdout: EXAMPLE_DIFFSTAT})]]);
438 const generatedFiles = ['generated1.file', 'generated2.file'];
439 repo.fetchSignificantLinesOfCode(ctx, 'abcdef', generatedFiles);
440 await nextTick();
441 expect(ejecaSpy).toHaveBeenCalledWith(
442 'sl',
443 expect.arrayContaining([
444 'diff',
445 '-B',
446 '-X',
447 '**__generated__**',
448 '-X',
449 '/path/to/repo/generated1.file',
450 '-X',
451 '/path/to/repo/generated2.file',
452 '-c',
453 'abcdef',
454 ]),
455 expect.anything(),
456 );
457 });
458 });
459
460 describe('fetchSmartlogCommits', () => {
461 const repoInfo: ValidatedRepoInfo = {
462 type: 'success',
463 command: 'sl',
464 dotdir: '/path/to/repo/.sl',
465 repoRoot: '/path/to/repo',
466 codeReviewSystem: {type: 'unknown'},
467 pullRequestDomain: undefined,
468 isEdenFs: false,
469 };
470
471 const expectCalledWithRevset = (spy: jest.SpyInstance<unknown>, revset: string) => {
472 expect(spy).toHaveBeenCalledWith(
473 'sl',
474 expect.arrayContaining(['log', '--rev', revset]),
475 expect.anything(),
476 );
477 };
478
479 it('uses correct revset in normal case', async () => {
480 const repo = new Repository(repoInfo, ctx);
481
482 const ejecaSpy = mockEjeca([]);
483
484 await repo.fetchSmartlogCommits();
485 expectCalledWithRevset(
486 ejecaSpy,
487 'smartlog(((interestingbookmarks() + heads(draft())) & date(-14)) + .)',
488 );
489 });
490
491 it('updates revset when changing date range', async () => {
492 const ejecaSpy = mockEjeca([]);
493 const repo = new Repository(repoInfo, ctx);
494
495 repo.nextVisibleCommitRangeInDays();
496 await repo.fetchSmartlogCommits();
497 expectCalledWithRevset(
498 ejecaSpy,
499 'smartlog(((interestingbookmarks() + heads(draft())) & date(-60)) + .)',
500 );
501
502 repo.nextVisibleCommitRangeInDays();
503 await repo.fetchSmartlogCommits();
504 expectCalledWithRevset(ejecaSpy, 'smartlog((interestingbookmarks() + heads(draft())) + .)');
505 });
506
507 it('fetches additional revsets', async () => {
508 const ejecaSpy = mockEjeca([]);
509 const repo = new Repository(repoInfo, ctx);
510
511 repo.stableLocations = [
512 {name: 'mystable', hash: 'aaa', info: 'this is the stable for aaa', date: new Date(0)},
513 ];
514 await repo.fetchSmartlogCommits();
515 expectCalledWithRevset(
516 ejecaSpy,
517 'smartlog(((interestingbookmarks() + heads(draft())) & date(-14)) + . + present(aaa))',
518 );
519
520 repo.stableLocations = [
521 {name: 'mystable', hash: 'aaa', info: 'this is the stable for aaa', date: new Date(0)},
522 {name: '2', hash: 'bbb', info: '2', date: new Date(0)},
523 ];
524 await repo.fetchSmartlogCommits();
525 expectCalledWithRevset(
526 ejecaSpy,
527 'smartlog(((interestingbookmarks() + heads(draft())) & date(-14)) + . + present(aaa) + present(bbb))',
528 );
529
530 repo.nextVisibleCommitRangeInDays();
531 repo.nextVisibleCommitRangeInDays();
532 await repo.fetchSmartlogCommits();
533 expectCalledWithRevset(
534 ejecaSpy,
535 'smartlog((interestingbookmarks() + heads(draft())) + . + present(aaa) + present(bbb))',
536 );
537 });
538 });
539
540 describe('merge conflicts', () => {
541 const repoInfo: ValidatedRepoInfo = {
542 type: 'success',
543 command: 'sl',
544 dotdir: '/path/to/repo/.sl',
545 repoRoot: '/path/to/repo',
546 codeReviewSystem: {type: 'unknown'},
547 pullRequestDomain: undefined,
548 isEdenFs: false,
549 };
550 const NOT_IN_CONFLICT: ResolveCommandConflictOutput = [
551 {
552 command: null,
553 conflicts: [],
554 pathconflicts: [],
555 },
556 ];
557
558 const conflictFileData = (contents: string) => ({
559 contents,
560 exists: true,
561 isexec: false,
562 issymlink: false,
563 });
564 const MARK_IN = '<'.repeat(7) + ` dest: aaaaaaaaaaaa - unixname: Commit A`;
565 const MARK_OUT = '>'.repeat(7) + ` source: bbbbbbbbbbbb - unixname: Commit B`;
566 const MARK_BASE_START = `||||||| base`;
567 const MARK_BASE_END = `=======`;
568
569 const MOCK_CONFLICT: ResolveCommandConflictOutput = [
570 {
571 command: 'rebase',
572 command_details: {
573 cmd: 'rebase',
574 to_abort: 'rebase --abort',
575 to_continue: 'rebase --continue',
576 },
577 conflicts: [
578 {
579 base: conflictFileData('hello\nworld\n'),
580 local: conflictFileData('hello\nworld - modified 1\n'),
581 other: conflictFileData('hello\nworld - modified 2\n'),
582 output: conflictFileData(
583 `\
584hello
585${MARK_IN}
586world - modified 1
587${MARK_BASE_START}
588world
589${MARK_BASE_END}
590modified 2
591${MARK_OUT}
592`,
593 ),
594 path: 'file1.txt',
595 },
596 {
597 base: conflictFileData('hello\nworld\n'),
598 local: conflictFileData('hello\nworld - modified 1\n'),
599 other: conflictFileData('hello\nworld - modified 2\n'),
600 output: conflictFileData(
601 `\
602hello
603${MARK_IN}
604world - modified 1
605${MARK_BASE_START}
606world
607${MARK_BASE_END}
608modified 2
609${MARK_OUT}
610`,
611 ),
612 path: 'file2.txt',
613 },
614 ],
615 pathconflicts: [],
616 },
617 ];
618
619 // same as MOCK_CONFLICT, but without any data for file1.txt
620 const MOCK_CONFLICT_WITH_FILE1_RESOLVED: ResolveCommandConflictOutput = clone(MOCK_CONFLICT);
621 MOCK_CONFLICT_WITH_FILE1_RESOLVED[0].conflicts.splice(0, 1);
622
623 // these mock values are returned by ejeca / fs mocks
624 // default: start in a not-in-conflict state
625 let slMergeDirExists = false;
626 let conflictData: ResolveCommandConflictOutput = NOT_IN_CONFLICT;
627
628 /**
629 * the next time repo.checkForMergeConflicts is called, this new conflict data will be used
630 */
631 function enterMergeConflict(conflict: ResolveCommandConflictOutput) {
632 slMergeDirExists = true;
633 conflictData = conflict;
634 }
635
636 beforeEach(() => {
637 slMergeDirExists = false;
638 conflictData = NOT_IN_CONFLICT;
639
640 jest.spyOn(fsUtils, 'exists').mockImplementation(() => Promise.resolve(slMergeDirExists));
641
642 mockEjeca([
643 [
644 /^sl resolve --tool internal:dumpjson --all/,
645 () => ({stdout: JSON.stringify(conflictData)}),
646 ],
647 ]);
648 });
649
650 it('checks for merge conflicts', async () => {
651 const repo = new Repository(repoInfo, ctx);
652
653 const onChange = jest.fn();
654 repo.onChangeConflictState(onChange);
655
656 await repo.checkForMergeConflicts();
657 expect(onChange).toHaveBeenCalledTimes(0);
658
659 enterMergeConflict(MOCK_CONFLICT);
660
661 await repo.checkForMergeConflicts();
662
663 expect(onChange).toHaveBeenCalledWith({state: 'loading'});
664 expect(onChange).toHaveBeenCalledWith({
665 state: 'loaded',
666 command: 'rebase',
667 toContinue: 'rebase --continue',
668 toAbort: 'rebase --abort',
669 files: [
670 {path: 'file1.txt', status: 'U', conflictType: 'both_changed'},
671 {path: 'file2.txt', status: 'U', conflictType: 'both_changed'},
672 ],
673 fetchStartTimestamp: expect.anything(),
674 fetchCompletedTimestamp: expect.anything(),
675 } as MergeConflicts);
676 });
677
678 it('shows deleted file conflicts', async () => {
679 const repo = new Repository(repoInfo, ctx);
680
681 const onChange = jest.fn();
682 repo.onChangeConflictState(onChange);
683
684 await repo.checkForMergeConflicts();
685 expect(onChange).toHaveBeenCalledTimes(0);
686
687 const MOCK_DELETED_CONFLICT: ResolveCommandConflictOutput = [
688 {
689 command: 'rebase',
690 command_details: {
691 cmd: 'rebase',
692 to_abort: 'rebase --abort',
693 to_continue: 'rebase --continue',
694 },
695 conflicts: [
696 {
697 base: conflictFileData('hello\nworld\n'),
698 local: conflictFileData('hello\nworld - modified 1\n'),
699 other: {
700 contents: null,
701 exists: false,
702 isexec: false,
703 issymlink: false,
704 },
705 output: {
706 contents: null,
707 exists: false,
708 isexec: false,
709 issymlink: false,
710 },
711 path: 'file_del1.txt',
712 },
713 {
714 base: conflictFileData('hello\nworld\n'),
715 local: {
716 contents: null,
717 exists: false,
718 isexec: false,
719 issymlink: false,
720 },
721 other: conflictFileData('hello\nworld - modified 2\n'),
722 output: conflictFileData('hello\nworld - modified 2\n'),
723 path: 'file_del2.txt',
724 },
725 ],
726 pathconflicts: [],
727 },
728 ];
729
730 enterMergeConflict(MOCK_DELETED_CONFLICT);
731
732 await repo.checkForMergeConflicts();
733
734 expect(onChange).toHaveBeenCalledWith({state: 'loading'});
735 expect(onChange).toHaveBeenCalledWith({
736 state: 'loaded',
737 command: 'rebase',
738 toContinue: 'rebase --continue',
739 toAbort: 'rebase --abort',
740 files: [
741 {path: 'file_del1.txt', status: 'U', conflictType: 'source_deleted'},
742 {path: 'file_del2.txt', status: 'U', conflictType: 'dest_deleted'},
743 ],
744 fetchStartTimestamp: expect.anything(),
745 fetchCompletedTimestamp: expect.anything(),
746 } as MergeConflicts);
747 });
748
749 it('disposes conflict change subscriptions', async () => {
750 const repo = new Repository(repoInfo, ctx);
751
752 const onChange = jest.fn();
753 const subscription = repo.onChangeConflictState(onChange);
754 subscription.dispose();
755
756 enterMergeConflict(MOCK_CONFLICT);
757 await repo.checkForMergeConflicts();
758 expect(onChange).toHaveBeenCalledTimes(0);
759 });
760
761 it('sends conflicts right away on subscription if already in conflicts', async () => {
762 enterMergeConflict(MOCK_CONFLICT);
763
764 const repo = new Repository(repoInfo, ctx);
765
766 const onChange = jest.fn();
767 repo.onChangeConflictState(onChange);
768 await nextTick(); // allow message to get sent
769
770 expect(onChange).toHaveBeenCalledWith({state: 'loading'});
771 expect(onChange).toHaveBeenCalledWith({
772 state: 'loaded',
773 command: 'rebase',
774 toContinue: 'rebase --continue',
775 toAbort: 'rebase --abort',
776 files: [
777 {path: 'file1.txt', status: 'U', conflictType: 'both_changed'},
778 {path: 'file2.txt', status: 'U', conflictType: 'both_changed'},
779 ],
780 fetchStartTimestamp: expect.anything(),
781 fetchCompletedTimestamp: expect.anything(),
782 });
783 });
784
785 it('preserves previous conflicts as resolved', async () => {
786 const repo = new Repository(repoInfo, ctx);
787 const onChange = jest.fn();
788 repo.onChangeConflictState(onChange);
789
790 enterMergeConflict(MOCK_CONFLICT);
791 await repo.checkForMergeConflicts();
792 expect(onChange).toHaveBeenCalledWith({
793 state: 'loaded',
794 command: 'rebase',
795 toContinue: 'rebase --continue',
796 toAbort: 'rebase --abort',
797 files: [
798 {path: 'file1.txt', status: 'U', conflictType: 'both_changed'},
799 {path: 'file2.txt', status: 'U', conflictType: 'both_changed'},
800 ],
801 fetchStartTimestamp: expect.anything(),
802 fetchCompletedTimestamp: expect.anything(),
803 });
804
805 enterMergeConflict(MOCK_CONFLICT_WITH_FILE1_RESOLVED);
806 await repo.checkForMergeConflicts();
807 expect(onChange).toHaveBeenCalledWith({
808 state: 'loaded',
809 command: 'rebase',
810 toContinue: 'rebase --continue',
811 toAbort: 'rebase --abort',
812 files: [
813 // even though file1 is no longer in the output, we remember it from before.
814 {path: 'file1.txt', status: 'Resolved', conflictType: 'both_changed'},
815 {path: 'file2.txt', status: 'U', conflictType: 'both_changed'},
816 ],
817 fetchStartTimestamp: expect.anything(),
818 fetchCompletedTimestamp: expect.anything(),
819 });
820 });
821
822 it('handles errors from `sl resolve`', async () => {
823 mockEjeca([
824 [/^sl resolve --tool internal:dumpjson --all/, new Error('failed to do the thing')],
825 ]);
826
827 const repo = new Repository(repoInfo, ctx);
828 const onChange = jest.fn();
829 repo.onChangeConflictState(onChange);
830
831 enterMergeConflict(MOCK_CONFLICT);
832 await expect(repo.checkForMergeConflicts()).resolves.toEqual(undefined);
833
834 expect(onChange).toHaveBeenCalledWith({state: 'loading'});
835 expect(onChange).toHaveBeenCalledWith(undefined);
836 });
837 });
838});
839
840describe('extractRepoInfoFromUrl', () => {
841 describe('github.com', () => {
842 it('handles http', () => {
843 expect(extractRepoInfoFromUrl('https://github.com/myUsername/myRepo.git')).toEqual({
844 owner: 'myUsername',
845 repo: 'myRepo',
846 hostname: 'github.com',
847 });
848 });
849 it('handles plain github.com', () => {
850 expect(extractRepoInfoFromUrl('github.com/myUsername/myRepo.git')).toEqual({
851 owner: 'myUsername',
852 repo: 'myRepo',
853 hostname: 'github.com',
854 });
855 });
856 it('handles git@github', () => {
857 expect(extractRepoInfoFromUrl('git@github.com:myUsername/myRepo.git')).toEqual({
858 owner: 'myUsername',
859 repo: 'myRepo',
860 hostname: 'github.com',
861 });
862 });
863 it('handles ssh with slashes', () => {
864 expect(extractRepoInfoFromUrl('ssh://git@github.com/myUsername/my-repo.git')).toEqual({
865 owner: 'myUsername',
866 repo: 'my-repo',
867 hostname: 'github.com',
868 });
869 });
870 it('handles git+ssh', () => {
871 expect(extractRepoInfoFromUrl('git+ssh://git@github.com:myUsername/myRepo.git')).toEqual({
872 owner: 'myUsername',
873 repo: 'myRepo',
874 hostname: 'github.com',
875 });
876 });
877 it('handles dotted http', () => {
878 expect(extractRepoInfoFromUrl('https://github.com/myUsername/my.dotted.repo.git')).toEqual({
879 owner: 'myUsername',
880 repo: 'my.dotted.repo',
881 hostname: 'github.com',
882 });
883 });
884 it('handles dotted ssh', () => {
885 expect(extractRepoInfoFromUrl('git@github.com:myUsername/my.dotted.repo.git')).toEqual({
886 owner: 'myUsername',
887 repo: 'my.dotted.repo',
888 hostname: 'github.com',
889 });
890 });
891 });
892
893 describe('github enterprise', () => {
894 it('handles http', () => {
895 expect(extractRepoInfoFromUrl('https://ghe.company.com/myUsername/myRepo.git')).toEqual({
896 owner: 'myUsername',
897 repo: 'myRepo',
898 hostname: 'ghe.company.com',
899 });
900 });
901 it('handles plain github.com', () => {
902 expect(extractRepoInfoFromUrl('ghe.company.com/myUsername/myRepo.git')).toEqual({
903 owner: 'myUsername',
904 repo: 'myRepo',
905 hostname: 'ghe.company.com',
906 });
907 });
908 it('handles git@github', () => {
909 expect(extractRepoInfoFromUrl('git@ghe.company.com:myUsername/myRepo.git')).toEqual({
910 owner: 'myUsername',
911 repo: 'myRepo',
912 hostname: 'ghe.company.com',
913 });
914 });
915 it('handles ssh with slashes', () => {
916 expect(extractRepoInfoFromUrl('ssh://git@ghe.company.com/myUsername/my-repo.git')).toEqual({
917 owner: 'myUsername',
918 repo: 'my-repo',
919 hostname: 'ghe.company.com',
920 });
921 });
922 it('handles git+ssh', () => {
923 expect(extractRepoInfoFromUrl('git+ssh://git@ghe.company.com:myUsername/myRepo.git')).toEqual(
924 {
925 owner: 'myUsername',
926 repo: 'myRepo',
927 hostname: 'ghe.company.com',
928 },
929 );
930 });
931 it('handles dotted http', () => {
932 expect(
933 extractRepoInfoFromUrl('https://ghe.company.com/myUsername/my.dotted.repo.git'),
934 ).toEqual({
935 owner: 'myUsername',
936 repo: 'my.dotted.repo',
937 hostname: 'ghe.company.com',
938 });
939 });
940 it('handles dotted ssh', () => {
941 expect(extractRepoInfoFromUrl('git@ghe.company.com:myUsername/my.dotted.repo.git')).toEqual({
942 owner: 'myUsername',
943 repo: 'my.dotted.repo',
944 hostname: 'ghe.company.com',
945 });
946 });
947 });
948});
949
950describe('absolutePathForFileInRepo', () => {
951 let ctx: RepositoryContext;
952 beforeEach(() => {
953 ctx = {
954 cmd: 'sl',
955 cwd: '/path/to/cwd',
956 logger: mockLogger,
957 tracker: mockTracker,
958 };
959 });
960
961 it('rejects .. in paths that escape the repo', () => {
962 const repoInfo: ValidatedRepoInfo = {
963 type: 'success',
964 command: 'sl',
965 dotdir: '/path/to/repo/.sl',
966 repoRoot: '/path/to/repo',
967 codeReviewSystem: {type: 'unknown'},
968 pullRequestDomain: undefined,
969 isEdenFs: false,
970 };
971 const repo = new Repository(repoInfo, ctx);
972
973 expect(absolutePathForFileInRepo('foo/bar/file.txt', repo)).toEqual(
974 '/path/to/repo/foo/bar/file.txt',
975 );
976 expect(absolutePathForFileInRepo('foo/../bar/file.txt', repo)).toEqual(
977 '/path/to/repo/bar/file.txt',
978 );
979 expect(absolutePathForFileInRepo('file.txt', repo)).toEqual('/path/to/repo/file.txt');
980
981 expect(absolutePathForFileInRepo('/file.txt', repo)).toEqual(null);
982 expect(absolutePathForFileInRepo('', repo)).toEqual(null);
983 expect(absolutePathForFileInRepo('foo/../../file.txt', repo)).toEqual(null);
984 expect(absolutePathForFileInRepo('../file.txt', repo)).toEqual(null);
985 expect(absolutePathForFileInRepo('/../file.txt', repo)).toEqual(null);
986 });
987
988 it('works on windows', () => {
989 const repoInfo: ValidatedRepoInfo = {
990 type: 'success',
991 command: 'sl',
992 dotdir: 'C:\\path\\to\\repo\\.sl',
993 repoRoot: 'C:\\path\\to\\repo',
994 codeReviewSystem: {type: 'unknown'},
995 pullRequestDomain: undefined,
996 isEdenFs: false,
997 };
998 const repo = new Repository(repoInfo, ctx);
999
1000 expect(absolutePathForFileInRepo('foo\\bar\\file.txt', repo, path.win32)).toEqual(
1001 'C:\\path\\to\\repo\\foo\\bar\\file.txt',
1002 );
1003
1004 expect(absolutePathForFileInRepo('foo\\..\\..\\file.txt', repo, path.win32)).toEqual(null);
1005 });
1006});
1007
1008describe('getCwdInfo', () => {
1009 it('computes cwd path and labels', async () => {
1010 mockEjeca([[/^sl root/, {stdout: '/path/to/myRepo'}]]);
1011 jest.spyOn(fs.promises, 'realpath').mockImplementation(async (path, _opts) => {
1012 return path as string;
1013 });
1014 await expect(
1015 Repository.getCwdInfo({
1016 cmd: 'sl',
1017 cwd: '/path/to/myRepo/some/subdir',
1018 logger: mockLogger,
1019 tracker: mockTracker,
1020 }),
1021 ).resolves.toEqual({
1022 cwd: '/path/to/myRepo/some/subdir',
1023 repoRoot: '/path/to/myRepo',
1024 repoRelativeCwdLabel: 'myRepo/some/subdir',
1025 });
1026 });
1027
1028 it('uses realpath', async () => {
1029 mockEjeca([[/^sl root/, {stdout: '/data/users/name/myRepo'}]]);
1030 jest.spyOn(fs.promises, 'realpath').mockImplementation(async (path, _opts) => {
1031 return (path as string).replace(/^\/home\/name\//, '/data/users/name/');
1032 });
1033 await expect(
1034 Repository.getCwdInfo({
1035 cmd: 'sl',
1036 cwd: '/home/name/myRepo/some/subdir',
1037 logger: mockLogger,
1038 tracker: mockTracker,
1039 }),
1040 ).resolves.toEqual({
1041 cwd: '/home/name/myRepo/some/subdir', // cwd is not realpath'd
1042 repoRoot: '/data/users/name/myRepo', // repo root is realpath'd
1043 repoRelativeCwdLabel: 'myRepo/some/subdir',
1044 });
1045 });
1046
1047 it('returns null for non-repos', async () => {
1048 mockEjeca([[/^sl root/, new Error('not a repository')]]);
1049 await expect(
1050 Repository.getCwdInfo({
1051 cmd: 'sl',
1052 cwd: '/path/ro/myRepo/some/subdir',
1053 logger: mockLogger,
1054 tracker: mockTracker,
1055 }),
1056 ).resolves.toEqual({
1057 cwd: '/path/ro/myRepo/some/subdir',
1058 });
1059 });
1060});
1061
1062describe('fetchSubmoduleMap', () => {
1063 let myRepoRoot: AbsolutePath;
1064 let ctx: RepositoryContext;
1065 beforeEach(() => {
1066 myRepoRoot = '/data/users/name/myRepo';
1067 ctx = {
1068 cmd: 'sl',
1069 cwd: myRepoRoot,
1070 logger: mockLogger,
1071 tracker: mockTracker,
1072 };
1073 });
1074
1075 it('simple', async () => {
1076 const submodules: Submodule[] = [
1077 {
1078 name: 'submoduleA',
1079 url: 'https://ghe.myCompany.com/myUsername/myRepo/submoduleA',
1080 path: 'submoduleA',
1081 active: true,
1082 },
1083 {
1084 name: 'submoduleB',
1085 url: 'https://ghe.myCompany.com/myUsername/myRepo/submoduleB',
1086 path: 'submoduleB',
1087 active: false,
1088 },
1089 ];
1090 const submodulesJson = JSON.stringify(submodules);
1091 mockEjeca([
1092 [/^sl root/, {stdout: myRepoRoot}],
1093 [/^sl debugroots/, {stdout: myRepoRoot}],
1094 [/^sl debuggitmodules/, {stdout: submodulesJson}],
1095 ]);
1096 const info = (await Repository.getRepoInfo(ctx)) as ValidatedRepoInfo;
1097 const repo = new Repository(info, ctx);
1098 await repo.fetchSubmoduleMap();
1099 const fetchedSubmoduleMap = repo.getSubmoduleMap();
1100 expect(fetchedSubmoduleMap).not.toBeUndefined();
1101 expect(fetchedSubmoduleMap?.get(myRepoRoot)?.value).toEqual(submodules);
1102 });
1103
1104 it('no submodules', async () => {
1105 const submodules: Submodule[] = [];
1106 const submodulesJson = JSON.stringify(submodules);
1107 mockEjeca([
1108 [/^sl root/, {stdout: myRepoRoot}],
1109 [/^sl debugroots/, {stdout: myRepoRoot}],
1110 [/^sl debuggitmodules/, {stdout: submodulesJson}],
1111 ]);
1112 const info = (await Repository.getRepoInfo(ctx)) as ValidatedRepoInfo;
1113 const repo = new Repository(info, ctx);
1114 await repo.fetchSubmoduleMap();
1115 const fetchedSubmoduleMap = repo.getSubmoduleMap();
1116 expect(fetchedSubmoduleMap).not.toBeUndefined();
1117 expect(fetchedSubmoduleMap?.get(myRepoRoot)?.value).toBeUndefined();
1118 });
1119
1120 it('nested', async () => {
1121 const submodulesOfMyRepo: Submodule[] = [
1122 {
1123 name: 'submoduleA',
1124 url: 'https://ghe.myCompany.com/myUsername/myRepo/submoduleA',
1125 path: 'submoduleA',
1126 active: true,
1127 },
1128 ];
1129 const submoduleARoot = myRepoRoot + '/submoduleA';
1130 const submodulesOfA: Submodule[] = [
1131 {
1132 name: 'submoduleB',
1133 url: 'https://ghe.myCompany.com/myUsername/myRepo/submoduleA/submoduleB',
1134 path: 'submoduleB',
1135 active: true,
1136 },
1137 ];
1138 const submoduleBRoot = submoduleARoot + '/submoduleB';
1139 mockEjeca([
1140 [
1141 new RegExp(`^sl debuggitmodules --json --repo ${submoduleARoot}`),
1142 {stdout: JSON.stringify(submodulesOfA)},
1143 ],
1144 [
1145 new RegExp(`^sl debuggitmodules --json --repo ${myRepoRoot}`),
1146 {stdout: JSON.stringify(submodulesOfMyRepo)},
1147 ],
1148 [/^sl root/, {stdout: submoduleBRoot}],
1149 [/^sl debugroots/, {stdout: myRepoRoot + '\n' + submoduleARoot + '\n' + submoduleBRoot}],
1150 ]);
1151 const updatedCtx = {...ctx, cwd: submoduleBRoot};
1152 const info = (await Repository.getRepoInfo(updatedCtx)) as ValidatedRepoInfo;
1153 const repo = new Repository(info, updatedCtx);
1154 await repo.fetchSubmoduleMap();
1155 const fetchedSubmoduleMap = repo.getSubmoduleMap();
1156
1157 expect(fetchedSubmoduleMap).not.toBeUndefined();
1158 expect(fetchedSubmoduleMap?.get(myRepoRoot)?.value).toEqual(submodulesOfMyRepo);
1159 expect(fetchedSubmoduleMap?.get(submoduleARoot)?.value).toEqual(submodulesOfA);
1160 });
1161
1162 it('error', async () => {
1163 const msg = 'mock sapling error';
1164 mockEjeca([
1165 [/^sl root/, {stdout: myRepoRoot}],
1166 [/^sl debugroots/, {stdout: myRepoRoot}],
1167 [/^sl debuggitmodules/, new Error(msg)],
1168 ]);
1169 const info = (await Repository.getRepoInfo(ctx)) as ValidatedRepoInfo;
1170 const repo = new Repository(info, ctx);
1171 await repo.fetchSubmoduleMap();
1172 const fetchedSubmoduleMap = repo.getSubmoduleMap();
1173
1174 expect(fetchedSubmoduleMap).not.toBeUndefined();
1175 expect(fetchedSubmoduleMap?.get(myRepoRoot)?.value).toBeUndefined();
1176 expect(fetchedSubmoduleMap?.get(myRepoRoot)?.error?.message).toMatch(msg);
1177 });
1178});
1179