diff --git a/src/git.test.ts b/src/git.test.ts index a95a14a..b9e8083 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { + assertGitAvailable, buildPathspecArgs, ensureCommitAvailable, extractBranchName, @@ -745,3 +746,35 @@ describe("merge commit handling", () => { }); }); }); + +describe("assertGitAvailable", () => { + it("succeeds inside a git repository with git on PATH", () => { + const repo = createTempRepo(); + try { + expect(() => assertGitAvailable(repo.cwd)).not.toThrow(); + } finally { + rmSync(repo.cwd, { recursive: true, force: true }); + } + }); + + it("throws when not inside a git repository", () => { + const cwd = mkdtempSync(join(tmpdir(), "linear-release-no-repo-")); + try { + expect(() => assertGitAvailable(cwd)).toThrow(/git repository/); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + it("throws with a PATH hint when the git binary is missing", () => { + const repo = createTempRepo(); + const originalPath = process.env.PATH; + process.env.PATH = "/nonexistent-linear-release-test-dir"; + try { + expect(() => assertGitAvailable(repo.cwd)).toThrow(/git.*on PATH/); + } finally { + process.env.PATH = originalPath; + rmSync(repo.cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/src/git.ts b/src/git.ts index 87d927c..aab13ec 100644 --- a/src/git.ts +++ b/src/git.ts @@ -30,6 +30,37 @@ export function buildPathspecArgs(includePaths: string[] | null): string { return `-- ${patterns.join(" ")}`; } +/** + * Verifies the runtime environment can satisfy the CLI's git requirements: + * 1. The `git` binary is on PATH. + * 2. The current working directory is inside a git repository. + * + * Call once at startup, before any other git operations, so cryptic + * downstream failures (ENOENT, "not a git repository") become useful + * diagnostics for CI users. + */ +export function assertGitAvailable(cwd: string = process.cwd()): void { + try { + execSync("git --version", { + cwd, + stdio: ["ignore", "ignore", "pipe"], + }); + } catch { + throw new Error( + "linear-release requires `git` on PATH, but `git --version` failed. Please make sure that git is installed and available.", + ); + } + + try { + execSync("git rev-parse --is-inside-work-tree", { + cwd, + stdio: ["ignore", "ignore", "pipe"], + }); + } catch { + throw new Error("linear-release must run inside a git repository, but no `.git` directory was found."); + } +} + export function getCurrentGitInfo(cwd: string = process.cwd()): GitInfo { try { const branch = execSync("git rev-parse --abbrev-ref HEAD", { diff --git a/src/index.ts b/src/index.ts index a73b294..7d23b73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,11 @@ import { LinearClient, LinearClientOptions } from "@linear/sdk"; -import { ensureCommitAvailable, getCommitContextsBetweenShas, getCurrentGitInfo, getRepoInfo } from "./git"; +import { + assertGitAvailable, + ensureCommitAvailable, + getCommitContextsBetweenShas, + getCurrentGitInfo, + getRepoInfo, +} from "./git"; import { scanCommits } from "./scan"; import { Release, @@ -524,6 +530,8 @@ async function updateReleaseByPipeline(options: { } async function main() { + assertGitAvailable(); + let result: { release: { id: string; name: string; version?: string; url?: string }; } | null = null;