Skip to content
31 changes: 31 additions & 0 deletions src/install/lockfile/bun.lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1604,6 +1604,10 @@ pub(crate) fn parse_into_binary_lockfile(
) -> Result<(), ParseError> {
lockfile.init_empty();

let link_workspace_packages = manager
.as_deref()
.is_none_or(|m| m.options.link_workspace_packages);

let Some(lockfile_version_expr) = root.get(b"lockfileVersion") else {
log.add_error(Some(source), root.loc, b"Missing lockfile version");
return Err(ParseError::InvalidLockfileVersion);
Expand Down Expand Up @@ -2774,6 +2778,7 @@ pub(crate) fn parse_into_binary_lockfile(
// tree path (see `resolve_peer_dep_version_based`).
let package_index = &lockfile.package_index;
let overrides = &lockfile.overrides;
let workspace_versions = &lockfile.workspace_versions;

// Disjoint-field split of `lockfile.buffers` so each loop body can hold
// `&mut dependencies[i]` and `&mut resolutions[i]` together with a shared
Expand Down Expand Up @@ -2833,6 +2838,9 @@ pub(crate) fn parse_into_binary_lockfile(
resolutions,
lockfile_version,
pkg_resolutions,
string_buf,
workspace_versions,
link_workspace_packages,
);
}
}
Expand Down Expand Up @@ -2917,6 +2925,9 @@ pub(crate) fn parse_into_binary_lockfile(
resolutions,
lockfile_version,
pkg_resolutions,
string_buf,
workspace_versions,
link_workspace_packages,
);
}
}
Expand Down Expand Up @@ -2989,6 +3000,9 @@ pub(crate) fn parse_into_binary_lockfile(
resolutions,
lockfile_version,
pkg_resolutions,
string_buf,
workspace_versions,
link_workspace_packages,
);
}
}
Expand Down Expand Up @@ -3133,12 +3147,29 @@ fn map_dep_to_pkg(
resolutions: &mut [PackageID],
text_lockfile_version: Version,
pkg_resolutions: &[Resolution],
string_buf: &[u8],
workspace_versions: &VersionHashMap,
link_workspace_packages: bool,
) {
resolutions[dep_id as usize] = pkg_id;

if text_lockfile_version != Version::V0 {
let res = &pkg_resolutions[pkg_id as usize];
if res.tag == ResolutionTag::Workspace {
// Only rewrite into the shape `Package::parse_dependency` would
// produce (a member version the range satisfies); otherwise the
// loaded root never matches a fresh parse and every install
// re-saves the unchanged lockfile. With linking disabled, always
// rewrite so a stale workspace resolution diffs and re-resolves.
Comment thread
robobun marked this conversation as resolved.
Resolved
if dep.version.tag == DependencyVersionTag::Npm && link_workspace_packages {
let npm = dep.version.npm();
let parse_would_rewrite = workspace_versions
.get(&StringBuilder::string_hash(npm.name.slice(string_buf)))
.is_some_and(|version| npm.version.satisfies(*version, string_buf, string_buf));
if !parse_would_rewrite {
return;
}
}
// Whole-struct assign so `DependencyVersion::Drop` frees any prior
// npm chain. SAFETY: `res.tag == Workspace` checked above.
let literal = dep.version.literal;
Expand Down
25 changes: 20 additions & 5 deletions test/cli/install/__snapshots__/bun-workspaces.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ exports[`dependency on workspace without version in package.json: version: * 1`]
{
"name": "no-deps",
"literal": "*",
"workspace": "packages/mono",
"npm": {
"name": "no-deps",
"version": ">=0.0.0"
},
"package_id": 2,
"behavior": {
"prod": true
Expand Down Expand Up @@ -173,7 +176,10 @@ exports[`dependency on workspace without version in package.json: version: *.*.*
{
"name": "no-deps",
"literal": "*.*.*",
"workspace": "packages/mono",
"npm": {
"name": "no-deps",
"version": ">=0.0.0"
},
"package_id": 2,
"behavior": {
"prod": true
Expand Down Expand Up @@ -296,7 +302,10 @@ exports[`dependency on workspace without version in package.json: version: =* 1`
{
"name": "no-deps",
"literal": "=*",
"workspace": "packages/mono",
"npm": {
"name": "no-deps",
"version": ">=0.0.0"
},
"package_id": 2,
"behavior": {
"prod": true
Expand Down Expand Up @@ -542,7 +551,10 @@ exports[`dependency on workspace without version in package.json: version: *.1.*
{
"name": "no-deps",
"literal": "*.1.*",
"workspace": "packages/mono",
"npm": {
"name": "no-deps",
"version": ">=0.0.0"
},
"package_id": 2,
"behavior": {
"prod": true
Expand Down Expand Up @@ -665,7 +677,10 @@ exports[`dependency on workspace without version in package.json: version: *-pre
{
"name": "no-deps",
"literal": "*-pre",
"workspace": "packages/mono",
"npm": {
"name": "no-deps",
"version": ">=0.0.0"
},
"package_id": 2,
"behavior": {
"prod": true
Expand Down
63 changes: 63 additions & 0 deletions test/cli/install/bun-workspaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,69 @@ test("dependency on workspace without version in package.json", async () => {
}
});

test.concurrent(
"repeat install does not re-save the lockfile for a wildcard dependency on a versionless workspace member",
async () => {
using ctx = await setupTest();
const { packageDir, packageJson, env } = ctx;
await Promise.all([
write(
packageJson,
JSON.stringify({
name: "foo",
workspaces: ["packages/*"],
dependencies: {
"no-deps": "*",
},
}),
),
// no version field on purpose
write(join(packageDir, "packages", "no-deps", "package.json"), JSON.stringify({ name: "no-deps" })),
write(
join(packageDir, "packages", "pkg1", "package.json"),
JSON.stringify({
name: "pkg1",
dependencies: {
"no-deps": "*",
},
}),
),
]);

var { stderr, exited } = spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
stdout: "ignore",
stderr: "pipe",
env,
});

var [err, exitCode] = await Promise.all([stderr.text(), exited]);
expect(err).toContain("Saved lockfile");
expect(err).not.toContain("error:");
expect(exitCode).toBe(0);

const lockBefore = await file(join(packageDir, "bun.lock")).text();
// the wildcard dependencies resolve to the workspace member, not the registry
expect(lockBefore).toContain("no-deps@workspace:packages/no-deps");

({ stderr, exited } = spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
stdout: "ignore",
stderr: "pipe",
env,
}));

[err, exitCode] = await Promise.all([stderr.text(), exited]);
expect(err).not.toContain("Saved lockfile");
expect(err).not.toContain("error:");
expect(exitCode).toBe(0);

expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockBefore);
},
);

test.concurrent("allowing negative workspace patterns", async () => {
using ctx = await setupTest();
const { packageDir, env } = ctx;
Expand Down