Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
67 changes: 55 additions & 12 deletions src/install/PackageManager/PackageManagerDirectories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -920,10 +920,18 @@ pub struct CacheDirAndSubpath<'a> {

/// this is copy pasted from `installPackageWithNameAndResolution()`
/// it's not great to do this
///
/// `resolution_string_bytes` is the string buffer that `resolution`'s strings
/// index into, for callers whose resolution was read from a lockfile other
/// than `manager.lockfile` (`bun patch --commit` loads its own copy before the
/// install populates the manager's). `None` means `resolution` belongs to
/// `manager.lockfile`. Strings longer than 7 bytes are offsets into that
/// buffer, so slicing them with the wrong one yields empty or garbage paths.
Comment thread
robobun marked this conversation as resolved.
Outdated
pub fn compute_cache_dir_and_subpath<'a>(
manager: &mut PackageManager,
pkg_name: &[u8],
resolution: &Resolution,
resolution_string_bytes: Option<&[u8]>,
folder_path_buf: &'a mut PathBuffer,
patch_hash: Option<u64>,
) -> CacheDirAndSubpath<'a> {
Expand All @@ -939,16 +947,35 @@ pub fn compute_cache_dir_and_subpath<'a>(
}
ResolutionTag::Git => {
let git = resolution.git();
cache_dir_subpath = cached_git_folder_name(manager, git, patch_hash);
let resolved = match resolution_string_bytes {
Some(buf) => git.resolved.slice(buf),
None => manager.lockfile.str(&git.resolved),
};
cache_dir_subpath = cached_git_folder_name_print(
cached_package_folder_name_buf(),
resolved,
patch_hash,
);
cache_dir = get_cache_directory(manager);
}
ResolutionTag::Github => {
let github = resolution.github();
cache_dir_subpath = cached_github_folder_name(manager, github, patch_hash);
let resolved = match resolution_string_bytes {
Some(buf) => github.resolved.slice(buf),
None => manager.lockfile.str(&github.resolved),
};
cache_dir_subpath = cached_github_folder_name_print(
cached_package_folder_name_buf(),
resolved,
patch_hash,
);
cache_dir = get_cache_directory(manager);
}
ResolutionTag::Folder => {
let buf = manager.lockfile.buffers.string_bytes.as_slice();
let buf = match resolution_string_bytes {
Some(buf) => buf,
None => manager.lockfile.buffers.string_bytes.as_slice(),
};
let folder = resolution.folder().slice(buf);
// Handle when a package depends on itself via file:
// example:
Expand All @@ -963,17 +990,30 @@ pub fn compute_cache_dir_and_subpath<'a>(
cache_dir = Fd::cwd();
}
ResolutionTag::LocalTarball => {
let tarball = *resolution.local_tarball();
cache_dir_subpath = cached_tarball_folder_name(manager, tarball, patch_hash);
let tarball = resolution.local_tarball();
let url = match resolution_string_bytes {
Some(buf) => tarball.slice(buf),
None => manager.lockfile.str(tarball),
};
cache_dir_subpath =
cached_tarball_folder_name_print(cached_package_folder_name_buf(), url, patch_hash);
cache_dir = get_cache_directory(manager);
}
ResolutionTag::RemoteTarball => {
let tarball = *resolution.remote_tarball();
cache_dir_subpath = cached_tarball_folder_name(manager, tarball, patch_hash);
let tarball = resolution.remote_tarball();
let url = match resolution_string_bytes {
Some(buf) => tarball.slice(buf),
None => manager.lockfile.str(tarball),
};
cache_dir_subpath =
cached_tarball_folder_name_print(cached_package_folder_name_buf(), url, patch_hash);
cache_dir = get_cache_directory(manager);
}
ResolutionTag::Workspace => {
let buf = manager.lockfile.buffers.string_bytes.as_slice();
let buf = match resolution_string_bytes {
Some(buf) => buf,
None => manager.lockfile.buffers.string_bytes.as_slice(),
};
let folder = resolution.workspace().slice(buf);
// Handle when a package depends on itself
if folder.is_empty() || (folder.len() == 1 && folder[0] == b'.') {
Expand All @@ -991,10 +1031,13 @@ pub fn compute_cache_dir_and_subpath<'a>(
// borrowck — `global_link_dir_path` below reborrows
// `manager` mutably, so copy the symlink target out of the lockfile
// string buffer first instead of holding a slice across that call.
let folder = resolution
.symlink()
.slice(manager.lockfile.buffers.string_bytes.as_slice())
.to_vec();
let folder = {
let buf = match resolution_string_bytes {
Some(buf) => buf,
None => manager.lockfile.buffers.string_bytes.as_slice(),
};
resolution.symlink().slice(buf).to_vec()
};

if folder.is_empty() || (folder.len() == 1 && folder[0] == b'.') {
cache_dir_subpath = z_static(b".\0");
Expand Down
4 changes: 4 additions & 0 deletions src/install/PackageManager/patchPackage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ pub fn do_patch_commit(
manager,
&name,
&resolution_clone,
Some(lockfile.buffers.string_bytes.as_slice()),
&mut folder_path_buf,
None,
);
Expand Down Expand Up @@ -289,6 +290,7 @@ pub fn do_patch_commit(
manager,
&pkg_name_slice,
&resolution_clone,
Some(lockfile.buffers.string_bytes.as_slice()),
&mut folder_path_buf,
None,
);
Expand Down Expand Up @@ -889,6 +891,7 @@ pub fn prepare_patch(manager: &mut PackageManager) -> Result<(), crate::Error> {
manager,
&name,
&actual_package.resolution,
None,
&mut folder_path_buf,
existing_patchfile_hash,
);
Expand Down Expand Up @@ -949,6 +952,7 @@ pub fn prepare_patch(manager: &mut PackageManager) -> Result<(), crate::Error> {
manager,
&pkg_name,
&pkg_resolution,
None,
&mut folder_path_buf,
existing_patchfile_hash,
);
Expand Down
1 change: 1 addition & 0 deletions src/install/patch_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,7 @@ impl PatchTask {
pkg_manager,
&pkg_name_slice,
&resolution_clone,
None,
&mut folder_path_buf,
Some(patch_hash),
);
Expand Down
176 changes: 176 additions & 0 deletions test/cli/install/bun-patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -946,3 +946,179 @@
}
});
});

