From 74c8e1f9ceabbfd0a81bfd7d9867e6d9771d4c5e Mon Sep 17 00:00:00 2001 From: NiftyAndy Date: Sun, 2 Aug 2026 15:17:19 -0400 Subject: [PATCH] fix(reconcile): classify final tree deltas --- src/commands/release.mjs | 14 +++++++++++++- src/lib/release-policy.mjs | 34 +++++++++++++++++++++++++++++++--- test/cli.test.mjs | 30 +++++++++++++++++++++++++++++- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/commands/release.mjs b/src/commands/release.mjs index 1df36a2..20239fc 100644 --- a/src/commands/release.mjs +++ b/src/commands/release.mjs @@ -83,14 +83,16 @@ function resolveReconciliationState(target, base, head, allowed, requireRemote) try { const mainOnlyCommits = divergentCommits(target, stagingSha, mainSha) const stagingOnlyCommits = divergentCommits(target, mainSha, stagingSha) + const directChangedPaths = treeChangedPaths(target, stagingSha, mainSha) const plan = classifyReconciliation({ mainSha, stagingSha, mainOnlyCommits, stagingOnlyCommits, + directChangedPaths, allowed, }) - return { plan, mainSha, stagingSha, mainOnlyCommits, stagingOnlyCommits } + return { plan, mainSha, stagingSha, mainOnlyCommits, stagingOnlyCommits, directChangedPaths } } catch (error) { const message = error instanceof Error ? error.message : String(error) return { @@ -102,6 +104,7 @@ function resolveReconciliationState(target, base, head, allowed, requireRemote) stagingSha, mainOnlyCommits: [], stagingOnlyCommits: [], + directChangedPaths: [], } } } @@ -570,6 +573,15 @@ function commitChangedPaths(root, commitSha) { return result.stdout.split(/\r?\n/).filter(Boolean) } +/** @param {string} root @param {string} from @param {string} to @returns {string[]} */ +function treeChangedPaths(root, from, to) { + const result = spawnSync('git', ['diff', '--name-only', from, to], { cwd: root, encoding: 'utf8' }) + if (result.status !== 0) { + throw new Error(`Failed to compare branch trees between ${from} and ${to}. ${result.stderr?.trim() || result.stdout?.trim()}`) + } + return result.stdout.split(/\r?\n/).filter(Boolean) +} + /** @param {string} root @param {string[]} args @returns {{ status: number | null, stdout: string, stderr: string }} */ function ghSpawn(root, args) { const token = process.env.RELEASE_PLEASE_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN diff --git a/src/lib/release-policy.mjs b/src/lib/release-policy.mjs index 44ef45c..28c9e74 100644 --- a/src/lib/release-policy.mjs +++ b/src/lib/release-policy.mjs @@ -217,12 +217,16 @@ function compareVersions(a, b) { } /** - * Classify the relationship between main and staging using commit-level divergence. - * Any non-release main-only commit fails. Any staging-only commit triggers a replay - * path so no staging-only work can be silently discarded. + * Classify the relationship between main and staging using the final tree delta + * when available. Historical commit paths can include equivalent workflow or + * configuration changes that are no longer present in the branch delta, so the + * final trees are the source of truth for release-only reconciliation. Any + * staging-only commit still triggers a replay path so no staging-only work can + * be silently discarded. * @param {{ * mainSha: string, * stagingSha: string, + * directChangedPaths?: string[], * mainOnlyCommits?: Array<{ sha?: string, changedPaths?: string[] }>, * stagingOnlyCommits?: Array<{ sha?: string, changedPaths?: string[] }>, * allowed?: Set, @@ -232,6 +236,7 @@ export function classifyReconciliation(input) { const { mainSha, stagingSha, + directChangedPaths, mainOnlyCommits = [], stagingOnlyCommits = [], allowed = approvedReleaseFiles(), @@ -248,6 +253,29 @@ export function classifyReconciliation(input) { action: 'fail', reason: 'Unable to inspect staging-only commit metadata.', } + if (Array.isArray(directChangedPaths)) { + const paths = [...new Set(directChangedPaths.map((path) => path.trim()).filter(Boolean))] + const unexpected = unexpectedReleasePaths(paths, allowed) + const stagingOnlyReleaseOnly = stagingOnlyCommits.length > 0 && stagingOnlyCommits.every((commit) => + Array.isArray(commit.changedPaths) && commit.changedPaths.length > 0 && unexpectedReleasePaths(commit.changedPaths, allowed).length === 0, + ) + if (!unexpected.length && !stagingOnlyReleaseOnly) { + return { + action: 'fast-forward', + targetSha: mainSha, + reason: paths.length + ? 'Branches differ only by approved release metadata.' + : 'Branches have different history but identical content.', + } + } + if (!stagingOnlyCommits.length) { + return { + action: 'fail', + reason: 'branch content differs outside release metadata.', + unexpected, + } + } + } const unexpectedMain = unexpectedReleasePaths( [...new Set(mainOnlyCommits.flatMap((commit) => commit.changedPaths || []))], allowed, diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 301952b..a48e744 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -1051,6 +1051,34 @@ describe('code-foundry CLI', () => { ) }) + it('classifies historical workflow commits by their final tree delta', () => { + assert.deepEqual( + classifyReconciliation({ + mainSha: 'main', + stagingSha: 'staging', + directChangedPaths: ['CHANGELOG.md', 'Cargo.toml', 'Cargo.lock'], + mainOnlyCommits: [{ sha: 'workflow-main', changedPaths: ['.github/workflows/release.yml'] }], + stagingOnlyCommits: [{ sha: 'workflow-staging', changedPaths: ['.github/workflows/release.yml'] }], + allowed: approvedReleaseFiles(), + }), + { action: 'fast-forward', targetSha: 'main', reason: 'Branches differ only by approved release metadata.' }, + ) + }) + + it('fails closed when the final tree delta contains an unexpected path', () => { + assert.deepEqual( + classifyReconciliation({ + mainSha: 'main', + stagingSha: 'staging', + directChangedPaths: ['CHANGELOG.md', 'src/index.ts'], + mainOnlyCommits: [{ sha: 'main-code', changedPaths: ['src/index.ts'] }], + stagingOnlyCommits: [], + allowed: approvedReleaseFiles(), + }), + { action: 'fail', reason: 'branch content differs outside release metadata.', unexpected: ['src/index.ts'] }, + ) + }) + it('recommends replaying staging-only work even when it touches release-only files', () => { const allowed = approvedReleaseFiles() assert.deepEqual( @@ -1118,7 +1146,7 @@ describe('code-foundry CLI', () => { assert.throws( () => reconcileRelease(root, { github: false, dryRun: false, base: 'main', head: 'staging' }), - /main contains commits that are not release metadata\. Unexpected paths: src\/index.ts/, + /branch content differs outside release metadata\. Unexpected paths: src\/index.ts/, ) rmSync(root, { recursive: true, force: true }) rmSync(remote, { recursive: true, force: true })