Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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: 1 addition & 1 deletion aube.usage.kdl
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 {
Expand Down
23 changes: 23 additions & 0 deletions crates/aube-linker/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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/<name>`
/// symlink pass. Exposed so the install driver can omit root-level
/// bin linking and lifecycle-script invocations when the user has
Expand Down
3 changes: 3 additions & 0 deletions crates/aube-linker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 23 additions & 2 deletions crates/aube-linker/src/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
remove_entry_checked(&aube_entry)?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
if aube_entry.exists() {
// Already in place from a previous run —
// count as cached. `install.rs`
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
remove_entry_checked(&aube_entry)?;
}
if aube_entry.exists() {
local_stats.packages_cached += 1;
return Ok(local_stats);
Expand Down Expand Up @@ -1780,6 +1788,19 @@ impl Linker {
}
}

/// Remove a stale `.aube/<dep_path>` 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
Expand Down
56 changes: 53 additions & 3 deletions crates/aube-linker/src/materialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,44 @@ fn place_materialized_entry(src: &Path, dst: &Path) -> io::Result<MaterializePla
}
}

/// Swap a freshly staged entry over an existing one (`install --force`).
/// The old entry is renamed aside first, so the shared path is never
/// half-written; if the final rename fails the old entry is put back.
fn replace_materialized_entry(
src: &Path,
dst: &Path,
aside: &Path,
) -> io::Result<MaterializePlacement> {
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() {
// 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()
))),
}
}
Comment thread
jdx marked this conversation as resolved.
Resolved
}
}

fn is_transient_rename_error(err: &io::Error) -> bool {
matches!(
err.kind(),
Expand Down Expand Up @@ -271,7 +309,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)?;
}
Expand Down Expand Up @@ -312,7 +353,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");
}
Expand Down Expand Up @@ -448,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;
Expand Down
53 changes: 53 additions & 0 deletions crates/aube-linker/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 9 additions & 5 deletions crates/aube/src/commands/install/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// 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.
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/aube/src/commands/install/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -79,6 +80,7 @@ pub(super) fn run_link_phase(input: LinkPhaseInput<'_>) -> miette::Result<LinkPh
dep_selection_filtered,
workspace_filter_empty,
ignore_scripts,
force,
prog_ref,
phase_timings,
} = input;
Expand Down Expand Up @@ -148,6 +150,7 @@ pub(super) fn run_link_phase(input: LinkPhaseInput<'_>) -> miette::Result<LinkPh
.with_node_linker(node_linker)
.with_link_concurrency(link_concurrency_setting)
.with_virtual_store_only(virtual_store_only)
.with_force(force)
Comment thread
jdx marked this conversation as resolved.
Resolved
.with_modules_dir_name(modules_dir_name.to_string())
.with_aube_dir_override(aube_dir.to_path_buf());
if planned_gvs && node_linker == aube_linker::NodeLinker::Isolated {
Expand Down
1 change: 1 addition & 0 deletions crates/aube/src/commands/install/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2432,6 +2432,7 @@ async fn run_inner(mut opts: InstallOptions, cwd: std::path::PathBuf) -> 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,
})?;
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 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": [
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 <PATH>`** — Add a global pnpmfile that runs before the local one.

Mirrors pnpm's `--global-pnpmfile <path>`. 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.
Expand Down
Loading
Loading