From 398c2888989dfb7bc056c272275f238be31d08ad Mon Sep 17 00:00:00 2001 From: Mark Bennett Date: Mon, 09 Feb 2026 15:55:45 +0000 Subject: [PATCH] Implement repository context resolution from Git remotes Add context resolver to automatically detect tangled.org repositories: - getCurrentRepoContext(): Get repository context from current directory - getTangledRemotes(): Find all tangled.org remotes - promptForRemote(): Interactive selection for multiple remotes - Smart selection logic: prefer "origin", use config, or prompt - Offers to save selection to config for future use This enables commands to automatically determine which repository they're operating on without requiring explicit flags. Includes comprehensive test coverage (19 tests) for all resolution scenarios including config handling and user prompts. Co-Authored-By: Claude Sonnet 4.5 --- src/lib/context.ts | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/lib/context.test.ts | 428 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 file(s) changed, 593 insertion(s)(+), 0 deletion(s)(-) diff --git a/src/lib/context.ts b/src/lib/context.ts new file mode 100644 --- /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 --- /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(); + }); + }); +}); -- tangled.sh