Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions src/install/PackageManager/PackageManagerEnqueue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1182,6 +1182,14 @@
return Ok(());
}

// Second: the package an identical dependency literal resolved to
if let Some(pkg_id) =
find_locked_git_package(this, id, dependency, &dep, ResolutionTag::Git)
{
success_fn(this, id, pkg_id);
return Ok(());
}

// reshaped for borrowck — `alias`/`url` borrow
// `this.lockfile.buffers.string_bytes`; detach the slice
// lifetimes so the `&mut PackageManager` reborrows for the
Expand Down Expand Up @@ -1292,6 +1300,14 @@
return Ok(());
}

// Second: the package an identical dependency literal resolved to
if let Some(pkg_id) =
find_locked_git_package(this, id, dependency, dep, ResolutionTag::Github)
{
success_fn(this, id, pkg_id);
return Ok(());
}

let url = this.alloc_github_url(dep);
// url is Box<[u8]>; dropped at scope end
let task_id = Task::Id::for_tarball(&url);
Expand Down Expand Up @@ -1933,6 +1949,70 @@
}
}

/// The package an identical git/github dependency (same name and version
/// literal) is already bound to. `bun.lock` writes the resolved commit in the
/// committish position of the resolution string, so after a reload a branch,
/// tag, or bare ref never matches `get_package_id`'s committish comparison;
/// the unchanged dependency literal is the lossless record of the previous
/// resolution, and reusing its binding keeps re-resolution off the network.
Comment thread
robobun marked this conversation as resolved.
fn find_locked_git_package(
this: &PackageManager,
id: DependencyID,
dependency: &Dependency,
repo: &Repository,
resolution_tag: ResolutionTag,
) -> Option<PackageID> {
if this.lockfile.buffers.resolutions[id as usize] != invalid_package_id {
return None;
}

// An update target must re-resolve against the remote (same test as
// `Diff::generate`; an empty request list is a bare `bun update`).
Comment thread
robobun marked this conversation as resolved.
if this.to_update
&& (this.update_requests.is_empty()
|| this
.update_requests
.iter()
.any(|request| request.name_hash == dependency.name_hash))
{
return None;
}

let buf = this.lockfile.buffers.string_bytes.as_slice();
let package_resolutions = this.lockfile.packages.items_resolution();
let dependencies = this.lockfile.buffers.dependencies.as_slice();
let resolutions = this.lockfile.buffers.resolutions.as_slice();

for (other, &package_id) in dependencies.iter().zip(resolutions) {
if package_id == invalid_package_id || (package_id as usize) >= package_resolutions.len() {
continue;
}
if other.name_hash != dependency.name_hash
|| other.version.tag != dependency.version.tag
|| !other
.version
.literal
.eql(dependency.version.literal, buf, buf)
{
continue;
}
// Overrides/catalogs replace the version after parsing, so the bound
// package must also match the effective repository (changed overrides
// and catalogs invalidate old bindings before re-enqueueing).
Comment thread
robobun marked this conversation as resolved.
let resolution = &package_resolutions[package_id as usize];
if resolution.tag != resolution_tag {
continue;
}
let locked = resolution.repository();
if !locked.repo.eql(repo.repo, buf, buf) || !locked.owner.eql(repo.owner, buf, buf) {
continue;

Check warning on line 2008 in src/install/PackageManager/PackageManagerEnqueue.rs

View check run for this annotation

Claude / Claude Code Review

SCP-style git URLs (git@host:path) are excluded from the lockfile reuse optimization

Bare SCP-style git specifiers (`git@host:owner/repo.git#ref` — the default SSH remote form for private repos) don't benefit from this reuse: the lockfile Formatter prepends `ssh://` on write (repository.rs:1097) but `parse_append_git` doesn't strip it on read, so `locked.repo` = `"ssh://git@host:..."` never byte-equals the freshly-parsed `"git@host:..."` here and the guard falls through to the fetch task. Not a regression — behavior for these URLs is unchanged from before the PR — but stripping
Comment thread
robobun marked this conversation as resolved.
}
return Some(package_id);
}

Check failure on line 2011 in src/install/PackageManager/PackageManagerEnqueue.rs

View check run for this annotation

Claude / Claude Code Review

Changing an override/catalog git committish is silently ignored when multiple dependency slots share the name

Changing an override or catalog entry's git committish (e.g. `#v1` → `#v2` on the same repo URL) is now silently ignored whenever ≥2 dependency slots carry that name: the override/catalog loops in `install_with_manager.rs:487-527` invalidate and re-enqueue **one slot at a time**, so when `find_locked_git_package` scans for slot *i* it finds sibling slot *j* still bound to the stale package — same pre-override `name_hash`/`version.tag`/`version.literal`, and the repo/owner check passes because on
Comment thread
robobun marked this conversation as resolved.

None
}

pub(crate) enum ResolvedPackageTask {
/// Pending network task to schedule
NetworkTask(*mut NetworkTask),
Expand Down
249 changes: 249 additions & 0 deletions test/cli/install/bun-lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1012,3 +1012,252 @@ it("optional peer with a non-wildcard range is idempotent with two versions of t
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
await run(["install", "--frozen-lockfile"]);
});

// Minimal gzipped tarball with a single root folder wrapping the files, the
// shape of both github codeload tarballs and npm pack tarballs.
function makeTarball(rootDir: string, files: Record<string, string>): Uint8Array {
function tarHeader(name: string, size: number, isDir: boolean): Uint8Array {
const header = new Uint8Array(512);
const encoder = new TextEncoder();
header.set(encoder.encode(name), 0);
header.set(encoder.encode(isDir ? "0000755 " : "0000644 "), 100);
header.set(encoder.encode("0000000 "), 108);
header.set(encoder.encode("0000000 "), 116);
header.set(encoder.encode(size.toString(8).padStart(11, "0") + " "), 124);
header.set(encoder.encode("00000000000 "), 136);
header.set(encoder.encode(" "), 148);
header[156] = (isDir ? "5" : "0").charCodeAt(0);
header.set(encoder.encode("ustar"), 257);
header.set(encoder.encode("00"), 263);
let checksum = 0;
for (const byte of header) checksum += byte;
header.set(encoder.encode(checksum.toString(8).padStart(6, "0") + "\0 "), 148);
return header;
}
const blocks: Uint8Array[] = [];
blocks.push(tarHeader(`${rootDir}/`, 0, true));
for (const [name, contents] of Object.entries(files)) {
const bytes = new TextEncoder().encode(contents);
blocks.push(tarHeader(`${rootDir}/${name}`, bytes.length, false));
blocks.push(bytes);
if (bytes.length % 512 !== 0) blocks.push(new Uint8Array(512 - (bytes.length % 512)));
}
blocks.push(new Uint8Array(1024));
return Bun.gzipSync(Buffer.concat(blocks));
}

// Isolate git from system/global config (e.g. core.autocrlf on Windows).
async function makeGitFixture(packageDir: string) {
const gitEnv = {
...env,
GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: join(packageDir, "gitconfig"),
GIT_AUTHOR_NAME: "bun-test",
GIT_AUTHOR_EMAIL: "test@bun.sh",
GIT_COMMITTER_NAME: "bun-test",
GIT_COMMITTER_EMAIL: "test@bun.sh",
};
async function git(args: string[], cwd: string): Promise<string> {
await using proc = spawn({ cmd: ["git", ...args], cwd, env: gitEnv, stdout: "pipe", stderr: "pipe" });
const [out, err, code] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(err).not.toContain("fatal:");
expect(code).toBe(0);
return out;
}
Comment thread
robobun marked this conversation as resolved.
await write(join(packageDir, "gitconfig"), "[core]\n\tautocrlf = false\n");
return { gitEnv, git };
}

