From 1a583c1c4da66e7aaccc3c9080d6dd2822b642de Mon Sep 17 00:00:00 2001 From: Mark Bennett Date: Mon, 9 Feb 2026 08:41:36 -0700 Subject: [PATCH] Implement Git utilities for parsing tangled.org remote URLs Add utilities to parse and validate tangled.org Git remote URLs: - isTangledRemote(): Check if URL points to tangled.org - parseTangledRemote(): Extract owner, repo name, protocol from URL - Support both SSH (git@tangled.org:owner/repo.git) and HTTPS (https://tangled.org/owner/repo) formats - Parse owner as DID (did:plc:...) or AT Protocol handle - Extract repository name and protocol information Uses validation helpers from validation.ts for DID and handle checking. Includes comprehensive test coverage (17 tests) for all URL formats and edge cases. Co-Authored-By: Claude Sonnet 4.5 --- src/utils/git.ts | 101 +++++++++++++++++++++++++++ tests/utils/git.test.ts | 148 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 src/utils/git.ts create mode 100644 tests/utils/git.test.ts diff --git a/src/utils/git.ts b/src/utils/git.ts new file mode 100644 index 0000000..ea52578 --- /dev/null +++ b/src/utils/git.ts @@ -0,0 +1,101 @@ +/** + * Git utilities for parsing and validating tangled.org remote URLs + */ + +import { isValidHandle, isValidTangledDid } from './validation.js'; + +export interface ParsedTangledRemote { + owner: string; + ownerType: 'did' | 'handle'; + name: string; + protocol: 'ssh' | 'https'; +} + +/** + * Check if a Git remote URL is a tangled.org URL + * @param url - Git remote URL + * @returns true if URL points to tangled.org + */ +export function isTangledRemote(url: string): boolean { + // Match tangled.org in SSH or HTTPS URLs + return ( + url.includes('tangled.org') && + (url.startsWith('git@tangled.org:') || + url.startsWith('ssh://git@tangled.org') || + url.startsWith('https://tangled.org')) + ); +} + +/** + * Parse a tangled.org Git remote URL to extract owner and repo name + * @param url - Git remote URL + * @returns Parsed remote info or null if not a valid tangled URL + */ +export function parseTangledRemote(url: string): ParsedTangledRemote | null { + if (!isTangledRemote(url)) { + return null; + } + + let path: string; + let protocol: 'ssh' | 'https'; + + // Parse based on protocol + if (url.startsWith('https://tangled.org')) { + // HTTPS: https://tangled.org/owner/repo + protocol = 'https'; + path = url.replace(/^https:\/\/tangled\.org\//, ''); + } else if (url.startsWith('ssh://git@tangled.org')) { + // SSH with ssh:// prefix: ssh://git@tangled.org/owner/repo.git + protocol = 'ssh'; + path = url.replace(/^ssh:\/\/git@tangled\.org\//, ''); + } else if (url.startsWith('git@tangled.org:')) { + // SSH shorthand: git@tangled.org:owner/repo.git + protocol = 'ssh'; + path = url.replace(/^git@tangled\.org:/, ''); + } else { + return null; + } + + // Remove trailing slashes + path = path.replace(/\/+$/, ''); + + // Remove .git extension if present + path = path.replace(/\.git$/, ''); + + // Split path into owner and repo name + const parts = path.split('/'); + if (parts.length < 2) { + return null; + } + + const owner = parts[0]; + const name = parts[1]; + + // Validate that we have both parts + if (!owner || !name) { + return null; + } + + // Determine owner type based on format + let ownerType: 'did' | 'handle'; + if (owner.startsWith('did:plc:')) { + ownerType = 'did'; + // Validate DID format + if (!isValidTangledDid(owner)) { + return null; + } + } else { + ownerType = 'handle'; + // Validate handle format + if (!isValidHandle(owner)) { + return null; + } + } + + return { + owner, + ownerType, + name, + protocol, + }; +} diff --git a/tests/utils/git.test.ts b/tests/utils/git.test.ts new file mode 100644 index 0000000..c0cc531 --- /dev/null +++ b/tests/utils/git.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import { isTangledRemote, parseTangledRemote } from '../../src/utils/git.js'; + +describe('Git Utilities', () => { + describe('isTangledRemote', () => { + it('should detect SSH tangled remotes', () => { + expect(isTangledRemote('git@tangled.org:did:plc:abc123/repo.git')).toBe(true); + expect(isTangledRemote('ssh://git@tangled.org/did:plc:abc123/repo.git')).toBe(true); + }); + + it('should detect HTTPS tangled remotes', () => { + expect(isTangledRemote('https://tangled.org/markbennett.ca/tangled-cli')).toBe(true); + expect(isTangledRemote('https://tangled.org/user.bsky.social/repo')).toBe(true); + }); + + it('should reject non-tangled remotes', () => { + expect(isTangledRemote('git@github.com:user/repo.git')).toBe(false); + expect(isTangledRemote('https://github.com/user/repo')).toBe(false); + expect(isTangledRemote('https://gitlab.com/user/repo')).toBe(false); + expect(isTangledRemote('')).toBe(false); + }); + }); + + describe('parseTangledRemote', () => { + describe('SSH URLs with DIDs', () => { + it('should parse git@tangled.org:did:plc:xxx/repo.git format', () => { + const result = parseTangledRemote( + 'git@tangled.org:did:plc:b2mcbcamkwyznc5fkplwlxbf/tangled-cli.git' + ); + expect(result).toEqual({ + owner: 'did:plc:b2mcbcamkwyznc5fkplwlxbf', + ownerType: 'did', + name: 'tangled-cli', + protocol: 'ssh', + }); + }); + + it('should parse ssh://git@tangled.org/did:plc:xxx/repo.git format', () => { + const result = parseTangledRemote( + 'ssh://git@tangled.org/did:plc:b2mcbcamkwyznc5fkplwlxbf/tangled-cli.git' + ); + expect(result).toEqual({ + owner: 'did:plc:b2mcbcamkwyznc5fkplwlxbf', + ownerType: 'did', + name: 'tangled-cli', + protocol: 'ssh', + }); + }); + + it('should handle SSH URLs without .git extension', () => { + const result = parseTangledRemote('git@tangled.org:did:plc:abc123/repo'); + expect(result).toEqual({ + owner: 'did:plc:abc123', + ownerType: 'did', + name: 'repo', + protocol: 'ssh', + }); + }); + }); + + describe('SSH URLs with handles', () => { + it('should parse SSH URL with handle instead of DID', () => { + const result = parseTangledRemote('git@tangled.org:markbennett.ca/tangled-cli.git'); + expect(result).toEqual({ + owner: 'markbennett.ca', + ownerType: 'handle', + name: 'tangled-cli', + protocol: 'ssh', + }); + }); + }); + + describe('HTTPS URLs with handles', () => { + it('should parse https://tangled.org/handle/repo format', () => { + const result = parseTangledRemote('https://tangled.org/markbennett.ca/tangled-cli'); + expect(result).toEqual({ + owner: 'markbennett.ca', + ownerType: 'handle', + name: 'tangled-cli', + protocol: 'https', + }); + }); + + it('should parse HTTPS with user.bsky.social handle', () => { + const result = parseTangledRemote('https://tangled.org/user.bsky.social/repo'); + expect(result).toEqual({ + owner: 'user.bsky.social', + ownerType: 'handle', + name: 'repo', + protocol: 'https', + }); + }); + + it('should handle HTTPS URLs without .git extension', () => { + const result = parseTangledRemote('https://tangled.org/markbennett.ca/repo'); + expect(result?.name).toBe('repo'); + }); + }); + + describe('HTTPS URLs with DIDs', () => { + it('should parse HTTPS URL with DID instead of handle', () => { + const result = parseTangledRemote( + 'https://tangled.org/did:plc:b2mcbcamkwyznc5fkplwlxbf/repo' + ); + expect(result).toEqual({ + owner: 'did:plc:b2mcbcamkwyznc5fkplwlxbf', + ownerType: 'did', + name: 'repo', + protocol: 'https', + }); + }); + }); + + describe('edge cases', () => { + it('should handle trailing slashes', () => { + const result = parseTangledRemote('https://tangled.org/markbennett.ca/repo/'); + expect(result?.name).toBe('repo'); + }); + + it('should handle .git extension in various positions', () => { + const result1 = parseTangledRemote('git@tangled.org:did:plc:abc123/repo.git'); + const result2 = parseTangledRemote('https://tangled.org/markbennett.ca/repo.git'); + expect(result1?.name).toBe('repo'); + expect(result2?.name).toBe('repo'); + }); + + it('should return null for invalid DID format', () => { + const result = parseTangledRemote('git@tangled.org:did:plc:INVALID/repo.git'); + expect(result).toBeNull(); + }); + + it('should return null for invalid handle format', () => { + const result = parseTangledRemote('https://tangled.org/invalid/repo'); + expect(result).toBeNull(); + }); + + it('should return null for missing repo name', () => { + const result = parseTangledRemote('git@tangled.org:did:plc:abc123'); + expect(result).toBeNull(); + }); + + it('should return null for non-tangled URLs', () => { + expect(parseTangledRemote('git@github.com:user/repo.git')).toBeNull(); + expect(parseTangledRemote('https://github.com/user/repo')).toBeNull(); + }); + }); + }); +}); -- 2.51.2