diff --git a/node/lib/cmd/merge.js b/node/lib/cmd/merge.js index 3ac14aa1b..34b1e19f7 100644 --- a/node/lib/cmd/merge.js +++ b/node/lib/cmd/merge.js @@ -107,12 +107,14 @@ exports.executeableSubcommand = co.wrap(function *(args) { const colors = require("colors"); - const MergeUtil = require("../util/merge_util"); - const GitUtil = require("../util/git_util"); - const Hook = require("../util/hook"); - const UserError = require("../util/user_error"); + const MergeUtil = require("../util/merge_util"); + const MergeCommon = require("../util/merge_common"); + const GitUtil = require("../util/git_util"); + const Hook = require("../util/hook"); + const Open = require("../util/open"); + const UserError = require("../util/user_error"); - const MODE = MergeUtil.MODE; + const MODE = MergeCommon.MODE; let mode = MODE.NORMAL; if (args.ff + args.continue + args.abort + args.no_ff + args.ff_only > 1) { @@ -167,8 +169,12 @@ Merge of '${commitName}' return GitUtil.editMessage(repo, message); }; const commit = yield repo.getCommit(commitish.id()); - const result = - yield MergeUtil.merge(repo, commit, mode, args.message, editMessage); + const result = yield MergeUtil.merge(repo, + commit, + mode, + Open.SUB_OPEN_OPTION.ALLOW_BARE, + args.message, + editMessage); if (null !== result.errorMessage) { throw new UserError(result.errorMessage); } diff --git a/node/lib/cmd/merge_bare.js b/node/lib/cmd/merge_bare.js index a2fbc75ec..ac4ac20d8 100644 --- a/node/lib/cmd/merge_bare.js +++ b/node/lib/cmd/merge_bare.js @@ -88,17 +88,19 @@ exports.executeableSubcommand = co.wrap(function *(args) { const colors = require("colors"); - const MergeBareUtil = require("../util/merge_bare_util"); + const MergeUtil = require("../util/merge_util"); + const MergeCommon = require("../util/merge_common"); const GitUtil = require("../util/git_util"); const Hook = require("../util/hook"); + const Open = require("../util/open"); const UserError = require("../util/user_error"); const repo = yield GitUtil.getCurrentRepo(); const mode = args.no_ff ? - MergeBareUtil.MODE.NORMAL : - MergeBareUtil.MODE.FORCE_COMMIT; - let ourCommitName = args.ourCommit; - let theirCommitName = args.theirCommit; + MergeCommon.MODE.NORMAL : + MergeCommon.MODE.FORCE_COMMIT; + let ourCommitName = args.ourCommit[0]; + let theirCommitName = args.theirCommit[0]; if (null === ourCommitName || null === theirCommitName) { throw new UserError("Two commits must be given."); } @@ -117,11 +119,12 @@ Could not resolve ${colors.red(theirCommitName)} to a commit.`); const ourCommit = yield repo.getCommit(ourCommitish.id()); const theirCommit = yield repo.getCommit(theirCommitish.id()); - const result = yield MergeBareUtil.merge(repo, - ourCommit, - theirCommit, - mode, - args.message); + const result = yield MergeUtil.merge(repo, + ourCommit, + theirCommit, + mode, + Open.SUB_OPEN_OPTION.FORCE_BARE, + args.message); if (null !== result.errorMessage) { throw new UserError(result.errorMessage); } diff --git a/node/lib/cmd/submodule.js b/node/lib/cmd/submodule.js index 248631df4..dc583d45f 100644 --- a/node/lib/cmd/submodule.js +++ b/node/lib/cmd/submodule.js @@ -236,7 +236,8 @@ Could not find ${colors.red(metaCommittish)} in the meta-repo.`); const subName = paths[0]; const opener = new Open.Opener(repo, null); - const subRepo = yield opener.getSubrepo(subName, false); + const subRepo = yield opener.getSubrepo(subName, + Open.SUB_OPEN_OPTION.FORCE_OPEN); const metaCommit = yield repo.getCommit(metaAnnotated.id()); // Now that we have an open submodule, we can attempt to resolve diff --git a/node/lib/util/add_submodule.js b/node/lib/util/add_submodule.js index 8377c7c04..da44be91a 100644 --- a/node/lib/util/add_submodule.js +++ b/node/lib/util/add_submodule.js @@ -75,7 +75,8 @@ exports.addSubmodule = co.wrap(function *(repo, url, filename, importArg) { repo, filename, url, - templatePath); + templatePath, + false); if (null === importArg) { return subRepo; // RETURN } diff --git a/node/lib/util/cherry_pick_util.js b/node/lib/util/cherry_pick_util.js index a10d86853..c7a64c216 100644 --- a/node/lib/util/cherry_pick_util.js +++ b/node/lib/util/cherry_pick_util.js @@ -116,7 +116,9 @@ exports.changeSubmodules = co.wrap(function *(repo, yield rmrf(name); } else if (yield opener.isOpen(name)) { - const subRepo = yield opener.getSubrepo(name, false); + const subRepo = + yield opener.getSubrepo(name, + Open.SUB_OPEN_OPTION.FORCE_OPEN); yield fetcher.fetchSha(subRepo, name, sub.sha); const commit = yield subRepo.getCommit(sub.sha); yield GitUtil.setHeadHard(subRepo, commit); @@ -446,7 +448,8 @@ exports.pickSubs = co.wrap(function *(metaRepo, opener, metaIndex, subs) { }; const fetcher = yield opener.fetcher(); const pickSub = co.wrap(function *(name) { - const repo = yield opener.getSubrepo(name, false); + const repo = yield opener.getSubrepo(name, + Open.SUB_OPEN_OPTION.FORCE_OPEN); const change = subs[name]; const commitText = "(" + GitUtil.shortSha(change.oldSha) + ".." + GitUtil.shortSha(change.newSha) + "]"; diff --git a/node/lib/util/commit.js b/node/lib/util/commit.js index d180fe01a..a37e7e1b2 100644 --- a/node/lib/util/commit.js +++ b/node/lib/util/commit.js @@ -1021,7 +1021,9 @@ exports.getAmendStatus = co.wrap(function *(repo, options) { old = new Submodule(commit.url, commit.sha); } } - const getRepo = () => opener.getSubrepo(name, false); + const getRepo = + () => opener.getSubrepo(name, + Open.SUB_OPEN_OPTION.FORCE_OPEN); const result = yield exports.getSubmoduleAmendStatus(currentSub, old, diff --git a/node/lib/util/merge_bare_util.js b/node/lib/util/merge_bare_util.js deleted file mode 100644 index ad5d1f38a..000000000 --- a/node/lib/util/merge_bare_util.js +++ /dev/null @@ -1,276 +0,0 @@ -/* - * Copyright (c) 2019, Two Sigma Open Source - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * * Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * - * * Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * * Neither the name of git-meta nor the names of its - * contributors may be used to endorse or promote products derived from - * this software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE - * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF - * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS - * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN - * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) - * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - * POSSIBILITY OF SUCH DAMAGE. - */ -"use strict"; - -const assert = require("chai").assert; -const co = require("co"); -const colors = require("colors"); -const NodeGit = require("nodegit"); - -const CherryPickUtil = require("./cherry_pick_util"); -const ConfigUtil = require("./config_util"); -const DoWorkQueue = require("./do_work_queue"); -const GitUtil = require("./git_util"); -const Open = require("./open"); -const UserError = require("./user_error"); - - -/** - * @enum {MODE} - * Flags to describe what type of merge to do. - */ -const MODE = { - NORMAL : 0, // will do a fast-forward merge when possible - FORCE_COMMIT: 1, // will generate merge commit even could fast-forward -}; - -exports.MODE = MODE; - -/** - * Merge in each submodule and update in memory index accordingly. - * - * @param {NodeGit.Repository} repo - * @param {Open.Opener} opener - * @param {NodeGit.Index} mergeIndex - * @param {Object} subs map from sub name to changes - * @param {String} message commit message - * */ -exports.mergeSubmoduleBare = co.wrap(function *(repo, - opener, - mergeIndex, - subs, - message) { - assert.instanceOf(repo, NodeGit.Repository); - assert.instanceOf(mergeIndex, NodeGit.Index); - assert.isObject(subs); - assert.isString(message); - - const result = { - conflicts: {}, - commits: {}, - }; - const sig = yield ConfigUtil.defaultSignature(repo); - const fetcher = yield opener.fetcher(); - - const mergeSubmodule = co.wrap(function *(name) { - - const subRepo = yield opener.getSubrepo(name, true); - const change = subs[name]; - - const theirSha = change.newSha; - const ourSha = change.ourSha; - yield fetcher.fetchSha(subRepo, name, theirSha); - yield fetcher.fetchSha(subRepo, name, ourSha); - const theirCommit = yield subRepo.getCommit(theirSha); - const ourCommit = yield subRepo.getCommit(ourSha); - - // Commit forwards or backwards are handled at meta repo level. - if ((yield NodeGit.Graph.descendantOf(subRepo, ourSha, theirSha)) || - (yield NodeGit.Graph.descendantOf(subRepo, theirSha, ourSha))) { - return result; // RETURN - } - - console.log(`Submodule ${colors.blue(name)}: merging commit ` + - `${colors.green(theirSha)}.`); - - // Start the merge. - let subIndex = yield NodeGit.Merge.commits(subRepo, - ourCommit, - theirCommit, - null); - - // Abort if conflicted. - if (subIndex.hasConflicts()) { - result.conflicts[name] = theirSha; - return; // RETURN - } - - // Otherwise, finish off the merge. - const treeId = yield subIndex.writeTreeTo(subRepo); - const mergeCommit - = yield subRepo.createCommit(null, - sig, - sig, - message, - treeId, - [ourCommit, theirCommit]); - const mergeSha = mergeCommit.tostrS(); - result.commits[name] = mergeSha; - yield CherryPickUtil.addSubmoduleCommit(mergeIndex, name, mergeSha); - }); - yield DoWorkQueue.doInParallel(Object.keys(subs), mergeSubmodule); - return result; -}); - -/** - * Return a formatted string indicating merge will abort for - * irresolvable conflicts. - */ -function formatConflictsMessage(conflicts) { - let errorMessage = "CONFLICT (content): \n"; - const names = Object.keys(conflicts).sort(); - for (let name of names) { - errorMessage += `Conflicting entries for submodule: ` + - `${colors.red(name)}\n`; - } - errorMessage += "Automatic merge failed\n"; - return errorMessage; -} - -/** - * Merge `theirCommit` into `ourCommit` in the specified `repo` with specific - * commitMessage. Return `null` if there are merge conflicts and the conflicts - * cannot be resolved automatically. Return an object describing merge commits - * otherwise. Use our commit as the merged commit if our commit is up to date, - * use theirs if this is a fast forward merge and return a new merge commit - * otherwise. Throw a `UserError` if there are no commits in common between - * `theirCommit` and `ourCommit`. - * - * @async - * @param {NodeGit.Repository} repo - * @param {NodeGit.Commit} ourCommit - * @param {NodeGit.Commit} theirCommit - * @param {MODE} mode - * @param {String} commitMessage - * @return {Object} - * @return {String|null} return.metaCommit - * @return {Object} return.submoduleCommits map from submodule to commit - * @return {String|null} return.errorMessage - */ -exports.merge = co.wrap(function *(repo, - ourCommit, - theirCommit, - mode, - commitMessage) { - assert.instanceOf(repo, NodeGit.Repository); - assert.instanceOf(ourCommit, NodeGit.Commit); - assert.instanceOf(theirCommit, NodeGit.Commit); - assert.isNumber(mode); - assert.isString(commitMessage); - - const baseCommit = yield GitUtil.getMergeBase(repo, ourCommit, theirCommit); - - if (null === baseCommit) { - throw new UserError(`No commits in common between `+ - `${colors.red(GitUtil.shortSha(ourCommit.id().tostrS()))} and ` + - `${colors.red(GitUtil.shortSha(theirCommit.id().tostrS()))}`); - } - - yield CherryPickUtil.ensureNoURLChanges(repo, ourCommit, theirCommit); - - const result = { - metaCommit: null, - submoduleCommits: {}, - errorMessage: null, - }; - - const ourCommitSha = ourCommit.id().tostrS(); - const theirCommitSha = theirCommit.id().tostrS(); - if (ourCommitSha === theirCommitSha) { - console.log(`Nothing to do for merging ${colors.green(ourCommitSha)}` + - `into itself.`); - result.metaCommit = ourCommitSha; - return result; - } - - const upToDate = yield NodeGit.Graph.descendantOf(repo, - ourCommitSha, - theirCommitSha); - - if (upToDate) { - console.log(`${colors.green(ourCommitSha)} is up-to-date.`); - result.metaCommit = ourCommitSha; - return result; // RETURN - } - - const canFF = yield NodeGit.Graph.descendantOf(repo, - theirCommitSha, - ourCommitSha); - - if (canFF && MODE.FORCE_COMMIT !== mode) { - console.log(`Fast-forward merge: `+ - `${colors.green(theirCommitSha)} is a descendant of `+ - `${colors.green(ourCommitSha)}`); - result.metaCommit = theirCommitSha; - return result; // RETURN - } - - const sig = yield ConfigUtil.defaultSignature(repo); - - const changeIndex - = yield NodeGit.Merge.commits(repo, ourCommit, theirCommit, []); - const changes - = yield CherryPickUtil.computeChangesBetweenTwoCommits(repo, - changeIndex, - ourCommit, - theirCommit); - if (Object.keys(changes.conflicts).length > 0) { - result.errorMessage = formatConflictsMessage(changes.conflicts); - return result; // RETURN - } - const opener = new Open.Opener(repo, null); - - const makeMetaCommit = co.wrap(function *(indexToWrite) { - console.log(`Merging meta-repo commits ` + - `${colors.green(ourCommitSha)} and ` + - `${colors.green(theirCommitSha)}`); - - const id = yield indexToWrite.writeTreeTo(repo); - // And finally, commit it. - const metaCommit = yield repo.createCommit(null, - sig, - sig, - commitMessage, - id, - [ourCommit, theirCommit]); - result.metaCommit = metaCommit.tostrS(); - console.log(`Merge commit created at ` + - `${colors.green(result.metaCommit)}.`); - }); - - - yield CherryPickUtil.changeSubmodulesBare(repo, - changeIndex, - changes.simpleChanges); - const merges = yield exports.mergeSubmoduleBare(repo, - opener, - changeIndex, - changes.changes, - commitMessage); - if (Object.keys(merges.conflicts).length > 0) { - result.errorMessage = formatConflictsMessage(merges.conflicts); - } else { - yield makeMetaCommit(changeIndex); - result.submoduleCommits = merges.commits; - } - return result; - -}); diff --git a/node/lib/util/merge_common.js b/node/lib/util/merge_common.js new file mode 100644 index 000000000..520e68d2d --- /dev/null +++ b/node/lib/util/merge_common.js @@ -0,0 +1,325 @@ +/* + * Copyright (c) 2019, Two Sigma Open Source + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * * Neither the name of git-meta nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +"use strict"; + +const assert = require("chai").assert; +const CherryPickUtil = require("./cherry_pick_util"); +const co = require("co"); +const ConfigUtil = require("./config_util"); +const GitUtil = require("./git_util"); +const NodeGit = require("nodegit"); +const Open = require("./open"); +const UserError = require("./user_error"); + +/** + * @enum {MODE} + * Flags to describe what type of merge to do. + */ +const MODE = { + NORMAL : 0, // will do a fast-forward merge when possible + FF_ONLY : 1, // will fail unless fast-forward merge is possible + FORCE_COMMIT: 2, // will generate merge commit even could fast-forward +}; + +exports.MODE = MODE; + +/** + * @class MergeContext + * A class that manages the necessary objects for merging. + */ +class MergeContext { + /** + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit|null} ourCommit + * @param {NodeGit.Commit} theirCommit + * @param {MergeCommon.MODE} mode + * @param {Open.SUB_OPEN_OPTION} openOption + * @param {String|null} commitMessage + * @param {() -> Promise(String)} editMessage + */ + constructor(metaRepo, + ourCommit, + theirCommit, + mode, + openOption, + commitMessage, + editMessage) { + assert.instanceOf(metaRepo, NodeGit.Repository); + if (null !== ourCommit) { + assert.instanceOf(ourCommit, NodeGit.Commit); + } + assert.instanceOf(theirCommit, NodeGit.Commit); + assert.isNumber(mode); + assert.isNumber(openOption); + if (null !== commitMessage) { + assert.isString(commitMessage); + } + assert.isFunction(editMessage); + this.d_metaRepo = metaRepo; + this.d_ourCommit = ourCommit; + this.d_theirCommit = theirCommit; + this.d_mode = mode; + this.d_openOption = openOption; + this.d_commitMessage = commitMessage; + this.d_editMessage = editMessage; + this.d_opener = new Open.Opener(metaRepo, null); + this.d_changeIndex = null; + this.d_changes = null; + this.d_conflictsMessage = ""; + } + + /** + * @property {Boolean} forceBare if working directory is disabled + */ + get forceBare() { + return Open.SUB_OPEN_OPTION.FORCE_BARE === this.d_openOption; + } + + /** + * @property {NodeGit.Repository} + */ + get metaRepo() { + return this.d_metaRepo; + } + + /** + * @property {Opener} + */ + get opener() { + return this.d_opener; + } + + /** + * @property {NodeGit.Commit} + */ + get theirCommit() { + return this.d_theirCommit; + } + + /** + * @property {Open.SUB_OPEN_OPTION} + */ + get openOption() { + return this.d_openOption; + } + + /** + * @property {MODE} + */ + get mode() { + return this.d_mode; + } + + /** + * Reference to update when creating the merge commit + * @property {String | null} + */ + get refToUpdate() { + return this.forceBare ? null : "HEAD"; + } +} + +/** + * @async + * @return {Object} return from sub name to `SubmoduleChange` + * @return {Object} return.simpleChanges from sub name to `Submodule` + * @return {Object} return.changes from sub name to `Submodule` + * @return {Object} return.conflicts from sub name to `Conflict` + */ +MergeContext.prototype.getChanges = co.wrap(function *() { + if (null === this.d_changes) { + this.d_changes = + yield CherryPickUtil.computeChangesBetweenTwoCommits( + this.d_metaRepo, + yield this.getChangeIndex(), + yield this.getOurCommit(), + this.d_theirCommit); + } + return this.d_changes; +}); + +/** + * @async + * @return {NodeGit.Commit} return left side merge commit + */ +MergeContext.prototype.getOurCommit = co.wrap(function *() { + if (null !== this.d_ourCommit) { + return this.d_ourCommit; + } + if (this.forceBare) { + throw new UserError("Left side merge commit is undefined!"); + } + this.d_ourCommit = yield this.d_metaRepo.getHeadCommit(); + return this.d_ourCommit; +}); + +/** + * return an index object that contains the merge changes and whose tree + * representation will be flushed to disk. + * @async + * @return {NodeGit.Index} + */ +MergeContext.prototype.getIndexToWrite = co.wrap(function *() { + return this.forceBare ? + yield this.getChangeIndex() : + yield this.d_metaRepo.index(); +}); + +/** + * in memeory index object by merging `ourCommit` and `theirCommit` + * @return {NodeGit.Index} + */ +MergeContext.prototype.getChangeIndex = co.wrap(function *() { + if (null !== this.d_changeIndex) { + return this.d_changeIndex; + } + this.d_changeIndex = yield NodeGit.Merge.commits(this.d_metaRepo, + yield this.getOurCommit(), + this.d_theirCommit, + []); + return this.d_changeIndex; +}); + +/** + * Return the previously set/built commit message, or use the callback to + * build commit messsage. Once built, the commit message will be cached. + * + * @async + * @return {String} commit message + */ +MergeContext.prototype.getCommitMessage = co.wrap(function *() { + const message = (null === this.d_commitMessage) ? + GitUtil.stripMessage(yield this.d_editMessage()) : + this.d_commitMessage; + if ("" === message) { + console.log("Empty commit message."); + } + return message; +}); + +/** + * @async + * @returns {NodeGit.Signature} + */ +MergeContext.prototype.getSig = co.wrap(function *() { + return yield ConfigUtil.defaultSignature(this.d_metaRepo); +}); + +/** + * @async + * @returns {SubmoduleFetcher} + */ +MergeContext.prototype.getFetcher = co.wrap(function *() { + return yield this.d_opener.fetcher(); +}); + +exports.MergeContext = MergeContext; + +/** + * A class that tracks result from merging steps. + */ +class MergeStepResult { + + /** + * @param {String | null} infoMessage message to display to user + * @param {String | null} errorMessage message signifies a fatal error + * @param {String | null} finishSha commit sha indicating end of merge + * @param {Object} submoduleCommits map from submodule to commit + */ + constructor(infoMessage, errorMessage, finishSha, submoduleCommits) { + this.d_infoMessage = infoMessage; + this.d_errorMessage = errorMessage; + this.d_finishSha = finishSha; + this.d_submoduleCommits = submoduleCommits; + } + + /** + * @property {String|null} + */ + get errorMessage() { + return this.d_errorMessage; + } + + /** + * @property {String|null} + */ + get infoMessage() { + return this.d_infoMessage; + } + + /** + * @property {String|null} + */ + get finishSha() { + return this.d_finishSha; + } + + /** + * @property {Object} map from submodule to commit + */ + get submoduleCommits() { + if (null === this.d_submoduleCommits) { + return {}; + } + return this.d_submoduleCommits; + } + + /** + * @static + * @return {MergeStepResult} empty result object + */ + static empty() { + return new MergeStepResult(null, null, null, {}); + } + + /** + * A merge result that signifies we need to abort current merging process. + * + * @static + * @param {MergeStepResult} msg error message + */ + static error(msg) { + return new MergeStepResult(null, msg, null, {}); + } + /** + * A merge result that does not have any submodule commit. Only a finishing + * sha at the meta repo level will be returned. + * + * @static + * @param {String} infoMessage + * @param {String} finishSha meta repo commit sha + */ + static justMeta(infoMessage, finishSha) { + return new MergeStepResult(infoMessage, null, finishSha, {}); + } +} + +exports.MergeStepResult = MergeStepResult; diff --git a/node/lib/util/merge_util.js b/node/lib/util/merge_util.js index 81c1de35e..bb3affe2e 100644 --- a/node/lib/util/merge_util.js +++ b/node/lib/util/merge_util.js @@ -41,18 +41,25 @@ const CherryPickUtil = require("./cherry_pick_util"); const ConfigUtil = require("./config_util"); const DoWorkQueue = require("./do_work_queue"); const GitUtil = require("./git_util"); +const MergeCommon = require("./merge_common"); const Open = require("./open"); const RepoStatus = require("./repo_status"); const SequencerState = require("./sequencer_state"); const SequencerStateUtil = require("./sequencer_state_util"); const SparseCheckoutUtil = require("./sparse_checkout_util"); const StatusUtil = require("./status_util"); +const SubmoduleChange = require("./submodule_change"); +const SubmoduleFetcher = require("./submodule_fetcher"); const SubmoduleRebaseUtil = require("./submodule_rebase_util"); const SubmoduleUtil = require("./submodule_util"); const UserError = require("./user_error"); -const CommitAndRef = SequencerState.CommitAndRef; -const MERGE = SequencerState.TYPE.MERGE; +const CommitAndRef = SequencerState.CommitAndRef; +const MERGE = SequencerState.TYPE.MERGE; +const MergeContext = MergeCommon.MergeContext; +const MergeStepResult = MergeCommon.MergeStepResult; +const MODE = MergeCommon.MODE; +const SUB_OPEN_OPTION = Open.SUB_OPEN_OPTION; /** * If there is a sequencer with a merge in the specified `path` return it, @@ -85,34 +92,39 @@ const checkForMerge = co.wrap(function *(path) { }); /** - * @enum {MODE} - * Flags to describe what type of merge to do. + * Return a formatted string indicating merge will abort for + * irresolvable conflicts. + * + * @param {Object} conflicts map from name to commit causing conflict + * @return {String} conflict message */ -const MODE = { - NORMAL : 0, // will do a fast-forward merge when possible - FF_ONLY : 1, // will fail unless fast-forward merge is possible - FORCE_COMMIT: 2, // will generate merge commit even could fast-forward +exports.formatConflictsMessage = function(conflicts) { + if (0 === Object.keys(conflicts).length) { + return ""; + } + let errorMessage = "CONFLICT (content): \n"; + const names = Object.keys(conflicts).sort(); + for (let name of names) { + errorMessage += `Conflicting entries for submodule: ` + + `${colors.red(name)}\n`; + } + errorMessage += "Automatic merge failed\n"; + return errorMessage; }; -exports.MODE = MODE; /** * Perform a fast-forward merge in the specified `repo` to the - * specified `commit`. When generating a merge commit, use the - * optionally specified `message`. The behavior is undefined unless - * `commit` is different from but descendant of the HEAD commit in - * `repo`. + * specified `commit`. The behavior is undefined unless `commit` + * is different from but descendant of the HEAD commit in `repo`. * * @param {NodeGit.Repository} repo - * @param {MODE} mode + * @param {MergeCommon.MODE} mode * @param {NodeGit.Commit} commit - * @param {String|null} message - */ -exports.fastForwardMerge = co.wrap(function *(repo, commit, message) { +exports.fastForwardMerge = co.wrap(function *(repo, commit) { assert.instanceOf(repo, NodeGit.Repository); assert.instanceOf(commit, NodeGit.Commit); - assert.isString(message); // Remember the current branch; the checkoutCommit function will move it. @@ -129,294 +141,492 @@ exports.fastForwardMerge = co.wrap(function *(repo, commit, message) { }); /** - * Merge the specified `subs` in the specified `repo` having the specified - * `index`. Stage submodule commits in `metaRepo`. Return an object - * describing any commits that were generated and conflicted commits. Use the - * specified `opener` to acces submodule repos. Use the specified `message` to - * write commit messages. - * @param {NodeGit.Repository} metaRepo - * @param {Open.Opener} opener - * @param {NodeGit.Index} metaIndex - * @param {Object} subs map from name to SubmoduleChange + * Write tree representation of the index to the disk, create a commit + * from the tree and update reference if needed. + * + * @async + * @param {NodeGit.Repository} repo + * @param {NodeGit.Index} indexToWrite + * @param {NodeGit.Commit | null} ourCommit + * @param {NodeGit.Commit} theirCommit + * @param {String} commitMessage + * @param {String | null} refToUpdate * @return {Object} - * @return {Object} return.commits map from name to map from new to old ids - * @return {Object} return.conflicts map from name to commit causing conflict + * @return {String|null} return.infoMessage informative message + * @return {String|null} return.metaCommit in case no further merge operation + * is required, this is the merge commit. */ -const mergeSubmodules = co.wrap(function *(repo, - opener, - index, - subs, - message) { - assert.instanceOf(repo, NodeGit.Repository); - assert.instanceOf(opener, Open.Opener); - assert.instanceOf(index, NodeGit.Index); - assert.isObject(subs); - assert.isString(message); - - const result = { - conflicts: {}, - commits: {}, - }; +exports.makeMetaCommit = co.wrap(function *(repo, + indexToWrite, + ourCommit, + theirCommit, + commitMessage, + refToUpdate) { + const id = yield indexToWrite.writeTreeTo(repo); const sig = yield ConfigUtil.defaultSignature(repo); - const fetcher = yield opener.fetcher(); - const mergeSubmodule = co.wrap(function *(name) { - const subRepo = yield opener.getSubrepo(name, false); - const change = subs[name]; - const fromSha = change.newSha; - yield fetcher.fetchSha(subRepo, name, fromSha); - const subHead = yield subRepo.getHeadCommit(); - const headSha = subHead.id().tostrS(); - const fromCommit = yield subRepo.getCommit(fromSha); + const metaCommit = yield repo.createCommit(refToUpdate, + sig, + sig, + commitMessage, + id, + [ourCommit, theirCommit]); + const commitSha = metaCommit.tostrS(); + return { + metaCommit: commitSha, + infoMessage: `Merge commit created at ` + + `${colors.green(commitSha)}.`, + }; +}); - // See if up-to-date +/** + * Merge the specified `subName` and update the in memeory `metaindex`. + * + * @async + * @param {NodeGit.Index} metaIndex index of the meta repo + * @param {String} subName submodule name + * @param {SubmoduleChange} change specifies the commits to merge + * @param {String} message commit message + * @param {SubmoduleFetcher} fetcher helper to fetch commits in the sub + * @param {NodeGit.Signature} sig default signature + * @param {Open.Opener} opener helper to open a sub + * @param {SUB_OPEN_OPTION} openOption opention to open a sub + * @return {Object} + * @return {String|null} return.mergeSha + * @return {String|null} return.conflictSha + */ +exports.mergeSubmodule = co.wrap(function *(metaIndex, + subName, + change, + message, + opener, + fetcher, + sig, + openOption) { + assert.instanceOf(metaIndex, NodeGit.Index); + assert.isString(subName); + assert.instanceOf(change, SubmoduleChange); + assert.isString(message); + assert.instanceOf(opener, Open.Opener); + assert.instanceOf(fetcher, SubmoduleFetcher); + assert.instanceOf(sig, NodeGit.Signature); + assert.isNumber(openOption); + + let subRepo = yield opener.getSubrepo(subName, openOption); + + const isHalfOpened = yield opener.isHalfOpened(subName); + const forceBare = openOption === SUB_OPEN_OPTION.FORCE_BARE; + const theirSha = change.newSha; + yield fetcher.fetchSha(subRepo, subName, theirSha); + if (null !== change.ourSha) { + yield fetcher.fetchSha(subRepo, subName, change.ourSha); + } + const theirCommit = yield subRepo.getCommit(theirSha); - if (yield NodeGit.Graph.descendantOf(subRepo, headSha, fromSha)) { - return; // RETURN - } + const ourSha = change.ourSha; + const ourCommit = yield subRepo.getCommit(ourSha); + + const result = { + mergeSha: null, + conflictSha: null, + }; - // See if can fast-forward + // See if up-to-date + if (yield NodeGit.Graph.descendantOf(subRepo, ourSha, theirSha)) { + return result; // RETURN + } - if (yield NodeGit.Graph.descendantOf(subRepo, fromSha, headSha)) { - yield GitUtil.setHeadHard(subRepo, fromCommit); - yield index.addByPath(name); - return result; // RETURN + // See if can fast-forward and update HEAD if the submodule is opened. + if (yield NodeGit.Graph.descendantOf(subRepo, theirSha, ourSha)) { + if (isHalfOpened) { + yield CherryPickUtil.addSubmoduleCommit(metaIndex, + subName, + theirSha); + } else { + yield GitUtil.setHeadHard(subRepo, theirCommit); + yield metaIndex.addByPath(subName); } + return result; // RETURN + } - console.log(`Submodule ${colors.blue(name)}: merging commit \ -${colors.green(fromSha)}.`); - - // Start the merge. - - let subIndex = yield NodeGit.Merge.commits(subRepo, - subHead, - fromCommit, - null); + console.log(`Submodule ${colors.blue(subName)}: merging commit \ +${colors.green(theirSha)}.`); + // Start the merge. + let subIndex = yield NodeGit.Merge.commits(subRepo, + ourCommit, + theirCommit, + null); + if (!isHalfOpened) { yield NodeGit.Checkout.index(subRepo, subIndex, { checkoutStrategy: NodeGit.Checkout.STRATEGY.FORCE, - }); - - // Abort if conflicted. + }); + } - if (subIndex.hasConflicts()) { - const seq = new SequencerState({ - type: MERGE, - originalHead: new CommitAndRef(subHead.id().tostrS(), null), - target: new CommitAndRef(fromSha, null), - currentCommit: 0, - commits: [fromSha], - message: message, + // handle conflicts: + // 1. if force bare, bubble up conflicts and direct return + // 2. if this is interactive merge and bare is allowed, open submodule, + // record conflicts and then bubble up the conflicts. + // 3. if bare is not allowed, record conflicts and bubble up conflicts + if (subIndex.hasConflicts()) { + if (forceBare) { + result.conflictSha = theirSha; + return result; + } + // fully open the submodule if conflict for manual resolution + if (isHalfOpened) { + opener.clearAbsorbedCache(subName); + subRepo = yield opener.getSubrepo(subName, + SUB_OPEN_OPTION.FORCE_OPEN); + yield NodeGit.Checkout.index(subRepo, subIndex, { + checkoutStrategy: NodeGit.Checkout.STRATEGY.FORCE, }); - yield SequencerStateUtil.writeSequencerState(subRepo.path(), seq); - result.conflicts[name] = fromSha; - return; // RETURN } + const seq = new SequencerState({ + type: MERGE, + originalHead: new CommitAndRef(ourCommit.id().tostrS(), null), + target: new CommitAndRef(theirSha, null), + currentCommit: 0, + commits: [theirSha], + message: message, + }); + yield SequencerStateUtil.writeSequencerState(subRepo.path(), seq); + result.conflictSha = theirSha; + return result; // RETURN + } - // Otherwise, finish off the merge. - + // Otherwise, finish off the merge. + if (!isHalfOpened) { subIndex = yield subRepo.index(); - const treeId = yield subIndex.writeTreeTo(subRepo); - const mergeCommit = yield subRepo.createCommit("HEAD", - sig, - sig, - message, - treeId, - [subHead, fromCommit]); - result.commits[name] = mergeCommit.tostrS(); + } + const refToUpdate = isHalfOpened ? null : "HEAD"; + const treeId = yield subIndex.writeTreeTo(subRepo); + const mergeCommit = yield subRepo.createCommit(refToUpdate, + sig, + sig, + message, + treeId, + [ourCommit, theirCommit]); + const mergeSha = mergeCommit.tostrS(); + result.mergeSha = mergeSha; + if (isHalfOpened) { + yield CherryPickUtil.addSubmoduleCommit(metaIndex, subName, mergeSha); + } else { + yield metaIndex.addByPath(subName); // Clean up the conflict for this submodule and stage our change. - - yield index.addByPath(name); - yield index.conflictRemove(name); - }); - yield DoWorkQueue.doInParallel(Object.keys(subs), mergeSubmodule); + yield metaIndex.conflictRemove(subName); + } return result; }); /** - * Merge the specified `commit` in the specified `repo` having the specified - * `status`, using the specified `mode` to control whether or not a merge - * commit will be generated. Return `null` if the repository is up-to-date, or - * an object describing generated commits otherwise. If the optionally - * specified `commitMessage` is provided, use it as the commit message for any - * generated merge commit; otherwise, use the specified `editMessage` promise - * to request a message. Throw a `UserError` exception if a fast-forward merge - * is requested and cannot be completed. Throw a `UserError` if there are - * conflicts, or if local modifications prevent the merge from happening. - * Throw a `UserError` if there are no commits in common between `commit` and - * the HEAD commit of `repo`. - * + * Perform preparation work before merge, including + * 1. locate merge base + * 2. ensureNoURLChanges see also {CherryPickUtil.ensureNoURLChanges} + * 3. check if working dir is clean (non-bare repo) + * 3. check if two merging commits are the same or if their commit + * is an ancestor of ours, both cases are no-op. + * * @async - * @param {NodeGit.Repository} repo - * @param {NodeGit.Commit} commit - * @param {MODE} mode - * @param {String|null} commitMessage - * @param {() -> Promise(String)} editMessage - * @return {Object} - * @return {String|null} return.metaCommit - * @return {Object} return.submoduleCommits map from submodule to commit - * @return {String|null} return.errorMessage + * @param {MergeContext} context + * @return {MergeStepResult} */ -exports.merge = co.wrap(function *(repo, - commit, - mode, - commitMessage, - editMessage) { - assert.instanceOf(repo, NodeGit.Repository); - assert.isNumber(mode); - assert.instanceOf(commit, NodeGit.Commit); - if (null !== commitMessage) { - assert.isString(commitMessage); - } - assert.isFunction(editMessage); +const mergeStepPrepare = co.wrap(function *(context) { + assert.instanceOf(context, MergeContext); - const head = yield repo.getHeadCommit(); - const baseCommit = yield GitUtil.getMergeBase(repo, commit, head); + let errorMessage = null; + let infoMessage = null; + + const forceBare = context.forceBare; + const metaRepo = context.metaRepo; + const ourCommit = yield context.getOurCommit(); + const ourCommitSha = ourCommit.id().tostrS(); + const theirCommit = context.theirCommit; + const theirCommitSha = theirCommit.id().tostrS(); + + const baseCommit = + yield GitUtil.getMergeBase(metaRepo, theirCommit, ourCommit); if (null === baseCommit) { - throw new UserError(`\ -No commits in common with \ -${colors.red(GitUtil.shortSha(commit.id().tostrS()))}`); + errorMessage = "No commits in common with" + + `${colors.red(GitUtil.shortSha(ourCommitSha))} and ` + + `${colors.red(GitUtil.shortSha(theirCommitSha))}`; + return MergeStepResult.error(errorMessage); // RETURN } - yield CherryPickUtil.ensureNoURLChanges(repo, commit, baseCommit); - - const result = { - metaCommit: null, - submoduleCommits: {}, - errorMessage: null, - }; + yield CherryPickUtil.ensureNoURLChanges(metaRepo, theirCommit, baseCommit); - const status = yield StatusUtil.getRepoStatus(repo); - StatusUtil.ensureReady(status); - if (!status.isDeepClean(false)) { - // TODO: Git will refuse to run if there are staged changes, but will - // attempt a merge if there are just workdir changes. We should - // support this in the future, but it basically requires us to dry-run - // the merges in all the submodules. - - throw new UserError(`\ -The repository has uncommitted changes. Please stash or commit them before -running merge.`); + if (!forceBare) { + const status = yield StatusUtil.getRepoStatus(metaRepo); + const statusError = StatusUtil.checkReadiness(status); + if (null !== statusError) { + return MergeStepResult.error(statusError); // RETURN + } + if (!status.isDeepClean(false)) { + errorMessage = "The repository has uncommitted changes. "+ + "Please stash or commit them before running merge."; + return MergeStepResult.error(errorMessage); // RETURN + } } - const commitSha = commit.id().tostrS(); - - if (head.id().tostrS() === commit.id().tostrS()) { - console.log("Nothing to do."); - return result; + if (ourCommitSha === theirCommitSha) { + infoMessage = "Nothing to do."; + return MergeStepResult.justMeta(infoMessage, theirCommit); // RETURN } - const upToDate = yield NodeGit.Graph.descendantOf(repo, - head.id().tostrS(), - commitSha); + const upToDate = yield NodeGit.Graph.descendantOf(metaRepo, + ourCommitSha, + theirCommitSha); if (upToDate) { - console.log("Up-to-date."); - return result; + return MergeStepResult.justMeta(infoMessage, ourCommitSha); // RETURN } + return MergeStepResult.empty(); +}); - const canFF = yield NodeGit.Graph.descendantOf(repo, - commitSha, - head.id().tostrS()); - let message = ""; - if (!canFF || MODE.FORCE_COMMIT === mode) { - if (null === commitMessage) { - const raw = yield editMessage(); - message = GitUtil.stripMessage(raw); - if ("" === message) { - console.log("Empty commit message."); - return result; - } - } - else { - message = commitMessage; +/** + * Perform a fast-forward merge in the specified `repo` to the + * specified `commit`. When generating a merge commit, use the + * optionally specified `message`. The behavior is undefined unless + * `commit` is different from but descendant of the HEAD commit in + * `repo`. + * + * @async + * @param {MergeContext} content + * @return {MergeStepResult} + */ +const mergeStepFF = co.wrap(function *(context) { + assert.instanceOf(context, MergeContext); + + const forceBare = context.forceBare; + const metaRepo = context.metaRepo; + const mode = context.mode; + const ourCommit = yield context.getOurCommit(); + const ourCommitSha = ourCommit.id().tostrS(); + const theirCommit = context.theirCommit; + const theirCommitSha = theirCommit.id().tostrS(); + + let errorMessage = null; + let infoMessage = null; + + const canFF = yield NodeGit.Graph.descendantOf(metaRepo, + theirCommitSha, + ourCommitSha); + if (MODE.FF_ONLY === mode && !canFF) { + errorMessage = "The meta-repository cannot be fast-forwarded " + + `to ${colors.red(theirCommitSha)}.`; + return MergeStepResult.error(errorMessage); // RETURN + } else if (canFF && MODE.FORCE_COMMIT !== mode) { + infoMessage = `Fast-forwarding meta repo from `+ + `${colors.green(ourCommitSha)} to `+ + `${colors.green(theirCommitSha)}`; + if (!forceBare) { + yield exports.fastForwardMerge(metaRepo, theirCommit); } + return MergeStepResult.justMeta(infoMessage, theirCommitSha); // RETURN } + return MergeStepResult.empty(); +}); - if (MODE.FF_ONLY === mode && !canFF) { - throw new UserError(`The meta-repository cannot be fast-forwarded to \ -${colors.red(commitSha)}.`); - } - else if (canFF && MODE.FORCE_COMMIT !== mode) { - console.log(`Fast-forwarding meta-repo to ${colors.green(commitSha)}.`); - - result.metaCommit = commitSha; - yield exports.fastForwardMerge(repo, - commit, - message); - return result; +/** + * @async + * @param {MergeContext} context + * @return {MergeStepResult} + */ +const mergeStepMergeSubmodules = co.wrap(function *(context) { + assert.instanceOf(context, MergeContext); + + const changes = yield context.getChanges(); + const fetcher = yield context.getFetcher(); + const forceBare = context.forceBare; + const index = yield context.getIndexToWrite(); + const opener = context.opener; + const openOption = context.openOption; + const ourCommit = yield context.getOurCommit(); + const ourCommitSha = ourCommit.id().tostrS(); + const refToUpdate = context.refToUpdate; + const repo = context.metaRepo; + const sig = yield context.getSig(); + const theirCommit = context.theirCommit; + const theirCommitSha = theirCommit.id().tostrS(); + + let conflictMessage = ""; + // abort merge if conflicted under FROCE_BARE mode + if (forceBare && Object.keys(changes.conflicts).length > 0) { + conflictMessage = exports.formatConflictsMessage(changes.conflicts); + return MergeStepResult.error(conflictMessage); // RETURN } - const sig = yield ConfigUtil.defaultSignature(repo); - - const changeIndex = yield NodeGit.Merge.commits(repo, head, commit, []); - const changes = - yield CherryPickUtil.computeChanges(repo, changeIndex, commit); - const index = yield repo.index(); - const opener = new Open.Opener(repo, null); - - // Perform simple changes that don't require picks -- addition, deletions, - // and fast-forwards. - - yield CherryPickUtil.changeSubmodules(repo, - opener, - index, - changes.simpleChanges); - - // Render any conflicts - - let errorMessage = - yield CherryPickUtil.writeConflicts(repo, index, changes.conflicts); - - // Then do the submodule merges - - const merges = - yield mergeSubmodules(repo, opener, index, changes.changes, message); - result.submoduleCommits = merges.commits; - const conflicts = merges.conflicts; + // deal with simple changes + if (forceBare) { + yield CherryPickUtil.changeSubmodulesBare(repo, + index, + changes.simpleChanges); + } else { + yield CherryPickUtil.changeSubmodules(repo, + opener, + index, + changes.simpleChanges); + } - yield CherryPickUtil.closeSubs(opener, merges); + const message = yield context.getCommitMessage(); + if ("" === message) { + return MergeStepResult.empty(); + } - Object.keys(conflicts).sort().forEach(name => { - errorMessage += SubmoduleRebaseUtil.subConflictErrorMessage(name); + const merges = { + conflicts: {}, + commits: {}, + }; + const mergeSubmoduleRunner = co.wrap(function *(subName) { + const subResult = + yield exports.mergeSubmodule(index, + subName, + changes.changes[subName], + message, + opener, + fetcher, + sig, + openOption); + if (null !== subResult.mergeSha) { + merges.commits[subName] = subResult.mergeSha; + } + if (null !== subResult.conflictSha) { + merges.conflicts[subName] = subResult.conflictSha; + } }); - - // We must write the index here or the staging we've done earlier will go - // away. - yield SparseCheckoutUtil.writeMetaIndex(repo, index); - - if ("" !== errorMessage) { - // We're about to fail due to conflict. First, record that there is a - // merge in progress so that we can continue or abort it later. - // TODO: some day when we make use of it, write the ref name for HEAD - - const seq = new SequencerState({ - type: MERGE, - originalHead: new CommitAndRef(head.id().tostrS(), null), - target: new CommitAndRef(commit.id().tostrS(), null), - currentCommit: 0, - commits: [commit.id().tostrS()], - message: message, - }); - yield SequencerStateUtil.writeSequencerState(repo.path(), seq); - result.errorMessage = errorMessage; + yield DoWorkQueue.doInParallel(Object.keys(changes.changes), + mergeSubmoduleRunner); + // Render any conflicts + if (forceBare) { + conflictMessage = exports.formatConflictsMessage(merges.conflicts); } else { + conflictMessage = + yield CherryPickUtil.writeConflicts(repo, + index, + changes.conflicts); + /// + Object.keys(merges.conflicts).sort().forEach(name => { + conflictMessage += + SubmoduleRebaseUtil.subConflictErrorMessage(name); + }); + } - console.log(`Merging meta-repo commit ${colors.green(commitSha)}.`); + // finishing merge for interactive merges + // 1. close unnecessaried opened submodules + // 2. write the index to the meta repo or the staging we've done earlier + // will go away + if (!forceBare) { + yield CherryPickUtil.closeSubs(opener, merges); + yield SparseCheckoutUtil.writeMetaIndex(repo, index); + } - const id = yield index.writeTreeTo(repo); + if ("" !== conflictMessage) { + // For interactive merge, record that there is a merge in progress so + // that we can continue or abort it later + if (!forceBare) { + const seq = new SequencerState({ + type: MERGE, + originalHead: new CommitAndRef(ourCommitSha, null), + target: new CommitAndRef(theirCommitSha, null), + currentCommit: 0, + commits: [theirCommitSha], + message: message, + }); + yield SequencerStateUtil.writeSequencerState(repo.path(), seq); + } + return MergeStepResult.error(conflictMessage); + } - // And finally, commit it. + let infoMessage = `Merging meta-repo commits ` + + `${colors.green(ourCommitSha)} and ` + + `${colors.green(theirCommitSha)}`; + const metaCommitRet = yield exports.makeMetaCommit(repo, + index, + ourCommit, + theirCommit, + message, + refToUpdate); + infoMessage += "\n" + metaCommitRet.infoMessage; + return new MergeStepResult(infoMessage, + null, + metaCommitRet.metaCommit, + merges.commits); +}); - const metaCommit = yield repo.createCommit("HEAD", - sig, - sig, - message, - id, - [head, commit]); - result.metaCommit = metaCommit.tostrS(); +/** + * Merge `theirCommit` into `ourCommit` in the specified `repo` with specific + * commitMessage. using the specified `mode` to control whether or not a merge + * commit will be generated. `openOption` tells if creating a submodule under + * the working directory is forbidden (bare repo), is not encouraged or is + * always enforced. Commit message is either provided from `commitMessage` + * or from the `editMessage` callback. + * + * Return an object describing the resulting commit which can be: + * 1. our commit if our commit is up to date + * 2. their commit if this is a fast forward merge and FF is allowed + * 3. new commit whose parents are `ourCommit` and `theirCommit` + * + * Throw a `UserError` if: + * 1. there are no commits in common between `theirCommit` and `ourCommit`. + * 2. the repository has uncommitted changes + * 3. FF is enforced but not possible + * 4. FORCE_BARE is enabled, but there are merging conflicts + * + * @async + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit|null} ourCommit + * @param {NodeGit.Commit} theirCommit + * @param {MergeCommon.MODE} mode + * @param {Open.SUB_OPEN_OPTION} openOption + * @param {String|null} commitMessage + * @param {() -> Promise(String)} editMessage + * @return {Object} + * @return {String|null} return.metaCommit + * @return {Object} return.submoduleCommits map from submodule to commit + * @return {String|null} return.errorMessage + */ +exports.merge = co.wrap(function *(repo, + ourCommit, + theirCommit, + mode, + openOption, + commitMessage, + editMessage) { + // pack and validate merging objects + const context = new MergeContext(repo, + ourCommit, + theirCommit, + mode, + openOption, + commitMessage, + editMessage); + // + const result = { + metaCommit: null, + submoduleCommits: {}, + errorMessage: null, + }; + const mergeAsyncSteps = [ + mergeStepPrepare, + mergeStepFF, + mergeStepMergeSubmodules, + ]; + + for (const asyncStep of mergeAsyncSteps) { + const ret = yield asyncStep(context); + if (null !== ret.infoMessage) { + console.log(ret.infoMessage); + } + if (null !== ret.errorMessage) { + throw new UserError(ret.errorMessage); + } + if (null !== ret.finishSha) { + result.metaCommit = ret.finishSha; + result.submoduleCommits = ret.submoduleCommits; + return result; + } } return result; }); diff --git a/node/lib/util/open.js b/node/lib/util/open.js index 63add574c..021c114cc 100644 --- a/node/lib/util/open.js +++ b/node/lib/util/open.js @@ -43,6 +43,17 @@ const SubmoduleUtil = require("./submodule_util"); const SubmoduleConfigUtil = require("./submodule_config_util"); const SubmoduleFetcher = require("./submodule_fetcher"); +/** + * @enum {SUB_OPEN_OPTION} + * Flags that describe whether to open a submodule if it is part of a merge. + */ +const SUB_OPEN_OPTION = { + FORCE_OPEN : 0, // non-bare repo and open sub if it is part of a merge + ALLOW_BARE : 1, // non-bare repo, do not open submodule unless have to + FORCE_BARE : 2, // bare repo, open submodule is not allowed +}; +exports.SUB_OPEN_OPTION = SUB_OPEN_OPTION; + /** * Open the submodule having the specified `submoduleName` in the meta-repo * associated with the specified `fetcher`; fetch the specified `submoduleSha` @@ -157,7 +168,14 @@ Opener.prototype._initialize = co.wrap(function *() { if (null === this.d_commit) { this.d_commit = yield this.d_repo.getHeadCommit(); } - this.d_subRepos = {}; + + // d_cachedSubs: normal subrepo opened and cached by this object + // d_cachedAbsorbedSubs: absorbed subrepo opened and cached by this object + // d_openSubs: subs that were open when this object was created + // d_absorbedSubs: subs that were half open when this object was created + this.d_cachedSubs = {}; + this.d_cachedAbsorbedSubs = {}; + this.d_openSubs = new Set(); if (!this.d_repo.isBare()) { const openSubsList = yield SubmoduleUtil.listOpenSubmodules(this.d_repo); @@ -202,13 +220,25 @@ Opener.prototype.getOpenedSubs = co.wrap(function*() { if (!this.d_initialized) { yield this._initialize(); } - const subs = Object.keys(this.d_subRepos); + const subs = Object.keys(this.d_cachedSubs); return subs.filter(name => !this.d_openSubs.has(name)); }); /** - * Return true if the submodule having the specified `subName` is open and - * false otherwise. + * Opener caches all repos that have previously been gotten, this method + * removes the sub repo from the absorbed cache given its name. Useful when + * the repo was previously opened as bare repo, and later need to be + * opened as a normal submodule. + * + * @param subName + */ +Opener.prototype.clearAbsorbedCache = function (subName) { + delete this.d_cachedAbsorbedSubs[subName]; +}; + +/** + * Return true if the submodule having the specified `subName` is fully + * openable, return false otherwise. * * @param {String} subName * @return {Boolean} @@ -217,12 +247,13 @@ Opener.prototype.isOpen = co.wrap(function *(subName) { if (!this.d_initialized) { yield this._initialize(); } - return this.d_openSubs.has(subName) || (subName in this.d_subRepos); + return this.d_openSubs.has(subName) || (subName in this.d_cachedSubs); }); /** * Return true if the submodule is opened nor half opened. * + * @async * @param {String} subName * @return {Boolean} */ @@ -232,12 +263,55 @@ Opener.prototype.isAtLeastHalfOpen = co.wrap(function *(subName) { } return this.d_absorbedSubs.has(subName) || this.d_openSubs.has(subName) || - (subName in this.d_subRepos); + (subName in this.d_cachedSubs) || + (subName in this.d_cachedAbsorbedSubs); +}); + +/** + * Return true if the submodule is opened as a bare or absorbed repo. + * + * @async + * @param {String} subName + * @return {Boolean} + */ +Opener.prototype.isHalfOpened = co.wrap(function *(subName) { + if (!this.d_initialized) { + yield this._initialize(); + } + return (subName in this.d_cachedAbsorbedSubs); +}); + +/** + * Get sha of a submodule and open the submodule on that sha + * + * @param {String} subName + * @returns {NodeGit.Repository} sub repo that is opened. + */ +Opener.prototype.fullOpen = co.wrap(function *(subName) { + const entry = yield this.d_tree.entryByPath(subName); + const sha = entry.sha(); + console.log(`\ +Opening ${colors.blue(subName)} on ${colors.green(sha)}.`); + return yield exports.openOnCommit(this.d_fetcher, + subName, + sha, + this.d_templatePath, + false); }); /** * Return the repository for the specified `submoduleName`, opening it if - * necessary. + * necessary based on the expected working directory type: + * 1. FORCE_BARE + * - directly return opened absorbed sub if there is one + * - open bare repo otherwise + * 2. ALLOW_BARE + * - directly return opened sub if there is one + * - directly return opened absorbed sub if there is one + * - open absorbed sub + * 3. FORCE_OPEN + * - directly return opened sub if there is one + * - open normal repo otherwise * * Note that after opening one or more submodules, * `SparseCheckoutUtil.writeMetaIndex` must be called so that `SKIP_WORKTREE` @@ -245,43 +319,59 @@ Opener.prototype.isAtLeastHalfOpen = co.wrap(function *(subName) { * each time a submodule is opened. * * @param {String} subName - * @param {boolean} bare + * @param {SUB_OPEN_OPTION} openOption * @return {NodeGit.Repository} */ -Opener.prototype.getSubrepo = co.wrap(function *(subName, bare) { +Opener.prototype.getSubrepo = co.wrap(function *(subName, openOption) { if (!this.d_initialized) { yield this._initialize(); } - let subRepo = this.d_subRepos[subName]; + let subRepo = this.d_cachedSubs[subName]; if (undefined !== subRepo) { return subRepo; // it was found } - if (bare) { - if (this.d_absorbedSubs.has(subName)) { - subRepo = yield SubmoduleUtil.getBareRepo(this.d_repo, subName); - } else { - subRepo = yield exports.openOnCommit(this.d_fetcher, - subName, - "", - this.d_templatePath, - true); + if (SUB_OPEN_OPTION.FORCE_OPEN !== openOption) { + subRepo = this.d_cachedAbsorbedSubs[subName]; + if (undefined !== subRepo) { + return subRepo; } } - else if (this.d_openSubs.has(subName)) { - subRepo = yield SubmoduleUtil.getRepo(this.d_repo, subName); - } - else { - const entry = yield this.d_tree.entryByPath(subName); - const sha = entry.sha(); - console.log(`\ -Opening ${colors.blue(subName)} on ${colors.green(sha)}.`); - subRepo = yield exports.openOnCommit(this.d_fetcher, - subName, - sha, - this.d_templatePath, - false); + const openable = yield this.isOpen(subName); + const halfOpenable = yield this.isAtLeastHalfOpen(subName); + + switch (openOption) { + case SUB_OPEN_OPTION.FORCE_BARE: + subRepo = halfOpenable ? + yield SubmoduleUtil.getBareRepo(this.d_repo, subName) : + yield exports.openOnCommit(this.d_fetcher, + subName, + "", + this.d_templatePath, + true); + this.d_cachedAbsorbedSubs[subName] = subRepo; + break; + case SUB_OPEN_OPTION.ALLOW_BARE: + if (openable) { + subRepo = yield SubmoduleUtil.getRepo(this.d_repo, subName); + this.d_cachedSubs[subName] = subRepo; + } else { + subRepo = halfOpenable ? + yield SubmoduleUtil.getBareRepo(this.d_repo, subName) : + yield exports.openOnCommit(this.d_fetcher, + subName, + "", + this.d_templatePath, + true); + this.d_cachedAbsorbedSubs[subName] = subRepo; + } + break; + default: + subRepo = openable ? + yield SubmoduleUtil.getRepo(this.d_repo, subName) : + yield this.fullOpen(subName); + this.d_cachedSubs[subName] = subRepo; + break; } - this.d_subRepos[subName] = subRepo; return subRepo; }); exports.Opener = Opener; diff --git a/node/lib/util/reset.js b/node/lib/util/reset.js index bdfaf9f6a..0322d4346 100644 --- a/node/lib/util/reset.js +++ b/node/lib/util/reset.js @@ -219,7 +219,9 @@ exports.reset = co.wrap(function *(repo, commit, type) { // Open the submodule and fetch the sha of the commit to which we're // resetting in case we don't have it. - const subRepo = yield opener.getSubrepo(name, false); + const subRepo = + yield opener.getSubrepo(name, + Open.SUB_OPEN_OPTION.FORCE_OPEN); let subCommitSha; diff --git a/node/lib/util/stash_util.js b/node/lib/util/stash_util.js index f34f11829..225700f85 100644 --- a/node/lib/util/stash_util.js +++ b/node/lib/util/stash_util.js @@ -317,7 +317,9 @@ exports.apply = co.wrap(function *(repo, id, reinstateIndex) { return; // RETURN } - const subRepo = yield opener.getSubrepo(name, false); + const subRepo = + yield opener.getSubrepo(name, + Open.SUB_OPEN_OPTION.FORCE_OPEN); // Try to get the comit for the stash; if it's missing, fail. diff --git a/node/lib/util/status_util.js b/node/lib/util/status_util.js index 7cd352f54..0487183f8 100644 --- a/node/lib/util/status_util.js +++ b/node/lib/util/status_util.js @@ -583,33 +583,50 @@ exports.getRepoStatus = co.wrap(function *(repo, options) { }); /** - * Throw a `UserError` unless the specified `status` reflects a repository in a + * Wrapper around `checkReadiness` and throw a `UserError` if the repo + * is not in anormal, ready state. + * @see {checkReadiness} + * @param {RepoStatus} status + * @throws {UserError} + */ +exports.ensureReady = function (status) { + const errorMessage = exports.checkReadiness(status); + if (null !== errorMessage) { + throw new UserError(errorMessage); + } +}; + +/** + * Return an error message if the specified `status` of a repository isn't in a * normal, ready state, that is, it does not have any conflicts or in-progress * operations from the sequencer. Adjust output paths to be relative to the * specified `cwd`. * * @param {RepoStatus} status + * @returns {String | null} if not null, the return implies that the repo is + * not ready. */ -exports.ensureReady = function (status) { +exports.checkReadiness = function (status) { assert.instanceOf(status, RepoStatus); if (null !== status.rebase) { - throw new UserError(`\ + return (`\ Before proceeding, you must complete the rebase in progress (by running 'git meta rebase --continue') or abort it (by running 'git meta rebase --abort').`); } if (status.isConflicted()) { - throw new UserError(`\ + return (`\ Please resolve outstanding conflicts before proceeding: ${PrintStatusUtil.printRepoStatus(status, "")}`); } if (null !== status.sequencerState) { const command = PrintStatusUtil.getSequencerCommand(status.sequencerState.type); - throw new UserError(`\ + return (`\ Before proceeding, you must complete the ${command} in progress (by running 'git meta ${command} --continue') or abort it (by running 'git meta ${command} --abort').`); } + return null; }; diff --git a/node/lib/util/submodule_config_util.js b/node/lib/util/submodule_config_util.js index 14286e275..ef3715004 100644 --- a/node/lib/util/submodule_config_util.js +++ b/node/lib/util/submodule_config_util.js @@ -522,7 +522,7 @@ exports.initSubmoduleAndRepo = co.wrap(function *(repoUrl, name, url, templatePath, - bare=false) { + bare) { if (null !== repoUrl) { assert.isString(repoUrl); } diff --git a/node/lib/util/submodule_util.js b/node/lib/util/submodule_util.js index 33606b090..455a7170e 100644 --- a/node/lib/util/submodule_util.js +++ b/node/lib/util/submodule_util.js @@ -564,7 +564,7 @@ exports.getSubmodulesInPath = function (dir, indexSubNames) { } // test if the short path a parent dir of the long path - const isParentDir = (short, long) => { + const isParentDir = function(short, long) { return long.startsWith(short) && ( short[short.length-1] === "/" || long[short.length] === "/" diff --git a/node/lib/util/write_repo_ast_util.js b/node/lib/util/write_repo_ast_util.js index efbc3f445..ffa8a5e8b 100644 --- a/node/lib/util/write_repo_ast_util.js +++ b/node/lib/util/write_repo_ast_util.js @@ -814,7 +814,8 @@ git -C '${repo.path()}' -c gc.reflogExpire=0 -c gc.reflogExpireUnreachable=0 \ repo, subName, sub.url, - null); + null, + false); // Pull in commits from the commits repo, but remove the remote // when done. diff --git a/node/test/util/merge_bare_util.js b/node/test/util/merge_bare.js similarity index 92% rename from node/test/util/merge_bare_util.js rename to node/test/util/merge_bare.js index 0b1f82d17..3ed1c8342 100644 --- a/node/test/util/merge_bare_util.js +++ b/node/test/util/merge_bare.js @@ -34,13 +34,15 @@ const assert = require("chai").assert; const co = require("co"); const colors = require("colors"); -const MergeBareUtil = require("../../lib//util/merge_bare_util"); +const MergeUtil = require("../../lib/util/merge_util"); +const Open = require("../../lib/util/open"); +const MergeCommon = require("../../lib//util/merge_common"); const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); describe("MergeBareUtil", function () { describe("merge_with_all_cases", function () { // Similar to tests of merge, but with no need for a working directory. - const MODE = MergeBareUtil.MODE; + const MODE = MergeCommon.MODE; const cases = { "3 way merge in bare": { initial: ` @@ -111,7 +113,7 @@ x=U:C3-2 s=Sa:b;C4-2 s=Sa:a;Bmaster=3;Bfoo=4`, theirCommit: "4", ourCommit: "3", }, - "non-ffmerge with ffwd submodule change": { + "non-ffmerge with ffwd submodule change xxxx": { initial: ` a=Aa:Cb-a;Bb=b;Cc-b;Bc=c| x=U:C3-2 s=Sa:b;C4-2 s=Sa:c;Bmaster=3;Bfoo=4;Os`, @@ -207,6 +209,7 @@ a=B:Ca-1;Cb-1;Ba=a;Bb=b| x=S:C2-1 s=Sa:a;C3-1 s=Sa:b;Bmaster=2;Bfoo=3`, theirCommit: "3", ourCommit: "2", + fails: true, errorMessage: `\ CONFLICT (content): Conflicting entries for submodule: ${colors.red("s")} @@ -219,6 +222,7 @@ a=B:Ca-1 README.md=8;Cb-1 README.md=9;Ba=a;Bb=b| x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;Bmaster=3;Bfoo=4`, theirCommit: "4", ourCommit: "3", + fails: true, errorMessage: `\ CONFLICT (content): Conflicting entries for submodule: ${colors.red("s")} @@ -288,11 +292,15 @@ x=S:C2-1 r=Sa:1,s=Sa:1,t=Sa:1; message = "message\n"; } const mode = !("mode" in c) ? MODE.FORCE_COMMIT : c.mode; - const result = yield MergeBareUtil.merge(x, - ourCommit, - theirCommit, - mode, - message); + const openOption = Open.SUB_OPEN_OPTION.FORCE_BARE; + const defaultEditor = function () {}; + const result = yield MergeUtil.merge(x, + ourCommit, + theirCommit, + mode, + openOption, + message, + defaultEditor); const errorMessage = c.errorMessage || null; assert.equal(result.errorMessage, errorMessage); if (upToDate) { diff --git a/node/test/util/merge_full_open.js b/node/test/util/merge_full_open.js new file mode 100644 index 000000000..35ff79a83 --- /dev/null +++ b/node/test/util/merge_full_open.js @@ -0,0 +1,427 @@ +/* + * Copyright (c) 2019, Two Sigma Open Source + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * * Neither the name of git-meta nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +"use strict"; + +const assert = require("chai").assert; +const co = require("co"); +const colors = require("colors"); + +const MergeUtil = require("../../lib//util/merge_util"); +const MergeCommon = require("../../lib//util/merge_common"); +const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); +const Open = require("../../lib/util/open"); + +/** + * Return the commit map required by 'RepoASTTestUtil.testMultiRepoManipulator' + * from the specified 'result' returned by the 'merge' and 'continue' function, + * using the specified 'maps' provided to the manipulators. + */ +function mapReturnedCommits(result, maps) { + assert.isObject(result); + let newCommitMap = {}; + + // If a new commit was generated -- it wasn't a fast-forward commit -- + // record a mapping from the new commit to it's logical name: "x". + + const commitMap = maps.commitMap; + if (null !== result.metaCommit && !(result.metaCommit in commitMap)) { + newCommitMap[result.metaCommit] = "x"; + } + + // Map the new commits in submodules to the names of the submodules where + // they were made. + + Object.keys(result.submoduleCommits).forEach(name => { + commitMap[result.submoduleCommits[name]] = name; + }); + return { + commitMap: newCommitMap, + }; +} + +describe("MergeFullOpen", function () { + describe("merge", function () { + // Will do merge from repo `x`. A merge commit in the meta-repo will + // be named `x`; any merge commits in the sub-repos will be given the + // name of the sub-repo in which they are made. TODO: test for changes + // to submodule shas, and submodule deletions + + // Test plan: + // - basic merging with meta-repo: normal/ffw/force commit; note that + // fast-forward merges are tested in the driver for + // 'fastForwardMerge', so we just need to validate that it works once + // here + // - many scenarios with submodules + // - merges with open/closed unaffected submodules + // - where submodules are opened and closed + // - where they can and can't be fast-forwarded + + const MODE = MergeCommon.MODE; + const cases = { + "no merge base": { + initial: "x=S:Cx s=Sa:1;Bfoo=x", + fromCommit: "x", + fails: true, + }, + "not ready": { + initial: "x=S:QR 1: 1: 0 1", + fromCommit: "1", + fails: true, + }, + "url changes": { + initial: "a=B|b=B|x=U:C3-2 s=Sb:1;Bfoo=3", + fromCommit: "3", + fails: true, + }, + "ancestor url changes": { + initial: "a=B|b=B|x=U:C4-3 q=Sa:1;C3-2 s=Sb:1;Bfoo=4", + fromCommit: "4", + fails: true, + }, + "dirty": { + initial: "a=B|x=U:C3-1 t=Sa:1;Bfoo=3;Os W README.md=8", + fromCommit: "3", + fails: true, + }, + "dirty index": { + initial: "a=B|x=U:C3-1 t=Sa:1;Bfoo=3;Os I README.md=8", + fromCommit: "3", + fails: true, + }, + "trivial -- nothing to do xxxx": { + initial: "x=S", + fromCommit: "1", + }, + "up-to-date": { + initial: "a=B|x=U:C3-2 t=Sa:1;Bmaster=3;Bfoo=2", + fromCommit: "2", + }, + "trivial -- nothing to do, has untracked change": { + initial: "a=B|x=U:Os W foo=8", + fromCommit: "2", + }, + "staged change": { + initial: "a=B|x=U:Os I foo=bar", + fromCommit: "1", + fails: true, + }, + "submodule commit": { + initial: "a=B|x=U:Os Cs-1!H=s", + fromCommit: "1", + fails: true, + }, + "already a merge in progress": { + initial: "x=S:Qhia#M 1: 1: 0 1", + fromCommit: "1", + fails: true, + }, + "fast forward": { + initial: "a=B|x=S:C2-1 s=Sa:1;Bfoo=2", + fromCommit: "2", + expected: "a=B|x=E:Bmaster=2", + }, + "fast forward, but forced commit": { + initial: "a=B|x=S:C2-1 s=Sa:1;Bfoo=2", + fromCommit: "2", + mode: MergeCommon.MODE.FORCE_COMMIT, + expected: "a=B|x=E:Bmaster=x;Cx-1,2 s=Sa:1", + }, + "one merge": { + initial: ` +a=B:Ca-1;Cb-1;Ba=a;Bb=b| +x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;Bmaster=3;Bfoo=4`, + fromCommit: "4", + expected: "x=E:Cx-3,4 s=Sa:s;Bmaster=x;Os Cs-a,b b=b", + }, + "one merge, but ff only": { + initial: ` +a=B:Ca-1;Cb-1;Ba=a;Bb=b| +x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;Bmaster=3;Bfoo=4`, + fromCommit: "4", + mode: MergeCommon.MODE.FF_ONLY, + fails: true, + }, + "one merge with ancestor": { + initial: ` +a=B:Ca-1;Cb-1;Ba=a;Bb=b| +x=U:C3-2 s=Sa:a;C5-4 t=Sa:b;C4-2 s=Sa:b;Bmaster=3;Bfoo=5`, + fromCommit: "5", + expected: ` +x=E:Cx-3,5 t=Sa:b,s=Sa:s;Bmaster=x;Os Cs-a,b b=b`, + }, + "one merge with editor": { + initial: ` +a=B:Ca-1;Cb-1;Ba=a;Bb=b| +x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;Bmaster=3;Bfoo=4`, + fromCommit: "4", + editMessage: () => Promise.resolve("foo\nbar\n# baz\n"), + expected: ` +x=E:Cfoo\nbar\n#x-3,4 s=Sa:s;Bmaster=x;Os Cfoo\nbar\n#s-a,b b=b`, + message: null, + }, + "one merge with empty message": { + initial: ` +a=B:Ca-1;Cb-1;Ba=a;Bb=b| +x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;Bmaster=3;Bfoo=4`, + fromCommit: "4", + editMessage: () => Promise.resolve(""), + message: null, + }, + "non-ffmerge with trivial ffwd submodule change": { + initial: ` +a=Aa:Cb-a;Bb=b| +x=U:C3-2 t=Sa:b;C4-2 s=Sa:b;Bmaster=3;Bfoo=4;Os`, + fromCommit: "4", + expected: "x=E:Cx-3,4 s=Sa:b;Os H=b;Bmaster=x", + }, + "sub is same": { + initial: ` +a=Aa:Cb-a;Bb=b| +x=U:C3-2 s=Sa:b;C4-2 s=Sa:b,t=Sa:b;Bmaster=3;Bfoo=4;Os`, + fromCommit: "4", + expected: "x=E:Cx-3,4 t=Sa:b;Bmaster=x", + }, + "sub is same, closed": { + initial: ` +a=Aa:Cb-a;Bb=b| +x=U:C3-2 s=Sa:b;C4-2 s=Sa:b,t=Sa:b;Bmaster=3;Bfoo=4`, + fromCommit: "4", + expected: "x=E:Cx-3,4 t=Sa:b;Bmaster=x", + }, + "sub is behind": { + initial: ` +a=Aa:Cb-a;Bb=b| +x=U:C3-2 s=Sa:b;C4-2 s=Sa:a;Bmaster=3;Bfoo=4;Os`, + fromCommit: "4", + expected: "x=E:Cx-3,4 ;Bmaster=x", + }, + "sub is behind, closed": { + initial: ` +a=Aa:Cb-a;Bb=b| +x=U:C3-2 s=Sa:b;C4-2 s=Sa:a;Bmaster=3;Bfoo=4`, + fromCommit: "4", + expected: "x=E:Cx-3,4 ;Bmaster=x", + }, + "non-ffmerge with ffwd submodule change": { + initial: ` +a=Aa:Cb-a;Bb=b;Cc-b;Bc=c| +x=U:C3-2 s=Sa:b;C4-2 s=Sa:c;Bmaster=3;Bfoo=4;Os`, + fromCommit: "4", + expected: "x=E:Cx-3,4 s=Sa:c;Os H=c;Bmaster=x", + }, + "non-ffmerge with ffwd submodule change, closed": { + initial: ` +a=Aa:Cb-a;Bb=b;Cc-b;Bc=c| +x=U:C3-2 s=Sa:b;C4-2 s=Sa:c;Bmaster=3;Bfoo=4`, + fromCommit: "4", + expected: "x=E:Cx-3,4 s=Sa:c;Bmaster=x", + }, + "non-ffmerge with deeper ffwd submodule change": { + initial: ` +a=Aa:Cb-a;Bb=b;Cc-b;Cd-c;Bd=d| +x=U:C3-2 s=Sa:b;C5-4 s=Sa:d;C4-2 s=Sa:c;Bmaster=3;Bfoo=5`, + fromCommit: "5", + expected: "x=E:Cx-3,5 s=Sa:d;Bmaster=x", + }, + "non-ffmerge with ffwd submodule change on lhs": { + initial: ` +a=Aa:Cb-a;Bb=b;Cc-b;Bc=c| +x=U:C3-2 s=Sa:b;C4-2 q=Sa:a;Bmaster=3;Bfoo=4`, + fromCommit: "4", + expected: "x=E:Cx-3,4 q=Sa:a;Bmaster=x", + }, + "non-ffmerge with non-ffwd submodule change": { + initial: ` +a=Aa:Cb-a;Cc-a;Bfoo=b;Bbar=c| +x=U:C3-2 s=Sa:b;C4-2 s=Sa:c;Bmaster=3;Bfoo=4`, + fromCommit: "4", + expected: "x=E:Cx-3,4 s=Sa:s;Os Cs-b,c c=c!H=s;Bmaster=x", + }, + "non-ffmerge with non-ffwd submodule change, sub already open": { + initial: ` +a=Aa:Cb-a;Cc-a;Bfoo=b;Bbar=c| +x=U:C3-2 s=Sa:b;C4-2 s=Sa:c;Bmaster=3;Bfoo=4;Os`, + fromCommit: "4", + expected: "x=E:Cx-3,4 s=Sa:s;Os Cs-b,c c=c!H=s;Bmaster=x", + }, + "submodule commit is up-to-date": { + initial:` +a=Aa:Cb-a;Cc-b;Bfoo=b;Bbar=c| +x=U:C3-2 s=Sa:c;C4-2 s=Sa:b,t=Sa:a;Bmaster=3;Bfoo=4;Os`, + fromCommit: "4", + expected: "x=E:Cx-3,4 t=Sa:a;Os H=c;Bmaster=x", + }, + "submodule commit is up-to-date, was not open": { + initial:` +a=Aa:Cb-a;Cc-b;Bfoo=b;Bbar=c| +x=U:C3-2 s=Sa:c;C4-2 s=Sa:b,t=Sa:a;Bmaster=3;Bfoo=4`, + fromCommit: "4", + expected: "x=E:Cx-3,4 t=Sa:a;Bmaster=x", + }, + "submodule commit is same": { + initial: ` +a=Aa:Cb-a;Cc-b;Bfoo=b;Bbar=c| +x=U:C3-2 s=Sa:c;C4-2 s=Sa:c,q=Sa:a;Bmaster=3;Bfoo=4`, + fromCommit: "4", + expected: "x=E:Cx-3,4 q=Sa:a;Bmaster=x", + }, + "added in merge": { + initial: ` +a=B| +x=S:C2-1;C3-1 t=Sa:1;Bmaster=2;Bfoo=3`, + fromCommit: "3", + expected: "x=E:Cx-2,3 t=Sa:1;Bmaster=x", + }, + "added on both sides": { + initial: ` +a=B| +x=S:C2-1 s=Sa:1;C3-1 t=Sa:1;Bmaster=2;Bfoo=3`, + fromCommit: "3", + expected: "x=E:Cx-2,3 t=Sa:1;Bmaster=x", + }, + "conflicted add": { + initial: ` +a=B:Ca-1;Cb-1;Ba=a;Bb=b| +x=S:C2-1 s=Sa:a;C3-1 s=Sa:b;Bmaster=2;Bfoo=3`, + fromCommit: "3", + fails: true, + expected: `x=E:Qmessage\n#M 2: 3: 0 3;I *s=~*S:a*S:b`, + errorMessage: `\ +Conflicting entries for submodule ${colors.red("s")} +`, + }, + "conflict in submodule": { + initial: ` +a=B:Ca-1 README.md=8;Cb-1 README.md=9;Ba=a;Bb=b| +x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;Bmaster=3;Bfoo=4`, + fromCommit: "4", + fails: true, + errorMessage: `\ +Submodule ${colors.red("s")} is conflicted. +`, + expected: ` +x=E:Qmessage\n#M 3: 4: 0 4; +Os Qmessage\n#M a: b: 0 b!I *README.md=hello world*8*9!W README.md=\ +<<<<<<< ours +8 +======= +9 +>>>>>>> theirs +; +`, + }, + "new commit in sub in target branch but not in HEAD branch": { + initial: ` +a=B:Ca-1;Cb-1;Ba=a;Bb=b| +x=U:C3-2 t=Sa:1;C4-3 s=Sa:a;C5-3 t=Sa:b;Bmaster=4;Bfoo=5;Os;Ot`, + fromCommit: "5", + expected: ` +x=E:Cx-4,5 t=Sa:b;Bmaster=x;Ot H=b;Os` + }, + "new commit in sub in target branch but not in HEAD branch, closed" + : { + initial: ` +a=B:Ca-1;Cb-1;Ba=a;Bb=b| +x=U:C3-2 t=Sa:1;C4-3 s=Sa:a;C5-3 t=Sa:b;Bmaster=4;Bfoo=5`, + fromCommit: "5", + expected: ` +x=E:Cx-4,5 t=Sa:b;Bmaster=x` + }, + "merge in a branch with a removed sub": { + initial: ` +a=B:Ca-1;Ba=a| +x=U:C3-2 t=Sa:1;C4-2 s;Bmaster=3;Bfoo=4`, + fromCommit: "4", + expected: `x=E:Cx-3,4 s;Bmaster=x`, + }, + "merge to a branch with a removed sub": { + initial: ` +a=B:Ca-1;Ba=a| +x=U:C3-2 t=Sa:1;C4-2 s;Bmaster=4;Bfoo=3`, + fromCommit: "3", + expected: `x=E:Cx-4,3 t=Sa:1;Bmaster=x`, + }, + "change with multiple merge bases": { + initial: ` +a=B:Ca-1;Ba=a| +x=S:C2-1 r=Sa:1,s=Sa:1,t=Sa:1; + C3-2 s=Sa:a; + C4-2 t=Sa:a; + Cl-3,4 s,t; + Ct-3,4 a=Sa:1,t=Sa:a; + Bmaster=l;Bfoo=t`, + fromCommit: "t", + expected: "x=E:Cx-l,t a=Sa:1;Bmaster=x", + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const expected = c.expected; + + const doMerge = co.wrap(function *(repos, maps) { + const upToDate = null === expected; + const mode = !("mode" in c) ? MODE.NORMAL : c.mode; + const x = repos.x; + const reverseCommitMap = maps.reverseCommitMap; + assert.property(reverseCommitMap, c.fromCommit); + const physicalCommit = reverseCommitMap[c.fromCommit]; + const commit = yield x.getCommit(physicalCommit); + let message = c.message; + if (undefined === message) { + message = "message\n"; + } + const defaultEditor = function () {}; + const editMessage = c.editMessage || defaultEditor; + const openOption = Open.SUB_OPEN_OPTION.FORCE_OPEN; + const result = yield MergeUtil.merge(x, + null, + commit, + mode, + openOption, + message, + editMessage); + const errorMessage = c.errorMessage || null; + assert.equal(result.errorMessage, errorMessage); + if (upToDate) { + assert.isNull(result.metaCommit); + return; // RETURN + } + return mapReturnedCommits(result, maps); + }); + yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, + expected || {}, + doMerge, + c.fails); + })); + }); + }); +}); diff --git a/node/test/util/merge_util.js b/node/test/util/merge_util.js index b7e717eb9..f12c06d92 100644 --- a/node/test/util/merge_util.js +++ b/node/test/util/merge_util.js @@ -35,7 +35,9 @@ const co = require("co"); const colors = require("colors"); const MergeUtil = require("../../lib//util/merge_util"); +const MergeCommon = require("../../lib//util/merge_common"); const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); +const Open = require("../../lib/util/open"); /** * Return the commit map required by 'RepoASTTestUtil.testMultiRepoManipulator' @@ -149,7 +151,7 @@ x=U:C3-2 s=Sa:a;Bfoo=3;Os W a=b`, // - where submodules are opened and closed // - where they can and can't be fast-forwarded - const MODE = MergeUtil.MODE; + const MODE = MergeCommon.MODE; const cases = { "no merge base": { initial: "x=S:Cx s=Sa:1;Bfoo=x", @@ -216,7 +218,7 @@ x=U:C3-2 s=Sa:a;Bfoo=3;Os W a=b`, "fast forward, but forced commit": { initial: "a=B|x=S:C2-1 s=Sa:1;Bfoo=2", fromCommit: "2", - mode: MergeUtil.MODE.FORCE_COMMIT, + mode: MergeCommon.MODE.FORCE_COMMIT, expected: "a=B|x=E:Bmaster=x;Cx-1,2 s=Sa:1", }, "one merge": { @@ -224,14 +226,14 @@ x=U:C3-2 s=Sa:a;Bfoo=3;Os W a=b`, a=B:Ca-1;Cb-1;Ba=a;Bb=b| x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;Bmaster=3;Bfoo=4`, fromCommit: "4", - expected: "x=E:Cx-3,4 s=Sa:s;Bmaster=x;Os Cs-a,b b=b", + expected: "x=E:Cx-3,4 s=Sa:s;Bmaster=x", }, "one merge, but ff only": { initial: ` a=B:Ca-1;Cb-1;Ba=a;Bb=b| x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;Bmaster=3;Bfoo=4`, fromCommit: "4", - mode: MergeUtil.MODE.FF_ONLY, + mode: MergeCommon.MODE.FF_ONLY, fails: true, }, "one merge with ancestor": { @@ -240,7 +242,7 @@ a=B:Ca-1;Cb-1;Ba=a;Bb=b| x=U:C3-2 s=Sa:a;C5-4 t=Sa:b;C4-2 s=Sa:b;Bmaster=3;Bfoo=5`, fromCommit: "5", expected: ` -x=E:Cx-3,5 t=Sa:b,s=Sa:s;Bmaster=x;Os Cs-a,b b=b`, +x=E:Cx-3,5 t=Sa:b,s=Sa:s;Bmaster=x`, }, "one merge with editor": { initial: ` @@ -249,7 +251,7 @@ x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;Bmaster=3;Bfoo=4`, fromCommit: "4", editMessage: () => Promise.resolve("foo\nbar\n# baz\n"), expected: ` -x=E:Cfoo\nbar\n#x-3,4 s=Sa:s;Bmaster=x;Os Cfoo\nbar\n#s-a,b b=b`, +x=E:Cfoo\nbar\n#x-3,4 s=Sa:s;Bmaster=x`, message: null, }, "one merge with empty message": { @@ -328,7 +330,7 @@ x=U:C3-2 s=Sa:b;C4-2 q=Sa:a;Bmaster=3;Bfoo=4`, a=Aa:Cb-a;Cc-a;Bfoo=b;Bbar=c| x=U:C3-2 s=Sa:b;C4-2 s=Sa:c;Bmaster=3;Bfoo=4`, fromCommit: "4", - expected: "x=E:Cx-3,4 s=Sa:s;Os Cs-b,c c=c!H=s;Bmaster=x", + expected: "x=E:Cx-3,4 s=Sa:s;Bmaster=x", }, "non-ffmerge with non-ffwd submodule change, sub already open": { initial: ` @@ -378,6 +380,7 @@ a=B:Ca-1;Cb-1;Ba=a;Bb=b| x=S:C2-1 s=Sa:a;C3-1 s=Sa:b;Bmaster=2;Bfoo=3`, fromCommit: "3", expected: `x=E:Qmessage\n#M 2: 3: 0 3;I *s=~*S:a*S:b`, + fails: true, errorMessage: `\ Conflicting entries for submodule ${colors.red("s")} `, @@ -387,6 +390,7 @@ Conflicting entries for submodule ${colors.red("s")} a=B:Ca-1 README.md=8;Cb-1 README.md=9;Ba=a;Bb=b| x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;Bmaster=3;Bfoo=4`, fromCommit: "4", + fails: true, errorMessage: `\ Submodule ${colors.red("s")} is conflicted. `, @@ -464,17 +468,26 @@ x=S:C2-1 r=Sa:1,s=Sa:1,t=Sa:1; } const defaultEditor = function () {}; const editMessage = c.editMessage || defaultEditor; + const openOption = Open.SUB_OPEN_OPTION.ALLOW_BARE; + const result = yield MergeUtil.merge(x, + null, commit, mode, + openOption, message, editMessage); const errorMessage = c.errorMessage || null; assert.equal(result.errorMessage, errorMessage); + if (upToDate) { assert.isNull(result.metaCommit); return; // RETURN } + + if (!result.metaCommit) { + return; + } return mapReturnedCommits(result, maps); }); yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, diff --git a/node/test/util/open.js b/node/test/util/open.js index c88a3d5f9..21109640f 100644 --- a/node/test/util/open.js +++ b/node/test/util/open.js @@ -40,6 +40,7 @@ const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); const SubmoduleFetcher = require("../../lib/util/submodule_fetcher"); const SubmoduleUtil = require("../../lib/util/submodule_util"); +const FORCE_OPEN = Open.SUB_OPEN_OPTION.FORCE_OPEN; describe("openOnCommit", function () { // Assumption is that 'x' is the target repo. // TODO: test for template path usage. We're just passing it through but @@ -104,8 +105,8 @@ describe("openOnCommit", function () { const w = yield RepoASTTestUtil.createMultiRepos("a=B|x=U:Os"); const repo = w.repos.x; const opener = new Open.Opener(repo, null); - const s1 = yield opener.getSubrepo("s", false); - const s2 = yield opener.getSubrepo("s", false); + const s1 = yield opener.getSubrepo("s", FORCE_OPEN); + const s2 = yield opener.getSubrepo("s", FORCE_OPEN); const base = yield SubmoduleUtil.getRepo(repo, "s"); assert.equal(s1, s2, "not re-opened"); assert.equal(s1.workdir(), base.workdir(), "right path"); @@ -114,8 +115,8 @@ describe("openOnCommit", function () { const w = yield RepoASTTestUtil.createMultiRepos("a=B|x=U"); const repo = w.repos.x; const opener = new Open.Opener(repo, null); - const s1 = yield opener.getSubrepo("s", false); - const s2 = yield opener.getSubrepo("s", false); + const s1 = yield opener.getSubrepo("s", FORCE_OPEN); + const s2 = yield opener.getSubrepo("s", FORCE_OPEN); const base = yield SubmoduleUtil.getRepo(repo, "s"); assert.equal(s1, s2, "not re-opened"); assert.equal(s1.workdir(), base.workdir(), "right path"); @@ -133,7 +134,7 @@ describe("openOnCommit", function () { const repo = w.repos.x; const commit = yield repo.getCommit(baseSha); const opener = new Open.Opener(repo, commit); - const s = yield opener.getSubrepo("s", false); + const s = yield opener.getSubrepo("s", FORCE_OPEN); const head = yield s.getHeadCommit(); assert.equal(head.id().tostrS(), subSha); })); @@ -158,7 +159,7 @@ describe("openOnCommit", function () { const w = yield RepoASTTestUtil.createMultiRepos(state); const repo = w.repos.x; const opener = new Open.Opener(repo, null); - yield opener.getSubrepo("s", false); + yield opener.getSubrepo("s", FORCE_OPEN); const open = yield opener.getOpenSubs(); assert.deepEqual(Array.from(open), []); })); @@ -183,7 +184,7 @@ describe("openOnCommit", function () { const w = yield RepoASTTestUtil.createMultiRepos(state); const repo = w.repos.x; const opener = new Open.Opener(repo, null); - yield opener.getSubrepo("s", false); + yield opener.getSubrepo("s", FORCE_OPEN); const opened = yield opener.getOpenedSubs(); assert.deepEqual(opened, []); })); @@ -192,7 +193,7 @@ describe("openOnCommit", function () { const w = yield RepoASTTestUtil.createMultiRepos(state); const repo = w.repos.x; const opener = new Open.Opener(repo, null); - yield opener.getSubrepo("s", false); + yield opener.getSubrepo("s", FORCE_OPEN); const opened = yield opener.getOpenedSubs(); assert.deepEqual(opened, ["s"]); })); @@ -209,7 +210,7 @@ describe("openOnCommit", function () { const w = yield RepoASTTestUtil.createMultiRepos(state); const repo = w.repos.x; const opener = new Open.Opener(repo, null); - const s = yield opener.getSubrepo("s", false); + const s = yield opener.getSubrepo("s", FORCE_OPEN); const result = yield opener.isOpen("s"); assert.equal(true, result); const config = yield s.config(); diff --git a/node/test/util/read_repo_ast_util.js b/node/test/util/read_repo_ast_util.js index 3cbf504c9..b65c5961d 100644 --- a/node/test/util/read_repo_ast_util.js +++ b/node/test/util/read_repo_ast_util.js @@ -1580,7 +1580,8 @@ describe("readRAST", function () { r, "x", "foo", - null); + null, + false); const ast = yield ReadRepoASTUtil.readRAST(r); const headId = yield r.getHeadCommit(); @@ -1626,7 +1627,8 @@ describe("readRAST", function () { r, "x", "foo", - null); + null, + false); const subCommit = yield TestUtil.generateCommit(subRepo); yield NodeGit.Checkout.tree(subRepo, subCommit, { checkoutStrategy: NodeGit.Checkout.STRATEGY.FORCE, diff --git a/node/test/util/submodule_config_util.js b/node/test/util/submodule_config_util.js index 6782ec876..5468cfeb9 100644 --- a/node/test/util/submodule_config_util.js +++ b/node/test/util/submodule_config_util.js @@ -672,7 +672,8 @@ foo repo, subName, url, - null); + null, + false); assert.instanceOf(result, NodeGit.Repository); assert(TestUtil.isSameRealPath(result.workdir(), path.join(repoPath, subName))); @@ -714,7 +715,8 @@ foo repo, "foo", url, - null); + null, + false); const remote = yield newSub.getRemote("origin"); const newUrl = remote.url(); assert.equal(newUrl, url); @@ -789,7 +791,8 @@ foo repo, "foo", url, - templateDir); + templateDir, + false); const copiedPath = path.join(repo.path(), "modules", diff --git a/npm-debug.log b/npm-debug.log new file mode 100644 index 000000000..0273b6491 --- /dev/null +++ b/npm-debug.log @@ -0,0 +1,25 @@ +0 info it worked if it ends with ok +1 verbose cli [ '/Users/DaisyZhu/.nvm/versions/node/v6.14.4/bin/node', +1 verbose cli '/Users/DaisyZhu/.nvm/versions/node/v6.14.4/bin/npm', +1 verbose cli 'test', +1 verbose cli '--', +1 verbose cli '--grep', +1 verbose cli 'jshint' ] +2 info using npm@3.10.10 +3 info using node@v6.14.4 +4 verbose stack Error: ENOENT: no such file or directory, open '/Users/DaisyZhu/workspace/twosigma/git-meta/package.json' +4 verbose stack at Error (native) +5 verbose cwd /Users/DaisyZhu/workspace/twosigma/git-meta +6 error Darwin 17.7.0 +7 error argv "/Users/DaisyZhu/.nvm/versions/node/v6.14.4/bin/node" "/Users/DaisyZhu/.nvm/versions/node/v6.14.4/bin/npm" "test" "--" "--grep" "jshint" +8 error node v6.14.4 +9 error npm v3.10.10 +10 error path /Users/DaisyZhu/workspace/twosigma/git-meta/package.json +11 error code ENOENT +12 error errno -2 +13 error syscall open +14 error enoent ENOENT: no such file or directory, open '/Users/DaisyZhu/workspace/twosigma/git-meta/package.json' +15 error enoent ENOENT: no such file or directory, open '/Users/DaisyZhu/workspace/twosigma/git-meta/package.json' +15 error enoent This is most likely not a problem with npm itself +15 error enoent and is related to npm not being able to find a file. +16 verbose exit [ -2, true ]