WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)
1import { beforeEach, describe, expect, it, vi } from 'vitest';
2import type { RepositoryContext } from '../../src/lib/context.js';
3import {
4 getCurrentRepoContext,
5 getTangledRemotes,
6 promptForRemote,
7} from '../../src/lib/context.js';
8
9// Mock modules
10vi.mock('simple-git');
11vi.mock('../../src/lib/config.js');
12vi.mock('../../src/utils/prompts.js');
13
14// Import mocked modules
15import { simpleGit } from 'simple-git';
16import * as configModule from '../../src/lib/config.js';
17import * as promptsModule from '../../src/utils/prompts.js';
18
19describe('Context Resolution', () => {
20 beforeEach(() => {
21 vi.clearAllMocks();
22 });
23
24 describe('getTangledRemotes', () => {
25 it('should return empty array when not in a Git repository', async () => {
26 const mockGit = {
27 checkIsRepo: vi.fn().mockResolvedValue(false),
28 };
29 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
30
31 const remotes = await getTangledRemotes();
32
33 expect(remotes).toEqual([]);
34 });
35
36 it('should return empty array when no tangled remotes exist', async () => {
37 const mockGit = {
38 checkIsRepo: vi.fn().mockResolvedValue(true),
39 getRemotes: vi
40 .fn()
41 .mockResolvedValue([{ name: 'origin', refs: { fetch: 'git@github.com:user/repo.git' } }]),
42 };
43 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
44
45 const remotes = await getTangledRemotes();
46
47 expect(remotes).toEqual([]);
48 });
49
50 it('should parse SSH tangled remote', async () => {
51 const mockGit = {
52 checkIsRepo: vi.fn().mockResolvedValue(true),
53 getRemotes: vi.fn().mockResolvedValue([
54 {
55 name: 'origin',
56 refs: { fetch: 'git@tangled.org:did:plc:b2mcbcamkwyznc5fkplwlxbf/tangled-cli.git' },
57 },
58 ]),
59 };
60 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
61
62 const remotes = await getTangledRemotes();
63
64 expect(remotes).toEqual([
65 {
66 owner: 'did:plc:b2mcbcamkwyznc5fkplwlxbf',
67 ownerType: 'did',
68 name: 'tangled-cli',
69 remoteName: 'origin',
70 remoteUrl: 'git@tangled.org:did:plc:b2mcbcamkwyznc5fkplwlxbf/tangled-cli.git',
71 protocol: 'ssh',
72 },
73 ]);
74 });
75
76 it('should parse HTTPS tangled remote', async () => {
77 const mockGit = {
78 checkIsRepo: vi.fn().mockResolvedValue(true),
79 getRemotes: vi.fn().mockResolvedValue([
80 {
81 name: 'origin',
82 refs: { fetch: 'https://tangled.org/markbennett.ca/tangled-cli' },
83 },
84 ]),
85 };
86 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
87
88 const remotes = await getTangledRemotes();
89
90 expect(remotes).toEqual([
91 {
92 owner: 'markbennett.ca',
93 ownerType: 'handle',
94 name: 'tangled-cli',
95 remoteName: 'origin',
96 remoteUrl: 'https://tangled.org/markbennett.ca/tangled-cli',
97 protocol: 'https',
98 },
99 ]);
100 });
101
102 it('should parse multiple tangled remotes', async () => {
103 const mockGit = {
104 checkIsRepo: vi.fn().mockResolvedValue(true),
105 getRemotes: vi.fn().mockResolvedValue([
106 {
107 name: 'origin',
108 refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' },
109 },
110 {
111 name: 'upstream',
112 refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' },
113 },
114 ]),
115 };
116 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
117
118 const remotes = await getTangledRemotes();
119
120 expect(remotes).toHaveLength(2);
121 expect(remotes[0].remoteName).toBe('origin');
122 expect(remotes[1].remoteName).toBe('upstream');
123 });
124
125 it('should skip invalid tangled remotes with warning', async () => {
126 const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
127
128 const mockGit = {
129 checkIsRepo: vi.fn().mockResolvedValue(true),
130 getRemotes: vi.fn().mockResolvedValue([
131 {
132 name: 'invalid',
133 refs: { fetch: 'git@tangled.org:invalid' }, // Missing repo name
134 },
135 {
136 name: 'valid',
137 refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' },
138 },
139 ]),
140 };
141 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
142
143 const remotes = await getTangledRemotes();
144
145 expect(remotes).toHaveLength(1);
146 expect(remotes[0].remoteName).toBe('valid');
147 expect(consoleWarnSpy).toHaveBeenCalledWith(
148 expect.stringContaining('Invalid tangled.org remote URL')
149 );
150
151 consoleWarnSpy.mockRestore();
152 });
153
154 it('should handle Git errors gracefully', async () => {
155 const mockGit = {
156 checkIsRepo: vi.fn().mockRejectedValue(new Error('Git error')),
157 };
158 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
159
160 const remotes = await getTangledRemotes();
161
162 expect(remotes).toEqual([]);
163 });
164 });
165
166 describe('promptForRemote', () => {
167 it('should return single remote without prompting', async () => {
168 const remote: RepositoryContext = {
169 owner: 'did:plc:abc123',
170 ownerType: 'did',
171 name: 'repo',
172 remoteName: 'origin',
173 remoteUrl: 'git@tangled.org:did:plc:abc123/repo.git',
174 protocol: 'ssh',
175 };
176
177 const result = await promptForRemote([remote]);
178
179 expect(result).toBe(remote);
180 expect(promptsModule.promptForRemoteSelection).not.toHaveBeenCalled();
181 });
182
183 it('should prompt when multiple remotes available', async () => {
184 const remotes: RepositoryContext[] = [
185 {
186 owner: 'did:plc:abc123',
187 ownerType: 'did',
188 name: 'repo',
189 remoteName: 'origin',
190 remoteUrl: 'git@tangled.org:did:plc:abc123/repo.git',
191 protocol: 'ssh',
192 },
193 {
194 owner: 'did:plc:xyz789',
195 ownerType: 'did',
196 name: 'repo',
197 remoteName: 'upstream',
198 remoteUrl: 'git@tangled.org:did:plc:xyz789/repo.git',
199 protocol: 'ssh',
200 },
201 ];
202
203 vi.mocked(promptsModule.promptForRemoteSelection).mockResolvedValue('upstream');
204
205 const result = await promptForRemote(remotes);
206
207 expect(result.remoteName).toBe('upstream');
208 expect(promptsModule.promptForRemoteSelection).toHaveBeenCalledWith([
209 { name: 'origin', url: 'git@tangled.org:did:plc:abc123/repo.git' },
210 { name: 'upstream', url: 'git@tangled.org:did:plc:xyz789/repo.git' },
211 ]);
212 });
213
214 it('should throw error when no remotes provided', async () => {
215 await expect(promptForRemote([])).rejects.toThrow('No remotes available to select from');
216 });
217 });
218
219 describe('getCurrentRepoContext', () => {
220 it('should return null when not in a Git repository', async () => {
221 const mockGit = {
222 checkIsRepo: vi.fn().mockResolvedValue(false),
223 };
224 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
225
226 const context = await getCurrentRepoContext();
227
228 expect(context).toBeNull();
229 });
230
231 it('should return null when no tangled remotes exist', async () => {
232 const mockGit = {
233 checkIsRepo: vi.fn().mockResolvedValue(true),
234 getRemotes: vi
235 .fn()
236 .mockResolvedValue([{ name: 'origin', refs: { fetch: 'git@github.com:user/repo.git' } }]),
237 };
238 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
239
240 const context = await getCurrentRepoContext();
241
242 expect(context).toBeNull();
243 });
244
245 it('should return single tangled remote', async () => {
246 const mockGit = {
247 checkIsRepo: vi.fn().mockResolvedValue(true),
248 getRemotes: vi.fn().mockResolvedValue([
249 {
250 name: 'origin',
251 refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' },
252 },
253 ]),
254 };
255 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
256
257 const context = await getCurrentRepoContext();
258
259 expect(context).toEqual({
260 owner: 'did:plc:abc123',
261 ownerType: 'did',
262 name: 'repo',
263 remoteName: 'origin',
264 remoteUrl: 'git@tangled.org:did:plc:abc123/repo.git',
265 protocol: 'ssh',
266 });
267 });
268
269 it('should use configured remote when multiple remotes exist', async () => {
270 const mockGit = {
271 checkIsRepo: vi.fn().mockResolvedValue(true),
272 getRemotes: vi.fn().mockResolvedValue([
273 {
274 name: 'origin',
275 refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' },
276 },
277 {
278 name: 'upstream',
279 refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' },
280 },
281 ]),
282 };
283 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
284 vi.mocked(configModule.getConfiguredRemote).mockResolvedValue('upstream');
285
286 const context = await getCurrentRepoContext();
287
288 expect(context?.remoteName).toBe('upstream');
289 });
290
291 it('should fall back to origin when config points to non-existent remote', async () => {
292 const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
293
294 const mockGit = {
295 checkIsRepo: vi.fn().mockResolvedValue(true),
296 getRemotes: vi.fn().mockResolvedValue([
297 {
298 name: 'origin',
299 refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' },
300 },
301 {
302 name: 'upstream',
303 refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' },
304 },
305 ]),
306 };
307 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
308 vi.mocked(configModule.getConfiguredRemote).mockResolvedValue('nonexistent');
309
310 const context = await getCurrentRepoContext();
311
312 expect(context?.remoteName).toBe('origin');
313 expect(consoleWarnSpy).toHaveBeenCalledWith(
314 expect.stringContaining('Configured remote "nonexistent" not found')
315 );
316
317 consoleWarnSpy.mockRestore();
318 });
319
320 it('should prefer origin remote when no config set', async () => {
321 const mockGit = {
322 checkIsRepo: vi.fn().mockResolvedValue(true),
323 getRemotes: vi.fn().mockResolvedValue([
324 {
325 name: 'upstream',
326 refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' },
327 },
328 {
329 name: 'origin',
330 refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' },
331 },
332 ]),
333 };
334 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
335 vi.mocked(configModule.getConfiguredRemote).mockResolvedValue(null);
336
337 const context = await getCurrentRepoContext();
338
339 expect(context?.remoteName).toBe('origin');
340 });
341
342 it('should prompt when no origin and no config', async () => {
343 const mockGit = {
344 checkIsRepo: vi.fn().mockResolvedValue(true),
345 getRemotes: vi.fn().mockResolvedValue([
346 {
347 name: 'upstream',
348 refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' },
349 },
350 {
351 name: 'fork',
352 refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' },
353 },
354 ]),
355 };
356 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
357 vi.mocked(configModule.getConfiguredRemote).mockResolvedValue(null);
358 vi.mocked(promptsModule.promptForRemoteSelection).mockResolvedValue('fork');
359 vi.mocked(promptsModule.promptToSaveRemote).mockResolvedValue(false);
360
361 const context = await getCurrentRepoContext();
362
363 expect(context?.remoteName).toBe('fork');
364 expect(promptsModule.promptForRemoteSelection).toHaveBeenCalled();
365 });
366
367 it('should save config when user confirms', async () => {
368 const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
369
370 const mockGit = {
371 checkIsRepo: vi.fn().mockResolvedValue(true),
372 getRemotes: vi.fn().mockResolvedValue([
373 {
374 name: 'upstream',
375 refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' },
376 },
377 {
378 name: 'fork',
379 refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' },
380 },
381 ]),
382 };
383 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
384 vi.mocked(configModule.getConfiguredRemote).mockResolvedValue(null);
385 vi.mocked(promptsModule.promptForRemoteSelection).mockResolvedValue('fork');
386 vi.mocked(promptsModule.promptToSaveRemote).mockResolvedValue(true);
387 vi.mocked(configModule.setLocalRemote).mockResolvedValue(undefined);
388
389 const context = await getCurrentRepoContext();
390
391 expect(context?.remoteName).toBe('fork');
392 expect(configModule.setLocalRemote).toHaveBeenCalledWith('fork', process.cwd());
393 expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Saved remote "fork"'));
394
395 consoleLogSpy.mockRestore();
396 });
397
398 it('should continue even if saving config fails', async () => {
399 const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
400
401 const mockGit = {
402 checkIsRepo: vi.fn().mockResolvedValue(true),
403 getRemotes: vi.fn().mockResolvedValue([
404 {
405 name: 'upstream',
406 refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' },
407 },
408 {
409 name: 'fork',
410 refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' },
411 },
412 ]),
413 };
414 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
415 vi.mocked(configModule.getConfiguredRemote).mockResolvedValue(null);
416 vi.mocked(promptsModule.promptForRemoteSelection).mockResolvedValue('upstream');
417 vi.mocked(promptsModule.promptToSaveRemote).mockResolvedValue(true);
418 vi.mocked(configModule.setLocalRemote).mockRejectedValue(new Error('Write failed'));
419
420 const context = await getCurrentRepoContext();
421
422 expect(context?.remoteName).toBe('upstream');
423 expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to save config'));
424
425 consoleWarnSpy.mockRestore();
426 });
427 });
428});