diff --git a/src/__tests__/e2e/workflow-records-reports-branch-1083.test.ts b/src/__tests__/e2e/workflow-records-reports-branch-1083.test.ts index 5aa3648f6..b1ec8995e 100644 --- a/src/__tests__/e2e/workflow-records-reports-branch-1083.test.ts +++ b/src/__tests__/e2e/workflow-records-reports-branch-1083.test.ts @@ -113,8 +113,10 @@ describe('#1083 workflow run records reach the reports branch', () => { expect(startRecord.username).toBe('alice'); expect(startRecord.sourceHash).toBe(sourceHash); expect(startRecord.teamRevision).toBe(teamRevision); - expect(() => git(repo, 'cat-file', '-e', teamRevision)).not.toThrow(); - expect(git(repo, 'show', `${teamRevision}:workflows/build.yaml`)).toBe(TWO_STEPS); + git(sandbox, 'clone', 'origin.git', 'fresh'); + const fresh = path.join(sandbox, 'fresh'); + expect(() => git(fresh, 'cat-file', '-e', teamRevision)).not.toThrow(); + expect(git(fresh, 'show', `${teamRevision}:workflows/build.yaml`)).toBe(TWO_STEPS); const done = await runLocalDriver(config, runId, work, { tools: { default: 'claude' }, diff --git a/src/__tests__/workflow-cache.test.ts b/src/__tests__/workflow-cache.test.ts index 028337617..4396dd61d 100644 --- a/src/__tests__/workflow-cache.test.ts +++ b/src/__tests__/workflow-cache.test.ts @@ -128,6 +128,46 @@ describe('workflow cache', () => { expect((await loadCachedWorkflow(config, 'build'))?.teamRevision).toBe(localRevision); }); + it('keeps a verified revision when an unrelated unpushed commit moves HEAD', async () => { + const config = testConfig(); + const repository = initializeTeamRepository(config); + writeWorkflow(config, 'build', VALID); + const pushed = repository.commit('add workflow'); + repository.push(); + await syncWorkflowDefinitions(config); + + fs.writeFileSync(path.join(config.repo.localPath, 'README.md'), 'unrelated\n'); + repository.commit('unrelated local commit'); + await syncWorkflowDefinitions(config); + + expect((await loadCachedWorkflow(config, 'build'))?.teamRevision).toBe(pushed); + const started = await startRun(config, 'build'); + if ('error' in started) throw new Error(started.error.message); + const run = await readRun(config, String(started.data.run)); + if (typeof run === 'string') throw new Error(`Could not read run: ${run}`); + expect(run.teamRevision).toBe(pushed); + }); + + it('drops an unchanged workflow revision that the team remote no longer reaches', async () => { + const config = testConfig(); + const repository = initializeTeamRepository(config); + writeWorkflow(config, 'build', VALID); + const pushed = repository.commit('add workflow'); + repository.push(); + await syncWorkflowDefinitions(config); + expect((await loadCachedWorkflow(config, 'build'))?.teamRevision).toBe(pushed); + + const tree = git(config.repo.localPath, 'write-tree'); + const rewritten = git(config.repo.localPath, 'commit-tree', tree, '-m', 'rewrite team history'); + git(config.repo.localPath, 'checkout', '-B', 'main', rewritten); + git(config.repo.localPath, 'push', '--force', '--set-upstream', 'origin', 'main'); + fs.writeFileSync(path.join(config.repo.localPath, 'README.md'), 'unrelated\n'); + repository.commit('unrelated local commit'); + await syncWorkflowDefinitions(config); + + expect((await loadCachedWorkflow(config, 'build'))?.teamRevision).toBeUndefined(); + }); + it('omits the team revision for an untracked workflow at a remote-reachable commit', async () => { const config = testConfig(); const repository = initializeTeamRepository(config); @@ -248,6 +288,26 @@ steps: expect(mixedRun.teamRevision).toBeUndefined(); }); + it('records the earlier team revision on a run of a kept workflow', async () => { + const config = testConfig(); + const repository = initializeTeamRepository(config); + writeWorkflow(config, 'build', VALID); + const pushed = repository.commit('add workflow'); + repository.push(); + await syncWorkflowDefinitions(config); + + writeWorkflow(config, 'build', 'schemaVersion: [\n'); + repository.commit('break workflow'); + repository.push(); + expect((await syncWorkflowDefinitions(config)).kept).toEqual(['build']); + + const started = await startRun(config, 'build'); + if ('error' in started) throw new Error(started.error.message); + const run = await readRun(config, String(started.data.run)); + if (typeof run === 'string') throw new Error(`Could not read run: ${run}`); + expect(run.teamRevision).toBe(pushed); + }); + it('clears a kept workflow revision that the team remote no longer reaches', async () => { const config = testConfig(); const repository = initializeTeamRepository(config); diff --git a/src/workflow/cache.ts b/src/workflow/cache.ts index 78c828840..07dd02208 100644 --- a/src/workflow/cache.ts +++ b/src/workflow/cache.ts @@ -15,7 +15,7 @@ import { formatDiagnostic, parseWorkflow, promptFileDiagnostics, stepField } fro import type { WorkflowFiles } from './parse-context.js'; import type { CachedWorkflow, WorkflowDefinition, WorkflowDiagnostic, WorkflowStep } from './types.js'; import { assertSafePath, assertWithinRoot } from '../utils/path-safety.js'; -import { createGit } from '../utils/git.js'; +import { createGit, getHeadCommit } from '../utils/git.js'; export interface SyncWorkflowOptions { dryRun?: boolean; @@ -136,11 +136,10 @@ async function isTeamRemoteRevision(root: string, revision: string): Promise { try { - const git = createGit(root); - const revision = (await git.revparse(['HEAD'])).trim(); - if (!await isTeamRemoteRevision(root, revision)) return undefined; + const revision = await getHeadCommit(root); + if (!revision || !await isTeamRemoteRevision(root, revision)) return undefined; return { - repositoryRoot: await fs.realpath((await git.revparse(['--show-toplevel'])).trim()), + repositoryRoot: await fs.realpath((await createGit(root).revparse(['--show-toplevel'])).trim()), revision, }; } catch { @@ -269,13 +268,15 @@ export async function syncWorkflowDefinitions( reject(file, problems); continue; } - const previous = await readCache(config, file.definition.name); + const previous = kept.get(file.stem); result.cached.push(file.definition.name); - if (options.dryRun || (previous?.sourceHash === file.sourceHash && previous.teamRevision === file.teamRevision)) continue; + // Equal bytes are still stored at the earlier entry's revision; the loop that filled `kept` dropped it if origin no longer reaches it. + const teamRevision = file.teamRevision ?? (previous?.sourceHash === file.sourceHash ? previous.teamRevision : undefined); + if (options.dryRun || (previous?.sourceHash === file.sourceHash && previous.teamRevision === teamRevision)) continue; await writeJsonAtomic(cachePath(config, file.definition.name), { relativePath: file.relativePath, sourceHash: file.sourceHash, - ...(file.teamRevision ? { teamRevision: file.teamRevision } : {}), + ...(teamRevision ? { teamRevision } : {}), content: file.content, definition: file.definition, validatedAt: new Date().toISOString(),