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! :)
at main 428 lines 14 kB view raw
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});