Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 3 additions & 3 deletions aube.usage.kdl
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,11 @@ Same selector grammar as `--filter`, but graph walks (`pkg...`, `...pkg`) only f
"""#
arg <PATTERN>
}
flag --ignore-workspace help="Ignore workspace discovery for commands that support workspace fanout." global=#true hide=#true {
flag --ignore-workspace help="Ignore workspace discovery and operate on the current project alone." global=#true {
long_help #"""
Ignore workspace discovery for commands that support workspace fanout.
Ignore workspace discovery and operate on the current project alone.

Parsed for pnpm compatibility.
Skips the search for an enclosing `aube-workspace.yaml`, `pnpm-workspace.yaml`, or `package.json#workspaces` root, so the nearest `package.json` is installed on its own.
"""#
}
flag --include-workspace-root help="Include the workspace root in recursive workspace operations." global=#true hide=#true {
Expand Down
140 changes: 135 additions & 5 deletions crates/aube/src/dirs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ static CWD: RwLock<Option<PathBuf>> = RwLock::new(None);

static DIR_FLAG_GIVEN: AtomicBool = AtomicBool::new(false);

static IGNORE_WORKSPACE: AtomicBool = AtomicBool::new(false);

/// Record that the user passed `--ignore-workspace`, so workspace-root
/// discovery is skipped and the nearest project is used as-is.
pub fn set_ignore_workspace(ignore: bool) {
IGNORE_WORKSPACE.store(ignore, Ordering::Relaxed);
}

/// Record that the user passed `-C` / `--dir` / `--prefix`, so commands
/// that bootstrap a manifest treat the directory as the project itself
/// instead of walking up to an ancestor `package.json`.
Expand Down Expand Up @@ -126,7 +134,20 @@ fn package_json_has_workspaces(path: &Path) -> bool {
///
/// The aube-owned yaml name wins at read time elsewhere, but discovery
/// only needs to know whether any of those markers fixes the root.
///
/// An ancestor root only claims `start` when the project containing
/// `start` is a member of it (the root itself, or matched by `packages:` /
/// `workspaces`; with no patterns only the root is a member). A project
/// that sits below a root without being a member is not part of that
/// workspace, matching pnpm. `--ignore-workspace` skips discovery entirely.
pub fn find_workspace_root(start: &Path) -> Option<PathBuf> {
find_workspace_root_with(start, IGNORE_WORKSPACE.load(Ordering::Relaxed))
}

fn find_workspace_root_with(start: &Path, ignore_workspace: bool) -> Option<PathBuf> {
if ignore_workspace {
return None;
Comment thread
jdx marked this conversation as resolved.
Resolved
}
// Same positive-only caching as `find_project_root`: bootstrap
// commands like `aube add` may create the workspace boundary
// mid-execution, so a cached `None` would shadow it.
Expand Down Expand Up @@ -155,13 +176,23 @@ fn find_workspace_root_uncached(start: &Path) -> Option<PathBuf> {
// outer root; per-member lockfile freshness (tracked in install
// state) is what keeps repeat installs warm under
// `sharedWorkspaceLockfile=false`.
//
// The first package.json at or above `start` (below the candidate
// root) is the project the walk is anchored on; it must be a member
// of the candidate root for the root to claim it.
let mut project: Option<&Path> = None;
for dir in start.ancestors() {
if aube_manifest::workspace::workspace_yaml_existing(dir).is_some() {
return Some(dir.to_path_buf());
}
let pkg = dir.join("package.json");
if pkg.is_file() && package_json_has_workspaces(&pkg) {
return Some(dir.to_path_buf());
let is_root = aube_manifest::workspace::workspace_yaml_existing(dir).is_some()
|| (pkg.is_file() && package_json_has_workspaces(&pkg));
if is_root {
match project {
Some(project) if !is_workspace_member(dir, project) => {}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
_ => return Some(dir.to_path_buf()),
}
}
if project.is_none() && pkg.is_file() {
project = Some(dir);
}
if stop.as_deref() == Some(dir) {
return None;
Expand All @@ -170,6 +201,23 @@ fn find_workspace_root_uncached(start: &Path) -> Option<PathBuf> {
None
}

/// Whether `project` (a directory below `root`) is one of `root`'s
/// workspace packages. The expanded member list is cached per root since
/// glob expansion walks the tree. Discovery errors count as membership so
/// a malformed workspace file still surfaces from the normal install path
/// instead of silently rerouting to the subdirectory.
fn is_workspace_member(root: &Path, project: &Path) -> bool {
static MEMBERS: aube_util::cache::ProcessCache<PathBuf, Option<Vec<PathBuf>>> =
aube_util::cache::ProcessCache::new();
let members = MEMBERS.get_or_compute(root.to_path_buf(), || {
aube_workspace::find_workspace_packages(root).ok()
});
match members.as_ref() {
Some(members) => members.iter().any(|m| m == project),
None => true,
}
}

/// Walk upward from `start` looking for the nearest ancestor that
/// contains `aube-workspace.yaml` or `pnpm-workspace.yaml`. Unlike
/// [`find_workspace_root`], this ignores `package.json#workspaces`
Expand Down Expand Up @@ -305,6 +353,88 @@ mod tests {
assert_eq!(find_workspace_root(&child).unwrap(), dir.path());
}

#[test]
fn find_workspace_root_skips_non_member_below_settings_only_root() {
// A settings-only yaml has no `packages:`, so only the root is a
// member. A project below it is installed on its own.
let dir = tempfile::tempdir().unwrap();
write(&dir.path().join("package.json"), r#"{"name":"root"}"#);
write(
&dir.path().join("aube-workspace.yaml"),
"allowBuilds:\n esbuild: true\n",
);
let sub = dir.path().join("sub/app");
write(&sub.join("package.json"), r#"{"name":"app"}"#);

assert!(find_workspace_root(&sub).is_none());
assert_eq!(find_workspace_root(dir.path()).unwrap(), dir.path());
}

#[test]
fn find_workspace_root_skips_non_member_outside_packages_glob() {
let dir = tempfile::tempdir().unwrap();
write(
&dir.path().join("pnpm-workspace.yaml"),
"packages:\n - 'packages/*'\n",
);
write(&dir.path().join("packages/a/package.json"), "{}");
write(&dir.path().join("tools/b/package.json"), "{}");

assert_eq!(
find_workspace_root(&dir.path().join("packages/a")).unwrap(),
dir.path()
);
assert!(find_workspace_root(&dir.path().join("tools/b")).is_none());
}

#[test]
fn find_workspace_root_skips_member_excluded_by_negated_glob() {
let dir = tempfile::tempdir().unwrap();
write(
&dir.path().join("pnpm-workspace.yaml"),
"packages:\n - 'packages/*'\n - '!packages/skip'\n",
);
write(&dir.path().join("packages/a/package.json"), "{}");
write(&dir.path().join("packages/skip/package.json"), "{}");

assert_eq!(
find_workspace_root(&dir.path().join("packages/a")).unwrap(),
dir.path()
);
assert!(find_workspace_root(&dir.path().join("packages/skip")).is_none());
}

#[test]
fn find_workspace_root_skips_non_member_below_package_json_workspaces() {
let dir = tempfile::tempdir().unwrap();
write(
&dir.path().join("package.json"),
r#"{"name":"root","workspaces":["packages/*"]}"#,
);
write(&dir.path().join("packages/a/package.json"), "{}");
write(&dir.path().join("other/c/package.json"), "{}");

assert!(find_workspace_root(&dir.path().join("other/c")).is_none());
assert_eq!(
find_workspace_root(&dir.path().join("packages/a")).unwrap(),
dir.path()
);
}

#[test]
fn find_workspace_root_ignored_with_ignore_workspace() {
let dir = tempfile::tempdir().unwrap();
write(
&dir.path().join("pnpm-workspace.yaml"),
"packages:\n - 'packages/*'\n",
);
write(&dir.path().join("packages/a/package.json"), "{}");
let child = dir.path().join("packages/a");

assert!(find_workspace_root_with(&child, true).is_none());
assert_eq!(find_workspace_root_with(&child, false).unwrap(), dir.path());
}

#[test]
fn find_workspace_root_finds_package_json_workspaces_array() {
// yarn / npm / bun: no yaml, just a `workspaces` field in the
Expand Down
7 changes: 4 additions & 3 deletions crates/aube/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,10 @@ pub(crate) struct Cli {
#[usage(long, global, value_name = "PATTERN")]
filter_prod: Vec<String>,

/// Ignore workspace discovery for commands that support workspace fanout.
/// Ignore workspace discovery and operate on the current project alone.
///
/// Parsed for pnpm compatibility.
#[usage(long, global, hide)]
/// Skips the search for an enclosing `aube-workspace.yaml`, `pnpm-workspace.yaml`, or `package.json#workspaces` root, so the nearest `package.json` is installed on its own.
#[usage(long, global)]
ignore_workspace: bool,

/// Include the workspace root in recursive workspace operations.
Expand Down Expand Up @@ -1006,6 +1006,7 @@ async fn async_main(cli: Cli, invoked_as_aubr: bool) -> miette::Result<Option<i3
.wrap_err_with(|| format!("failed to change directory to {}", dir.display()))?;
}
crate::dirs::set_dir_flag_given(cli.dir.is_some());
crate::dirs::set_ignore_workspace(cli.ignore_workspace);

let print_top_level_version = should_print_top_level_version(&cli);
if cli.workspace_root && !print_top_level_version {
Expand Down
8 changes: 4 additions & 4 deletions docs/cli/commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -16834,14 +16834,14 @@
{
"name": "ignore-workspace",
"usage": "--ignore-workspace",
"help": "Ignore workspace discovery for commands that support workspace fanout.",
"help_long": "Ignore workspace discovery for commands that support workspace fanout.\n\nParsed for pnpm compatibility.",
"help_first_line": "Ignore workspace discovery for commands that support workspace fanout.",
"help": "Ignore workspace discovery and operate on the current project alone.",
"help_long": "Ignore workspace discovery and operate on the current project alone.\n\nSkips the search for an enclosing `aube-workspace.yaml`, `pnpm-workspace.yaml`, or `package.json#workspaces` root, so the nearest `package.json` is installed on its own.",
"help_first_line": "Ignore workspace discovery and operate on the current project alone.",
"short": [],
"long": [
"ignore-workspace"
],
"hide": true,
"hide": false,
"global": true
},
{
Expand Down
3 changes: 3 additions & 0 deletions docs/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ limited behavior. See [pnpm differences](/pnpm-users) before replacing a workflo
- **`--filter-prod <PATTERN>`** — Production-only variant of `--filter`.

Same selector grammar as `--filter`, but graph walks (`pkg...`, `...pkg`) only follow `dependencies` / `optionalDependencies` / `peerDependencies` edges — `devDependencies` (and packages reachable solely through them) are skipped. Non-graph forms (exact name, glob, path, `[git-ref]`) behave identically to `--filter`. Repeatable; can be combined with `--filter`.
- **`--ignore-workspace`** — Ignore workspace discovery and operate on the current project alone.

Skips the search for an enclosing `aube-workspace.yaml`, `pnpm-workspace.yaml`, or `package.json#workspaces` root, so the nearest `package.json` is installed on its own.
- **`--loglevel <LEVEL>`** — Set the log level. Logs at or above this level are shown.

**Choices:** `trace`, `debug`, `info`, `warn`, `error`, `silent`
Expand Down
35 changes: 35 additions & 0 deletions test/workspace_member_install_walks_up.bats
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,38 @@ _setup_minimal_workspace() {
# both snapshots and we can diff what changed.
assert_equal "$(cat node_modules/.aube-state/state.json)" "$root_state_before"
}

@test "aube install in a non-member subdirectory installs it on its own" {
# Settings-only workspace yaml (no `packages:`): only the root is a
# member, so a nested project is not part of the workspace.
cat >package.json <<-'JSON'
{"name": "ws-root", "version": "0.0.0", "private": true}
JSON
cat >aube-workspace.yaml <<-'YAML'
allowBuilds:
esbuild: true
YAML
mkdir -p sub
cat >sub/package.json <<-'JSON'
{"name": "sub-app", "version": "1.0.0", "dependencies": {"is-odd": "3.0.1"}}
JSON

cd sub
run aube install
assert_success
assert_file_exists aube-lock.yaml
assert_dir_exists node_modules/is-odd
assert_file_not_exists ../aube-lock.yaml
assert_dir_not_exists ../node_modules
}

@test "aube install --ignore-workspace installs a workspace member on its own" {
_setup_minimal_workspace

cd packages/a
run aube install --ignore-workspace
assert_success
assert_file_exists aube-lock.yaml
assert_dir_exists node_modules/is-odd
assert_file_not_exists ../../aube-lock.yaml
}
Loading