Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions docs/pm/cli/dedupe.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Each row is a version Bun removed and the version its dependents now use.

`bun dedupe` only chooses between versions already in the lockfile and never modifies `package.json`. It never fetches new versions from the registry and never moves a dependency outside its range. Use [`bun update`](/pm/cli/update) for that.

`bun install` and `bun update` run the same collapse over the versions they add, so a fresh install does not create duplicates that one version could have covered. Versions already in `bun.lock` are left as they are; only `bun dedupe` removes those.

### `--check` and `--dry-run`

`--check` reports what Bun would remove without changing anything, and exits `1` if there are duplicates. Use it in CI:
Expand Down
2 changes: 2 additions & 0 deletions docs/pm/cli/update.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ Updated packages appear in the install summary as `↑ name old → new`, with `
### What is held back

- Bun never widens ranges. A package that depends on `foo@^1.0.0` never gets `foo@2.x`.
- A transitive dependency that shares the copy your own `package.json` entry resolves to (in the root or in a workspace) stays on that copy and moves with it. With `@types/node: ~20` in your `package.json`, the `@types/node: *` that `@types/ws` declares keeps using your `~20` copy instead of nesting the newest major under `@types/ws`. Bun updates a transitive range on its own when it rejects the version your entry moves to, or when you removed the entry.
- After resolving, Bun collapses the versions it added onto the fewest that satisfy every range, so it does not add a copy that an existing version could cover. Two dependencies declaring `foo@^1` and `foo@*` share one `1.x` copy, on `bun install` and on `bun update` alike. Versions already in `bun.lock`, your own `package.json` entries, and `bun audit fix` pins are never moved by this.
- Versions in `patchedDependencies` stay put as long as their range allows. Bun reports them as `kept name@version (patched, v1.2.3 available)`. `--latest` and [`bun audit fix`](/pm/cli/audit#bun-audit-fix) do move them; re-create the patch with [`bun patch`](/pm/cli/patch) afterwards.
- If a registry request for a transitive package fails, that package keeps its locked version and Bun prints a warning. A failed request for a direct dependency is an error.

Expand Down
5 changes: 5 additions & 0 deletions src/install/PackageManager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,9 @@ pub struct PackageManager {
// bun dedupe: printed by dedupe::print_dedupe_summary in place of the install summary.
pub(crate) dedupe_report: Option<crate::dedupe::Report>,

// Rows `bun audit fix` moved to a fixed version; the post-resolve collapse leaves them there.
pub(crate) fixed_rows: bun_collections::DynamicBitSet,

// add/remove/update --filter: only these importers are linked; None = every importer.
pub(crate) filtered_link_targets: Option<workspace_selection::LinkTargets>,

Expand Down Expand Up @@ -2142,6 +2145,7 @@ pub fn init(
wr!(kept_patched, Vec::new());
wr!(kept_patched_text, Vec::new());
wr!(dedupe_report, None);
wr!(fixed_rows, bun_collections::DynamicBitSet::default());
wr!(filtered_link_targets, None);
wr!(pending_filtered_write, None);
wr!(edited_package_jsons, Vec::new());
Expand Down Expand Up @@ -2609,6 +2613,7 @@ fn init_with_runtime_once(
wr!(kept_patched, Vec::new());
wr!(kept_patched_text, Vec::new());
wr!(dedupe_report, None);
wr!(fixed_rows, bun_collections::DynamicBitSet::default());
wr!(filtered_link_targets, None);
wr!(pending_filtered_write, None);
wr!(edited_package_jsons, Vec::new());
Expand Down
3 changes: 3 additions & 0 deletions src/install/PackageManager/install_with_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,9 @@ pub fn install_with_manager(
direct_deps_before.redirect_dependents(&mut manager.lockfile);
transitive.redirect_dependents(&mut manager.lockfile);
redirect_moved_edges(&mut manager.lockfile, &named.moved);
if manager.subcommand != Subcommand::Dedupe {
crate::dedupe::collapse_appended(manager);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
transitive.print_plan(manager, &direct_deps_before, &named.moved);
print_kept_patched(manager);

Expand Down
5 changes: 5 additions & 0 deletions src/install/audit_fix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1238,6 +1238,11 @@ pub fn enqueue_planned_fixes(manager: &mut PackageManager) -> crate::Result<()>
continue;
};
enqueue_pinned_as(manager, live_dep_id, pkg_name, pin.to_version)?;
let fixed_rows = &mut manager.fixed_rows;
if fixed_rows.bit_length() <= live_dep_id as usize {
bun_core::handle_oom(fixed_rows.resize(live_dep_id as usize + 1, false));
}
fixed_rows.set(live_dep_id as usize);
manager.summary.update += 1;
}
}
Expand Down
52 changes: 47 additions & 5 deletions src/install/dedupe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ struct Pass<'a> {
groups: &'a [Vec<PackageID>],
group_of: &'a [u32],
pinned: &'a DynamicBitSet,
/// Packages below this id (loaded from the lockfile) keep their edges, and so do root/workspace rows and audit-fixed rows.
frozen_below: Option<PackageID>,
fixed_rows: &'a DynamicBitSet,
edges: Vec<Vec<(DependencyID, bool)>>,
candidates: Vec<PackageID>,
edge_count: usize,
Expand All @@ -42,6 +45,8 @@ impl<'a> Pass<'a> {
groups: &'a [Vec<PackageID>],
group_of: &'a [u32],
pinned: &'a DynamicBitSet,
frozen_below: Option<PackageID>,
fixed_rows: &'a DynamicBitSet,
max_candidates: usize,
max_edges: usize,
) -> Pass<'a> {
Expand All @@ -51,6 +56,8 @@ impl<'a> Pass<'a> {
groups,
group_of,
pinned,
frozen_below,
fixed_rows,
edges: vec![Vec::new(); groups.len()],
candidates: Vec::with_capacity(max_candidates),
edge_count: 0,
Expand Down Expand Up @@ -197,7 +204,7 @@ impl<'a> Pass<'a> {
self.cur_c.resize(m, 0);
self.required.unmanaged.set_all(false);

for (e, &(dep_id, _)) in edges.iter().enumerate() {
for (e, &(dep_id, direct)) in edges.iter().enumerate() {
let dep = &deps[dep_id as usize];
let target = cur[dep_id as usize];
let cur_c = self
Expand All @@ -208,7 +215,15 @@ impl<'a> Pass<'a> {
self.cur_c[e] = cur_c;
let row = e * n;

let range = if pinned.is_set(target as usize) || dep.behavior.is_bundled() {
let frozen = self.frozen_below.is_some_and(|below| {
direct
|| target < below
|| self
.fixed_rows
.is_set_allow_out_of_bound(dep_id as usize, false)
});
let range = if pinned.is_set(target as usize) || frozen || dep.behavior.is_bundled()
{
None
} else {
effective_npm_range(lockfile, dep_id, dep)
Expand Down Expand Up @@ -346,7 +361,11 @@ fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}

fn dedupe_lockfile(lockfile: &mut Lockfile) -> Report {
fn dedupe_lockfile(
lockfile: &mut Lockfile,
frozen_below: Option<PackageID>,
fixed_rows: &DynamicBitSet,
) -> Report {
let buf = lockfile.buffers.string_bytes.as_slice();
let pkg_res = lockfile.packages.items_resolution();
let checked = pkg_res.len();
Expand Down Expand Up @@ -378,7 +397,9 @@ fn dedupe_lockfile(lockfile: &mut Lockfile) -> Report {
.copied()
.filter(|&id| pkg_res[id as usize].tag == ResolutionTag::Npm)
.collect();
if candidates.len() < 2 {
if candidates.len() < 2
|| frozen_below.is_some_and(|below| candidates.iter().all(|&id| id < below))
{
continue;
}
index_sort::sort_indices(&mut candidates, &mut |a, b| {
Expand Down Expand Up @@ -430,6 +451,8 @@ fn dedupe_lockfile(lockfile: &mut Lockfile) -> Report {
&groups,
&group_of,
&pinned,
frozen_below,
fixed_rows,
max_candidates,
max_edges,
);
Expand Down Expand Up @@ -855,7 +878,7 @@ pub fn dedupe_after_differ(manager: &mut PackageManager) {
refuse_out_of_date(manager);
}

let report = dedupe_lockfile(&mut manager.lockfile);
let report = dedupe_lockfile(&mut manager.lockfile, None, &DynamicBitSet::default());
if report.rows.is_empty() {
report_already_deduplicated(manager, &report);
}
Expand Down Expand Up @@ -902,3 +925,22 @@ pub fn dedupe_after_differ(manager: &mut PackageManager) {
.enable
.set(Enable::FORCE_SAVE_LOCKFILE, true);
}

/// Collapses the versions appended this session onto the fewest that satisfy their edges, so the result does not depend on the order manifests landed in. Packages loaded from the lockfile and root/workspace rows keep their edges.
pub fn collapse_appended(manager: &mut PackageManager) {
let lockfile = &mut *manager.lockfile;
let frozen_below = lockfile.loaded_package_count;
if lockfile.packages.len() as PackageID <= frozen_below {
return;
}
let report = dedupe_lockfile(lockfile, Some(frozen_below), &manager.fixed_rows);
for row in &report.rows {
bun_output::scoped_log!(
PackageManager,
"collapsed {}@{} onto {}",
BStr::new(&row.name),
BStr::new(&row.from),
BStr::new(&row.to)
);
}
}
83 changes: 22 additions & 61 deletions src/install/update_transitive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,6 @@ struct Pin {
#[derive(Default)]
pub struct TransitiveUpdate {
pins: Vec<Pin>,
/// Kept for `print_plan` when no install summary will print the rows (`--dry-run`, `--lockfile-only`).
report: Option<Report>,
}

impl TransitiveUpdate {
Expand All @@ -286,13 +284,9 @@ impl TransitiveUpdate {
}
edges
};
let (pins, report) = plan_edges(manager, &edges, direct)?;
register_moved(manager, &report.moved)?;
let printed_here = manager.options.dry_run || manager.options.lockfile_only;
Ok(TransitiveUpdate {
pins,
report: printed_here.then_some(report),
})
let (pins, moved) = plan_edges(manager, &edges, direct)?;
register_moved(manager, &moved)?;
Ok(TransitiveUpdate { pins })
}

/// Runs after the differ's own enqueues (including its override/catalog invalidation loops) so the pins win; edges the differ moved off their package are left to it.
Expand Down Expand Up @@ -348,13 +342,13 @@ impl TransitiveUpdate {
return;
}
let dry_run = options.dry_run;
let mut rows = self
.report
.as_ref()
.map_or_else(Vec::new, |report| report.rows.clone());
// Read back from the resolutions so rows the collapse re-pointed print where they landed.
let pinned: Vec<(DependencyID, PackageID)> =
self.pins.iter().map(|pin| (pin.dep_id, pin.from)).collect();
let mut pairs = direct.moved_pairs(&manager.lockfile);
pairs.extend(named_pairs(&manager.lockfile, named));
rows.extend(rows_between(manager, pairs));
pairs.extend(named_pairs(&manager.lockfile, &pinned));
let mut rows = rows_between(manager, pairs);
sort_dedup_rows(&mut rows);
let kept = kept_patched_rows(manager);
if !dry_run && rows.is_empty() && kept.is_empty() {
Expand Down Expand Up @@ -417,9 +411,9 @@ pub(crate) fn refresh_children_of(
}
edges
};
let (pins, report) = plan_edges(manager, &edges, &DirectDependencies::default())?;
register_moved(manager, &report.moved)?;
let update = TransitiveUpdate { pins, report: None };
let (pins, moved) = plan_edges(manager, &edges, &DirectDependencies::default())?;
register_moved(manager, &moved)?;
let update = TransitiveUpdate { pins };
update.enqueue(manager)?;
Ok(update
.pins
Expand Down Expand Up @@ -537,13 +531,6 @@ struct Row {
later: Box<[u8]>,
}

#[derive(Default)]
struct Report {
rows: Vec<Row>,
/// Pre-clean ids of the instances at least one row moves away from.
moved: Vec<PackageID>,
}

/// Registers each npm package of `moved` (pre-clean ids) like a `bun update <name>` request so the install summary prints its update row; a name with several moving instances keeps its lowest original.
pub(crate) fn register_moved(
manager: &mut PackageManager,
Expand Down Expand Up @@ -1018,12 +1005,12 @@ pub(crate) fn plannable_peer_rows(
rows
}

/// `edges` selects the rows to plan; each moves to the newest release its (post-override/catalog) range allows, or follows its dist-tag.
/// `edges` selects the rows to plan; each moves to the newest release its (post-override/catalog) range allows, or follows its dist-tag. Also returns the pre-clean ids of the instances at least one row moves away from.
fn plan_edges(
manager: &mut PackageManager,
edges: &DynamicBitSet,
direct: &DirectDependencies,
) -> crate::Result<(Vec<Pin>, Report)> {
) -> crate::Result<(Vec<Pin>, Vec<PackageID>)> {
let mut instances: Vec<Instance> = Vec::new();
let mut kept: Vec<PackageID> = Vec::new();
{
Expand Down Expand Up @@ -1122,7 +1109,7 @@ fn plan_edges(
}
instances.retain(|inst| !inst.wants.is_empty());
if instances.is_empty() {
return Ok((Vec::new(), Report::default()));
return Ok((Vec::new(), Vec::new()));
}

let ids: Vec<PackageID> = instances.iter().map(|inst| inst.pkg_id).collect();
Expand All @@ -1136,7 +1123,7 @@ fn plan_edges(
let pkg_names = manager.lockfile.packages.items_name();

let mut pins: Vec<Pin> = Vec::new();
let mut report = Report::default();
let mut moved: Vec<PackageID> = Vec::new();
let mut unchecked: Vec<(Box<[u8]>, Box<[u8]>)> = Vec::new();
// Non-inline prerelease strings of planned versions live in the manifest buffer; copied into the lockfile's below.
let mut pre_strings: Vec<(core::ops::Range<usize>, u64, Box<[u8]>)> = Vec::new();
Expand All @@ -1162,9 +1149,9 @@ fn plan_edges(
};
let manifest: &PackageManifest = manifest;
let manifest_buf: &[u8] = &manifest.string_buf;
let rows_before = report.rows.len();
let pins_before = pins.len();
for want in &inst.wants {
let (v, to, later) = if want.version.tag == DependencyVersionTag::Npm {
let to = if want.version.tag == DependencyVersionTag::Npm {
let range = &want.version.npm().version;
let Some(found) = manifest
.find_best_version_with_filter(range, buf, min_age, excludes)
Expand All @@ -1184,7 +1171,7 @@ fn plan_edges(
Box::from(v.tag.pre.slice(manifest_buf)),
));
}
(v, Some(v), later_than(manifest, v, min_age, excludes))
Some(v)
} else {
let tag = want.version.dist_tag().tag.slice(buf);
let Some(found) = manifest
Expand All @@ -1196,22 +1183,16 @@ fn plan_edges(
if found.version.order(inst.current, manifest_buf, buf) == Ordering::Equal {
continue;
}
(found.version, None, Box::default())
None
};
pins.extend(want.dep_ids.iter().map(|&dep_id| Pin {
dep_id,
from: inst.pkg_id,
to,
}));
report.rows.push(Row {
name: Box::from(name),
from: text(inst.current.fmt(buf)),
to: text(v.fmt(manifest_buf)),
later,
});
}
if report.rows.len() != rows_before {
report.moved.push(inst.pkg_id);
if pins.len() != pins_before {
moved.push(inst.pkg_id);
}
}
index_sort::sort_vec_unstable_by(&mut unchecked, |a, b| a.cmp(b));
Expand All @@ -1230,25 +1211,5 @@ fn plan_edges(
}
}

sort_dedup_rows(&mut report.rows);
Ok((pins, report))
}

/// The `latest` dist-tag when it is newer than the release `v` an in-range move stops at, like the `+` rows' `(vX available)`.
fn later_than(
manifest: &PackageManifest,
v: Semver::Version,
min_age: Option<f64>,
excludes: Option<&[&[u8]]>,
) -> Box<[u8]> {
if v.tag.has_pre() {
return Box::default();
}
let manifest_buf: &[u8] = &manifest.string_buf;
let latest = manifest
.find_by_dist_tag_with_filter(b"latest", min_age, excludes)
.unwrap()
.map(|found| found.version)
.filter(|latest| latest.order(v, manifest_buf, manifest_buf) == Ordering::Greater);
latest.map_or_else(Box::default, |latest| text(latest.fmt(manifest_buf)))
Ok((pins, moved))
}
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,7 @@ exports[`hoisting text lockfile is hoisted 1`] = `

