Compare commits

...

2 Commits

Author SHA1 Message Date
autofix-ci[bot]
83ceada2d9 [autofix.ci] apply automated fixes 2026-01-12 10:23:10 +00:00
Claude Bot
88cad7bbeb fix(install): unapply removed patches with --frozen-lockfile
When switching to a commit/branch without a patch (e.g., after `git checkout`),
`bun install --frozen-lockfile` now correctly reinstalls packages that had
patches applied, removing the patch.

Previously, the verification logic only checked if a patch tag file existed
when a patch was expected, but didn't check if a patch tag file existed when
NO patch was expected. This caused stale patched packages to remain in
node_modules.

The fix:
1. Adds a `.bun-patched` marker file when applying patches (alongside the
   existing `.bun-tag-{hash}` file) for O(1) detection
2. Adds a `verifyNoPatchApplied()` check that stats this single marker file
   when no patch is expected. If found, the package is marked for reinstallation.

Fixes #25932

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:21:27 +00:00
4 changed files with 204 additions and 1 deletions

View File

@@ -175,8 +175,43 @@ pub const PackageInstall = struct {
if (this.patch) |*patch| {
if (!verified) return false;
return this.verifyPatchHash(patch, root_node_modules_dir);
} else {
// If there's no patch expected, verify that the installed package
// doesn't have a patch tag file (from a previously applied patch).
if (!verified) return false;
return this.verifyNoPatchApplied(root_node_modules_dir);
}
}
/// Verify that the installed package doesn't have a .bun-patched marker file,
/// which would indicate a patch was previously applied that needs to be removed.
fn verifyNoPatchApplied(this: *@This(), root_node_modules_dir: std.fs.Dir) bool {
const patched_tag_path = bun.path.joinZ(&[_][]const u8{
this.destination_dir_subpath,
bun_patched_tag,
}, .posix);
var destination_dir = this.node_modules.openDir(root_node_modules_dir) catch return true;
defer {
if (std.fs.cwd().fd != destination_dir.fd) destination_dir.close();
}
// If .bun-patched exists, the package was patched and needs reinstallation
if (comptime bun.Environment.isPosix) {
if (bun.sys.fstatat(.fromStdDir(destination_dir), patched_tag_path).unwrap()) |_| {
return false; // File exists - package is patched
} else |_| {
return true; // File doesn't exist - package is not patched
}
} else {
switch (bun.sys.openat(.fromStdDir(destination_dir), patched_tag_path, bun.O.RDONLY, 0)) {
.err => return true, // File doesn't exist - package is not patched
.result => |fd| {
fd.close();
return false; // File exists - package is patched
},
}
}
return verified;
}
// Only check for destination directory in node_modules. We can't use package.json because
@@ -1497,5 +1532,6 @@ const PackageManager = install.PackageManager;
const Repository = install.Repository;
const Resolution = install.Resolution;
const TruncatedPackageNameHash = install.TruncatedPackageNameHash;
const bun_patched_tag = install.bun_patched_tag;
const buntaghashbuf_make = install.buntaghashbuf_make;
const initializeStore = install.initializeStore;

View File

@@ -1,6 +1,7 @@
threadlocal var initialized_store = false;
pub const bun_hash_tag = ".bun-tag-";
pub const bun_patched_tag: [:0]const u8 = ".bun-patched";
pub const max_hex_hash_len: comptime_int = brk: {
var buf: [128]u8 = undefined;
break :brk (std.fmt.bufPrint(buf[0..], "{x}", .{std.math.maxInt(u64)}) catch @panic("Buf wasn't big enough.")).len;

View File

@@ -9,6 +9,7 @@ pub const Resolution = @import("./resolution.zig").Resolution;
pub const PackageInstall = bun.install.PackageInstall;
pub const bun_hash_tag = bun.install.bun_hash_tag;
pub const bun_patched_tag = bun.install.bun_patched_tag;
pub const max_hex_hash_len: comptime_int = brk: {
var buf: [128]u8 = undefined;
break :brk (std.fmt.bufPrint(buf[0..], "{x}", .{std.math.maxInt(u64)}) catch @panic("Buf wasn't big enough.")).len;
@@ -371,6 +372,26 @@ pub const PatchTask = struct {
},
};
buntagfd.close();
// Also create a simple .bun-patched marker for efficient detection
// when verifying if a package needs to be reinstalled without a patch
const patched_tag_fd = switch (bun.sys.openat(
patch_pkg_dir,
bun_patched_tag,
bun.O.RDWR | bun.O.CREAT,
0o666,
)) {
.result => |fd| fd,
.err => |e| {
return try log.addErrorFmtOpts(
this.manager.allocator,
"failed adding bun patched tag: {f}",
.{e.withPath(bun_patched_tag)},
.{},
);
},
};
patched_tag_fd.close();
}
// 6. rename to cache dir

View File

