Skip to content
Open
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
183 changes: 183 additions & 0 deletions src/base-sha.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof buildRepo>;
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<typeof buildRepo>;
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)"]);
});
});
45 changes: 45 additions & 0 deletions src/base-sha.ts
Original file line number Diff line number Diff line change
@@ -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 <base>..<HEAD>` 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" };
}
24 changes: 24 additions & 0 deletions src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getCommitContextsBetweenShas,
getCommitParents,
getRepoInfo,
isAncestor,
normalizePathspec,
parseRepoUrl,
resolveFirstSyncBoundary,
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <base>..<HEAD>` — 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;

/**
Expand Down
59 changes: 39 additions & 20 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -316,47 +319,63 @@ async function updateCommand(): Promise<{
: null;
}

async function getLatestRelease(): Promise<Release | null> {
const response = await apiRequest<AccessKeyLatestReleaseResponse>(
async function getRecentReleases(): Promise<Release[]> {
// 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<AccessKeyRecentReleasesResponse>(
`
query latestReleaseByAccessKey {
latestReleaseByAccessKey {
query recentReleasesByAccessKey($limit: Int) {
recentReleasesByAccessKey(limit: $limit) {
id
name
createdAt
commitSha
}
}
`,
{ limit: 20 },
);

return response.data.latestReleaseByAccessKey;
return response.data.recentReleasesByAccessKey;
}

async function getLatestSha(): Promise<string> {
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;
}
Expand Down
Loading
Loading