Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a013f24
install: hold a transitive update that would re-fork a deduped package
robobun Aug 15, 2026
3337f55
test: raise the default timeout in bun-update-transitive like the oth…
robobun Aug 15, 2026
8ef54f9
Tighten the forks_surviving_instance doc comment
robobun Aug 15, 2026
c0d7fd9
Shorten the forks_surviving_instance doc comment
robobun Aug 15, 2026
f8ff333
install: model direct rows by re-resolution when deciding holds
robobun Aug 15, 2026
1df703e
Single-line doc comments in update_transitive
robobun Aug 15, 2026
9675f22
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 15, 2026
4edbaec
Assert the update report in the direct-move test; hash-compare names …
robobun Aug 15, 2026
886cf00
ci: retrigger
robobun Aug 15, 2026
daca2e6
install: mirror should_update when deciding whether a direct row stays
robobun Aug 15, 2026
78d39ab
Single-line comment on the should_update mirror
robobun Aug 15, 2026
4a26c93
install: a non-re-resolved direct row only stays on the instance it r…
robobun Aug 15, 2026
efde396
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 15, 2026
373648a
install: a catalog row is never modeled as resolving to latest
robobun Aug 15, 2026
518c808
install: model keep-locked-if-ahead, recover unresolved root rows, sk…
robobun Aug 15, 2026
b91e094
Single-line comments
robobun Aug 15, 2026
cda4023
Re-run checks
robobun Aug 15, 2026
61b83b8
install: true reachability for follower owners; keep-locked only on t…
robobun Aug 15, 2026
04846db
Single-line comment
robobun Aug 15, 2026
0dd4c29
install: reach owners from the root only; model keep-locked by its lo…
robobun Aug 15, 2026
5352dd7
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 15, 2026
a4baec7
Check reachability first; note the manifest mutation in the two-insta…
robobun Aug 15, 2026
8f83f97
install: carry the locked version onto every fanned instance of a bar…
robobun Aug 15, 2026
c29661a
install: tolerate unresolved optional rows; model patched capture and…
robobun Aug 15, 2026
d2e13f9
install: peer rows fall through the patched capture, as in the resolver
robobun Aug 15, 2026
8289986
install: test stayers against the actual redirect target
robobun Aug 15, 2026
3e4b33a
install: carry stayers toward every redirect target
robobun Aug 15, 2026
bb246b2
Single-line comment
robobun Aug 15, 2026
ee72500
install: a moved direct row's landing only carries its own instance's…
robobun Aug 15, 2026
fe93047
install: only the first moved direct row's landing carries an instanc…
robobun Aug 15, 2026
f8d1e1e
install: cover workspace-captured rows converging under bun update -r
robobun Aug 15, 2026
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
257 changes: 226 additions & 31 deletions src/install/update_transitive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,13 @@
to: Option<Semver::Version>,
}

struct Planned {
v: Semver::Version,
/// `None` re-resolves the edge through its own dist-tag.
to: Option<Semver::Version>,
later: Box<[u8]>,
}

