mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 09:55:39 -04:00
feat(pacquet): --resume-from, --report-summary (#12093)
* feat(cli): port recursive run with --resume-from and --report-summary Port pnpm's `pnpm run -r` (recursive run) to pacquet, including the `--resume-from` and `--report-summary` flags, which previously existed only in the TypeScript CLI. - `pacquet -r run <script>` now runs the script in every workspace project in topological order, mirroring pnpm's runRecursive: discover projects, build the inter-project dependency graph, sort it into chunks via graph_sequencer (the port of sortProjects), and execute. - `--resume-from <pkg>` drops every chunk before the one containing <pkg>, mirroring getResumedPackageChunks; an unknown package fails with ERR_PNPM_RESUME_FROM_NOT_FOUND. - `--report-summary` writes pnpm-exec-summary.json with the per-package status (queued/running/passed/skipped/failure) and duration, nested under an executionStatus key, mirroring writeRecursiveSummary. - `--no-bail` keeps running after a failure (recursive runs bail by default). Failures surface ERR_PNPM_RECURSIVE_FAIL, or ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL when bailing; a run that matches no script fails with ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT. A new executor helper, execute_shell_with_status, returns the child's exit status so per-package pass/fail can be recorded; execute_shell is unchanged. Not yet ported (noted in the module): --no-sort, --reverse, --workspace-concurrency parallelism, --filter narrowing of the selected set, and the RegExp script selector. The selected set is every workspace project, matching pacquet's currently-unfiltered install. Integration tests port the upstream resume-from and report-summary cases from exec/commands/test/runRecursive.ts. https://claude.ai/code/session_01QUdrDcP9iU3DwxR2TATobQ * test(cli): gate recursive-run tests to unix and add macro trailing commas Fix two CI failures on the recursive-run integration tests: - Dylint (`perfectionist::macro_trailing_comma`): add the trailing comma to the four multi-line `assert!` invocations. - Lint and Test (windows-latest): the shared helpers are used only by the Unix-gated tests, so on Windows they tripped `dead_code` under `-D warnings`. Gate the whole file with `#![cfg(unix)]` (the build scripts run through pacquet's `sh -c` executor anyway), matching the single-package `run` tests. https://claude.ai/code/session_01QUdrDcP9iU3DwxR2TATobQ * test(cli): cover recursive-run bail summary and no-script branches Fill the two coverage holes in the recursive-run handler: - bail + report-summary: the first failing script writes the summary, then aborts with ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL; a package that sorts after the failure stays `queued`. - no-script: a recursive run for a script no package defines fails with ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT, and `--if-present` turns that into a clean no-op. https://claude.ai/code/session_01QUdrDcP9iU3DwxR2TATobQ * test(cli): cover the bail path without --report-summary The bail tests always passed --report-summary, leaving the report-summary-off side of the bail block (recursive.rs:136) uncovered. Add a test for a failing script with bail on and no --report-summary: it still fails with ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL and writes no summary file. Verified with cargo llvm-cov that recursive.rs now has no missing lines. https://claude.ai/code/session_01QUdrDcP9iU3DwxR2TATobQ * fix(cli): run each recursive-run script from its own package root Recursive run spawned every package's script via `sh -c` without setting the working directory, so scripts ran in pacquet's process CWD (the workspace root) instead of their own package root. That breaks scripts relying on relative paths and diverges from pnpm, whose `runLifecycleHook` runs with `pkgRoot` as the working directory. - Give `execute_shell_with_status` a `current_dir` argument (factored through a private `spawn_shell` helper); `execute_shell` keeps its inherited-CWD behavior, so its callers are unchanged. - Pass each package's root as the script's working directory. - Make the marker-based recursive-run tests cwd-sensitive: scripts now write a relative `ran.txt`, and the tests assert it lands under each package root (and not at the workspace root), so a wrong-CWD regression fails the suite. https://claude.ai/code/session_01QUdrDcP9iU3DwxR2TATobQ --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -2129,6 +2129,7 @@ dependencies = [
|
||||
"derive_more",
|
||||
"dunce",
|
||||
"home",
|
||||
"indexmap",
|
||||
"insta",
|
||||
"miette 7.6.0",
|
||||
"pacquet-config",
|
||||
@@ -2146,10 +2147,13 @@ dependencies = [
|
||||
"pacquet-store-dir",
|
||||
"pacquet-tarball",
|
||||
"pacquet-testing-utils",
|
||||
"pacquet-workspace",
|
||||
"pacquet-workspace-projects-graph",
|
||||
"pipe-trait",
|
||||
"pnpr",
|
||||
"pretty_assertions",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde-saphyr",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
|
||||
@@ -15,27 +15,31 @@ name = "pacquet"
|
||||
path = "src/bin/main.rs"
|
||||
|
||||
[dependencies]
|
||||
pacquet-pnpr-client = { workspace = true }
|
||||
pacquet-executor = { workspace = true }
|
||||
pacquet-fs = { workspace = true }
|
||||
pacquet-lockfile = { workspace = true }
|
||||
pacquet-network = { workspace = true }
|
||||
pacquet-config = { workspace = true }
|
||||
pacquet-package-manifest = { workspace = true }
|
||||
pacquet-package-manager = { workspace = true }
|
||||
pacquet-package-is-installable = { workspace = true }
|
||||
pacquet-registry = { workspace = true }
|
||||
pacquet-reporter = { workspace = true }
|
||||
pacquet-tarball = { workspace = true }
|
||||
pacquet-diagnostics = { workspace = true }
|
||||
pacquet-pnpr-client = { workspace = true }
|
||||
pacquet-executor = { workspace = true }
|
||||
pacquet-fs = { workspace = true }
|
||||
pacquet-lockfile = { workspace = true }
|
||||
pacquet-network = { workspace = true }
|
||||
pacquet-config = { workspace = true }
|
||||
pacquet-package-manifest = { workspace = true }
|
||||
pacquet-package-manager = { workspace = true }
|
||||
pacquet-package-is-installable = { workspace = true }
|
||||
pacquet-registry = { workspace = true }
|
||||
pacquet-reporter = { workspace = true }
|
||||
pacquet-tarball = { workspace = true }
|
||||
pacquet-diagnostics = { workspace = true }
|
||||
pacquet-workspace = { workspace = true }
|
||||
pacquet-workspace-projects-graph = { workspace = true }
|
||||
|
||||
clap = { workspace = true }
|
||||
derive_more = { workspace = true }
|
||||
dunce = { workspace = true }
|
||||
home = { workspace = true }
|
||||
indexmap = { workspace = true }
|
||||
miette = { workspace = true }
|
||||
pipe-trait = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
|
||||
@@ -233,7 +233,13 @@ impl CliArgs {
|
||||
.wrap_err(format!("executing command: \"{0}\"", script))?;
|
||||
}
|
||||
}
|
||||
CliCommand::Run(args) => args.run(manifest_path())?,
|
||||
CliCommand::Run(args) => {
|
||||
if recursive {
|
||||
args.run_recursive(config()?, &dir)?;
|
||||
} else {
|
||||
args.run(manifest_path())?;
|
||||
}
|
||||
}
|
||||
CliCommand::Start => {
|
||||
// Runs an arbitrary command specified in the package's start property of its scripts
|
||||
// object. If no start property is specified on the scripts object, it will attempt to
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use clap::Args;
|
||||
use miette::Context;
|
||||
use pacquet_config::Config;
|
||||
use pacquet_executor::execute_shell;
|
||||
use pacquet_package_manifest::PackageManifest;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod recursive;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct RunArgs {
|
||||
@@ -17,12 +20,32 @@ pub struct RunArgs {
|
||||
/// execution chain.
|
||||
#[clap(long)]
|
||||
pub if_present: bool,
|
||||
|
||||
/// Run the script starting from the given package, skipping every
|
||||
/// package that sorts before it. Only meaningful together with the
|
||||
/// global `-r` / `--recursive` flag. Mirrors pnpm's `--resume-from`.
|
||||
#[clap(long = "resume-from")]
|
||||
pub resume_from: Option<String>,
|
||||
|
||||
/// Save the execution result of every package to
|
||||
/// `pnpm-exec-summary.json`. Only meaningful together with the
|
||||
/// global `-r` / `--recursive` flag. Mirrors pnpm's
|
||||
/// `--report-summary`.
|
||||
#[clap(long = "report-summary")]
|
||||
pub report_summary: bool,
|
||||
|
||||
/// Keep running the remaining packages after a script fails instead
|
||||
/// of aborting on the first failure. Only meaningful together with
|
||||
/// the global `-r` / `--recursive` flag. Mirrors pnpm's `--no-bail`
|
||||
/// (recursive runs bail by default).
|
||||
#[clap(long = "no-bail")]
|
||||
pub no_bail: bool,
|
||||
}
|
||||
|
||||
impl RunArgs {
|
||||
/// Execute the subcommand.
|
||||
/// Execute the subcommand for a single project.
|
||||
pub fn run(self, manifest_path: PathBuf) -> miette::Result<()> {
|
||||
let RunArgs { command, args, if_present } = self;
|
||||
let RunArgs { command, args, if_present, .. } = self;
|
||||
|
||||
let manifest = PackageManifest::from_path(manifest_path)
|
||||
.wrap_err("getting the package.json in current directory")?;
|
||||
@@ -38,4 +61,11 @@ impl RunArgs {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute the subcommand for every project in the workspace, in
|
||||
/// topological order. The recursive counterpart of [`Self::run`],
|
||||
/// selected when the global `-r` / `--recursive` flag is set.
|
||||
pub fn run_recursive(&self, config: &Config, dir: &Path) -> miette::Result<()> {
|
||||
recursive::run_recursive(self, config, dir)
|
||||
}
|
||||
}
|
||||
|
||||
325
pacquet/crates/cli/src/cli_args/run/recursive.rs
Normal file
325
pacquet/crates/cli/src/cli_args/run/recursive.rs
Normal file
@@ -0,0 +1,325 @@
|
||||
//! Recursive `pacquet run` — run a package script in every project of
|
||||
//! the workspace, in topological order.
|
||||
//!
|
||||
//! Port of pnpm's
|
||||
//! [`runRecursive`](https://github.com/pnpm/pnpm/blob/8eb1be4988/exec/commands/src/runRecursive.ts)
|
||||
//! together with the `getResumedPackageChunks` / `writeRecursiveSummary`
|
||||
//! helpers from
|
||||
//! [`exec.ts`](https://github.com/pnpm/pnpm/blob/8eb1be4988/exec/commands/src/exec.ts)
|
||||
//! and the `throwOnCommandFail` failure check from
|
||||
//! [`@pnpm/cli.utils`](https://github.com/pnpm/pnpm/blob/8eb1be4988/cli/utils/src/recursiveSummary.ts).
|
||||
//!
|
||||
//! Scope versus upstream: projects are sorted topologically (upstream's
|
||||
//! default) and run sequentially. `--no-sort`, `--reverse`,
|
||||
//! `--workspace-concurrency` parallelism, `--filter` narrowing of the
|
||||
//! selected set, and the RegExp script selector are not ported yet — the
|
||||
//! selected set is every workspace project, matching pacquet's
|
||||
//! currently-unfiltered `install`.
|
||||
|
||||
use super::RunArgs;
|
||||
use derive_more::{Display, Error};
|
||||
use indexmap::IndexMap;
|
||||
use miette::{Context, Diagnostic, IntoDiagnostic};
|
||||
use pacquet_config::Config;
|
||||
use pacquet_executor::execute_shell_with_status;
|
||||
use pacquet_package_manager::graph_sequencer;
|
||||
use pacquet_package_manifest::DependencyGroup;
|
||||
use pacquet_workspace::{
|
||||
FindWorkspaceProjectsOpts, Project, find_workspace_projects, read_workspace_manifest,
|
||||
};
|
||||
use pacquet_workspace_projects_graph::{
|
||||
BaseProject, CreateProjectsGraphOptions, GraphProject, ProjectGraph, create_projects_graph,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::{Path, PathBuf},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// Errors surfaced by a recursive run. Codes mirror pnpm's so log
|
||||
/// consumers and `pnpm.io/errors` references stay valid across the two
|
||||
/// implementations.
|
||||
#[derive(Debug, Display, Error, Diagnostic)]
|
||||
#[non_exhaustive]
|
||||
pub enum RecursiveRunError {
|
||||
#[display("Cannot find package {resume_from}. Could not determine where to resume from.")]
|
||||
#[diagnostic(code(ERR_PNPM_RESUME_FROM_NOT_FOUND))]
|
||||
ResumeFromNotFound {
|
||||
#[error(not(source))]
|
||||
resume_from: String,
|
||||
},
|
||||
|
||||
#[display("None of the packages has a \"{script_name}\" script")]
|
||||
#[diagnostic(code(ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT))]
|
||||
NoScript {
|
||||
#[error(not(source))]
|
||||
script_name: String,
|
||||
},
|
||||
|
||||
#[display("\"pnpm recursive run\" failed in {count} packages")]
|
||||
#[diagnostic(code(ERR_PNPM_RECURSIVE_FAIL))]
|
||||
RecursiveFail {
|
||||
#[error(not(source))]
|
||||
count: usize,
|
||||
},
|
||||
|
||||
#[display("\"pnpm recursive run\" failed in {prefix}")]
|
||||
#[diagnostic(code(ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL))]
|
||||
RecursiveRunFirstFail {
|
||||
#[error(not(source))]
|
||||
prefix: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Run `args.command` across every workspace project, sorted
|
||||
/// topologically. `dir` is the canonicalized working directory; the
|
||||
/// workspace root (and the directory the summary is written to) is
|
||||
/// `config.workspace_dir`, falling back to `dir` when no
|
||||
/// `pnpm-workspace.yaml` exists.
|
||||
pub fn run_recursive(args: &RunArgs, config: &Config, dir: &Path) -> miette::Result<()> {
|
||||
let script_name = args.command.as_str();
|
||||
let workspace_root = config.workspace_dir.as_deref().unwrap_or(dir);
|
||||
|
||||
let patterns = read_workspace_manifest(workspace_root)
|
||||
.into_diagnostic()
|
||||
.wrap_err("reading pnpm-workspace.yaml")?
|
||||
.and_then(|manifest| manifest.packages);
|
||||
let projects = find_workspace_projects(workspace_root, &FindWorkspaceProjectsOpts { patterns })
|
||||
.wrap_err("finding workspace projects")?;
|
||||
|
||||
let adapters = projects.iter().map(|project| GraphPkg { project }).collect();
|
||||
let graph = create_projects_graph(adapters, &CreateProjectsGraphOptions::default()).graph;
|
||||
|
||||
let mut chunks = sort_projects(&graph);
|
||||
if let Some(resume_from) = &args.resume_from {
|
||||
chunks = get_resumed_package_chunks(resume_from, chunks, &graph)?;
|
||||
}
|
||||
|
||||
let bail = !args.no_bail;
|
||||
let passed_through_args = args.args.join(" ");
|
||||
let mut result: IndexMap<PathBuf, ExecutionStatus> =
|
||||
chunks.iter().flatten().map(|root| (root.clone(), ExecutionStatus::queued())).collect();
|
||||
let mut has_command = 0_usize;
|
||||
|
||||
for chunk in &chunks {
|
||||
for root in chunk {
|
||||
let manifest = &graph[root].package.project.manifest;
|
||||
let Some(script) = manifest.script(script_name, true)? else {
|
||||
result[root].status = Status::Skipped;
|
||||
continue;
|
||||
};
|
||||
|
||||
result[root].status = Status::Running;
|
||||
has_command += 1;
|
||||
let start = Instant::now();
|
||||
let command = format!("{script} {passed_through_args}");
|
||||
let status = execute_shell_with_status(command.trim(), root).into_diagnostic()?;
|
||||
let duration = start.elapsed().as_secs_f64() * 1e3;
|
||||
|
||||
if status.success() {
|
||||
let entry = &mut result[root];
|
||||
entry.status = Status::Passed;
|
||||
entry.duration = Some(duration);
|
||||
} else {
|
||||
let prefix = root.to_string_lossy().into_owned();
|
||||
let entry = &mut result[root];
|
||||
entry.status = Status::Failure;
|
||||
entry.duration = Some(duration);
|
||||
entry.message =
|
||||
Some(format!("command failed with exit code {}", status.code().unwrap_or(1)));
|
||||
entry.prefix = Some(prefix.clone());
|
||||
|
||||
if bail {
|
||||
if args.report_summary {
|
||||
write_recursive_summary(workspace_root, &result)?;
|
||||
}
|
||||
return Err(RecursiveRunError::RecursiveRunFirstFail { prefix }.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `test` is exempt because `pnpm test` falls back to a default and
|
||||
// should not error on a workspace with no `test` script; otherwise a
|
||||
// recursive run that matched nothing is a user error, unless
|
||||
// `--if-present` opted out of it.
|
||||
if script_name != "test" && has_command == 0 && !args.if_present {
|
||||
return Err(RecursiveRunError::NoScript { script_name: script_name.to_string() }.into());
|
||||
}
|
||||
|
||||
if args.report_summary {
|
||||
write_recursive_summary(workspace_root, &result)?;
|
||||
}
|
||||
|
||||
throw_on_command_fail(&result)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sort the workspace graph into topologically ordered chunks: every
|
||||
/// project in chunk `i` depends only on projects in earlier chunks, so
|
||||
/// chunk `i` may run after chunks `0..i`.
|
||||
///
|
||||
/// Port of pnpm's
|
||||
/// [`sortProjects`](https://github.com/pnpm/pnpm/blob/8eb1be4988/workspace/projects-sorter/src/index.ts):
|
||||
/// build a node → in-set-dependencies map (dropping self-edges and edges
|
||||
/// leaving the selected set) and run it through [`graph_sequencer`].
|
||||
fn sort_projects(graph: &ProjectGraph<GraphPkg<'_>>) -> Vec<Vec<PathBuf>> {
|
||||
let keys: Vec<PathBuf> = graph.keys().cloned().collect();
|
||||
let key_set: HashSet<&PathBuf> = keys.iter().collect();
|
||||
let dependency_graph: HashMap<PathBuf, Vec<PathBuf>> = graph
|
||||
.iter()
|
||||
.map(|(root, node)| {
|
||||
let dependencies = node
|
||||
.dependencies
|
||||
.iter()
|
||||
.filter(|dependency| *dependency != root && key_set.contains(dependency))
|
||||
.cloned()
|
||||
.collect();
|
||||
(root.clone(), dependencies)
|
||||
})
|
||||
.collect();
|
||||
graph_sequencer(&dependency_graph, &keys).chunks
|
||||
}
|
||||
|
||||
/// Drop every chunk before the one containing the `resume_from` package,
|
||||
/// so execution resumes from that package.
|
||||
///
|
||||
/// Port of pnpm's
|
||||
/// [`getResumedPackageChunks`](https://github.com/pnpm/pnpm/blob/8eb1be4988/exec/commands/src/exec.ts#L100-L118):
|
||||
/// the package is located by manifest name; an unknown name is a
|
||||
/// `RESUME_FROM_NOT_FOUND` error.
|
||||
fn get_resumed_package_chunks(
|
||||
resume_from: &str,
|
||||
chunks: Vec<Vec<PathBuf>>,
|
||||
graph: &ProjectGraph<GraphPkg<'_>>,
|
||||
) -> Result<Vec<Vec<PathBuf>>, RecursiveRunError> {
|
||||
let resume_root = graph
|
||||
.iter()
|
||||
.find(|(_, node)| node.package.manifest_name() == Some(resume_from))
|
||||
.map(|(root, _)| root.clone())
|
||||
.ok_or_else(|| RecursiveRunError::ResumeFromNotFound {
|
||||
resume_from: resume_from.to_string(),
|
||||
})?;
|
||||
let position = chunks
|
||||
.iter()
|
||||
.position(|chunk| chunk.contains(&resume_root))
|
||||
.expect("the resume-from package is present in the sorted chunks");
|
||||
Ok(chunks.into_iter().skip(position).collect())
|
||||
}
|
||||
|
||||
/// Write the recursive summary to `pnpm-exec-summary.json` under `dir`.
|
||||
///
|
||||
/// Port of pnpm's
|
||||
/// [`writeRecursiveSummary`](https://github.com/pnpm/pnpm/blob/8eb1be4988/exec/commands/src/exec.ts#L120-L124):
|
||||
/// the per-package map is nested under an `executionStatus` key.
|
||||
fn write_recursive_summary(
|
||||
dir: &Path,
|
||||
summary: &IndexMap<PathBuf, ExecutionStatus>,
|
||||
) -> miette::Result<()> {
|
||||
let execution_status = summary
|
||||
.iter()
|
||||
.map(|(root, status)| (root.to_string_lossy().into_owned(), status.clone()))
|
||||
.collect();
|
||||
let path = dir.join("pnpm-exec-summary.json");
|
||||
let mut contents =
|
||||
serde_json::to_string_pretty(&ExecSummaryFile { execution_status }).into_diagnostic()?;
|
||||
contents.push('\n');
|
||||
std::fs::write(&path, contents)
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("writing {}", path.display()))
|
||||
}
|
||||
|
||||
/// Fail when any package's script failed.
|
||||
///
|
||||
/// Port of pnpm's
|
||||
/// [`throwOnCommandFail`](https://github.com/pnpm/pnpm/blob/8eb1be4988/cli/utils/src/recursiveSummary.ts#L28-L33).
|
||||
fn throw_on_command_fail(
|
||||
summary: &IndexMap<PathBuf, ExecutionStatus>,
|
||||
) -> Result<(), RecursiveRunError> {
|
||||
let count = summary.values().filter(|status| status.status == Status::Failure).count();
|
||||
if count > 0 {
|
||||
return Err(RecursiveRunError::RecursiveFail { count });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adapter that lets a [`Project`] feed [`create_projects_graph`]. Owns
|
||||
/// nothing beyond a borrow of the project; the graph reads the manifest
|
||||
/// name, version, and dependency groups through it.
|
||||
struct GraphPkg<'a> {
|
||||
project: &'a Project,
|
||||
}
|
||||
|
||||
impl BaseProject for GraphPkg<'_> {
|
||||
fn root_dir(&self) -> &Path {
|
||||
&self.project.root_dir
|
||||
}
|
||||
|
||||
fn manifest_name(&self) -> Option<&str> {
|
||||
self.project.manifest.value().get("name").and_then(|name| name.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl GraphProject for GraphPkg<'_> {
|
||||
fn manifest_version(&self) -> Option<&str> {
|
||||
self.project.manifest.value().get("version").and_then(|version| version.as_str())
|
||||
}
|
||||
|
||||
fn merged_dependencies(&self, ignore_dev_deps: bool) -> Vec<(String, String)> {
|
||||
// Precedence mirrors upstream's `createNode` spread: peer, then
|
||||
// dev (unless excluded), then optional, then prod, with a later
|
||||
// group overwriting an earlier duplicate's specifier while
|
||||
// keeping the first-seen position.
|
||||
let mut merged: IndexMap<String, String> = IndexMap::new();
|
||||
let mut absorb = |group: DependencyGroup| {
|
||||
for (name, spec) in self.project.manifest.dependencies([group]) {
|
||||
merged.insert(name.to_string(), spec.to_string());
|
||||
}
|
||||
};
|
||||
absorb(DependencyGroup::Peer);
|
||||
if !ignore_dev_deps {
|
||||
absorb(DependencyGroup::Dev);
|
||||
}
|
||||
absorb(DependencyGroup::Optional);
|
||||
absorb(DependencyGroup::Prod);
|
||||
merged.into_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// `pnpm-exec-summary.json` top-level shape: `{ "executionStatus": { ... } }`.
|
||||
#[derive(Serialize)]
|
||||
struct ExecSummaryFile {
|
||||
#[serde(rename = "executionStatus")]
|
||||
execution_status: IndexMap<String, ExecutionStatus>,
|
||||
}
|
||||
|
||||
/// One package's entry in the recursive summary. `duration` is in
|
||||
/// milliseconds and present only once the script has run; `prefix` and
|
||||
/// `message` are filled in for failures.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ExecutionStatus {
|
||||
status: Status,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
duration: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
prefix: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
impl ExecutionStatus {
|
||||
fn queued() -> Self {
|
||||
ExecutionStatus { status: Status::Queued, duration: None, prefix: None, message: None }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum Status {
|
||||
Queued,
|
||||
Running,
|
||||
Passed,
|
||||
Skipped,
|
||||
Failure,
|
||||
}
|
||||
327
pacquet/crates/cli/tests/run_recursive.rs
Normal file
327
pacquet/crates/cli/tests/run_recursive.rs
Normal file
@@ -0,0 +1,327 @@
|
||||
//! Recursive-run integration tests. The build scripts run through
|
||||
//! pacquet's `sh -c` executor, so the whole file is gated to Unix —
|
||||
//! same as the single-package `run` tests.
|
||||
#![cfg(unix)]
|
||||
|
||||
use assert_cmd::prelude::*;
|
||||
use command_extra::CommandExtra;
|
||||
use pacquet_testing_utils::bin::CommandTempCwd;
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
|
||||
/// Write a `pnpm-workspace.yaml` listing `names` as packages, plus a
|
||||
/// `package.json` per name under its own subdirectory of `workspace`.
|
||||
fn write_workspace(workspace: &Path, manifests: &[(&str, Value)]) {
|
||||
let packages = manifests.iter().map(|(name, _)| format!(" - {name}")).collect::<Vec<_>>();
|
||||
let workspace_yaml = format!("packages:\n{}\n", packages.join("\n"));
|
||||
fs::write(workspace.join("pnpm-workspace.yaml"), workspace_yaml)
|
||||
.expect("write pnpm-workspace.yaml");
|
||||
for (name, manifest) in manifests {
|
||||
let dir = workspace.join(name);
|
||||
fs::create_dir_all(&dir).expect("create project dir");
|
||||
fs::write(dir.join("package.json"), manifest.to_string()).expect("write package.json");
|
||||
}
|
||||
}
|
||||
|
||||
/// Map each summary entry to `(basename, status)` so assertions don't
|
||||
/// depend on the absolute tempdir path used as the key.
|
||||
fn summary_statuses(workspace: &Path) -> HashMap<String, String> {
|
||||
let contents =
|
||||
fs::read_to_string(workspace.join("pnpm-exec-summary.json")).expect("read summary file");
|
||||
let value: Value = serde_json::from_str(&contents).expect("parse summary file");
|
||||
value["executionStatus"]
|
||||
.as_object()
|
||||
.expect("executionStatus is an object")
|
||||
.iter()
|
||||
.map(|(prefix, entry)| {
|
||||
let basename = Path::new(prefix)
|
||||
.file_name()
|
||||
.expect("prefix has a basename")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let status = entry["status"].as_str().expect("status is a string").to_string();
|
||||
(basename, status)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A package whose `build` script writes a marker via a *relative* path
|
||||
/// (`touch ran.txt`), so it lands in the script's working directory.
|
||||
/// Tests assert the marker appears under the package's own root, which
|
||||
/// only holds if each script runs with cwd == its package root rather
|
||||
/// than the workspace root.
|
||||
fn build_writes_marker(name: &str) -> Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"version": "1.0.0",
|
||||
"scripts": { "build": "touch ran.txt" },
|
||||
})
|
||||
}
|
||||
|
||||
/// `pacquet -r run <script>` runs the script in every workspace project,
|
||||
/// in topological order. Mirrors the ordering pnpm's recursive run
|
||||
/// produces from the workspace dependency graph.
|
||||
#[test]
|
||||
fn recursive_run_executes_script_in_every_project() {
|
||||
let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init();
|
||||
write_workspace(
|
||||
&workspace,
|
||||
&[
|
||||
("project-1", build_writes_marker("project-1")),
|
||||
("project-2", build_writes_marker("project-2")),
|
||||
("project-3", build_writes_marker("project-3")),
|
||||
],
|
||||
);
|
||||
|
||||
pacquet.with_arg("-r").with_arg("run").with_arg("build").assert().success();
|
||||
|
||||
for name in ["project-1", "project-2", "project-3"] {
|
||||
assert!(
|
||||
workspace.join(name).join("ran.txt").exists(),
|
||||
"{name} build script should have run from its own package root",
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!workspace.join("ran.txt").exists(),
|
||||
"scripts must run from each package root, not the workspace root",
|
||||
);
|
||||
|
||||
drop(root);
|
||||
}
|
||||
|
||||
/// `pacquet -r run --resume-from <pkg>` skips every chunk that sorts
|
||||
/// before the chunk containing `<pkg>`. With `project-2` and `project-3`
|
||||
/// both depending on `project-1`, the sorted chunks are
|
||||
/// `[[project-1], [project-2, project-3]]`; resuming from `project-3`
|
||||
/// drops the first chunk, so only `project-2` and `project-3` run.
|
||||
///
|
||||
/// Ports pnpm's
|
||||
/// [`runRecursive.ts:817`](https://github.com/pnpm/pnpm/blob/8eb1be4988/exec/commands/test/runRecursive.ts#L817)
|
||||
/// `` `pnpm -r --resume-from run` should executed from given package ``.
|
||||
#[test]
|
||||
fn recursive_run_resume_from_starts_at_the_given_package() {
|
||||
let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init();
|
||||
let dependent = |name: &str| {
|
||||
let mut manifest = build_writes_marker(name);
|
||||
manifest["dependencies"] = json!({ "project-1": "1" });
|
||||
manifest
|
||||
};
|
||||
write_workspace(
|
||||
&workspace,
|
||||
&[
|
||||
("project-1", build_writes_marker("project-1")),
|
||||
("project-2", dependent("project-2")),
|
||||
("project-3", dependent("project-3")),
|
||||
],
|
||||
);
|
||||
|
||||
pacquet
|
||||
.with_arg("-r")
|
||||
.with_arg("run")
|
||||
.with_arg("--resume-from")
|
||||
.with_arg("project-3")
|
||||
.with_arg("build")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
assert!(
|
||||
!workspace.join("project-1").join("ran.txt").exists(),
|
||||
"project-1 sorts before the resume point and must be skipped",
|
||||
);
|
||||
assert!(workspace.join("project-2").join("ran.txt").exists(), "project-2 should run");
|
||||
assert!(workspace.join("project-3").join("ran.txt").exists(), "project-3 should run");
|
||||
|
||||
drop(root);
|
||||
}
|
||||
|
||||
/// An unknown `--resume-from` package fails with pnpm's
|
||||
/// `ERR_PNPM_RESUME_FROM_NOT_FOUND`. Ports the error path of pnpm's
|
||||
/// `getResumedPackageChunks`.
|
||||
#[test]
|
||||
fn recursive_run_resume_from_unknown_package_errors() {
|
||||
let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init();
|
||||
write_workspace(&workspace, &[("project-1", build_writes_marker("project-1"))]);
|
||||
|
||||
let output = pacquet
|
||||
.with_arg("-r")
|
||||
.with_arg("run")
|
||||
.with_arg("--resume-from")
|
||||
.with_arg("does-not-exist")
|
||||
.with_arg("build")
|
||||
.output()
|
||||
.expect("spawn pacquet");
|
||||
assert!(!output.status.success(), "an unknown resume-from package must fail");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("ERR_PNPM_RESUME_FROM_NOT_FOUND"),
|
||||
"stderr should carry the resume-from error code, got: {stderr}",
|
||||
);
|
||||
|
||||
drop(root);
|
||||
}
|
||||
|
||||
/// `pacquet -r run --report-summary` writes `pnpm-exec-summary.json`
|
||||
/// recording every package's status: `passed`, `failure`, or `skipped`
|
||||
/// (no matching script). With `--no-bail` every package runs even after
|
||||
/// a failure, and the overall run fails with `ERR_PNPM_RECURSIVE_FAIL`.
|
||||
///
|
||||
/// Ports pnpm's
|
||||
/// [`runRecursive.ts:956`](https://github.com/pnpm/pnpm/blob/8eb1be4988/exec/commands/test/runRecursive.ts#L956)
|
||||
/// `pnpm recursive run report summary` (whose `DEFAULT_OPTS` set
|
||||
/// `bail: false`).
|
||||
#[test]
|
||||
fn recursive_run_report_summary_records_every_package_status() {
|
||||
let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init();
|
||||
let build = |name: &str, body: &str| json!({ "name": name, "version": "1.0.0", "scripts": { "build": body } });
|
||||
write_workspace(
|
||||
&workspace,
|
||||
&[
|
||||
("project-1", build("project-1", "true")),
|
||||
("project-2", build("project-2", "exit 1")),
|
||||
("project-3", build("project-3", "true")),
|
||||
("project-4", build("project-4", "exit 1")),
|
||||
("project-5", json!({ "name": "project-5", "version": "1.0.0" })),
|
||||
],
|
||||
);
|
||||
|
||||
let output = pacquet
|
||||
.with_arg("-r")
|
||||
.with_arg("run")
|
||||
.with_arg("--report-summary")
|
||||
.with_arg("--no-bail")
|
||||
.with_arg("build")
|
||||
.output()
|
||||
.expect("spawn pacquet");
|
||||
assert!(!output.status.success(), "a run with failing packages must fail overall");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("ERR_PNPM_RECURSIVE_FAIL"),
|
||||
"stderr should carry the recursive-fail code, got: {stderr}",
|
||||
);
|
||||
|
||||
let statuses = summary_statuses(&workspace);
|
||||
let expected = [
|
||||
("project-1", "passed"),
|
||||
("project-2", "failure"),
|
||||
("project-3", "passed"),
|
||||
("project-4", "failure"),
|
||||
("project-5", "skipped"),
|
||||
];
|
||||
for (name, status) in expected {
|
||||
assert_eq!(statuses.get(name).map(String::as_str), Some(status), "status of {name}");
|
||||
}
|
||||
|
||||
drop(root);
|
||||
}
|
||||
|
||||
/// With bail on (the default) and `--report-summary`, the first failing
|
||||
/// script aborts the run *after* the summary is written: the run fails
|
||||
/// with `ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL`, the summary records the
|
||||
/// failed package, and a package that sorts after it stays `queued`
|
||||
/// because it never ran. Covers the bail + report-summary branch.
|
||||
#[test]
|
||||
fn recursive_run_bail_writes_summary_then_stops_at_first_failure() {
|
||||
let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init();
|
||||
let build = |name: &str, body: &str| json!({ "name": name, "version": "1.0.0", "scripts": { "build": body } });
|
||||
write_workspace(
|
||||
&workspace,
|
||||
&[("project-1", build("project-1", "exit 1")), ("project-2", build("project-2", "true"))],
|
||||
);
|
||||
|
||||
let output = pacquet
|
||||
.with_arg("-r")
|
||||
.with_arg("run")
|
||||
.with_arg("--report-summary")
|
||||
.with_arg("build")
|
||||
.output()
|
||||
.expect("spawn pacquet");
|
||||
assert!(!output.status.success(), "a failing script with bail on must fail the run");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL"),
|
||||
"stderr should carry the bail first-fail code, got: {stderr}",
|
||||
);
|
||||
|
||||
let statuses = summary_statuses(&workspace);
|
||||
assert_eq!(statuses.get("project-1").map(String::as_str), Some("failure"), "project-1 failed");
|
||||
assert_eq!(
|
||||
statuses.get("project-2").map(String::as_str),
|
||||
Some("queued"),
|
||||
"project-2 never ran because bail stopped at project-1",
|
||||
);
|
||||
|
||||
drop(root);
|
||||
}
|
||||
|
||||
/// With bail on (the default) and `--report-summary` *off*, a failing
|
||||
/// script still aborts with `ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL`, but no
|
||||
/// summary file is written. Covers the report-summary-off side of the
|
||||
/// bail block.
|
||||
#[test]
|
||||
fn recursive_run_bail_without_report_summary_writes_no_file() {
|
||||
let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init();
|
||||
let build = |name: &str, body: &str| json!({ "name": name, "version": "1.0.0", "scripts": { "build": body } });
|
||||
write_workspace(
|
||||
&workspace,
|
||||
&[("project-1", build("project-1", "exit 1")), ("project-2", build("project-2", "true"))],
|
||||
);
|
||||
|
||||
let output =
|
||||
pacquet.with_arg("-r").with_arg("run").with_arg("build").output().expect("spawn pacquet");
|
||||
assert!(!output.status.success(), "a failing script with bail on must fail the run");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL"),
|
||||
"stderr should carry the bail first-fail code, got: {stderr}",
|
||||
);
|
||||
assert!(
|
||||
!workspace.join("pnpm-exec-summary.json").exists(),
|
||||
"no summary file should be written without --report-summary",
|
||||
);
|
||||
|
||||
drop(root);
|
||||
}
|
||||
|
||||
/// A recursive run for a script no package defines fails with pnpm's
|
||||
/// `ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT`. Covers the no-script branch.
|
||||
#[test]
|
||||
fn recursive_run_errors_when_no_package_has_the_script() {
|
||||
let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init();
|
||||
write_workspace(
|
||||
&workspace,
|
||||
&[
|
||||
("project-1", build_writes_marker("project-1")),
|
||||
("project-2", build_writes_marker("project-2")),
|
||||
],
|
||||
);
|
||||
|
||||
let output =
|
||||
pacquet.with_arg("-r").with_arg("run").with_arg("lint").output().expect("spawn pacquet");
|
||||
assert!(!output.status.success(), "a script no package defines must fail");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT"),
|
||||
"stderr should carry the no-script code, got: {stderr}",
|
||||
);
|
||||
|
||||
drop(root);
|
||||
}
|
||||
|
||||
/// `--if-present` turns the no-script case into a clean no-op: the run
|
||||
/// exits 0 even though no package defines the script. Guards the
|
||||
/// `!args.if_present` side of the no-script branch.
|
||||
#[test]
|
||||
fn recursive_run_if_present_is_a_noop_when_no_package_has_the_script() {
|
||||
let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init();
|
||||
write_workspace(&workspace, &[("project-1", build_writes_marker("project-1"))]);
|
||||
|
||||
pacquet
|
||||
.with_arg("-r")
|
||||
.with_arg("run")
|
||||
.with_arg("--if-present")
|
||||
.with_arg("lint")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
drop(root);
|
||||
}
|
||||
@@ -13,7 +13,10 @@ pub use shell::{ScriptShellError, SelectedShell, select_shell};
|
||||
|
||||
use derive_more::{Display, Error};
|
||||
use miette::Diagnostic;
|
||||
use std::process::Command;
|
||||
use std::{
|
||||
path::Path,
|
||||
process::{Command, ExitStatus},
|
||||
};
|
||||
|
||||
#[derive(Debug, Display, Error, Diagnostic)]
|
||||
#[non_exhaustive]
|
||||
@@ -28,12 +31,38 @@ pub enum ExecutorError {
|
||||
}
|
||||
|
||||
pub fn execute_shell(command: &str) -> Result<(), ExecutorError> {
|
||||
let mut cmd =
|
||||
Command::new("sh").arg("-c").arg(command).spawn().map_err(ExecutorError::SpawnCommand)?;
|
||||
spawn_shell(command, None).map(|_status| ())
|
||||
}
|
||||
|
||||
cmd.wait().map_err(ExecutorError::WaitProcess)?;
|
||||
/// Run `command` through `sh -c` in `current_dir` and return the child's
|
||||
/// exit status.
|
||||
///
|
||||
/// The variant [`execute_shell`] builds on: callers that need to react
|
||||
/// to a non-zero exit (e.g. recursive run recording a per-package
|
||||
/// `passed` / `failure` status) inspect the returned [`ExitStatus`],
|
||||
/// while callers that only care about spawn / wait failures use
|
||||
/// [`execute_shell`]. A non-zero exit is *not* an [`ExecutorError`] —
|
||||
/// only a failure to spawn the shell or to wait on it is.
|
||||
///
|
||||
/// `current_dir` is the directory the script runs in. A recursive run
|
||||
/// passes each package's root so scripts resolve relative paths against
|
||||
/// their own project, matching pnpm's `runLifecycleHook` (which runs
|
||||
/// with `pkgRoot` as the working directory).
|
||||
pub fn execute_shell_with_status(
|
||||
command: &str,
|
||||
current_dir: &Path,
|
||||
) -> Result<ExitStatus, ExecutorError> {
|
||||
spawn_shell(command, Some(current_dir))
|
||||
}
|
||||
|
||||
Ok(())
|
||||
fn spawn_shell(command: &str, current_dir: Option<&Path>) -> Result<ExitStatus, ExecutorError> {
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c").arg(command);
|
||||
if let Some(current_dir) = current_dir {
|
||||
cmd.current_dir(current_dir);
|
||||
}
|
||||
let mut child = cmd.spawn().map_err(ExecutorError::SpawnCommand)?;
|
||||
child.wait().map_err(ExecutorError::WaitProcess)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user