From 9f50f1e016437a430f3d2813ec0d03eef6fe0abf Mon Sep 17 00:00:00 2001 From: Romain Cascino Date: Thu, 7 May 2026 16:57:54 +0100 Subject: [PATCH 1/3] Add isAncestor helper Wraps `git merge-base --is-ancestor` for the upcoming base-SHA walk. A candidate SHA on a side branch isn't reachable from the main-train HEAD, so the walk needs this primitive to skip it. --- src/git.test.ts | 24 ++++++++++++++++++++++++ src/git.ts | 19 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/git.test.ts b/src/git.test.ts index 80e4a74..3215d3b 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -13,6 +13,7 @@ import { getCommitContextsBetweenShas, getCommitParents, getRepoInfo, + isAncestor, normalizePathspec, parseRepoUrl, resolveFirstSyncBoundary, @@ -911,6 +912,29 @@ describe("merge commit handling", () => { }); }); + describe("isAncestor", () => { + it("returns true when sha is an ancestor of headSha", () => { + expect(isAncestor(mergeRepo.commits.base, mergeRepo.commits.mergeCommit, mergeRepo.cwd)).toBe(true); + }); + + it("returns true for a sha equal to headSha", () => { + expect(isAncestor(mergeRepo.commits.mergeCommit, mergeRepo.commits.mergeCommit, mergeRepo.cwd)).toBe(true); + }); + + it("returns false when sha is not on headSha's history", () => { + // featureBranch is reachable from mergeCommit (parent #2), but mergeCommit + // is not reachable from featureBranch — that's the asymmetric case the + // walk relies on to skip side-branch candidates. + expect(isAncestor(mergeRepo.commits.mergeCommit, mergeRepo.commits.featureBranch, mergeRepo.cwd)).toBe(false); + }); + + it("returns false for an unknown sha", () => { + expect(isAncestor("0000000000000000000000000000000000000000", mergeRepo.commits.mergeCommit, mergeRepo.cwd)).toBe( + false, + ); + }); + }); + describe("getCommitContextsBetweenShas with merge commits", () => { it("should include merge commit when path filtering would exclude it", () => { // The merge node itself adds no file changes, so default simplification diff --git a/src/git.ts b/src/git.ts index 710965d..6b1be0b 100644 --- a/src/git.ts +++ b/src/git.ts @@ -165,6 +165,25 @@ export function commitExists(sha: string, cwd: string = process.cwd()): boolean } } +/** + * True iff `sha` is reachable by walking parents from `headSha`. + * + * Used to verify that a candidate base SHA is actually on HEAD's history before + * we hand it to `git log ..` — a candidate from a side branch (e.g. + * a hotfix release) will scan a wrong range otherwise. + */ +export function isAncestor(sha: string, headSha: string, cwd: string = process.cwd()): boolean { + try { + execSync(`git merge-base --is-ancestor ${sha} ${headSha}`, { + cwd, + stdio: ["ignore", "ignore", "ignore"], + }); + return true; + } catch { + return false; + } +} + const SHA_PATTERN = /^[0-9a-f]{7,40}$/i; /** From 31c4ec179bdb13c81fe4d8b8578d0c2719c2aee6 Mon Sep 17 00:00:00 2001 From: Romain Cascino Date: Thu, 7 May 2026 16:58:02 +0100 Subject: [PATCH 2/3] Add findBaseSha walk with scenario tests Pure helper that picks a base SHA from a list of candidate releases by asking git which one is reachable from HEAD. Tests cover the four bug shapes (concurrent trains, mirror, all-non-ancestors, null commitSha) plus baseline + first-sync, and an end-to-end pairing with getCommitContextsBetweenShas that turns the LIN-69430 bug into a concrete reproduction. --- src/base-sha.test.ts | 183 +++++++++++++++++++++++++++++++++++++++++++ src/base-sha.ts | 45 +++++++++++ 2 files changed, 228 insertions(+) create mode 100644 src/base-sha.test.ts create mode 100644 src/base-sha.ts diff --git a/src/base-sha.test.ts b/src/base-sha.test.ts new file mode 100644 index 0000000..7f25474 --- /dev/null +++ b/src/base-sha.test.ts @@ -0,0 +1,183 @@ +import { execSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { findBaseSha, type FindBaseShaDeps } from "./base-sha"; +import { commitExists, ensureCommitAvailable, getCommitContextsBetweenShas, isAncestor } from "./git"; +import type { Release } from "./types"; + +function runGit(args: string, cwd: string): string { + return execSync(`git ${args}`, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); +} + +function commit(cwd: string, file: string, content: string, message: string): string { + writeFileSync(join(cwd, file), content); + runGit("add .", cwd); + runGit(`commit -qm "${message}"`, cwd); + return runGit("rev-parse HEAD", cwd); +} + +function release(name: string, commitSha: string | undefined, daysAgoCreated: number): Release { + return { + id: `id-${name}`, + name, + commitSha, + createdAt: new Date(Date.now() - daysAgoCreated * 24 * 60 * 60 * 1000).toISOString(), + }; +} + +/** + * Topology shared by most scenarios: + * + * main: root ─ m1 ─ m2 ─ m3 ─ mainHead (HEAD when CI is on main) + * │ + * └─ h1 ─ h2 (hotfix side branch) + * └─ hotfixHead (HEAD when CI is on hotfix) + * + * - mainPrev = the "1.71.0" sha on main (m3 here, one before mainHead). + * - hotfixSha = h2, the "1.70.1" release sha on the hotfix branch. + * - mainHead = main's tip, used as HEAD for "CI on main" scenarios. + * - hotfixHead = an extra commit on top of h2, used as HEAD for "CI on hotfix" scenarios. + * + * Side branches are kept alive (named refs) so the SHAs stay reachable in this + * test repo regardless of GC; the walk doesn't care about branch names, only + * ancestry from the HEAD it's given. + */ +function buildRepo() { + const cwd = mkdtempSync(join(tmpdir(), "base-sha-")); + runGit("init -q -b main", cwd); + runGit('config user.email "t@t"', cwd); + runGit('config user.name "t"', cwd); + + commit(cwd, "f", "0", "root"); + const m1 = commit(cwd, "f", "1", "m1"); + + // Branch off m1 for the hotfix + runGit(`checkout -q -b hotfix ${m1}`, cwd); + commit(cwd, "f", "h1", "h1"); + const hotfixSha = commit(cwd, "f", "h2", "h2 (1.70.1 release)"); + const hotfixHead = commit(cwd, "f", "h3", "h3 (hotfix HEAD)"); + + // Back to main + runGit("checkout -q main", cwd); + commit(cwd, "f", "2", "m2"); + const mainPrev = commit(cwd, "f", "3", "m3 (1.71.0 release)"); + const mainHead = commit(cwd, "f", "4", "m4 (1.72.0 HEAD)"); + + return { cwd, hotfixSha, hotfixHead, mainPrev, mainHead }; +} + +describe("findBaseSha", () => { + let repo: ReturnType; + let deps: FindBaseShaDeps; + + beforeAll(() => { + repo = buildRepo(); + deps = { + isAncestor: (sha, head) => isAncestor(sha, head, repo.cwd), + commitExists: (sha) => commitExists(sha, repo.cwd), + ensureCommitAvailable: (sha) => ensureCommitAvailable(sha, repo.cwd), + }; + }); + + afterAll(() => { + if (repo) rmSync(repo.cwd, { recursive: true, force: true }); + }); + + it("scenario A — healthy single train: picks the only candidate", () => { + const candidates = [release("1.71.0", repo.mainPrev, 5)]; + expect(findBaseSha(candidates, repo.mainHead, deps)).toEqual({ kind: "found", sha: repo.mainPrev }); + }); + + it("scenario B — concurrent trains, hotfix listed first: skips hotfix, picks main", () => { + // The hotfix candidate sorts ahead of the main-train candidate, but its + // commitSha sits on a side branch — not reachable from HEAD. Using it as + // the base would scan a range covering everything the main train already + // shipped between the fork point and HEAD. Walk past it to the main + // release whose SHA is reachable. + const candidates = [release("1.70.1", repo.hotfixSha, 3), release("1.71.0", repo.mainPrev, 10)]; + expect(findBaseSha(candidates, repo.mainHead, deps)).toEqual({ kind: "found", sha: repo.mainPrev }); + }); + + it("scenario C — CI on the hotfix branch, main listed first: skips main, picks hotfix", () => { + // Mirror of scenario B: HEAD is on the hotfix branch and the main-train + // candidate sorts first. The main SHA isn't reachable from the hotfix + // HEAD, so the walk continues to the hotfix's own previous release. + const candidates = [release("1.71.0", repo.mainPrev, 3), release("1.70.1", repo.hotfixSha, 10)]; + expect(findBaseSha(candidates, repo.hotfixHead, deps)).toEqual({ kind: "found", sha: repo.hotfixSha }); + }); + + it("scenario D — newly created release with null commitSha: skipped, walks to previous release", () => { + // A release just created via the API has no commitSha until the first CI + // sync writes one. Treating null as "no prior release" would under-cover + // everything that landed since the actual previous release; the walk + // skips the null entry and lands on the previous real release. + const candidates = [release("1.72.0", undefined, 1), release("1.71.0", repo.mainPrev, 10)]; + expect(findBaseSha(candidates, repo.mainHead, deps)).toEqual({ kind: "found", sha: repo.mainPrev }); + }); + + it("scenario E — all candidates non-ancestors: returns fallback", () => { + // Every candidate's commitSha lives on a history disjoint from HEAD — + // shape produced by force-pushes that orphan old release SHAs, manual + // edits, or stale rows the API hasn't pruned. The walk exhausts the list + // and returns fallback so the caller can decide how to scan. + const candidates = [release("1.70.1", repo.hotfixSha, 3), release("hotfix-tip", repo.hotfixHead, 1)]; + expect(findBaseSha(candidates, repo.mainHead, deps)).toEqual({ kind: "fallback" }); + }); + + it("scenario F — empty list (first-ever sync): returns fallback", () => { + expect(findBaseSha([], repo.mainHead, deps)).toEqual({ kind: "fallback" }); + }); +}); + +/** + * Pairs scenario B's base selection with the actual `git log` range + * computation: instead of asserting only on the picked SHA, feed it into + * `getCommitContextsBetweenShas` and check the resulting commit list. Makes + * concrete why ancestor checking matters — a naive "use the first candidate" + * pick produces a range that includes commits the main train already shipped, + * while the walk's pick collapses the range to just the new bump. + */ +describe("end-to-end: concurrent trains", () => { + let repo: ReturnType; + let deps: FindBaseShaDeps; + let candidates: Release[]; + + beforeAll(() => { + repo = buildRepo(); + deps = { + isAncestor: (sha, head) => isAncestor(sha, head, repo.cwd), + commitExists: (sha) => commitExists(sha, repo.cwd), + ensureCommitAvailable: (sha) => ensureCommitAvailable(sha, repo.cwd), + }; + // Hotfix sorts ahead of the main-train release in the candidate list. + candidates = [release("1.70.1", repo.hotfixSha, 3), release("1.71.0", repo.mainPrev, 10)]; + }); + + afterAll(() => { + if (repo) rmSync(repo.cwd, { recursive: true, force: true }); + }); + + it("naive 'use first candidate' base scans commits already shipped via the main train", () => { + const naiveBase = candidates[0]!.commitSha!; + const range = getCommitContextsBetweenShas(naiveBase, repo.mainHead, { cwd: repo.cwd }); + const messages = range.map((c) => c.message?.split("\n")[0]).filter(Boolean); + + // m2 and m3 belong to 1.71.0; only m4 is the 1.72.0 bump. Using the + // hotfix SHA as base scans all three — exactly the re-attachment shape + // we want to avoid. + expect(messages).toEqual(["m4 (1.72.0 HEAD)", "m3 (1.71.0 release)", "m2"]); + }); + + it("findBaseSha picks the main release; range collapses to just the new bump", () => { + const result = findBaseSha(candidates, repo.mainHead, deps); + expect(result).toEqual({ kind: "found", sha: repo.mainPrev }); + if (result.kind !== "found") return; + + const range = getCommitContextsBetweenShas(result.sha, repo.mainHead, { cwd: repo.cwd }); + const messages = range.map((c) => c.message?.split("\n")[0]).filter(Boolean); + + expect(messages).toEqual(["m4 (1.72.0 HEAD)"]); + }); +}); diff --git a/src/base-sha.ts b/src/base-sha.ts new file mode 100644 index 0000000..4449a7a --- /dev/null +++ b/src/base-sha.ts @@ -0,0 +1,45 @@ +import { verbose } from "./log"; +import type { Release } from "./types"; + +export type BaseShaResult = { kind: "found"; sha: string } | { kind: "fallback" }; + +export type FindBaseShaDeps = { + isAncestor: (sha: string, headSha: string) => boolean; + commitExists: (sha: string) => boolean; + ensureCommitAvailable: (sha: string) => void; +}; + +/** + * Picks the base SHA for `git log ..` from a list of recent + * release candidates (most-relevant first). Returns the first candidate whose + * `commitSha` is reachable from `headSha` — the API can't disambiguate + * concurrent release trains via SQL alone, so we use git as ground truth. + * + * `commitExists` gates `ensureCommitAvailable` so a shallow clone doesn't pay + * a `git fetch` per candidate when the SHAs are already local. + */ +export function findBaseSha(candidates: Release[], headSha: string, deps: FindBaseShaDeps): BaseShaResult { + for (const candidate of candidates) { + const sha = candidate.commitSha; + if (!sha) { + verbose(`findBaseSha: skipping ${candidate.name}: no commitSha`); + continue; + } + if (!deps.commitExists(sha)) { + try { + deps.ensureCommitAvailable(sha); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + verbose(`findBaseSha: skipping ${candidate.name} (${sha}): ${message}`); + continue; + } + } + if (!deps.isAncestor(sha, headSha)) { + verbose(`findBaseSha: skipping ${candidate.name} (${sha}): not an ancestor of ${headSha}`); + continue; + } + verbose(`findBaseSha: using ${candidate.name} (${sha})`); + return { kind: "found", sha }; + } + return { kind: "fallback" }; +} From c756b72a32e8324f7bd10484a6f7deb8546b8ea5 Mon Sep 17 00:00:00 2001 From: Romain Cascino Date: Thu, 7 May 2026 16:58:09 +0100 Subject: [PATCH 3/3] Walk recent releases to pick base SHA on sync Replace `getLatestRelease` + verbatim-trust logic with `recentReleasesByAccessKey` (limit pinned at 20 via a GraphQL variable) and let findBaseSha pick the first ancestor of HEAD. Falls back to the existing first-sync boundary when no candidate matches; surface that fall-through at warn level when the candidate list was non-empty so the case shows up in CI logs. --- src/index.ts | 59 ++++++++++++++++++++++++++++++++++------------------ src/types.ts | 4 ++-- 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2cfe48a..632fc46 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,20 @@ import { LinearClient, LinearClientOptions } from "@linear/sdk"; import { assertGitAvailable, + commitExists, ensureCommitAvailable, getCommitContextsBetweenShas, getCurrentGitInfo, getRepoInfo, + isAncestor, resolveFirstSyncBoundary, } from "./git"; +import { findBaseSha } from "./base-sha"; import { scanCommits } from "./scan"; import { Release, - AccessKeyLatestReleaseResponse, AccessKeyPipelineSettingsResponse, + AccessKeyRecentReleasesResponse, AccessKeySyncReleaseResponse, AccessKeyCompleteReleaseResponse, AccessKeyUpdateByPipelineResponse, @@ -316,11 +319,14 @@ async function updateCommand(): Promise<{ : null; } -async function getLatestRelease(): Promise { - const response = await apiRequest( +async function getRecentReleases(): Promise { + // Pin the limit explicitly rather than relying on the server default — the + // walk's correctness depends on the right ancestor being in this page, so + // the cap is a meaningful contract, not an implementation detail. + const response = await apiRequest( ` - query latestReleaseByAccessKey { - latestReleaseByAccessKey { + query recentReleasesByAccessKey($limit: Int) { + recentReleasesByAccessKey(limit: $limit) { id name createdAt @@ -328,35 +334,48 @@ async function getLatestRelease(): Promise { } } `, + { limit: 20 }, ); - return response.data.latestReleaseByAccessKey; + return response.data.recentReleasesByAccessKey; } async function getLatestSha(): Promise { - const latestRelease = await getLatestRelease(); - const latestSha = latestRelease?.commitSha; - if (latestSha) { - return latestSha; - } - - if (!latestRelease) { - verbose("Could not find latest release, assuming it's the first release"); - } else if (!latestRelease.commitSha) { - verbose("Latest release has no commit SHA"); - } - const currentSha = await getCurrentGitInfo().commit; + const currentSha = getCurrentGitInfo().commit; if (!currentSha) { throw new Error("Could not get current commit"); } + const candidates = await getRecentReleases(); + const result = findBaseSha(candidates, currentSha, { isAncestor, commitExists, ensureCommitAvailable }); + if (result.kind === "found") { + return result.sha; + } + + if (candidates.length === 0) { + verbose("No recent releases found; assuming first sync"); + } else { + // The candidate list came back non-empty but no entry is reachable from + // HEAD. This usually means orphaned/stale commitShas, but can also mean + // the actual previous release is older than the recent-releases page — + // in which case we'll silently under-cover. Surface it at warn level so + // it's visible in CI logs. + // Don't promise "current commit only" here — the actual fallback is + // resolveFirstSyncBoundary, which uses HEAD^1 when HEAD is a merge commit. + // The follow-up verbose lines below print the boundary that was chosen. + warn( + `No recent release is an ancestor of ${currentSha} (${candidates.length} candidate${ + candidates.length === 1 ? "" : "s" + } considered); falling back to the first-sync scan boundary`, + ); + } // For a merge HEAD the issue keys live on HEAD^2's branch, not on HEAD // itself, so HEAD-only would miss them. Non-merge HEAD carries its own key. const boundary = resolveFirstSyncBoundary(currentSha); if (boundary !== currentSha) { - verbose(`First sync on merge HEAD: using HEAD^1 (${boundary}) as the scan boundary`); + verbose(`Merge HEAD: using HEAD^1 (${boundary}) as the scan boundary`); } else { - verbose("First sync: only inspecting current commit"); + verbose("Inspecting current commit only"); } return boundary; } diff --git a/src/types.ts b/src/types.ts index a807549..bd6ce6f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,9 +9,9 @@ export type Release = { }; // Access key endpoint response types -export type AccessKeyLatestReleaseResponse = { +export type AccessKeyRecentReleasesResponse = { data: { - latestReleaseByAccessKey: Release | null; + recentReleasesByAccessKey: Release[]; }; };