Something went wrong. Try again.
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 fro
Something went wrong. Try again.
14 kB · 428 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429import { beforeEach, describe, expect, it, vi } from 'vitest';import type { RepositoryContext } from '../../src/lib/context.js';import { getCurrentRepoContext, getTangledRemotes, promptForRemote,} from '../../src/lib/context.js';
// Mock modulesvi.mock('simple-git');vi.mock('../../src/lib/config.js');vi.mock('../../src/utils/prompts.js');
// Import mocked modulesimport { simpleGit } from 'simple-git';import * as configModule from '../../src/lib/config.js';import * as promptsModule from '../../src/utils/prompts.js';
describe('Context Resolution', () => { beforeEach(() => { vi.clearAllMocks(); });
describe('getTangledRemotes', () => { it('should return empty array when not in a Git repository', async () => { const mockGit = { checkIsRepo: vi.fn().mockResolvedValue(false), }; vi.mocked(simpleGit).mockReturnValue(mockGit as never);
const remotes = await getTangledRemotes();
expect(remotes).toEqual([]); });
it('should return empty array when no tangled remotes exist', async () => { const mockGit = { checkIsRepo: vi.fn().mockResolvedValue(true), getRemotes: vi .fn() .mockResolvedValue([{ name: 'origin', refs: { fetch: 'git@github.com:user/repo.git' } }]), }; vi.mocked(simpleGit).mockReturnValue(mockGit as never);
const remotes = await getTangledRemotes();
expect(remotes).toEqual([]); });
it('should parse SSH tangled remote', async () => { const mockGit = { checkIsRepo: vi.fn().mockResolvedValue(true), getRemotes: vi.fn().mockResolvedValue([ { name: 'origin', refs: { fetch: 'git@tangled.org:did:plc:b2mcbcamkwyznc5fkplwlxbf/tangled-cli.git' }, }, ]), }; vi.mocked(simpleGit).mockReturnValue(mockGit as never);
const remotes = await getTangledRemotes();
expect(remotes).toEqual([ { owner: 'did:plc:b2mcbcamkwyznc5fkplwlxbf', ownerType: 'did', name: 'tangled-cli', remoteName: 'origin', remoteUrl: 'git@tangled.org:did:plc:b2mcbcamkwyznc5fkplwlxbf/tangled-cli.git', protocol: 'ssh', }, ]); });
it('should parse HTTPS tangled remote', async () => { const mockGit = { checkIsRepo: vi.fn().mockResolvedValue(true), getRemotes: vi.fn().mockResolvedValue([ { name: 'origin', refs: { fetch: 'https://tangled.org/markbennett.ca/tangled-cli' }, }, ]), }; vi.mocked(simpleGit).mockReturnValue(mockGit as never);
const remotes = await getTangledRemotes();
expect(remotes).toEqual([ { owner: 'markbennett.ca', ownerType: 'handle', name: 'tangled-cli', remoteName: 'origin', remoteUrl: 'https://tangled.org/markbennett.ca/tangled-cli', protocol: 'https', }, ]); });
it('should parse multiple tangled remotes', async () => { const mockGit = { checkIsRepo: vi.fn().mockResolvedValue(true), getRemotes: vi.fn().mockResolvedValue([ { name: 'origin', refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' }, }, { name: 'upstream', refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' }, }, ]), }; vi.mocked(simpleGit).mockReturnValue(mockGit as never);
const remotes = await getTangledRemotes();
expect(remotes).toHaveLength(2); expect(remotes[0].remoteName).toBe('origin'); expect(remotes[1].remoteName).toBe('upstream'); });
it('should skip invalid tangled remotes with warning', async () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const mockGit = { checkIsRepo: vi.fn().mockResolvedValue(true), getRemotes: vi.fn().mockResolvedValue([ { name: 'invalid', refs: { fetch: 'git@tangled.org:invalid' }, // Missing repo name }, { name: 'valid', refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' }, }, ]), }; vi.mocked(simpleGit).mockReturnValue(mockGit as never);
const remotes = await getTangledRemotes();
expect(remotes).toHaveLength(1); expect(remotes[0].remoteName).toBe('valid'); expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining('Invalid tangled.org remote URL') );
consoleWarnSpy.mockRestore(); });
it('should handle Git errors gracefully', async () => { const mockGit = { checkIsRepo: vi.fn().mockRejectedValue(new Error('Git error')), }; vi.mocked(simpleGit).mockReturnValue(mockGit as never);
const remotes = await getTangledRemotes();
expect(remotes).toEqual([]); }); });
describe('promptForRemote', () => { it('should return single remote without prompting', async () => { const remote: RepositoryContext = { owner: 'did:plc:abc123', ownerType: 'did', name: 'repo', remoteName: 'origin', remoteUrl: 'git@tangled.org:did:plc:abc123/repo.git', protocol: 'ssh', };
const result = await promptForRemote([remote]);
expect(result).toBe(remote); expect(promptsModule.promptForRemoteSelection).not.toHaveBeenCalled(); });
it('should prompt when multiple remotes available', async () => { const remotes: RepositoryContext[] = [ { owner: 'did:plc:abc123', ownerType: 'did', name: 'repo', remoteName: 'origin', remoteUrl: 'git@tangled.org:did:plc:abc123/repo.git', protocol: 'ssh', }, { owner: 'did:plc:xyz789', ownerType: 'did', name: 'repo', remoteName: 'upstream', remoteUrl: 'git@tangled.org:did:plc:xyz789/repo.git', protocol: 'ssh', }, ];
vi.mocked(promptsModule.promptForRemoteSelection).mockResolvedValue('upstream');
const result = await promptForRemote(remotes);
expect(result.remoteName).toBe('upstream'); expect(promptsModule.promptForRemoteSelection).toHaveBeenCalledWith([ { name: 'origin', url: 'git@tangled.org:did:plc:abc123/repo.git' }, { name: 'upstream', url: 'git@tangled.org:did:plc:xyz789/repo.git' }, ]); });
it('should throw error when no remotes provided', async () => { await expect(promptForRemote([])).rejects.toThrow('No remotes available to select from'); }); });
describe('getCurrentRepoContext', () => { it('should return null when not in a Git repository', async () => { const mockGit = { checkIsRepo: vi.fn().mockResolvedValue(false), }; vi.mocked(simpleGit).mockReturnValue(mockGit as never);
const context = await getCurrentRepoContext();
expect(context).toBeNull(); });
it('should return null when no tangled remotes exist', async () => { const mockGit = { checkIsRepo: vi.fn().mockResolvedValue(true), getRemotes: vi .fn() .mockResolvedValue([{ name: 'origin', refs: { fetch: 'git@github.com:user/repo.git' } }]), }; vi.mocked(simpleGit).mockReturnValue(mockGit as never);
const context = await getCurrentRepoContext();
expect(context).toBeNull(); });
it('should return single tangled remote', async () => { const mockGit = { checkIsRepo: vi.fn().mockResolvedValue(true), getRemotes: vi.fn().mockResolvedValue([ { name: 'origin', refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' }, }, ]), }; vi.mocked(simpleGit).mockReturnValue(mockGit as never);
const context = await getCurrentRepoContext();
expect(context).toEqual({ owner: 'did:plc:abc123', ownerType: 'did', name: 'repo', remoteName: 'origin', remoteUrl: 'git@tangled.org:did:plc:abc123/repo.git', protocol: 'ssh', }); });
it('should use configured remote when multiple remotes exist', async () => { const mockGit = { checkIsRepo: vi.fn().mockResolvedValue(true), getRemotes: vi.fn().mockResolvedValue([ { name: 'origin', refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' }, }, { name: 'upstream', refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' }, }, ]), }; vi.mocked(simpleGit).mockReturnValue(mockGit as never); vi.mocked(configModule.getConfiguredRemote).mockResolvedValue('upstream');
const context = await getCurrentRepoContext();
expect(context?.remoteName).toBe('upstream'); });
it('should fall back to origin when config points to non-existent remote', async () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const mockGit = { checkIsRepo: vi.fn().mockResolvedValue(true), getRemotes: vi.fn().mockResolvedValue([ { name: 'origin', refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' }, }, { name: 'upstream', refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' }, }, ]), }; vi.mocked(simpleGit).mockReturnValue(mockGit as never); vi.mocked(configModule.getConfiguredRemote).mockResolvedValue('nonexistent');
const context = await getCurrentRepoContext();
expect(context?.remoteName).toBe('origin'); expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining('Configured remote "nonexistent" not found') );
consoleWarnSpy.mockRestore(); });
it('should prefer origin remote when no config set', async () => { const mockGit = { checkIsRepo: vi.fn().mockResolvedValue(true), getRemotes: vi.fn().mockResolvedValue([ { name: 'upstream', refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' }, }, { name: 'origin', refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' }, }, ]), }; vi.mocked(simpleGit).mockReturnValue(mockGit as never); vi.mocked(configModule.getConfiguredRemote).mockResolvedValue(null);
const context = await getCurrentRepoContext();
expect(context?.remoteName).toBe('origin'); });
it('should prompt when no origin and no config', async () => { const mockGit = { checkIsRepo: vi.fn().mockResolvedValue(true), getRemotes: vi.fn().mockResolvedValue([ { name: 'upstream', refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' }, }, { name: 'fork', refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' }, }, ]), }; vi.mocked(simpleGit).mockReturnValue(mockGit as never); vi.mocked(configModule.getConfiguredRemote).mockResolvedValue(null); vi.mocked(promptsModule.promptForRemoteSelection).mockResolvedValue('fork'); vi.mocked(promptsModule.promptToSaveRemote).mockResolvedValue(false);
const context = await getCurrentRepoContext();
expect(context?.remoteName).toBe('fork'); expect(promptsModule.promptForRemoteSelection).toHaveBeenCalled(); });
it('should save config when user confirms', async () => { const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const mockGit = { checkIsRepo: vi.fn().mockResolvedValue(true), getRemotes: vi.fn().mockResolvedValue([ { name: 'upstream', refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' }, }, { name: 'fork', refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' }, }, ]), }; vi.mocked(simpleGit).mockReturnValue(mockGit as never); vi.mocked(configModule.getConfiguredRemote).mockResolvedValue(null); vi.mocked(promptsModule.promptForRemoteSelection).mockResolvedValue('fork'); vi.mocked(promptsModule.promptToSaveRemote).mockResolvedValue(true); vi.mocked(configModule.setLocalRemote).mockResolvedValue(undefined);
const context = await getCurrentRepoContext();
expect(context?.remoteName).toBe('fork'); expect(configModule.setLocalRemote).toHaveBeenCalledWith('fork', process.cwd()); expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Saved remote "fork"'));
consoleLogSpy.mockRestore(); });
it('should continue even if saving config fails', async () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const mockGit = { checkIsRepo: vi.fn().mockResolvedValue(true), getRemotes: vi.fn().mockResolvedValue([ { name: 'upstream', refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' }, }, { name: 'fork', refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' }, }, ]), }; vi.mocked(simpleGit).mockReturnValue(mockGit as never); vi.mocked(configModule.getConfiguredRemote).mockResolvedValue(null); vi.mocked(promptsModule.promptForRemoteSelection).mockResolvedValue('upstream'); vi.mocked(promptsModule.promptToSaveRemote).mockResolvedValue(true); vi.mocked(configModule.setLocalRemote).mockRejectedValue(new Error('Write failed'));
const context = await getCurrentRepoContext();
expect(context?.remoteName).toBe('upstream'); expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to save config'));
consoleWarnSpy.mockRestore(); }); });});