"hoist-lockfile-3": ["hoist-lockfile-3@1.0.0", "http://localhost:1234/hoist-lockfile-3/-/hoist-lockfile-3-1.0.0.tgz", { "dependencies": { "hoist-lockfile-shared": ">=1.0.1" } }, "sha512-iGz7jH7jxz/zq4OZM8hhT7kUX2Ye1m+45SoyMVcWTM7ZB+cY306Ff1mQePKTjkn84/pJMITMdRgDv/qF8PuQUw=="],

"hoist-lockfile-shared": ["hoist-lockfile-shared@2.0.2", "http://localhost:1234/hoist-lockfile-shared/-/hoist-lockfile-shared-2.0.2.tgz", {}, "sha512-xPWoyP8lv+/JrbClRzhJx1eUsHqDflSTmWOxx82xvMIEs6mbiIuvIp3/L+Ojc6mqex6y426h7L5j0hjLZE3V9w=="],

"hoist-lockfile-2/hoist-lockfile-shared": ["hoist-lockfile-shared@1.0.2", "http://localhost:1234/hoist-lockfile-shared/-/hoist-lockfile-shared-1.0.2.tgz", {}, "sha512-p7IQ/BbkTRLG/GUx6j2cDQ+vTUc/v9OW9Ss9igh/GFysbr0Qjriz/DiETnISkxYaTFitqOkUSOUkEKyeLNJsfQ=="],
"hoist-lockfile-shared": ["hoist-lockfile-shared@1.0.2", "http://localhost:1234/hoist-lockfile-shared/-/hoist-lockfile-shared-1.0.2.tgz", {}, "sha512-p7IQ/BbkTRLG/GUx6j2cDQ+vTUc/v9OW9Ss9igh/GFysbr0Qjriz/DiETnISkxYaTFitqOkUSOUkEKyeLNJsfQ=="],
}
}
"
Expand Down
Loading
Loading