Skip to content

Commit

Permalink
feat(import): Add --preserve-commit option (#2079)
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisdothtml authored and evocateur committed May 11, 2019
1 parent ea41fe9 commit 6a7448d
Show file tree
Hide file tree
Showing 4 changed files with 96 additions and 6 deletions.
10 changes: 10 additions & 0 deletions commands/import/README.md
Expand Up @@ -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
```
33 changes: 33 additions & 0 deletions commands/import/__tests__/import-command.test.js
Expand Up @@ -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");
Expand Down
5 changes: 5 additions & 0 deletions commands/import/command.js
Expand Up @@ -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",
Expand Down
54 changes: 48 additions & 6 deletions commands/import/index.js
Expand Up @@ -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");
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();

Expand All @@ -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);

Expand All @@ -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",
Expand Down

0 comments on commit 6a7448d

Please sign in to comment.