Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
161 changes: 126 additions & 35 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,63 @@
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)),
));
}
(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 {
continue;
};
if found.version.order(inst.current, manifest_buf, buf) == Ordering::Equal {
continue;
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(),
})
}
(found.version, None, Box::default())
})
.collect();
for (w, want) in inst.wants.iter().enumerate() {
let Some(plan) = &planned[w] else {
continue;
};
let (v, to) = (plan.v, plan.to);
if to.is_some()
&& forks_surviving_instance(
&manager.lockfile,
inst,
w,
&planned,
&edges_on[inst_i],
v,
manifest_buf,
)
{
continue;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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 +1240,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 +1267,67 @@
Ok((pins, report))
}

/// For each planned instance, every edge in the lockfile still resolving to it.
fn edges_on_instances(lockfile: &Lockfile, instances: &[Instance]) -> Vec<Vec<DependencyID>> {
let mut slot_of: Vec<u32> = vec![u32::MAX; lockfile.packages.len()];
for (i, inst) in instances.iter().enumerate() {
slot_of[inst.pkg_id as usize] = i as u32;
}
let mut edges_on: Vec<Vec<DependencyID>> = vec![Vec::new(); instances.len()];
for (j, &target) in lockfile.buffers.resolutions.iter().enumerate() {
let Some(&slot) = slot_of.get(target as usize) else {
continue;
Comment thread
robobun marked this conversation as resolved.
};
if slot != u32::MAX {
edges_on[slot as usize].push(j as DependencyID);
}
}
edges_on
}
Comment thread
robobun marked this conversation as resolved.

/// A move to `v` is dropped when another edge on the instance stays behind at `current` (its range
/// rejects `v`, or it is bundled or has no npm range, so the post-resolve redirect cannot carry it)
/// while `current` already satisfies the moving range: the fork would add exactly the duplicate
/// `bun dedupe` removes, and the two commands would undo each other forever.
Comment thread
robobun marked this conversation as resolved.
Outdated
fn forks_surviving_instance(
lockfile: &Lockfile,
inst: &Instance,
want_index: usize,
planned: &[Option<Planned>],
Comment thread
claude[bot] marked this conversation as resolved.
edges: &[DependencyID],
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;
}
let deps = lockfile.buffers.dependencies.as_slice();
let stays = |version: &dependency::Version| {
version.tag != DependencyVersionTag::Npm
|| !version.npm().version.satisfies(v, buf, manifest_buf)
};
edges.iter().any(|&edge| {
match inst
.wants
.iter()
.position(|other| other.dep_ids.contains(&edge))
{
Some(w) if w == want_index => false,
Some(w) => planned[w].is_none() && stays(&inst.wants[w].version),
None => {
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
let dep = &deps[edge as usize];
dep.behavior.is_bundled()
|| dedupe::effective_npm_range(lockfile, edge, dep)
.is_none_or(|range| stays(&range))
}
}

Check failure on line 1327 in src/install/update_transitive.rs

View check run for this annotation

Claude / Claude Code Review

forks_surviving_instance misreads orphaned root rows: spurious holds and a new update↔dedupe oscillation

`forks_surviving_instance`'s `None` branch misreads root edges: under bare `bun update` the differ leaves *every* new root row at `invalid_package_id` (Package.rs:1430-1441 skips `mapping[i]=…` for all root deps), so the only root edges `edges_on_instances` sees are the *orphaned old rows*, and "stays iff range rejects `v`" models `redirect()`, not the differ that actually re-resolves those rows. Two regressions follow: (1) removing a root dep that shared an instance with a transitive range spur
Comment thread
robobun marked this conversation as resolved.
Outdated
})
Comment thread
coderabbitai[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
45 changes: 36 additions & 9 deletions test/cli/install/bun-update-transitive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,27 +507,54 @@ test.concurrent("a transitive dependency pinned exactly by its dependent stays p
expect(exitCode).toBe(0);
});

// The root's no-deps@1.0.0 dedupes both dependents onto 1.0.0 before it is dropped; the update forks only the `^1.0.0` edge.
test.concurrent("dependents with different ranges are resolved independently", async () => {
// The root's no-deps@1.0.0 dedupes both dependents onto 1.0.0 before it is dropped; the fixed edge keeps 1.0.0 alive, so the `^1.0.0` edge is held instead of forked into the duplicate `bun dedupe` would remove.
test.concurrent("a range edge is not forked off an instance a fixed sibling keeps alive", async () => {
const dependents = { "one-fixed-dep": "1.0.0", "one-range-dep": "1.0.0" };
const dir = await setup({ "package.json": pkgJson({ "no-deps": "1.0.0", ...dependents }) });
const packageJson = pkgJson(dependents);
await reinstall(dir, packageJson);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
const before = await lockText(dir);
const { stdout, stderr, exitCode } = await run(dir, "update");
expectSummary(stdout, NO_DEPS_ROW_HINTED, "", installed(1));
expectSummary(stdout, noChanges(3, 4));
expectCleanStderr(stderr);
expect(stderr).not.toContain("Saved lockfile");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0", "1.1.0"]);
const { packages } = await lock(dir);
expect([packages["no-deps"][0], packages["one-range-dep/no-deps"][0]]).toStrictEqual([
"no-deps@1.0.0",
"no-deps@1.1.0",
]);
expect(await lockText(dir)).toBe(before);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
await frozen(dir);
expect(exitCode).toBe(0);
});

// #38903: `bun update` and `bun dedupe` must reach a fixed point. After dedupe collapses the `^1.0.0`
// edge onto the exact pin's 1.0.0, a bare update holds that edge instead of re-adding the duplicate.
test.concurrent("a deduped lockfile is a fixed point of `bun update`", async () => {
const dir = await setup({ "package.json": pkgJson({ "one-range-dep": "1.0.0" }) });
const packageJson = pkgJson({ "one-range-dep": "1.0.0", "no-deps": "1.0.0" });
await reinstall(dir, packageJson);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0", "1.1.0"]);

const deduped = await run(dir, "dedupe");
expect(deduped.stderr).not.toContain("error:");
expect(deduped.exitCode).toBe(0);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
const before = await lockText(dir);

const { stdout, stderr, exitCode } = await run(dir, "update");
expectSummary(stdout, noChanges(2, 3));
expectCleanStderr(stderr);
expect(stderr).not.toContain("Saved lockfile");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockText(dir)).toBe(before);
expect(exitCode).toBe(0);

const check = await run(dir, "dedupe", "--check");
expect(check.stderr).not.toContain("error:");
expect(check.exitCode).toBe(0);
expect(await lockText(dir)).toBe(before);
await frozen(dir);
});

// The root's exact 1.0.0 takes the root slot and pushes one-range-dep's 1.1.0 into a nested folder; widening the root keeps 1.0.0 locked, so the update collapses both rows onto 1.1.0.
test.concurrent("hoisted: a bare update removes the nested copy whose row it collapsed", async () => {
const dir = await setup({ "package.json": pkgJson({ "one-range-dep": "1.0.0" }) });
Expand Down
Loading