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
20 changes: 20 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import {
getCommitContextsBetweenShas,
getCurrentGitInfo,
getRemoteUrl,
isAncestor,
resolveCommitRef,
verifyAncestorReachable,
} from "./git";
import {
assertBaseRefIsAncestor,
evaluateScanRangeSize,
findAnchorAheadOfHead,
getBroadScanWarning,
ScanBase,
selectAutomaticScanBase,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)}`,
Expand Down Expand Up @@ -618,6 +636,7 @@ async function syncRelease(
releaseLinks: ReleaseLink[],
releaseDocuments: ReleaseDocument[],
releaseNotesValue: ReleaseNotes | undefined,
options: { preserveStoredCommitSha: boolean },
): Promise<Release> {
const currentSha = await getCurrentGitInfo().commit;
if (!currentSha) {
Expand Down Expand Up @@ -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,
Expand Down
87 changes: 87 additions & 0 deletions src/scan-base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
assertBaseRefIsAncestor,
BROAD_SCAN_COMMIT_THRESHOLD,
evaluateScanRangeSize,
findAnchorAheadOfHead,
getBroadScanWarning,
SCAN_COMMIT_HARD_LIMIT,
type ScanBase,
Expand Down Expand Up @@ -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);
});
});
45 changes: 45 additions & 0 deletions src/scan-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading