diff --git a/commands/import/README.md b/commands/import/README.md index 013f881a0a..6824085c70 100644 --- a/commands/import/README.md +++ b/commands/import/README.md @@ -52,3 +52,13 @@ When importing repositories, you can specify the destination directory by the di ``` $ lerna import ~/Product --dest=utilities ``` + +### `--preserve-commit` + +Each git commit has an **author** and a **committer** (with a separate date for each). Usually they're the same person (and date), but since `lerna import` re-creates each commit from the external repository, the **committer** becomes the current git user (and date). This is *technically* correct, but may be undesireable, for example, on Github, which displays both the **author** and **committer** if they're different people, leading to potentially confusing history/blames on imported commits. + +Enabling this option preserves the original **committer** (and commit date) to avoid such issues. + +``` +$ lerna import ~/Product --preserve-commit +``` diff --git a/commands/import/__tests__/import-command.test.js b/commands/import/__tests__/import-command.test.js index 8f0cfb28ef..0a6eecabe9 100644 --- a/commands/import/__tests__/import-command.test.js +++ b/commands/import/__tests__/import-command.test.js @@ -154,6 +154,39 @@ describe("ImportCommand", () => { expect(await lastCommitInDir(testDir)).toBe("Init commit"); }); + it("preserves original committer and date with --preserve-commit", async () => + Promise.all( + // running the same test with and without --preserve-commit + [true, false].map(async shouldPreserve => { + const [testDir, externalDir] = await initBasicFixtures(); + const filePath = path.join(externalDir, "old-file"); + let expectedEmail; + let expectedName; + + await execa("git", ["config", "user.name", "'test-name'"], { cwd: externalDir }); + await execa("git", ["config", "user.email", "'test-email@foo.bar'"], { cwd: externalDir }); + await fs.writeFile(filePath, "non-empty content"); + await gitAdd(externalDir, filePath); + await gitCommit(externalDir, "Non-empty commit"); + + if (shouldPreserve) { + await lernaImport(testDir)(externalDir, "--preserve-commit"); + // original committer + expectedEmail = "test-email@foo.bar"; + expectedName = "test-name"; + } else { + await lernaImport(testDir)(externalDir); + // whatever the current git user is + expectedEmail = execa.sync("git", ["config", "user.email"], { cwd: testDir }).stdout; + expectedName = execa.sync("git", ["config", "user.name"], { cwd: testDir }).stdout; + } + + expect(execa.sync("git", ["log", "-1", "--format=%cn <%ce>"], { cwd: testDir }).stdout).toBe( + `${expectedName} <${expectedEmail}>` + ); + }) + )); + it("allows skipping confirmation prompt", async () => { const [testDir, externalDir] = await initBasicFixtures(); await lernaImport(testDir)(externalDir, "--yes"); diff --git a/commands/import/command.js b/commands/import/command.js index f27bf6921c..648511572a 100644 --- a/commands/import/command.js +++ b/commands/import/command.js @@ -21,6 +21,11 @@ exports.builder = yargs => describe: "Import destination directory for the external git repository", type: "string", }, + "preserve-commit": { + group: "Command Options:", + describe: "Preserve original committer in addition to original author", + type: "boolean", + }, y: { group: "Command Options:", describe: "Skip all confirmation prompts", diff --git a/commands/import/index.js b/commands/import/index.js index e52dc99d7b..96c1024832 100644 --- a/commands/import/index.js +++ b/commands/import/index.js @@ -94,10 +94,16 @@ class ImportCommand extends Command { throw new ValidationError("NOCOMMITS", `No git commits to import at "${inputPath}"`); } + if (this.options.preserveCommit) { + // Back these up since they'll change for each commit + this.origGitEmail = this.execSync("git", ["config", "user.email"]); + this.origGitName = this.execSync("git", ["config", "user.name"]); + } + // Stash the repo's pre-import head away in case something goes wrong. this.preImportHead = this.getCurrentSHA(); - if (ChildProcessUtilities.execSync("git", ["diff-index", "HEAD"], this.execOpts)) { + if (this.execSync("git", ["diff-index", "HEAD"])) { throw new ValidationError("ECHANGES", "Local repository has un-committed changes"); } @@ -126,11 +132,15 @@ class ImportCommand extends Command { } getCurrentSHA() { - return ChildProcessUtilities.execSync("git", ["rev-parse", "HEAD"], this.execOpts); + return this.execSync("git", ["rev-parse", "HEAD"]); } getWorkspaceRoot() { - return ChildProcessUtilities.execSync("git", ["rev-parse", "--show-toplevel"], this.execOpts); + return this.execSync("git", ["rev-parse", "--show-toplevel"]); + } + + execSync(cmd, args) { + return ChildProcessUtilities.execSync(cmd, args, this.execOpts); } externalExecSync(cmd, args) { @@ -186,6 +196,18 @@ class ImportCommand extends Command { .replace(/^(rename (from|to)) /gm, `$1 ${formattedTarget}/`); } + getGitUserFromSha(sha) { + return { + email: this.externalExecSync("git", ["show", "-s", "--format='%ae'", sha]), + name: this.externalExecSync("git", ["show", "-s", "--format='%an'", sha]), + }; + } + + configureGitUser({ email, name }) { + this.execSync("git", ["config", "user.email", `"${email}"`]); + this.execSync("git", ["config", "user.name", `"${name}"`]); + } + execute() { this.enableProgressBar(); @@ -194,13 +216,19 @@ class ImportCommand extends Command { tracker.info(sha); const patch = this.createPatchForCommit(sha); + const procArgs = ["am", "-3", "--keep-non-patch"]; + + if (this.options.preserveCommit) { + this.configureGitUser(this.getGitUserFromSha(sha)); + procArgs.push("--committer-date-is-author-date"); + } // Apply the modified patch to the current lerna repository, preserving // original commit date, author and message. // // Fall back to three-way merge, which can help with duplicate commits // due to merge history. - const proc = ChildProcessUtilities.exec("git", ["am", "-3", "--keep-non-patch"], this.execOpts); + const proc = ChildProcessUtilities.exec("git", procArgs, this.execOpts); proc.stdin.end(patch); @@ -227,16 +255,30 @@ class ImportCommand extends Command { .then(() => { tracker.finish(); + if (this.options.preserveCommit) { + this.configureGitUser({ + email: this.origGitEmail, + name: this.origGitName, + }); + } + this.logger.success("import", "finished"); }) .catch(err => { tracker.finish(); + if (this.options.preserveCommit) { + this.configureGitUser({ + email: this.origGitEmail, + name: this.origGitName, + }); + } + this.logger.error("import", `Rolling back to previous HEAD (commit ${this.preImportHead})`); // Abort the failed `git am` and roll back to previous HEAD. - ChildProcessUtilities.execSync("git", ["am", "--abort"], this.execOpts); - ChildProcessUtilities.execSync("git", ["reset", "--hard", this.preImportHead], this.execOpts); + this.execSync("git", ["am", "--abort"]); + this.execSync("git", ["reset", "--hard", this.preImportHead]); throw new ValidationError( "EIMPORT",