diff --git a/crates/aube/src/commands/install/mod.rs b/crates/aube/src/commands/install/mod.rs index cfaad9ffc..856fb5695 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 3069aa96b..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, @@ -252,38 +288,73 @@ 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 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) /// 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 /// 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 { - if importer_path == "." { - // The root manifest gets no per-project lockfile under - // sharedWorkspaceLockfile=false; it's the workspace anchor, - // not an installable importer. + // 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 + // (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 { @@ -292,7 +363,13 @@ pub(super) fn write_per_project_lockfiles( ); 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. 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` @@ -420,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!( @@ -455,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(), @@ -463,4 +542,144 @@ 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 + /// 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_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 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![ + (".".to_string(), manifest("root")), + ("packages/lib".to_string(), manifest("@test/lib")), + ]; + + 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. + 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()); + } + + /// 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, None) + .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..3f269c2fc 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 @@ -680,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 @@ -723,8 +767,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.