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}`) }