Files
bun-src/test/cli/install/bun-install-git-deps.test.ts
2026-08-27 21:09:14 +00:00

426 lines
16 KiB
TypeScript

// Tests for installing git dependencies that live in ONE repository as
// multiple branches (issue #35420), `git+file://` dependencies, and
// tarball-URL / `github:` dependencies that appear both directly and
// transitively (issues #10915, #8501, #11348, #28284). Everything is local:
// a bare repo on disk (served over git's dumb HTTP protocol by Bun.serve
// when an http URL is needed) or static tarballs.
import { expect, test } from "bun:test";
import { mkdirSync, writeFileSync } from "fs";
import { bunEnv, bunExe, tempDir } from "harness";
import { join } from "path";
import { pathToFileURL } from "url";
const gitEnv = {
...bunEnv,
GIT_CONFIG_NOSYSTEM: "1",
GIT_AUTHOR_NAME: "Test",
GIT_AUTHOR_EMAIL: "[email protected]",
GIT_COMMITTER_NAME: "Test",
GIT_COMMITTER_EMAIL: "[email protected]",
};
async function run(cwd: string, cmd: string[], what: string) {
await using proc = Bun.spawn({ cmd, cwd, env: gitEnv, stdout: "ignore", stderr: "pipe" });
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
if (exitCode !== 0) {
throw new Error(`${what} failed in ${cwd}:\n${stderr}`);
}
}
function git(cwd: string, ...args: string[]) {
return run(cwd, ["git", ...args], `git ${args.join(" ")}`);
}
interface BranchPackage {
name: string;
branch: string;
dependencies?: Record<string, string>;
}
// Creates `<root>/shared-repo.git`, a bare repo with one orphan branch per
// package, and prepares it for serving over dumb HTTP.
async function makeSharedRepo(root: string, packages: BranchPackage[]): Promise<string> {
const bare = join(root, "shared-repo.git");
const work = join(root, "work");
await git(root, "init", "-q", "--bare", "shared-repo.git");
mkdirSync(work);
await git(work, "init", "-q");
for (const pkg of packages) {
await git(work, "checkout", "-q", "--orphan", pkg.branch);
writeFileSync(
join(work, "package.json"),
JSON.stringify({ name: pkg.name, version: "1.0.0", dependencies: pkg.dependencies }, null, 2),
);
writeFileSync(join(work, "index.js"), `module.exports = ${JSON.stringify(pkg.branch)};\n`);
await git(work, "add", "-A");
await git(work, "commit", "-q", "-m", pkg.branch, "--no-gpg-sign");
await git(work, "push", "-q", bare, pkg.branch);
}
// dumb HTTP clients read the static files this generates
await git(bare, "update-server-info");
return bare;
}
function serveStatic(root: string) {
return Bun.serve({
port: 0,
async fetch(req) {
const file = Bun.file(join(root, new URL(req.url).pathname));
return (await file.exists()) ? new Response(file) : new Response("not found", { status: 404 });
},
});
}
async function runInstall(cwd: string, cacheDir: string, extraEnv: Record<string, string>, ...args: string[]) {
const env = { ...gitEnv, ...extraEnv, BUN_INSTALL_CACHE_DIR: cacheDir };
// Set on ASAN CI lanes; it arms a subreaper around internal git spawns that
// SIGKILLs concurrent clone tasks (see #33982). This test exercises install
// task bookkeeping, not orphan reaping.
delete env.BUN_FEATURE_FLAG_NO_ORPHANS;
await using proc = Bun.spawn({
cmd: [bunExe(), "install", ...args],
cwd,
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}
async function installedVersionOf(dir: string, name: string): Promise<string | null> {
const file = Bun.file(join(dir, "node_modules", name, "index.js"));
if (!(await file.exists())) return null;
const text = await file.text();
return JSON.parse(text.slice(text.indexOf("=") + 1, text.lastIndexOf(";")));
}
// issue #35420 bug 1: with no lockfile and a cold cache, dependencies that
// appear both directly and transitively (same repo URL + committish) raced
// against the shared clone/checkout tasks and failed with "failed to resolve".
test.concurrent(
"installs every git dependency when many branches of one repo appear directly and transitively",
async () => {
const letters = "abcdefghijklmnop".split("");
using dir = tempDir("git-dep-dup", {});
const root = String(dir);
await using server = serveStatic(root);
const repoUrl = `git+http://localhost:${server.port}/shared-repo.git`;
// pkg-a re-declares 11 of its siblings as its own dependencies, so those
// specs appear both directly (from the project) and transitively.
const transitive = Object.fromEntries(letters.slice(1, 12).map(l => [`@scope/pkg-${l}`, `${repoUrl}#pkg-${l}`]));
await makeSharedRepo(
root,
letters.map(l => ({
name: `@scope/pkg-${l}`,
branch: `pkg-${l}`,
dependencies: l === "a" ? transitive : undefined,
})),
);
const project = join(root, "project");
mkdirSync(project);
writeFileSync(
join(project, "package.json"),
JSON.stringify({
name: "project",
version: "1.0.0",
dependencies: Object.fromEntries(letters.map(l => [`@scope/pkg-${l}`, `${repoUrl}#pkg-${l}`])),
}),
);
// the race depends on threadpool scheduling; two fresh-cache attempts to
// make the failure reliable on the unfixed code
for (let attempt = 0; attempt < 2; attempt++) {
await Bun.$`rm -rf ${join(project, "node_modules")} ${join(project, "bun.lock")} ${join(root, "cache-" + attempt)}`;
const { stderr, exitCode } = await runInstall(project, join(root, `cache-${attempt}`), {});
expect(stderr).not.toContain("failed to resolve");
expect(stderr).not.toContain("error:");
for (const l of letters) {
expect(await installedVersionOf(project, `@scope/pkg-${l}`)).toBe(`pkg-${l}`);
}
expect(exitCode).toBe(0);
}
},
30_000,
);
// same mechanism as above but for tarball-URL dependencies (issues #10915,
// #8501): a dependency enqueued after its tarball's extract task already
// completed and drained its callback queue was parked forever and failed
// with "failed to resolve".
test.concurrent(
"installs every tarball-URL dependency that appears directly and transitively",
async () => {
const letters = "abcdefghijklmnop".split("");
using dir = tempDir("tarball-dep-dup", {});
const root = String(dir);
const tarballs = join(root, "tarballs");
mkdirSync(tarballs);
await using server = serveStatic(tarballs);
const urlOf = (l: string) => `http://localhost:${server.port}/pkg-${l}.tgz`;
// pkg-a re-declares 11 of its siblings as its own dependencies, so those
// tarball specs appear both directly (from the project) and transitively.
const transitive = Object.fromEntries(letters.slice(1, 12).map(l => [`@scope/pkg-${l}`, urlOf(l)]));
for (const l of letters) {
const pkgDir = join(root, `work-${l}`, "package");
mkdirSync(pkgDir, { recursive: true });
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({
name: `@scope/pkg-${l}`,
version: "1.0.0",
dependencies: l === "a" ? transitive : undefined,
}),
);
writeFileSync(join(pkgDir, "index.js"), `module.exports = ${JSON.stringify(`pkg-${l}`)};\n`);
await run(root, ["tar", "-czf", join(tarballs, `pkg-${l}.tgz`), "-C", join(root, `work-${l}`), "package"], "tar");
}
const project = join(root, "project");
mkdirSync(project);
writeFileSync(
join(project, "package.json"),
JSON.stringify({
name: "project",
version: "1.0.0",
dependencies: Object.fromEntries(letters.map(l => [`@scope/pkg-${l}`, urlOf(l)])),
}),
);
// the race depends on threadpool scheduling; two fresh-cache attempts to
// make the failure reliable on the unfixed code
for (let attempt = 0; attempt < 2; attempt++) {
await Bun.$`rm -rf ${join(project, "node_modules")} ${join(project, "bun.lock")} ${join(root, "cache-" + attempt)}`;
const { stderr, exitCode } = await runInstall(project, join(root, `cache-${attempt}`), {});
expect(stderr).not.toContain("failed to resolve");
expect(stderr).not.toContain("error:");
for (const l of letters) {
expect(await installedVersionOf(project, `@scope/pkg-${l}`)).toBe(`pkg-${l}`);
}
expect(exitCode).toBe(0);
}
},
30_000,
);
// issue #11348: same mechanism for `github:` dependencies. The root and a
// transitive `github:` dependency both request the same `github:owner/repo`
// spec; the late enqueue lands after the shared extract task already drained
// its callback queue and was parked forever with "failed to resolve".
test.concurrent(
"installs every github: dependency that appears directly and transitively",
async () => {
const letters = "abcdefgh".split("");
using dir = tempDir("github-dep-dup", {});
const root = String(dir);
const tarballs = join(root, "tarballs");
mkdirSync(tarballs);
// pkg-a re-declares its siblings as its own dependencies, so those
// `github:` specs appear both directly (from the project) and transitively.
const transitive = Object.fromEntries(letters.slice(1).map(l => [`@scope/pkg-${l}`, `github:scope/pkg-${l}`]));
for (const l of letters) {
// GitHub tarballs have a top-level `<owner>-<repo>-<sha>` directory; the
// extractor reads it as the resolved tag and it becomes the cache key.
const top = `scope-pkg-${l}-0000000`;
const pkgDir = join(root, `work-${l}`, top);
mkdirSync(pkgDir, { recursive: true });
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({
name: `@scope/pkg-${l}`,
version: "1.0.0",
dependencies: l === "a" ? transitive : undefined,
}),
);
writeFileSync(join(pkgDir, "index.js"), `module.exports = ${JSON.stringify(`pkg-${l}`)};\n`);
await run(root, ["tar", "-czf", join(tarballs, `pkg-${l}.tgz`), "-C", join(root, `work-${l}`), top], "tar");
}
await using server = Bun.serve({
port: 0,
fetch(req) {
const match = /^\/repos\/scope\/(pkg-[a-z])\/tarball\/?$/.exec(new URL(req.url).pathname);
if (match) return new Response(Bun.file(join(tarballs, `${match[1]}.tgz`)));
return new Response("not found", { status: 404 });
},
});
const project = join(root, "project");
mkdirSync(project);
writeFileSync(
join(project, "package.json"),
JSON.stringify({
name: "project",
version: "1.0.0",
dependencies: Object.fromEntries(letters.map(l => [`@scope/pkg-${l}`, `github:scope/pkg-${l}`])),
}),
);
// the race depends on threadpool scheduling; two fresh-cache attempts to
// make the failure reliable on the unfixed code
for (let attempt = 0; attempt < 2; attempt++) {
await Bun.$`rm -rf ${join(project, "node_modules")} ${join(project, "bun.lock")} ${join(root, "cache-" + attempt)}`;
const { stderr, exitCode } = await runInstall(project, join(root, `cache-${attempt}`), {
GITHUB_API_URL: `http://localhost:${server.port}`,
});
expect(stderr).not.toContain("failed to resolve");
expect(stderr).not.toContain("error:");
for (const l of letters) {
expect(await installedVersionOf(project, `@scope/pkg-${l}`)).toBe(`pkg-${l}`);
}
expect(exitCode).toBe(0);
}
},
30_000,
);
// issue #35420 bug 2: installing from a complete lockfile with a cold cache
// only checked out the single dependency stored on the shared clone task; the
// other branches of the same repo were silently skipped with exit code 0.
test.concurrent(
"installs every git dependency from a lockfile on a cold cache when deps share one repo",
async () => {
using dir = tempDir("git-dep-lockfile", {});
const root = String(dir);
await using server = serveStatic(root);
const repoUrl = `git+http://localhost:${server.port}/shared-repo.git`;
await makeSharedRepo(root, [
{ name: "@scope/pkg-m", branch: "pkg-m" },
{ name: "@scope/pkg-n", branch: "pkg-n" },
]);
const project = join(root, "project");
mkdirSync(project);
writeFileSync(
join(project, "package.json"),
JSON.stringify({
name: "project",
version: "1.0.0",
dependencies: {
"@scope/pkg-m": `${repoUrl}#pkg-m`,
"@scope/pkg-n": `${repoUrl}#pkg-n`,
},
}),
);
// fresh install to produce a complete lockfile
{
const { stderr, exitCode } = await runInstall(project, join(root, "cache-warm"), {});
expect(stderr).not.toContain("error:");
expect(exitCode).toBe(0);
expect(await installedVersionOf(project, "@scope/pkg-m")).toBe("pkg-m");
expect(await installedVersionOf(project, "@scope/pkg-n")).toBe("pkg-n");
}
// simulate a fresh machine: keep bun.lock, drop node_modules + cache
await Bun.$`rm -rf ${join(project, "node_modules")}`;
const { stderr, exitCode } = await runInstall(project, join(root, "cache-cold"), {}, "--frozen-lockfile");
expect(stderr).not.toContain("error:");
expect(await installedVersionOf(project, "@scope/pkg-m")).toBe("pkg-m");
expect(await installedVersionOf(project, "@scope/pkg-n")).toBe("pkg-n");
expect(exitCode).toBe(0);
},
30_000,
);
// With the isolated linker, a cold-cache frozen install re-enqueues each
// dependency after the shared clone completes; the checkout id was derived
// from the branch committish's current tip instead of the lockfile's pinned
// SHA, so a branch that moved after the lockfile was written installed the
// wrong commit and stranded the install context. The hoisted linker had the
// same mismatch in its clone-completion waiter loop.
for (const linker of ["hoisted", "isolated"] as const) {
test.concurrent(
`${linker} linker installs the locked commit from a cold cache after the branch moves`,
async () => {
using dir = tempDir(`git-dep-${linker}-moved`, {});
const root = String(dir);
await using server = serveStatic(root);
const repoUrl = `git+http://localhost:${server.port}/shared-repo.git`;
const bare = await makeSharedRepo(root, [
{ name: "@scope/pkg-m", branch: "pkg-m" },
{ name: "@scope/pkg-n", branch: "pkg-n" },
]);
const project = join(root, "project");
mkdirSync(project);
writeFileSync(
join(project, "package.json"),
JSON.stringify({
name: "project",
version: "1.0.0",
dependencies: {
"@scope/pkg-m": `${repoUrl}#pkg-m`,
"@scope/pkg-n": `${repoUrl}#pkg-n`,
},
}),
);
// fresh install to produce a complete lockfile
{
const { stderr, exitCode } = await runInstall(project, join(root, "cache-warm"), {}, `--linker=${linker}`);
expect(stderr).not.toContain("error:");
expect(exitCode).toBe(0);
}
// move pkg-m past the locked commit
const work = join(root, "work");
await git(work, "checkout", "-q", "pkg-m");
writeFileSync(join(work, "index.js"), `module.exports = "pkg-m-v2";\n`);
await git(work, "commit", "-aqm", "v2", "--no-gpg-sign");
await git(work, "push", "-q", bare, "pkg-m");
await git(bare, "update-server-info");
// cold cache from the lockfile: must install the locked commit, not the tip
await Bun.$`rm -rf ${join(project, "node_modules")}`;
const { stderr, exitCode } = await runInstall(
project,
join(root, "cache-cold"),
{},
"--frozen-lockfile",
`--linker=${linker}`,
);
expect(stderr).not.toContain("error:");
expect(await installedVersionOf(project, "@scope/pkg-m")).toBe("pkg-m");
expect(await installedVersionOf(project, "@scope/pkg-n")).toBe("pkg-n");
expect(exitCode).toBe(0);
},
30_000,
);
}
// issue #35420 bug 3: `git+file://` dependencies never cloned at all — the
// clone task recognized neither an https nor an ssh URL and finished without
// running git, leaving a poisoned repo handle behind.
test.concurrent("installs a git+file:// dependency", async () => {
using dir = tempDir("git-dep-file", {});
const root = String(dir);
const bare = await makeSharedRepo(root, [{ name: "@scope/pkg-b", branch: "pkg-b" }]);
const project = join(root, "project");
mkdirSync(project);
writeFileSync(
join(project, "package.json"),
JSON.stringify({
name: "project",
version: "1.0.0",
dependencies: {
"@scope/pkg-b": `git+${pathToFileURL(bare)}#pkg-b`,
},
}),
);
const { stderr, exitCode } = await runInstall(project, join(root, "cache"), {});
expect(stderr).not.toContain("error:");
expect(await installedVersionOf(project, "@scope/pkg-b")).toBe("pkg-b");
expect(exitCode).toBe(0);
});