From fb8efd2766e80a261d91935d0e790064e2db3de1 Mon Sep 17 00:00:00 2001 From: dietrich ayala Date: Fri, 31 Jul 2026 10:47:35 +0200 Subject: [PATCH] Match Tangled repos by rkey when the record has no name sh.tangled.repo records are not reliably named: of 136 on one account, 7 have a null name and several names are duplicated. Matching on name alone reported those as missing. matchRepo() now falls back to the rkey when no named record matches, and reports the fallback so a guessed match stays visible in the output. A plural match on either key resolves to 'ambiguous' rather than picking one, because the rkey is sometimes a TID and sometimes a name that differs from its GitHub counterpart. --- CLAUDE.md | 5 +++++ src/migrate.mjs | 55 +++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f3e1cb0..1ce9e0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,11 @@ Re-verify before relying on them; all of these are moving targets. - `repoDid` is minted by the knot and stored on the `sh.tangled.repo` record in the owner's PDS. It is not the account DID and cannot be derived from the repo name. Older records lack it entirely. +- `sh.tangled.repo` records are not reliably named. Of 136 on `burrito.space`, 7 have + a null `name` and several names are duplicated. `migrate.mjs` `matchRepo()` falls + back to the rkey and refuses to resolve a plural match, because the rkey is + sometimes a TID and sometimes a name that differs from the GitHub one — this repo + is `entangled` on Tangled and `entangle` on disk. - `TANGLED_SITES_DOMAIN` defaults to `tngl.io`. `tngl.sh` is the PDS user domain — two different `tngl.*` hosts, easy to conflate. Accounts on Tangled's PDS get a handle-bound `.tngl.sh` sites domain that cannot be released. diff --git a/src/migrate.mjs b/src/migrate.mjs index 2317819..e0b4dcf 100644 --- a/src/migrate.mjs +++ b/src/migrate.mjs @@ -6,6 +6,47 @@ import * as t from './tangled.mjs' +/** + * Find the Tangled repo corresponding to a GitHub repo name. + * + * `name` on the sh.tangled.repo record is the right key, but records written by + * some clients leave it null, so the rkey is the only handle they have. The rkey + * is not a reliable stand-in — it is sometimes a TID, and when it is a name it + * can differ from the GitHub one — so an rkey hit is reported as a guess and a + * plural match is never resolved silently. + */ +export function matchRepo(repos, name) { + const want = name.toLowerCase() + + const named = repos.filter((r) => r.name?.toLowerCase() === want) + if (named.length === 1) return { target: named[0] } + if (named.length > 1) { + return { + status: 'ambiguous', + detail: `${named.length} Tangled repos are named ${name} (rkeys ${named.map((r) => r.rkey).join(', ')})`, + } + } + + const keyed = repos.filter((r) => !r.name && r.rkey.toLowerCase() === want) + if (keyed.length === 1) { + return { + target: keyed[0], + detail: `matched on rkey; the record has no name`, + } + } + if (keyed.length > 1) { + return { status: 'ambiguous', detail: `${keyed.length} unnamed records share the rkey ${name}` } + } + + const unnamed = repos.filter((r) => !r.name).length + return { + status: 'missing', + detail: unnamed + ? `no repo of that name on Tangled; ${unnamed} records have no name and could not be matched` + : 'no repo of that name on Tangled yet', + } +} + export async function migrate(plan, { handle, password, apply = false, force = false } = {}) { if (!(await t.xrpcAvailable())) throw new Error(t.XRPC_UNAVAILABLE) @@ -24,16 +65,17 @@ export async function migrate(plan, { handle, password, apply = false, force = f } const tangledRepos = await t.listRepos(session) - const byName = new Map(tangledRepos.map((r) => [r.name?.toLowerCase(), r])) const results = [] for (const entry of plan.entries) { - const target = byName.get(entry.name.toLowerCase()) + const { target, status, detail } = matchRepo(tangledRepos, entry.name) if (!target) { - results.push({ ...entry, status: 'missing', detail: 'no repo of that name on Tangled yet' }) + results.push({ ...entry, status, detail }) continue } + // Carried onto every downstream result so a guessed match stays visible. + const matchNote = detail ? { matchedBy: detail } : {} if (!target.repoDid) { results.push({ ...entry, @@ -54,7 +96,7 @@ export async function migrate(plan, { handle, password, apply = false, force = f } if (!apply) { - results.push({ ...entry, status: 'would-deploy', repoDid: target.repoDid }) + results.push({ ...entry, ...matchNote, status: 'would-deploy', repoDid: target.repoDid }) continue } @@ -65,7 +107,7 @@ export async function migrate(plan, { handle, password, apply = false, force = f dir: entry.dir, isIndex: entry.isIndex, }) - results.push({ ...entry, status: 'deployed', repoDid: target.repoDid }) + results.push({ ...entry, ...matchNote, status: 'deployed', repoDid: target.repoDid }) } catch (e) { results.push({ ...entry, status: 'failed', detail: String(e.message || e) }) } @@ -84,7 +126,8 @@ export function migrateSummary(r) { ] for (const x of r.results) { if (x.status === 'deployed' || x.status === 'would-deploy') { - L.push(` ${x.status.padEnd(13)} ${x.name} → ${x.targetUrl} (${x.branch} @ ${x.dir})`) + const note = x.matchedBy ? ` [${x.matchedBy}]` : '' + L.push(` ${x.status.padEnd(13)} ${x.name} → ${x.targetUrl} (${x.branch} @ ${x.dir})${note}`) } else { L.push(` ${x.status.padEnd(13)} ${x.name} — ${x.detail}`) } -- 2.51.2