diff --git a/src/install/PackageInstaller.rs b/src/install/PackageInstaller.rs index a73884b9ccba..f2a3373474bd 100644 --- a/src/install/PackageInstaller.rs +++ b/src/install/PackageInstaller.rs @@ -1609,8 +1609,8 @@ impl<'a> PackageInstaller<'a> { if package_manager::enqueue_git_for_checkout( self.manager_mut(), dependency_id, + package_id, alias.slice(string_buf!()), - resolution, context, download_patch_hash, ) == package_manager::GitEnqueueResult::OfflineMiss diff --git a/src/install/PackageManager/PackageManagerEnqueue.rs b/src/install/PackageManager/PackageManagerEnqueue.rs index ea9ec05aa566..59a120f90b14 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.rs +++ b/src/install/PackageManager/PackageManagerEnqueue.rs @@ -261,17 +261,16 @@ pub enum GitEnqueueResult { OfflineMiss, } +/// `dependency_id` need not resolve to `package_id`: see `TaskCallbackContext::GitCheckout`. pub fn enqueue_git_for_checkout( this: &mut PackageManager, dependency_id: DependencyID, + package_id: PackageID, alias: &[u8], - resolution: &Resolution, task_context: TaskCallbackContext, patch_name_and_version_hash: Option, ) -> GitEnqueueResult { - // SAFETY: caller passes `resolution.tag == Git`; the `git` arm is the - // active union field. Copy out so the value no longer borrows - // `*resolution` while `*this` is mutably reborrowed below. + let resolution: Resolution = this.lockfile.packages.items_resolution()[package_id as usize]; let repository: Repository = *resolution.git(); // reshaped for borrowck — `url`/`resolved` borrow // `this.lockfile.buffers.string_bytes`; detach the slice lifetimes so the @@ -314,9 +313,9 @@ pub fn enqueue_git_for_checkout( repo_fd, dependency_id, alias, - resolution, + &resolution, resolved, - patch_name_and_version_hash, + patch_name_and_version_hash.map(|hash| (package_id, hash)), ); this.enqueue_git_task(task); } else { @@ -327,14 +326,17 @@ pub fn enqueue_git_for_checkout( clone_queue .value_ptr - .push(TaskCallbackContext::Dependency(dependency_id)); + .push(TaskCallbackContext::GitCheckout { + dependency_id, + package_id, + }); if clone_queue.found_existing { return GitEnqueueResult::Queued; } let dep = this.lockfile.buffers.dependencies[dependency_id as usize].clone(); - let task = enqueue_git_clone(this, clone_id, alias, &repository, &dep, resolution, None); + let task = enqueue_git_clone(this, clone_id, alias, &repository, &dep, None); this.enqueue_git_task(task); } GitEnqueueResult::Queued @@ -1386,56 +1388,36 @@ pub fn enqueue_dependency_with_main_and_success_fn( let needs_ctx = this.lockfile.buffers.resolutions[id as usize] == invalid_package_id; - // An already-resolved dependency (install-phase re-enqueue - // after the shared clone finished) is pinned: its install - // context is keyed on the stored SHA, and a branch - // committish's current tip may differ. - let pinned: Option> = if needs_ctx { - None - } else { - let pkg_id = this.lockfile.buffers.resolutions[id as usize]; - let pkg_res = this.lockfile.packages.items_resolution()[pkg_id as usize]; - // SAFETY: tag checked — `value.git` is the active union arm. - (pkg_res.tag == ResolutionTag::Git) - .then(|| this.lockfile.str(&pkg_res.git().resolved).to_vec()) - }; - let resolved = match pinned { - Some(resolved) => resolved, + // Waits on a `git log` task; `run_tasks` fills `git_commits` and re-enters here. + let committish = this.lockfile.str_detached(&dep.committish); + let commit_id = Task::Id::for_git_commit(url, committish); + let resolved = match this.git_commits.get(&commit_id) { + Some(resolved) => resolved.clone(), None => { - // Waits on a `git log` task; `run_tasks` fills `git_commits` and re-enters here. - let committish = this.lockfile.str_detached(&dep.committish); - let commit_id = Task::Id::for_git_commit(url, committish); - match this.git_commits.get(&commit_id) { - Some(resolved) => resolved.clone(), - None => { - let entry = this - .task_queue - .get_or_put_context(commit_id, ()) - .expect("unreachable"); - if !entry.found_existing { - *entry.value_ptr = TaskCallbackList::default(); - } - entry.value_ptr.push(ctx); - - if dependency.behavior.is_peer() && !install_peer { - this.peer_dependencies.write_item(id)?; - return Ok(()); - } + let entry = this + .task_queue + .get_or_put_context(commit_id, ()) + .expect("unreachable"); + if !entry.found_existing { + *entry.value_ptr = TaskCallbackList::default(); + } + entry.value_ptr.push(ctx); - if this.has_created_network_task( - commit_id, - dependency.behavior.is_required(), - ) { - return Ok(()); - } + if dependency.behavior.is_peer() && !install_peer { + this.peer_dependencies.write_item(id)?; + return Ok(()); + } - let task = enqueue_git_commit( - this, commit_id, clone_id, alias, url, committish, - ); - this.enqueue_git_task(task); - return Ok(()); - } + if this + .has_created_network_task(commit_id, dependency.behavior.is_required()) + { + return Ok(()); } + + let task = + enqueue_git_commit(this, commit_id, clone_id, alias, url, committish); + this.enqueue_git_task(task); + return Ok(()); } }; let checkout_id = Task::Id::for_git_checkout(url, &resolved); @@ -1504,7 +1486,7 @@ pub fn enqueue_dependency_with_main_and_success_fn( return Ok(()); } - let task = enqueue_git_clone(this, clone_id, alias, &dep, dependency, &res, None); + let task = enqueue_git_clone(this, clone_id, alias, &dep, dependency, None); this.enqueue_git_task(task); } Ok(()) @@ -1924,7 +1906,6 @@ fn enqueue_git_clone( name: &[u8], repository: &Repository, dependency: &Dependency, - res: &Resolution, // if patched then we need to do apply step after network task is done patch_name_and_version_hash: Option, ) -> NonNull> { @@ -1966,7 +1947,6 @@ fn enqueue_git_clone( &mut crate::network_task::filename_store_appender(), ) .expect("unreachable"), - res: *res, }), }, id: task_id, @@ -2039,16 +2019,17 @@ pub fn enqueue_git_checkout( name: &[u8], resolution: &Resolution, resolved: &[u8], - // if patched then we need to do apply step after network task is done - patch_name_and_version_hash: Option, + // the package to patch and its name-and-version hash, if an apply step follows the checkout + patch: Option<(PackageID, u64)>, ) -> NonNull> { // The patched-dependency entry can be missing (or its hash not yet // computed) when install state went stale — e.g. the patch was removed // from package.json, leaving the hash only in // `patched_dependencies_to_remove`. Install the package unpatched instead // of panicking. - let patch = patch_name_and_version_hash.and_then(|h| { + let patch = patch.and_then(|(pkg_id, h)| { Some(( + pkg_id, h, this.lockfile .patched_dependencies @@ -2087,18 +2068,7 @@ pub fn enqueue_git_checkout( .expect("unreachable"), }), }, - apply_patch_task: if let Some((h, patch_hash)) = patch { - let dep_name_hash = - this.lockfile.buffers.dependencies[dependency_id as usize].name_hash; - let pkg_id = match this - .lockfile - .package_index - .get(&dep_name_hash) - .unwrap_or_else(|| panic!("Package not found")) - { - PackageIndexEntry::Id(p) => *p, - PackageIndexEntry::Ids(ps) => ps[0], // TODO is this correct - }; + apply_patch_task: if let Some((pkg_id, h, patch_hash)) = patch { let mut pt = PatchTask::new_apply_patch_hash(this, pkg_id, patch_hash, h); pt.callback.apply_mut().task_id = Some(task_id); Some(pt) @@ -3235,16 +3205,16 @@ impl PackageManager { pub(crate) fn enqueue_git_for_checkout( &mut self, dependency_id: DependencyID, + package_id: PackageID, alias: &[u8], - resolution: &Resolution, task_context: TaskCallbackContext, patch_name_and_version_hash: Option, ) -> GitEnqueueResult { enqueue_git_for_checkout( self, dependency_id, + package_id, alias, - resolution, task_context, patch_name_and_version_hash, ) diff --git a/src/install/PackageManager/processDependencyList.rs b/src/install/PackageManager/processDependencyList.rs index 7fdd1f654dac..04d5e03e6842 100644 --- a/src/install/PackageManager/processDependencyList.rs +++ b/src/install/PackageManager/processDependencyList.rs @@ -395,6 +395,10 @@ impl PackageManager { } } } + // A linker's `run_tasks` starts this checkout. Here it would be dropped. + TaskCallbackContext::GitCheckout { .. } => { + debug_assert!(false, "git checkout waiter outside the install phase"); + } _ => {} } Ok(()) diff --git a/src/install/PackageManager/runTasks.rs b/src/install/PackageManager/runTasks.rs index 3feca04e87af..080ca06edeb8 100644 --- a/src/install/PackageManager/runTasks.rs +++ b/src/install/PackageManager/runTasks.rs @@ -1334,60 +1334,32 @@ fn run_tasks_erased( // never reaches checkout, so drain every waiting // checkout for this repo or the install loop blocks // forever on the entry's pending-task slot. - let mut drained_any = false; if let Some(waiters) = manager.task_queue.remove(&task.id) { - let pkg_resolutions = manager.lockfile.packages.items_resolution(); for waiter in waiters.iter() { - let dep_id = match waiter { - bun_install::TaskCallbackContext::Dependency(id) => *id, - _ => continue, - }; - let pkg_id = manager.lockfile.buffers.resolutions[dep_id as usize]; - if pkg_id == INVALID_PACKAGE_ID { - continue; - } - let res = &pkg_resolutions[pkg_id as usize]; - if res.tag != bun_install::ResolutionTag::Git { + let &bun_install::TaskCallbackContext::GitCheckout { + package_id, + .. + } = waiter + else { continue; - } - // SAFETY: `res.tag == Git` checked just above — - // `value.git` is the active union arm. - let res_git = res.git(); + }; + let res = manager.lockfile.packages.items_resolution() + [package_id as usize]; + let git = res.git(); let checkout_id = Task::Id::for_git_checkout( - manager.lockfile.str(&res_git.repo), - manager.lockfile.str(&res_git.resolved), + manager.lockfile.str(&git.repo), + manager.lockfile.str(&git.resolved), ); - drained_any = true; (cb.on_package_download_error_store)( extract_ctx, checkout_id, name, - res, + &res, err, url, ); } } - if !drained_any { - // No clone waiters recorded (or all were skipped - // above) — fall back to the clone task's own - // resolution so the originating entry is still - // released. - // SAFETY: `clone.res.tag == Git` — git-clone tasks are - // only enqueued for git resolutions; `value.git` is - // the active union arm. - let resolved = &clone.res.git().resolved; - let checkout_id = - Task::Id::for_git_checkout(url, manager.lockfile.str(resolved)); - (cb.on_package_download_error_store)( - extract_ctx, - checkout_id, - name, - &clone.res, - err, - url, - ); - } } else if log_level != Options::LogLevel::Silent { bun_ast::add_error_pretty!( manager.log_mut(), @@ -1403,16 +1375,19 @@ fn run_tasks_erased( manager.git_repositories.insert(task.id, repo_fd); - if cb.has_on_extract && cb.is_package_installer { + if cb.has_on_extract { // Installing! The clone task is shared by every dependency on // this repo URL; enqueue a checkout per waiter, not just one. let Some(waiters) = manager.task_queue.remove(&task.id) else { continue; }; for waiter in waiters.iter() { - let dep_id = match waiter { - bun_install::TaskCallbackContext::Dependency(id) => *id, - _ => continue, + let &bun_install::TaskCallbackContext::GitCheckout { + dependency_id: dep_id, + package_id, + } = waiter + else { + continue; }; // reshaped for borrowck — copy the small `String` handles // so the `&manager.lockfile` borrow doesn't extend across @@ -1421,16 +1396,7 @@ fn run_tasks_erased( let dep = &manager.lockfile.buffers.dependencies[dep_id as usize]; (dep.name, dep.behavior.is_required()) }; - let pkg_id = manager.lockfile.buffers.resolutions[dep_id as usize]; - if pkg_id == INVALID_PACKAGE_ID { - continue; - } - let res = manager.lockfile.packages.items_resolution()[pkg_id as usize]; - if res.tag != bun_install::ResolutionTag::Git { - continue; - } - // SAFETY: `res.tag == Git` checked just above — - // `value.git` is the active union arm. + let res = manager.lockfile.packages.items_resolution()[package_id as usize]; let git = *res.git(); // SAFETY: `string_bytes` lives as long as `manager.lockfile` // and is not reallocated while resolve tasks are draining. diff --git a/src/install/PackageManagerTask.rs b/src/install/PackageManagerTask.rs index e35688d9a7cf..17f4d7c81e99 100644 --- a/src/install/PackageManagerTask.rs +++ b/src/install/PackageManagerTask.rs @@ -572,7 +572,6 @@ pub struct ExtractRequest<'a> { pub struct GitCloneRequest { pub(crate) name: StringOrTinyString, pub(crate) url: StringOrTinyString, - pub(crate) res: Resolution, } pub struct GitCommitRequest { diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index 756bfd98712f..919ba258b509 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -2465,8 +2465,8 @@ pub(crate) fn install_isolated_packages( ResolutionTag::Git => { if installer.manager_mut().enqueue_git_for_checkout( dep_id, + pkg_id, dep.name.slice(string_buf), - &pkg_res, ctx, patch_info.name_and_version_hash(), ) == crate::package_manager::GitEnqueueResult::OfflineMiss diff --git a/src/install/lib.rs b/src/install/lib.rs index e4038faccc6e..59354d28857f 100644 --- a/src/install/lib.rs +++ b/src/install/lib.rs @@ -1090,8 +1090,16 @@ pub enum TaskCallbackContext { IsolatedPackageInstallContext(isolated_install::EntryId), RootDependency(DependencyID), RootRequestId(PackageID), + /// Install phase, parked on a git clone. `dependency_id` may resolve to another package. + GitCheckout { + dependency_id: DependencyID, + package_id: PackageID, + }, } +// One per waiter in `task_queue`. +const _: () = assert!(core::mem::size_of::() == 32); + // We can't know all the packages we need until we've downloaded all the packages // The easy way would be: // 1. Download all packages, parsing their dependencies and enqueuing all dependencies for resolution diff --git a/test/cli/install/bun-install-git-deps.test.ts b/test/cli/install/bun-install-git-deps.test.ts index e63c751009a6..c817384e60d0 100644 --- a/test/cli/install/bun-install-git-deps.test.ts +++ b/test/cli/install/bun-install-git-deps.test.ts @@ -5,9 +5,9 @@ // a bare repo on disk (served over git's dumb HTTP protocol by Bun.serve // when an http URL is needed) or tarballs built in memory. import { afterAll, beforeAll, expect, test } from "bun:test"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "fs"; import { bunEnv, bunExe, isLinux, isWindows, normalizeBunSnapshot, tempDir } from "harness"; -import { join } from "path"; +import { dirname, join } from "path"; import { pathToFileURL } from "url"; const gitEnv = { @@ -493,8 +493,8 @@ test.concurrent( 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 +// With the isolated linker, a cold-cache frozen install re-enqueued each +// dependency after the shared clone completed; 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 @@ -565,6 +565,381 @@ for (const linker of ["hoisted", "isolated"] as const) { ); } +// A registry that has version 1.0.0 of each package: name -> the rest of its +// package.json. The marker its index.js exports is `-from-registry`. +async function serveRegistry(packages: Record) { + const tarballs = new Map(); + for (const [name, fields] of Object.entries(packages)) { + const files = { + "package.json": JSON.stringify({ name, version: "1.0.0", ...fields }), + "index.js": indexJs(`${name}-from-registry`), + }; + tarballs.set(name, await tarballOf("package", files)); + } + return Bun.serve({ + port: 0, + fetch(req) { + const { origin, pathname } = new URL(req.url); + const name = pathname.slice(1).replace(/\.tgz$/, ""); + const tarball = tarballs.get(name); + if (!tarball) return new Response("not found", { status: 404 }); + if (pathname.endsWith(".tgz")) return new Response(tarball); + const dist = { tarball: `${origin}/${name}.tgz`, integrity: integrityOf(tarball) }; + return Response.json({ + name, + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { name, version: "1.0.0", ...packages[name], dist } }, + }); + }, + }); +} + +// bun.lock's `packages` section as key -> `@`. +async function lockedResolutions(project: string): Promise> { + return Object.fromEntries( + Object.entries(await lockedPackages(project)).map(([key, [resolution]]) => [key, resolution]), + ); +} + +// Where the isolated linker installed the `name` that `dir` resolves: the +// store entry, whose node_modules has the package and what it resolves. +function storeEntryOf(dir: string, name: string) { + return dirname(dirname(realpathSync(join(dir, "node_modules", name)))); +} + +// `/project`, a workspace root with no dependencies of its own, and its +// one member `packages/m`. A new workspace gets the isolated linker. +function writeWorkspace(root: string, dependencies: Record) { + const project = join(root, "project"); + const member = join(project, "packages", "m"); + mkdirSync(member, { recursive: true }); + writeFileSync(join(project, "package.json"), JSON.stringify({ name: "project", workspaces: ["packages/*"] })); + writeFileSync(join(member, "package.json"), JSON.stringify({ name: "m", version: "1.0.0", dependencies })); + return { project, member }; +} + +// The isolated linker installs a package once for all the dependencies on it +// and keeps one of them with it: the first that its depth-first, name-ordered +// walk reaches. In the tests below that is, for the git package `b` of the +// workspace member `m`, the peer dependency `b: "*"` of `a`, which is walked +// first. It is not a git dependency, and bun.lock resolves a required one to +// the registry's `b` (`a/b`). Once the repository was cloned, the install took +// the checkout to start from that dependency: it started none and never exited. +for (const peer of ["required", "optional"] as const) { + test.concurrent( + `installs a workspace's git dependency that is the ${peer} peer of an earlier package from a cold cache`, + async () => { + using dir = tempDir(`git-dep-${peer}-peer`, {}); + const root = String(dir); + const bare = await makeSharedRepo(root, [{ name: "b", branch: "main" }], "b.git"); + const repoUrl = `git+${pathToFileURL(bare)}`; + // A required peer is also resolved on its own, from the registry. + await using registry = await serveRegistry( + peer === "required" + ? { a: { peerDependencies: { b: "*" } }, b: {} } + : { a: { peerDependencies: { b: "*" }, peerDependenciesMeta: { b: { optional: true } } } }, + ); + const env = { BUN_CONFIG_REGISTRY: registry.url.href }; + const { project, member } = writeWorkspace(root, { a: "1.0.0", b: `${repoUrl}#main` }); + const locked = { + a: "a@1.0.0", + b: `b@${repoUrl}#${branchCommits(bare).main}`, + m: "m@workspace:packages/m", + ...(peer === "required" && { "a/b": "b@1.0.0" }), + }; + // What `m` resolves, and the `b` that `a` resolves: the git one. + const installed = async () => ({ + ...(await installedVersions(member, ["a", "b"])), + "a > b": await installedVersionOf(storeEntryOf(member, "a"), "b"), + }); + const expected = { a: "a-from-registry", b: "main", "a > b": "main" }; + const output = [expect.stringContaining("bun install v"), "", "2 packages installed"]; + + // fresh install to produce a complete lockfile + { + const { stdout, stderr, exitCode } = await runInstall(project, join(root, "cache-warm"), env); + expect(stderr).toContain("Saved lockfile"); + expect(installOutput(stdout)).toEqual(output); + expect(await installed()).toEqual(expected); + expect(await lockedResolutions(project)).toEqual(locked); + expect(exitCode).toBe(0); + } + + // simulate a fresh machine: keep bun.lock, drop node_modules + cache + rmSync(join(project, "node_modules"), { recursive: true }); + rmSync(join(member, "node_modules"), { recursive: true }); + const { stdout, stderr, exitCode } = await runInstall( + project, + join(root, "cache-cold"), + env, + "--frozen-lockfile", + ); + expect(stderr).toBe(""); + expect(installOutput(stdout)).toEqual(output); + expect(await installed()).toEqual(expected); + expect(exitCode).toBe(0); + }, + ); +} + +// One clone serves every package of a repository. Three wait for it here, and +// the one that a's peer dependency stands for is neither the first nor the last. +test.concurrent("checks out every package of a repository when a peer dependency stands for one of them", async () => { + using dir = tempDir("git-dep-peer-shared-repo", {}); + const root = String(dir); + const names = ["aa", "b", "c"]; + const bare = await makeSharedRepo( + root, + names.map(name => ({ name, branch: name })), + ); + const repoUrl = `git+${pathToFileURL(bare)}`; + const commits = branchCommits(bare); + await using registry = await serveRegistry({ a: { peerDependencies: { b: "*" } }, b: {} }); + const env = { BUN_CONFIG_REGISTRY: registry.url.href }; + const { project, member } = writeWorkspace(root, { + a: "1.0.0", + ...Object.fromEntries(names.map(name => [name, `${repoUrl}#${name}`])), + }); + const locked = { + a: "a@1.0.0", + ...Object.fromEntries(names.map(name => [name, `${name}@${repoUrl}#${commits[name]}`])), + m: "m@workspace:packages/m", + "a/b": "b@1.0.0", + }; + const installed = async () => ({ + ...(await installedVersions(member, ["a", ...names])), + "a > b": await installedVersionOf(storeEntryOf(member, "a"), "b"), + }); + const expected = { a: "a-from-registry", aa: "aa", b: "b", c: "c", "a > b": "b" }; + const output = [expect.stringContaining("bun install v"), "", "4 packages installed"]; + + // fresh install to produce a complete lockfile + { + const { stdout, stderr, exitCode } = await runInstall(project, join(root, "cache-warm"), env); + expect(stderr).toContain("Saved lockfile"); + expect(installOutput(stdout)).toEqual(output); + expect(await installed()).toEqual(expected); + expect(await lockedResolutions(project)).toEqual(locked); + expect(exitCode).toBe(0); + } + + // simulate a fresh machine: keep bun.lock, drop node_modules + cache + rmSync(join(project, "node_modules"), { recursive: true }); + rmSync(join(member, "node_modules"), { recursive: true }); + const { stdout, stderr, exitCode } = await runInstall(project, join(root, "cache-cold"), env, "--frozen-lockfile"); + expect(stderr).toBe(""); + expect(installOutput(stdout)).toEqual(output); + expect(await installed()).toEqual(expected); + expect(exitCode).toBe(0); +}); + +// The repository is gone when the cold install clones it. The failed clone +// fails every package that waits for it. The one that a's peer dependency +// stands for was left waiting, and the install never exited. +test.concurrent( + "fails every package of a repository that cannot be cloned when a peer dependency stands for one of them", + async () => { + using dir = tempDir("git-dep-peer-clone-fails", {}); + const root = String(dir); + const names = ["b", "c"]; + const bare = await makeSharedRepo( + root, + names.map(name => ({ name, branch: name })), + ); + const repoUrl = `git+${pathToFileURL(bare)}`; + await using registry = await serveRegistry({ a: { peerDependencies: { b: "*" } }, b: {} }); + const env = { BUN_CONFIG_REGISTRY: registry.url.href }; + const { project, member } = writeWorkspace(root, { + a: "1.0.0", + ...Object.fromEntries(names.map(name => [name, `${repoUrl}#${name}`])), + }); + + // fresh install to produce a complete lockfile + { + const { stderr, exitCode } = await runInstall(project, join(root, "cache-warm"), env); + expect(stderr).toContain("Saved lockfile"); + expect(await installedVersions(member, ["a", ...names])).toEqual({ a: "a-from-registry", b: "b", c: "c" }); + expect((await lockedResolutions(project))["a/b"]).toBe("b@1.0.0"); + expect(exitCode).toBe(0); + } + + // a fresh machine that cannot reach the repository + rmSync(join(project, "node_modules"), { recursive: true }); + rmSync(join(member, "node_modules"), { recursive: true }); + renameSync(bare, `${bare}.gone`); + const { stdout, stderr, exitCode } = await runInstall(project, join(root, "cache-cold"), env, "--frozen-lockfile"); + const failed = "error: failed to download "; + expect( + stderr + .split(/\r?\n/) + .filter(line => line.startsWith(failed)) + .map(line => line.slice(failed.length, line.indexOf("@"))), + ).toEqual(names); + expect(stdout).toContain("Failed to install 2 packages"); + expect(exitCode).toBe(1); + }, +); + +// `overrides` puts a git commit in place of a's registry dependency `b`. When +// the clone ended, the install sent that dependency through the resolver, which +// found the package of the pinned commit already loaded and started no checkout. +test.concurrent( + "isolated linker installs a git package that an override puts in place of a registry dependency, from a cold cache", + async () => { + using dir = tempDir("git-dep-override", {}); + const root = String(dir); + const bare = await makeSharedRepo(root, [{ name: "b", branch: "main" }], "b.git"); + const repoUrl = `git+${pathToFileURL(bare)}`; + const sha = branchCommits(bare).main; + await using registry = await serveRegistry({ a: { dependencies: { b: "^1.0.0" } } }); + const env = { BUN_CONFIG_REGISTRY: registry.url.href }; + const project = join(root, "project"); + mkdirSync(project); + writeFileSync( + join(project, "package.json"), + JSON.stringify({ + name: "project", + version: "1.0.0", + dependencies: { a: "1.0.0" }, + overrides: { b: `${repoUrl}#${sha}` }, + }), + ); + const installed = async () => ({ + a: await installedVersionOf(project, "a"), + "a > b": await installedVersionOf(storeEntryOf(project, "a"), "b"), + }); + const expected = { a: "a-from-registry", "a > b": "main" }; + const output = [expect.stringContaining("bun install v"), "", "+ a@1.0.0", "", "2 packages installed"]; + + // fresh install to produce a complete lockfile + { + const { stdout, stderr, exitCode } = await runInstall( + project, + join(root, "cache-warm"), + env, + "--linker=isolated", + ); + expect(stderr).toContain("Saved lockfile"); + expect(installOutput(stdout)).toEqual(output); + expect(await installed()).toEqual(expected); + expect(await lockedResolutions(project)).toEqual({ a: "a@1.0.0", b: `b@${repoUrl}#${sha}` }); + expect(exitCode).toBe(0); + } + + // simulate a fresh machine: keep bun.lock, drop node_modules + cache + rmSync(join(project, "node_modules"), { recursive: true }); + const { stdout, stderr, exitCode } = await runInstall( + project, + join(root, "cache-cold"), + env, + "--frozen-lockfile", + "--linker=isolated", + ); + expect(stderr).toBe(""); + expect(installOutput(stdout)).toEqual(output); + expect(await installed()).toEqual(expected); + expect(exitCode).toBe(0); + }, +); + +// bun.lock can spell a repository's URL differently from the dependency: an +// scp-form URL comes back from it with `ssh://` in front. Here the locked URL +// has a `.` segment. The install clones and checks out what bun.lock names. It +// used to go by the dependency once the clone ended: it cloned a second time +// and left the package waiting. +test.concurrent( + "isolated linker checks out a git package whose URL bun.lock spells differently from the dependency", + async () => { + using dir = tempDir("git-dep-url-spelling", {}); + const root = String(dir); + const name = nameOf("b"); + const repoUrl = `git+${pathToFileURL(sharedBare)}`; + const lockedUrl = repoUrl.replace(/\/shared-repo\.git$/, "/./shared-repo.git"); + expect(lockedUrl).not.toBe(repoUrl); + const sha = sharedCommits["pkg-b"]; + const project = writeProject(root, { [name]: `${repoUrl}#pkg-b` }); + writeFileSync( + join(project, "bun.lock"), + JSON.stringify({ + lockfileVersion: 1, + configVersion: 1, + workspaces: { "": { name: "project", dependencies: { [name]: `${repoUrl}#pkg-b` } } }, + packages: { [name]: [`${name}@${lockedUrl}#${sha}`, {}, sha] }, + }), + ); + + const cache = join(root, "cache"); + const { stdout, stderr, exitCode } = await runInstall(project, cache, {}, "--frozen-lockfile", "--linker=isolated"); + expect(stderr).toBe(""); + expectInstalled(stdout, { [name]: `${lockedUrl}#${sha}` }); + expect(await installedVersions(project, [name])).toEqual(markers(["b"])); + // one bare clone of the repository + expect(readdirSync(cache).filter(entry => entry.endsWith(".git"))).toHaveLength(1); + expect(exitCode).toBe(0); + }, +); + +// A patch is applied to a git package right after its checkout when the +// repository was cloned earlier in the same process, here to resolve the new +// dependency `c`. The package to patch was looked up by the name of the +// dependency, which an alias does not share with it: `panic: Package not found`. +for (const [added, editsPackageJson] of [ + ["`bun install `", false], + ["an edit of package.json", true], +] as const) { + test.concurrent( + `patches a git package under an alias when ${added} adds another package of its repository`, + async () => { + using dir = tempDir("git-dep-alias-patch", { + "project/patches/b.patch": `diff --git a/index.js b/index.js +index 0000000000000000000000000000000000000000..1111111111111111111111111111111111111111 100644 +--- a/index.js ++++ b/index.js +@@ -1 +1 @@ +-module.exports = "b"; ++module.exports = "b-patched"; +`, + }); + const root = String(dir); + const project = join(root, "project"); + const bare = await makeSharedRepo( + root, + ["b", "c"].map(name => ({ name, branch: name })), + ); + const repoUrl = `git+${pathToFileURL(bare)}`; + const writePackageJson = (dependencies: Record) => + writeFileSync( + join(project, "package.json"), + JSON.stringify({ + name: "project", + version: "1.0.0", + dependencies, + patchedDependencies: { [`b@${repoUrl}#${branchCommits(bare).b}`]: "patches/b.patch" }, + }), + ); + writePackageJson({ "b-alias": `${repoUrl}#b` }); + + // fresh install to produce a complete lockfile + { + const { stderr, exitCode } = await runInstall(project, join(root, "cache-warm"), {}); + expect(stderr).toContain("Saved lockfile"); + expect(await installedVersions(project, ["b-alias"])).toEqual({ "b-alias": "b-patched" }); + expect(exitCode).toBe(0); + } + + // a fresh machine: keep bun.lock, drop node_modules + cache, and add `c` + rmSync(join(project, "node_modules"), { recursive: true }); + const args: string[] = []; + if (editsPackageJson) writePackageJson({ "b-alias": `${repoUrl}#b`, c: `${repoUrl}#c` }); + else args.push(`${repoUrl}#c`); + const { stderr, exitCode } = await runInstall(project, join(root, "cache-cold"), {}, ...args); + expect(stderr).toContain("Saved lockfile"); + expect(await installedVersions(project, ["b-alias", "c"])).toEqual({ "b-alias": "b-patched", c: "c" }); + expect(exitCode).toBe(0); + }, + ); +} + // 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.