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
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/site/docs-en/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ ferrflow init [OPTIONS]
| ------------------- | ---------------------------------------------- |
| `--format <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`
Expand Down
2 changes: 2 additions & 0 deletions docs/site/docs-fr/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ ferrflow init [OPTIONS]
| ------------------- | -------------------------------------------------------------- |
| `--format <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`
Expand Down
83 changes: 65 additions & 18 deletions src/config/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<R> {
input: R,
Expand Down Expand Up @@ -119,28 +120,30 @@ impl<R: BufRead> Prompter<R> {
&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<Vec<PackageConfig>> {
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<Vec<PackageConfig>> {
Expand All @@ -164,6 +167,45 @@ impl<R: BufRead> Prompter<R> {
}
}

fn package_config(
name: String,
path: String,
versioned_files: Vec<VersionedFile>,
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"];
Expand Down Expand Up @@ -233,9 +275,14 @@ fn init_from(input: impl BufRead, format: Option<ConfigFileFormat>, 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 {
Expand Down
65 changes: 65 additions & 0 deletions src/config/init/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, ".");
}
1 change: 0 additions & 1 deletion src/config/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/config/migrate/changesets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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_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 \
Expand Down
Loading
Loading