diff --git a/src/git.test.ts b/src/git.test.ts index b9d105b..1c9343e 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { buildPathspecArgs, + ensureCommitAvailable, extractBranchName, extractBranchNameFromMergeMessage, getCommitContext, @@ -308,6 +309,11 @@ type TempRepo = { }; }; +type ShallowCloneRepo = TempRepo & { + origin: string; + source: string; +}; + type TempRepoWithMerge = { cwd: string; commits: { @@ -362,6 +368,17 @@ function createTempRepo(): TempRepo { return { cwd, commits: { first, second, third } }; } +function createShallowCloneRepo(): ShallowCloneRepo { + const source = createTempRepo(); + const origin = mkdtempSync(join(tmpdir(), "linear-release-origin-")); + runGit(`clone --bare ${source.cwd} ${origin}`, tmpdir()); + + const cwd = mkdtempSync(join(tmpdir(), "linear-release-shallow-")); + runGit(`clone --depth 1 file://${origin} ${cwd}`, tmpdir()); + + return { cwd, origin, source: source.cwd, commits: source.commits }; +} + /** * Build a deterministic git repo with a merge commit for integration tests. * @@ -412,6 +429,27 @@ describe("getCommitContextsBetweenShas", () => { repo = createTempRepo(); }); + it("should auto-fetch deeper history for shallow clones", () => { + const shallowRepo = createShallowCloneRepo(); + + try { + expect(runGit("rev-parse --is-shallow-repository", shallowRepo.cwd)).toBe("true"); + + ensureCommitAvailable(shallowRepo.commits.first, shallowRepo.cwd); + + const result = getCommitContextsBetweenShas(shallowRepo.commits.first, shallowRepo.commits.third, { + cwd: shallowRepo.cwd, + }); + + expect(result.map((commit) => commit.sha)).toEqual([shallowRepo.commits.third, shallowRepo.commits.second]); + expect(runGit("rev-parse --is-shallow-repository", shallowRepo.cwd)).toBe("false"); + } finally { + rmSync(shallowRepo.cwd, { recursive: true, force: true }); + rmSync(shallowRepo.origin, { recursive: true, force: true }); + rmSync(shallowRepo.source, { recursive: true, force: true }); + } + }); + afterAll(() => { rmSync(repo.cwd, { recursive: true, force: true }); }); diff --git a/src/git.ts b/src/git.ts index 1949d57..229fdcf 100644 --- a/src/git.ts +++ b/src/git.ts @@ -98,12 +98,7 @@ export function commitExists(sha: string, cwd: string = process.cwd()): boolean stdio: ["ignore", "ignore", "ignore"], }); return true; - } catch (error) { - // Only log unexpected errors, not "commit not found" which is expected - const message = error instanceof Error ? error.message : String(error); - if (!message.includes("Not a valid object")) { - warn(`commitExists: Unexpected error checking ${sha}: ${message}`); - } + } catch { return false; } } @@ -195,7 +190,7 @@ export function getCommitContext(sha: string, cwd: string = process.cwd()): Comm * For shallow clones, progressively fetches more history until the commit is found. * Throws if the commit cannot be made available (e.g., not on the current branch). */ -function ensureCommitAvailable(sha: string, cwd: string): void { +export function ensureCommitAvailable(sha: string, cwd: string = process.cwd()): void { if (commitExists(sha, cwd)) { return; } @@ -258,9 +253,6 @@ export function getCommitContextsBetweenShas( return []; } - // Ensure the base commit is available (handles shallow clones) - ensureCommitAvailable(fromSha, cwd); - const pathspecArgs = buildPathspecArgs(includePaths); // If fromSha and toSha are the same, get that single commit only diff --git a/src/index.ts b/src/index.ts index 7f5fd79..ac1fed7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { LinearClient, LinearClientOptions } from "@linear/sdk"; -import { commitExists, getCommitContextsBetweenShas, getCurrentGitInfo, getRepoInfo } from "./git"; +import { ensureCommitAvailable, getCommitContextsBetweenShas, getCurrentGitInfo, getRepoInfo } from "./git"; import { scanCommits } from "./scan"; import { Release, @@ -157,9 +157,12 @@ async function syncCommand(): Promise<{ let latestSha = await getLatestSha(); let inspectingOnlyCurrentCommit = false; - if (!commitExists(latestSha)) { + try { + ensureCommitAvailable(latestSha); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); warn( - `Could not find sha ${latestSha} in the git history (it may be on a different branch or the repository history was not fully fetched)`, + `Could not make sha ${latestSha} available in local git history; falling back to current commit only. ${message}`, ); inspectingOnlyCurrentCommit = true; latestSha = currentCommit.commit; @@ -224,7 +227,14 @@ async function syncCommand(): Promise<{ info("Finished"); - return { release: { id: release.id, name: release.name, version: release.version, url: release.url } }; + return { + release: { + id: release.id, + name: release.name, + version: release.version, + url: release.url, + }, + }; } async function completeCommand(): Promise<{ @@ -336,7 +346,9 @@ async function getLatestSha(): Promise { return currentSha; } -async function getPipelineSettings(): Promise<{ includePathPatterns: string[] }> { +async function getPipelineSettings(): Promise<{ + includePathPatterns: string[]; +}> { const response = await apiRequest( ` query pipelineSettingsByAccessKey { @@ -418,10 +430,10 @@ async function syncRelease( return response.data.releaseSyncByAccessKey.release; } -async function completeRelease(options: { - version?: string | null; - commitSha?: string | null; -}): Promise<{ success: boolean; release: { id: string; name: string; version?: string; url?: string } | null }> { +async function completeRelease(options: { version?: string | null; commitSha?: string | null }): Promise<{ + success: boolean; + release: { id: string; name: string; version?: string; url?: string } | null; +}> { const { version, commitSha } = options; const response = await apiRequest( @@ -451,7 +463,13 @@ async function completeRelease(options: { async function updateReleaseByPipeline(options: { stage?: string; version?: string | null }): Promise<{ success: boolean; - release: { id: string; name: string; version?: string; url?: string; stageName: string } | null; + release: { + id: string; + name: string; + version?: string; + url?: string; + stageName: string; + } | null; }> { const { stage, version } = options; const versionInput = version ? `, version: "${version}"` : ""; @@ -496,7 +514,9 @@ async function updateReleaseByPipeline(options: { stage?: string; version?: stri } async function main() { - let result: { release: { id: string; name: string; version?: string; url?: string } } | null = null; + let result: { + release: { id: string; name: string; version?: string; url?: string }; + } | null = null; switch (command) { case "sync":