From 22bad8b3c14ef2b5ea56aa84db63c8f6a23555a2 Mon Sep 17 00:00:00 2001 From: James Newell Date: Fri, 19 May 2017 06:37:08 +1000 Subject: [PATCH] Add --only-updated option to exec and run subcommands (#726) --- README.md | 14 ++++++++ src/PackageUtilities.js | 7 ++++ src/commands/ExecCommand.js | 18 +++++++++- src/commands/RunCommand.js | 19 ++++++++-- test/ExecCommand.js | 36 +++++++++++++++++++ test/PackageUtilities.js | 51 +++++++++++++++++++++++++++ test/RunCommand.js | 32 +++++++++++++++++ test/__snapshots__/RunCommand.js.snap | 6 ++++ 8 files changed, 180 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d8c9fe5ac4..647a282fef 100644 --- a/README.md +++ b/README.md @@ -674,6 +674,20 @@ $ lerna bootstrap --scope "package-*" --ignore "package-util-*" --include-filter # package matched by "package-*" ``` +#### --only-updated + +When executing a script or command, only run the script or command on packages that have been updated since the last release. + +A package is considered "updated" using the same rules as `lerna updated`. + +```sh +$ lerna exec --only-updated -- ls -la +``` + +``` +$ lerna run --only-updated test +``` + #### --loglevel [silent|error|warn|success|info|verbose|silly] What level of logs to report. On failure, all logs are written to lerna-debug.log in the current working directory. diff --git a/src/PackageUtilities.js b/src/PackageUtilities.js index 5b84b8f6ae..35cbdcaf61 100644 --- a/src/PackageUtilities.js +++ b/src/PackageUtilities.js @@ -157,6 +157,13 @@ export default class PackageUtilities { return packages; } + static filterPackagesThatAreNotUpdated(packagesToFilter, packageUpdates) { + return packageUpdates + .map((update) => update.package) + .filter((pkg) => packagesToFilter.some((p) => p.name === pkg.name)) + ; + } + static topologicallyBatchPackages(packagesToBatch, { depsOnly } = {}) { // We're going to be chopping stuff out of this array, so copy it. const packages = packagesToBatch.slice(); diff --git a/src/commands/ExecCommand.js b/src/commands/ExecCommand.js index e9cb36a527..e72dc19d92 100644 --- a/src/commands/ExecCommand.js +++ b/src/commands/ExecCommand.js @@ -1,8 +1,10 @@ import async from "async"; import ChildProcessUtilities from "../ChildProcessUtilities"; + import Command from "../Command"; import PackageUtilities from "../PackageUtilities"; +import UpdatedPackagesCollector from "../UpdatedPackagesCollector"; export function handler(argv) { return new ExecCommand([argv.command, ...argv.args], argv).run(); @@ -13,6 +15,10 @@ export const command = "exec [args..]"; export const describe = "Run an arbitrary command in each package."; export const builder = { + "only-updated": { + "describe": "When executing scripts/commands, only run the script/command on packages which " + + "have been updated since the last release" + }, "parallel": { group: "Command Options:", describe: "Run command in all packages with unlimited concurrency, streaming prefixed output", @@ -37,8 +43,18 @@ export default class ExecCommand extends Command { // don't interrupt spawned or streaming stdio this.logger.disableProgress(); + let filteredPackages = this.filteredPackages; + if (this.flags.onlyUpdated) { + const updatedPackagesCollector = new UpdatedPackagesCollector(this); + const packageUpdates = updatedPackagesCollector.getUpdates(); + filteredPackages = PackageUtilities.filterPackagesThatAreNotUpdated( + filteredPackages, + packageUpdates + ); + } + this.batchedPackages = this.toposort - ? PackageUtilities.topologicallyBatchPackages(this.filteredPackages) + ? PackageUtilities.topologicallyBatchPackages(filteredPackages) : [this.filteredPackages]; callback(null, true); diff --git a/src/commands/RunCommand.js b/src/commands/RunCommand.js index 8a45c22ce6..ac88364ac1 100644 --- a/src/commands/RunCommand.js +++ b/src/commands/RunCommand.js @@ -4,6 +4,7 @@ import Command from "../Command"; import NpmUtilities from "../NpmUtilities"; import output from "../utils/output"; import PackageUtilities from "../PackageUtilities"; +import UpdatedPackagesCollector from "../UpdatedPackagesCollector"; export function handler(argv) { return new RunCommand([argv.script, ...argv.args], argv).run(); @@ -19,6 +20,10 @@ export const builder = { describe: "Stream output with lines prefixed by package.", type: "boolean", }, + "only-updated": { + "describe": "When executing scripts/commands, only run the script/command on packages which " + + "have been updated since the last release" + }, "parallel": { group: "Command Options:", describe: "Run script in all packages with unlimited concurrency, streaming prefixed output", @@ -40,10 +45,20 @@ export default class RunCommand extends Command { return; } + let filteredPackages = this.filteredPackages; + if (this.flags.onlyUpdated) { + const updatedPackagesCollector = new UpdatedPackagesCollector(this); + const packageUpdates = updatedPackagesCollector.getUpdates(); + filteredPackages = PackageUtilities.filterPackagesThatAreNotUpdated( + filteredPackages, + packageUpdates + ); + } + if (this.script === "test" || this.script === "env") { - this.packagesWithScript = this.filteredPackages; + this.packagesWithScript = filteredPackages; } else { - this.packagesWithScript = this.filteredPackages + this.packagesWithScript = filteredPackages .filter((pkg) => pkg.scripts && pkg.scripts[this.script]); } diff --git a/test/ExecCommand.js b/test/ExecCommand.js index 0d0f5d0790..403bdea9a3 100644 --- a/test/ExecCommand.js +++ b/test/ExecCommand.js @@ -3,6 +3,7 @@ import path from "path"; // mocked modules import ChildProcessUtilities from "../src/ChildProcessUtilities"; +import UpdatedPackagesCollector from "../src/UpdatedPackagesCollector"; // helpers import callsBack from "./helpers/callsBack"; @@ -13,6 +14,7 @@ import initFixture from "./helpers/initFixture"; import ExecCommand from "../src/commands/ExecCommand"; jest.mock("../src/ChildProcessUtilities"); +jest.mock("../src/UpdatedPackagesCollector"); // silence logs log.level = "silent"; @@ -110,6 +112,40 @@ describe("ExecCommand", () => { })); }); + it("should filter packages that are not updated when onlyUpdate", (done) => { + + UpdatedPackagesCollector.prototype.getUpdates = jest.fn(() => [{ package: { + name: "package-2", + location: path.join(testDir, "packages/package-2") + } }]); + + const execCommand = new ExecCommand(["ls"], { + onlyUpdated: true, + }, testDir); + + execCommand.runValidations(); + execCommand.runPreparations(); + + execCommand.runCommand(exitWithCode(0, (err) => { + if (err) return done.fail(err); + + try { + expect(ChildProcessUtilities.spawn).toHaveBeenCalledTimes(1); + expect(ChildProcessUtilities.spawn).lastCalledWith("ls", [], { + cwd: path.join(testDir, "packages/package-2"), + env: expect.objectContaining({ + LERNA_PACKAGE_NAME: "package-2", + }), + shell: true, + }, expect.any(Function)); + + done(); + } catch (ex) { + done.fail(ex); + } + })); + }); + it("should run a command", (done) => { const execCommand = new ExecCommand(["ls"], {}, testDir); diff --git a/test/PackageUtilities.js b/test/PackageUtilities.js index 77ad54a39a..1908730357 100644 --- a/test/PackageUtilities.js +++ b/test/PackageUtilities.js @@ -182,6 +182,57 @@ describe("PackageUtilities", () => { }); }); + describe(".filterPackagesThatAreNotUpdated()", () => { + + it("should return an empty array when there are no packages to filter", () => { + const packagesToFilter = []; + const packageUpdates = [ + { package: { name: "ghi" } }, + { package: { name: "xyz" } } + ]; + const updatedPackages = PackageUtilities.filterPackagesThatAreNotUpdated( + packagesToFilter, + packageUpdates + ); + expect(updatedPackages).toHaveLength(0); + }); + + it("should return an empty array when there are no packages that have been updated", () => { + const packagesToFilter = [ + { name: "abc" }, + { name: "def" }, + { name: "ghi" }, + { name: "jkl" }, + ]; + const packageUpdates = []; + const updatedPackages = PackageUtilities.filterPackagesThatAreNotUpdated( + packagesToFilter, + packageUpdates + ); + expect(updatedPackages).toHaveLength(0); + }); + + it("should return only packages that are to be filtered and have been updated", () => { + const packagesToFilter = [ + { name: "abc" }, + { name: "def" }, + { name: "ghi" }, + { name: "jkl" }, + ]; + const packageUpdates = [ + { package: { name: "ghi" } }, + { package: { name: "xyz" } }, + ]; + const updatedPackages = PackageUtilities.filterPackagesThatAreNotUpdated( + packagesToFilter, + packageUpdates + ); + expect(updatedPackages).toHaveLength(1); + expect(updatedPackages).toEqual([{ name: "ghi" }]); + }); + + }); + describe(".topologicallyBatchPackages()", () => { let packages; diff --git a/test/RunCommand.js b/test/RunCommand.js index ccb162f86e..3caf7bd5e9 100644 --- a/test/RunCommand.js +++ b/test/RunCommand.js @@ -1,8 +1,10 @@ +import path from "path"; import log from "npmlog"; // mocked modules import NpmUtilities from "../src/NpmUtilities"; import output from "../src/utils/output"; +import UpdatedPackagesCollector from "../src/UpdatedPackagesCollector"; // helpers import callsBack from "./helpers/callsBack"; @@ -147,6 +149,34 @@ describe("RunCommand", () => { }); }); + it("should filter packages that are not updated when onlyUpdate", (done) => { + UpdatedPackagesCollector.prototype.getUpdates = jest.fn(() => [{ package: { + name: "package-3", + location: path.join(testDir, "packages/package-3"), + scripts: { "my-script": "echo package-3" } + } }]); + + const runCommand = new RunCommand(["my-script"], { + onlyUpdated: true, + }, testDir); + + runCommand.runValidations(); + runCommand.runPreparations(); + + runCommand.runCommand(exitWithCode(0, (err) => { + if (err) return done.fail(err); + + try { + expect(ranInPackages(testDir)) + .toMatchSnapshot("run