// `bun patch --commit` derives the pristine copy's cache folder from the
// package's resolution. For non-registry resolutions (git, github, tarball)
// the resolution strings live in the lockfile's string buffer; resolving them
// against the wrong buffer produced paths like "@GH@@@@1" and the diff step
// failed with "Could not access".
describe.concurrent("bun patch --commit for non-registry dependencies", () => {
async function runBun(cwd: string, env: Record<string, string | undefined>, ...args: string[]) {
await using proc = Bun.spawn({
cmd: [bunExe(), ...args],
env,
cwd,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}

async function expectPatchFlowWorks(dir: string, env: Record<string, string | undefined>, commitArg: string) {
{
const { stderr, exitCode } = await runBun(dir, env, "install");
expect(stderr).not.toContain("error:");
expect(exitCode).toBe(0);
}
{
const { stderr, exitCode } = await runBun(dir, env, "patch", "pkg-to-patch");
expect(stderr).not.toContain("error:");
expect(exitCode).toBe(0);
}

await Bun.write(join(dir, "node_modules", "pkg-to-patch", "index.js"), `module.exports = "patched";\n`);

{
const { stderr, exitCode } = await runBun(dir, env, "patch", "--commit", commitArg);
expect(stderr).not.toContain("Could not access");
expect(stderr).not.toContain("error:");
expect(exitCode).toBe(0);
}

const pkg = await Bun.file(join(dir, "package.json")).json();
const entries = Object.entries(pkg.patchedDependencies ?? {}) as [string, string][];
expect(entries).toHaveLength(1);
const [patchKey, patchPath] = entries[0];
const patchContents = await Bun.file(join(dir, patchPath)).text();
expect(patchContents).toContain('-module.exports = "original";');
expect(patchContents).toContain('+module.exports = "patched";');
// the commit flow reinstalls with the patch applied
expect(await Bun.file(join(dir, "node_modules", "pkg-to-patch", "index.js")).text()).toBe(
`module.exports = "patched";\n`,
);
return patchKey;
}

test("github dependency", async () => {

Check failure on line 1003 in test/cli/install/bun-patch.test.ts

View check run for this annotation

Claude / Claude Code Review

github/git patch --commit still fails on Windows: patch filename contains ':' (unescaped); new tests will fail on Windows CI

The new `github dependency` and `git dependency` tests will fail on Windows CI: now that the cache-path lookup is fixed, `bun patch --commit` reaches `escape_patch_filename`, which escapes `/ \ space \n \r \t` but not `:` — so the patch filename (e.g. `pkg-to-patch@github:testowner%2Ftestrepo#aaaaaaa.patch`) contains a colon, and `renameat_concurrently` to `patches/…` fails on NTFS (`:` is reserved / the ADS separator), producing `error: failed renaming patch file to patches dir` and a nonzero e
Comment thread
robobun marked this conversation as resolved.
await using dir = tempDir("patch-commit-github", {
"package.json": JSON.stringify({
name: "test-patch-github",
dependencies: { "pkg-to-patch": "github:testowner/testrepo#aaaaaaa" },
}),
// GitHub API tarballs have an `<owner>-<repo>-<committish>` root folder;
// that folder name becomes the `resolved` part of the cache folder name.
"tarball-src": {
"testowner-testrepo-aaaaaaa": {
"package.json": JSON.stringify({ name: "pkg-to-patch", version: "1.0.0" }),
"index.js": `module.exports = "original";\n`,
},
},
});

await using tarProc = Bun.spawn({
cmd: [
"tar",
"-czf",
join(String(dir), "gh.tgz"),
"-C",
join(String(dir), "tarball-src"),
"testowner-testrepo-aaaaaaa",
],
env: bunEnv,
stdout: "inherit",
stderr: "inherit",
});
expect(await tarProc.exited).toBe(0);
const tgz = await Bun.file(join(String(dir), "gh.tgz")).bytes();

await using server = Bun.serve({
port: 0,
fetch: () => new Response(tgz, { headers: { "content-type": "application/gzip" } }),
});

const env = {
...bunEnv,
GITHUB_API_URL: `http://localhost:${server.port}`,
BUN_INSTALL_CACHE_DIR: join(String(dir), ".bun-cache"),
};

const patchKey = await expectPatchFlowWorks(String(dir), env, "node_modules/pkg-to-patch");
expect(patchKey).toBe("pkg-to-patch@github:testowner/testrepo#aaaaaaa");
});

test("git dependency", async () => {
await using dir = tempDir("patch-commit-git", {
"gitrepo": {
"package.json": JSON.stringify({ name: "pkg-to-patch", version: "1.0.0" }),
"index.js": `module.exports = "original";\n`,
},
"project": {},
});
const repo = join(String(dir), "gitrepo");

for (const args of [
["init", "-q"],
["add", "-A"],
["-c", "user.email=test@test.test", "-c", "user.name=test", "commit", "-q", "-m", "init"],
// serve the repo over git's dumb HTTP protocol (plain file fetches)
["update-server-info"],
]) {
await using proc = Bun.spawn({ cmd: ["git", ...args], cwd: repo, env: bunEnv, stderr: "pipe" });
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");

Check warning on line 1069 in test/cli/install/bun-patch.test.ts

View check run for this annotation

Claude / Claude Code Review

git dependency test: strict expect(stderr).toBe("") on git setup is non-hermetic

nit: the git setup loop spawns with `env: bunEnv` (which inherits `~/.gitconfig` / system gitconfig) and asserts `expect(stderr).toBe("")`, so ambient config like `core.autocrlf=true` (git-for-windows default → "LF will be replaced by CRLF" on `git add -A`) or `commit.gpgsign=true` will fail the strict equality check. Match the harness convention in `test/cli/test/test-changed.test.ts` / `test/js/bun/patch/patch.test.ts` — set `GIT_CONFIG_NOSYSTEM: "1"` + an empty `GIT_CONFIG_GLOBAL` (and/or `-c
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
expect(exitCode).toBe(0);
}

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

const project = join(String(dir), "project");
const depUrl = `git+http://localhost:${server.port}/repo.git`;
await Bun.write(
join(project, "package.json"),
JSON.stringify({ name: "test-patch-git", dependencies: { "pkg-to-patch": depUrl } }),
);

const env = { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(String(dir), ".bun-cache") };

const patchKey = await expectPatchFlowWorks(project, env, "node_modules/pkg-to-patch");
expect(patchKey).toStartWith(`pkg-to-patch@${depUrl}#`);
});

test("local tarball dependency", async () => {
await using dir = tempDir("patch-commit-tarball", {
"package.json": JSON.stringify({
name: "test-patch-tarball",
dependencies: { "pkg-to-patch": "file:./dep.tgz" },
}),
"tarball-src": {
"package": {
"package.json": JSON.stringify({ name: "pkg-to-patch", version: "1.0.0" }),
"index.js": `module.exports = "original";\n`,
},
},
});

await using tarProc = Bun.spawn({
cmd: ["tar", "-czf", join(String(dir), "dep.tgz"), "-C", join(String(dir), "tarball-src"), "package"],
env: bunEnv,
stdout: "inherit",
stderr: "inherit",
});
expect(await tarProc.exited).toBe(0);

const env = { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(String(dir), ".bun-cache") };

// name-only argument exercises the name-and-version lookup path
const patchKey = await expectPatchFlowWorks(String(dir), env, "pkg-to-patch");
expect(patchKey).toBe("pkg-to-patch@./dep.tgz");
});
});
Loading