// The text lockfile writes the resolved commit in the committish position of a
// git/github resolution string ("git+url#<sha>"), so after a lockfile round
// trip a dependency naming a branch or tag (or no ref at all) never matches the
// loaded committish again. Re-resolving (any edit that re-parses a workspace
// member's dependency list) then fetched every such dependency from the remote
// on every install. The identical dependency literal already bound in the
// loaded lockfile must be reused instead.
it("re-resolving reuses branch and bare ref git dependencies from the lockfile instead of re-fetching", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
const { gitEnv, git } = await makeGitFixture(packageDir);

// Bare repos served over git's dumb HTTP protocol: after
// `git update-server-info`, a bare repo is plain static files.
async function makeBareRepo(name: string): Promise<string> {
const srcDir = join(packageDir, `${name}-src`);
await write(join(srcDir, "package.json"), JSON.stringify({ name, version: "1.0.0" }));
await write(join(srcDir, "index.js"), `module.exports = '${name}';\n`);
await git(["init", "-q", "-b", "main"], srcDir);
await git(["add", "-A"], srcDir);
await git(["commit", "-qm", "init"], srcDir);
const sha = (await git(["rev-parse", "HEAD"], srcDir)).trim();
await git(["clone", "-q", "--bare", srcDir, join(packageDir, `${name}.git`)], packageDir);
await git(["update-server-info"], join(packageDir, `${name}.git`));
return sha;
}
const bareSha = await makeBareRepo("bare-dep");
const branchSha = await makeBareRepo("branch-dep");

let gitRequests = 0;
await using gitServer = Bun.serve({
port: 0,
async fetch(req) {
gitRequests++;
const { pathname } = new URL(req.url);
const match = pathname.match(/^\/((?:bare|branch)-dep\.git)\/(.+)$/);
if (!match) return new Response("not found", { status: 404 });
const f = file(join(packageDir, match[1], match[2]));
return (await f.exists()) ? new Response(f) : new Response("not found", { status: 404 });
},
});

const ghTarball = makeTarball("testowner-testrepo-aaaaaaa", {
"package.json": JSON.stringify({ name: "gh-dep", version: "1.0.0" }),
"index.js": "module.exports = 'gh';\n",
});
let githubDownloads = 0;
await using ghServer = Bun.serve({
port: 0,
fetch() {
githubDownloads++;
return new Response(ghTarball, { headers: { "Content-Type": "application/gzip" } });
},
});

const installEnv = {
...gitEnv,
GITHUB_API_URL: `http://localhost:${ghServer.port}`,
// CI exports BUN_INSTALL_CACHE_DIR; pin it so this test's cache is its own.
BUN_INSTALL_CACHE_DIR: join(packageDir, ".bun-cache"),
};
async function install(retries = 1) {
const gitRequestsBefore = gitRequests;
const githubDownloadsBefore = githubDownloads;
await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
env: installEnv,
stdout: "pipe",
stderr: "pipe",
});
const [err, code] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]);
// A loaded CI machine can OOM-kill the spawned git child (SIGKILL); that
// is environmental, not the behavior under test. Restore the counters so
// the retried attempt starts from the aborted attempt's baseline.
if (retries > 0 && err.includes("git failed with signal 9")) {
gitRequests = gitRequestsBefore;
githubDownloads = githubDownloadsBefore;
return install(retries - 1);
}
expect(err).not.toContain("error:");
expect(code).toBe(0);
}

await write(packageJson, JSON.stringify({ name: "ws-root", workspaces: ["packages/*"] }));
const memberPackageJson = join(packageDir, "packages", "member", "package.json");
const memberDeps: Record<string, string> = {
"bare-dep": `git+http://127.0.0.1:${gitServer.port}/bare-dep.git`,
"branch-dep": `git+http://127.0.0.1:${gitServer.port}/branch-dep.git#main`,
"gh-dep": "github:testowner/testrepo#main",
};
await write(memberPackageJson, JSON.stringify({ name: "member", version: "1.0.0", dependencies: memberDeps }));
await write(
join(packageDir, "packages", "member", "dummy", "package.json"),
JSON.stringify({ name: "dummy", version: "1.0.0" }),
);

