Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/platform/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}

/**
Expand Down
41 changes: 39 additions & 2 deletions packages/sdk/src/plugins/git-status/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -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 })
Expand Down
27 changes: 21 additions & 6 deletions packages/sdk/src/plugins/git-status/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}

/**
Expand Down Expand Up @@ -167,8 +169,8 @@ async function refresh(ctx: GitStatusCallContext): Promise<GitStatusSnapshot | n
}

const snapshot = git
? await computeGitStatusOverPort(git, workdir, baseBranch)
: await computeGitStatus(processRunner, workdir, baseBranch)
? await computeGitStatusOverPort(git, workdir, baseBranch, ctx.pluginConfig?.missingBase)
: await computeGitStatus(processRunner, workdir, baseBranch, ctx.pluginConfig?.missingBase)
if (!snapshot) {
ctx.logger.warn('git-status: snapshot failed', { sessionId, workdir, baseBranch })
return null
Expand Down Expand Up @@ -214,10 +216,10 @@ function snapshotsEqual(a: GitStatusSnapshot, b: GitStatusSnapshot): boolean {
&& a.lastCommitMessage === b.lastCommitMessage
}

async function computeGitStatusOverPort(git: GitClient, workdir: string, baseBranch: string): Promise<GitStatusSnapshot | null> {
async function computeGitStatusOverPort(git: GitClient, workdir: string, baseBranch: string, missingBase?: 'all'): Promise<GitStatusSnapshot | null> {
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 }),
])
Expand All @@ -243,8 +245,11 @@ async function detectDefaultBranchOverPort(git: GitClient, workdir: string): Pro
}
}

async function computeGitStatus(process: ProcessRunner, workdir: string, baseBranch: string): Promise<GitStatusSnapshot | null> {
const countOutput = await runGit(process, workdir, ['rev-list', '--count', `${baseBranch}..HEAD`])
async function computeGitStatus(process: ProcessRunner, workdir: string, baseBranch: string, missingBase?: 'all'): Promise<GitStatusSnapshot | null> {
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
Expand All @@ -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<boolean> {
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<string | null> {
const output = await runGit(process, workdir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
if (output === null) return null
Expand Down
13 changes: 11 additions & 2 deletions packages/sdk/src/testing/conformance-violations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/src/testing/conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
},
{
Expand Down
Loading