From e1c46fe0b0bf2b236f4c0c7759a7be73c4a1d14d Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:35:45 +0000 Subject: [PATCH 1/3] fix(install): make --force re-materialize linked packages Co-Authored-By: Claude Sonnet 5.5 Entire-Checkpoint: 01M5CZ92ZGTFFP2T4HZ5VQ2KSH --- aube.usage.kdl | 2 +- crates/aube-linker/src/builder.rs | 23 ++++++++++ crates/aube-linker/src/lib.rs | 3 ++ crates/aube-linker/src/link.rs | 12 +++++- crates/aube-linker/src/materialize.rs | 45 +++++++++++++++++++- crates/aube-linker/src/tests.rs | 53 ++++++++++++++++++++++++ crates/aube/src/commands/install/args.rs | 14 ++++--- crates/aube/src/commands/install/link.rs | 3 ++ crates/aube/src/commands/install/mod.rs | 1 + docs/cli/commands.json | 2 +- docs/cli/install.md | 2 +- test/install.bats | 38 +++++++++++++++++ 12 files changed, 186 insertions(+), 12 deletions(-) diff --git a/aube.usage.kdl b/aube.usage.kdl index 21c14f204..40c161bcb 100644 --- a/aube.usage.kdl +++ b/aube.usage.kdl @@ -1231,7 +1231,7 @@ Leaves everything else pinned at its locked version. Unchanged specs keep their long_help #""" Force reinstall, ignoring lockfile/state freshness. -Bypasses the `node_modules/.aube-state` freshness check and re-resolves the lockfile even when nothing has drifted. Mirrors pnpm's `install --force`. +Bypasses the `node_modules/.aube-state` freshness check, re-resolves the lockfile even when nothing has drifted, and re-materializes every linked package so deleted or modified files in `node_modules` are restored. With the global virtual store, the shared entry is rebuilt and swapped into place for every project that uses it. Mirrors pnpm's `install --force`. """# } flag --global-pnpmfile help="Add a global pnpmfile that runs before the local one." conflicts=--ignore-pnpmfile { diff --git a/crates/aube-linker/src/builder.rs b/crates/aube-linker/src/builder.rs index 6d1788404..016940357 100644 --- a/crates/aube-linker/src/builder.rs +++ b/crates/aube-linker/src/builder.rs @@ -5,6 +5,7 @@ use aube_lockfile::graph_hash::GraphHashes; use aube_store::Store; use std::path::{Path, PathBuf}; +use crate::sweep::EntryState; use crate::{ HoistingLimits, LinkStrategy, Linker, NodeLinker, Patches, default_linker_parallelism, }; @@ -42,6 +43,7 @@ impl Linker { node_linker: NodeLinker::Isolated, link_concurrency: None, virtual_store_only: false, + force: false, modules_dir_name: "node_modules".to_string(), aube_dir_override: None, } @@ -313,6 +315,27 @@ impl Linker { self } + /// Toggle `install --force`. When enabled, `link_all` / + /// `link_workspace` re-materialize every virtual-store entry rather + /// than treating an existing one as a cache hit, so deleted or + /// modified package files are restored. Per-project `.aube/` entries + /// are removed and rewritten. A shared global virtual-store entry is + /// rebuilt in a staging directory and swapped into place, so the + /// shared path is never left half-written for other projects. + pub fn with_force(mut self, force: bool) -> Self { + self.force = force; + self + } + + /// Under `--force` a symlink that already points at the right target + /// is not a cache hit: the entry behind it is rebuilt too. + pub(crate) fn forced_entry_state(&self, state: EntryState) -> EntryState { + match state { + EntryState::Fresh if self.force => EntryState::Stale, + other => other, + } + } + /// Whether this linker will skip the top-level `node_modules/` /// symlink pass. Exposed so the install driver can omit root-level /// bin linking and lifecycle-script invocations when the user has diff --git a/crates/aube-linker/src/lib.rs b/crates/aube-linker/src/lib.rs index e6bd12fd8..707c77048 100644 --- a/crates/aube-linker/src/lib.rs +++ b/crates/aube-linker/src/lib.rs @@ -240,6 +240,9 @@ pub struct Linker { /// `NodeLinker::Hoisted` — that layout *is* a flat top-level /// materialization, so "only the virtual store" doesn't apply. virtual_store_only: bool, + /// `install --force`: rebuild every virtual-store entry instead of + /// trusting entries that already exist on disk. + force: bool, } /// Strategy for linking files from the store to node_modules. diff --git a/crates/aube-linker/src/link.rs b/crates/aube-linker/src/link.rs index a6d290be7..494164a84 100644 --- a/crates/aube-linker/src/link.rs +++ b/crates/aube-linker/src/link.rs @@ -222,7 +222,7 @@ impl Linker { // materialization instead of treating the path-keyed entry as a // cache hit. The install-state warm path prevents this work when // the source fingerprint is unchanged. - if matches!(local, LocalSource::Directory(_) | LocalSource::Portal(_)) { + if self.force || matches!(local, LocalSource::Directory(_) | LocalSource::Portal(_)) { try_remove_entry(&aube_entry); if aube_entry.exists() { return Err(Error::Io( @@ -326,6 +326,7 @@ impl Linker { } else { classify_entry_state(&local_aube_entry, &global_entry) }; + let state = self.forced_entry_state(state); if matches!(state, EntryState::Fresh) { local_stats.packages_cached += 1; @@ -473,6 +474,9 @@ impl Linker { .map(|(dep_path, pkg)| { let mut local_stats = LinkStats::default(); let aube_entry = aube_dir.join(self.aube_dir_entry_name(dep_path)); + if self.force { + try_remove_entry(&aube_entry); + } if aube_entry.exists() { // Already in place from a previous run — // count as cached. `install.rs` @@ -927,7 +931,7 @@ impl Linker { continue; }; let aube_entry = aube_dir.join(self.aube_dir_entry_name(dep_path)); - if matches!(local, LocalSource::Directory(_) | LocalSource::Portal(_)) { + if self.force || matches!(local, LocalSource::Directory(_) | LocalSource::Portal(_)) { try_remove_entry(&aube_entry); if aube_entry.exists() { return Err(Error::Io( @@ -1023,6 +1027,7 @@ impl Linker { } else { classify_entry_state(&local_aube_entry, &global_entry) }; + let state = self.forced_entry_state(state); if matches!(state, EntryState::Fresh) { if !project_local { @@ -1121,6 +1126,9 @@ impl Linker { .map(|(dep_path, pkg)| { let mut local_stats = LinkStats::default(); let aube_entry = aube_dir.join(self.aube_dir_entry_name(dep_path)); + if self.force { + try_remove_entry(&aube_entry); + } if aube_entry.exists() { local_stats.packages_cached += 1; return Ok(local_stats); diff --git a/crates/aube-linker/src/materialize.rs b/crates/aube-linker/src/materialize.rs index 9d1c5dcef..2ba12787f 100644 --- a/crates/aube-linker/src/materialize.rs +++ b/crates/aube-linker/src/materialize.rs @@ -75,6 +75,35 @@ fn place_materialized_entry(src: &Path, dst: &Path) -> io::Result io::Result { + match std::fs::rename(dst, aside) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::NotFound => { + return place_materialized_entry(src, dst); + } + Err(e) => return Err(e), + } + match place_materialized_entry(src, dst) { + Ok(MaterializePlacement::Placed) => { + let _ = std::fs::remove_dir_all(aside); + Ok(MaterializePlacement::Placed) + } + other => { + if !dst.exists() { + let _ = std::fs::rename(aside, dst); + } + other + } + } +} + fn is_transient_rename_error(err: &io::Error) -> bool { matches!( err.kind(), @@ -271,7 +300,10 @@ impl Linker { .join("node_modules") .join(&pkg.name); - if pkg_nm_dir.exists() { + // Under `--force` an existing entry is only trusted when this very + // install just placed it. + let trust_existing = !self.force || self.fresh_virtual_store_entries.contains(dep_path); + if trust_existing && pkg_nm_dir.exists() { if !self.fresh_virtual_store_entries.contains(dep_path) { self.reconcile_virtual_store_entry(dep_path, pkg, nested_link_targets)?; } @@ -312,7 +344,16 @@ impl Linker { return Err(e); } - match place_materialized_entry(&tmp_entry, &final_entry) { + let placement = if self.force && final_entry.exists() { + replace_materialized_entry( + &tmp_entry, + &final_entry, + &self.virtual_store.join(materialize_tmp_name()), + ) + } else { + place_materialized_entry(&tmp_entry, &final_entry) + }; + match placement { Ok(MaterializePlacement::Placed) => { trace!("atomically placed {subdir} in virtual store"); } diff --git a/crates/aube-linker/src/tests.rs b/crates/aube-linker/src/tests.rs index 56000736c..25eb4a183 100644 --- a/crates/aube-linker/src/tests.rs +++ b/crates/aube-linker/src/tests.rs @@ -634,6 +634,59 @@ fn test_link_all_creates_pnpm_virtual_store() { assert!(stats.files_linked >= 3); // foo has 2 files, bar has 1 } +#[test] +fn force_relink_restores_deleted_files_in_both_virtual_store_modes() { + for global in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let project_dir = dir.path().join("project"); + std::fs::create_dir_all(&project_dir).unwrap(); + + let (store, indices) = setup_store_with_files(dir.path()); + let graph = make_graph(); + Linker::new_with_gvs(&store, LinkStrategy::Copy, global) + .link_all(&project_dir, &graph, &indices) + .unwrap(); + + let foo_index = project_dir.join("node_modules/foo/index.js"); + std::fs::remove_file(&foo_index).unwrap(); + + Linker::new_with_gvs(&store, LinkStrategy::Copy, global) + .link_all(&project_dir, &graph, &indices) + .unwrap(); + assert!( + !foo_index.exists(), + "a plain relink trusts the existing entry (global={global})" + ); + + let stats = Linker::new_with_gvs(&store, LinkStrategy::Copy, global) + .with_force(true) + .link_all(&project_dir, &graph, &indices) + .unwrap(); + assert_eq!( + std::fs::read_to_string(&foo_index).unwrap(), + "module.exports = 'foo';", + "force restores the deleted file (global={global})" + ); + assert_eq!(stats.packages_linked, 2, "global={global}"); + assert!( + project_dir + .join("node_modules/.aube/foo@1.0.0/node_modules/bar") + .exists(), + "sibling dependency link survives the rebuild (global={global})" + ); + let leftovers = std::fs::read_dir(store.virtual_store_dir()) + .into_iter() + .flatten() + .flatten() + .filter(|e| e.file_name().to_string_lossy().starts_with(".tmp-")) + .count(); + assert_eq!( + leftovers, 0, + "no staging dirs left behind (global={global})" + ); + } +} + #[test] fn gvs_link_replaces_a_real_directory_at_a_virtual_store_entry() { // Copying a project in a way that follows links (Windows Explorer, diff --git a/crates/aube/src/commands/install/args.rs b/crates/aube/src/commands/install/args.rs index 197b68bce..4311029a9 100644 --- a/crates/aube/src/commands/install/args.rs +++ b/crates/aube/src/commands/install/args.rs @@ -34,9 +34,12 @@ pub struct InstallArgs { pub fix_lockfile: bool, /// Force reinstall, ignoring lockfile/state freshness. /// - /// Bypasses the `node_modules/.aube-state` freshness check and - /// re-resolves the lockfile even when nothing has drifted. Mirrors - /// pnpm's `install --force`. + /// Bypasses the `node_modules/.aube-state` freshness check, + /// re-resolves the lockfile even when nothing has drifted, and + /// re-materializes every linked package so deleted or modified + /// files in `node_modules` are restored. With the global virtual + /// store, the shared entry is rebuilt and swapped into place for + /// every project that uses it. Mirrors pnpm's `install --force`. #[usage(long)] pub force: bool, /// Add a global pnpmfile that runs before the local one. @@ -410,8 +413,9 @@ pub struct InstallOptions { /// default (`CI=1`, no explicit flag) leaves this `false` so a /// fresh checkout still resolves and writes a lockfile. pub strict_no_lockfile: bool, - /// `--force`: re-resolve and relink even when `node_modules/.aube-state` says the - /// tree is up to date. Mirrors pnpm's `install --force`. + /// `--force`: re-resolve and re-materialize every linked package even when + /// `node_modules/.aube-state` says the tree is up to date. Mirrors pnpm's + /// `install --force`. pub force: bool, /// Parsed CLI flag bag forwarded into /// [`aube_settings::ResolveCtx::cli`] so the build-time-generated diff --git a/crates/aube/src/commands/install/link.rs b/crates/aube/src/commands/install/link.rs index a1ae3610d..f2204b493 100644 --- a/crates/aube/src/commands/install/link.rs +++ b/crates/aube/src/commands/install/link.rs @@ -37,6 +37,7 @@ pub(super) struct LinkPhaseInput<'a> { pub(super) dep_selection_filtered: bool, pub(super) workspace_filter_empty: bool, pub(super) ignore_scripts: bool, + pub(super) force: bool, pub(super) prog_ref: Option<&'a crate::progress::InstallProgress>, pub(super) phase_timings: &'a mut InstallPhaseTimings, } @@ -79,6 +80,7 @@ pub(super) fn run_link_phase(input: LinkPhaseInput<'_>) -> miette::Result) -> miette::Result miette: dep_selection_filtered: opts.dep_selection.is_filtered(), workspace_filter_empty: opts.workspace_filter.is_empty(), ignore_scripts: opts.ignore_scripts, + force: opts.force, prog_ref, phase_timings: &mut phase_timings, })?; diff --git a/docs/cli/commands.json b/docs/cli/commands.json index 2b00ff848..ee18e9957 100644 --- a/docs/cli/commands.json +++ b/docs/cli/commands.json @@ -7269,7 +7269,7 @@ "name": "force", "usage": "--force", "help": "Force reinstall, ignoring lockfile/state freshness.", - "help_long": "Force reinstall, ignoring lockfile/state freshness.\n\nBypasses the `node_modules/.aube-state` freshness check and re-resolves the lockfile even when nothing has drifted. Mirrors pnpm's `install --force`.", + "help_long": "Force reinstall, ignoring lockfile/state freshness.\n\nBypasses the `node_modules/.aube-state` freshness check, re-resolves the lockfile even when nothing has drifted, and re-materializes every linked package so deleted or modified files in `node_modules` are restored. With the global virtual store, the shared entry is rebuilt and swapped into place for every project that uses it. Mirrors pnpm's `install --force`.", "help_first_line": "Force reinstall, ignoring lockfile/state freshness.", "short": [], "long": [ diff --git a/docs/cli/install.md b/docs/cli/install.md index 53c00e9fc..69fde13c5 100644 --- a/docs/cli/install.md +++ b/docs/cli/install.md @@ -42,7 +42,7 @@ Read the [workflow guide](/package-manager/install) for context. Leaves everything else pinned at its locked version. Unchanged specs keep their existing version and integrity hash; only drifted entries (and any new transitives they pull in) get re-resolved. - **`--force`** — Force reinstall, ignoring lockfile/state freshness. - Bypasses the `node_modules/.aube-state` freshness check and re-resolves the lockfile even when nothing has drifted. Mirrors pnpm's `install --force`. + Bypasses the `node_modules/.aube-state` freshness check, re-resolves the lockfile even when nothing has drifted, and re-materializes every linked package so deleted or modified files in `node_modules` are restored. With the global virtual store, the shared entry is rebuilt and swapped into place for every project that uses it. Mirrors pnpm's `install --force`. - **`--global-pnpmfile `** — Add a global pnpmfile that runs before the local one. Mirrors pnpm's `--global-pnpmfile `. Relative paths resolve against the project root. The global hook runs first and the local hook (if any) runs second, so local mutations win on conflicts — matching pnpm's composition order. diff --git a/test/install.bats b/test/install.bats index da958c860..149f77678 100644 --- a/test/install.bats +++ b/test/install.bats @@ -1400,3 +1400,41 @@ JSON shard_count="$(find "$store_v1/files" -type f | wc -l | tr -d ' ')" [ "$shard_count" -gt 0 ] } + +@test "aube install --force restores a deleted file in a per-project virtual store" { + _setup_basic_fixture + run aube --disable-global-virtual-store install + assert_success + rm node_modules/is-odd/index.js + run aube --disable-global-virtual-store install + assert_success + assert_file_not_exists node_modules/is-odd/index.js + + run aube --disable-global-virtual-store install --force + assert_success + assert_file_exists node_modules/is-odd/index.js +} + +@test "aube install --force restores a deleted file in the global virtual store" { + _setup_basic_fixture + run aube --enable-global-virtual-store install + assert_success + rm node_modules/is-odd/index.js + assert_file_not_exists node_modules/is-odd/index.js + + run aube --enable-global-virtual-store install --force + assert_success + assert_file_exists node_modules/is-odd/index.js + assert_file_exists node_modules/is-odd/package.json +} + +@test "aube install --force restores a deleted file with node-linker=hoisted" { + _setup_basic_fixture + run aube install --node-linker=hoisted + assert_success + rm node_modules/is-odd/index.js + + run aube install --node-linker=hoisted --force + assert_success + assert_file_exists node_modules/is-odd/index.js +} From fa5ecd9303e39f0dd877ac2bee4fe8645f167928 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:52:47 +0000 Subject: [PATCH 2/3] fix(install): harden --force relink against failed removals and races Co-Authored-By: Claude Sonnet 5.5 Entire-Checkpoint: 01M5D084J1FP0DX6JTPH1AZDBN --- aube.usage.kdl | 2 +- crates/aube-linker/src/link.rs | 17 +++++++++++++++-- crates/aube-linker/src/materialize.rs | 17 +++++++++++++---- crates/aube/src/commands/install/args.rs | 2 +- docs/cli/commands.json | 2 +- docs/cli/install.md | 2 +- 6 files changed, 32 insertions(+), 10 deletions(-) diff --git a/aube.usage.kdl b/aube.usage.kdl index 40c161bcb..45aa0f04b 100644 --- a/aube.usage.kdl +++ b/aube.usage.kdl @@ -1231,7 +1231,7 @@ Leaves everything else pinned at its locked version. Unchanged specs keep their long_help #""" Force reinstall, ignoring lockfile/state freshness. -Bypasses the `node_modules/.aube-state` freshness check, re-resolves the lockfile even when nothing has drifted, and re-materializes every linked package so deleted or modified files in `node_modules` are restored. With the global virtual store, the shared entry is rebuilt and swapped into place for every project that uses it. Mirrors pnpm's `install --force`. +Bypasses the `node_modules/.aube-state` freshness check, re-resolves the lockfile even when nothing has drifted, and re-materializes every linked package so deleted files in `node_modules` are restored. With the global virtual store, the shared entry is rebuilt and swapped into place for every project that uses it. Mirrors pnpm's `install --force`. """# } flag --global-pnpmfile help="Add a global pnpmfile that runs before the local one." conflicts=--ignore-pnpmfile { diff --git a/crates/aube-linker/src/link.rs b/crates/aube-linker/src/link.rs index 494164a84..9a51bd060 100644 --- a/crates/aube-linker/src/link.rs +++ b/crates/aube-linker/src/link.rs @@ -475,7 +475,7 @@ impl Linker { let mut local_stats = LinkStats::default(); let aube_entry = aube_dir.join(self.aube_dir_entry_name(dep_path)); if self.force { - try_remove_entry(&aube_entry); + remove_entry_checked(&aube_entry)?; } if aube_entry.exists() { // Already in place from a previous run — @@ -1127,7 +1127,7 @@ impl Linker { let mut local_stats = LinkStats::default(); let aube_entry = aube_dir.join(self.aube_dir_entry_name(dep_path)); if self.force { - try_remove_entry(&aube_entry); + remove_entry_checked(&aube_entry)?; } if aube_entry.exists() { local_stats.packages_cached += 1; @@ -1788,6 +1788,19 @@ impl Linker { } } +/// Remove a stale `.aube/` entry for `--force`, failing when it +/// survives so a partly removed tree is never counted as a cache hit. +fn remove_entry_checked(entry: &Path) -> Result<(), Error> { + try_remove_entry(entry); + if entry.symlink_metadata().is_ok() { + return Err(Error::Io( + entry.to_path_buf(), + std::io::Error::other("failed to remove existing entry for --force"), + )); + } + Ok(()) +} + /// Build a `dep_path → absolute on-disk target` map for every /// `LocalSource::Link` in the graph. Returned `None` when the graph /// has no link entries (vast majority of installs), so the materialize diff --git a/crates/aube-linker/src/materialize.rs b/crates/aube-linker/src/materialize.rs index 2ba12787f..a388d215f 100644 --- a/crates/aube-linker/src/materialize.rs +++ b/crates/aube-linker/src/materialize.rs @@ -96,10 +96,19 @@ fn replace_materialized_entry( Ok(MaterializePlacement::Placed) } other => { - if !dst.exists() { - let _ = std::fs::rename(aside, dst); + if dst.exists() { + // Another installer published its entry first; ours is stale. + let _ = std::fs::remove_dir_all(aside); + return other; + } + match std::fs::rename(aside, dst) { + Ok(()) => other, + Err(rollback) => Err(io::Error::other(format!( + "could not place the new entry and could not restore the previous one \ + (kept at {}): {rollback}", + aside.display() + ))), } - other } } } @@ -489,7 +498,7 @@ impl Linker { ) -> Result<(), Error> { let local_aube_entry = aube_dir.join(self.aube_dir_entry_name(dep_path)); let global_entry = self.virtual_store.join(self.virtual_store_subdir(dep_path)); - let state = classify_entry_state(&local_aube_entry, &global_entry); + let state = self.forced_entry_state(classify_entry_state(&local_aube_entry, &global_entry)); if matches!(state, EntryState::Fresh) { self.reconcile_virtual_store_entry(dep_path, pkg, nested_link_targets)?; stats.packages_cached += 1; diff --git a/crates/aube/src/commands/install/args.rs b/crates/aube/src/commands/install/args.rs index 4311029a9..9dab6725f 100644 --- a/crates/aube/src/commands/install/args.rs +++ b/crates/aube/src/commands/install/args.rs @@ -36,7 +36,7 @@ pub struct InstallArgs { /// /// Bypasses the `node_modules/.aube-state` freshness check, /// re-resolves the lockfile even when nothing has drifted, and - /// re-materializes every linked package so deleted or modified + /// re-materializes every linked package so deleted /// files in `node_modules` are restored. With the global virtual /// store, the shared entry is rebuilt and swapped into place for /// every project that uses it. Mirrors pnpm's `install --force`. diff --git a/docs/cli/commands.json b/docs/cli/commands.json index ee18e9957..3fffc358b 100644 --- a/docs/cli/commands.json +++ b/docs/cli/commands.json @@ -7269,7 +7269,7 @@ "name": "force", "usage": "--force", "help": "Force reinstall, ignoring lockfile/state freshness.", - "help_long": "Force reinstall, ignoring lockfile/state freshness.\n\nBypasses the `node_modules/.aube-state` freshness check, re-resolves the lockfile even when nothing has drifted, and re-materializes every linked package so deleted or modified files in `node_modules` are restored. With the global virtual store, the shared entry is rebuilt and swapped into place for every project that uses it. Mirrors pnpm's `install --force`.", + "help_long": "Force reinstall, ignoring lockfile/state freshness.\n\nBypasses the `node_modules/.aube-state` freshness check, re-resolves the lockfile even when nothing has drifted, and re-materializes every linked package so deleted files in `node_modules` are restored. With the global virtual store, the shared entry is rebuilt and swapped into place for every project that uses it. Mirrors pnpm's `install --force`.", "help_first_line": "Force reinstall, ignoring lockfile/state freshness.", "short": [], "long": [ diff --git a/docs/cli/install.md b/docs/cli/install.md index 69fde13c5..108816888 100644 --- a/docs/cli/install.md +++ b/docs/cli/install.md @@ -42,7 +42,7 @@ Read the [workflow guide](/package-manager/install) for context. Leaves everything else pinned at its locked version. Unchanged specs keep their existing version and integrity hash; only drifted entries (and any new transitives they pull in) get re-resolved. - **`--force`** — Force reinstall, ignoring lockfile/state freshness. - Bypasses the `node_modules/.aube-state` freshness check, re-resolves the lockfile even when nothing has drifted, and re-materializes every linked package so deleted or modified files in `node_modules` are restored. With the global virtual store, the shared entry is rebuilt and swapped into place for every project that uses it. Mirrors pnpm's `install --force`. + Bypasses the `node_modules/.aube-state` freshness check, re-resolves the lockfile even when nothing has drifted, and re-materializes every linked package so deleted files in `node_modules` are restored. With the global virtual store, the shared entry is rebuilt and swapped into place for every project that uses it. Mirrors pnpm's `install --force`. - **`--global-pnpmfile `** — Add a global pnpmfile that runs before the local one. Mirrors pnpm's `--global-pnpmfile `. Relative paths resolve against the project root. The global hook runs first and the local hook (if any) runs second, so local mutations win on conflicts — matching pnpm's composition order. From d724a1718d6c6e8a40e08a500aab03f8eaeabe0b Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:02:10 +0000 Subject: [PATCH 3/3] fix(install): keep the previous package entry until a --force relink succeeds Co-Authored-By: Claude Sonnet 5.5 Entire-Checkpoint: 01M5D0RZQYWK51ZVKSFXGD9EB2 --- crates/aube-linker/src/link.rs | 31 +++-------------- crates/aube-linker/src/materialize.rs | 48 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 27 deletions(-) diff --git a/crates/aube-linker/src/link.rs b/crates/aube-linker/src/link.rs index 9a51bd060..17339321b 100644 --- a/crates/aube-linker/src/link.rs +++ b/crates/aube-linker/src/link.rs @@ -474,10 +474,7 @@ impl Linker { .map(|(dep_path, pkg)| { let mut local_stats = LinkStats::default(); let aube_entry = aube_dir.join(self.aube_dir_entry_name(dep_path)); - if self.force { - remove_entry_checked(&aube_entry)?; - } - if aube_entry.exists() { + if !self.force && aube_entry.exists() { // Already in place from a previous run — // count as cached. `install.rs` // deliberately omits this dep_path from @@ -508,14 +505,12 @@ impl Linker { &owned_index } }; - self.materialize_at( - &aube_dir.join(self.aube_dir_entry_name(dep_path)), + self.materialize_replacing( &aube_dir, dep_path, pkg, index, &mut local_stats, - false, nested_link_targets.as_ref(), )?; Ok(local_stats) @@ -1126,10 +1121,7 @@ impl Linker { .map(|(dep_path, pkg)| { let mut local_stats = LinkStats::default(); let aube_entry = aube_dir.join(self.aube_dir_entry_name(dep_path)); - if self.force { - remove_entry_checked(&aube_entry)?; - } - if aube_entry.exists() { + if !self.force && aube_entry.exists() { local_stats.packages_cached += 1; return Ok(local_stats); } @@ -1150,14 +1142,12 @@ impl Linker { &owned_index } }; - self.materialize_at( - &aube_dir.join(self.aube_dir_entry_name(dep_path)), + self.materialize_replacing( &aube_dir, dep_path, pkg, index, &mut local_stats, - false, nested_link_targets.as_ref(), )?; Ok(local_stats) @@ -1788,19 +1778,6 @@ impl Linker { } } -/// Remove a stale `.aube/` entry for `--force`, failing when it -/// survives so a partly removed tree is never counted as a cache hit. -fn remove_entry_checked(entry: &Path) -> Result<(), Error> { - try_remove_entry(entry); - if entry.symlink_metadata().is_ok() { - return Err(Error::Io( - entry.to_path_buf(), - std::io::Error::other("failed to remove existing entry for --force"), - )); - } - Ok(()) -} - /// Build a `dep_path → absolute on-disk target` map for every /// `LocalSource::Link` in the graph. Returned `None` when the graph /// has no link entries (vast majority of installs), so the materialize diff --git a/crates/aube-linker/src/materialize.rs b/crates/aube-linker/src/materialize.rs index a388d215f..4d06864cb 100644 --- a/crates/aube-linker/src/materialize.rs +++ b/crates/aube-linker/src/materialize.rs @@ -470,6 +470,54 @@ impl Linker { Ok(targets) } + /// Materialize a per-project `.aube` entry. Under `--force` an existing + /// entry is set aside first and only deleted once the replacement is + /// complete; if materializing fails, the partial entry is dropped and + /// the original is restored. + pub(crate) fn materialize_replacing( + &self, + aube_dir: &Path, + dep_path: &str, + pkg: &LockedPackage, + index: &PackageIndex, + stats: &mut LinkStats, + nested_link_targets: Option<&BTreeMap>, + ) -> Result<(), Error> { + let entry = &aube_dir.join(self.aube_dir_entry_name(dep_path)); + let aside = if self.force && entry.symlink_metadata().is_ok() { + let aside = aube_dir.join(materialize_tmp_name()); + std::fs::rename(entry, &aside).map_err(|e| Error::Io(entry.to_path_buf(), e))?; + Some(aside) + } else { + None + }; + let result = self.materialize_at( + entry, + aube_dir, + dep_path, + pkg, + index, + stats, + false, + nested_link_targets, + ); + if let Some(aside) = aside { + if result.is_ok() { + let _ = std::fs::remove_dir_all(&aside); + } else { + try_remove_entry(entry); + if let Err(e) = std::fs::rename(&aside, entry) { + tracing::warn!( + "could not restore {} after a failed --force relink (kept at {}): {e}", + entry.display(), + aside.display() + ); + } + } + } + result + } + /// Materialize a globally-reproducible local source (a `git` /// dependency or a remote `.tgz`) into the shared virtual store and /// point the per-project `.aube/` entry at it — the exact