From 8adea16f9ea1580da9a1fb702a8c85e2b99b78fc Mon Sep 17 00:00:00 2001 From: NiftyAndy Date: Mon, 3 Aug 2026 09:20:43 -0400 Subject: [PATCH] fix(reconcile): ignore historical paths absent from final tree --- src/lib/release-policy.mjs | 22 +++++++++++--- test/cli.test.mjs | 61 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/lib/release-policy.mjs b/src/lib/release-policy.mjs index e37605d..92c9277 100644 --- a/src/lib/release-policy.mjs +++ b/src/lib/release-policy.mjs @@ -220,9 +220,14 @@ function compareVersions(a, b) { * 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. + * final trees are the source of truth for release-only reconciliation. When + * directChangedPaths is supplied, unexpected paths in historical main-only + * commits are ignored only when they are absent from the final tree delta + * (their content is already identical in both branches); unexpected main-only + * paths that still differ between the final trees fail closed. Without + * directChangedPaths the historical check stays strict. Any staging-only + * commit still triggers a replay path so no staging-only work can be silently + * discarded. * @param {{ * mainSha: string, * stagingSha: string, @@ -282,10 +287,19 @@ export function classifyReconciliation(input) { } } } + // Historical main-only commits may list paths whose content is already + // identical in the final staging tree (for example an equivalent workflow + // change). When the final tree delta is available, such paths are not + // genuine content differences and are ignored unless they also appear in + // the direct tree delta; without directChangedPaths the strict historical + // check is preserved unchanged. + const directTreePaths = Array.isArray(directChangedPaths) + ? new Set(directChangedPaths.map((path) => path.trim()).filter(Boolean)) + : null const unexpectedMain = unexpectedReleasePaths( [...new Set(mainOnlyCommits.flatMap((commit) => commit.changedPaths || []))], allowed, - ) + ).filter((path) => directTreePaths === null || directTreePaths.has(path)) if (unexpectedMain.length) { return { action: 'fail', diff --git a/test/cli.test.mjs b/test/cli.test.mjs index d286904..92f6a9f 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -1123,6 +1123,67 @@ describe('code-foundry CLI', () => { ) }) + it('rebase-staging historical main-only paths absent from the final tree delta', () => { + const allowed = approvedReleaseFiles() + assert.deepEqual( + classifyReconciliation({ + mainSha: 'main', + stagingSha: 'staging', + directChangedPaths: ['CHANGELOG.md', 'src/index.ts'], + mainOnlyCommits: [ + { sha: 'release-commit', changedPaths: ['CHANGELOG.md', 'package.json'] }, + { sha: 'historical-workflow', changedPaths: ['.github/workflows/release.yml'] }, + ], + stagingOnlyCommits: [{ sha: 'staging-feature', changedPaths: ['src/index.ts'] }], + allowed, + }), + { + action: 'rebase-staging', + targetSha: 'main', + mainOnly: ['release-commit', 'historical-workflow'], + stagingOnly: ['staging-feature'], + reason: 'staging contains unpromoted commits; replay them onto main.', + }, + ) + }) + + it('fails when a historical main-only unexpected path still differs in the final tree delta', () => { + const allowed = approvedReleaseFiles() + assert.deepEqual( + classifyReconciliation({ + mainSha: 'main', + stagingSha: 'staging', + directChangedPaths: ['CHANGELOG.md', '.github/workflows/release.yml'], + mainOnlyCommits: [ + { sha: 'main-workflow', changedPaths: ['.github/workflows/release.yml'] }, + ], + stagingOnlyCommits: [{ sha: 'staging-feature', changedPaths: ['src/index.ts'] }], + allowed, + }), + { + action: 'fail', + reason: 'main contains commits that are not release metadata.', + unexpected: ['.github/workflows/release.yml'], + }, + ) + }) + + it('keeps historical main-only path rejection strict without directChangedPaths', () => { + const allowed = approvedReleaseFiles() + assert.equal( + classifyReconciliation({ + mainSha: 'main', + stagingSha: 'staging', + mainOnlyCommits: [ + { sha: 'main-workflow', changedPaths: ['.github/workflows/release.yml'] }, + ], + stagingOnlyCommits: [{ sha: 'staging-feature', changedPaths: ['src/index.ts'] }], + allowed, + }).action, + 'fail', + ) + }) + it('surfaces exact reconciliation failure reason from local classification', () => { const { root, remote, run } = createReconcileWorkspace() const commit = (message) => {