@@ -0,0 +1,145 @@
import { $ } from "bun";
import { beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import { join } from "path";
beforeAll(() => {
setDefaultTimeout(1000 * 60 * 5);
});
describe("issue#25932", () => {
// Patch that adds a console.log to is-even (a visible difference)
const is_even_patch = /* patch */ `diff --git a/index.js b/index.js
index 832d92223a9ec491364ee10dcbe3ad495446ab80..bc652e496c165a7415880ef4520c0ab302bf0765 100644
--- a/index.js
+++ b/index.js
@@ -10,5 +10,6 @@
var isOdd = require('is-odd');
module.exports = function isEven(i) {
+ console.log("PATCHED");
return !isOdd(i);
};
`;
test("frozen-lockfile should unapply removed patches", async () => {
// Create temp directory with the patched setup
using dir = tempDir("issue-25932", {});
const cwd = String(dir);
// Initial package.json with patch
const packageJsonWithPatch = {
main: "index.js",
dependencies: {
"is-even": "^1.0.0",
},
patchedDependencies: {
"is-even@1.0.0": "patches/is-even@1.0.0.patch",
},
};
// package.json without patch
const packageJsonWithoutPatch = {
main: "index.js",
dependencies: {
"is-even": "^1.0.0",
},
};
// Set up initial state with patch
await Bun.write(join(cwd, "package.json"), JSON.stringify(packageJsonWithPatch, null, 2));
await Bun.write(join(cwd, "patches/is-even@1.0.0.patch"), is_even_patch);
await Bun.write(join(cwd, "index.js"), `const isEven = require('is-even'); console.log(isEven(5));`);
// Install with patch
await $`${bunExe()} install`.env(bunEnv).cwd(cwd);
// Verify the patch is applied (should output "PATCHED")
{
const result = await $`${bunExe()} run index.js`.env(bunEnv).cwd(cwd).quiet();
expect(result.stdout.toString()).toContain("PATCHED");
}
// Save the lockfile without patchedDependencies
// This simulates what happens when user switches branches in git
const lockContent = await Bun.file(join(cwd, "bun.lock")).text();
const lockWithoutPatch = lockContent.replace(/,?\n\s*"patchedDependencies":\s*\{[^}]*\}/g, "");
await Bun.write(join(cwd, "bun.lock"), lockWithoutPatch);
// Also update package.json to remove the patch
await Bun.write(join(cwd, "package.json"), JSON.stringify(packageJsonWithoutPatch, null, 2));
// Run install with --frozen-lockfile (simulates `bun i --frozen-lockfile` after git checkout)
await $`${bunExe()} install --frozen-lockfile`.env(bunEnv).cwd(cwd);
// Verify the patch is no longer applied (should NOT output "PATCHED")
{
const result = await $`${bunExe()} run index.js`.env(bunEnv).cwd(cwd).quiet();
expect(result.stdout.toString()).not.toContain("PATCHED");
}
});
test("should also work when switching back to patched version", async () => {
// Create temp directory
using dir = tempDir("issue-25932-back", {});
const cwd = String(dir);
// Start without a patch
const packageJsonWithoutPatch = {
main: "index.js",
dependencies: {
"is-even": "^1.0.0",
},
};
const packageJsonWithPatch = {
main: "index.js",
dependencies: {
"is-even": "^1.0.0",
},
patchedDependencies: {
"is-even@1.0.0": "patches/is-even@1.0.0.patch",
},
};
// Set up initial state without patch
await Bun.write(join(cwd, "package.json"), JSON.stringify(packageJsonWithoutPatch, null, 2));
await Bun.write(join(cwd, "patches/is-even@1.0.0.patch"), is_even_patch);
await Bun.write(join(cwd, "index.js"), `const isEven = require('is-even'); console.log(isEven(5));`);
// Install without patch
await $`${bunExe()} install`.env(bunEnv).cwd(cwd);
// Verify the patch is NOT applied (should NOT output "PATCHED")
{
const result = await $`${bunExe()} run index.js`.env(bunEnv).cwd(cwd).quiet();
expect(result.stdout.toString()).not.toContain("PATCHED");
}
// Save the current lockfile
const lockWithoutPatch = await Bun.file(join(cwd, "bun.lock")).text();
// Now add the patch
await Bun.write(join(cwd, "package.json"), JSON.stringify(packageJsonWithPatch, null, 2));
await $`${bunExe()} install`.env(bunEnv).cwd(cwd);
// Verify the patch IS applied (should output "PATCHED")
{
const result = await $`${bunExe()} run index.js`.env(bunEnv).cwd(cwd).quiet();
expect(result.stdout.toString()).toContain("PATCHED");
}
// Now simulate switching back to version without patch (like git checkout)
await Bun.write(join(cwd, "package.json"), JSON.stringify(packageJsonWithoutPatch, null, 2));
await Bun.write(join(cwd, "bun.lock"), lockWithoutPatch);
// Run install with --frozen-lockfile
await $`${bunExe()} install --frozen-lockfile`.env(bunEnv).cwd(cwd);
// Verify the patch is no longer applied (should NOT output "PATCHED")
{
const result = await $`${bunExe()} run index.js`.env(bunEnv).cwd(cwd).quiet();
expect(result.stdout.toString()).not.toContain("PATCHED");
}
});
});