From feb234f7f4ad4a7295beae6105cc3f36290975e1 Mon Sep 17 00:00:00 2001 From: BryanFRD Date: Sat, 10 Oct 2026 15:07:15 +0200 Subject: [PATCH 1/2] feat(init): discover monorepo packages from Cargo, Go and Gradle workspaces --- ...10-10-ferrflow-init-workspace-discovery.md | 16 + docs/site/docs-en/reference/cli.md | 2 + docs/site/docs-fr/reference/cli.md | 2 + src/config/init.rs | 83 +++- src/config/init/tests.rs | 65 ++++ src/config/migrate.rs | 1 - src/config/migrate/changesets.rs | 2 +- src/config/migrate/workspace_packages.rs | 308 --------------- src/config/mod.rs | 2 + src/config/workspace_discovery.rs | 129 +++++++ src/config/workspace_discovery/cargo.rs | 92 +++++ src/config/workspace_discovery/go.rs | 65 ++++ src/config/workspace_discovery/gradle.rs | 51 +++ src/config/workspace_discovery/node.rs | 79 ++++ src/config/workspace_discovery/tests.rs | 359 ++++++++++++++++++ 15 files changed, 928 insertions(+), 328 deletions(-) create mode 100644 docs/site/changelog/2026-10-10-ferrflow-init-workspace-discovery.md delete mode 100644 src/config/migrate/workspace_packages.rs create mode 100644 src/config/workspace_discovery.rs create mode 100644 src/config/workspace_discovery/cargo.rs create mode 100644 src/config/workspace_discovery/go.rs create mode 100644 src/config/workspace_discovery/gradle.rs create mode 100644 src/config/workspace_discovery/node.rs create mode 100644 src/config/workspace_discovery/tests.rs diff --git a/docs/site/changelog/2026-10-10-ferrflow-init-workspace-discovery.md b/docs/site/changelog/2026-10-10-ferrflow-init-workspace-discovery.md new file mode 100644 index 00000000..bffefc47 --- /dev/null +++ b/docs/site/changelog/2026-10-10-ferrflow-init-workspace-discovery.md @@ -0,0 +1,16 @@ +--- +title: 'FerrFlow: init lists the packages of your workspace' +summary: 'ferrflow init now reads Cargo, Go and Gradle workspaces as well as npm and pnpm ones, and offers to scaffold one package per member instead of asking you to type them.' +date: 2026-10-10T15:00:00Z +product: ferrflow +type: new +docsLink: https://ferrflow.com/docs/reference/cli +--- + +Setting up a monorepo meant typing every package name, path and versioned file by hand, even though the workspace file already listed them. `ferrflow init` now reads that file and shows what it found before asking anything else. + +It understands `workspaces` in `package.json` and `pnpm-workspace.yaml`, `[workspace] members` and `exclude` in `Cargo.toml`, `use` entries in `go.work`, and `include` calls in `settings.gradle` or `settings.gradle.kts`. Each member becomes a package with its manifest as the versioned file and a changelog beside it. + +A Cargo workspace whose members use `version.workspace = true` is scaffolded as a single package at the root, since that is where the version lives. + +Answer `n` to the prompt to fall back to entering packages by hand, as before. diff --git a/docs/site/docs-en/reference/cli.md b/docs/site/docs-en/reference/cli.md index 75aaf683..5104efce 100644 --- a/docs/site/docs-en/reference/cli.md +++ b/docs/site/docs-en/reference/cli.md @@ -169,6 +169,8 @@ ferrflow init [OPTIONS] | ------------------- | ---------------------------------------------- | | `--format ` | Config file format: `json`, `json5`, or `toml` | +If the repository declares a workspace, `init` lists the packages it found and offers to use them, one package per member with its manifest as the versioned file and a changelog next to it. It reads `workspaces` in `package.json`, `pnpm-workspace.yaml`, `[workspace] members` in `Cargo.toml`, `use` entries in `go.work` and `include` calls in `settings.gradle(.kts)`. Answer `n` to enter the packages by hand. + --- ## `ferrflow migrate` diff --git a/docs/site/docs-fr/reference/cli.md b/docs/site/docs-fr/reference/cli.md index cfa383c0..d88aec6c 100644 --- a/docs/site/docs-fr/reference/cli.md +++ b/docs/site/docs-fr/reference/cli.md @@ -138,6 +138,8 @@ ferrflow init [OPTIONS] | ------------------- | -------------------------------------------------------------- | | `--format ` | Format du fichier de configuration : `json`, `json5` ou `toml` | +Si le repository déclare un workspace, `init` liste les packages trouvés et propose de les utiliser, un package par membre avec son manifeste comme fichier versionné et un changelog à côté. Il lit `workspaces` dans `package.json`, `pnpm-workspace.yaml`, `[workspace] members` dans `Cargo.toml`, les entrées `use` de `go.work` et les appels `include` de `settings.gradle(.kts)`. Répondre `n` permet de saisir les packages à la main. + --- ## `ferrflow migrate` diff --git a/src/config/init.rs b/src/config/init.rs index 9c6722ab..21b10a39 100644 --- a/src/config/init.rs +++ b/src/config/init.rs @@ -9,6 +9,7 @@ use super::format::{CONFIG_FORMATS, ConfigFileFormat, format_handler}; use super::loader_js::{JS_CONFIG_FILENAME, TS_CONFIG_FILENAME}; use super::package::{FileFormat, PackageConfig, VersionedFile}; use super::workspace::WorkspaceConfig; +use super::workspace_discovery::{self, DiscoveredPackage}; struct Prompter { input: R, @@ -119,28 +120,30 @@ impl Prompter { &under(&path, "CHANGELOG.md"), ); - PackageConfig { + package_config( name, path, - versioned_files: vec![VersionedFile { + vec![VersionedFile { path: version_file_path, format: parse_file_format(&format_str), selector: None, }], - changelog: Some(changelog), - shared_paths: Vec::new(), - depends_on: vec![], - versioning: None, - tag_template: None, - version_template: None, - hooks: None, - floating_tags: None, - latest_tag: None, - build_metadata: None, - publishers: vec![], - update_lockfiles: None, - version_source: None, + changelog, + ) + } + + fn discovered(&mut self, found: &[DiscoveredPackage]) -> Option> { + if found.is_empty() { + return None; + } + println!("Found {} workspace package(s):", found.len()); + for package in found { + println!(" {} ({})", package.path, package.name); } + if !self.ask_bool("Use these packages?", true) { + return None; + } + Some(found.iter().map(discovered_package).collect()) } fn packages(&mut self, monorepo: bool) -> Result> { @@ -164,6 +167,45 @@ impl Prompter { } } +fn package_config( + name: String, + path: String, + versioned_files: Vec, + changelog: String, +) -> PackageConfig { + PackageConfig { + name, + path, + versioned_files, + changelog: Some(changelog), + shared_paths: Vec::new(), + depends_on: vec![], + versioning: None, + tag_template: None, + version_template: None, + hooks: None, + floating_tags: None, + latest_tag: None, + build_metadata: None, + publishers: vec![], + update_lockfiles: None, + version_source: None, + } +} + +fn discovered_package(found: &DiscoveredPackage) -> PackageConfig { + package_config( + found.name.clone(), + found.path.clone(), + vec![VersionedFile { + path: under(&found.path, found.manifest), + format: found.format.clone(), + selector: None, + }], + under(&found.path, "CHANGELOG.md"), + ) +} + const ALLOWED_FORMATS: &[&str] = &["toml", "json", "xml", "gradle", "gomod", "txt"]; const ALLOWED_CONFIG_FORMATS: &[&str] = &["json", "json5", "toml", "dotfile"]; @@ -233,9 +275,14 @@ fn init_from(input: impl BufRead, format: Option, manifest: bo let fmt = format.unwrap_or_else(|| prompter.ask_config_format()); let handler = format_handler(fmt); - let monorepo = prompter.ask_bool("Is this a monorepo?", false); - - let packages = prompter.packages(monorepo)?; + let found = workspace_discovery::discover(&std::env::current_dir()?); + let packages = match prompter.discovered(&found) { + Some(packages) => packages, + None => { + let monorepo = prompter.ask_bool("Is this a monorepo?", false); + prompter.packages(monorepo)? + } + }; let mut workspace = WorkspaceConfig::default(); if manifest { diff --git a/src/config/init/tests.rs b/src/config/init/tests.rs index d646f3fd..5f7fbd27 100644 --- a/src/config/init/tests.rs +++ b/src/config/init/tests.rs @@ -265,3 +265,68 @@ fn an_existing_config_of_any_kind_is_never_overwritten() { ); } } + +#[test] +fn a_cargo_workspace_is_scaffolded_one_package_per_member() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/*\"]\n", + ) + .unwrap(); + for name in ["api", "cli"] { + let member = dir.path().join("crates").join(name); + std::fs::create_dir_all(&member).unwrap(); + std::fs::write( + member.join("Cargo.toml"), + format!("[package]\nname = \"acme-{name}\"\nversion = \"0.1.0\"\n"), + ) + .unwrap(); + } + + run_init(dir.path(), "", Some(ConfigFileFormat::Json), false).unwrap(); + + let config = load(dir.path(), "ferrflow.json"); + let packages: Vec<_> = config + .packages + .iter() + .map(|p| (p.name.as_str(), p.path.as_str())) + .collect(); + assert_eq!( + packages, + [("acme-api", "crates/api"), ("acme-cli", "crates/cli")] + ); + assert_eq!( + config.packages[0].versioned_files[0].path, + "crates/api/Cargo.toml" + ); + assert_eq!( + config.packages[0].changelog.as_deref(), + Some("crates/api/CHANGELOG.md") + ); +} + +#[test] +fn declining_the_discovered_packages_falls_back_to_the_manual_flow() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("package.json"), + r#"{"workspaces": ["packages/*"]}"#, + ) + .unwrap(); + let member = dir.path().join("packages/a"); + std::fs::create_dir_all(&member).unwrap(); + std::fs::write(member.join("package.json"), r#"{"name": "a"}"#).unwrap(); + + run_init( + dir.path(), + &lines(&["n"]), + Some(ConfigFileFormat::Json), + false, + ) + .unwrap(); + + let config = load(dir.path(), "ferrflow.json"); + assert_eq!(config.packages.len(), 1); + assert_eq!(config.packages[0].path, "."); +} diff --git a/src/config/migrate.rs b/src/config/migrate.rs index 0e270731..e2c39d3e 100644 --- a/src/config/migrate.rs +++ b/src/config/migrate.rs @@ -16,7 +16,6 @@ use super::workspace::WorkspaceConfig; mod changesets; mod release_please; mod standard_version; -mod workspace_packages; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Source { diff --git a/src/config/migrate/changesets.rs b/src/config/migrate/changesets.rs index 6de355d4..004ebbfd 100644 --- a/src/config/migrate/changesets.rs +++ b/src/config/migrate/changesets.rs @@ -93,7 +93,7 @@ pub(super) fn build(raw: &str, root: &Path) -> Result<(Config, MigrationReport)> )); } - let discovered = super::workspace_packages::discover(root); + let discovered = crate::config::workspace_discovery::discover(root); let packages = if discovered.is_empty() { report.warnings.push( "no JS workspace found (`workspaces` in package.json or pnpm-workspace.yaml), so a \ diff --git a/src/config/migrate/workspace_packages.rs b/src/config/migrate/workspace_packages.rs deleted file mode 100644 index b45eb538..00000000 --- a/src/config/migrate/workspace_packages.rs +++ /dev/null @@ -1,308 +0,0 @@ -use std::path::Path; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct DiscoveredPackage { - pub name: String, - pub path: String, -} - -const SKIP_DIRS: &[&str] = &[ - "node_modules", - ".git", - "target", - "dist", - "build", - ".next", - ".turbo", - "coverage", - "vendor", -]; - -const MAX_DEPTH: usize = 6; - -pub(super) fn discover(root: &Path) -> Vec { - let globs = workspace_globs(root); - if globs.is_empty() { - return Vec::new(); - } - - let mut found = Vec::new(); - collect(root, root, 0, &globs, &mut found); - found.sort_by(|a, b| a.path.cmp(&b.path)); - found.dedup_by(|a, b| a.path == b.path); - - let excluded: Vec<&str> = globs.iter().filter_map(|g| g.strip_prefix('!')).collect(); - found.retain(|p| !excluded.iter().any(|g| glob_match::glob_match(g, &p.path))); - found -} - -fn workspace_globs(root: &Path) -> Vec { - let mut globs = globs_from_package_json(root); - globs.extend(globs_from_pnpm_workspace(root)); - globs.sort(); - globs.dedup(); - globs -} - -fn globs_from_package_json(root: &Path) -> Vec { - let Ok(raw) = std::fs::read_to_string(root.join("package.json")) else { - return Vec::new(); - }; - let Ok(value) = serde_json::from_str::(&raw) else { - return Vec::new(); - }; - match value.get("workspaces") { - Some(serde_json::Value::Array(items)) => string_list(items), - Some(serde_json::Value::Object(obj)) => obj - .get("packages") - .and_then(|p| p.as_array()) - .map(|items| string_list(items)) - .unwrap_or_default(), - _ => Vec::new(), - } -} - -fn globs_from_pnpm_workspace(root: &Path) -> Vec { - let raw = ["pnpm-workspace.yaml", "pnpm-workspace.yml"] - .iter() - .find_map(|name| std::fs::read_to_string(root.join(name)).ok()); - let Some(raw) = raw else { - return Vec::new(); - }; - let Ok(value) = serde_norway::from_str::(&raw) else { - return Vec::new(); - }; - value - .get("packages") - .and_then(|p| p.as_array()) - .map(|items| string_list(items)) - .unwrap_or_default() -} - -fn string_list(items: &[serde_json::Value]) -> Vec { - items - .iter() - .filter_map(|v| v.as_str()) - .map(normalise_glob) - .collect() -} - -fn normalise_glob(raw: &str) -> String { - raw.trim_start_matches("./") - .trim_end_matches('/') - .to_string() -} - -fn collect( - root: &Path, - dir: &Path, - depth: usize, - globs: &[String], - found: &mut Vec, -) { - if depth > MAX_DEPTH { - return; - } - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let Some(name) = path.file_name().and_then(|n| n.to_str()) else { - continue; - }; - if name.starts_with('.') || SKIP_DIRS.contains(&name) { - continue; - } - if let Some(rel) = relative_slash_path(root, &path) - && globs.iter().any(|g| matches_glob(g, &rel)) - && let Some(pkg) = read_manifest(&path, &rel) - { - found.push(pkg); - } - collect(root, &path, depth + 1, globs, found); - } -} - -fn matches_glob(glob: &str, rel: &str) -> bool { - !glob.starts_with('!') && glob_match::glob_match(glob, rel) -} - -fn relative_slash_path(root: &Path, path: &Path) -> Option { - let rel = path.strip_prefix(root).ok()?; - Some( - rel.components() - .filter_map(|c| c.as_os_str().to_str()) - .collect::>() - .join("/"), - ) -} - -fn read_manifest(dir: &Path, rel: &str) -> Option { - let manifest = dir.join("package.json"); - let raw = std::fs::read_to_string(&manifest).ok()?; - let value = serde_json::from_str::(&raw).ok()?; - let name = value - .get("name") - .and_then(|n| n.as_str()) - .map(str::to_string) - .or_else(|| dir.file_name().and_then(|n| n.to_str()).map(str::to_string))?; - Some(DiscoveredPackage { - name, - path: rel.to_string(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn write(root: &Path, rel: &str, contents: &str) { - let path = root.join(rel); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, contents).unwrap(); - } - - fn pkg(name: &str) -> String { - format!("{{\"name\": \"{name}\", \"version\": \"1.0.0\"}}") - } - - #[test] - fn npm_workspaces_array_is_expanded() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(root, "package.json", r#"{"workspaces": ["packages/*"]}"#); - write(root, "packages/a/package.json", &pkg("@acme/a")); - write(root, "packages/b/package.json", &pkg("@acme/b")); - - let found = discover(root); - assert_eq!( - found, - vec![ - DiscoveredPackage { - name: "@acme/a".into(), - path: "packages/a".into() - }, - DiscoveredPackage { - name: "@acme/b".into(), - path: "packages/b".into() - }, - ] - ); - } - - #[test] - fn yarn_berry_object_form_is_expanded() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write( - root, - "package.json", - r#"{"workspaces": {"packages": ["libs/*"]}}"#, - ); - write(root, "libs/one/package.json", &pkg("one")); - - let found = discover(root); - assert_eq!(found.len(), 1); - assert_eq!(found[0].path, "libs/one"); - } - - #[test] - fn pnpm_workspace_globs_are_read() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(root, "package.json", r#"{"name": "root"}"#); - write(root, "pnpm-workspace.yaml", "packages:\n - 'apps/*'\n"); - write(root, "apps/web/package.json", &pkg("@acme/web")); - - let found = discover(root); - assert_eq!(found.len(), 1); - assert_eq!(found[0].name, "@acme/web"); - assert_eq!(found[0].path, "apps/web"); - } - - #[test] - fn node_modules_is_never_walked() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(root, "package.json", r#"{"workspaces": ["packages/*"]}"#); - write(root, "packages/a/package.json", &pkg("@acme/a")); - write( - root, - "packages/a/node_modules/left-pad/package.json", - &pkg("left-pad"), - ); - write(root, "node_modules/react/package.json", &pkg("react")); - - let found = discover(root); - assert_eq!(found.len(), 1); - assert_eq!(found[0].name, "@acme/a"); - } - - #[test] - fn nested_globs_are_matched() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(root, "package.json", r#"{"workspaces": ["apps/*/pkg/*"]}"#); - write(root, "apps/web/pkg/ui/package.json", &pkg("ui")); - - let found = discover(root); - assert_eq!(found.len(), 1); - assert_eq!(found[0].path, "apps/web/pkg/ui"); - } - - #[test] - fn a_directory_without_a_manifest_is_skipped() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(root, "package.json", r#"{"workspaces": ["packages/*"]}"#); - std::fs::create_dir_all(root.join("packages/empty")).unwrap(); - write(root, "packages/real/package.json", &pkg("real")); - - let found = discover(root); - assert_eq!(found.len(), 1); - assert_eq!(found[0].name, "real"); - } - - #[test] - fn a_nameless_manifest_falls_back_to_its_directory() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(root, "package.json", r#"{"workspaces": ["packages/*"]}"#); - write(root, "packages/tools/package.json", r#"{"private": true}"#); - - let found = discover(root); - assert_eq!(found.len(), 1); - assert_eq!(found[0].name, "tools"); - } - - #[test] - fn no_workspace_declaration_discovers_nothing() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(root, "package.json", r#"{"name": "solo"}"#); - write(root, "packages/a/package.json", &pkg("@acme/a")); - - assert!(discover(root).is_empty()); - } - - #[test] - fn negated_globs_exclude_their_matches() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write( - root, - "package.json", - r#"{"workspaces": ["packages/*", "!packages/internal"]}"#, - ); - write(root, "packages/keep/package.json", &pkg("keep")); - write(root, "packages/internal/package.json", &pkg("internal")); - - let found = discover(root); - assert_eq!(found.len(), 1); - assert_eq!(found[0].name, "keep"); - } -} diff --git a/src/config/mod.rs b/src/config/mod.rs index 249a469d..2c63d455 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -18,6 +18,8 @@ mod package; mod types; mod unknown_keys; mod workspace; +#[cfg(feature = "cli")] +mod workspace_discovery; #[allow(unused_imports)] pub use commit_formats::{CATCH_ALL, CommitFormats, PatternSet}; diff --git a/src/config/workspace_discovery.rs b/src/config/workspace_discovery.rs new file mode 100644 index 00000000..bdba08c5 --- /dev/null +++ b/src/config/workspace_discovery.rs @@ -0,0 +1,129 @@ +use std::path::Path; + +use super::package::FileFormat; + +mod cargo; +mod go; +mod gradle; +mod node; + +#[cfg(test)] +mod tests; + +#[derive(Debug, Clone, PartialEq)] +pub(super) struct DiscoveredPackage { + pub name: String, + pub path: String, + pub manifest: &'static str, + pub format: FileFormat, +} + +type ReadManifest = fn(root: &Path, dir: &Path, rel: &str) -> Option; + +struct Source { + globs: Vec, + read: ReadManifest, +} + +const SKIP_DIRS: &[&str] = &[ + "node_modules", + ".git", + "target", + "dist", + "build", + ".next", + ".turbo", + "coverage", + "vendor", +]; + +const MAX_DEPTH: usize = 6; + +pub(super) fn discover(root: &Path) -> Vec { + let mut found: Vec = [ + node::source(root), + cargo::source(root), + go::source(root), + gradle::source(root), + ] + .iter() + .filter(|source| !source.globs.is_empty()) + .flat_map(|source| source.collect(root)) + .collect(); + found.sort_by(|a, b| a.path.cmp(&b.path)); + found.dedup_by(|a, b| a.path == b.path); + found +} + +impl Source { + fn collect(&self, root: &Path) -> Vec { + let mut found = Vec::new(); + if self.globs.iter().any(|g| g == ".") { + found.extend((self.read)(root, root, ".")); + } + self.walk(root, root, 0, &mut found); + + let excluded: Vec<&str> = self + .globs + .iter() + .filter_map(|g| g.strip_prefix('!')) + .collect(); + found.retain(|p| !excluded.iter().any(|g| glob_match::glob_match(g, &p.path))); + found + } + + fn walk(&self, root: &Path, dir: &Path, depth: usize, found: &mut Vec) { + if depth > MAX_DEPTH { + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if name.starts_with('.') || SKIP_DIRS.contains(&name) { + continue; + } + if let Some(rel) = relative_slash_path(root, &path) + && self.globs.iter().any(|g| matches_glob(g, &rel)) + && let Some(pkg) = (self.read)(root, &path, &rel) + { + found.push(pkg); + } + self.walk(root, &path, depth + 1, found); + } + } +} + +fn normalise_glob(raw: &str) -> String { + let trimmed = raw.trim_start_matches("./").trim_end_matches('/'); + if trimmed.is_empty() { + ".".to_string() + } else { + trimmed.to_string() + } +} + +fn matches_glob(glob: &str, rel: &str) -> bool { + !glob.starts_with('!') && glob_match::glob_match(glob, rel) +} + +fn relative_slash_path(root: &Path, path: &Path) -> Option { + let rel = path.strip_prefix(root).ok()?; + Some( + rel.components() + .filter_map(|c| c.as_os_str().to_str()) + .collect::>() + .join("/"), + ) +} + +fn directory_name(dir: &Path) -> Option { + dir.file_name().and_then(|n| n.to_str()).map(str::to_string) +} diff --git a/src/config/workspace_discovery/cargo.rs b/src/config/workspace_discovery/cargo.rs new file mode 100644 index 00000000..e03bfe0a --- /dev/null +++ b/src/config/workspace_discovery/cargo.rs @@ -0,0 +1,92 @@ +use std::path::Path; + +use toml_edit::{DocumentMut, Item}; + +use super::{DiscoveredPackage, Source, directory_name, normalise_glob}; +use crate::config::package::FileFormat; + +const MANIFEST: &str = "Cargo.toml"; + +pub(super) fn source(root: &Path) -> Source { + Source { + globs: globs(root), + read: read_member, + } +} + +fn load(dir: &Path) -> Option { + std::fs::read_to_string(dir.join(MANIFEST)) + .ok()? + .parse() + .ok() +} + +fn globs(root: &Path) -> Vec { + let Some(doc) = load(root) else { + return Vec::new(); + }; + let Some(workspace) = doc.get("workspace") else { + return Vec::new(); + }; + let listed = |key: &str, prefix: &str| -> Vec { + workspace + .get(key) + .and_then(Item::as_array) + .into_iter() + .flatten() + .filter_map(|v| v.as_str()) + .map(|raw| format!("{prefix}{}", normalise_glob(raw))) + .collect() + }; + let mut globs = listed("members", ""); + globs.extend(listed("exclude", "!")); + globs +} + +fn read_member(root: &Path, dir: &Path, rel: &str) -> Option { + let doc = load(dir)?; + let package = doc.get("package")?; + if rel != "." && inherits_version(package) { + return workspace_root_package(root); + } + let name = package + .get("name") + .and_then(Item::as_str) + .map(str::to_string) + .or_else(|| directory_name(dir))?; + Some(DiscoveredPackage { + name, + path: rel.to_string(), + manifest: MANIFEST, + format: FileFormat::Toml, + }) +} + +fn inherits_version(package: &Item) -> bool { + package + .get("version") + .and_then(Item::as_table_like) + .and_then(|table| table.get("workspace")) + .and_then(Item::as_bool) + .unwrap_or(false) +} + +fn workspace_root_package(root: &Path) -> Option { + let doc = load(root)?; + doc.get("workspace")? + .get("package")? + .get("version")? + .as_str()?; + let name = doc + .get("package") + .and_then(|p| p.get("name")) + .and_then(Item::as_str) + .map(str::to_string) + .or_else(|| directory_name(root))?; + Some(DiscoveredPackage { + name, + path: ".".to_string(), + manifest: MANIFEST, + format: FileFormat::Toml, + }) +} diff --git a/src/config/workspace_discovery/go.rs b/src/config/workspace_discovery/go.rs new file mode 100644 index 00000000..45e97753 --- /dev/null +++ b/src/config/workspace_discovery/go.rs @@ -0,0 +1,65 @@ +use std::path::Path; + +use regex::Regex; + +use super::{DiscoveredPackage, Source, directory_name, normalise_glob}; +use crate::config::package::FileFormat; + +const MANIFEST: &str = "go.mod"; + +pub(super) fn source(root: &Path) -> Source { + Source { + globs: uses(root), + read: read_module, + } +} + +fn uses(root: &Path) -> Vec { + let Ok(raw) = std::fs::read_to_string(root.join("go.work")) else { + return Vec::new(); + }; + let mut in_block = false; + let mut paths = Vec::new(); + for line in raw.lines() { + let line = line.split("//").next().unwrap_or("").trim(); + if in_block { + if line.starts_with(')') { + in_block = false; + } else if !line.is_empty() { + paths.push(line); + } + continue; + } + let Some(rest) = line + .strip_prefix("use") + .filter(|r| r.starts_with(char::is_whitespace) || r.starts_with('(')) + else { + continue; + }; + let rest = rest.trim(); + match rest.strip_prefix('(') { + Some(_) => in_block = true, + None if !rest.is_empty() => paths.push(rest), + None => {} + } + } + paths + .into_iter() + .map(|p| normalise_glob(p.trim_matches('"'))) + .collect() +} + +fn read_module(_root: &Path, dir: &Path, rel: &str) -> Option { + let raw = std::fs::read_to_string(dir.join(MANIFEST)).ok()?; + let module = Regex::new(r"(?m)^module\s+(\S+)").ok()?; + let name = module + .captures(&raw) + .map(|c| c[1].trim_matches('"').to_string()) + .or_else(|| directory_name(dir))?; + Some(DiscoveredPackage { + name, + path: rel.to_string(), + manifest: MANIFEST, + format: FileFormat::GoMod, + }) +} diff --git a/src/config/workspace_discovery/gradle.rs b/src/config/workspace_discovery/gradle.rs new file mode 100644 index 00000000..f833b2ee --- /dev/null +++ b/src/config/workspace_discovery/gradle.rs @@ -0,0 +1,51 @@ +use std::path::Path; + +use regex::Regex; + +use super::{DiscoveredPackage, Source}; +use crate::config::package::FileFormat; + +const SETTINGS: [&str; 2] = ["settings.gradle.kts", "settings.gradle"]; +const BUILD_FILES: [&str; 2] = ["build.gradle.kts", "build.gradle"]; + +pub(super) fn source(root: &Path) -> Source { + Source { + globs: includes(root), + read: read_project, + } +} + +fn includes(root: &Path) -> Vec { + let Some(raw) = SETTINGS + .iter() + .find_map(|name| std::fs::read_to_string(root.join(name)).ok()) + else { + return Vec::new(); + }; + let (Ok(statement), Ok(quoted)) = ( + Regex::new(r"(?m)^\s*include(?:\s*\(|\s+)([^\n]*)"), + Regex::new(r#"["']([^"']+)["']"#), + ) else { + return Vec::new(); + }; + statement + .captures_iter(&raw) + .flat_map(|c| { + quoted + .captures_iter(&c[1]) + .map(|q| q[1].trim_matches(':').replace(':', "/")) + .collect::>() + }) + .filter(|path| !path.is_empty()) + .collect() +} + +fn read_project(_root: &Path, dir: &Path, rel: &str) -> Option { + let manifest = BUILD_FILES.iter().find(|name| dir.join(name).is_file())?; + Some(DiscoveredPackage { + name: rel.rsplit('/').next()?.to_string(), + path: rel.to_string(), + manifest, + format: FileFormat::Gradle, + }) +} diff --git a/src/config/workspace_discovery/node.rs b/src/config/workspace_discovery/node.rs new file mode 100644 index 00000000..3f5c5c79 --- /dev/null +++ b/src/config/workspace_discovery/node.rs @@ -0,0 +1,79 @@ +use std::path::Path; + +use super::{DiscoveredPackage, Source, directory_name, normalise_glob}; +use crate::config::package::FileFormat; + +const MANIFEST: &str = "package.json"; + +pub(super) fn source(root: &Path) -> Source { + let mut globs = globs_from_package_json(root); + globs.extend(globs_from_pnpm_workspace(root)); + globs.sort(); + globs.dedup(); + Source { + globs, + read: read_manifest, + } +} + +fn globs_from_package_json(root: &Path) -> Vec { + let Ok(raw) = std::fs::read_to_string(root.join(MANIFEST)) else { + return Vec::new(); + }; + let Ok(value) = serde_json::from_str::(&raw) else { + return Vec::new(); + }; + match value.get("workspaces") { + Some(serde_json::Value::Array(items)) => string_list(items), + Some(serde_json::Value::Object(obj)) => obj + .get("packages") + .and_then(|p| p.as_array()) + .map(|items| string_list(items)) + .unwrap_or_default(), + _ => Vec::new(), + } +} + +fn globs_from_pnpm_workspace(root: &Path) -> Vec { + let raw = ["pnpm-workspace.yaml", "pnpm-workspace.yml"] + .iter() + .find_map(|name| std::fs::read_to_string(root.join(name)).ok()); + let Some(raw) = raw else { + return Vec::new(); + }; + let Ok(value) = serde_norway::from_str::(&raw) else { + return Vec::new(); + }; + value + .get("packages") + .and_then(|p| p.as_array()) + .map(|items| string_list(items)) + .unwrap_or_default() +} + +fn string_list(items: &[serde_json::Value]) -> Vec { + items + .iter() + .filter_map(|v| v.as_str()) + .map(|raw| match raw.strip_prefix('!') { + Some(negated) => format!("!{}", normalise_glob(negated)), + None => normalise_glob(raw), + }) + .collect() +} + +fn read_manifest(_root: &Path, dir: &Path, rel: &str) -> Option { + let raw = std::fs::read_to_string(dir.join(MANIFEST)).ok()?; + let value = serde_json::from_str::(&raw).ok()?; + let name = value + .get("name") + .and_then(|n| n.as_str()) + .map(str::to_string) + .or_else(|| directory_name(dir))?; + Some(DiscoveredPackage { + name, + path: rel.to_string(), + manifest: MANIFEST, + format: FileFormat::Json, + }) +} diff --git a/src/config/workspace_discovery/tests.rs b/src/config/workspace_discovery/tests.rs new file mode 100644 index 00000000..2883a32d --- /dev/null +++ b/src/config/workspace_discovery/tests.rs @@ -0,0 +1,359 @@ +use super::*; + +fn write(root: &Path, rel: &str, contents: &str) { + let path = root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, contents).unwrap(); +} + +fn pkg(name: &str) -> String { + format!("{{\"name\": \"{name}\", \"version\": \"1.0.0\"}}") +} + +fn cargo_package(name: &str) -> String { + format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n") +} + +fn names_and_paths(found: &[DiscoveredPackage]) -> Vec<(&str, &str)> { + found + .iter() + .map(|p| (p.name.as_str(), p.path.as_str())) + .collect() +} + +#[test] +fn npm_workspaces_array_is_expanded() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(root, "package.json", r#"{"workspaces": ["packages/*"]}"#); + write(root, "packages/a/package.json", &pkg("@acme/a")); + write(root, "packages/b/package.json", &pkg("@acme/b")); + + let found = discover(root); + assert_eq!( + found, + vec![ + DiscoveredPackage { + name: "@acme/a".into(), + path: "packages/a".into(), + manifest: "package.json", + format: FileFormat::Json, + }, + DiscoveredPackage { + name: "@acme/b".into(), + path: "packages/b".into(), + manifest: "package.json", + format: FileFormat::Json, + }, + ] + ); +} + +#[test] +fn yarn_berry_object_form_is_expanded() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write( + root, + "package.json", + r#"{"workspaces": {"packages": ["libs/*"]}}"#, + ); + write(root, "libs/one/package.json", &pkg("one")); + + assert_eq!(names_and_paths(&discover(root)), [("one", "libs/one")]); +} + +#[test] +fn pnpm_workspace_globs_are_read() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(root, "package.json", r#"{"name": "root"}"#); + write(root, "pnpm-workspace.yaml", "packages:\n - 'apps/*'\n"); + write(root, "apps/web/package.json", &pkg("@acme/web")); + + assert_eq!( + names_and_paths(&discover(root)), + [("@acme/web", "apps/web")] + ); +} + +#[test] +fn node_modules_is_never_walked() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(root, "package.json", r#"{"workspaces": ["packages/*"]}"#); + write(root, "packages/a/package.json", &pkg("@acme/a")); + write( + root, + "packages/a/node_modules/left-pad/package.json", + &pkg("left-pad"), + ); + write(root, "node_modules/react/package.json", &pkg("react")); + + assert_eq!( + names_and_paths(&discover(root)), + [("@acme/a", "packages/a")] + ); +} + +#[test] +fn nested_globs_are_matched() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(root, "package.json", r#"{"workspaces": ["apps/*/pkg/*"]}"#); + write(root, "apps/web/pkg/ui/package.json", &pkg("ui")); + + assert_eq!( + names_and_paths(&discover(root)), + [("ui", "apps/web/pkg/ui")] + ); +} + +#[test] +fn a_directory_without_a_manifest_is_skipped() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(root, "package.json", r#"{"workspaces": ["packages/*"]}"#); + std::fs::create_dir_all(root.join("packages/empty")).unwrap(); + write(root, "packages/real/package.json", &pkg("real")); + + assert_eq!( + names_and_paths(&discover(root)), + [("real", "packages/real")] + ); +} + +#[test] +fn a_nameless_manifest_falls_back_to_its_directory() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(root, "package.json", r#"{"workspaces": ["packages/*"]}"#); + write(root, "packages/tools/package.json", r#"{"private": true}"#); + + assert_eq!( + names_and_paths(&discover(root)), + [("tools", "packages/tools")] + ); +} + +#[test] +fn no_workspace_declaration_discovers_nothing() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(root, "package.json", r#"{"name": "solo"}"#); + write(root, "packages/a/package.json", &pkg("@acme/a")); + + assert!(discover(root).is_empty()); +} + +#[test] +fn negated_globs_exclude_their_matches() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write( + root, + "package.json", + r#"{"workspaces": ["packages/*", "!packages/internal"]}"#, + ); + write(root, "packages/keep/package.json", &pkg("keep")); + write(root, "packages/internal/package.json", &pkg("internal")); + + assert_eq!( + names_and_paths(&discover(root)), + [("keep", "packages/keep")] + ); +} + +#[test] +fn cargo_workspace_members_are_expanded_with_their_package_names() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write( + root, + "Cargo.toml", + "[workspace]\nmembers = [\"crates/*\"]\n", + ); + write(root, "crates/api/Cargo.toml", &cargo_package("acme-api")); + write(root, "crates/cli/Cargo.toml", &cargo_package("acme-cli")); + + let found = discover(root); + assert_eq!( + names_and_paths(&found), + [("acme-api", "crates/api"), ("acme-cli", "crates/cli")] + ); + assert!( + found + .iter() + .all(|p| p.manifest == "Cargo.toml" && p.format == FileFormat::Toml) + ); +} + +#[test] +fn cargo_workspace_exclude_drops_members() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write( + root, + "Cargo.toml", + "[workspace]\nmembers = [\"crates/*\"]\nexclude = [\"crates/scratch\"]\n", + ); + write(root, "crates/keep/Cargo.toml", &cargo_package("keep")); + write(root, "crates/scratch/Cargo.toml", &cargo_package("scratch")); + + assert_eq!(names_and_paths(&discover(root)), [("keep", "crates/keep")]); +} + +#[test] +fn cargo_members_inheriting_the_workspace_version_collapse_to_the_root() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write( + root, + "Cargo.toml", + "[workspace]\nmembers = [\"crates/*\"]\n[workspace.package]\nversion = \"2.0.0\"\n", + ); + write( + root, + "crates/a/Cargo.toml", + "[package]\nname = \"a\"\nversion.workspace = true\n", + ); + write( + root, + "crates/b/Cargo.toml", + "[package]\nname = \"b\"\nversion.workspace = true\n", + ); + + let found = discover(root); + assert_eq!(found.len(), 1); + assert_eq!(found[0].path, "."); + assert_eq!(found[0].manifest, "Cargo.toml"); +} + +#[test] +fn cargo_root_package_listed_as_a_member_is_kept() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write( + root, + "Cargo.toml", + "[package]\nname = \"tool\"\nversion = \"1.0.0\"\n[workspace]\nmembers = [\".\", \"helper\"]\n", + ); + write(root, "helper/Cargo.toml", &cargo_package("helper")); + + assert_eq!( + names_and_paths(&discover(root)), + [("tool", "."), ("helper", "helper")] + ); +} + +#[test] +fn a_cargo_manifest_without_a_workspace_table_discovers_nothing() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(root, "Cargo.toml", &cargo_package("solo")); + write(root, "crates/a/Cargo.toml", &cargo_package("a")); + + assert!(discover(root).is_empty()); +} + +#[test] +fn go_work_use_lines_and_blocks_are_expanded() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write( + root, + "go.work", + "go 1.22\n\nuse ./api\n\nuse (\n\t./worker // background jobs\n\t./libs/shared\n)\n", + ); + write( + root, + "api/go.mod", + "module example.com/acme/api\n\ngo 1.22\n", + ); + write(root, "worker/go.mod", "module example.com/acme/worker\n"); + write( + root, + "libs/shared/go.mod", + "module example.com/acme/shared\n", + ); + + let found = discover(root); + assert_eq!( + names_and_paths(&found), + [ + ("example.com/acme/api", "api"), + ("example.com/acme/shared", "libs/shared"), + ("example.com/acme/worker", "worker"), + ] + ); + assert!( + found + .iter() + .all(|p| p.manifest == "go.mod" && p.format == FileFormat::GoMod) + ); +} + +#[test] +fn gradle_includes_map_colons_to_directories() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write( + root, + "settings.gradle.kts", + "rootProject.name = \"acme\"\ninclude(\":app\", \":libs:core\")\nincludeBuild(\"../other\")\n", + ); + write(root, "app/build.gradle.kts", ""); + write(root, "libs/core/build.gradle", ""); + + let found = discover(root); + assert_eq!( + names_and_paths(&found), + [("app", "app"), ("core", "libs/core")] + ); + assert_eq!(found[0].manifest, "build.gradle.kts"); + assert_eq!(found[1].manifest, "build.gradle"); + assert!(found.iter().all(|p| p.format == FileFormat::Gradle)); +} + +#[test] +fn gradle_groovy_includes_without_parentheses_are_read() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(root, "settings.gradle", "include 'server', ':client'\n"); + write(root, "server/build.gradle", ""); + write(root, "client/build.gradle", ""); + + assert_eq!( + names_and_paths(&discover(root)), + [("client", "client"), ("server", "server")] + ); +} + +#[test] +fn a_gradle_include_without_a_build_file_is_skipped() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(root, "settings.gradle", "include 'ghost', 'real'\n"); + std::fs::create_dir_all(root.join("ghost")).unwrap(); + write(root, "real/build.gradle", ""); + + assert_eq!(names_and_paths(&discover(root)), [("real", "real")]); +} + +#[test] +fn a_path_declared_by_two_ecosystems_is_listed_once() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(root, "package.json", r#"{"workspaces": ["crates/*"]}"#); + write( + root, + "Cargo.toml", + "[workspace]\nmembers = [\"crates/*\"]\n", + ); + write(root, "crates/bridge/package.json", &pkg("bridge-js")); + write(root, "crates/bridge/Cargo.toml", &cargo_package("bridge")); + + let found = discover(root); + assert_eq!(found.len(), 1); + assert_eq!(found[0].name, "bridge-js"); +} From f198aa15aec2315079aa5c40fd33298a4a68af63 Mon Sep 17 00:00:00 2001 From: BryanFRD Date: Sat, 10 Oct 2026 15:34:49 +0200 Subject: [PATCH 2/2] fix(init): keep the changesets migration on npm workspaces --- src/config/migrate/changesets.rs | 2 +- src/config/workspace_discovery.rs | 12 ++++++++ src/config/workspace_discovery/gradle.rs | 4 +-- src/config/workspace_discovery/tests.rs | 35 ++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/config/migrate/changesets.rs b/src/config/migrate/changesets.rs index 004ebbfd..ea11c391 100644 --- a/src/config/migrate/changesets.rs +++ b/src/config/migrate/changesets.rs @@ -93,7 +93,7 @@ pub(super) fn build(raw: &str, root: &Path) -> Result<(Config, MigrationReport)> )); } - let discovered = crate::config::workspace_discovery::discover(root); + let discovered = crate::config::workspace_discovery::discover_node(root); let packages = if discovered.is_empty() { report.warnings.push( "no JS workspace found (`workspaces` in package.json or pnpm-workspace.yaml), so a \ diff --git a/src/config/workspace_discovery.rs b/src/config/workspace_discovery.rs index bdba08c5..0cab2957 100644 --- a/src/config/workspace_discovery.rs +++ b/src/config/workspace_discovery.rs @@ -55,6 +55,18 @@ pub(super) fn discover(root: &Path) -> Vec { found } +pub(super) fn discover_node(root: &Path) -> Vec { + let source = node::source(root); + let mut found = if source.globs.is_empty() { + Vec::new() + } else { + source.collect(root) + }; + found.sort_by(|a, b| a.path.cmp(&b.path)); + found.dedup_by(|a, b| a.path == b.path); + found +} + impl Source { fn collect(&self, root: &Path) -> Vec { let mut found = Vec::new(); diff --git a/src/config/workspace_discovery/gradle.rs b/src/config/workspace_discovery/gradle.rs index f833b2ee..83cc3456 100644 --- a/src/config/workspace_discovery/gradle.rs +++ b/src/config/workspace_discovery/gradle.rs @@ -23,7 +23,7 @@ fn includes(root: &Path) -> Vec { return Vec::new(); }; let (Ok(statement), Ok(quoted)) = ( - Regex::new(r"(?m)^\s*include(?:\s*\(|\s+)([^\n]*)"), + Regex::new(r"(?m)^\s*include\s*\(([^)]*)\)|^\s*include\s+([^\n(][^\n]*)"), Regex::new(r#"["']([^"']+)["']"#), ) else { return Vec::new(); @@ -32,7 +32,7 @@ fn includes(root: &Path) -> Vec { .captures_iter(&raw) .flat_map(|c| { quoted - .captures_iter(&c[1]) + .captures_iter(c.get(1).or_else(|| c.get(2)).map_or("", |m| m.as_str())) .map(|q| q[1].trim_matches(':').replace(':', "/")) .collect::>() }) diff --git a/src/config/workspace_discovery/tests.rs b/src/config/workspace_discovery/tests.rs index 2883a32d..54bfdbbb 100644 --- a/src/config/workspace_discovery/tests.rs +++ b/src/config/workspace_discovery/tests.rs @@ -357,3 +357,38 @@ fn a_path_declared_by_two_ecosystems_is_listed_once() { assert_eq!(found.len(), 1); assert_eq!(found[0].name, "bridge-js"); } + +#[test] +fn gradle_includes_spanning_several_lines_are_read() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write( + root, + "settings.gradle.kts", + "include(\n \":a\",\n \":b:c\"\n)\n", + ); + write(root, "a/build.gradle.kts", ""); + write(root, "b/c/build.gradle.kts", ""); + + assert_eq!(names_and_paths(&discover(root)), [("a", "a"), ("c", "b/c")]); +} + +#[test] +fn discover_node_ignores_other_ecosystems() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write( + root, + "Cargo.toml", + "[workspace]\nmembers = [\"crates/*\"]\n", + ); + write(root, "crates/api/Cargo.toml", &cargo_package("api")); + write(root, "package.json", r#"{"workspaces": ["packages/*"]}"#); + write(root, "packages/web/package.json", &pkg("web")); + + assert_eq!( + names_and_paths(&discover_node(root)), + [("web", "packages/web")] + ); + assert_eq!(discover(root).len(), 2); +}