diff --git a/src/install/PackageManager/PackageManagerDirectories.zig b/src/install/PackageManager/PackageManagerDirectories.zig index ed24ca03e3..dfcc1ff0a4 100644 --- a/src/install/PackageManager/PackageManagerDirectories.zig +++ b/src/install/PackageManager/PackageManagerDirectories.zig @@ -179,12 +179,23 @@ pub fn fetchCacheDirectoryPath(env: *DotEnv.Loader, options: ?*const Options) Ca return CacheDir{ .is_node_modules = true, .path = Fs.FileSystem.instance.abs(&fallback_parts) }; } -pub fn cachedGitFolderNamePrint(buf: []u8, resolved: string, patch_hash: ?u64) stringZ { +pub fn cachedGitFolderNamePrint(buf: []u8, resolved: string, subdirectory: ?string, patch_hash: ?u64) stringZ { + if (subdirectory) |subdir| { + if (subdir.len > 0) { + // Include subdirectory in cache path to separate different subdirs from same repo + const subdir_hash = bun.hash(subdir); + return std.fmt.bufPrintZ(buf, "@G@{s}+{x}{}", .{ resolved, subdir_hash, PatchHashFmt{ .hash = patch_hash } }) catch unreachable; + } + } return std.fmt.bufPrintZ(buf, "@G@{s}{}", .{ resolved, PatchHashFmt{ .hash = patch_hash } }) catch unreachable; } pub fn cachedGitFolderName(this: *const PackageManager, repository: *const Repository, patch_hash: ?u64) stringZ { - return cachedGitFolderNamePrint(&PackageManager.cached_package_folder_name_buf, this.lockfile.str(&repository.resolved), patch_hash); + const subdirectory: ?string = if (!repository.subdirectory.isEmpty()) + this.lockfile.str(&repository.subdirectory) + else + null; + return cachedGitFolderNamePrint(&PackageManager.cached_package_folder_name_buf, this.lockfile.str(&repository.resolved), subdirectory, patch_hash); } pub fn cachedGitFolderNamePrintAuto(this: *const PackageManager, repository: *const Repository, patch_hash: ?u64) stringZ { diff --git a/src/install/PackageManager/PackageManagerEnqueue.zig b/src/install/PackageManager/PackageManagerEnqueue.zig index a8c32df74f..6a0256498b 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.zig +++ b/src/install/PackageManager/PackageManagerEnqueue.zig @@ -166,7 +166,11 @@ pub fn enqueueGitForCheckout( const url = this.lockfile.str(&repository.repo); const clone_id = Task.Id.forGitClone(url); const resolved = this.lockfile.str(&repository.resolved); - const checkout_id = Task.Id.forGitCheckout(url, resolved); + const subdirectory: ?string = if (!repository.subdirectory.isEmpty()) + this.lockfile.str(&repository.subdirectory) + else + null; + const checkout_id = Task.Id.forGitCheckout(url, resolved, subdirectory); var checkout_queue = this.task_queue.getOrPut(this.allocator, checkout_id) catch unreachable; if (!checkout_queue.found_existing) { checkout_queue.value_ptr.* = .{}; @@ -846,7 +850,11 @@ pub fn enqueueDependencyWithMainAndSuccessFn( this.lockfile.str(&dep.committish), clone_id, ); - const checkout_id = Task.Id.forGitCheckout(url, resolved); + const subdirectory: ?string = if (!dep.subdirectory.isEmpty()) + this.lockfile.str(&dep.subdirectory) + else + null; + const checkout_id = Task.Id.forGitCheckout(url, resolved, subdirectory); var entry = this.task_queue.getOrPutContext(this.allocator, checkout_id, .{}) catch unreachable; if (!entry.found_existing) entry.value_ptr.* = .{}; @@ -1267,6 +1275,11 @@ pub fn enqueueGitCheckout( *FileSystem.FilenameStore, FileSystem.FilenameStore.instance, ) catch unreachable, + .subdirectory = strings.StringOrTinyString.initAppendIfNeeded( + this.lockfile.str(&resolution.value.git.subdirectory), + *FileSystem.FilenameStore, + FileSystem.FilenameStore.instance, + ) catch unreachable, .env = Repository.shared_env.get(this.allocator, this.env), }, }, diff --git a/src/install/PackageManager/runTasks.zig b/src/install/PackageManager/runTasks.zig index 1b9553d0cb..82c6ccd155 100644 --- a/src/install/PackageManager/runTasks.zig +++ b/src/install/PackageManager/runTasks.zig @@ -760,7 +760,11 @@ pub fn runTasks( task.id, ); - const checkout_id = Task.Id.forGitCheckout(repo, resolved); + const subdirectory: ?string = if (!clone.res.value.git.subdirectory.isEmpty()) + manager.lockfile.str(&clone.res.value.git.subdirectory) + else + null; + const checkout_id = Task.Id.forGitCheckout(repo, resolved, subdirectory); if (manager.hasCreatedNetworkTask(checkout_id, dep.behavior.isRequired())) continue; diff --git a/src/install/PackageManagerTask.zig b/src/install/PackageManagerTask.zig index b34acf6a0f..1a260912c1 100644 --- a/src/install/PackageManagerTask.zig +++ b/src/install/PackageManagerTask.zig @@ -59,11 +59,17 @@ pub const Id = enum(u64) { return @enumFromInt(@as(u64, 4 << 61) | @as(u64, @as(u61, @truncate(hasher.final())))); } - pub fn forGitCheckout(url: string, resolved: string) Id { + pub fn forGitCheckout(url: string, resolved: string, subdirectory: ?string) Id { var hasher = bun.Wyhash11.init(0); hasher.update(url); hasher.update("@"); hasher.update(resolved); + if (subdirectory) |subdir| { + if (subdir.len > 0) { + hasher.update("#path:"); + hasher.update(subdir); + } + } return @enumFromInt(@as(u64, 5 << 61) | @as(u64, @as(u61, @truncate(hasher.final())))); } }; @@ -208,6 +214,12 @@ pub fn callback(task: *ThreadPool.Task) void { }, .git_checkout => { const git_checkout = &this.request.git_checkout; + const subdir_slice = git_checkout.subdirectory.slice(); + const subdirectory: ?string = if (subdir_slice.len == 0) + null + else + subdir_slice; + const data = Repository.checkout( manager.allocator, this.request.git_checkout.env, @@ -217,6 +229,7 @@ pub fn callback(task: *ThreadPool.Task) void { git_checkout.name.slice(), git_checkout.url.slice(), git_checkout.resolved.slice(), + subdirectory, ) catch |err| { this.err = err; this.status = Status.fail; @@ -339,6 +352,7 @@ pub const Request = union { name: strings.StringOrTinyString, url: strings.StringOrTinyString, resolved: strings.StringOrTinyString, + subdirectory: strings.StringOrTinyString, resolution: Resolution, env: DotEnv.Map, }, diff --git a/src/install/dependency.zig b/src/install/dependency.zig index 6e3ce8e255..76b9155adb 100644 --- a/src/install/dependency.zig +++ b/src/install/dependency.zig @@ -606,8 +606,14 @@ pub const Version = struct { }, else => false, }) { + // Check for path: parameter anywhere in the URL before routing to GitHub + if (strings.indexOf(dependency, "&path:") != null or strings.indexOf(dependency, "#path:") != null) { + return .git; + } + if (strings.hasPrefixComptime(url, "github.com/")) { - if (hosted_git_info.isGitHubShorthand(url["github.com/".len..])) return .github; + const gh_path = url["github.com/".len..]; + if (hosted_git_info.isGitHubShorthand(gh_path)) return .github; } if (hosted_git_info.HostedGitInfo.fromUrl(allocator, dependency) catch null) |info| { @@ -621,7 +627,12 @@ pub const Version = struct { }, 'h' => { if (strings.hasPrefixComptime(url, "hub:")) { - if (hosted_git_info.isGitHubShorthand(url["hub:".len..])) return .github; + const shorthand = url["hub:".len..]; + // If it contains path: parameter, use git instead of github API + if (strings.indexOf(shorthand, "&path:") != null or strings.indexOf(shorthand, "#path:") != null) { + return .git; + } + if (hosted_git_info.isGitHubShorthand(shorthand)) return .github; } }, else => {}, @@ -653,6 +664,10 @@ pub const Version = struct { if (strings.hasPrefixComptime(url, "github.com/")) { const path = url["github.com/".len..]; if (isGitHubTarballPath(path)) return .tarball; + // If it contains path: parameter, use git instead of github API + if (strings.indexOf(path, "&path:") != null or strings.indexOf(path, "#path:") != null) { + return .git; + } if (hosted_git_info.isGitHubShorthand(path)) return .github; } @@ -1027,13 +1042,37 @@ pub fn parseWithTag( } const hash_index = strings.lastIndexOfChar(input, '#'); + // Parse committish and subdirectory + var committish_slice: []const u8 = ""; + var subdirectory_slice: []const u8 = ""; + + if (hash_index) |index| { + const after_hash = input[index + 1 ..]; + + // Check for &path: or #path: format + if (strings.indexOf(after_hash, "&path:")) |amp_idx| { + committish_slice = after_hash[0..amp_idx]; + subdirectory_slice = after_hash[amp_idx + "&path:".len ..]; + } else if (strings.hasPrefixComptime(after_hash, "path:")) { + subdirectory_slice = after_hash["path:".len..]; + } else { + committish_slice = after_hash; + } + } + + // Normalize subdirectory: strip leading slash once during parsing + if (subdirectory_slice.len > 0 and subdirectory_slice[0] == '/') { + subdirectory_slice = subdirectory_slice[1..]; + } + return .{ .literal = sliced.value(), .value = .{ .git = .{ .owner = String.from(""), .repo = sliced.sub(if (hash_index) |index| input[0..index] else input).value(), - .committish = if (hash_index) |index| sliced.sub(input[index + 1 ..]).value() else String.from(""), + .committish = if (committish_slice.len > 0) sliced.sub(committish_slice).value() else String.from(""), + .subdirectory = if (subdirectory_slice.len > 0) sliced.sub(subdirectory_slice).value() else String.from(""), }, }, .tag = .git, @@ -1053,7 +1092,22 @@ pub fn parseWithTag( // to create String objects that point to the original buffer const owner_str = info.user orelse ""; const repo_str = info.project; - const committish_str = info.committish orelse ""; + var committish_str = info.committish orelse ""; + + // Parse subdirectory from committish if present + var subdirectory_str: []const u8 = ""; + if (strings.indexOf(committish_str, "&path:")) |amp_idx| { + subdirectory_str = committish_str[amp_idx + "&path:".len ..]; + committish_str = committish_str[0..amp_idx]; + } else if (strings.hasPrefixComptime(committish_str, "path:")) { + subdirectory_str = committish_str["path:".len..]; + committish_str = ""; + } + + // Normalize subdirectory: strip leading slash once during parsing + if (subdirectory_str.len > 0 and subdirectory_str[0] == '/') { + subdirectory_str = subdirectory_str[1..]; + } // Find owner in dependency string const owner_idx = strings.indexOf(dependency, owner_str); @@ -1078,6 +1132,15 @@ pub fn parseWithTag( String.from(""); } else String.from(""); + // Find subdirectory in dependency string + const subdirectory = if (subdirectory_str.len > 0) blk: { + const subdir_idx = strings.indexOf(dependency, subdirectory_str); + break :blk if (subdir_idx) |idx| + sliced.sub(dependency[idx .. idx + subdirectory_str.len]).value() + else + String.from(""); + } else String.from(""); + return .{ .literal = sliced.value(), .value = .{ @@ -1085,6 +1148,7 @@ pub fn parseWithTag( .owner = owner, .repo = repo, .committish = committish, + .subdirectory = subdirectory, }, }, .tag = .github, diff --git a/src/install/repository.zig b/src/install/repository.zig index fcf74988f9..517adda4a9 100644 --- a/src/install/repository.zig +++ b/src/install/repository.zig @@ -105,6 +105,7 @@ pub const Repository = extern struct { committish: String = .{}, resolved: String = .{}, package_name: String = .{}, + subdirectory: String = .{}, pub var shared_env: struct { env: ?DotEnv.Map = null, @@ -150,10 +151,37 @@ pub const Repository = extern struct { remain = remain["git+".len..]; } if (strings.lastIndexOfChar(remain, '#')) |hash| { - return .{ + // Parse committish and possible path parameter + // Formats: repo#committish or repo#path:/subdir or repo#committish&path:/subdir + const after_hash = remain[hash + 1 ..]; + + // Look for &path: or path: in the committish part + var committish_str: []const u8 = after_hash; + var subdirectory_str: ?[]const u8 = null; + + if (strings.indexOf(after_hash, "&path:")) |amp_idx| { + // Format: committish&path:/subdir + committish_str = after_hash[0..amp_idx]; + subdirectory_str = after_hash[amp_idx + "&path:".len ..]; + } else if (strings.hasPrefixComptime(after_hash, "path:")) { + // Format: path:/subdir (no committish) + committish_str = ""; + subdirectory_str = after_hash["path:".len..]; + } + + var result = Repository{ .repo = try buf.append(remain[0..hash]), - .committish = try buf.append(remain[hash + 1 ..]), }; + + if (committish_str.len > 0) { + result.committish = try buf.append(committish_str); + } + + if (subdirectory_str) |subdir| { + result.subdirectory = try buf.append(subdir); + } + + return result; } return .{ .repo = try buf.append(remain), @@ -183,7 +211,29 @@ pub const Repository = extern struct { }; if (hash != 0) { - result.committish = try buf.append(remain[hash + 1 ..]); + const after_hash = remain[hash + 1 ..]; + + // Parse committish and possible path parameter + var committish_str: []const u8 = after_hash; + var subdirectory_str: ?[]const u8 = null; + + if (strings.indexOf(after_hash, "&path:")) |amp_idx| { + // Format: committish&path:/subdir + committish_str = after_hash[0..amp_idx]; + subdirectory_str = after_hash[amp_idx + "&path:".len ..]; + } else if (strings.hasPrefixComptime(after_hash, "path:")) { + // Format: path:/subdir (no committish) + committish_str = ""; + subdirectory_str = after_hash["path:".len..]; + } + + if (committish_str.len > 0) { + result.committish = try buf.append(committish_str); + } + + if (subdirectory_str) |subdir| { + result.subdirectory = try buf.append(subdir); + } } return result; @@ -234,8 +284,10 @@ pub const Repository = extern struct { if (owner_order != .eq) return owner_order; const repo_order = lhs.repo.order(&rhs.repo, lhs_buf, rhs_buf); if (repo_order != .eq) return repo_order; + const committish_order = lhs.committish.order(&rhs.committish, lhs_buf, rhs_buf); + if (committish_order != .eq) return committish_order; - return lhs.committish.order(&rhs.committish, lhs_buf, rhs_buf); + return lhs.subdirectory.order(&rhs.subdirectory, lhs_buf, rhs_buf); } pub fn count(this: *const Repository, buf: []const u8, comptime StringBuilder: type, builder: StringBuilder) void { @@ -244,6 +296,7 @@ pub const Repository = extern struct { builder.count(this.committish.slice(buf)); builder.count(this.resolved.slice(buf)); builder.count(this.package_name.slice(buf)); + builder.count(this.subdirectory.slice(buf)); } pub fn clone(this: *const Repository, buf: []const u8, comptime StringBuilder: type, builder: StringBuilder) Repository { @@ -253,12 +306,14 @@ pub const Repository = extern struct { .committish = builder.append(String, this.committish.slice(buf)), .resolved = builder.append(String, this.resolved.slice(buf)), .package_name = builder.append(String, this.package_name.slice(buf)), + .subdirectory = builder.append(String, this.subdirectory.slice(buf)), }; } pub fn eql(lhs: *const Repository, rhs: *const Repository, lhs_buf: []const u8, rhs_buf: []const u8) bool { if (!lhs.owner.eql(rhs.owner, lhs_buf, rhs_buf)) return false; if (!lhs.repo.eql(rhs.repo, lhs_buf, rhs_buf)) return false; + if (!lhs.subdirectory.eql(rhs.subdirectory, lhs_buf, rhs_buf)) return false; if (lhs.resolved.isEmpty() or rhs.resolved.isEmpty()) return lhs.committish.eql(rhs.committish, lhs_buf, rhs_buf); return lhs.resolved.eql(rhs.resolved, lhs_buf, rhs_buf); } @@ -595,49 +650,154 @@ pub const Repository = extern struct { name: string, url: string, resolved: string, + subdirectory: ?string, ) !ExtractData { bun.analytics.Features.git_dependencies += 1; - const folder_name = PackageManager.cachedGitFolderNamePrint(&folder_name_buf, resolved, null); + const folder_name = PackageManager.cachedGitFolderNamePrint(&folder_name_buf, resolved, subdirectory, null); var package_dir = bun.openDir(cache_dir, folder_name) catch |not_found| brk: { if (not_found != error.ENOENT) return not_found; const target = Path.joinAbsString(PackageManager.get().cache_directory_path, &.{folder_name}, .auto); - _ = exec(allocator, env, &[_]string{ - "git", - "clone", - "-c core.longpaths=true", - "--quiet", - "--no-checkout", - try bun.getFdPath(.fromStdDir(repo_dir), &final_path_buf), - target, - }) catch |err| { - log.addErrorFmt( - null, - logger.Loc.Empty, - allocator, - "\"git clone\" for \"{s}\" failed", - .{name}, - ) catch unreachable; - return err; - }; + // Modern git sparse checkout approach for subdirectories + const use_sparse = subdirectory != null and subdirectory.?.len > 0; - const folder = Path.joinAbsString(PackageManager.get().cache_directory_path, &.{folder_name}, .auto); + if (use_sparse) { + // Clone with sparse checkout support (Git 2.25+) + // Note: We're cloning from a local bare repository, so we can't use --filter + // The --filter flag was already used when creating the bare repo + _ = exec(allocator, env, &[_]string{ + "git", + "clone", + "-c core.longpaths=true", + "--quiet", + "--no-checkout", + try bun.getFdPath(.fromStdDir(repo_dir), &final_path_buf), + target, + }) catch |err| { + log.addErrorFmt( + null, + logger.Loc.Empty, + allocator, + "\"git clone\" for \"{s}\" failed", + .{name}, + ) catch unreachable; + return err; + }; + + const folder = Path.joinAbsString(PackageManager.get().cache_directory_path, &.{folder_name}, .auto); + + // Initialize sparse checkout in cone mode (faster) + _ = exec(allocator, env, &[_]string{ "git", "-C", folder, "sparse-checkout", "init", "--cone" }) catch |err| { + log.addErrorFmt( + null, + logger.Loc.Empty, + allocator, + "\"git sparse-checkout init\" for \"{s}\" failed", + .{name}, + ) catch unreachable; + return err; + }; + + // Set sparse checkout to only the requested subdirectory + // Note: subdirectory is already normalized (no leading slash) during parsing + _ = exec(allocator, env, &[_]string{ "git", "-C", folder, "sparse-checkout", "set", subdirectory.? }) catch |err| { + log.addErrorFmt( + null, + logger.Loc.Empty, + allocator, + "\"git sparse-checkout set\" for \"{s}\" failed", + .{name}, + ) catch unreachable; + return err; + }; + + // Now checkout the commit + _ = exec(allocator, env, &[_]string{ "git", "-C", folder, "checkout", "--quiet", resolved }) catch |err| { + log.addErrorFmt( + null, + logger.Loc.Empty, + allocator, + "\"git checkout\" for \"{s}\" failed", + .{name}, + ) catch unreachable; + return err; + }; + } else { + // Standard clone without sparse checkout + _ = exec(allocator, env, &[_]string{ + "git", + "clone", + "-c core.longpaths=true", + "--quiet", + "--no-checkout", + try bun.getFdPath(.fromStdDir(repo_dir), &final_path_buf), + target, + }) catch |err| { + log.addErrorFmt( + null, + logger.Loc.Empty, + allocator, + "\"git clone\" for \"{s}\" failed", + .{name}, + ) catch unreachable; + return err; + }; + + const folder = Path.joinAbsString(PackageManager.get().cache_directory_path, &.{folder_name}, .auto); + + _ = exec(allocator, env, &[_]string{ "git", "-C", folder, "checkout", "--quiet", resolved }) catch |err| { + log.addErrorFmt( + null, + logger.Loc.Empty, + allocator, + "\"git checkout\" for \"{s}\" failed", + .{name}, + ) catch unreachable; + return err; + }; + } - _ = exec(allocator, env, &[_]string{ "git", "-C", folder, "checkout", "--quiet", resolved }) catch |err| { - log.addErrorFmt( - null, - logger.Loc.Empty, - allocator, - "\"git checkout\" for \"{s}\" failed", - .{name}, - ) catch unreachable; - return err; - }; var dir = try bun.openDir(cache_dir, folder_name); dir.deleteTree(".git") catch {}; + // For sparse checkout with subdirectories, we need to restructure the directory + // Move the subdirectory contents to the root since PackageInstaller expects package at root + // Note: subdirectory is already normalized (no leading slash) during parsing + if (use_sparse and subdirectory != null and subdirectory.?.len > 0) { + // Use a single atomic rename via intermediary to avoid partial state + var path_buf: bun.PathBuffer = undefined; + const cache_path = PackageManager.get().cache_directory_path; + + // Source: cache/folder/subdir -> Dest: cache/folder + const subdir_absolute = std.fmt.bufPrint(&path_buf, "{s}/{s}/{s}", .{ cache_path, folder_name, subdirectory.? }) catch unreachable; + + // Temp path outside the folder to avoid conflicts + var temp_buf: bun.PathBuffer = undefined; + const temp_path = std.fmt.bufPrintZ(&temp_buf, "{s}/.bun-tmp-{x}", .{ cache_path, @as(u64, @intCast(@intFromPtr(subdirectory.?.ptr))) }) catch unreachable; + + // Atomic operations: 1) move subdir out, 2) delete old structure, 3) move subdir back + std.posix.rename(subdir_absolute, temp_path) catch |err| { + log.addErrorFmt(null, logger.Loc.Empty, allocator, "failed to move subdirectory during sparse checkout", .{}) catch unreachable; + return err; + }; + + // Clean up old structure + dir.close(); + cache_dir.deleteTree(folder_name) catch {}; + + // Move temp to final location + const final_absolute = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ cache_path, folder_name }) catch unreachable; + std.posix.rename(temp_path, final_absolute) catch |err| { + log.addErrorFmt(null, logger.Loc.Empty, allocator, "failed to finalize subdirectory during sparse checkout", .{}) catch unreachable; + return err; + }; + + // Reopen the directory + dir = try bun.openDir(cache_dir, folder_name); + } + if (resolved.len > 0) insert_tag: { const git_tag = dir.createFileZ(".bun-tag", .{ .truncate = true }) catch break :insert_tag; defer git_tag.close(); diff --git a/test/cli/install/git-sparse-checkout.test.ts b/test/cli/install/git-sparse-checkout.test.ts new file mode 100644 index 0000000000..d8b15b6a51 --- /dev/null +++ b/test/cli/install/git-sparse-checkout.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, readdirSync } from "fs"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "path"; + +// Test both regular install and isolated install +describe.each([{ isolated: false }, { isolated: true }])("git sparse checkout (isolated=$isolated)", ({ isolated }) => { + const installCmd = isolated ? ["install", "--isolated"] : ["install"]; + + test("should install from git subdirectory - bun-types from Bun repo", async () => { + using dir = tempDir("git-sparse-bun-types", { + "package.json": JSON.stringify({ + name: "test-sparse-checkout", + dependencies: { + // Install bun-types from the packages/bun-types subdirectory + // Using specific commit for stability + "bun-types": "git+https://github.com/oven-sh/bun.git#6f8138b6e4&path:packages/bun-types", + }, + }), + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), ...installCmd], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("panic"); + expect(stderr).not.toContain("uncaught exception"); + expect(exitCode).toBe(0); + + // Verify the package was installed + const nodeModulesPath = join(String(dir), "node_modules", "bun-types"); + expect(existsSync(nodeModulesPath)).toBe(true); + + // Verify package.json exists in the installed package + const packageJsonPath = join(nodeModulesPath, "package.json"); + expect(existsSync(packageJsonPath)).toBe(true); + + // Verify it's actually bun-types by checking the package.json + const packageJson = JSON.parse(await Bun.file(packageJsonPath).text()); + expect(packageJson.name).toBe("bun-types"); + + // CRITICAL: Verify the main .d.ts file exists + const bunDtsPath = join(nodeModulesPath, "bun.d.ts"); + expect(existsSync(bunDtsPath)).toBe(true); + + const bunDtsContent = await Bun.file(bunDtsPath).text(); + expect(bunDtsContent).toContain("declare module"); + expect(bunDtsContent.length).toBeGreaterThan(1000); // Should be a substantial file + + // Verify other key .d.ts files exist + expect(existsSync(join(nodeModulesPath, "fetch.d.ts"))).toBe(true); + expect(existsSync(join(nodeModulesPath, "test.d.ts"))).toBe(true); + + // Verify we didn't download the entire repo (shouldn't have root-level files) + const files = readdirSync(nodeModulesPath); + expect(files).not.toContain("CMakeLists.txt"); // Root file from bun repo + expect(files).not.toContain("build.zig"); // Root file from bun repo + expect(files).not.toContain("src"); // Root dir from bun repo + + // Should NOT have a nested packages directory (means sparse checkout moved files correctly) + expect(files).not.toContain("packages"); + }, 180000); // 3 min timeout for git clone + + test("should handle path parameter without leading slash", async () => { + using dir = tempDir("git-sparse-no-slash", { + "package.json": JSON.stringify({ + name: "test-sparse-no-slash", + dependencies: { + // Path without leading / + "bun-types": "git+https://github.com/oven-sh/bun.git#6f8138b6e4&path:packages/bun-types", + }, + }), + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), ...installCmd], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + + const bunDtsPath = join(String(dir), "node_modules", "bun-types", "bun.d.ts"); + expect(existsSync(bunDtsPath)).toBe(true); + }, 180000); + + test("should cache subdirectory separately from full repo", async () => { + using dir = tempDir("git-sparse-cache", { + "package.json": JSON.stringify({ + name: "test-sparse-cache", + dependencies: { + "types": "git+https://github.com/oven-sh/bun.git#6f8138b6e4&path:packages/bun-types", + }, + }), + }); + + // First install + await using proc1 = Bun.spawn({ + cmd: [bunExe(), ...installCmd], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode1 = await proc1.exited; + expect(exitCode1).toBe(0); + + // Verify package installed + const pkgPath = join(String(dir), "node_modules", "types", "bun.d.ts"); + expect(existsSync(pkgPath)).toBe(true); + + // Second install should reuse cache + await using proc2 = Bun.spawn({ + cmd: [bunExe(), ...installCmd], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, exitCode2] = await Promise.all([proc2.stdout.text(), proc2.exited]); + + expect(exitCode2).toBe(0); + // Verify it's still installed correctly + expect(existsSync(pkgPath)).toBe(true); + }, 240000); +});