/// The transitive half of a bare `bun update`: every edge owned by a non-workspace package the selected workspaces reach (all of them from the root or with -r) moves to the newest release its own range allows, or to wherever its dist-tag points now.
#[derive(Default)]
pub struct TransitiveUpdate {
Expand Down Expand Up @@ -1126,6 +1133,7 @@
if instances.is_empty() {
return Ok((Vec::new(), Report::default()));
}
let edges_on = edges_on_instances(&manager.lockfile, &instances);

let ids: Vec<PackageID> = instances.iter().map(|inst| inst.pkg_id).collect();
let msgs_before = manager.log_mut().msgs.len();
Expand All @@ -1142,7 +1150,7 @@
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();
for inst in &instances {
for (inst_i, inst) in instances.iter().enumerate() {
if inst.held {
continue;
}
Expand All @@ -1166,41 +1174,103 @@
let manifest: &PackageManifest = manifest;
let manifest_buf: &[u8] = &manifest.string_buf;
let rows_before = report.rows.len();
for want in &inst.wants {
let (v, to, later) = 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)
.unwrap()
else {
continue;
};
let v = found.version;
if v.order(inst.current, manifest_buf, buf) != Ordering::Greater {
continue;
}
if !v.tag.pre.value.is_inline() {
let end = pins.len() + want.dep_ids.len();
pre_strings.push((
pins.len()..end,
v.tag.pre.hash,
Box::from(v.tag.pre.slice(manifest_buf)),
));
let planned: Vec<Option<Planned>> = inst
.wants
.iter()
.map(|want| {
if want.version.tag == DependencyVersionTag::Npm {
let range = &want.version.npm().version;
manifest
.find_best_version_with_filter(range, buf, min_age, excludes)
.unwrap()
.map(|found| found.version)
.filter(|&v| v.order(inst.current, manifest_buf, buf) == Ordering::Greater)
.map(|v| Planned {
v,
to: Some(v),
later: later_than(manifest, v, min_age, excludes),
})
} else {
let tag = want.version.dist_tag().tag.slice(buf);
manifest
.find_by_dist_tag_with_filter(tag, min_age, excludes)
.unwrap()
.map(|found| found.version)
.filter(|&v| v.order(inst.current, manifest_buf, buf) != Ordering::Equal)
.map(|v| Planned {
v,
to: None,
later: Box::default(),
})
}
(v, Some(v), later_than(manifest, v, min_age, excludes))
} else {
let tag = want.version.dist_tag().tag.slice(buf);
let Some(found) = manifest
.find_by_dist_tag_with_filter(tag, min_age, excludes)
.unwrap()
else {
})
.collect();
let edge_wants: Vec<(DependencyID, Option<usize>)> = edges_on.followers[inst_i]
.iter()
.map(|&edge| {
let owner = inst
.wants
.iter()
.position(|want| want.dep_ids.contains(&edge));
(edge, owner)
})
.collect();
let direct_stays = edges_on.direct[inst_i].iter().any(|&dep_id| {
direct_row_stays(
&manager.lockfile,
manifest,
dep_id,
inst.current,
min_age,
excludes,
)
});
// Holds cascade (a held want stays behind and can block another), so iterate to a fixed point.
let mut held_wants = vec![false; inst.wants.len()];
loop {
let mut changed = false;
for w in 0..inst.wants.len() {
let Some(plan) = &planned[w] else {
continue;
};
if found.version.order(inst.current, manifest_buf, buf) == Ordering::Equal {
if held_wants[w] || plan.to.is_none() {
continue;
}
(found.version, None, Box::default())
if forks_surviving_instance(
&manager.lockfile,
inst,
w,
&planned,
&held_wants,
&edge_wants,
direct_stays,
plan.v,
manifest_buf,
) {
held_wants[w] = true;
changed = true;
}
}
if !changed {
break;
}
}
for (w, want) in inst.wants.iter().enumerate() {
let Some(plan) = &planned[w] else {
continue;
};
if held_wants[w] {
continue;
}
let (v, to) = (plan.v, plan.to);
if to.is_some() && !v.tag.pre.value.is_inline() {
let end = pins.len() + want.dep_ids.len();
pre_strings.push((
pins.len()..end,
v.tag.pre.hash,
Box::from(v.tag.pre.slice(manifest_buf)),
));
}
pins.extend(want.dep_ids.iter().map(|&dep_id| Pin {
dep_id,
from: inst.pkg_id,
Expand All @@ -1210,7 +1280,7 @@
name: Box::from(name),
from: text(inst.current.fmt(buf)),
to: text(v.fmt(manifest_buf)),
later,
later: plan.later.clone(),
});
}
if report.rows.len() != rows_before {
Expand All @@ -1237,6 +1307,131 @@
Ok((pins, report))
}

/// Live rows per planned instance: `followers` resolve to it and move only via the post-resolve redirect; `direct` root/workspace rows are re-resolved by the differ, so they are matched by name.
struct InstanceEdges {
followers: Vec<Vec<DependencyID>>,
direct: Vec<Vec<DependencyID>>,
}

fn edges_on_instances(lockfile: &Lockfile, instances: &[Instance]) -> InstanceEdges {
let packages_len = lockfile.packages.len();
let mut slot_of: Vec<u32> = vec![u32::MAX; packages_len];
for (i, inst) in instances.iter().enumerate() {
slot_of[inst.pkg_id as usize] = i as u32;
}
let buf = lockfile.buffers.string_bytes.as_slice();
let pkg_res = lockfile.packages.items_resolution();
let pkg_names = lockfile.packages.items_name();
let dep_slices = lockfile.packages.items_dependencies();
let deps = lockfile.buffers.dependencies.as_slice();
let resolutions = lockfile.buffers.resolutions.as_slice();

let mut followers: Vec<Vec<DependencyID>> = vec![Vec::new(); instances.len()];
let mut direct: Vec<Vec<DependencyID>> = vec![Vec::new(); instances.len()];
for owner in 0..packages_len {
let slice = dep_slices[owner];
let is_direct = matches!(
pkg_res[owner].tag,
ResolutionTag::Root | ResolutionTag::Workspace
);
for row in slice.begin() as usize..slice.end() as usize {
if !is_direct {
let Some(&slot) = slot_of.get(resolutions[row] as usize) else {
continue;
};
if slot != u32::MAX {
followers[slot as usize].push(row as DependencyID);
}
continue;
}
let Some(version) =
dedupe::effective_version(lockfile, row as DependencyID, &deps[row])
else {
continue;
};
let names = match version.tag {

Check warning on line 1352 in src/install/update_transitive.rs

View workflow job for this annotation

GitHub Actions / mordant

this `match` on `bun_install_types::DependencyVersionTag` repeats an earlier one arm for arm. The mapping exists twice, and a change to one copy will miss the other
DependencyVersionTag::Npm => version.npm().name,
DependencyVersionTag::DistTag => version.dist_tag().name,
_ => continue,
};
for (i, inst) in instances.iter().enumerate() {
if names.eql(pkg_names[inst.pkg_id as usize], buf, buf) {
direct[i].push(row as DependencyID);
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
InstanceEdges { followers, direct }
}

/// The differ lands this root/workspace row back on `current` when that is the best release (or dist-tag target) its range allows.
fn direct_row_stays(
lockfile: &Lockfile,
manifest: &PackageManifest,
dep_id: DependencyID,
current: Semver::Version,
min_age: Option<f64>,
excludes: Option<&[&[u8]]>,
) -> bool {
let buf = lockfile.buffers.string_bytes.as_slice();
let dep = &lockfile.buffers.dependencies[dep_id as usize];
let Some(version) = dedupe::effective_version(lockfile, dep_id, dep) else {
return false;
};
let found = match version.tag {
DependencyVersionTag::Npm => manifest
.find_best_version_with_filter(&version.npm().version, buf, min_age, excludes)
.unwrap(),
DependencyVersionTag::DistTag => manifest
.find_by_dist_tag_with_filter(version.dist_tag().tag.slice(buf), min_age, excludes)
.unwrap(),
_ => return false,
};
Comment thread
claude[bot] marked this conversation as resolved.
found.is_some_and(|found| {
found.version.order(current, &manifest.string_buf, buf) == Ordering::Equal
})
}
Comment thread
robobun marked this conversation as resolved.
Outdated
Comment thread
robobun marked this conversation as resolved.

/// An edge left behind at a still-satisfying `current` would re-create the duplicate `bun dedupe` removes.
#[allow(clippy::too_many_arguments)]
fn forks_surviving_instance(
lockfile: &Lockfile,
inst: &Instance,
want_index: usize,
planned: &[Option<Planned>],
Comment thread
claude[bot] marked this conversation as resolved.
held_wants: &[bool],
edge_wants: &[(DependencyID, Option<usize>)],
direct_stays: bool,
v: Semver::Version,
manifest_buf: &[u8],
) -> bool {
let buf = lockfile.buffers.string_bytes.as_slice();
let want = &inst.wants[want_index];
if want.version.tag != DependencyVersionTag::Npm
|| !want.version.npm().version.satisfies(inst.current, buf, buf)
{
return false;
}
if direct_stays {
return true;
}
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
let deps = lockfile.buffers.dependencies.as_slice();
let stays = |version: &dependency::Version| {
version.tag != DependencyVersionTag::Npm
|| !version.npm().version.satisfies(v, buf, manifest_buf)
};
edge_wants.iter().any(|&(edge, owner)| match owner {
Some(w) if w == want_index => false,
Some(w) => (planned[w].is_none() || held_wants[w]) && stays(&inst.wants[w].version),
None => {
let dep = &deps[edge as usize];
dep.behavior.is_bundled()
|| dedupe::effective_npm_range(lockfile, edge, dep)
.is_none_or(|range| stays(&range))
}
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
})
}

/// 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,
Expand Down
Loading
Loading