await install();
expect(gitRequests).toBeGreaterThan(0);
expect(githubDownloads).toBeGreaterThan(0);
const lock = await file(join(packageDir, "bun.lock")).text();
expect(lock).toContain(`bare-dep@git+http://127.0.0.1:${gitServer.port}/bare-dep.git#${bareSha}`);
expect(lock).toContain(`branch-dep@git+http://127.0.0.1:${gitServer.port}/branch-dep.git#${branchSha}`);

// Dirty the lockfile with a change that re-resolves the member's unchanged
// dependencies (a workspace member edit re-parses its whole dependency list).
memberDeps["dummy"] = "file:./dummy";
await write(memberPackageJson, JSON.stringify({ name: "member", version: "1.0.0", dependencies: memberDeps }));
const requestsAfterFirstInstall = gitRequests;
const downloadsAfterFirstInstall = githubDownloads;
await install();
expect({ gitRequests, githubDownloads }).toEqual({
gitRequests: requestsAfterFirstInstall,
githubDownloads: downloadsAfterFirstInstall,
});

// The locked commits did not move.
const lockAfter = await file(join(packageDir, "bun.lock")).text();
expect(lockAfter).toContain(`bare-dep@git+http://127.0.0.1:${gitServer.port}/bare-dep.git#${bareSha}`);
expect(lockAfter).toContain(`branch-dep@git+http://127.0.0.1:${gitServer.port}/branch-dep.git#${branchSha}`);
expect(await file(join(packageDir, "node_modules", "bare-dep", "index.js")).text()).toBe(
"module.exports = 'bare-dep';\n",
);
expect(await file(join(packageDir, "node_modules", "branch-dep", "index.js")).text()).toBe(
"module.exports = 'branch-dep';\n",
);
expect(await file(join(packageDir, "node_modules", "gh-dep", "index.js")).text()).toBe("module.exports = 'gh';\n");
});

// `bun update` must keep going to the remote for a branch-tracking ref: the
// reuse above is explicitly skipped for update targets.
it("`bun update` still re-resolves a branch ref git dependency against the remote", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
const { gitEnv, git } = await makeGitFixture(packageDir);

const srcDir = join(packageDir, "git-src");
const bareDir = join(packageDir, "repo.git");
await write(join(srcDir, "package.json"), JSON.stringify({ name: "git-dep", version: "1.0.0" }));
await git(["init", "-q", "-b", "main"], srcDir);
await git(["add", "-A"], srcDir);
await git(["commit", "-qm", "init"], srcDir);
await git(["clone", "-q", "--bare", srcDir, bareDir], packageDir);
await git(["update-server-info"], bareDir);

let gitRequests = 0;
await using server = Bun.serve({
port: 0,
async fetch(req) {
gitRequests++;
const { pathname } = new URL(req.url);
if (!pathname.startsWith("/repo.git/")) return new Response("not found", { status: 404 });
const f = file(join(bareDir, pathname.slice("/repo.git/".length)));
return (await f.exists()) ? new Response(f) : new Response("not found", { status: 404 });
},
});

const installEnv = {
...gitEnv,
BUN_INSTALL_CACHE_DIR: join(packageDir, ".bun-cache"),
};
async function run(args: string[], retries = 1) {
await using proc = spawn({
cmd: [bunExe(), ...args],
cwd: packageDir,
env: installEnv,
stdout: "pipe",
stderr: "pipe",
});
const [err, code] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]);
// A loaded CI machine can OOM-kill the spawned git child (SIGKILL);
// retrying only ever adds requests, so the requests-increase assertion
// holds.
if (retries > 0 && err.includes("git failed with signal 9")) {
return run(args, retries - 1);
}
expect(err).not.toContain("error:");
expect(code).toBe(0);
}

await write(
packageJson,
JSON.stringify({
name: "git-update-root",
dependencies: { "git-dep": `git+http://127.0.0.1:${server.port}/repo.git#main` },
}),
);

await run(["install"]);
const requestsAfterInstall = gitRequests;
expect(requestsAfterInstall).toBeGreaterThan(0);

await run(["update", "git-dep"]);
expect(gitRequests).toBeGreaterThan(requestsAfterInstall);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated