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! :)
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

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 <noreply@anthropic.com>

+249
+101
src/utils/git.ts
··· 1 + /** 2 + * Git utilities for parsing and validating tangled.org remote URLs 3 + */ 4 + 5 + import { isValidHandle, isValidTangledDid } from './validation.js'; 6 + 7 + export interface ParsedTangledRemote { 8 + owner: string; 9 + ownerType: 'did' | 'handle'; 10 + name: string; 11 + protocol: 'ssh' | 'https'; 12 + } 13 + 14 + /** 15 + * Check if a Git remote URL is a tangled.org URL 16 + * @param url - Git remote URL 17 + * @returns true if URL points to tangled.org 18 + */ 19 + export function isTangledRemote(url: string): boolean { 20 + // Match tangled.org in SSH or HTTPS URLs 21 + return ( 22 + url.includes('tangled.org') && 23 + (url.startsWith('git@tangled.org:') || 24 + url.startsWith('ssh://git@tangled.org') || 25 + url.startsWith('https://tangled.org')) 26 + ); 27 + } 28 + 29 + /** 30 + * Parse a tangled.org Git remote URL to extract owner and repo name 31 + * @param url - Git remote URL 32 + * @returns Parsed remote info or null if not a valid tangled URL 33 + */ 34 + export function parseTangledRemote(url: string): ParsedTangledRemote | null { 35 + if (!isTangledRemote(url)) { 36 + return null; 37 + } 38 + 39 + let path: string; 40 + let protocol: 'ssh' | 'https'; 41 + 42 + // Parse based on protocol 43 + if (url.startsWith('https://tangled.org')) { 44 + // HTTPS: https://tangled.org/owner/repo 45 + protocol = 'https'; 46 + path = url.replace(/^https:\/\/tangled\.org\//, ''); 47 + } else if (url.startsWith('ssh://git@tangled.org')) { 48 + // SSH with ssh:// prefix: ssh://git@tangled.org/owner/repo.git 49 + protocol = 'ssh'; 50 + path = url.replace(/^ssh:\/\/git@tangled\.org\//, ''); 51 + } else if (url.startsWith('git@tangled.org:')) { 52 + // SSH shorthand: git@tangled.org:owner/repo.git 53 + protocol = 'ssh'; 54 + path = url.replace(/^git@tangled\.org:/, ''); 55 + } else { 56 + return null; 57 + } 58 + 59 + // Remove trailing slashes 60 + path = path.replace(/\/+$/, ''); 61 + 62 + // Remove .git extension if present 63 + path = path.replace(/\.git$/, ''); 64 + 65 + // Split path into owner and repo name 66 + const parts = path.split('/'); 67 + if (parts.length < 2) { 68 + return null; 69 + } 70 + 71 + const owner = parts[0]; 72 + const name = parts[1]; 73 + 74 + // Validate that we have both parts 75 + if (!owner || !name) { 76 + return null; 77 + } 78 + 79 + // Determine owner type based on format 80 + let ownerType: 'did' | 'handle'; 81 + if (owner.startsWith('did:plc:')) { 82 + ownerType = 'did'; 83 + // Validate DID format 84 + if (!isValidTangledDid(owner)) { 85 + return null; 86 + } 87 + } else { 88 + ownerType = 'handle'; 89 + // Validate handle format 90 + if (!isValidHandle(owner)) { 91 + return null; 92 + } 93 + } 94 + 95 + return { 96 + owner, 97 + ownerType, 98 + name, 99 + protocol, 100 + }; 101 + }
+148
tests/utils/git.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { isTangledRemote, parseTangledRemote } from '../../src/utils/git.js'; 3 + 4 + describe('Git Utilities', () => { 5 + describe('isTangledRemote', () => { 6 + it('should detect SSH tangled remotes', () => { 7 + expect(isTangledRemote('git@tangled.org:did:plc:abc123/repo.git')).toBe(true); 8 + expect(isTangledRemote('ssh://git@tangled.org/did:plc:abc123/repo.git')).toBe(true); 9 + }); 10 + 11 + it('should detect HTTPS tangled remotes', () => { 12 + expect(isTangledRemote('https://tangled.org/markbennett.ca/tangled-cli')).toBe(true); 13 + expect(isTangledRemote('https://tangled.org/user.bsky.social/repo')).toBe(true); 14 + }); 15 + 16 + it('should reject non-tangled remotes', () => { 17 + expect(isTangledRemote('git@github.com:user/repo.git')).toBe(false); 18 + expect(isTangledRemote('https://github.com/user/repo')).toBe(false); 19 + expect(isTangledRemote('https://gitlab.com/user/repo')).toBe(false); 20 + expect(isTangledRemote('')).toBe(false); 21 + }); 22 + }); 23 + 24 + describe('parseTangledRemote', () => { 25 + describe('SSH URLs with DIDs', () => { 26 + it('should parse git@tangled.org:did:plc:xxx/repo.git format', () => { 27 + const result = parseTangledRemote( 28 + 'git@tangled.org:did:plc:b2mcbcamkwyznc5fkplwlxbf/tangled-cli.git' 29 + ); 30 + expect(result).toEqual({ 31 + owner: 'did:plc:b2mcbcamkwyznc5fkplwlxbf', 32 + ownerType: 'did', 33 + name: 'tangled-cli', 34 + protocol: 'ssh', 35 + }); 36 + }); 37 + 38 + it('should parse ssh://git@tangled.org/did:plc:xxx/repo.git format', () => { 39 + const result = parseTangledRemote( 40 + 'ssh://git@tangled.org/did:plc:b2mcbcamkwyznc5fkplwlxbf/tangled-cli.git' 41 + ); 42 + expect(result).toEqual({ 43 + owner: 'did:plc:b2mcbcamkwyznc5fkplwlxbf', 44 + ownerType: 'did', 45 + name: 'tangled-cli', 46 + protocol: 'ssh', 47 + }); 48 + }); 49 + 50 + it('should handle SSH URLs without .git extension', () => { 51 + const result = parseTangledRemote('git@tangled.org:did:plc:abc123/repo'); 52 + expect(result).toEqual({ 53 + owner: 'did:plc:abc123', 54 + ownerType: 'did', 55 + name: 'repo', 56 + protocol: 'ssh', 57 + }); 58 + }); 59 + }); 60 + 61 + describe('SSH URLs with handles', () => { 62 + it('should parse SSH URL with handle instead of DID', () => { 63 + const result = parseTangledRemote('git@tangled.org:markbennett.ca/tangled-cli.git'); 64 + expect(result).toEqual({ 65 + owner: 'markbennett.ca', 66 + ownerType: 'handle', 67 + name: 'tangled-cli', 68 + protocol: 'ssh', 69 + }); 70 + }); 71 + }); 72 + 73 + describe('HTTPS URLs with handles', () => { 74 + it('should parse https://tangled.org/handle/repo format', () => { 75 + const result = parseTangledRemote('https://tangled.org/markbennett.ca/tangled-cli'); 76 + expect(result).toEqual({ 77 + owner: 'markbennett.ca', 78 + ownerType: 'handle', 79 + name: 'tangled-cli', 80 + protocol: 'https', 81 + }); 82 + }); 83 + 84 + it('should parse HTTPS with user.bsky.social handle', () => { 85 + const result = parseTangledRemote('https://tangled.org/user.bsky.social/repo'); 86 + expect(result).toEqual({ 87 + owner: 'user.bsky.social', 88 + ownerType: 'handle', 89 + name: 'repo', 90 + protocol: 'https', 91 + }); 92 + }); 93 + 94 + it('should handle HTTPS URLs without .git extension', () => { 95 + const result = parseTangledRemote('https://tangled.org/markbennett.ca/repo'); 96 + expect(result?.name).toBe('repo'); 97 + }); 98 + }); 99 + 100 + describe('HTTPS URLs with DIDs', () => { 101 + it('should parse HTTPS URL with DID instead of handle', () => { 102 + const result = parseTangledRemote( 103 + 'https://tangled.org/did:plc:b2mcbcamkwyznc5fkplwlxbf/repo' 104 + ); 105 + expect(result).toEqual({ 106 + owner: 'did:plc:b2mcbcamkwyznc5fkplwlxbf', 107 + ownerType: 'did', 108 + name: 'repo', 109 + protocol: 'https', 110 + }); 111 + }); 112 + }); 113 + 114 + describe('edge cases', () => { 115 + it('should handle trailing slashes', () => { 116 + const result = parseTangledRemote('https://tangled.org/markbennett.ca/repo/'); 117 + expect(result?.name).toBe('repo'); 118 + }); 119 + 120 + it('should handle .git extension in various positions', () => { 121 + const result1 = parseTangledRemote('git@tangled.org:did:plc:abc123/repo.git'); 122 + const result2 = parseTangledRemote('https://tangled.org/markbennett.ca/repo.git'); 123 + expect(result1?.name).toBe('repo'); 124 + expect(result2?.name).toBe('repo'); 125 + }); 126 + 127 + it('should return null for invalid DID format', () => { 128 + const result = parseTangledRemote('git@tangled.org:did:plc:INVALID/repo.git'); 129 + expect(result).toBeNull(); 130 + }); 131 + 132 + it('should return null for invalid handle format', () => { 133 + const result = parseTangledRemote('https://tangled.org/invalid/repo'); 134 + expect(result).toBeNull(); 135 + }); 136 + 137 + it('should return null for missing repo name', () => { 138 + const result = parseTangledRemote('git@tangled.org:did:plc:abc123'); 139 + expect(result).toBeNull(); 140 + }); 141 + 142 + it('should return null for non-tangled URLs', () => { 143 + expect(parseTangledRemote('git@github.com:user/repo.git')).toBeNull(); 144 + expect(parseTangledRemote('https://github.com/user/repo')).toBeNull(); 145 + }); 146 + }); 147 + }); 148 + });