index.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. "use strict";
  2. const dedent = require("dedent");
  3. const fs = require("fs-extra");
  4. const path = require("path");
  5. const pMapSeries = require("p-map-series");
  6. const childProcess = require("@lerna/child-process");
  7. const { Command } = require("@lerna/command");
  8. const { promptConfirmation } = require("@lerna/prompt");
  9. const { ValidationError } = require("@lerna/validation-error");
  10. const { pulseTillDone } = require("@lerna/pulse-till-done");
  11. module.exports = factory;
  12. function factory(argv) {
  13. return new ImportCommand(argv);
  14. }
  15. class ImportCommand extends Command {
  16. gitParamsForTargetCommits() {
  17. const params = ["log", "--format=%h"];
  18. if (this.options.flatten) {
  19. params.push("--first-parent");
  20. }
  21. return params;
  22. }
  23. initialize() {
  24. const inputPath = this.options.dir;
  25. const externalRepoPath = path.resolve(inputPath);
  26. const externalRepoBase = path.basename(externalRepoPath);
  27. this.externalExecOpts = Object.assign({}, this.execOpts, {
  28. cwd: externalRepoPath,
  29. });
  30. let stats;
  31. try {
  32. stats = fs.statSync(externalRepoPath);
  33. } catch (e) {
  34. if (e.code === "ENOENT") {
  35. throw new ValidationError("ENOENT", `No repository found at "${inputPath}"`);
  36. }
  37. throw e;
  38. }
  39. if (!stats.isDirectory()) {
  40. throw new ValidationError("ENODIR", `Input path "${inputPath}" is not a directory`);
  41. }
  42. const packageJson = path.join(externalRepoPath, "package.json");
  43. // eslint-disable-next-line import/no-dynamic-require, global-require
  44. const packageName = require(packageJson).name;
  45. if (!packageName) {
  46. throw new ValidationError("ENOPKG", `No package name specified in "${packageJson}"`);
  47. }
  48. // Compute a target directory relative to the Lerna root
  49. const targetBase = this.getTargetBase();
  50. if (this.getPackageDirectories().indexOf(targetBase) === -1) {
  51. throw new ValidationError(
  52. "EDESTDIR",
  53. `--dest does not match with the package directories: ${this.getPackageDirectories()}`
  54. );
  55. }
  56. const targetDir = path.join(targetBase, externalRepoBase);
  57. // Compute a target directory relative to the Git root
  58. const gitRepoRoot = this.getWorkspaceRoot();
  59. const lernaRootRelativeToGitRoot = path.relative(gitRepoRoot, this.project.rootPath);
  60. this.targetDirRelativeToGitRoot = path.join(lernaRootRelativeToGitRoot, targetDir);
  61. if (fs.existsSync(path.resolve(this.project.rootPath, targetDir))) {
  62. throw new ValidationError("EEXISTS", `Target directory already exists "${targetDir}"`);
  63. }
  64. this.commits = this.externalExecSync("git", this.gitParamsForTargetCommits()).split("\n").reverse();
  65. // this.commits = this.externalExecSync("git", [
  66. // "rev-list",
  67. // "--no-merges",
  68. // "--topo-order",
  69. // "--reverse",
  70. // "HEAD",
  71. // ]).split("\n");
  72. if (!this.commits.length) {
  73. throw new ValidationError("NOCOMMITS", `No git commits to import at "${inputPath}"`);
  74. }
  75. if (this.options.preserveCommit) {
  76. // Back these up since they'll change for each commit
  77. this.origGitEmail = this.execSync("git", ["config", "user.email"]);
  78. this.origGitName = this.execSync("git", ["config", "user.name"]);
  79. }
  80. // Stash the repo's pre-import head away in case something goes wrong.
  81. this.preImportHead = this.getCurrentSHA();
  82. if (this.execSync("git", ["diff-index", "HEAD"])) {
  83. throw new ValidationError("ECHANGES", "Local repository has un-committed changes");
  84. }
  85. this.logger.info(
  86. "",
  87. `About to import ${this.commits.length} commits from ${inputPath} into ${targetDir}`
  88. );
  89. if (this.options.yes) {
  90. return true;
  91. }
  92. return promptConfirmation("Are you sure you want to import these commits onto the current branch?");
  93. }
  94. getPackageDirectories() {
  95. return this.project.packageConfigs.filter((p) => p.endsWith("*")).map((p) => path.dirname(p));
  96. }
  97. getTargetBase() {
  98. if (this.options.dest) {
  99. return this.options.dest;
  100. }
  101. return this.getPackageDirectories().shift() || "packages";
  102. }
  103. getCurrentSHA() {
  104. return this.execSync("git", ["rev-parse", "HEAD"]);
  105. }
  106. getWorkspaceRoot() {
  107. return this.execSync("git", ["rev-parse", "--show-toplevel"]);
  108. }
  109. execSync(cmd, args) {
  110. return childProcess.execSync(cmd, args, this.execOpts);
  111. }
  112. externalExecSync(cmd, args) {
  113. return childProcess.execSync(cmd, args, this.externalExecOpts);
  114. }
  115. createPatchForCommit(sha) {
  116. let patch = null;
  117. if (this.options.flatten) {
  118. const diff = this.externalExecSync("git", [
  119. "log",
  120. "--reverse",
  121. "--first-parent",
  122. "-p",
  123. "-m",
  124. "--pretty=email",
  125. "--stat",
  126. "--binary",
  127. "-1",
  128. "--color=never",
  129. sha,
  130. // custom git prefixes for accurate parsing of filepaths (#1655)
  131. `--src-prefix=COMPARE_A/`,
  132. `--dst-prefix=COMPARE_B/`,
  133. ]);
  134. const version = this.externalExecSync("git", ["--version"]).replace(/git version /g, "");
  135. patch = `${diff}\n--\n${version}`;
  136. } else {
  137. patch = this.externalExecSync("git", [
  138. "format-patch",
  139. "-1",
  140. sha,
  141. "--stdout",
  142. // custom git prefixes for accurate parsing of filepaths (#1655)
  143. `--src-prefix=COMPARE_A/`,
  144. `--dst-prefix=COMPARE_B/`,
  145. ]);
  146. }
  147. const formattedTarget = this.targetDirRelativeToGitRoot.replace(/\\/g, "/");
  148. const replacement = `$1/${formattedTarget}`;
  149. // Create a patch file for this commit and prepend the target directory
  150. // to all affected files. This moves the git history for the entire
  151. // external repository into the package subdirectory, commit by commit.
  152. return patch
  153. .replace(/^([-+]{3} "?COMPARE_[AB])/gm, replacement)
  154. .replace(/^(diff --git "?COMPARE_A)/gm, replacement)
  155. .replace(/^(diff --git (?! "?COMPARE_B\/).+ "?COMPARE_B)/gm, replacement)
  156. .replace(/^(copy (from|to)) ("?)/gm, `$1 $3${formattedTarget}/`)
  157. .replace(/^(rename (from|to)) ("?)/gm, `$1 $3${formattedTarget}/`);
  158. }
  159. getGitUserFromSha(sha) {
  160. return {
  161. email: this.externalExecSync("git", ["show", "-s", "--format='%ae'", sha]),
  162. name: this.externalExecSync("git", ["show", "-s", "--format='%an'", sha]),
  163. };
  164. }
  165. configureGitUser({ email, name }) {
  166. this.execSync("git", ["config", "user.email", `"${email}"`]);
  167. this.execSync("git", ["config", "user.name", `"${name}"`]);
  168. }
  169. execute() {
  170. this.enableProgressBar();
  171. const tracker = this.logger.newItem("execute");
  172. const mapper = (sha) => {
  173. tracker.info(sha);
  174. const patch = this.createPatchForCommit(sha);
  175. const procArgs = ["am", "-3", "--keep-non-patch"];
  176. if (this.options.preserveCommit) {
  177. this.configureGitUser(this.getGitUserFromSha(sha));
  178. procArgs.push("--committer-date-is-author-date");
  179. }
  180. // Apply the modified patch to the current lerna repository, preserving
  181. // original commit date, author and message.
  182. //
  183. // Fall back to three-way merge, which can help with duplicate commits
  184. // due to merge history.
  185. const proc = childProcess.exec("git", procArgs, this.execOpts);
  186. proc.stdin.end(patch);
  187. return pulseTillDone(proc)
  188. .then(() => {
  189. tracker.completeWork(1);
  190. })
  191. .catch((err) => {
  192. // Getting commit diff to see if it's empty
  193. const diff = this.externalExecSync("git", ["diff", "-s", `${sha}^!`]).trim();
  194. if (diff === "") {
  195. tracker.completeWork(1);
  196. // Automatically skip empty commits
  197. return childProcess.exec("git", ["am", "--skip"], this.execOpts);
  198. }
  199. err.sha = sha;
  200. throw err;
  201. });
  202. };
  203. tracker.addWork(this.commits.length);
  204. return pMapSeries(this.commits, mapper)
  205. .then(() => {
  206. tracker.finish();
  207. if (this.options.preserveCommit) {
  208. this.configureGitUser({
  209. email: this.origGitEmail,
  210. name: this.origGitName,
  211. });
  212. }
  213. this.logger.success("import", "finished");
  214. })
  215. .catch((err) => {
  216. tracker.finish();
  217. if (this.options.preserveCommit) {
  218. this.configureGitUser({
  219. email: this.origGitEmail,
  220. name: this.origGitName,
  221. });
  222. }
  223. this.logger.error("import", `Rolling back to previous HEAD (commit ${this.preImportHead})`);
  224. // Abort the failed `git am` and roll back to previous HEAD.
  225. this.execSync("git", ["am", "--abort"]);
  226. this.execSync("git", ["reset", "--hard", this.preImportHead]);
  227. throw new ValidationError(
  228. "EIMPORT",
  229. dedent`
  230. Failed to apply commit ${err.sha}.
  231. ${err.message}
  232. You may try again with --flatten to import flat history.
  233. `
  234. );
  235. });
  236. }
  237. }
  238. module.exports.ImportCommand = ImportCommand;