Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
17 changes: 15 additions & 2 deletions crates/aube/src/commands/install/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down
10 changes: 9 additions & 1 deletion crates/aube/src/commands/install/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>>,
pub ws_config: &'a aube_manifest::workspace::WorkspaceConfig,
pub workspace_catalogs: &'a crate::commands::CatalogMap,
pub settings_ctx: &'a aube_settings::ResolveCtx<'a>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
249 changes: 234 additions & 15 deletions crates/aube/src/commands/install/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pkg> 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<Option<std::collections::BTreeSet<String>>> {
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,
Expand Down Expand Up @@ -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 <pkg>`); `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<String>>,
) -> 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 <member>`
// 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 {
Expand All @@ -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
// `<root>/../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`
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -455,12 +533,153 @@ 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(),
"fallback kind (pnpm) must be used for a member with no lockfile"
);
assert!(!pkg_dir.join("aube-lock.yaml").exists());
}

/// A filtered install (`aube install --filter <pkg>`) 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<String> =
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());
}
}
Loading
Loading