diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 45d574d1b6d..0f39f530fa8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -852,6 +852,10 @@ jobs: with: native-runtime: node + # R1: an upgrade from the newest release must adopt its live terminal daemon. + - name: Daemon protocol crossing against newest release + run: pnpm run check:daemon-protocol-crossing + # A path filter that matches nothing exits 1 ("No test files found"), so this # lane cannot report success while running zero tests. - name: Old/new client and server compatibility journeys diff --git a/config/scripts/check-daemon-protocol-crossing.mjs b/config/scripts/check-daemon-protocol-crossing.mjs new file mode 100644 index 00000000000..f46b875cde7 --- /dev/null +++ b/config/scripts/check-daemon-protocol-crossing.mjs @@ -0,0 +1,113 @@ +// R1 gate: the working tree must attach the newest release's terminal daemon, or an +// upgrade strands every live terminal. Rollback crossing is reported, not enforced. +// Usage: node check-daemon-protocol-crossing.mjs [--release-ref ] +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + DAEMON_PROTOCOL_SOURCE_PATH, + canAttach, + crossingRequirements, + parseDaemonProtocolFacts +} from './daemon-protocol-facts.mjs' +import { selectLatestStableReleaseTag } from './stable-release-tags.mjs' + +// Same override the cross-version-wire harness honors, so both pair against one ref. +const RELEASE_REF_ENV = 'ORCA_CROSS_VERSION_BASELINE_REF' + +function git(repoRoot, args) { + return execFileSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 16 * 1024 * 1024 + }).trim() +} + +export function resolveReleaseRef(repoRoot, explicitRef, env = process.env) { + const override = explicitRef?.trim() || env[RELEASE_REF_ENV]?.trim() + if (override) { + return override + } + const tags = git(repoRoot, ['tag', '--list', 'v[0-9]*']).split('\n').filter(Boolean) + const latest = selectLatestStableReleaseTag(tags) + if (!latest) { + // Why: a shallow clone has no tags; passing here would silently skip the gate. + throw new Error( + `no stable release tags matching vX.Y.Z (saw ${tags.length} tag(s)). ` + + `Check out with fetch-depth: 0, or pass --release-ref / ${RELEASE_REF_ENV}.` + ) + } + return latest +} + +/** + * @param {{ release: ReturnType, candidate: ReturnType, releaseRef: string }} input + */ +export function assessDaemonProtocolCrossing({ release, candidate, releaseRef }) { + const upgrade = canAttach(candidate, release) + const rollback = canAttach(release, candidate) + const requirements = crossingRequirements(release) + const lines = [ + `release ${releaseRef}: daemon protocol ${release.protocolVersion}`, + `candidate (working tree): daemon protocol ${candidate.protocolVersion}`, + upgrade + ? `upgrade ${releaseRef} -> candidate: OK, live terminals are adopted` + : `upgrade ${releaseRef} -> candidate: FAIL, ${requirements.upgrade}; add ${release.protocolVersion} to PREVIOUS_DAEMON_PROTOCOL_VERSIONS`, + rollback + ? `rollback candidate -> ${releaseRef}: OK, live terminals survive (info)` + : `rollback candidate -> ${releaseRef}: terminals from the candidate's daemon are unreachable until re-upgrade (info, expected after a protocol bump)` + ] + return { upgrade, rollback, lines } +} + +export function checkDaemonProtocolCrossing({ repoRoot, releaseRef: explicitRef, env }) { + const releaseRef = resolveReleaseRef(repoRoot, explicitRef, env) + let releaseSource + try { + releaseSource = git(repoRoot, ['show', `${releaseRef}:${DAEMON_PROTOCOL_SOURCE_PATH}`]) + } catch (error) { + throw new Error( + `cannot read ${DAEMON_PROTOCOL_SOURCE_PATH} at ${releaseRef}: ${error.stderr?.trim() || String(error)}` + ) + } + const release = parseDaemonProtocolFacts( + releaseSource, + `${releaseRef}:${DAEMON_PROTOCOL_SOURCE_PATH}` + ) + const candidate = parseDaemonProtocolFacts( + readFileSync(join(repoRoot, DAEMON_PROTOCOL_SOURCE_PATH), 'utf8'), + DAEMON_PROTOCOL_SOURCE_PATH + ) + return assessDaemonProtocolCrossing({ release, candidate, releaseRef }) +} + +function parseArgs(argv) { + const index = argv.indexOf('--release-ref') + if (index === -1) { + return {} + } + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('--release-ref requires a value') + } + return { releaseRef: value } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const repoRoot = resolve(import.meta.dirname, '..', '..') + try { + const { releaseRef } = parseArgs(process.argv.slice(2)) + const result = checkDaemonProtocolCrossing({ repoRoot, releaseRef }) + for (const line of result.lines) { + console.log(line) + } + if (!result.upgrade) { + process.exitCode = 1 + } + } catch (error) { + console.error(`daemon protocol crossing check failed: ${error.message}`) + process.exitCode = 1 + } +} diff --git a/config/scripts/check-daemon-protocol-crossing.test.mjs b/config/scripts/check-daemon-protocol-crossing.test.mjs new file mode 100644 index 00000000000..464b32ad349 --- /dev/null +++ b/config/scripts/check-daemon-protocol-crossing.test.mjs @@ -0,0 +1,105 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { checkDaemonProtocolCrossing } from './check-daemon-protocol-crossing.mjs' +import { DAEMON_PROTOCOL_SOURCE_PATH } from './daemon-protocol-facts.mjs' +import { selectLatestStableReleaseTag } from './stable-release-tags.mjs' + +const repos = [] + +function git(repo, args) { + execFileSync('git', args, { cwd: repo, stdio: 'ignore' }) +} + +function writeProtocol(repo, current) { + const previous = Array.from({ length: current - 1 }, (_, index) => index + 1) + const file = join(repo, DAEMON_PROTOCOL_SOURCE_PATH) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync( + file, + `export const PROTOCOL_VERSION = ${current}\nexport const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [${previous.join(', ')}] as const\n` + ) +} + +function repoWithTags(tagged) { + const repo = mkdtempSync(join(tmpdir(), 'daemon-protocol-crossing-')) + repos.push(repo) + git(repo, ['init', '-q']) + for (const [tag, protocol] of tagged) { + writeProtocol(repo, protocol) + git(repo, ['add', '.']) + git(repo, [ + '-c', + 'user.name=test', + '-c', + 'user.email=test@example.com', + 'commit', + '-q', + '--no-gpg-sign', + '-m', + tag + ]) + git(repo, ['tag', tag]) + } + return repo +} + +afterEach(() => { + for (const repo of repos.splice(0)) { + rmSync(repo, { recursive: true, force: true }) + } +}) + +describe('selectLatestStableReleaseTag', () => { + it('orders numerically and skips prerelease tags', () => { + expect(selectLatestStableReleaseTag(['v1.4.9', 'v1.4.10', 'v1.5.0-rc.1', 'nightly'])).toBe( + 'v1.4.10' + ) + expect(selectLatestStableReleaseTag(['nightly'])).toBeNull() + }) +}) + +describe('checkDaemonProtocolCrossing', () => { + const env = {} + + it('passes a bump that lists the newest release version, reporting rollback as info', () => { + const repo = repoWithTags([ + ['v1.0.9', 5], + ['v1.0.10', 6] + ]) + writeProtocol(repo, 7) + const result = checkDaemonProtocolCrossing({ repoRoot: repo, env }) + expect(result).toMatchObject({ upgrade: true, rollback: false }) + expect(result.lines[0]).toBe('release v1.0.10: daemon protocol 6') + }) + + it('fails when the working tree drops the newest release version', () => { + const repo = repoWithTags([['v1.0.0', 6]]) + writeFileSync( + join(repo, DAEMON_PROTOCOL_SOURCE_PATH), + 'export const PROTOCOL_VERSION = 7\nexport const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5] as const\n' + ) + const result = checkDaemonProtocolCrossing({ repoRoot: repo, env }) + expect(result.upgrade).toBe(false) + expect(result.lines.join('\n')).toContain('add 6 to PREVIOUS_DAEMON_PROTOCOL_VERSIONS') + }) + + it('refuses to pass silently without release tags', () => { + const repo = repoWithTags([['nightly', 6]]) + expect(() => checkDaemonProtocolCrossing({ repoRoot: repo, env })).toThrow( + /no stable release tags/ + ) + }) + + it('honors an explicit release ref', () => { + const repo = repoWithTags([ + ['v1.0.0', 5], + ['v1.0.1', 6] + ]) + const result = checkDaemonProtocolCrossing({ repoRoot: repo, releaseRef: 'v1.0.0', env }) + expect(result).toMatchObject({ upgrade: true, rollback: false }) + expect(result.lines[0]).toBe('release v1.0.0: daemon protocol 5') + }) +}) diff --git a/config/scripts/daemon-protocol-facts.mjs b/config/scripts/daemon-protocol-facts.mjs new file mode 100644 index 00000000000..c63d2c084d2 --- /dev/null +++ b/config/scripts/daemon-protocol-facts.mjs @@ -0,0 +1,99 @@ +// Reads a build's daemon protocol version and the older versions it can still attach to. +// Parsed from source text so a release tag and a candidate tree are read the same way. +// Usage: node daemon-protocol-facts.mjs [output.json] +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +export const DAEMON_PROTOCOL_SOURCE_PATH = 'src/main/daemon/daemon-protocol-version.ts' + +const CURRENT_DECLARATION = /^export const PROTOCOL_VERSION\b[^=\n]*=\s*([^\n;]*)/gmu +const PREVIOUS_DECLARATION = + /^export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS\b[^=\n]*=\s*\[([^\]]*)\]/gmu + +function singleMatch(source, pattern, name, label) { + const matches = [...source.matchAll(pattern)] + if (matches.length !== 1) { + throw new Error( + `${label}: expected exactly one \`export const ${name}\` declaration, found ${matches.length}` + ) + } + return matches[0][1] +} + +function parseProtocolInteger(text, name, label) { + const trimmed = text.trim() + // Why: a reference like `= NEXT_VERSION` must fail, not coerce to NaN or 0. + if (!/^\d+$/u.test(trimmed)) { + throw new Error(`${label}: ${name} must be an integer literal, got \`${trimmed}\``) + } + const value = Number(trimmed) + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label}: ${name} must be a positive integer, got \`${trimmed}\``) + } + return value +} + +/** + * @param {string} source + * @param {string} [label] names the source in errors + * @returns {{ protocolVersion: number, previousProtocolVersions: number[] }} + */ +export function parseDaemonProtocolFacts(source, label = DAEMON_PROTOCOL_SOURCE_PATH) { + const protocolVersion = parseProtocolInteger( + singleMatch(source, CURRENT_DECLARATION, 'PROTOCOL_VERSION', label), + 'PROTOCOL_VERSION', + label + ) + const previousProtocolVersions = singleMatch( + source, + PREVIOUS_DECLARATION, + 'PREVIOUS_DAEMON_PROTOCOL_VERSIONS', + label + ) + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => parseProtocolInteger(entry, 'PREVIOUS_DAEMON_PROTOCOL_VERSIONS entry', label)) + if (protocolVersion > 1 && previousProtocolVersions.length === 0) { + throw new Error(`${label}: PREVIOUS_DAEMON_PROTOCOL_VERSIONS parsed as empty`) + } + const outOfRange = previousProtocolVersions.filter((version) => version >= protocolVersion) + if (outOfRange.length > 0) { + throw new Error( + `${label}: PREVIOUS_DAEMON_PROTOCOL_VERSIONS lists ${outOfRange.join(', ')}, not below PROTOCOL_VERSION ${protocolVersion}` + ) + } + return { protocolVersion, previousProtocolVersions } +} + +/** Whether `reader` can route sessions owned by a daemon speaking `owner`'s protocol. */ +export function canAttach(reader, owner) { + return ( + reader.protocolVersion === owner.protocolVersion || + reader.previousProtocolVersions.includes(owner.protocolVersion) + ) +} + +/** What any candidate must declare for sessions to cross in each direction with `release`. */ +export function crossingRequirements(release) { + const accepted = [...release.previousProtocolVersions, release.protocolVersion] + return { + upgrade: `candidate speaks ${release.protocolVersion} or lists ${release.protocolVersion} as previous`, + rollback: `candidate speaks one of ${Math.min(...accepted)}..${Math.max(...accepted)} (the release's own or previous list)`, + // Only an owner the release already speaks can survive a rollback, so a newer-protocol candidate cannot. + bothDirections: `candidate speaks ${release.protocolVersion}, or speaks an older release-listed version and lists ${release.protocolVersion} as previous` + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const [input, output] = process.argv.slice(2) + if (!input) { + throw new Error('usage: daemon-protocol-facts.mjs [output.json]') + } + const facts = parseDaemonProtocolFacts(readFileSync(input, 'utf8'), input) + if (output) { + writeFileSync(output, `${JSON.stringify(facts)}\n`) + } + console.log(JSON.stringify(facts)) +} diff --git a/config/scripts/daemon-protocol-facts.test.mjs b/config/scripts/daemon-protocol-facts.test.mjs new file mode 100644 index 00000000000..4288f3179ee --- /dev/null +++ b/config/scripts/daemon-protocol-facts.test.mjs @@ -0,0 +1,90 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + DAEMON_PROTOCOL_SOURCE_PATH, + canAttach, + crossingRequirements, + parseDaemonProtocolFacts +} from './daemon-protocol-facts.mjs' + +const projectDir = resolve(import.meta.dirname, '../..') + +function source(current, previous) { + return [ + `export const PROTOCOL_VERSION = ${current}`, + 'export const OTHER_DAEMON_PROTOCOL_VERSION = 5', + `export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [${previous}] as const` + ].join('\n') +} + +const facts = (protocolVersion, previousProtocolVersions) => ({ + protocolVersion, + previousProtocolVersions +}) + +describe('parseDaemonProtocolFacts', () => { + it('reads the working tree declarations and keeps the append-only range', () => { + const parsed = parseDaemonProtocolFacts( + readFileSync(join(projectDir, DAEMON_PROTOCOL_SOURCE_PATH), 'utf8') + ) + expect(parsed.protocolVersion).toBeGreaterThan(1) + expect(parsed.previousProtocolVersions).toEqual( + Array.from({ length: parsed.protocolVersion - 1 }, (_, index) => index + 1) + ) + }) + + it('parses multi-line lists and type annotations', () => { + const text = [ + '// PROTOCOL_VERSION = 99 in a comment is ignored', + 'export const PROTOCOL_VERSION: number = 4', + 'export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS: readonly number[] = [', + ' 1, 2,', + ' 3,', + ']' + ].join('\n') + expect(parseDaemonProtocolFacts(text)).toEqual(facts(4, [1, 2, 3])) + }) + + it.each([ + ['a missing PROTOCOL_VERSION', 'export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1]'], + ['a missing previous list', 'export const PROTOCOL_VERSION = 2'], + ['a non-literal PROTOCOL_VERSION', source('NEXT_VERSION', '1')], + ['a spread in the previous list', source(3, '...LEGACY, 2')], + ['a comment in the previous list', source(3, '1, // legacy\n 2')], + ['an empty previous list past v1', source(3, '')], + ['a previous version at or above current', source(3, '1, 2, 3')], + ['a duplicate declaration', `${source(3, '1, 2')}\nexport const PROTOCOL_VERSION = 4`], + [ + 'a list moved behind a constant', + 'export const PROTOCOL_VERSION = 3\nexport const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = LEGACY' + ] + ])('fails loudly on %s', (_name, text) => { + expect(() => parseDaemonProtocolFacts(text, 'fixture.ts')).toThrow(/fixture\.ts/) + }) +}) + +describe('protocol crossing', () => { + const release = facts(37, [36, 35]) + + it('lets a same-version or listing candidate adopt the release daemon', () => { + expect(canAttach(facts(37, [36, 35]), release)).toBe(true) + expect(canAttach(facts(38, [37, 36, 35]), release)).toBe(true) + }) + + it('rejects a candidate that dropped the release version', () => { + expect(canAttach(facts(38, [36, 35]), release)).toBe(false) + }) + + it('cannot roll back past a protocol bump', () => { + expect(canAttach(release, facts(38, [37, 36, 35]))).toBe(false) + expect(canAttach(release, facts(36, [35]))).toBe(true) + }) + + it('describes the requirements in both directions', () => { + expect(crossingRequirements(release)).toMatchObject({ + upgrade: 'candidate speaks 37 or lists 37 as previous', + rollback: expect.stringContaining('35..37') + }) + }) +}) diff --git a/config/scripts/pr-code-change-scope-daemon-protocol.test.mjs b/config/scripts/pr-code-change-scope-daemon-protocol.test.mjs new file mode 100644 index 00000000000..014b83dd9fa --- /dev/null +++ b/config/scripts/pr-code-change-scope-daemon-protocol.test.mjs @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { classifyPrJobs } from './pr-code-change-scope.mjs' + +describe('daemon protocol crossing gate routing', () => { + it('runs the gate when the protocol constants change', () => { + expect(classifyPrJobs(['src/main/daemon/daemon-protocol-version.ts'])).toMatchObject({ + should_run: true, + 'cross-version-wire': true, + package: true, + package_windows: true + }) + }) + + it.each([ + 'config/scripts/daemon-protocol-facts.mjs', + 'config/scripts/check-daemon-protocol-crossing.mjs', + 'config/scripts/stable-release-tags.mjs' + ])('runs the gate when its checker %s changes', (file) => { + expect(classifyPrJobs([file])).toMatchObject({ should_run: true, 'cross-version-wire': true }) + }) +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 1a5c7ed3a4d..a98168d120f 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -144,6 +144,11 @@ function changesMobileWebApp(changedFiles) { const CROSS_VERSION_WIRE_PREFIXES = [ 'tests/e2e/cross-version-wire/', + 'config/scripts/stable-release-tags', + // The R1 daemon protocol crossing gate runs in this job. + 'config/scripts/daemon-protocol-facts', + 'config/scripts/check-daemon-protocol-crossing', + 'src/main/daemon/daemon-protocol-version.ts', 'src/shared/protocol-version', 'src/shared/terminal-stream-protocol', 'src/shared/browser-client-host-protocol', diff --git a/config/scripts/stable-release-tags.mjs b/config/scripts/stable-release-tags.mjs new file mode 100644 index 00000000000..2650f23eaa8 --- /dev/null +++ b/config/scripts/stable-release-tags.mjs @@ -0,0 +1,30 @@ +// Stable desktop release tags (vX.Y.Z); shared by the cross-version harness and the R1 protocol gate. +export const STABLE_DESKTOP_RELEASE_TAG = /^v\d+\.\d+\.\d+$/ + +export function compareReleaseTags(a, b) { + const parts = (tag) => + tag + .replace(/^v/, '') + .split('.') + .map((part) => Number.parseInt(part, 10)) + .map((value) => (Number.isFinite(value) ? value : 0)) + const left = parts(a) + const right = parts(b) + for (let index = 0; index < Math.max(left.length, right.length); index++) { + const diff = (left[index] ?? 0) - (right[index] ?? 0) + if (diff !== 0) { + return diff + } + } + return 0 +} + +/** @param {string[]} tags @returns {string | null} */ +export function selectLatestStableReleaseTag(tags) { + return ( + tags + .filter((tag) => STABLE_DESKTOP_RELEASE_TAG.test(tag)) + .sort(compareReleaseTags) + .at(-1) ?? null + ) +} diff --git a/package.json b/package.json index 6afa5141168..39ab30535d3 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs", "check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs", "check:readme-local-links": "node config/scripts/check-readme-local-links.mjs", + "check:daemon-protocol-crossing": "node config/scripts/check-daemon-protocol-crossing.mjs", "check:node-runtime-pin": "node config/scripts/check-node-runtime-pin.mjs", "build:orcad": "node config/scripts/build-orcad-bun.mjs", "build:orcad-template": "node config/scripts/build-orcad-template.mjs", diff --git a/tests/e2e/cross-version-wire/release-checkout.ts b/tests/e2e/cross-version-wire/release-checkout.ts index 6a2d58312d3..947b6f6fb4d 100644 --- a/tests/e2e/cross-version-wire/release-checkout.ts +++ b/tests/e2e/cross-version-wire/release-checkout.ts @@ -7,6 +7,9 @@ import { extractReleaseCheckoutTree, scavengeReleaseCheckoutStaging } from './release-checkout-tree.ts' +import { selectLatestStableReleaseTag } from '../../../config/scripts/stable-release-tags.mjs' + +export { selectLatestStableReleaseTag } export const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..') const DEFAULT_CACHE_ROOT = join(REPO_ROOT, 'tests', 'e2e', '.cross-version-checkouts') @@ -15,7 +18,6 @@ const DEFAULT_CACHE_ROOT = join(REPO_ROOT, 'tests', 'e2e', '.cross-version-check const CHECKOUT_FORMAT = 4 const BASELINE_REF_ENV = 'ORCA_CROSS_VERSION_BASELINE_REF' -const STABLE_DESKTOP_RELEASE_TAG = /^v\d+\.\d+\.\d+$/ export type ReleaseCheckout = { /** The ref as requested, e.g. `v1.4.169`. */ @@ -86,24 +88,6 @@ function git(args: string[]): string { }).trim() } -function compareReleaseTags(a: string, b: string): number { - const parts = (tag: string): number[] => - tag - .replace(/^v/, '') - .split('.') - .map((part) => Number.parseInt(part, 10)) - .map((value) => (Number.isFinite(value) ? value : 0)) - const left = parts(a) - const right = parts(b) - for (let index = 0; index < Math.max(left.length, right.length); index++) { - const diff = (left[index] ?? 0) - (right[index] ?? 0) - if (diff !== 0) { - return diff - } - } - return 0 -} - /** * The version point the harness pairs current code against. An explicit * {@link BASELINE_REF_ENV} wins; otherwise the newest stable desktop release tag. @@ -136,15 +120,6 @@ export function resolveBaselineReleaseRef(): string { return latest } -export function selectLatestStableReleaseTag(tags: string[]): string | null { - return ( - tags - .filter((tag) => STABLE_DESKTOP_RELEASE_TAG.test(tag)) - .sort(compareReleaseTags) - .at(-1) ?? null - ) -} - function resolveCommit(ref: string): string { try { return git(['rev-parse', `${ref}^{commit}`])