12.1 KB365 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 {RepoInfo, RepositoryError, ValidatedRepoInfo} from 'isl/src/types';
9import type {Repository} from '../Repository';
10import type {Logger} from '../logger';
11import type {ServerPlatform} from '../serverPlatform';
12import type {RepositoryContext} from '../serverTypes';
13
14import {ensureTrailingPathSep} from 'shared/pathUtils';
15import {mockLogger} from 'shared/testUtils';
16import {defer} from 'shared/utils';
17import {__TEST__} from '../RepositoryCache';
18import {makeServerSideTracker} from '../analytics/serverSideTracker';
19
20const {RepositoryCache, RepoMap, RefCounted} = __TEST__;
21
22const mockTracker = makeServerSideTracker(
23 mockLogger,
24 {platformName: 'test'} as ServerPlatform,
25 '0.1',
26 jest.fn(),
27);
28
29class SimpleMockRepositoryImpl {
30 static getRepoInfo(ctx: RepositoryContext): Promise<RepoInfo> {
31 const {cwd, cmd} = ctx;
32 let data;
33 if (cwd.includes('/path/to/repo')) {
34 data = {
35 repoRoot: '/path/to/repo',
36 dotdir: '/path/to/repo/.sl',
37 };
38 } else if (cwd.includes('/path/to/anotherrepo')) {
39 data = {
40 repoRoot: '/path/to/anotherrepo',
41 dotdir: '/path/to/anotherrepo/.sl',
42 };
43 } else if (cwd.includes('/path/to/submodule')) {
44 data = {
45 repoRoot: cwd.endsWith('/cwd') ? cwd.slice(0, -4) : cwd,
46 dotdir: ensureTrailingPathSep(cwd) + '.sl',
47 };
48 } else {
49 return Promise.resolve({type: 'cwdNotARepository', cwd} as RepositoryError);
50 }
51 return Promise.resolve({
52 type: 'success',
53 command: cmd,
54 pullRequestDomain: undefined,
55 preferredSubmitCommand: 'pr',
56 codeReviewSystem: {type: 'unknown'},
57 isEdenFs: false,
58 ...data,
59 });
60 }
61 constructor(
62 public info: RepoInfo,
63 public logger: Logger,
64 ) {}
65
66 dispose = jest.fn();
67}
68const SimpleMockRepository = SimpleMockRepositoryImpl as unknown as typeof Repository;
69
70const ctx: RepositoryContext = {
71 cmd: 'sl',
72 logger: mockLogger,
73 tracker: mockTracker,
74 cwd: '/path/to/repo/cwd',
75};
76
77describe('RepositoryCache', () => {
78 it('Provides repository references that resolve', async () => {
79 const cache = new RepositoryCache(SimpleMockRepository);
80 const ref = cache.getOrCreate(ctx);
81
82 const repo = await ref.promise;
83 expect(repo).toEqual(
84 expect.objectContaining({
85 info: expect.objectContaining({
86 repoRoot: '/path/to/repo',
87 dotdir: '/path/to/repo/.sl',
88 }),
89 }),
90 );
91
92 ref.unref();
93 });
94
95 it('Gives error for paths without repos', async () => {
96 const cache = new RepositoryCache(SimpleMockRepository);
97 const ref = cache.getOrCreate({...ctx, cwd: '/some/invalid/repo'});
98
99 const repo = await ref.promise;
100 expect(repo).toEqual({type: 'cwdNotARepository', cwd: '/some/invalid/repo'});
101
102 ref.unref();
103 });
104
105 it('Disposes repositories', async () => {
106 const cache = new RepositoryCache(SimpleMockRepository);
107 const ref = cache.getOrCreate(ctx);
108
109 const repo = await ref.promise;
110 const disposeFunc = (repo as Repository).dispose;
111
112 expect(cache.numberOfActiveServers()).toBe(1);
113
114 ref.unref();
115 expect(disposeFunc).toHaveBeenCalledTimes(1);
116 expect(cache.numberOfActiveServers()).toBe(0);
117 });
118
119 it('Can dispose references before the repo promise has resolved', async () => {
120 const cache = new RepositoryCache(SimpleMockRepository);
121 const ref = cache.getOrCreate(ctx);
122
123 ref.unref();
124
125 const repo = await ref.promise;
126 // even though this would be a valid repo, by disposing the ref before it is created,
127 // we prevent creating a repo.
128 expect(repo).toEqual({type: 'unknownError', error: expect.anything()});
129 });
130
131 it('shares repositories under the same cwd', async () => {
132 const cache = new RepositoryCache(SimpleMockRepository);
133 const ref1 = cache.getOrCreate({...ctx, cwd: '/path/to/repo'});
134 const repo1 = await ref1.promise;
135
136 const ref2 = cache.getOrCreate({...ctx, cwd: '/path/to/repo'});
137 const repo2 = await ref2.promise;
138
139 expect(cache.numberOfActiveServers()).toBe(2);
140
141 expect(repo1).toBe(repo2);
142
143 ref1.unref();
144 ref2.unref();
145 });
146
147 it('shares repositories under the same repo', async () => {
148 const cache = new RepositoryCache(SimpleMockRepository);
149 const ref1 = cache.getOrCreate({...ctx, cwd: '/path/to/repo/cwd1'});
150 const repo1 = await ref1.promise;
151
152 const ref2 = cache.getOrCreate({...ctx, cwd: '/path/to/repo/cwd2'});
153 const repo2 = await ref2.promise;
154
155 expect(cache.numberOfActiveServers()).toBe(2);
156
157 expect(repo1).toBe(repo2);
158
159 ref1.unref();
160 ref2.unref();
161 });
162
163 it('does not share repositories under different cwds', async () => {
164 const cache = new RepositoryCache(SimpleMockRepository);
165 const ref1 = cache.getOrCreate({...ctx, cwd: '/path/to/repo/cwd'});
166 const ref2 = cache.getOrCreate({...ctx, cwd: '/path/to/anotherrepo/cwd'});
167
168 const repo1 = await ref1.promise;
169 const repo2 = await ref2.promise;
170
171 expect(repo1).not.toBe(repo2);
172
173 ref1.unref();
174 ref2.unref();
175 });
176
177 it('reference counts and disposes after 0 refs', async () => {
178 const cache = new RepositoryCache(SimpleMockRepository);
179 const ref1 = cache.getOrCreate({...ctx, cwd: '/path/to/repo/cwd1'});
180 const ref2 = cache.getOrCreate({...ctx, cwd: '/path/to/repo/cwd2'});
181
182 const repo = await ref1.promise;
183 await ref2.promise;
184 expect(cache.numberOfActiveServers()).toBe(2);
185
186 const disposeFunc = (repo as Repository).dispose;
187
188 ref1.unref();
189 expect(disposeFunc).not.toHaveBeenCalled();
190 ref2.unref();
191 expect(disposeFunc).toHaveBeenCalledTimes(1);
192
193 expect(cache.numberOfActiveServers()).toBe(0);
194 });
195
196 it('does not reuse disposed repos', async () => {
197 const cache = new RepositoryCache(SimpleMockRepository);
198 const ref1 = cache.getOrCreate({...ctx, cwd: '/path/to/repo/cwd1'});
199
200 const repo1 = await ref1.promise;
201 ref1.unref();
202
203 const ref2 = cache.getOrCreate({...ctx, cwd: '/path/to/repo/cwd2'});
204 const repo2 = await ref2.promise;
205
206 expect(cache.numberOfActiveServers()).toBe(1);
207
208 expect(repo1).not.toBe(repo2);
209 expect((repo1 as Repository).dispose).toHaveBeenCalledTimes(1);
210 expect((repo2 as Repository).dispose).not.toHaveBeenCalled();
211
212 ref2.unref();
213 });
214
215 it('prefix matching repos are treated separately', async () => {
216 const cache = new RepositoryCache(SimpleMockRepository);
217 const ref1 = cache.getOrCreate({...ctx, cwd: '/path/to/repo'});
218 await ref1.promise;
219
220 expect(cache.cachedRepositoryForPath('/path/to/repo')).not.toEqual(undefined);
221 expect(cache.cachedRepositoryForPath('/path/to/repo/')).not.toEqual(undefined);
222 expect(cache.cachedRepositoryForPath('/path/to/repo/foo')).not.toEqual(undefined);
223 // this is actually different repo
224 expect(cache.cachedRepositoryForPath('/path/to/repo-1')).toEqual(undefined);
225
226 ref1.unref();
227 expect(cache.cachedRepositoryForPath('/path/to/repo')).toEqual(undefined);
228
229 // Test longest prefix match for nested repos/submodules
230 const refSubmodule = cache.getOrCreate({...ctx, cwd: '/path/to/submodule'});
231 await refSubmodule.promise;
232 const refSubmoduleNested = cache.getOrCreate({...ctx, cwd: '/path/to/submodule/nested'});
233 await refSubmoduleNested.promise;
234
235 const repoSubmodule = cache.cachedRepositoryForPath('/path/to/submodule');
236 const repoSubmoduleFoo = cache.cachedRepositoryForPath('/path/to/submodule/foo');
237 const repoNested = cache.cachedRepositoryForPath('/path/to/submodule/nested');
238 const repoNestedBar = cache.cachedRepositoryForPath('/path/to/submodule/nested/bar');
239
240 expect(repoSubmodule?.info.repoRoot).toEqual('/path/to/submodule');
241 expect(repoSubmoduleFoo).toEqual(repoSubmodule);
242 expect(repoNested?.info.repoRoot).toEqual('/path/to/submodule/nested');
243 expect(repoNestedBar).toEqual(repoNested);
244
245 refSubmoduleNested.unref();
246 expect(cache.cachedRepositoryForPath('/path/to/submodule/nested')).toEqual(undefined);
247 expect(cache.cachedRepositoryForPath('/path/to/submodule')).not.toEqual(undefined);
248
249 refSubmodule.unref();
250 expect(cache.cachedRepositoryForPath('/path/to/submodule')).toEqual(undefined);
251 });
252
253 it('only creates one Repository even when racing lookups', async () => {
254 const repoInfo = {
255 type: 'success',
256 command: 'sl',
257 pullRequestDomain: undefined,
258 codeReviewSystem: {type: 'unknown'},
259 repoRoot: '/path/to/repo',
260 dotdir: '/path/to/repo/.sl',
261 isEdenFs: false,
262 } as RepoInfo;
263
264 // two different fake async fetches for RepoInfo
265 const p1 = defer<RepoInfo>();
266 const p2 = defer<RepoInfo>();
267
268 class BlockingMockRepository {
269 static getRepoInfo(ctx: RepositoryContext): Promise<RepoInfo> {
270 const {cwd} = ctx;
271 if (cwd === '/path/to/repo/cwd1') {
272 return p1.promise;
273 } else if (cwd === '/path/to/repo/cwd2') {
274 return p2.promise;
275 }
276 throw new Error('unknown repo');
277 }
278 constructor(
279 public info: RepoInfo,
280 public logger: Logger,
281 ) {}
282
283 dispose = jest.fn();
284 }
285
286 const cache = new RepositoryCache(BlockingMockRepository as unknown as typeof Repository);
287 // start looking up repoInfos at the same time
288 const ref1 = cache.getOrCreate({...ctx, cwd: '/path/to/repo/cwd1'});
289 const ref2 = cache.getOrCreate({...ctx, cwd: '/path/to/repo/cwd2'});
290
291 p2.resolve({...repoInfo});
292 const repo2 = await ref2.promise;
293
294 p1.resolve({...repoInfo});
295 const repo1 = await ref1.promise;
296
297 // we end up with the same repo
298 expect(repo1).toBe(repo2);
299
300 ref1.unref();
301 ref2.unref();
302 });
303});
304
305describe('RepoMap', () => {
306 it('iteration with values() and forEach()', async () => {
307 const repoNum = 10;
308 const repoRoots = [];
309 const promises = [];
310 const createRefCountedRepo = async (ctx: RepositoryContext) => {
311 const repoInfo = await SimpleMockRepository.getRepoInfo(ctx);
312 const repo = new SimpleMockRepository(repoInfo as ValidatedRepoInfo, ctx);
313 return new RefCounted(repo);
314 };
315 for (let i = 0; i < repoNum; i++) {
316 repoRoots.push(`/path/to/submodule${i}`);
317 promises.push(createRefCountedRepo({...ctx, cwd: `/path/to/submodule${i}`}));
318 }
319 const repos = await Promise.all(promises);
320
321 const repoMap = new RepoMap();
322 for (let i = 0; i < repoNum; i++) {
323 repos[i].ref();
324 repoMap.set(repoRoots[i], repos[i]);
325 }
326
327 const values = [...repoMap.values()];
328 expect(values.length).toBe(repoNum);
329 for (let i = 0; i < repoNum; i++) {
330 expect(values[i].value.info.repoRoot).toBe(repoRoots[i]);
331 }
332
333 repoMap.forEach(repo => expect(repo.getNumberOfReferences()).toBe(1));
334 repoMap.forEach(repo => repo.dispose());
335 repoMap.forEach(repo => expect(repo.getNumberOfReferences()).toBe(0));
336 });
337
338 it('longest prefix match', async () => {
339 const createRefCountedRepo = async (ctx: RepositoryContext) => {
340 const repoInfo = await SimpleMockRepository.getRepoInfo(ctx);
341 const repo = new SimpleMockRepository(repoInfo as ValidatedRepoInfo, ctx);
342 return new RefCounted(repo);
343 };
344 const a = await createRefCountedRepo({...ctx, cwd: '/path/to/submoduleA/cwd'});
345 const b = await createRefCountedRepo({...ctx, cwd: '/path/to/submoduleB/cwd'});
346 const nested = await createRefCountedRepo({
347 ...ctx,
348 cwd: '/path/to/submoduleA/submoduleNested/cwd',
349 });
350
351 const repoMap = new RepoMap();
352 // A raw map would iterate in insertion order, so we
353 // call set in reverse order to test longest prefix match
354 repoMap.set('/path/to/submoduleA/submoduleNested', nested);
355 repoMap.set('/path/to/submoduleB', b);
356 repoMap.set('/path/to/submoduleA', a);
357
358 expect(repoMap.get('/path/to/submoduleA')).toBe(a);
359 expect(repoMap.get('/path/to/submoduleA/some/dir')).toBeUndefined();
360 expect(repoMap.getLongestPrefixMatch('/path/to/submoduleA/some/dir')).toBe(a);
361 expect(repoMap.getLongestPrefixMatch('/path/to/submoduleB/some/dir')).toBe(b);
362 expect(repoMap.getLongestPrefixMatch('/path/to/submoduleA/submoduleNested/dir')).toBe(nested);
363 });
364});
365