From 78602380e9d3cbe3eca0f6390d84fa5e1cdde374 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 9 Sep 2026 17:45:32 +0200 Subject: [PATCH] fix(sdk): support an explicitly unpublished git baseline --- README.md | 6 +++ packages/sdk/src/platform/git.ts | 2 + .../sdk/src/plugins/git-status/plugin.test.ts | 41 ++++++++++++++++++- packages/sdk/src/plugins/git-status/plugin.ts | 27 +++++++++--- .../testing/conformance-violations.test.ts | 13 +++++- packages/sdk/src/testing/conformance.ts | 3 ++ 6 files changed, 82 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c2b78b9a..d8b70bfd 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,12 @@ This works with both native Git and `platform.git`. The host owns fetching the remote ref. If the configured ref is missing or unreadable, refresh returns no snapshot and sends no notification; it does not substitute local `main` or zero. +For an initial publication where the remote branch may not exist yet, set +`missingBase: 'all'` alongside `baseBranch`. All HEAD commits then count as +unpublished until the ref appears. An unreadable existing ref still produces no +snapshot. A `platform.git` adapter must implement the corresponding +`GitCountAheadOptions.missingBase` policy. + ## Requirements - [Bun](https://bun.sh) (the monorepo, the server runtime and the test runner) diff --git a/packages/sdk/src/platform/git.ts b/packages/sdk/src/platform/git.ts index 0a7ab017..1f766196 100644 --- a/packages/sdk/src/platform/git.ts +++ b/packages/sdk/src/platform/git.ts @@ -41,6 +41,8 @@ export interface GitCountAheadOptions extends GitRepoOptions { base: string /** Ref being measured. Defaults to HEAD. */ ref?: string + /** Count all commits in ref if base does not exist; other read failures must still reject. */ + missingBase?: 'all' } /** diff --git a/packages/sdk/src/plugins/git-status/plugin.test.ts b/packages/sdk/src/plugins/git-status/plugin.test.ts index b8b8c18c..928e4a16 100644 --- a/packages/sdk/src/plugins/git-status/plugin.test.ts +++ b/packages/sdk/src/plugins/git-status/plugin.test.ts @@ -248,8 +248,17 @@ function realGitClient(runner: ProcessRunner): GitClient { }) }, - async countAhead({ dir, base, ref }) { - return Number.parseInt((await run(dir, ['rev-list', '--count', `${base}..${ref ?? 'HEAD'}`])).trim(), 10) + async countAhead({ dir, base, ref, missingBase }) { + let revision = `${base}..${ref ?? 'HEAD'}` + if (missingBase === 'all') { + try { + await run(dir, ['rev-parse', '--verify', '--quiet', base]) + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 1)) throw error + revision = ref ?? 'HEAD' + } + } + return Number.parseInt((await run(dir, ['rev-list', '--count', revision])).trim(), 10) }, async defaultBranch({ dir }) { @@ -295,6 +304,34 @@ async function makeRepo(): Promise<{ dir: string; lastCommitAt: number }> { } describe('git-status reads a repository through the port or the binary', () => { + test('an explicitly unpublished baseline counts all commits until the remote ref appears', async () => { + const repo = await makeRepo() + await gitRunner.execFile('git', ['update-ref', 'refs/heads/main', 'HEAD'], { cwd: repo.dir }) + const base: Platform = { ...createNodePlatform(), scheduler: new RecordingScheduler() } + const gitStatus: GitStatusPluginConfig = { baseBranch: 'origin/main', missingBase: 'all' } + const overPort = await bootSession({ platform: { ...base, git: realGitClient(gitRunner) }, workspaceDir: repo.dir, gitStatus }) + const overBinary = await bootSession({ platform: base, workspaceDir: repo.dir, gitStatus }) + for (const host of [overPort, overBinary]) { + expect((await pull(host.session)).snapshot?.committedAhead).toBe(2) + } + await gitRunner.execFile('git', ['update-ref', 'refs/remotes/origin/main', 'HEAD'], { cwd: repo.dir }) + for (const host of [overPort, overBinary]) { + expect((await pull(host.session)).snapshot?.committedAhead).toBe(0) + } + }) + + test('a damaged baseline is unknown even when missing baselines count as unpublished', async () => { + const repo = await makeRepo() + await mkdir(join(repo.dir, '.git', 'refs', 'remotes', 'origin'), { recursive: true }) + await writeFile(join(repo.dir, '.git', 'refs', 'remotes', 'origin', 'main'), 'a'.repeat(40) + '\n') + const base: Platform = { ...createNodePlatform(), scheduler: new RecordingScheduler() } + for (const platform of [base, { ...base, git: realGitClient(gitRunner) }]) { + const host = await bootSession({ platform, workspaceDir: repo.dir, gitStatus: { baseBranch: 'origin/main', missingBase: 'all' } }) + expect((await pull(host.session)).snapshot).toBeNull() + expect(seen(host.notifications)).toEqual([]) + } + }) + test('a published baseline stays ahead after local main advances, then clears when publication catches up', async () => { const repo = await makeRepo() await gitRunner.execFile('git', ['update-ref', 'refs/remotes/origin/main', 'main'], { cwd: repo.dir }) diff --git a/packages/sdk/src/plugins/git-status/plugin.ts b/packages/sdk/src/plugins/git-status/plugin.ts index 659a3fd5..15ed820f 100644 --- a/packages/sdk/src/plugins/git-status/plugin.ts +++ b/packages/sdk/src/plugins/git-status/plugin.ts @@ -78,6 +78,8 @@ interface GitStatusPluginContext { export interface GitStatusPluginConfig { /** Compare against this ref instead of the detected local default branch, e.g. origin/main. */ baseBranch?: string + /** Treat a missing baseline as an unpublished branch; by default no snapshot is emitted. */ + missingBase?: 'all' } /** @@ -167,8 +169,8 @@ async function refresh(ctx: GitStatusCallContext): Promise { +async function computeGitStatusOverPort(git: GitClient, workdir: string, baseBranch: string, missingBase?: 'all'): Promise { try { const [committedAhead, commits, status] = await Promise.all([ - git.countAhead({ dir: workdir, base: baseBranch }), + git.countAhead({ dir: workdir, base: baseBranch, missingBase }), git.log({ dir: workdir, depth: 1 }), git.status({ dir: workdir }), ]) @@ -243,8 +245,11 @@ async function detectDefaultBranchOverPort(git: GitClient, workdir: string): Pro } } -async function computeGitStatus(process: ProcessRunner, workdir: string, baseBranch: string): Promise { - const countOutput = await runGit(process, workdir, ['rev-list', '--count', `${baseBranch}..HEAD`]) +async function computeGitStatus(process: ProcessRunner, workdir: string, baseBranch: string, missingBase?: 'all'): Promise { + let countOutput = await runGit(process, workdir, ['rev-list', '--count', `${baseBranch}..HEAD`]) + if (countOutput === null && missingBase === 'all' && await isMissingRef(process, workdir, baseBranch)) { + countOutput = await runGit(process, workdir, ['rev-list', '--count', 'HEAD']) + } if (countOutput === null) return null const committedAhead = Number.parseInt(countOutput.trim(), 10) if (!Number.isFinite(committedAhead)) return null @@ -269,6 +274,16 @@ async function computeGitStatus(process: ProcessRunner, workdir: string, baseBra return { committedAhead, uncommittedFiles, lastCommitAt, lastCommitMessage } } +async function isMissingRef(process: ProcessRunner, workdir: string, ref: string): Promise { + try { + await process.execFile('git', ['rev-parse', '--verify', '--quiet', ref], { cwd: workdir, timeout: GIT_TIMEOUT_MS }) + return false + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 1) return true + throw error + } +} + async function detectDefaultBranch(process: ProcessRunner, workdir: string): Promise { const output = await runGit(process, workdir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']) if (output === null) return null diff --git a/packages/sdk/src/testing/conformance-violations.test.ts b/packages/sdk/src/testing/conformance-violations.test.ts index 82c007e6..b046b91e 100644 --- a/packages/sdk/src/testing/conformance-violations.test.ts +++ b/packages/sdk/src/testing/conformance-violations.test.ts @@ -220,8 +220,17 @@ function processGitClient(platform: Platform): GitClient { }) }, - async countAhead({ dir, base, ref }) { - return Number.parseInt((await run(dir, ['rev-list', '--count', `${base}..${ref ?? 'HEAD'}`])).trim(), 10) + async countAhead({ dir, base, ref, missingBase }) { + let revision = `${base}..${ref ?? 'HEAD'}` + if (missingBase === 'all') { + try { + await run(dir, ['rev-parse', '--verify', '--quiet', base]) + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 1)) throw error + revision = ref ?? 'HEAD' + } + } + return Number.parseInt((await run(dir, ['rev-list', '--count', revision])).trim(), 10) }, async defaultBranch({ dir }) { diff --git a/packages/sdk/src/testing/conformance.ts b/packages/sdk/src/testing/conformance.ts index 34ceee48..713aad27 100644 --- a/packages/sdk/src/testing/conformance.ts +++ b/packages/sdk/src/testing/conformance.ts @@ -1258,6 +1258,9 @@ const gitChecks: ConformanceCheck[] = [ expect(await platform.git?.countAhead({ dir, base: GIT_FIXTURE.base })).toBe(1) expect(await platform.git?.countAhead({ dir, base: GIT_FIXTURE.branch })).toBe(0) expect(await platform.git?.countAhead({ dir, base: GIT_FIXTURE.base, ref: GIT_FIXTURE.base })).toBe(0) + const all = await platform.git?.log({ dir }) + expect(await platform.git?.countAhead({ dir, base: 'refs/heads/not-published', missingBase: 'all' })).toBe(all?.length) + expect(await platform.git?.countAhead({ dir, base: GIT_FIXTURE.base, missingBase: 'all' })).toBe(1) }, }, {