diff --git a/src/lib/context.ts b/src/lib/context.ts new file mode 100644 index 0000000..1a10acf --- /dev/null +++ b/src/lib/context.ts @@ -0,0 +1,165 @@ +/** + * Repository context resolution for Tangled CLI + * Automatically infers repository context from Git remotes + */ + +import { simpleGit } from 'simple-git'; +import { isTangledRemote, parseTangledRemote } from '../utils/git.js'; +import { promptForRemoteSelection, promptToSaveRemote } from '../utils/prompts.js'; +import { getConfiguredRemote, setLocalRemote } from './config.js'; + +export interface RepositoryContext { + owner: string; // Owner identifier - DID (e.g., "did:plc:...") or handle (e.g., "markbennett.ca") + ownerType: 'did' | 'handle'; // Type of owner identifier + name: string; // Repository name (e.g., "tangled-cli") + remoteName: string; // Git remote name (e.g., "origin") + remoteUrl: string; // Full remote URL + protocol: 'ssh' | 'https'; // Protocol used by remote +} + +/** + * Get all tangled.org remotes from the current Git repository + * + * @param cwd - Current working directory + * @returns Array of repository contexts + */ +export async function getTangledRemotes(cwd: string = process.cwd()): Promise { + try { + const git = simpleGit(cwd); + + // Check if in a Git repository + const isRepo = await git.checkIsRepo(); + if (!isRepo) { + return []; + } + + // Get all remotes with URLs + const remotes = await git.getRemotes(true); + + // Filter and parse tangled.org remotes + const tangledRemotes: RepositoryContext[] = []; + + for (const remote of remotes) { + if (!remote.refs.fetch || !isTangledRemote(remote.refs.fetch)) { + continue; + } + + const parsed = parseTangledRemote(remote.refs.fetch); + if (!parsed) { + console.warn(`Warning: Invalid tangled.org remote URL: ${remote.refs.fetch}`); + continue; + } + + tangledRemotes.push({ + owner: parsed.owner, + ownerType: parsed.ownerType, + name: parsed.name, + remoteName: remote.name, + remoteUrl: remote.refs.fetch, + protocol: parsed.protocol, + }); + } + + return tangledRemotes; + } catch (error) { + // Git errors - return empty array + return []; + } +} + +/** + * Prompt user to select a remote when multiple tangled remotes exist + * + * @param remotes - Array of repository contexts + * @returns Selected repository context + */ +export async function promptForRemote(remotes: RepositoryContext[]): Promise { + if (remotes.length === 0) { + throw new Error('No remotes available to select from'); + } + + if (remotes.length === 1) { + return remotes[0]; + } + + // Convert to format expected by prompt + const remoteChoices = remotes.map((r) => ({ + name: r.remoteName, + url: r.remoteUrl, + })); + + const selectedName = await promptForRemoteSelection(remoteChoices); + + const selected = remotes.find((r) => r.remoteName === selectedName); + if (!selected) { + throw new Error(`Selected remote "${selectedName}" not found`); + } + + return selected; +} + +/** + * Get repository context from the current working directory + * Looks for Git remotes pointing to tangled.org + * + * @param cwd - Current working directory (defaults to process.cwd()) + * @returns Repository context or null if not in a tangled repo + */ +export async function getCurrentRepoContext( + cwd: string = process.cwd() +): Promise { + // Get all tangled remotes + const remotes = await getTangledRemotes(cwd); + + // No tangled remotes found + if (remotes.length === 0) { + return null; + } + + // Single remote - use it + if (remotes.length === 1) { + return remotes[0]; + } + + // Multiple remotes - check config first + const configuredRemote = await getConfiguredRemote(cwd); + + if (configuredRemote) { + // Check if configured remote exists and is a tangled remote + const matchingRemote = remotes.find((r) => r.remoteName === configuredRemote); + + if (matchingRemote) { + return matchingRemote; + } + + // Configured remote doesn't exist or isn't a tangled remote + console.warn( + `Warning: Configured remote "${configuredRemote}" not found or is not a tangled.org remote. Continuing with heuristics.` + ); + } + + // Check for "origin" remote + const originRemote = remotes.find((r) => r.remoteName === 'origin'); + if (originRemote) { + return originRemote; + } + + // Prompt user to select + const selected = await promptForRemote(remotes); + + // Ask if user wants to save selection + const shouldSave = await promptToSaveRemote(); + if (shouldSave) { + try { + await setLocalRemote(selected.remoteName, cwd); + console.log(`✓ Saved remote "${selected.remoteName}" to local config\n`); + } catch (error) { + console.warn( + `Warning: Failed to save config: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + // Don't block command execution if config save fails + } + } + + return selected; +} diff --git a/tests/lib/context.test.ts b/tests/lib/context.test.ts new file mode 100644 index 0000000..6895a96 --- /dev/null +++ b/tests/lib/context.test.ts @@ -0,0 +1,428 @@ +import { 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 modules +vi.mock('simple-git'); +vi.mock('../../src/lib/config.js'); +vi.mock('../../src/utils/prompts.js'); + +// Import mocked modules +import { 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(); + }); + }); +});