Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(import): Add --preserve-commit #2079

Merged
merged 3 commits into from May 11, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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