|
| 1 | +import { execSync } from "node:child_process"; |
| 2 | +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; |
| 3 | +import { tmpdir } from "node:os"; |
| 4 | +import { join } from "node:path"; |
| 5 | +import { afterAll, beforeAll, describe, expect, it } from "vitest"; |
| 6 | +import { findBaseSha, type FindBaseShaDeps } from "./base-sha"; |
| 7 | +import { commitExists, ensureCommitAvailable, getCommitContextsBetweenShas, isAncestor } from "./git"; |
| 8 | +import type { Release } from "./types"; |
| 9 | + |
| 10 | +function runGit(args: string, cwd: string): string { |
| 11 | + return execSync(`git ${args}`, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); |
| 12 | +} |
| 13 | + |
| 14 | +function commit(cwd: string, file: string, content: string, message: string): string { |
| 15 | + writeFileSync(join(cwd, file), content); |
| 16 | + runGit("add .", cwd); |
| 17 | + runGit(`commit -qm "${message}"`, cwd); |
| 18 | + return runGit("rev-parse HEAD", cwd); |
| 19 | +} |
| 20 | + |
| 21 | +function release(name: string, commitSha: string | undefined, daysAgoCreated: number): Release { |
| 22 | + return { |
| 23 | + id: `id-${name}`, |
| 24 | + name, |
| 25 | + commitSha, |
| 26 | + createdAt: new Date(Date.now() - daysAgoCreated * 24 * 60 * 60 * 1000).toISOString(), |
| 27 | + }; |
| 28 | +} |
| 29 | + |
| 30 | +/** |
| 31 | + * Topology shared by most scenarios: |
| 32 | + * |
| 33 | + * main: root ─ m1 ─ m2 ─ m3 ─ mainHead (HEAD when CI is on main) |
| 34 | + * │ |
| 35 | + * └─ h1 ─ h2 (hotfix side branch) |
| 36 | + * └─ hotfixHead (HEAD when CI is on hotfix) |
| 37 | + * |
| 38 | + * - mainPrev = the "1.71.0" sha on main (m3 here, one before mainHead). |
| 39 | + * - hotfixSha = h2, the "1.70.1" release sha on the hotfix branch. |
| 40 | + * - mainHead = main's tip, used as HEAD for "CI on main" scenarios. |
| 41 | + * - hotfixHead = an extra commit on top of h2, used as HEAD for "CI on hotfix" scenarios. |
| 42 | + * |
| 43 | + * Side branches are kept alive (named refs) so the SHAs stay reachable in this |
| 44 | + * test repo regardless of GC; the walk doesn't care about branch names, only |
| 45 | + * ancestry from the HEAD it's given. |
| 46 | + */ |
| 47 | +function buildRepo() { |
| 48 | + const cwd = mkdtempSync(join(tmpdir(), "base-sha-")); |
| 49 | + runGit("init -q -b main", cwd); |
| 50 | + runGit('config user.email "t@t"', cwd); |
| 51 | + runGit('config user.name "t"', cwd); |
| 52 | + |
| 53 | + commit(cwd, "f", "0", "root"); |
| 54 | + const m1 = commit(cwd, "f", "1", "m1"); |
| 55 | + |
| 56 | + // Branch off m1 for the hotfix |
| 57 | + runGit(`checkout -q -b hotfix ${m1}`, cwd); |
| 58 | + commit(cwd, "f", "h1", "h1"); |
| 59 | + const hotfixSha = commit(cwd, "f", "h2", "h2 (1.70.1 release)"); |
| 60 | + const hotfixHead = commit(cwd, "f", "h3", "h3 (hotfix HEAD)"); |
| 61 | + |
| 62 | + // Back to main |
| 63 | + runGit("checkout -q main", cwd); |
| 64 | + commit(cwd, "f", "2", "m2"); |
| 65 | + const mainPrev = commit(cwd, "f", "3", "m3 (1.71.0 release)"); |
| 66 | + const mainHead = commit(cwd, "f", "4", "m4 (1.72.0 HEAD)"); |
| 67 | + |
| 68 | + return { cwd, hotfixSha, hotfixHead, mainPrev, mainHead }; |
| 69 | +} |
| 70 | + |
| 71 | +describe("findBaseSha", () => { |
| 72 | + let repo: ReturnType<typeof buildRepo>; |
| 73 | + let deps: FindBaseShaDeps; |
| 74 | + |
| 75 | + beforeAll(() => { |
| 76 | + repo = buildRepo(); |
| 77 | + deps = { |
| 78 | + isAncestor: (sha, head) => isAncestor(sha, head, repo.cwd), |
| 79 | + commitExists: (sha) => commitExists(sha, repo.cwd), |
| 80 | + ensureCommitAvailable: (sha) => ensureCommitAvailable(sha, repo.cwd), |
| 81 | + }; |
| 82 | + }); |
| 83 | + |
| 84 | + afterAll(() => { |
| 85 | + if (repo) rmSync(repo.cwd, { recursive: true, force: true }); |
| 86 | + }); |
| 87 | + |
| 88 | + it("scenario A — healthy single train: picks the only candidate", () => { |
| 89 | + const candidates = [release("1.71.0", repo.mainPrev, 5)]; |
| 90 | + expect(findBaseSha(candidates, repo.mainHead, deps)).toEqual({ kind: "found", sha: repo.mainPrev }); |
| 91 | + }); |
| 92 | + |
| 93 | + it("scenario B — concurrent trains, hotfix listed first: skips hotfix, picks main", () => { |
| 94 | + // The hotfix candidate sorts ahead of the main-train candidate, but its |
| 95 | + // commitSha sits on a side branch — not reachable from HEAD. Using it as |
| 96 | + // the base would scan a range covering everything the main train already |
| 97 | + // shipped between the fork point and HEAD. Walk past it to the main |
| 98 | + // release whose SHA is reachable. |
| 99 | + const candidates = [release("1.70.1", repo.hotfixSha, 3), release("1.71.0", repo.mainPrev, 10)]; |
| 100 | + expect(findBaseSha(candidates, repo.mainHead, deps)).toEqual({ kind: "found", sha: repo.mainPrev }); |
| 101 | + }); |
| 102 | + |
| 103 | + it("scenario C — CI on the hotfix branch, main listed first: skips main, picks hotfix", () => { |
| 104 | + // Mirror of scenario B: HEAD is on the hotfix branch and the main-train |
| 105 | + // candidate sorts first. The main SHA isn't reachable from the hotfix |
| 106 | + // HEAD, so the walk continues to the hotfix's own previous release. |
| 107 | + const candidates = [release("1.71.0", repo.mainPrev, 3), release("1.70.1", repo.hotfixSha, 10)]; |
| 108 | + expect(findBaseSha(candidates, repo.hotfixHead, deps)).toEqual({ kind: "found", sha: repo.hotfixSha }); |
| 109 | + }); |
| 110 | + |
| 111 | + it("scenario D — newly created release with null commitSha: skipped, walks to previous release", () => { |
| 112 | + // A release just created via the API has no commitSha until the first CI |
| 113 | + // sync writes one. Treating null as "no prior release" would under-cover |
| 114 | + // everything that landed since the actual previous release; the walk |
| 115 | + // skips the null entry and lands on the previous real release. |
| 116 | + const candidates = [release("1.72.0", undefined, 1), release("1.71.0", repo.mainPrev, 10)]; |
| 117 | + expect(findBaseSha(candidates, repo.mainHead, deps)).toEqual({ kind: "found", sha: repo.mainPrev }); |
| 118 | + }); |
| 119 | + |
| 120 | + it("scenario E — all candidates non-ancestors: returns fallback", () => { |
| 121 | + // Every candidate's commitSha lives on a history disjoint from HEAD — |
| 122 | + // shape produced by force-pushes that orphan old release SHAs, manual |
| 123 | + // edits, or stale rows the API hasn't pruned. The walk exhausts the list |
| 124 | + // and returns fallback so the caller can decide how to scan. |
| 125 | + const candidates = [release("1.70.1", repo.hotfixSha, 3), release("hotfix-tip", repo.hotfixHead, 1)]; |
| 126 | + expect(findBaseSha(candidates, repo.mainHead, deps)).toEqual({ kind: "fallback" }); |
| 127 | + }); |
| 128 | + |
| 129 | + it("scenario F — empty list (first-ever sync): returns fallback", () => { |
| 130 | + expect(findBaseSha([], repo.mainHead, deps)).toEqual({ kind: "fallback" }); |
| 131 | + }); |
| 132 | +}); |
| 133 | + |
| 134 | +/** |
| 135 | + * Pairs scenario B's base selection with the actual `git log` range |
| 136 | + * computation: instead of asserting only on the picked SHA, feed it into |
| 137 | + * `getCommitContextsBetweenShas` and check the resulting commit list. Makes |
| 138 | + * concrete why ancestor checking matters — a naive "use the first candidate" |
| 139 | + * pick produces a range that includes commits the main train already shipped, |
| 140 | + * while the walk's pick collapses the range to just the new bump. |
| 141 | + */ |
| 142 | +describe("end-to-end: concurrent trains", () => { |
| 143 | + let repo: ReturnType<typeof buildRepo>; |
| 144 | + let deps: FindBaseShaDeps; |
| 145 | + let candidates: Release[]; |
| 146 | + |
| 147 | + beforeAll(() => { |
| 148 | + repo = buildRepo(); |
| 149 | + deps = { |
| 150 | + isAncestor: (sha, head) => isAncestor(sha, head, repo.cwd), |
| 151 | + commitExists: (sha) => commitExists(sha, repo.cwd), |
| 152 | + ensureCommitAvailable: (sha) => ensureCommitAvailable(sha, repo.cwd), |
| 153 | + }; |
| 154 | + // Hotfix sorts ahead of the main-train release in the candidate list. |
| 155 | + candidates = [release("1.70.1", repo.hotfixSha, 3), release("1.71.0", repo.mainPrev, 10)]; |
| 156 | + }); |
| 157 | + |
| 158 | + afterAll(() => { |
| 159 | + if (repo) rmSync(repo.cwd, { recursive: true, force: true }); |
| 160 | + }); |
| 161 | + |
| 162 | + it("naive 'use first candidate' base scans commits already shipped via the main train", () => { |
| 163 | + const naiveBase = candidates[0]!.commitSha!; |
| 164 | + const range = getCommitContextsBetweenShas(naiveBase, repo.mainHead, { cwd: repo.cwd }); |
| 165 | + const messages = range.map((c) => c.message?.split("\n")[0]).filter(Boolean); |
| 166 | + |
| 167 | + // m2 and m3 belong to 1.71.0; only m4 is the 1.72.0 bump. Using the |
| 168 | + // hotfix SHA as base scans all three — exactly the re-attachment shape |
| 169 | + // we want to avoid. |
| 170 | + expect(messages).toEqual(["m4 (1.72.0 HEAD)", "m3 (1.71.0 release)", "m2"]); |
| 171 | + }); |
| 172 | + |
| 173 | + it("findBaseSha picks the main release; range collapses to just the new bump", () => { |
| 174 | + const result = findBaseSha(candidates, repo.mainHead, deps); |
| 175 | + expect(result).toEqual({ kind: "found", sha: repo.mainPrev }); |
| 176 | + if (result.kind !== "found") return; |
| 177 | + |
| 178 | + const range = getCommitContextsBetweenShas(result.sha, repo.mainHead, { cwd: repo.cwd }); |
| 179 | + const messages = range.map((c) => c.message?.split("\n")[0]).filter(Boolean); |
| 180 | + |
| 181 | + expect(messages).toEqual(["m4 (1.72.0 HEAD)"]); |
| 182 | + }); |
| 183 | +}); |
0 commit comments