diff --git a/src/install/PackageInstall.rs b/src/install/PackageInstall.rs index 00ab60a992c3..afcc8fcc12e9 100644 --- a/src/install/PackageInstall.rs +++ b/src/install/PackageInstall.rs @@ -2350,6 +2350,12 @@ impl<'a> PackageInstall<'a> { ZStr::from_buf(&buf[..], subpath_len + 1 + b"package.json".len()); break 'package_json_exists sys::exists_at(self.cache_dir, subpath); } + resolution::Tag::Git => { + crate::package_manager::directories::is_git_folder_in_cache_at( + self.cache_dir, + self.cache_dir_subpath.as_bytes(), + ) + } _ => sys::directory_exists_at(self.cache_dir, self.cache_dir_subpath) .unwrap_or(false), }; @@ -2364,15 +2370,22 @@ impl<'a> PackageInstall<'a> { }); let cache_dir_subpath_without_patch_hash = &self.cache_dir_subpath.as_bytes()[..idx]; - // Use a stack PathBuffer (no shared state). - let mut join_buf = PathBuffer::uninit(); - join_buf[..cache_dir_subpath_without_patch_hash.len()] - .copy_from_slice(cache_dir_subpath_without_patch_hash); - join_buf[cache_dir_subpath_without_patch_hash.len()] = 0; - // SAFETY: NUL written above. - let subpath = - ZStr::from_buf(&join_buf[..], cache_dir_subpath_without_patch_hash.len()); - let exists = sys::directory_exists_at(self.cache_dir, subpath).unwrap_or(false); + let exists = if matches!(resolution_tag, resolution::Tag::Git) { + crate::package_manager::directories::is_git_folder_in_cache_at( + self.cache_dir, + cache_dir_subpath_without_patch_hash, + ) + } else { + // Use a stack PathBuffer (no shared state). + let mut join_buf = PathBuffer::uninit(); + join_buf[..cache_dir_subpath_without_patch_hash.len()] + .copy_from_slice(cache_dir_subpath_without_patch_hash); + join_buf[cache_dir_subpath_without_patch_hash.len()] = 0; + // SAFETY: NUL written above. + let subpath = + ZStr::from_buf(&join_buf[..], cache_dir_subpath_without_patch_hash.len()); + sys::directory_exists_at(self.cache_dir, subpath).unwrap_or(false) + }; if exists { manager.set_preinstall_state(package_id, crate::PreinstallState::Done); } diff --git a/src/install/PackageManager/PackageManagerDirectories.rs b/src/install/PackageManager/PackageManagerDirectories.rs index 950f3ef74b54..143e8a35a757 100644 --- a/src/install/PackageManager/PackageManagerDirectories.rs +++ b/src/install/PackageManager/PackageManagerDirectories.rs @@ -754,6 +754,25 @@ pub fn is_folder_in_cache(this: &mut PackageManager, folder_path: &ZStr) -> bool sys::directory_exists_at(get_cache_directory(this), folder_path).unwrap_or(false) } +/// Git checkouts can legitimately lack `package.json`, so their completeness +/// marker is the `.bun-tag` that `Repository::checkout` writes last. A folder +/// without it is a leftover from an interrupted checkout; installing it would +/// produce an empty package. +pub fn is_git_folder_in_cache(this: &mut PackageManager, folder_path: &ZStr) -> bool { + is_git_folder_in_cache_at(get_cache_directory(this), folder_path.as_bytes()) +} + +/// [`is_git_folder_in_cache`] for call sites that hold the cache dir `Fd` and +/// folder subpath directly (the hoisted and isolated installers). +pub fn is_git_folder_in_cache_at(cache_dir: Fd, folder_subpath: &[u8]) -> bool { + let mut buf = PathBuffer::uninit(); + let tag_path = path::resolve_path::join_z_buf::( + &mut buf.0, + &[folder_subpath, b".bun-tag"], + ); + sys::exists_at(cache_dir, tag_path) +} + // ─────────────────────────── global directories ─────────────────────────────── pub fn setup_global_dir(manager: &mut PackageManager, ctx: &Command::Context) -> Result<(), Error> { diff --git a/src/install/PackageManager/PackageManagerLifecycle.rs b/src/install/PackageManager/PackageManagerLifecycle.rs index 730d18f94fa4..cb3cc5bb66d2 100644 --- a/src/install/PackageManager/PackageManagerLifecycle.rs +++ b/src/install/PackageManager/PackageManagerLifecycle.rs @@ -177,7 +177,13 @@ impl PackageManager { return PreinstallState::Extract; } - if directories::is_folder_in_cache(self, folder_path) { + let folder_in_cache = + if matches!(pkg.resolution.tag, ResolutionTag::Git) && patch_hash.is_none() { + directories::is_git_folder_in_cache(self, folder_path) + } else { + directories::is_folder_in_cache(self, folder_path) + }; + if folder_in_cache { self.set_preinstall_state(pkg.meta.id, PreinstallState::Done); return PreinstallState::Done; } @@ -200,7 +206,12 @@ impl PackageManager { }); // Owned NUL-terminated copy. let non_patched_path = ZBox::from_bytes(&folder_path.as_bytes()[..idx]); - if directories::is_folder_in_cache(self, &non_patched_path) { + let base_in_cache = if matches!(pkg.resolution.tag, ResolutionTag::Git) { + directories::is_git_folder_in_cache(self, &non_patched_path) + } else { + directories::is_folder_in_cache(self, &non_patched_path) + }; + if base_in_cache { self.set_preinstall_state(pkg.meta.id, PreinstallState::ApplyPatch); // yay step 1 is already done for us return PreinstallState::ApplyPatch; diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index 35e5ea78bcfc..5eade5c271d7 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -2370,6 +2370,12 @@ pub(crate) fn install_isolated_packages( pkg_cache_dir_subpath.set_length(cache_dir_path_save); exists } + ResolutionTag::Git => { + package_manager::directories::is_git_folder_in_cache_at( + cache_dir, + pkg_cache_dir_subpath.slice_z().as_bytes(), + ) + } _ => sys::directory_exists_at( cache_dir, pkg_cache_dir_subpath.slice_z(), diff --git a/src/install/repository.rs b/src/install/repository.rs index 3b6db3b2906f..a9bfd5f1f1d9 100644 --- a/src/install/repository.rs +++ b/src/install/repository.rs @@ -421,6 +421,20 @@ fn exec(env: &bun_dotenv::Map, argv: &[&[u8]]) -> Result, Error> { Err(crate::Error::InstallFailed) } +/// `.bun-tag` (containing `resolved`) is the last file written when +/// populating a per-commit cache folder. A folder without a matching tag is a +/// leftover from an interrupted or failed clone/checkout and would resolve as +/// an empty package via the missing-`package.json` fallback in `checkout`. +fn cached_checkout_is_complete(dir: &bun_sys::Dir, resolved: &[u8]) -> bool { + match bun_sys::File::read_file_from(dir.fd(), b".bun-tag") { + Ok((file, contents)) => { + let _ = file.close(); // close error is non-actionable + contents == resolved + } + Err(_) => false, + } +} + impl RepositoryExt for Repository { fn parse_append_git(input: &[u8], buf: &mut StringBuf<'_>) -> Result { let mut remain = input; @@ -715,61 +729,119 @@ impl RepositoryExt for Repository { bun_core::ZStr::from_buf(&folder_name_buf[..], written) }; - match bun_sys::Dir::borrow(&cache_dir).open_dir_z(folder_name) { - Ok(dir) => { - let path = Path::resolve_path::join_abs_string::( - &PackageManager::get().cache_directory_path, - &[folder_name.as_bytes()], - ); + let repo_dir = 'repo_dir: { + match bun_sys::Dir::borrow(&cache_dir).open_dir_z(folder_name) { + Ok(dir) => { + // `git clone --bare` populates `HEAD`, `objects/` and + // `refs/` before transferring data; a folder missing any of + // them is a leftover from an interrupted clone that no + // `git fetch` can ever repair. A structurally complete + // mirror that fails to fetch (network, auth) is kept as-is. + let complete = bun_sys::exists_at(dir.fd(), bun_core::zstr!("HEAD")) + && bun_sys::directory_exists_at(dir.fd(), bun_core::zstr!("objects")) + .unwrap_or(false) + && bun_sys::directory_exists_at(dir.fd(), bun_core::zstr!("refs")) + .unwrap_or(false); + if complete { + let path = Path::resolve_path::join_abs_string::( + &PackageManager::get().cache_directory_path, + &[folder_name.as_bytes()], + ); - if let Err(err) = exec(env, &[b"git", b"-C", path, b"fetch", b"--quiet"]) { + if let Err(err) = exec(env, &[b"git", b"-C", path, b"fetch", b"--quiet"]) { + log.add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!("\"git fetch\" for \"{}\" failed", BStr::new(name)), + ); + return Err(err); + } + break 'repo_dir dir; + } + // Incomplete leftover: rebuild it (the rename below replaces it). + dir.close(); + } + Err(not_found) => { + if not_found.get_errno() != bun_sys::E::ENOENT { + return Err(not_found.into()); + } + } + } + + // Clone into a temporary sibling and rename it into place once + // complete, so a kill can't leave a half-built mirror. + let mut tmp_name_buf = [0u8; 64]; + let tmp_name: &[u8] = match bun_resolver::fs::FileSystem::tmpname( + b"tmp", + &mut tmp_name_buf, + bun_core::fast_random(), + ) { + Ok(name) => name.as_bytes(), + // max len is 1+16+1+8+1+3, well below 64 + Err(_no_space_left) => unreachable!(), + }; + + let target = Path::resolve_path::join_abs_string::( + &PackageManager::get().cache_directory_path, + &[tmp_name], + ); + + if let Err(err) = exec( + env, + &[ + b"git", + b"clone", + b"-c", + b"core.longpaths=true", + b"--quiet", + b"--bare", + url, + target, + ], + ) { + let _ = bun_sys::Dir::borrow(&cache_dir).delete_tree(tmp_name); + if err == crate::Error::RepositoryNotFound || attempt > 1 { log.add_error_fmt( None, bun_ast::Loc::EMPTY, - format_args!("\"git fetch\" for \"{}\" failed", BStr::new(name)), + format_args!("\"git clone\" for \"{}\" failed", BStr::new(name)), ); - return Err(err); } - Ok(dir) + return Err(err); } - Err(not_found) => { - if not_found.get_errno() != bun_sys::E::ENOENT { - return Err(not_found.into()); - } - let target = Path::resolve_path::join_abs_string::( - &PackageManager::get().cache_directory_path, - &[folder_name.as_bytes()], + if let Err(err) = bun_sys::renameat_concurrently_a( + cache_dir, + tmp_name, + cache_dir, + folder_name.as_bytes(), + bun_sys::RenameatConcurrentlyOptions { + move_fallback: false, + }, + ) { + log.add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "moving the repository for \"{}\" into the cache failed: {}", + BStr::new(name), + err, + ), ); + let _ = bun_sys::Dir::borrow(&cache_dir).delete_tree(tmp_name); + return Err(crate::Error::InstallFailed); + } - if let Err(err) = exec( - env, - &[ - b"git", - b"clone", - b"-c", - b"core.longpaths=true", - b"--quiet", - b"--bare", - url, - target, - ], - ) { - if err == crate::Error::RepositoryNotFound || attempt > 1 { - log.add_error_fmt( - None, - bun_ast::Loc::EMPTY, - format_args!("\"git clone\" for \"{}\" failed", BStr::new(name)), - ); - } - return Err(err); - } + // A lost rename race (or a replaced stale folder) can leave a + // directory at `tmp_name`; drop it. + let _ = bun_sys::Dir::borrow(&cache_dir).delete_tree(tmp_name); - bun_sys::Dir::borrow(&cache_dir) - .open_dir_z(folder_name) - .map_err(Into::into) - } - } + bun_sys::Dir::borrow(&cache_dir) + .open_dir_z(folder_name) + .map_err(Error::from)? + }; + + Ok(repo_dir) } fn find_commit( @@ -874,92 +946,162 @@ impl RepositoryExt for Repository { ) .as_bytes(); - let package_dir = match bun_sys::Dir::borrow(&cache_dir) - .open_at(folder_name) - .map_err(Error::from) - { - Ok(d) => d, - Err(not_found) => 'brk: { - if not_found != crate::Error::Sys(bun_errno::SystemErrno::ENOENT) { - return Err(not_found); + let package_dir = 'package_dir: { + match bun_sys::Dir::borrow(&cache_dir).open_at(folder_name) { + Ok(dir) => { + if cached_checkout_is_complete(&dir, resolved) { + break 'package_dir dir; + } + // Incomplete leftover: rebuild it (the rename below replaces it). + dir.close(); + } + Err(err) => { + if err.get_errno() != bun_sys::E::ENOENT { + return Err(err.into()); + } } + } - let target = Path::resolve_path::join_abs_string::( - &PackageManager::get().cache_directory_path, - &[folder_name], - ); + // Build the checkout in a temporary sibling and rename it into + // place once complete, so a kill can't leave a half-built folder. + let mut tmp_name_buf = [0u8; 64]; + let tmp_name: &[u8] = match bun_resolver::fs::FileSystem::tmpname( + b"tmp", + &mut tmp_name_buf, + bun_core::fast_random(), + ) { + Ok(name) => name.as_bytes(), + // max len is 1+16+1+8+1+3, well below 64 + Err(_no_space_left) => unreachable!(), + }; - let repo_path = bun_sys::get_fd_path( - repo_dir, - // Per-field accessor — disjoint from `folder_name_buf` - // borrow above. See `TlBufs` accessor doc. - TlBufs::final_path_buf(), - )?; - - if let Err(err) = exec( - env, - &[ - b"git", - b"clone", - b"-c", - b"core.longpaths=true", - b"--quiet", - b"--no-checkout", - repo_path, - target, - ], - ) { - log.add_error_fmt( - None, - bun_ast::Loc::EMPTY, - format_args!("\"git clone\" for \"{}\" failed", BStr::new(name)), - ); - return Err(err); - } + let target = Path::resolve_path::join_abs_string::( + &PackageManager::get().cache_directory_path, + &[tmp_name], + ); - let folder = Path::resolve_path::join_abs_string::( - &PackageManager::get().cache_directory_path, - &[folder_name], + let repo_path = bun_sys::get_fd_path( + repo_dir, + // Per-field accessor — disjoint from `folder_name_buf` + // borrow above. See `TlBufs` accessor doc. + TlBufs::final_path_buf(), + )?; + + if let Err(err) = exec( + env, + &[ + b"git", + b"clone", + b"-c", + b"core.longpaths=true", + b"--quiet", + b"--no-checkout", + repo_path, + target, + ], + ) { + log.add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!("\"git clone\" for \"{}\" failed", BStr::new(name)), ); + let _ = bun_sys::Dir::borrow(&cache_dir).delete_tree(tmp_name); + return Err(err); + } - if let Err(err) = exec( - env, - // `is_safe_resolved_tag` above rejects a leading `-`, so - // `resolved` cannot be parsed as a git option. - &[b"git", b"-C", folder, b"checkout", b"--quiet", resolved], - ) { - log.add_error_fmt( - None, - bun_ast::Loc::EMPTY, - format_args!("\"git checkout\" for \"{}\" failed", BStr::new(name)), - ); - return Err(err); + let folder = Path::resolve_path::join_abs_string::( + &PackageManager::get().cache_directory_path, + &[tmp_name], + ); + + if let Err(err) = exec( + env, + // `is_safe_resolved_tag` above rejects a leading `-`, so + // `resolved` cannot be parsed as a git option. + &[b"git", b"-C", folder, b"checkout", b"--quiet", resolved], + ) { + log.add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!("\"git checkout\" for \"{}\" failed", BStr::new(name)), + ); + let _ = bun_sys::Dir::borrow(&cache_dir).delete_tree(tmp_name); + return Err(err); + } + let dir = match bun_sys::Dir::borrow(&cache_dir).open_at(tmp_name) { + Ok(d) => d, + Err(err) => { + let _ = bun_sys::Dir::borrow(&cache_dir).delete_tree(tmp_name); + return Err(err.into()); } - let dir = bun_sys::Dir::borrow(&cache_dir) - .open_at(folder_name) - .map_err(Error::from)?; - let _ = dir.delete_tree(b".git"); - - if !resolved.is_empty() { - 'insert_tag: { - let Ok(git_tag) = dir.create_file_z( - bun_core::zstr!(".bun-tag"), - bun_sys::CreateFlags { - truncate: true, - ..Default::default() - }, - ) else { - break 'insert_tag; - }; - if git_tag.write_all(resolved).is_err() { - let _ = dir.delete_file_z(bun_core::zstr!(".bun-tag")); - } - let _ = git_tag.close(); // close error is non-actionable - } + }; + let _ = dir.delete_tree(b".git"); + + // `.bun-tag` marks the folder complete (`is_safe_resolved_tag` + // above guarantees `resolved` is non-empty). Publishing the folder + // without it would make every later install re-clone it. + let tag_error = match dir.create_file_z( + bun_core::zstr!(".bun-tag"), + bun_sys::CreateFlags { + truncate: true, + ..Default::default() + }, + ) { + Ok(git_tag) => { + let written = git_tag.write_all(resolved); + let _ = git_tag.close(); // close error is non-actionable + written.err() } + Err(err) => Some(err), + }; + if let Some(err) = tag_error { + log.add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "writing \".bun-tag\" for \"{}\" failed: {}", + BStr::new(name), + BStr::new(err.name()), + ), + ); + dir.close(); + let _ = bun_sys::Dir::borrow(&cache_dir).delete_tree(tmp_name); + return Err(crate::Error::InstallFailed); + } - break 'brk dir; + // Close before the rename: Windows can't move a directory while a + // handle into it is open. + dir.close(); + + if let Err(err) = bun_sys::renameat_concurrently_a( + cache_dir, + tmp_name, + cache_dir, + folder_name, + bun_sys::RenameatConcurrentlyOptions { + move_fallback: false, + }, + ) { + log.add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "moving git checkout of \"{}\" into the cache failed: {}", + BStr::new(name), + err, + ), + ); + let _ = bun_sys::Dir::borrow(&cache_dir).delete_tree(tmp_name); + return Err(crate::Error::InstallFailed); } + + // A lost rename race (or a replaced stale folder) can leave a + // directory at `tmp_name`; drop it. + let _ = bun_sys::Dir::borrow(&cache_dir).delete_tree(tmp_name); + + bun_sys::Dir::borrow(&cache_dir) + .open_at(folder_name) + .map_err(Error::from)? }; let (json_file, json_buf) = diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 5170edcc68f0..83dfd0860889 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -5248,6 +5248,195 @@ describe.concurrent("bun-install", () => { }); }); + it("re-clones a git dependency whose cache folder was left incomplete by an interrupted install", async () => { + using dir = tempDir("git-cache-incomplete", { + gitconfig: "[core]\n\tautocrlf = false\n", + "dep-src/package.json": `${JSON.stringify({ name: "real-dep-name", version: "2.5.0" })}\n`, + "dep-src/index.js": "module.exports = 'hello';\n", + }); + const root = String(dir); + const gitEnv = { + ...env, + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: join(root, "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 { + await using proc = spawn({ cmd: ["git", ...args], cwd, env: gitEnv, stdout: "pipe", stderr: "pipe" }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(err).not.toContain("fatal:"); + expect(exitCode).toBe(0); + return out; + } + const srcDir = join(root, "dep-src"); + 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(root, "my-git-dep.git")], root); + // After `update-server-info` a bare repo is served over git's dumb HTTP + // protocol as plain static files. + await git(["update-server-info"], join(root, "my-git-dep.git")); + await using server = Bun.serve({ + port: 0, + async fetch(req) { + const { pathname } = new URL(req.url); + if (!pathname.startsWith("/my-git-dep.git/")) return new Response("not found", { status: 404 }); + const f = file(join(root, "my-git-dep.git", pathname.slice("/my-git-dep.git/".length))); + return (await f.exists()) ? new Response(f) : new Response("not found", { status: 404 }); + }, + }); + + const cacheDir = join(root, "bun-cache"); + const cacheFolder = join(cacheDir, `@G@${sha}`); + const projectDir = join(root, "project"); + await write( + join(projectDir, "package.json"), + JSON.stringify({ + name: "my-app", + dependencies: { "my-git-dep": `git+http://127.0.0.1:${server.port}/my-git-dep.git` }, + }), + ); + + async function install(cwd = projectDir, args: string[] = [], retries = 1): Promise { + await using proc = spawn({ + cmd: [bunExe(), "install", ...args], + cwd, + env: { ...gitEnv, BUN_INSTALL_CACHE_DIR: cacheDir }, + stdout: "pipe", + stderr: "pipe", + }); + const [err, exitCode, out] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]); + // A loaded CI machine can OOM-kill the spawned git child; that is + // environmental, not the behavior under test. Retry once. + if (retries > 0 && err.includes("git failed with signal 9")) return install(cwd, args, retries - 1); + expect(err).not.toContain("error:"); + // The object form surfaces both streams when the exit code is wrong. + expect({ out, err, exitCode }).toMatchObject({ exitCode: 0 }); + } + async function resetProject(): Promise { + await rm(join(projectDir, "node_modules"), { recursive: true, force: true }); + await rm(join(projectDir, "bun.lock"), { force: true }); + } + async function poisonCacheFolder(): Promise { + await rm(cacheFolder, { recursive: true, force: true }); + await mkdir(cacheFolder, { recursive: true }); + } + + // An install killed between `git clone --no-checkout` and `git checkout` + // leaves the per-commit cache folder behind without its files. It must not + // be trusted as a cached checkout: that silently resolves the dependency + // as an empty package named after the URL ("my-git-dep.git"). + await poisonCacheFolder(); + await install(); + expect(await file(join(projectDir, "node_modules", "my-git-dep", "package.json")).json()).toMatchObject({ + name: "real-dep-name", + version: "2.5.0", + }); + expect(await exists(join(projectDir, "node_modules", "my-git-dep", "index.js"))).toBe(true); + expect(await file(join(projectDir, "bun.lock")).text()).toContain( + `real-dep-name@git+http://127.0.0.1:${server.port}/my-git-dep.git#${sha}`, + ); + expect(await file(join(cacheFolder, ".bun-tag")).text()).toBe(sha); + + // A complete cached checkout (matching .bun-tag) is reused, not re-cloned. + await write(join(cacheFolder, "cache-reused-sentinel.txt"), "reused"); + await resetProject(); + await install(); + expect(await exists(join(projectDir, "node_modules", "my-git-dep", "cache-reused-sentinel.txt"))).toBe(true); + + // A cache folder whose .bun-tag doesn't match the commit it is named after + // is rebuilt from the repository. + await write(join(cacheFolder, ".bun-tag"), "0000000000000000000000000000000000000000"); + await resetProject(); + await install(); + expect(await exists(join(projectDir, "node_modules", "my-git-dep", "cache-reused-sentinel.txt"))).toBe(false); + expect(await file(join(projectDir, "node_modules", "my-git-dep", "package.json")).json()).toMatchObject({ + name: "real-dep-name", + }); + expect(await file(join(cacheFolder, ".bun-tag")).text()).toBe(sha); + + // With the resolution already in bun.lock, the install phase checks the + // cache folder itself. An incomplete folder must be checked out again, not + // copied into node_modules as an empty package. + await poisonCacheFolder(); + await rm(join(projectDir, "node_modules"), { recursive: true, force: true }); + await install(); + expect(await file(join(projectDir, "node_modules", "my-git-dep", "package.json")).json()).toMatchObject({ + name: "real-dep-name", + version: "2.5.0", + }); + expect(await file(join(cacheFolder, ".bun-tag")).text()).toBe(sha); + + // Same for the isolated linker. + const isolatedDir = join(root, "project-isolated"); + await write( + join(isolatedDir, "package.json"), + JSON.stringify({ + name: "my-isolated-app", + dependencies: { "my-git-dep": `git+http://127.0.0.1:${server.port}/my-git-dep.git` }, + }), + ); + await install(isolatedDir, ["--linker=isolated"]); + await poisonCacheFolder(); + await rm(join(isolatedDir, "node_modules"), { recursive: true, force: true }); + await install(isolatedDir, ["--linker=isolated"]); + expect(await file(join(isolatedDir, "node_modules", "my-git-dep", "package.json")).json()).toMatchObject({ + name: "real-dep-name", + version: "2.5.0", + }); + expect(await file(join(cacheFolder, ".bun-tag")).text()).toBe(sha); + + // A bare mirror left incomplete by an interrupted `git clone --bare` + // (named after the URL hash, so it is revisited forever) is rebuilt + // instead of failing every later `git fetch`. + const mirror = (await readdirSorted(cacheDir)).find(entry => entry.endsWith(".git")); + expect(mirror).toBeDefined(); + await rm(join(cacheDir, mirror!), { recursive: true, force: true }); + await mkdir(join(cacheDir, mirror!), { recursive: true }); + await resetProject(); + await install(); + expect(await file(join(projectDir, "node_modules", "my-git-dep", "package.json")).json()).toMatchObject({ + name: "real-dep-name", + version: "2.5.0", + }); + + // A failed `git checkout` (here: the tree object missing from the mirror, + // which `git log` during resolution does not notice but checkout cannot + // unpack) reports the failure and leaves neither a cache folder nor + // temporary junk behind. + const treeSha = (await git(["rev-parse", "HEAD^{tree}"], srcDir)).trim(); + const blobPath = join(cacheDir, mirror!, "objects", treeSha.slice(0, 2), treeSha.slice(2)); + const blobBytes = await file(blobPath).arrayBuffer(); + await rm(blobPath, { force: true }); + await rm(cacheFolder, { recursive: true, force: true }); + await rm(join(projectDir, "node_modules"), { recursive: true, force: true }); + { + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: projectDir, + env: { ...gitEnv, BUN_INSTALL_CACHE_DIR: cacheDir }, + stdout: "pipe", + stderr: "pipe", + }); + const [err, exitCode, out] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]); + expect(err).toContain(`"git checkout" for "my-git-dep" failed`); + expect({ out, err, exitCode }).not.toMatchObject({ exitCode: 0 }); + } + expect((await readdirSorted(cacheDir)).filter(entry => entry.startsWith("@G@") || entry.endsWith(".tmp"))).toEqual( + [], + ); + await write(blobPath, blobBytes); + await install(); + expect(await file(join(projectDir, "node_modules", "my-git-dep", "package.json")).json()).toMatchObject({ + name: "real-dep-name", + version: "2.5.0", + }); + }); + it("should fail on invalid Git URL", async () => { await withContext(defaultOpts, async ctx => { const urls: string[] = [];