#!/usr/bin/env node /** * git remote helper entry point. Git invokes this as * `git-remote-atproto ` for atproto:// remotes and * speaks the helper protocol over stdin/stdout. * * Invoked by hand, it also manages OAuth sessions: * * git-remote-atproto login [--service ] [--scope ] * git-remote-atproto logout * git-remote-atproto pr create [--remote ] [--title ] [--body <body>] [--draft] * git-remote-atproto pr update [--remote <name>] [--title <title>] [--body <body>] [--draft] * git-remote-atproto pr merge [--remote <name>] [--mode merge|squash] [--subject <title>] [--body <body>] */ import { spawn } from 'node:child_process'; import { git } from './git.js'; import { AtprotoRemoteHelper } from './helper.js'; import { resolveRepoLocation } from './identity.js'; import { GIT_PULL_COLLECTION, GIT_REPO_COLLECTION } from './lexicon.js'; import { oauthLogin } from './oauth.js'; import { pullRkey } from './pull-requests.js'; import { repoNameOf, rkeyOf } from './record.js'; import { deleteSession, findSessionByDid, saveSession, } from './session-store.js'; import { parseAtprotoUrl } from './url.js'; import { XrpcClient } from './xrpc.js'; const USAGE = `usage: git-remote-atproto <remote-name> atproto://<handle-or-did>/<repo> git-remote-atproto login <handle-or-did> [--service <url>] [--scope <scopes>] git-remote-atproto logout <handle-or-did> git-remote-atproto pr create [--remote <name>] [--title <title>] [--body <body>] [--draft] git-remote-atproto pr update [--remote <name>] [--title <title>] [--body <body>] [--draft] git-remote-atproto pr merge [--remote <name>] [--mode merge|squash] [--subject <title>] [--body <body>] `; /** @param {string} url */ function openBrowser(url) { const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]]; // The URL is already printed, so a machine with no opener still works: the // user follows it by hand. const child = spawn(cmd, args, { stdio: 'ignore', detached: true }); child.on('error', () => {}); child.unref(); } /** * @param {string[]} args - argv after the subcommand * @returns {Promise<void>} */ async function login(args) { /** @type {string|undefined} */ let identifier; /** @type {string|undefined} */ let service; let scope = 'atproto'; for (let i = 0; i < args.length; i++) { if (args[i] === '--service') service = args[++i]; else if (args[i] === '--scope') scope = args[++i]; else if (!identifier) identifier = args[i]; else throw new Error(USAGE); } if (!identifier) throw new Error(USAGE); const location = await resolveRepoLocation(identifier, { ...process.env, ...(service ? { ATPROTO_GIT_SERVICE: service } : {}), }); const session = await oauthLogin({ service: location.service, scope, identifier, openBrowser, log: (text) => process.stderr.write(text), }); if (session.did !== location.did) { process.stderr.write( `warning: logged in as ${session.did}, but ${identifier} resolves to ${location.did}\n`, ); } session.identifier = identifier; saveSession(process.env, identifier, session); process.stderr.write( `Logged in as ${session.did}\n scope: ${session.scope}\n`, ); } /** * @param {string[]} args * @returns {{remote: string|undefined, title: string|undefined, note: string|undefined, draft: boolean}} */ function parsePullOptions(args) { /** @type {string|undefined} */ let remote; /** @type {string|undefined} */ let title; /** @type {string|undefined} */ let note; let draft = false; for (let i = 0; i < args.length; i++) { const option = args[i]; if (option === '--draft') { draft = true; continue; } if ( option !== '--remote' && option !== '--title' && option !== '--body' && option !== '--note' ) { throw new Error(USAGE); } const value = args[++i]; if (!value || value.startsWith('--')) throw new Error(USAGE); if (option === '--remote') remote = value; else if (option === '--title') title = value; else note = value; } if ([remote, title, note].some((value) => value === '')) { throw new Error(USAGE); } return { remote, title, note, draft }; } /** * @param {string[]} args * @param {string} what * @returns {string} */ function gitValue(args, what) { const result = git(args); if (result.status !== 0) { throw new Error(`git ${what} failed: ${result.stderr.trim()}`); } return result.stdout.trim(); } /** * @param {string[]} args * @returns {string} */ function gitOptional(args) { const result = git(args); return result.status === 0 ? result.stdout.trim() : ''; } /** * @param {string} uri * @returns {{did: string, repo: string}} */ function parseRepoUri(uri) { const match = /^at:\/\/([^/]+)\/dev\.pdsjs\.git\.repo\/([^/]+)$/.exec(uri); if (!match) throw new Error(`invalid upstream repository URI "${uri}"`); return { did: match[1], repo: match[2] }; } /** * The shared setup of a pull command: the branch at HEAD, the selected remote * and the account it names, and an authenticated XRPC client for that account. * The subject is the repository the work is offered to: the copy's own record, * or the upstream the record names when the copy is a fork. * @param {{remote?: string|undefined}} options * @returns {Promise<{ * branch: string, * sha: string, * remote: string, * location: {did: string, service: string}, * client: XrpcClient, * subject: {uri: string, cid: string}, * subjectName: string, * subjectKey: string, * workKey: string, * repoName: string, * authorHandle: string, * }>} */ async function pullContext(options) { const branch = gitValue( ['symbolic-ref', '--quiet', '--short', 'HEAD'], 'read branch', ); const sha = gitValue(['rev-parse', 'HEAD'], 'read HEAD'); const configuredRemote = gitOptional([ 'config', '--get', `branch.${branch}.remote`, ]); const remote = options.remote || configuredRemote || 'origin'; const remoteUrl = gitValue( ['remote', 'get-url', '--push', remote], `read ${remote} URL`, ); const parsedRemote = parseAtprotoUrl(remoteUrl); const location = await resolveRepoLocation( parsedRemote.authority, process.env, ); const session = findSessionByDid(process.env, location.did); if (!session) { throw new Error( `no OAuth session for ${location.did}; run git-remote-atproto login first`, ); } const client = new XrpcClient(location.service); await client.setOauthSession(session, (updated) => saveSession(process.env, session.identifier || location.did, updated), ); // The copy holding the branch is this account's own record, under this // account's own key, so the name on the remote finds it. A pull request // names a public record; a private repository has none, and its branches // are read through the space instead. const work = ( await client.listRecords(location.did, GIT_REPO_COLLECTION) ).find( (row) => repoNameOf(row.value, rkeyOf(row.uri)) === parsedRemote.repoName, ); if (!work) { throw new Error( `no git repo record "${parsedRemote.repoName}" on ${parsedRemote.authority}`, ); } let subject = { uri: work.uri, cid: work.cid }; let subjectName = parsedRemote.repoName; const workValue = /** @type {{upstream?: unknown}} */ (work.value || {}); if (typeof workValue.upstream === 'string') { const upstream = parseRepoUri(workValue.upstream); const upstreamLocation = await resolveRepoLocation( upstream.did, process.env, ); const upstreamClient = new XrpcClient(upstreamLocation.service); const canonical = await upstreamClient.getRecord( upstream.did, GIT_REPO_COLLECTION, upstream.repo, ); if (!canonical) { throw new Error(`no upstream git repo record at ${workValue.upstream}`); } subject = { uri: canonical.uri, cid: canonical.cid }; subjectName = repoNameOf(canonical.value, upstream.repo); } return { branch, sha, remote, location, client, subject, subjectName, // What the pull request record is keyed by, and what it names as the // copy holding the branch. subjectKey: rkeyOf(subject.uri), workKey: rkeyOf(work.uri), repoName: parsedRemote.repoName, authorHandle: session.identifier || location.did, }; } /** * The pull record that names one branch: the canonical repository it is * offered to, the author's own ref and tip, and the words the author sets. * The same shape the UI writes, so create and update make identical records. * @param {Object} about * @param {{uri: string, cid: string}} about.subject - the canonical repository * @param {string} about.repo - repository name, the rkey of the author's copy * @param {string} about.ref - full ref name of the branch * @param {string} about.sha * @param {string} [about.title] * @param {string} [about.status] * @param {string} [about.note] * @returns {Record<string, unknown>} */ function buildPullRecord(about) { return { $type: GIT_PULL_COLLECTION, subject: about.subject, repo: about.repo, ref: about.ref, sha: about.sha, ...(about.title ? { title: about.title } : {}), ...(about.status ? { status: about.status } : {}), ...(about.note ? { note: about.note } : {}), createdAt: new Date().toISOString(), }; } /** * Create the current branch's pull request: one record at the branch's key, * which `pr update` rewrites in place. Nothing may hold the key yet, so a * second `pr create` refuses rather than clobbering the words already there. * @param {string[]} args * @returns {Promise<void>} */ async function createPull(args) { const options = parsePullOptions(args); const { branch, sha, location, client, subject, subjectKey, workKey } = await pullContext(options); const ref = `refs/heads/${branch}`; const record = buildPullRecord({ subject, repo: workKey, ref, sha, title: options.title, status: options.draft ? 'draft' : '', note: options.note, }); const created = await client.putRecord( location.did, GIT_PULL_COLLECTION, pullRkey(subjectKey, ref), record, null, ); process.stdout.write(`${created.uri}\n`); } /** * Update the current branch's pull request: rewrite the record at the * branch's key with the newest words and the tip at HEAD, keeping what a * flag did not set. The write compares against the version just read, so a * concurrent edit surfaces instead of being silently overwritten. * @param {string[]} args * @returns {Promise<void>} */ async function updatePull(args) { const options = parsePullOptions(args); if (!options.title && !options.note && !options.draft) { throw new Error( 'pr update needs at least one of --title, --body or --draft; nothing to change', ); } const { branch, sha, location, client, subject, subjectKey, workKey } = await pullContext(options); const ref = `refs/heads/${branch}`; const rkey = pullRkey(subjectKey, ref); const existing = await client.getRecord( location.did, GIT_PULL_COLLECTION, rkey, ); if (!existing) { throw new Error( `no pull request for ${branch}; run git-remote-atproto pr create first`, ); } const held = /** @type {Record<string, unknown>} */ (existing.value); const record = buildPullRecord({ subject, repo: workKey, ref, sha, title: options.title ?? /** @type {string|undefined} */ (held.title), status: options.draft ? 'draft' : /** @type {string|undefined} */ (held.status), note: options.note ?? /** @type {string|undefined} */ (held.note), }); const updated = await client.putRecord( location.did, GIT_PULL_COLLECTION, rkey, record, existing.cid, ); process.stdout.write(`${updated.uri}\n`); } /** * @param {string[]} args * @returns {{remote: string|undefined, mode: 'merge'|'squash', subject: string|undefined, body: string|undefined}} */ function parseMergeOptions(args) { /** @type {string|undefined} */ let remote; /** @type {string|undefined} */ let subject; /** @type {string|undefined} */ let body; /** @type {'merge'|'squash'} */ let mode = 'merge'; for (let i = 0; i < args.length; i++) { const option = args[i]; if (option === '--mode') { const value = args[++i]; if (value !== 'merge' && value !== 'squash') throw new Error(USAGE); mode = value; continue; } if ( option !== '--remote' && option !== '--subject' && option !== '--body' ) { throw new Error(USAGE); } const value = args[++i]; if (!value || value.startsWith('--')) throw new Error(USAGE); if (option === '--remote') remote = value; else if (option === '--subject') subject = value; else body = value; } return { remote, mode, subject, body }; } /** * The account the work is offered to, as the canonical record names it. A * self-merge names the branch's own copy, which is the same account. * @param {{uri: string}} subject * @returns {{did: string}} */ function ownerOf(subject) { const match = /^at:\/\/([^/]+)\/dev\.pdsjs\.git\.repo\/([^/]+)$/.exec( subject.uri, ); if (!match) throw new Error(`invalid repository record ${subject.uri}`); return { did: match[1] }; } /** * Merge the current branch's pull request onto the repository's canonical * branch. Merging is the repository owner's own act, so the owner's session * is required: for a fork that is a session for the upstream account, not the * branch author's. A self-merge reuses the session the branch already reads * with. * @param {string[]} args * @returns {Promise<void>} */ async function mergePull(args) { const options = parseMergeOptions(args); const { branch, location, client, subject, subjectName, authorHandle } = await pullContext(options); const owner = ownerOf(subject); let ownerClient = client; if (owner.did !== location.did) { const ownerLocation = await resolveRepoLocation(owner.did, process.env); const session = findSessionByDid(process.env, owner.did); if (!session) { throw new Error( `only the repository's owner can merge; run git-remote-atproto login for ${owner.did}`, ); } ownerClient = new XrpcClient(ownerLocation.service); await ownerClient.setOauthSession(session, (updated) => saveSession(process.env, session.identifier || owner.did, updated), ); } const ref = `refs/heads/${branch}`; const result = await ownerClient.mergePull({ name: subjectName, did: location.did, ref, mode: options.mode, ...(authorHandle ? { handle: authorHandle } : {}), // A squash is the change, so its subject is the change's own subject when // the caller does not name one. ...(options.mode === 'squash' ? { subject: options.subject ?? gitValue(['log', '-1', '--format=%s'], 'read subject'), } : {}), ...(options.body ? { body: options.body } : {}), }); process.stdout.write(`${result.sha}\n`); } async function main() { const command = process.argv[2]; if (command === 'login') { await login(process.argv.slice(3)); return; } if (command === 'logout') { const identifier = process.argv[3]; if (!identifier) throw new Error(USAGE); deleteSession(process.env, identifier); process.stderr.write(`Logged out ${identifier}\n`); return; } if (command === 'pr') { const action = process.argv[3]; if (action === 'create') { await createPull(process.argv.slice(4)); return; } if (action === 'update') { await updatePull(process.argv.slice(4)); return; } if (action === 'merge') { await mergePull(process.argv.slice(4)); return; } throw new Error(USAGE); } const url = process.argv[3] ?? process.argv[2]; if (!url) { process.stderr.write(USAGE); process.exit(129); } const helper = new AtprotoRemoteHelper({ url, input: process.stdin, write: (text) => process.stdout.write(text), log: (text) => process.stderr.write(text), }); await helper.run(); } main().catch((err) => { const message = err instanceof Error ? err.message : String(err); process.stderr.write(`fatal: ${message}\n`); process.exit(128); });