diff --git a/README.md b/README.md index 4caf03bd8c..50adb72727 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ repositories is *messy* and difficult to track, and testing across repositories gets complicated really fast. To solve these (and many other) problems, some projects will organize their -codebases into multi-package repostories (sometimes called [monorepos](https://github.com/babel/babel/blob/master/doc/design/monorepo.md)). Projects like [Babel](https://github.com/babel/babel/tree/master/packages), [React](https://github.com/facebook/react/tree/master/packages), [Angular](https://github.com/angular/angular/tree/master/modules), +codebases into multi-package repositories (sometimes called [monorepos](https://github.com/babel/babel/blob/master/doc/design/monorepo.md)). Projects like [Babel](https://github.com/babel/babel/tree/master/packages), [React](https://github.com/facebook/react/tree/master/packages), [Angular](https://github.com/angular/angular/tree/master/modules), [Ember](https://github.com/emberjs/ember.js/tree/master/packages), [Meteor](https://github.com/meteor/meteor/tree/devel/packages), [Jest](https://github.com/facebook/jest/tree/master/packages), and many others develop all of their packages within a single repository. @@ -384,6 +384,21 @@ $ lerna exec --scope my-component -- ls -la $ lerna exec --concurrency 1 -- ls -la ``` +### import + +```sh +$ lerna import +``` + +Import the package at ``, with commit history, +into `packages/`. Original commit authors, dates and messages +are preserved. Commits are applied to the current branch. + +This is useful for gathering pre-existing standalone packages into a Lerna +repo. Each commit is modified to make changes relative to the package +directory. So, for example, the commit that added `package.json` will +instead add `packages//package.json`. + ## Misc Lerna will log to a `lerna-debug.log` file (same as `npm-debug.log`) when it encounters an error running a command. @@ -461,3 +476,16 @@ The `ignore` flag, when used with the `bootstrap` command, can also be set in `l > Hint: The glob is matched against the package name defined in `package.json`, > not the directory name the package lives in. + +#### --only-explicit-updates + +Only will bump versions for packages that have been updated explicitly rather than cross-dependencies. + +> This may not make sense for a major version bump since other packages that depend on the updated packages wouldn't be updated. + +```sh +$ lerna updated --only-explicit-updates +$ lerna publish --only-explicit-updates +``` + +Ex: in Babel, `babel-types` is depended upon by all packages in the monorepo (over 100). However, Babel uses `^` for most of it's dependencies so it isn't necessary to bump the versions of all packages if only `babel-types` is updated. This option allows only the packages that have been explicitly updated to make a new version. diff --git a/bin/lerna.js b/bin/lerna.js index 21f2f05ada..f070fe464d 100755 --- a/bin/lerna.js +++ b/bin/lerna.js @@ -12,6 +12,7 @@ var cli = meow([ " bootstrap Link together local packages and npm install remaining package dependencies", " publish Publish updated packages to npm", " updated Check which packages have changed since the last release", + " import Import a package with git history from an external repository", " clean Remove the node_modules directory from all packages", " diff Diff all packages or a single package since the last release", " init Initialize a lerna repo", diff --git a/package.json b/package.json index f066711bab..502c433104 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lerna", - "version": "2.0.0-beta.20", + "version": "2.0.0-beta.23", "description": "Tool for managing JavaScript projects with multiple packages", "main": "lib/index.js", "scripts": { diff --git a/src/ChildProcessUtilities.js b/src/ChildProcessUtilities.js index 1257e68a3a..7c7eea6b30 100644 --- a/src/ChildProcessUtilities.js +++ b/src/ChildProcessUtilities.js @@ -21,15 +21,14 @@ export default class ChildProcessUtilities { }); } - static execSync(command) { + static execSync(command, opts) { + const mergedOpts = objectAssign({ + encoding: "utf8" + }, opts); if (child.execSync) { - return child.execSync(command, { - encoding: "utf8" - }).trim(); + return child.execSync(command, mergedOpts).trim(); } else { - return syncExec(command, { - encoding: "utf8" - }).stdout.trim(); + return syncExec(command, mergedOpts).stdout.trim(); } } diff --git a/src/UpdatedPackagesCollector.js b/src/UpdatedPackagesCollector.js index c5b7eb4234..3023b5e43e 100644 --- a/src/UpdatedPackagesCollector.js +++ b/src/UpdatedPackagesCollector.js @@ -127,7 +127,7 @@ export default class UpdatedPackagesCollector { collectUpdates() { return this.packages.filter(pkg => { - return this.updatedPackages[pkg.name] || this.dependents[pkg.name] || this.flags.canary; + return this.updatedPackages[pkg.name] || (this.flags.onlyExplicitUpdates ? false : this.dependents[pkg.name]) || this.flags.canary; }).map(pkg => { return new Update(pkg); }); diff --git a/src/commands/BootstrapCommand.js b/src/commands/BootstrapCommand.js index 8002991219..b8cdab73e8 100644 --- a/src/commands/BootstrapCommand.js +++ b/src/commands/BootstrapCommand.js @@ -87,7 +87,8 @@ export default class BootstrapCommand extends Command { const packageJsonFileContents = JSON.stringify({ name: name, - version: pkg.version + version: pkg.version, + main: pkg.main }, null, " "); async.parallel([ diff --git a/src/commands/ImportCommand.js b/src/commands/ImportCommand.js new file mode 100644 index 0000000000..5b92c00bb9 --- /dev/null +++ b/src/commands/ImportCommand.js @@ -0,0 +1,101 @@ +import fs from "fs"; +import path from "path"; +import async from "async"; +import Command from "../Command"; +import progressBar from "../progressBar"; +import PromptUtilities from "../PromptUtilities"; +import ChildProcessUtilities from "../ChildProcessUtilities"; + +export default class ImportCommand extends Command { + initialize(callback) { + const inputPath = this.input[0]; + + if (!inputPath) { + return callback(new Error("Missing argument: Path to external repository")); + } + + const externalRepoPath = path.resolve(inputPath); + const externalRepoBase = path.basename(externalRepoPath); + + try { + const stats = fs.statSync(externalRepoPath); + if (!stats.isDirectory()) { + throw new Error(`Input path "${inputPath}" is not a directory`); + } + const packageJson = path.join(externalRepoPath, "package.json"); + const packageName = require(packageJson).name; + if (!packageName) { + throw new Error(`No package name specified in "${packageJson}"`); + } + } catch (e) { + if (e.code === "ENOENT") { + return callback(new Error(`No repository found at "${inputPath}"`)); + } + return callback(e); + } + + this.targetDir = "packages/" + externalRepoBase; + + try { + if (fs.statSync(this.targetDir)) { + return callback(new Error(`Target directory already exists "${this.targetDir}"`)); + } + } catch (e) { /* Pass */ } + + this.externalExecOpts = { + encoding: "utf8", + cwd: externalRepoPath + }; + + this.commits = this.externalExecSync("git log --format=\"%h\"").split("\n").reverse(); + + if (!this.commits.length) { + callback(new Error(`No git commits to import at "${inputPath}"`)); + } + + this.logger.info(`About to import ${this.commits.length} commits into from ${inputPath} into ${this.targetDir}`); + + if (this.flags.yes) { + callback(null, true); + } else { + PromptUtilities.confirm("Are you sure you want to import these commits onto the current branch?", confirmed => { + if (confirmed) { + callback(null, true); + } else { + this.logger.info("Okay bye!"); + callback(null, false); + } + }); + } + } + + externalExecSync(command) { + return ChildProcessUtilities.execSync(command, this.externalExecOpts).trim(); + } + + execute(callback) { + const replacement = "$1/" + this.targetDir; + + progressBar.init(this.commits.length); + + async.series(this.commits.map(sha => done => { + progressBar.tick(sha); + + // Create a patch file for this commit and prepend the target directory + // to all affected files. This moves the git history for the entire + // external repository into the package subdirectory, commit by commit. + const patch = this.externalExecSync(`git format-patch -1 ${sha} --stdout`) + .replace(/^([-+]{3} [ab])/mg, replacement) + .replace(/^(diff --git a)/mg, replacement) + .replace(/^(diff --git \S+ b)/mg, replacement); + + // Apply the modified patch to the current lerna repository, preserving + // original commit date, author and message. + ChildProcessUtilities.exec("git am", {}, done).stdin.end(patch); + }), err => { + progressBar.terminate(); + this.logger.info("Import complete!"); + callback(err, !err); + }); + } +} diff --git a/src/index.js b/src/index.js index d574e7d716..b98089e47b 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,7 @@ import BootstrapCommand from "./commands/BootstrapCommand"; import PublishCommand from "./commands/PublishCommand"; import UpdatedCommand from "./commands/UpdatedCommand"; +import ImportCommand from "./commands/ImportCommand"; import CleanCommand from "./commands/CleanCommand"; import DiffCommand from "./commands/DiffCommand"; import InitCommand from "./commands/InitCommand"; @@ -12,6 +13,7 @@ export const __commands__ = { bootstrap: BootstrapCommand, publish: PublishCommand, updated: UpdatedCommand, + import: ImportCommand, clean: CleanCommand, diff: DiffCommand, init: InitCommand, diff --git a/test/BootstrapCommand.js b/test/BootstrapCommand.js index 922fb0c03e..7589965a3b 100644 --- a/test/BootstrapCommand.js +++ b/test/BootstrapCommand.js @@ -58,7 +58,7 @@ describe("BootstrapCommand", () => { assert.equal(fs.readFileSync(path.join(testDir, "packages/package-2/node_modules/package-1/package.json")).toString(), "{\n \"name\": \"package-1\",\n \"version\": \"1.0.0\"\n}\n"); assert.equal(fs.readFileSync(path.join(testDir, "packages/package-3/node_modules/package-2/index.js")).toString(), "/**\n * @prefix\n */\nmodule.exports = require(\"" + path.join(testDir, "packages/package-2") + "\");\n"); - assert.equal(fs.readFileSync(path.join(testDir, "packages/package-3/node_modules/package-2/package.json")).toString(), "{\n \"name\": \"package-2\",\n \"version\": \"1.0.0\"\n}\n"); + assert.equal(fs.readFileSync(path.join(testDir, "packages/package-3/node_modules/package-2/package.json")).toString(), "{\n \"name\": \"package-2\",\n \"version\": \"1.0.0\",\n \"main\": \"lib/index.js\"\n}\n"); done(); } catch (err) { diff --git a/test/ImportCommand.js b/test/ImportCommand.js new file mode 100644 index 0000000000..de3834ed6f --- /dev/null +++ b/test/ImportCommand.js @@ -0,0 +1,148 @@ +import pathExists from "path-exists"; +import assert from "assert"; +import path from "path"; +import fs from "fs"; + +import PromptUtilities from "../src/PromptUtilities"; +import ChildProcessUtilities from "../src/ChildProcessUtilities"; +import ImportCommand from "../src/commands/ImportCommand"; +import exitWithCode from "./_exitWithCode"; +import initFixture from "./_initFixture"; +import initExternalFixture from "./_initExternalFixture"; +import assertStubbedCalls from "./_assertStubbedCalls"; + +describe("ImportCommand", () => { + + describe("import", () => { + let testDir, externalDir; + + beforeEach(done => { + testDir = initFixture("ImportCommand/basic", done); + }); + + beforeEach(done => { + externalDir = initExternalFixture("ImportCommand/external", done); + }); + + it("should import into packages with commit history", done => { + const importCommand = new ImportCommand([externalDir], {}); + + importCommand.runValidations(); + importCommand.runPreparations(); + + assertStubbedCalls([ + [PromptUtilities, "confirm", { valueCallback: true }, [ + { args: ["Are you sure you want to import these commits onto the current branch?"], returns: true } + ]], + ]); + + importCommand.runCommand(exitWithCode(0, err => { + if (err) return done(err); + + try { + const lastCommit = ChildProcessUtilities.execSync("git log --format=\"%s\"", {encoding:"utf8"}).split("\n")[0]; + const packageJson = path.join(testDir, "packages", path.basename(externalDir), "package.json"); + assert.ok(!pathExists.sync(path.join(testDir, "lerna-debug.log"))); + assert.ok(pathExists.sync(packageJson)); + assert.equal(lastCommit, "Init external commit"); + done(); + } catch (err) { + done(err); + } + })); + }); + + it("should be possible to skip asking for confirmation", done => { + + const importCommand = new ImportCommand([externalDir], { + yes: true + }); + + importCommand.runValidations(); + importCommand.runPreparations(); + + importCommand.initialize(done); + }); + + it("should fail without an argument", done => { + const importCommand = new ImportCommand([], {}); + + importCommand.runValidations(); + importCommand.runPreparations(); + + importCommand.runCommand(exitWithCode(1, err => { + const expect = "Missing argument: Path to external repository"; + assert.equal((err || {}).message, expect); + done(); + })); + }); + + it("should fail with a missing external directory", done => { + const missing = externalDir + "_invalidSuffix"; + const importCommand = new ImportCommand([missing], {}); + + importCommand.runValidations(); + importCommand.runPreparations(); + + importCommand.runCommand(exitWithCode(1, err => { + const expect = `No repository found at "${missing}"`; + assert.equal((err || {}).message, expect); + done(); + })); + }); + + it("should fail with a missing package.json", done => { + const importCommand = new ImportCommand([externalDir], {}); + + const packageJson = path.join(externalDir, "package.json"); + + fs.unlinkSync(packageJson); + + importCommand.runValidations(); + importCommand.runPreparations(); + + importCommand.runCommand(exitWithCode(1, err => { + const expect = `Cannot find module '${packageJson}'`; + assert.equal((err || {}).message, expect); + done(); + })); + }); + + it("should fail with no name in package.json", done => { + const importCommand = new ImportCommand([externalDir], {}); + + const packageJson = path.join(externalDir, "package.json"); + + fs.writeFileSync(packageJson, "{}"); + + importCommand.runValidations(); + importCommand.runPreparations(); + + importCommand.runCommand(exitWithCode(1, err => { + const expect = `No package name specified in "${packageJson}"`; + assert.equal((err || {}).message, expect); + done(); + })); + }); + + it("should fail if target directory exists", done => { + const importCommand = new ImportCommand([externalDir], {}); + + const targetDir = path.relative( + process.cwd(), + path.join(testDir, "packages", path.basename(externalDir)) + ); + + fs.mkdirSync(targetDir); + + importCommand.runValidations(); + importCommand.runPreparations(); + + importCommand.runCommand(exitWithCode(1, err => { + const expect = `Target directory already exists "${targetDir}"`; + assert.equal((err || {}).message, expect); + done(); + })); + }); + }); +}); diff --git a/test/UpdatedCommand.js b/test/UpdatedCommand.js index 8925b15acd..cb342c43fb 100644 --- a/test/UpdatedCommand.js +++ b/test/UpdatedCommand.js @@ -128,6 +128,31 @@ describe("UpdatedCommand", () => { updatedCommand.runCommand(exitWithCode(0, done)); }); + + it("should list changes for explicitly changed packages", done => { + execSync("git tag v1.0.0"); + execSync("touch " + path.join(testDir, "packages/package-2/random-file")); + execSync("git add -A"); + execSync("git commit -m 'Commit'"); + + const updatedCommand = new UpdatedCommand([], { + onlyExplicitUpdates: true + }); + + updatedCommand.runValidations(); + updatedCommand.runPreparations(); + + let calls = 0; + stub(logger, "info", message => { + if (calls === 0) assert.equal(message, "Checking for updated packages..."); + if (calls === 1) assert.equal(message, ""); + if (calls === 2) assert.equal(message, "- package-2"); + if (calls === 3) assert.equal(message, ""); + calls++; + }); + + updatedCommand.runCommand(exitWithCode(0, done)); + }); }); /** ========================================================================= diff --git a/test/_initExternalFixture.js b/test/_initExternalFixture.js new file mode 100644 index 0000000000..3d34b458cd --- /dev/null +++ b/test/_initExternalFixture.js @@ -0,0 +1,34 @@ +import rimraf from "rimraf"; +import child from "child_process"; +import syncExec from "sync-exec"; +import path from "path"; +import cpr from "cpr"; + +const tmpDir = path.resolve(__dirname, "../tmp"); + +const createdDirectories = []; + +after(() => { + createdDirectories.map(dir => rimraf.sync(dir)); +}); + +let uniqueId = 0; + +export default function initExternalFixture(fixturePath, callback) { + const fixtureDir = path.resolve(__dirname, "./fixtures/" + fixturePath); + const testDir = path.resolve(tmpDir, "test-external-" + Date.now() + "-" + (uniqueId++)); + + createdDirectories.push(testDir); + + cpr(fixtureDir, testDir, { + confirm: true + }, err => { + if (err) return callback(err); + (child.execSync || syncExec)("git init . && git add -A && git commit -m 'Init external commit'", { + cwd: testDir + }); + callback(); + }); + + return testDir; +} diff --git a/test/fixtures/BootstrapCommand/basic/packages/package-2/package.json b/test/fixtures/BootstrapCommand/basic/packages/package-2/package.json index d7e806f1b1..40bd46f9cb 100644 --- a/test/fixtures/BootstrapCommand/basic/packages/package-2/package.json +++ b/test/fixtures/BootstrapCommand/basic/packages/package-2/package.json @@ -1,6 +1,7 @@ { "name": "package-2", "version": "1.0.0", + "main": "lib/index.js", "dependencies": { "package-1": "^1.0.0" } diff --git a/test/fixtures/ImportCommand/basic/lerna.json b/test/fixtures/ImportCommand/basic/lerna.json new file mode 100644 index 0000000000..d8474fbed2 --- /dev/null +++ b/test/fixtures/ImportCommand/basic/lerna.json @@ -0,0 +1,4 @@ +{ + "lerna": "__TEST_VERSION__", + "version": "1.0.0" +} diff --git a/test/fixtures/ImportCommand/basic/package.json b/test/fixtures/ImportCommand/basic/package.json new file mode 100644 index 0000000000..46358b1693 --- /dev/null +++ b/test/fixtures/ImportCommand/basic/package.json @@ -0,0 +1,3 @@ +{ + "name": "independent" +} diff --git a/test/fixtures/ImportCommand/basic/packages/.keep b/test/fixtures/ImportCommand/basic/packages/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/ImportCommand/external/package.json b/test/fixtures/ImportCommand/external/package.json new file mode 100644 index 0000000000..6c7291598b --- /dev/null +++ b/test/fixtures/ImportCommand/external/package.json @@ -0,0 +1,4 @@ +{ + "//": "Import should use _directory_ name, not package name", + "name": "external-name" +}