diff --git a/src/index.ts b/src/index.ts index c903b7a..be06b98 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,12 +7,14 @@ import { getCommitContextsBetweenShas, getCurrentGitInfo, getRemoteUrl, + isAncestor, resolveCommitRef, verifyAncestorReachable, } from "./git"; import { assertBaseRefIsAncestor, evaluateScanRangeSize, + findAnchorAheadOfHead, getBroadScanWarning, ScanBase, selectAutomaticScanBase, @@ -292,6 +294,21 @@ async function syncCommand(): Promise<{ } const recentReleases = await getRecentReleases(); + + // Forward-only anchor: a rolled-back HEAD must not rewind a release's stored commit, or the + // next sync would re-scan (and re-attach) work that already shipped. + const anchorAheadOfHead = findAnchorAheadOfHead( + recentReleases, + currentCommit.commit, + { isAncestor, verifyAncestorReachable }, + releaseVersion, + ); + if (anchorAheadOfHead) { + info( + `HEAD ${currentCommit.commit.slice(0, 7)} is behind the stored baseline ${anchorAheadOfHead.slice(0, 7)}; leaving the release commit unchanged`, + ); + } + const scanBase = getScanBase(recentReleases, currentCommit.commit, releaseVersion); let latestSha = scanBase.sha; let inspectingOnlyCurrentCommit = false; @@ -416,6 +433,7 @@ async function syncCommand(): Promise<{ links, documents, releaseNotes, + { preserveStoredCommitSha: Boolean(anchorAheadOfHead) }, ); info( `Synced to release ${release.name} (${formatVersion(release)}): ${scanned}${formatLinkSummary(links)}${formatDocumentsSummary(documents)}${formatReleaseNotesSummary(releaseNotes)}`, @@ -618,6 +636,7 @@ async function syncRelease( releaseLinks: ReleaseLink[], releaseDocuments: ReleaseDocument[], releaseNotesValue: ReleaseNotes | undefined, + options: { preserveStoredCommitSha: boolean }, ): Promise { const currentSha = await getCurrentGitInfo().commit; if (!currentSha) { @@ -651,6 +670,7 @@ async function syncRelease( name: releaseName, version: releaseVersion, commitSha: currentSha, + preserveStoredCommitSha: options.preserveStoredCommitSha, issueReferences, revertedIssueReferences: revertedIssueReferences.length > 0 ? revertedIssueReferences : undefined, links: releaseLinks.length > 0 ? releaseLinks : undefined, diff --git a/src/scan-base.test.ts b/src/scan-base.test.ts index fc61740..cacab2c 100644 --- a/src/scan-base.test.ts +++ b/src/scan-base.test.ts @@ -9,6 +9,7 @@ import { assertBaseRefIsAncestor, BROAD_SCAN_COMMIT_THRESHOLD, evaluateScanRangeSize, + findAnchorAheadOfHead, getBroadScanWarning, SCAN_COMMIT_HARD_LIMIT, type ScanBase, @@ -428,3 +429,89 @@ describe("evaluateScanRangeSize", () => { expect(evaluateScanRangeSize(null, releaseBase)).toEqual({ degradeToCurrentCommit: false }); }); }); + +describe("findAnchorAheadOfHead", () => { + const anchor = "a".repeat(40); + const head = "b".repeat(40); + + function release(commitSha?: string): Release { + return { id: "release-id", name: "release", createdAt: "2026-01-01T00:00:00.000Z", commitSha }; + } + + function deps(overrides: { isAncestor?: boolean; verifyAncestorReachable?: boolean }) { + return { + isAncestor: vi.fn().mockReturnValue(overrides.isAncestor ?? false), + verifyAncestorReachable: vi.fn().mockReturnValue(overrides.verifyAncestorReachable ?? false), + }; + } + + it("returns the anchor when HEAD is strictly behind it", () => { + const d = deps({ isAncestor: false, verifyAncestorReachable: true }); + expect(findAnchorAheadOfHead([release(anchor)], head, d)).toBe(anchor); + expect(d.isAncestor).toHaveBeenCalledWith(anchor, head); + expect(d.verifyAncestorReachable).toHaveBeenCalledWith(head, anchor); + }); + + it("returns undefined when the anchor is an ancestor of HEAD, without the reachability walk", () => { + const d = deps({ isAncestor: true }); + expect(findAnchorAheadOfHead([release(anchor)], head, d)).toBeUndefined(); + expect(d.verifyAncestorReachable).not.toHaveBeenCalled(); + }); + + it("returns undefined when HEAD equals the anchor", () => { + const d = deps({}); + expect(findAnchorAheadOfHead([release(head)], head, d)).toBeUndefined(); + expect(d.isAncestor).not.toHaveBeenCalled(); + }); + + it("returns undefined when ancestry cannot be established in either direction", () => { + expect(findAnchorAheadOfHead([release(anchor)], head, deps({}))).toBeUndefined(); + }); + + it("returns undefined when no candidate carries a commit SHA", () => { + expect(findAnchorAheadOfHead([release(undefined), release(undefined)], head, deps({}))).toBeUndefined(); + }); + + it("compares against the first candidate with a commit SHA", () => { + const older = "c".repeat(40); + const d = deps({ isAncestor: false, verifyAncestorReachable: true }); + expect(findAnchorAheadOfHead([release(undefined), release(anchor), release(older)], head, d)).toBe(anchor); + expect(d.isAncestor).toHaveBeenCalledWith(anchor, head); + }); + + it("prioritizes the syncing version's stored commit over a more recent release from another train", () => { + // Divergent hotfix release ranks first by activity; ancestry to it cannot be established, + // but HEAD is strictly behind the syncing version's own stored commit. + const hotfix = "d".repeat(40); + const d = { + isAncestor: vi.fn().mockReturnValue(false), + verifyAncestorReachable: vi.fn((sha: string, headSha: string) => headSha === anchor), + }; + const candidates = [ + { ...release(hotfix), version: "hotfix-1" }, + { ...release(anchor), version: "v1.2.3" }, + ]; + expect(findAnchorAheadOfHead(candidates, head, d, "v1.2.3")).toBe(anchor); + expect(d.isAncestor).toHaveBeenCalledTimes(1); + expect(d.isAncestor).toHaveBeenCalledWith(anchor, head); + }); + + it("lets the syncing version's release decide alone when HEAD is at or ahead of it", () => { + const newer = "d".repeat(40); + const d = { + isAncestor: vi.fn((sha: string) => sha === anchor), + verifyAncestorReachable: vi.fn().mockReturnValue(true), + }; + const candidates = [ + { ...release(newer), version: "other" }, + { ...release(anchor), version: "v1.2.3" }, + ]; + expect(findAnchorAheadOfHead(candidates, head, d, "v1.2.3")).toBeUndefined(); + expect(d.verifyAncestorReachable).not.toHaveBeenCalled(); + }); + + it("falls back to the newest anchor when no candidate carries the syncing version", () => { + const d = deps({ isAncestor: false, verifyAncestorReachable: true }); + expect(findAnchorAheadOfHead([release(anchor)], head, d, "v9.9.9")).toBe(anchor); + }); +}); diff --git a/src/scan-base.ts b/src/scan-base.ts index 8bff0ad..b2e4fa5 100644 --- a/src/scan-base.ts +++ b/src/scan-base.ts @@ -79,6 +79,51 @@ export function selectAutomaticScanBase( }; } +export type AnchorComparisonDeps = FindBaseShaDeps & { + isAncestor: (sha: string, headSha: string) => boolean; +}; + +/** + * Returns the stored release anchor when the current HEAD is strictly behind it — the rollback + * case, where syncing HEAD as the release commit would rewind the pipeline's scan baseline and + * make the next sync re-scan (and re-attach) work that already shipped. When a syncing version is + * supplied and a candidate carries it, that release's commit alone decides, since it is the one + * the sync would overwrite; the candidate list is ordered by activity, so a release from another + * train can sit above it. Otherwise the newest stored anchor is used. Returns undefined when HEAD + * is at or ahead of the anchor, when no candidate carries a commit SHA, or when ancestry cannot + * be established (unrelated history, unreachable anchor). + */ +export function findAnchorAheadOfHead( + candidates: Release[], + headSha: string, + deps: AnchorComparisonDeps, + syncingVersion?: string, +): string | undefined { + if (syncingVersion !== undefined) { + const versionedSha = candidates.find( + (candidate) => candidate.version === syncingVersion && candidate.commitSha, + )?.commitSha; + if (versionedSha) { + return anchorIfAheadOfHead(versionedSha, headSha, deps); + } + } + + const anchorSha = candidates.find((candidate) => candidate.commitSha)?.commitSha; + return anchorSha ? anchorIfAheadOfHead(anchorSha, headSha, deps) : undefined; +} + +function anchorIfAheadOfHead(anchorSha: string, headSha: string, deps: AnchorComparisonDeps): string | undefined { + if (anchorSha === headSha) { + return undefined; + } + // Cheap forward check first: an anchor that is an ancestor of HEAD means HEAD is at or ahead of + // it — the common case, answered without deepening a shallow clone. + if (deps.isAncestor(anchorSha, headSha)) { + return undefined; + } + return deps.verifyAncestorReachable(headSha, anchorSha) ? anchorSha : undefined; +} + export function assertBaseRefIsAncestor( baseRef: string, resolvedSha: string,