diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 52a951793..535b06641 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed plugin install failing for sources pinned to a SHA: `git.clone()` no longer adds `--depth 1` when `options.sha` is set, so the checkout of arbitrary commits succeeds instead of bailing out with "shallow clone may not contain this commit" ([#1589](https://github.com/can1357/oh-my-pi/issues/1589)). + ## [15.7.3] - 2026-05-31 ### Added diff --git a/packages/coding-agent/src/utils/git.ts b/packages/coding-agent/src/utils/git.ts index 09ed2b3d9..30bddf9ca 100644 --- a/packages/coding-agent/src/utils/git.ts +++ b/packages/coding-agent/src/utils/git.ts @@ -1261,9 +1261,15 @@ export async function clone(url: string, targetDir: string, options: CloneOption const absoluteTarget = path.resolve(targetDir); await fs.promises.mkdir(path.dirname(absoluteTarget), { recursive: true }); - const args = ["clone", "--depth", "1"]; + // `git clone --depth 1 --single-branch` only fetches the tip of the target + // branch, so any subsequent `git checkout ` for a non-tip commit fails + // with "reference is not a tree". When the caller pinned a specific SHA we + // fall back to a full clone so the object is guaranteed to be present. + const shallow = !options.sha; + const args = ["clone"]; + if (shallow) args.push("--depth", "1"); if (options.ref) args.push("--branch", options.ref, "--single-branch"); - else args.push("--single-branch"); + else if (shallow) args.push("--single-branch"); args.push(url, absoluteTarget); try { @@ -1273,7 +1279,7 @@ export async function clone(url: string, targetDir: string, options: CloneOption await checkout(absoluteTarget, options.sha, options.signal); } catch { await fs.promises.rm(absoluteTarget, { force: true, recursive: true }); - throw new Error(`Failed to checkout SHA ${options.sha} - shallow clone may not contain this commit`); + throw new Error(`Failed to checkout SHA ${options.sha} in cloned repository ${url}`); } } } catch (err) { diff --git a/packages/coding-agent/test/utils/git-clone.test.ts b/packages/coding-agent/test/utils/git-clone.test.ts new file mode 100644 index 000000000..1bc5d102c --- /dev/null +++ b/packages/coding-agent/test/utils/git-clone.test.ts @@ -0,0 +1,78 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import * as git from "@oh-my-pi/pi-coding-agent/utils/git"; + +// Regression coverage for #1589: `git.clone({ sha })` used to hardcode +// `--depth 1`, producing a shallow clone whose object store never contained +// non-tip commits. The subsequent `git checkout ` then failed with +// "shallow clone may not contain this commit". + +const GIT_ENV = { + GIT_AUTHOR_NAME: "t", + GIT_AUTHOR_EMAIL: "t@example.com", + GIT_COMMITTER_NAME: "t", + GIT_COMMITTER_EMAIL: "t@example.com", +} as const; + +function gitRun(cwd: string, args: string[]): string { + const result = Bun.spawnSync({ + cmd: ["git", ...args], + cwd, + env: { ...process.env, ...GIT_ENV }, + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString()}`); + } + return result.stdout.toString().trim(); +} + +describe("git.clone with options.sha", () => { + let tmpRoot: string; + let upstreamUrl: string; + let firstSha: string; + let tipSha: string; + + beforeAll(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-git-clone-test-")); + const upstream = path.join(tmpRoot, "upstream"); + await fs.mkdir(upstream, { recursive: true }); + + // `file://` is required: local-path clones ignore `--depth`, which would + // mask the bug. See git-clone(1) "GIT URLS" / "LOCAL PROTOCOL". + upstreamUrl = `file://${upstream}`; + + gitRun(upstream, ["init", "-q", "-b", "main"]); + gitRun(upstream, ["commit", "-q", "--allow-empty", "-m", "first"]); + firstSha = gitRun(upstream, ["rev-parse", "HEAD"]); + gitRun(upstream, ["commit", "-q", "--allow-empty", "-m", "second"]); + gitRun(upstream, ["commit", "-q", "--allow-empty", "-m", "third"]); + tipSha = gitRun(upstream, ["rev-parse", "HEAD"]); + }); + + afterAll(async () => { + await fs.rm(tmpRoot, { recursive: true, force: true }); + }); + + test("checks out a non-tip SHA (regression for #1589)", async () => { + const target = path.join(tmpRoot, "clone-non-tip"); + await git.clone(upstreamUrl, target, { sha: firstSha }); + expect(gitRun(target, ["rev-parse", "HEAD"])).toBe(firstSha); + }); + + test("still succeeds when SHA happens to be the tip", async () => { + const target = path.join(tmpRoot, "clone-tip"); + await git.clone(upstreamUrl, target, { sha: tipSha }); + expect(gitRun(target, ["rev-parse", "HEAD"])).toBe(tipSha); + }); + + test("cleans up the target directory when SHA does not exist", async () => { + const target = path.join(tmpRoot, "clone-missing"); + await expect(git.clone(upstreamUrl, target, { sha: "0".repeat(40) })).rejects.toThrow(/Failed to checkout SHA/); + await expect(fs.stat(target)).rejects.toMatchObject({ code: "ENOENT" }); + }); +});