···11+/**
22+ * Map a knot hostname (as stored on `sh.tangled.repo`) to the SSH host we
33+ * actually push to. For the appview-hosted knot, the HTTPS XRPC endpoint is
44+ * `knot1.tangled.sh` (Cloudflare-fronted) but SSH lives on `tangled.org`.
55+ * Self-hosted knots serve both on the same host (their `knot` value may
66+ * include a `:port` suffix for non-default SSH; git URL parsing handles it).
77+ *
88+ * The official UI does this same mapping in
99+ * `appview/pages/templates/repo/empty.html`. If tangled adds more
1010+ * appview-hosted knots in future this'll need updating \u2014 see PLAN.md
1111+ * "Deferred / follow-ups".
1212+ */
1313+export function sshHostForKnot(knot: string): string {
1414+ if (knot === 'knot1.tangled.sh') return 'tangled.org'
1515+ return knot
1616+}
+1-16
server/utils/sync-push.ts
···77import { classifyPushFailure, git, RemoteRejectedPushError } from './git'
88import { installationOctokit } from './github-app'
99import { loadSshCommandForInstall } from './ssh-cmd'
1010+import { sshHostForKnot } from './sync-push-host'
10111112const ZERO_SHA = '0000000000000000000000000000000000000000'
1213···143144 // Escape any double-quotes inside the ref. We only support standard git ref
144145 // names which never contain quotes, but be defensive.
145146 return `"${ref.replaceAll('"', '\\"')}"`
146146-}
147147-148148-/**
149149- * Map a knot hostname (as stored on `sh.tangled.repo`) to the SSH host we
150150- * actually push to. For the appview-hosted knot, the HTTPS XRPC endpoint is
151151- * `knot1.tangled.sh` (Cloudflare-fronted) but SSH lives on `tangled.org`.
152152- * Self-hosted knots serve both on the same host (their `knot` value may
153153- * include a `:port` suffix for non-default SSH; git URL parsing handles it).
154154- *
155155- * The official UI does this same mapping in
156156- * `appview/pages/templates/repo/empty.html`. If tangled adds more
157157- * appview-hosted knots in future this'll need updating.
158158- */
159159-function sshHostForKnot(knot: string): string {
160160- if (knot === 'knot1.tangled.sh') return 'tangled.org'
161161- return knot
162147}
163148164149async function markMappingError(mappingId: number, message: string): Promise<void> {
+238
server/utils/sync-ref.ts
···11+import { mkdtempSync, rmSync } from 'node:fs'
22+import os from 'node:os'
33+import path from 'node:path'
44+import { and, eq, sql } from 'drizzle-orm'
55+import { repoMapping } from '../db/schema'
66+import { useDb } from './db'
77+import { classifyPushFailure, git } from './git'
88+import { installationOctokit } from './github-app'
99+import { loadSshCommandForInstall } from './ssh-cmd'
1010+import { sshHostForKnot } from './sync-push-host'
1111+1212+export type RefType = 'branch' | 'tag'
1313+1414+export interface CreateRefPayload {
1515+ installationId: number
1616+ githubRepoId: number
1717+ refType: RefType
1818+ /** Short ref name as GitHub delivers it (e.g. `v1.0`, `feature-x`) \u2014 NOT
1919+ * the `refs/...` qualified form. */
2020+ ref: string
2121+}
2222+2323+export interface DeleteRefPayload extends CreateRefPayload {}
2424+2525+export interface RefResult {
2626+ status: 'synced' | 'skipped'
2727+ reason?: 'no-mapping' | 'disabled' | 'not-branch-or-tag' | 'repo-gone'
2828+}
2929+3030+/**
3131+ * Mirror a branch or tag creation from GitHub to the configured knot.
3232+ *
3333+ * Triggered by GitHub's `create` webhook event. For branches, GitHub also
3434+ * sends a parallel `push` event (with `before = 0000…`), so the branch will
3535+ * usually have been created already by the time this fires \u2014 the push to
3636+ * knot is then a no-op via ref-tip dedupe. For lightweight and annotated
3737+ * tags, no `push` event is sent, so this is the only path that creates them
3838+ * on the knot.
3939+ */
4040+export async function syncCreateRef(payload: CreateRefPayload): Promise<RefResult> {
4141+ if (payload.refType !== 'branch' && payload.refType !== 'tag') {
4242+ return { status: 'skipped', reason: 'not-branch-or-tag' }
4343+ }
4444+4545+ const mapping = await loadActiveMapping(payload.installationId, payload.githubRepoId)
4646+ if ('skip' in mapping) return mapping.skip
4747+4848+ const fullRef = qualifyRef(payload.refType, payload.ref)
4949+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'synchub-create-'))
5050+ let sshCleanup: (() => void) | undefined
5151+5252+ try {
5353+ await git(['init', '--bare', '-q'], { cwd: tmpDir })
5454+5555+ const octokit = await installationOctokit(payload.installationId)
5656+ const { token } = (await octokit.auth({ type: 'installation' })) as { token: string }
5757+ const githubUrl = `https://x-access-token:${token}@github.com/${mapping.githubFullName}.git`
5858+5959+ // Fetch the ref by name. Tags carry whatever object git stores at the
6060+ // ref (commit for lightweight; tag object for annotated); fetch gives
6161+ // us all the reachable objects either way.
6262+ await git(
6363+ ['fetch', '--no-tags', '-q', githubUrl, `+${fullRef}:${fullRef}`],
6464+ { cwd: tmpDir, timeout: 120_000 },
6565+ )
6666+6767+ const { gitSshCommand, cleanup } = await loadSshCommandForInstall(payload.installationId)
6868+ sshCleanup = cleanup
6969+ const knotUrl = `ssh://git@${sshHostForKnot(mapping.knot)}/${mapping.tangledRepoDid}`
7070+7171+ try {
7272+ await git(
7373+ ['push', '-q', knotUrl, `+${fullRef}:${fullRef}`],
7474+ { cwd: tmpDir, env: { GIT_SSH_COMMAND: gitSshCommand }, timeout: 120_000 },
7575+ )
7676+ }
7777+ catch (err) {
7878+ const stderr = err instanceof Error && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : ''
7979+ const classified = classifyPushFailure(stderr)
8080+ if (classified?.reason === 'repo-gone') {
8181+ await markMappingError(mapping.id, 'knot reports repo no longer exists; stopping sync')
8282+ return { status: 'skipped', reason: 'repo-gone' }
8383+ }
8484+ throw classified ?? err
8585+ }
8686+8787+ // For branches we get the SHA from the local ref after fetch; for tags
8888+ // we still update lastSyncedRefs so a subsequent push event with the
8989+ // same SHA short-circuits via ref-tip dedupe.
9090+ const { stdout: sha } = await git(['rev-parse', fullRef], { cwd: tmpDir })
9191+ await updateLastSyncedRef(mapping.id, fullRef, sha.trim())
9292+9393+ return { status: 'synced' }
9494+ }
9595+ finally {
9696+ sshCleanup?.()
9797+ try {
9898+ rmSync(tmpDir, { recursive: true, force: true })
9999+ }
100100+ catch {
101101+ // best-effort
102102+ }
103103+ }
104104+}
105105+106106+/**
107107+ * Mirror a branch or tag deletion from GitHub to the configured knot.
108108+ *
109109+ * Triggered by GitHub's `delete` webhook event. For branches, GitHub also
110110+ * sends a parallel `push` event with `after = 0000\u2026`, which `syncPush`
111111+ * currently skips (`reason: 'deletion'`) \u2014 this is the path that actually
112112+ * removes the ref on the knot. Tag deletion arrives only via this event.
113113+ *
114114+ * Deletion is idempotent: if the ref doesn't exist on the knot we treat it
115115+ * as success. We wanted it gone, it's gone.
116116+ */
117117+export async function syncDeleteRef(payload: DeleteRefPayload): Promise<RefResult> {
118118+ if (payload.refType !== 'branch' && payload.refType !== 'tag') {
119119+ return { status: 'skipped', reason: 'not-branch-or-tag' }
120120+ }
121121+122122+ const mapping = await loadActiveMapping(payload.installationId, payload.githubRepoId)
123123+ if ('skip' in mapping) return mapping.skip
124124+125125+ const fullRef = qualifyRef(payload.refType, payload.ref)
126126+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'synchub-delete-'))
127127+ let sshCleanup: (() => void) | undefined
128128+129129+ try {
130130+ // No fetch needed; we're only telling the remote to drop a ref.
131131+ await git(['init', '--bare', '-q'], { cwd: tmpDir })
132132+133133+ const { gitSshCommand, cleanup } = await loadSshCommandForInstall(payload.installationId)
134134+ sshCleanup = cleanup
135135+ const knotUrl = `ssh://git@${sshHostForKnot(mapping.knot)}/${mapping.tangledRepoDid}`
136136+137137+ // The `:<ref>` (empty source) refspec means "delete <ref> on the remote".
138138+ try {
139139+ await git(
140140+ ['push', '-q', knotUrl, `:${fullRef}`],
141141+ { cwd: tmpDir, env: { GIT_SSH_COMMAND: gitSshCommand }, timeout: 60_000 },
142142+ )
143143+ }
144144+ catch (err) {
145145+ const stderr = err instanceof Error && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : ''
146146+ // "remote ref does not exist" is success for our purposes \u2014 the ref is
147147+ // gone, which is what we wanted.
148148+ if (/remote ref does not exist|unable to delete.*does not exist/i.test(stderr)) {
149149+ await clearLastSyncedRef(mapping.id, fullRef)
150150+ return { status: 'synced' }
151151+ }
152152+ const classified = classifyPushFailure(stderr)
153153+ if (classified?.reason === 'repo-gone') {
154154+ await markMappingError(mapping.id, 'knot reports repo no longer exists; stopping sync')
155155+ return { status: 'skipped', reason: 'repo-gone' }
156156+ }
157157+ throw classified ?? err
158158+ }
159159+160160+ await clearLastSyncedRef(mapping.id, fullRef)
161161+ return { status: 'synced' }
162162+ }
163163+ finally {
164164+ sshCleanup?.()
165165+ try {
166166+ rmSync(tmpDir, { recursive: true, force: true })
167167+ }
168168+ catch {
169169+ // best-effort
170170+ }
171171+ }
172172+}
173173+174174+function qualifyRef(refType: RefType, ref: string): string {
175175+ return refType === 'tag' ? `refs/tags/${ref}` : `refs/heads/${ref}`
176176+}
177177+178178+type ActiveMapping = {
179179+ id: number
180180+ githubFullName: string
181181+ tangledRepoDid: string
182182+ knot: string
183183+} | { skip: RefResult }
184184+185185+async function loadActiveMapping(installationId: number, githubRepoId: number): Promise<ActiveMapping> {
186186+ const db = useDb()
187187+ const rows = await db.select().from(repoMapping).where(
188188+ and(
189189+ eq(repoMapping.installationId, installationId),
190190+ eq(repoMapping.githubRepoId, githubRepoId),
191191+ ),
192192+ ).limit(1)
193193+194194+ if (rows.length === 0) return { skip: { status: 'skipped', reason: 'no-mapping' } }
195195+ const row = rows[0]!
196196+197197+ if (row.disabledAt) return { skip: { status: 'skipped', reason: 'disabled' } }
198198+ if (!row.tangledRepoDid || !row.knot) return { skip: { status: 'skipped', reason: 'no-mapping' } }
199199+200200+ return {
201201+ id: row.id,
202202+ githubFullName: row.githubFullName,
203203+ tangledRepoDid: row.tangledRepoDid,
204204+ knot: row.knot,
205205+ }
206206+}
207207+208208+async function updateLastSyncedRef(mappingId: number, fullRef: string, sha: string): Promise<void> {
209209+ const db = useDb()
210210+ await db.update(repoMapping)
211211+ .set({
212212+ lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPath(fullRef)}}`}::text[], ${`"${sha}"`}::jsonb, true)`,
213213+ updatedAt: new Date(),
214214+ })
215215+ .where(eq(repoMapping.id, mappingId))
216216+}
217217+218218+async function clearLastSyncedRef(mappingId: number, fullRef: string): Promise<void> {
219219+ const db = useDb()
220220+ // jsonb minus text removes a top-level key. Safe no-op if absent.
221221+ await db.update(repoMapping)
222222+ .set({
223223+ lastSyncedRefs: sql`${repoMapping.lastSyncedRefs} - ${fullRef}`,
224224+ updatedAt: new Date(),
225225+ })
226226+ .where(eq(repoMapping.id, mappingId))
227227+}
228228+229229+async function markMappingError(mappingId: number, message: string): Promise<void> {
230230+ const db = useDb()
231231+ await db.update(repoMapping)
232232+ .set({ status: 'error', lastError: message, updatedAt: new Date() })
233233+ .where(eq(repoMapping.id, mappingId))
234234+}
235235+236236+function jsonbPath(ref: string): string {
237237+ return `"${ref.replaceAll('"', '\\"')}"`
238238+}