mirror your GitHub repos to tangled.org automatically synchub.to
mirror sync tangled github git
92

Configure Feed

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

fix: treat unusable tangled repo names as terminal

Daniel Roe (Jul 10, 2026, 1:20 PM +0200) a286f9ff 735feac2

+45 -3
+2 -2
server/utils/repo-health.ts
··· 61 61 ) { 62 62 return 'A repo with this name already exists on your tangled account. Rename or remove the existing one, then resync.' 63 63 } 64 - if (err.includes('invalid path sequence')) { 65 - return 'The repository name can\'t be used as a tangled repo name. This one can\'t be mirrored as-is.' 64 + if (err.includes('invalid path sequence') || err.includes('can\'t be used as a tangled repo name')) { 65 + return 'This repository name can\'t be used as a tangled repo name, so it can\'t be mirrored.' 66 66 } 67 67 if (err.includes('repository access blocked') || err.includes('repo no longer exists')) { 68 68 return 'The mirror on tangled is no longer reachable. Try a resync; if it persists, let us know.'
+22 -1
server/utils/tangled-repo.ts
··· 90 90 91 91 export interface EnrolResult { 92 92 status: 'enrolled' | 'already' | 'skipped' 93 - reason?: 'private' | 'fork' | 'no-identity' | 'name-conflict' 93 + reason?: 'private' | 'fork' | 'no-identity' | 'name-conflict' | 'invalid-name' 94 94 } 95 95 96 96 /** ··· 111 111 if (status !== 400) return false 112 112 const lc = body.toLowerCase() 113 113 return lc.includes('accesscontrol') || lc.includes('sufficient access permissions') 114 + } 115 + 116 + /** 117 + * Stored `repo_mapping.lastError` when the GitHub repo name can't be a tangled 118 + * repo name (e.g. `.github`, whose leading dot the knot rejects as an invalid 119 + * path sequence). Terminal: the name won't become valid on retry. 120 + */ 121 + export const INVALID_NAME_ERROR 122 + = 'this repository name can\'t be used as a tangled repo name, so it can\'t be mirrored' 123 + 124 + /** 125 + * True when the knot rejects `repo.create` because the name isn't a valid repo 126 + * path (surfaced as a `Generic` 400 mentioning an invalid path sequence). 127 + */ 128 + function isInvalidNameResponse(status: number, body: string): boolean { 129 + if (status !== 400) return false 130 + return body.toLowerCase().includes('invalid path sequence') 114 131 } 115 132 116 133 /** ··· 244 261 if (isNameConflictResponse(knotResponse.status, body)) { 245 262 await recordEnrolError(db, opts, repo.full_name, NAME_CONFLICT_ERROR) 246 263 return { status: 'skipped', reason: 'name-conflict' } 264 + } 265 + if (isInvalidNameResponse(knotResponse.status, body)) { 266 + await recordEnrolError(db, opts, repo.full_name, INVALID_NAME_ERROR) 267 + return { status: 'skipped', reason: 'invalid-name' } 247 268 } 248 269 throw new Error(`knot ${knot} returned ${knotResponse.status}: ${body}`) 249 270 }
+21
test/unit/tangled-repo.spec.ts
··· 7 7 import { 8 8 buildReadOnlyDescription, 9 9 enrollRepo, 10 + INVALID_NAME_ERROR, 10 11 mergeRepoRecord, 11 12 NAME_CONFLICT_ERROR, 12 13 stripReadOnlyMarker, ··· 233 234 expect(rows).toHaveLength(1) 234 235 expect(rows[0]!.status).toBe('error') 235 236 expect(rows[0]!.lastError).toBe(NAME_CONFLICT_ERROR) 237 + }) 238 + 239 + it('records a terminal error for a name the knot rejects as an invalid path', async () => { 240 + githubGet.mockResolvedValue({ data: ghRepo({ full_name: 'alice/.github' }) }) 241 + fakeFetch.mockResolvedValue(new Response( 242 + JSON.stringify({ error: 'Generic', message: 'Repository name contains invalid path sequence' }), 243 + { status: 400 }, 244 + )) 245 + 246 + const result = await enrollRepo({ 247 + oauthSession: fakeOauthSession('did:plc:abc'), 248 + installationId: 1, 249 + githubRepoId: 9001, 250 + }) 251 + expect(result).toEqual({ status: 'skipped', reason: 'invalid-name' }) 252 + 253 + const rows = await useDb().select().from(repoMapping).where(sql`${repoMapping.installationId} = 1`) 254 + expect(rows).toHaveLength(1) 255 + expect(rows[0]!.status).toBe('error') 256 + expect(rows[0]!.lastError).toBe(INVALID_NAME_ERROR) 236 257 }) 237 258 238 259 it('still throws on non-conflict knot errors so the queue retries', async () => {