From 265cfba217e4f638c95952bc0017215023cf0d46 Mon Sep 17 00:00:00 2001 From: Ruben Nogueira <40404708+rubnogueira@users.noreply.github.com> Date: Sat, 13 Jun 2026 19:53:12 +0100 Subject: [PATCH 1/3] fix(install): write the root workspace lockfile under sharedWorkspaceLockfile=false --- crates/aube/src/commands/install/workspace.rs | 83 +++++++++++++++---- 1 file changed, 68 insertions(+), 15 deletions(-) diff --git a/crates/aube/src/commands/install/workspace.rs b/crates/aube/src/commands/install/workspace.rs index 3069aa96b..588683520 100644 --- a/crates/aube/src/commands/install/workspace.rs +++ b/crates/aube/src/commands/install/workspace.rs @@ -252,21 +252,28 @@ pub(super) fn order_lifecycle_manifests( .collect() } -/// Write one lockfile per non-root workspace importer when -/// `sharedWorkspaceLockfile=false` is set. Each lockfile contains -/// only the importer's own deps (remapped to `.`) plus the transitive -/// closure reachable from them. The workspace-root lockfile is not -/// written under this layout. +/// Write one lockfile per workspace importer when +/// `sharedWorkspaceLockfile=false` is set, the workspace root +/// included. Each lockfile contains only that importer's own deps +/// (remapped to `.`) plus the transitive closure reachable from them. /// -/// Each member's existing lockfile format is preserved: a package that +/// pnpm under `sharedWorkspaceLockfile=false` writes a separate +/// lockfile for every project — the root among them when it carries +/// its own dependencies — so the root's lockfile (importer `.`) is +/// written here too, at the workspace root. A dependency-free root +/// with no graph importer entry is skipped via `subset_to_importer` +/// below. +/// +/// Each project's existing lockfile format is preserved: a package that /// already ships a `pnpm-lock.yaml` (or any other supported lockfile) /// keeps getting that file rewritten in place instead of gaining a /// surprise `aube-lock.yaml` next to it. This mirrors the single-project /// write path ([`aube_lockfile::write_lockfile_preserving_existing`]) and /// pnpm's own `sharedWorkspaceLockfile=false` behavior, where each /// member keeps its own `pnpm-lock.yaml`. `fallback_kind` is only used -/// for members that have no lockfile yet (the workspace default, derived -/// from the root's lockfile format or aube's own when none exists). +/// for projects (root or member) that have no lockfile yet (the +/// workspace default, derived from the root's lockfile format or +/// aube's own when none exists). /// /// Importers without a corresponding manifest entry are skipped — the /// resolver should never produce one, but defensive skipping keeps a @@ -280,19 +287,21 @@ pub(super) fn write_per_project_lockfiles( ) -> miette::Result<()> { use miette::IntoDiagnostic; for (importer_path, pkg_manifest) in workspace_manifests { - if importer_path == "." { - // The root manifest gets no per-project lockfile under - // sharedWorkspaceLockfile=false; it's the workspace anchor, - // not an installable importer. - continue; - } let Some(subset) = graph.subset_to_importer(importer_path, |_| true) else { tracing::debug!( "sharedWorkspaceLockfile=false: skipping {importer_path} (no graph importer entry)" ); continue; }; - let pkg_dir = workspace_root.join(importer_path); + // The root importer (`.`) writes its lockfile at the workspace + // root itself; members nest under their relative path. pnpm + // writes the root project's own lockfile under + // sharedWorkspaceLockfile=false, so `.` must not be skipped. + let pkg_dir = if importer_path == "." { + workspace_root.to_path_buf() + } else { + workspace_root.join(importer_path) + }; // Honor the member's own on-disk lockfile format; only members // without one fall back to the workspace default. Without this, // a `pnpm-lock.yaml`-based member gets a redundant `aube-lock.yaml` @@ -463,4 +472,48 @@ mod per_project_lockfile_tests { ); assert!(!pkg_dir.join("aube-lock.yaml").exists()); } + + /// Under `sharedWorkspaceLockfile=false`, pnpm writes the root + /// project's OWN lockfile (importer `.`) at the workspace root, + /// alongside each member's. Regression for the root `pnpm-lock.yaml` + /// disappearing once aube switched to per-project lockfiles: the + /// root must keep getting its own lockfile, with its format + /// preserved (no surprise `aube-lock.yaml` beside it). + #[test] + fn writes_root_importer_lockfile_alongside_members() { + let root = tempfile::tempdir().unwrap(); + let lib_dir = root.path().join("packages/lib"); + std::fs::create_dir_all(&lib_dir).unwrap(); + + // Root already ships a pnpm-lock.yaml; its format must be kept. + std::fs::write(root.path().join("pnpm-lock.yaml"), "lockfileVersion: '9.0'\n").unwrap(); + + let graph = graph_with_importers(&[".", "packages/lib"]); + let manifests = vec![ + (".".to_string(), manifest("root")), + ("packages/lib".to_string(), manifest("@test/lib")), + ]; + + write_per_project_lockfiles(root.path(), &graph, &manifests, LockfileKind::Aube).unwrap(); + + // Root: its own pnpm-lock.yaml is (re)written at the workspace + // root, format preserved — no aube-lock.yaml beside it. + let root_lock = root.path().join("pnpm-lock.yaml"); + assert!( + root_lock.exists(), + "root pnpm-lock.yaml must be written under sharedWorkspaceLockfile=false" + ); + assert!( + !root.path().join("aube-lock.yaml").exists(), + "no aube-lock.yaml beside the root's preserved pnpm-lock.yaml" + ); + let root_contents = std::fs::read_to_string(&root_lock).unwrap(); + assert!( + root_contents.contains("importers:"), + "root lockfile must hold its own importer, got:\n{root_contents}" + ); + + // Member still gets its own lockfile (fallback kind, no prior lockfile). + assert!(lib_dir.join("aube-lock.yaml").exists()); + } } From 6bb7882d6277fa83b36f57f7a300803f71a63b2d Mon Sep 17 00:00:00 2001 From: Ruben Nogueira <40404708+rubnogueira@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:42:53 +0100 Subject: [PATCH 2/3] fix(install): only write the root lockfile when the workspace root is a project --- crates/aube/src/commands/install/workspace.rs | 86 +++++++++++++++---- test/pnpm_savecatalog.bats | 7 +- test/pnpm_update.bats | 4 +- test/workspace.bats | 23 +++-- 4 files changed, 94 insertions(+), 26 deletions(-) diff --git a/crates/aube/src/commands/install/workspace.rs b/crates/aube/src/commands/install/workspace.rs index 588683520..883584d17 100644 --- a/crates/aube/src/commands/install/workspace.rs +++ b/crates/aube/src/commands/install/workspace.rs @@ -258,11 +258,13 @@ pub(super) fn order_lifecycle_manifests( /// (remapped to `.`) plus the transitive closure reachable from them. /// /// pnpm under `sharedWorkspaceLockfile=false` writes a separate -/// lockfile for every project — the root among them when it carries -/// its own dependencies — so the root's lockfile (importer `.`) is -/// written here too, at the workspace root. A dependency-free root -/// with no graph importer entry is skipped via `subset_to_importer` -/// below. +/// lockfile for every project, the workspace root included when the +/// root is itself a project — i.e. it ships a `package.json`. So the +/// root's lockfile (importer `.`) is written here too, at the workspace +/// root, even when the root declares no dependencies (its lockfile then +/// just records an empty `.` importer). A config-only root that carries +/// only a `pnpm-workspace.yaml` and no `package.json` is not a project, +/// so its synthetic `.` importer is skipped. /// /// Each project's existing lockfile format is preserved: a package that /// already ships a `pnpm-lock.yaml` (or any other supported lockfile) @@ -287,6 +289,19 @@ pub(super) fn write_per_project_lockfiles( ) -> miette::Result<()> { use miette::IntoDiagnostic; for (importer_path, pkg_manifest) in workspace_manifests { + // The workspace root (`.`) gets its own lockfile only when it is + // itself a project — i.e. it ships a package.json. pnpm under + // sharedWorkspaceLockfile=false writes the root project's lockfile + // (even when it declares no dependencies), but writes nothing for a + // config-only root that carries just a pnpm-workspace.yaml and no + // package.json. Mirror that: skip the synthetic `.` importer when + // the root isn't a project. + if importer_path == "." && !workspace_root.join("package.json").exists() { + tracing::debug!( + "sharedWorkspaceLockfile=false: skipping root importer (workspace root has no package.json)" + ); + continue; + } let Some(subset) = graph.subset_to_importer(importer_path, |_| true) else { tracing::debug!( "sharedWorkspaceLockfile=false: skipping {importer_path} (no graph importer entry)" @@ -294,9 +309,7 @@ pub(super) fn write_per_project_lockfiles( continue; }; // The root importer (`.`) writes its lockfile at the workspace - // root itself; members nest under their relative path. pnpm - // writes the root project's own lockfile under - // sharedWorkspaceLockfile=false, so `.` must not be skipped. + // root itself; members nest under their relative path. let pkg_dir = if importer_path == "." { workspace_root.to_path_buf() } else { @@ -474,19 +487,30 @@ mod per_project_lockfile_tests { } /// Under `sharedWorkspaceLockfile=false`, pnpm writes the root - /// project's OWN lockfile (importer `.`) at the workspace root, - /// alongside each member's. Regression for the root `pnpm-lock.yaml` - /// disappearing once aube switched to per-project lockfiles: the - /// root must keep getting its own lockfile, with its format - /// preserved (no surprise `aube-lock.yaml` beside it). + /// project's OWN lockfile (importer `.`) at the workspace root when the + /// root is itself a project (it ships a package.json), alongside each + /// member's. Regression for the root lockfile disappearing once aube + /// switched to per-project lockfiles: a root project must keep getting + /// its own lockfile, with its format preserved (no surprise + /// `aube-lock.yaml` beside an existing `pnpm-lock.yaml`). #[test] - fn writes_root_importer_lockfile_alongside_members() { + fn writes_root_importer_lockfile_when_root_is_a_project() { let root = tempfile::tempdir().unwrap(); let lib_dir = root.path().join("packages/lib"); std::fs::create_dir_all(&lib_dir).unwrap(); - // Root already ships a pnpm-lock.yaml; its format must be kept. - std::fs::write(root.path().join("pnpm-lock.yaml"), "lockfileVersion: '9.0'\n").unwrap(); + // Root is a project: it ships a package.json and already uses pnpm, + // so its lockfile format must be preserved. + std::fs::write( + root.path().join("package.json"), + r#"{ "name": "root", "version": "1.0.0" }"#, + ) + .unwrap(); + std::fs::write( + root.path().join("pnpm-lock.yaml"), + "lockfileVersion: '9.0'\n", + ) + .unwrap(); let graph = graph_with_importers(&[".", "packages/lib"]); let manifests = vec![ @@ -516,4 +540,34 @@ mod per_project_lockfile_tests { // Member still gets its own lockfile (fallback kind, no prior lockfile). assert!(lib_dir.join("aube-lock.yaml").exists()); } + + /// A config-only workspace root (a `pnpm-workspace.yaml` but no + /// `package.json`) is not a project, so pnpm writes no root lockfile — + /// only the members get one. aube must match: skip the synthetic `.` + /// importer when the root has no package.json, even though the graph + /// and manifests still carry a `.` entry for it. + #[test] + fn skips_root_importer_when_root_is_not_a_project() { + let root = tempfile::tempdir().unwrap(); + let lib_dir = root.path().join("packages/lib"); + std::fs::create_dir_all(&lib_dir).unwrap(); + // Deliberately no package.json at the workspace root. + + let graph = graph_with_importers(&[".", "packages/lib"]); + let manifests = vec![ + (".".to_string(), manifest("root")), + ("packages/lib".to_string(), manifest("@test/lib")), + ]; + + write_per_project_lockfiles(root.path(), &graph, &manifests, LockfileKind::Aube).unwrap(); + + // No root lockfile of any kind — the root isn't a project. + assert!( + !root.path().join("aube-lock.yaml").exists(), + "config-only root (no package.json) must not get a lockfile" + ); + assert!(!root.path().join("pnpm-lock.yaml").exists()); + // Members still get theirs. + assert!(lib_dir.join("aube-lock.yaml").exists()); + } } diff --git a/test/pnpm_savecatalog.bats b/test/pnpm_savecatalog.bats index 7b4d6ad19..9d831bafc 100644 --- a/test/pnpm_savecatalog.bats +++ b/test/pnpm_savecatalog.bats @@ -126,11 +126,12 @@ YAML run aube install assert_success - # Each non-root project gets its own lockfile; the workspace root - # does not. + # Each project gets its own lockfile, the workspace root included + # (pnpm parity: the root is itself a project, so under + # sharedWorkspaceLockfile=false it writes its own root lockfile). assert_file_exists project-0/aube-lock.yaml assert_file_exists project-1/aube-lock.yaml - assert [ ! -e aube-lock.yaml ] + assert_file_exists aube-lock.yaml run aube --filter=project-1 add --save-catalog is-even@^1.0.0 assert_success diff --git a/test/pnpm_update.bats b/test/pnpm_update.bats index d0941504b..6d64740fb 100644 --- a/test/pnpm_update.bats +++ b/test/pnpm_update.bats @@ -318,7 +318,9 @@ EOF run grep '"@pnpm.e2e/foo": "\^100.1.0"' project/package.json assert_success - # No shared root lockfile. + # No root lockfile: this workspace root is config-only (a + # pnpm-workspace.yaml with no package.json), so it isn't a project + # and pnpm writes no lockfile for it — only the member does. assert_file_not_exists aube-lock.yaml } diff --git a/test/workspace.bats b/test/workspace.bats index 3e81c1f16..e84bb48d8 100644 --- a/test/workspace.bats +++ b/test/workspace.bats @@ -636,8 +636,9 @@ _setup_shared_direct_dep_workspace() { @test "aube install: sharedWorkspaceLockfile=false writes per-project lockfiles" { # Each workspace member gets its own lockfile next to its - # package.json; no root lockfile is written. Resolver still runs - # once over the whole workspace so workspace deps work. + # package.json, and the workspace root — itself a project — gets its + # own root lockfile too (pnpm parity). Resolver still runs once over + # the whole workspace so workspace deps work. cat >package.json <<-'JSON' { "name": "swl-root", "version": "0.0.0", "private": true } JSON @@ -661,8 +662,16 @@ _setup_shared_direct_dep_workspace() { # Each importer has its own lockfile… assert_file_exists packages/lib/aube-lock.yaml assert_file_exists packages/app/aube-lock.yaml - # …and no root lockfile under this layout. - assert [ ! -e aube-lock.yaml ] + # …and the root project gets its own lockfile too. pnpm parity: the + # workspace root is itself a project (it ships a package.json), so + # under sharedWorkspaceLockfile=false it writes a root lockfile + # carrying only its own importer. This root declares no deps, so that + # lockfile holds no member packages. + assert_file_exists aube-lock.yaml + run grep -qF "is-odd" aube-lock.yaml + assert_failure + run grep -qF "is-even" aube-lock.yaml + assert_failure # Each per-project lockfile carries only its own importer (remapped # to `.`). Pull the `importers:` block out and grep within it so the @@ -723,8 +732,10 @@ _setup_shared_direct_dep_workspace() { assert_file_exists packages/app/aube-lock.yaml assert [ ! -e packages/app/pnpm-lock.yaml ] - # No root lockfile under this layout. - assert [ ! -e aube-lock.yaml ] + # The root is a project (it ships a package.json) but had no prior + # lockfile, so it gets the workspace-default aube-lock.yaml carrying + # only its own (empty) importer — no pnpm-lock.yaml beside it. + assert_file_exists aube-lock.yaml assert [ ! -e pnpm-lock.yaml ] # node_modules still get linked correctly per package. From ba9f76950ef0ed3fbc880a0acfa424fef9c5b5d9 Mon Sep 17 00:00:00 2001 From: Ruben Nogueira <40404708+rubnogueira@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:36:46 +0100 Subject: [PATCH 3/3] fix(install): scope --filter lockfile writes under sharedWorkspaceLockfile=false A scoped `aube install --filter ` rewrote the root and every unrelated member's per-project lockfile, diverging from pnpm which touches only the selected project(s). Derive the write selection from the same selector the link pass uses, and reuse `importer_project_dir` for pkg_dir so parent-relative importer keys stay normalized. Co-authored-by: Cursor --- crates/aube/src/commands/install/mod.rs | 17 ++- crates/aube/src/commands/install/resolve.rs | 10 +- crates/aube/src/commands/install/workspace.rs | 132 ++++++++++++++++-- test/workspace.bats | 35 +++++ 4 files changed, 181 insertions(+), 13 deletions(-) diff --git a/crates/aube/src/commands/install/mod.rs b/crates/aube/src/commands/install/mod.rs index 41fe22992..20bd16b6e 100644 --- a/crates/aube/src/commands/install/mod.rs +++ b/crates/aube/src/commands/install/mod.rs @@ -71,7 +71,7 @@ use startup::{ use summary::print_already_up_to_date; use workspace::{ discover_workspace_plan, filter_graph_to_importers, filter_graph_to_workspace_selection, - importer_project_dir, write_per_project_lockfiles, + importer_project_dir, per_project_write_selection, write_per_project_lockfiles, }; #[derive(Default)] @@ -306,6 +306,12 @@ pub async fn run(opts: InstallOptions) -> miette::Result<()> { let ws_package_versions = workspace_plan.ws_package_versions; let ws_dirs = workspace_plan.ws_dirs; let lifecycle_manifests = workspace_plan.lifecycle_manifests; + // Importer keys whose per-project lockfiles a filtered install may + // (re)write. `None` for an unfiltered install (write every importer). + // Computed once and shared by the `--lockfile-only` short-circuit and + // the streaming-install write so both paths stay scoped identically. + let per_project_write_selection = + per_project_write_selection(&cwd, &workspace_packages, &opts.workspace_filter)?; let (build_policy, policy_warnings) = if let Some(override_policy) = opts.build_policy_override.as_deref() { (override_policy.clone(), Vec::new()) @@ -428,6 +434,7 @@ pub async fn run(opts: InstallOptions) -> miette::Result<()> { lockfile_importer_key: &lockfile_importer_key, manifest: &manifest, manifests: &manifests, + per_project_write_selection: per_project_write_selection.as_ref(), ws_config: &ws_config_shared, workspace_catalogs: &workspace_catalogs, settings_ctx: &settings_ctx, @@ -1518,7 +1525,13 @@ pub async fn run(opts: InstallOptions) -> miette::Result<()> { .unwrap_or_else(|| written_path.display().to_string()) ); } else { - write_per_project_lockfiles(&cwd, &graph, &manifests, write_kind)?; + write_per_project_lockfiles( + &cwd, + &graph, + &manifests, + write_kind, + per_project_write_selection.as_ref(), + )?; } } else { tracing::debug!("lockfile=false: skipping lockfile write"); diff --git a/crates/aube/src/commands/install/resolve.rs b/crates/aube/src/commands/install/resolve.rs index 52119736d..2c5105759 100644 --- a/crates/aube/src/commands/install/resolve.rs +++ b/crates/aube/src/commands/install/resolve.rs @@ -39,6 +39,7 @@ pub(super) struct LockfileOnlyInput<'a> { pub lockfile_importer_key: &'a str, pub manifest: &'a aube_manifest::PackageJson, pub manifests: &'a [(String, aube_manifest::PackageJson)], + pub per_project_write_selection: Option<&'a std::collections::BTreeSet>, pub ws_config: &'a aube_manifest::workspace::WorkspaceConfig, pub workspace_catalogs: &'a crate::commands::CatalogMap, pub settings_ctx: &'a aube_settings::ResolveCtx<'a>, @@ -69,6 +70,7 @@ pub(super) async fn run_lockfile_only(input: LockfileOnlyInput<'_>) -> miette::R lockfile_importer_key, manifest, manifests, + per_project_write_selection, ws_config, workspace_catalogs, settings_ctx, @@ -285,7 +287,13 @@ pub(super) async fn run_lockfile_only(input: LockfileOnlyInput<'_>) -> miette::R .unwrap_or_else(|| lo_written.display().to_string()) ); } else { - write_per_project_lockfiles(cwd, &graph, manifests, lo_write_kind)?; + write_per_project_lockfiles( + cwd, + &graph, + manifests, + lo_write_kind, + per_project_write_selection, + )?; } // Prune unused catalog entries *after* the lockfile hits disk — // same ordering as the main install path below, so a diff --git a/crates/aube/src/commands/install/workspace.rs b/crates/aube/src/commands/install/workspace.rs index 883584d17..50b96ec30 100644 --- a/crates/aube/src/commands/install/workspace.rs +++ b/crates/aube/src/commands/install/workspace.rs @@ -150,6 +150,42 @@ pub(super) fn filter_graph_to_workspace_selection( Ok(filtered.filter_deps(|_| true)) } +/// The set of importer keys whose per-project lockfiles a filtered +/// install should (re)write, or `None` for an unfiltered install (write +/// every importer). +/// +/// pnpm parity: `pnpm --filter install` under +/// `sharedWorkspaceLockfile=false` only writes the selected project(s)' +/// lockfiles — it does not create or rewrite the workspace root's +/// lockfile (nor unrelated members') as a side effect of a scoped +/// command. The link pass still keeps the root importer for its own +/// dependencies (see [`filter_graph_to_workspace_selection`]), but the +/// lockfile write must stay scoped to exactly what the selector matched, +/// so the root's lockfile is only written when the root itself is +/// selected (`select_workspace_packages` adds it only on a matching +/// filter). Keys use the same `.`/relative form as the importer keys in +/// `manifests` (see [`crate::commands::workspace_importer_path`]). +pub(super) fn per_project_write_selection( + workspace_root: &Path, + workspace_packages: &[PathBuf], + workspace_filter: &aube_workspace::selector::EffectiveFilter, +) -> miette::Result>> { + if workspace_filter.is_empty() { + return Ok(None); + } + let selected = aube_workspace::selector::select_workspace_packages( + workspace_root, + workspace_packages, + workspace_filter, + ) + .map_err(|e| miette!("invalid --filter selector: {e}"))?; + let mut keys = std::collections::BTreeSet::new(); + for pkg in &selected { + keys.insert(workspace_importer_path(workspace_root, &pkg.dir)?); + } + Ok(Some(keys)) +} + pub(super) fn importer_project_dir( workspace_root: &std::path::Path, importer_path: &str, @@ -281,14 +317,33 @@ pub(super) fn order_lifecycle_manifests( /// resolver should never produce one, but defensive skipping keeps a /// stale graph entry from triggering a write into a directory that /// doesn't exist on disk. +/// +/// `write_selection` scopes the write to a subset of importer keys for +/// filtered installs (`aube install --filter `); `None` writes every +/// importer. See [`per_project_write_selection`] for how the selection is +/// derived and why a filtered command must not touch the root lockfile. pub(super) fn write_per_project_lockfiles( workspace_root: &std::path::Path, graph: &aube_lockfile::LockfileGraph, workspace_manifests: &[(String, aube_manifest::PackageJson)], fallback_kind: aube_lockfile::LockfileKind, + write_selection: Option<&std::collections::BTreeSet>, ) -> miette::Result<()> { use miette::IntoDiagnostic; for (importer_path, pkg_manifest) in workspace_manifests { + // Filtered install: only (re)write the selected importers' + // lockfiles. Without this an `aube install --filter ` + // would rewrite the root and every unrelated member lockfile as + // a side effect, diverging from pnpm (which touches only the + // selected project's lockfile). + if let Some(selection) = write_selection + && !selection.contains(importer_path) + { + tracing::debug!( + "sharedWorkspaceLockfile=false: skipping {importer_path} (not in --filter selection)" + ); + continue; + } // The workspace root (`.`) gets its own lockfile only when it is // itself a project — i.e. it ships a package.json. pnpm under // sharedWorkspaceLockfile=false writes the root project's lockfile @@ -309,12 +364,12 @@ pub(super) fn write_per_project_lockfiles( continue; }; // The root importer (`.`) writes its lockfile at the workspace - // root itself; members nest under their relative path. - let pkg_dir = if importer_path == "." { - workspace_root.to_path_buf() - } else { - workspace_root.join(importer_path) - }; + // root itself; members nest under their relative path. Reuse + // `importer_project_dir` so a parent-relative importer key + // (`../sibling`, produced when `pnpm-workspace.yaml#packages` + // uses `../**`) is lexically normalized rather than left as + // `/../sibling`. + let pkg_dir = importer_project_dir(workspace_root, importer_path); // Honor the member's own on-disk lockfile format; only members // without one fall back to the workspace default. Without this, // a `pnpm-lock.yaml`-based member gets a redundant `aube-lock.yaml` @@ -442,7 +497,8 @@ mod per_project_lockfile_tests { // Fallback kind is the workspace default (aube's own) — what an // install with no root lockfile would pass. - write_per_project_lockfiles(root.path(), &graph, &manifests, LockfileKind::Aube).unwrap(); + write_per_project_lockfiles(root.path(), &graph, &manifests, LockfileKind::Aube, None) + .unwrap(); // `lib`: pnpm lockfile is rewritten in place, no aube-lock.yaml. assert!( @@ -477,7 +533,8 @@ mod per_project_lockfile_tests { ("packages/fresh".to_string(), manifest("@test/fresh")), ]; - write_per_project_lockfiles(root.path(), &graph, &manifests, LockfileKind::Pnpm).unwrap(); + write_per_project_lockfiles(root.path(), &graph, &manifests, LockfileKind::Pnpm, None) + .unwrap(); assert!( pkg_dir.join("pnpm-lock.yaml").exists(), @@ -486,6 +543,59 @@ mod per_project_lockfile_tests { assert!(!pkg_dir.join("aube-lock.yaml").exists()); } + /// A filtered install (`aube install --filter `) passes a + /// `write_selection` so only the selected importers' lockfiles are + /// (re)written. The workspace root and unrelated members must be left + /// untouched, matching pnpm's scoped-command behavior. Regression for + /// the per-project write iterating every importer regardless of filter. + #[test] + fn write_selection_scopes_filtered_install_to_selected_importers() { + let root = tempfile::tempdir().unwrap(); + let lib_dir = root.path().join("packages/lib"); + let app_dir = root.path().join("packages/app"); + std::fs::create_dir_all(&lib_dir).unwrap(); + std::fs::create_dir_all(&app_dir).unwrap(); + + // Root is a project too, so without scoping it would also get a + // lockfile — the selection must keep it out. + std::fs::write( + root.path().join("package.json"), + r#"{ "name": "root", "version": "1.0.0" }"#, + ) + .unwrap(); + + let graph = graph_with_importers(&[".", "packages/lib", "packages/app"]); + let manifests = vec![ + (".".to_string(), manifest("root")), + ("packages/lib".to_string(), manifest("@test/lib")), + ("packages/app".to_string(), manifest("@test/app")), + ]; + + let selection: std::collections::BTreeSet = + std::iter::once("packages/lib".to_string()).collect(); + write_per_project_lockfiles( + root.path(), + &graph, + &manifests, + LockfileKind::Aube, + Some(&selection), + ) + .unwrap(); + + assert!( + lib_dir.join("aube-lock.yaml").exists(), + "the selected member's lockfile must be written" + ); + assert!( + !app_dir.join("aube-lock.yaml").exists(), + "an unselected member's lockfile must not be written" + ); + assert!( + !root.path().join("aube-lock.yaml").exists(), + "the workspace root's lockfile must not be written by a filtered install" + ); + } + /// Under `sharedWorkspaceLockfile=false`, pnpm writes the root /// project's OWN lockfile (importer `.`) at the workspace root when the /// root is itself a project (it ships a package.json), alongside each @@ -518,7 +628,8 @@ mod per_project_lockfile_tests { ("packages/lib".to_string(), manifest("@test/lib")), ]; - write_per_project_lockfiles(root.path(), &graph, &manifests, LockfileKind::Aube).unwrap(); + write_per_project_lockfiles(root.path(), &graph, &manifests, LockfileKind::Aube, None) + .unwrap(); // Root: its own pnpm-lock.yaml is (re)written at the workspace // root, format preserved — no aube-lock.yaml beside it. @@ -559,7 +670,8 @@ mod per_project_lockfile_tests { ("packages/lib".to_string(), manifest("@test/lib")), ]; - write_per_project_lockfiles(root.path(), &graph, &manifests, LockfileKind::Aube).unwrap(); + write_per_project_lockfiles(root.path(), &graph, &manifests, LockfileKind::Aube, None) + .unwrap(); // No root lockfile of any kind — the root isn't a project. assert!( diff --git a/test/workspace.bats b/test/workspace.bats index e84bb48d8..3f269c2fc 100644 --- a/test/workspace.bats +++ b/test/workspace.bats @@ -689,6 +689,41 @@ _setup_shared_direct_dep_workspace() { assert_file_exists packages/app/node_modules/is-even/index.js } +@test "aube install --filter: sharedWorkspaceLockfile=false writes only the selected member's lockfile" { + # pnpm parity: `pnpm --filter install` under + # shared-workspace-lockfile=false rewrites only the selected + # project's lockfile — it must not create or rewrite the workspace + # root's lockfile (nor unrelated members') as a side effect of a + # scoped command. Regression for the per-project write path + # iterating every importer regardless of the active --filter. + cat >package.json <<-'JSON' + { "name": "swl-root", "version": "0.0.0", "private": true, "dependencies": { "is-odd": "^3.0.1" } } + JSON + cat >pnpm-workspace.yaml <<-'YAML' + sharedWorkspaceLockfile: false + packages: + - packages/* + YAML + + mkdir -p packages/lib packages/app + cat >packages/lib/package.json <<-'JSON' + { "name": "@test/lib", "version": "1.0.0", "dependencies": { "is-odd": "^3.0.1" } } + JSON + cat >packages/app/package.json <<-'JSON' + { "name": "@test/app", "version": "1.0.0", "dependencies": { "is-even": "^1.0.0" } } + JSON + + run aube install --filter @test/lib + assert_success + + # Only the selected member's lockfile is written… + assert_file_exists packages/lib/aube-lock.yaml + # …the root (itself a project with its own deps) is left untouched… + assert [ ! -e aube-lock.yaml ] + # …and the unrelated member gets no lockfile either. + assert [ ! -e packages/app/aube-lock.yaml ] +} + @test "aube install: sharedWorkspaceLockfile=false preserves each member's pnpm-lock.yaml" { # pnpm parity: when a workspace member already ships its own # pnpm-lock.yaml (the per-project layout pnpm writes under