diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..1963b6f5f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: CI +on: [push, pull_request, workflow_dispatch] +jobs: + Test-Git-Meta: + runs-on: ubuntu-20.04 + steps: + - run: echo "The job was automatically triggered by a ${{ github.event_name }} event." + - run: sudo apt-get install libkrb5-dev + - name: Check out repository code + uses: actions/checkout@v2 + - run: git config --global user.name "A U Thor" + - run: git config --global user.email author@example.com + - run: yarn install + working-directory: ${{ github.workspace }}/node + - run: yarn test + working-directory: ${{ github.workspace }}/node + - run: echo "This job's status is ${{ job.status }}." diff --git a/.jshintrc b/.jshintrc index dc4d9bbd1..946454b8d 100644 --- a/.jshintrc +++ b/.jshintrc @@ -12,7 +12,8 @@ "undef" : true, "node" : true, "unused" : true, - "esnext" : true, + "esversion": 8, + "noyield" : true, "varstmt" : true, "predef": [ "Intl" diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 4a2ae80b0..000000000 --- a/.travis.yml +++ /dev/null @@ -1,29 +0,0 @@ -language: node_js -node_js: - - "6.9.1" - -env: - - CC=gcc-5 -addons: - apt: - sources: - - ubuntu-toolchain-r-test - packages: - - gcc-5 - - g++-5 - -before_install: - - sudo add-apt-repository ppa:git-core/ppa -y - - sudo apt-get update -q - - sudo apt-get install git -y - - git version - - cd node - -install: - - npm install - -script: - - npm test - -notifications: - email: false diff --git a/README.md b/README.md index 14c9498d9..ab951ece4 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@

+[![Build Status](https://travis-ci.org/twosigma/git-meta.svg?branch=master)](https://travis-ci.org/twosigma/git-meta) + # What is git-meta? Git-meta allows developers to work with extremely large codebases -- diff --git a/doc/architecture.md b/doc/architecture.md index 71c143e81..20ba9dc5a 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -108,23 +108,29 @@ products that take a similar approach: - [Gitslave](http://gitslave.sourceforge.net) - [myrepos](https://myrepos.branchable.com) -- [Android Repo](https://source.android.com/source/using-repo.html) +- [git-repo](https://gerrit.googlesource.com/git-repo/) - [gclient](http://dev.chromium.org/developers/how-tos/depottools#TOC-gclient) - [Git subtrees](https://git-scm.com/book/en/v1/Git-Tools-Subtree-Merging) - [Git submodules](https://git-scm.com/docs/git-submodule) All of these tools overlap with the problems git-meta is trying to solve, but -none of them are sufficient: +none of them is sufficient: -- most don't provide a way to reference the state of all repositories - (Gitslave, Android Repo, Myrepos) -- some require a custom server (Android Repo) -- many are strongly focused on supporting a specific software platform (Android - Repo, gclient) +- Most don't provide a way to reference the state of all repositories (Gitslave + and Myrepos). git-repo has the ability to reference the state of all repos, + but not in a way that can be used easily with normal Git commands (the state + is tracked in an XML file in a separate repository). +- or are strongly focused on supporting a specific piece of software (gclient) - doesn't fully solve the scaling issue (Git subtrees) - prohibitively difficult to use (Git submodules) - lack scalable collaboration (e.g., pull request) strategies +The git-repo project uses an approach that is structurally similar to the one +used by git-meta: a (remote) meta-repo tracks the state of the sub-repos in an +XML file. It does not generally try to provide a full suite of +cross-repository operations (such as `rebase`, `cherry-pick`, etc.) and assumes +the use of the Gerrit code review tool. + Git submodules come the closest: they do provide the technical ability to solve the problem, but are very difficult to use and lack some of the desired features. With git-meta, we build on top of Git submodules to provide the @@ -292,13 +298,13 @@ explain our high-level branching and tagging strategy. Then, we define synthetic-meta-refs and discuss how they fit into git-meta workflows. Finally, we present two variations on synthetic-meta-refs to help illustrate how the concept evolved; one of these variations, *mega-refs*, may prove necessary for -old version of Git. +old versions of Git. ## Branches and Tags In git-meta, branches and tags are applied only to meta-repos. Because each commit in a meta-repo unambiguously describes the state of all sub-repos, it is -unnecessary to apply branches and tags to sup-repos. Furthermore, as described +unnecessary to apply branches and tags to sub-repos. Furthermore, as described in the "Design and Evolution" section below, schemes relying on sub-repo branches proved to be impractical. @@ -322,7 +328,7 @@ of this strategy on client-side checkouts. A synthetic-meta-ref is a ref in a sub-repo whose name includes the commit ID of the commit to which it points, such as: -`refs/meta/929e8afc03fef8d64249ad189341a4e8889561d7`. The term is derived from +`refs/commits/929e8afc03fef8d64249ad189341a4e8889561d7`. The term is derived from the fact that such a ref is: 1. _synthetic_ -- generated by a tool @@ -341,23 +347,23 @@ A mono-repo has two invariants with respect to synthetic-meta-refs: Some mono-repos in valid states: ``` -'-------------------` '-------------------` -| meta-repo | | a | -| - - - - - - - - - | | - - - - - - - - - | -| master | a [a1] | | refs/meta/a1 [a1] | -`-------------------, `-------------------, +'-------------------` '-----------------------` +| meta-repo | | a | +| - - - - - - - - - | | - - - - - - - - - - - | +| master | a [a1] | | refs/commits/a1 [a1] | +`-------------------, `-----------------------, The 'master' branch in the meta-repo indicates commit 'a1' for repo 'a' and a valid synthetic-meta-ref exists. ``` ``` -'-------------------` '-------------------` -| meta-repo | | a | -| - - - - - - - - - | | - - - - - - - - - | -| master | a [a1] | | refs/meta/a1 [a1] | -| - - - - -+- - - - | | refs/meta/ab [ab] | -| release | a [ab] | `-------------------, +'-------------------` '-----------------------` +| meta-repo | | a | +| - - - - - - - - - | | - - - - - - - - - - - | +| master | a [a1] | | refs/commits/a1 [a1] | +| - - - - -+- - - - | | refs/commits/ab [ab] | +| release | a [ab] | `-----------------------, `-------------------, The meta-repo has another branch, 'release', indicating commit 'ab' in 'a', @@ -365,11 +371,11 @@ which also has a valid synthetic-meta-ref. ``` ``` -'-----------------------` '-------------------` -| meta-repo | | a | -| - - - - - - - - - - - | | - - - - - - - - - | -| master | a [a1] | | refs/meta/a2 [a2] | -| - - - - -+- - - - - - | `-------------------, +'-----------------------` '-----------------------` +| meta-repo | | a | +| - - - - - - - - - - - | | - - - - - - - - - - - | +| master | a [a1] | | refs/commits/a2 [a2] | +| - - - - -+- - - - - - | `-----------------------, | release | a [a2->a1] | `-----------------------, @@ -381,31 +387,31 @@ for 'a1'. A few mono-repos in invalid states: ``` -'-------------------` '-------------------` -| meta-repo | | a | -| - - - - - - - - - | | - - - - - - - - - | -| master | a [a1] | | refs/meta/a1 [a2] | -`-------------------, `-------------------, +'-------------------` '-----------------------` +| meta-repo | | a | +| - - - - - - - - - | | - - - - - - - - - - - | +| master | a [a1] | | refs/commits/a1 [a2] | +`-------------------, `-----------------------, The synthetic-meta-ref for 'a1' does not point to 'a1'. ``` ``` -'-------------------` '-------------------` -| meta-repo | | a | -| - - - - - - - - - | | - - - - - - - - - | -| master | a [a1] | | refs/meta/ab [ab] | -`-------------------, `-------------------, +'-------------------` '-----------------------` +| meta-repo | | a | +| - - - - - - - - - | | - - - - - - - - - - - | +| master | a [a1] | | refs/commits/ab [ab] | +`-------------------, `-----------------------, No synthetic-meta-ref for commit 'a1'. ``` ``` -'-----------------------` '-------------------` -| meta-repo | | a | -| - - - - - - - - - - - | | - - - - - - - - - | -| master | a [a1] | | refs/meta/a1 [a1] | -| - - - - -+- - - - - - | `-------------------, +'-----------------------` '-----------------------` +| meta-repo | | a | +| - - - - - - - - - - - | | - - - - - - - - - - - | +| master | a [a1] | | refs/commits/a1 [a1] | +| - - - - -+- - - - - - | `-----------------------, | release | a [a2->a1] | `-----------------------, @@ -452,32 +458,32 @@ local `---------------------------------, remote -'---------------------` '--------------` '--------------` -| meta-repo | | | a | | b | -| master | a [a1] | | refs/meta/a1 | | refs/meta/b1 | -| [m1] | b [b1] | | [a1] | | [b1] | -`---------------------, `--------------, `--------------, +'---------------------` '-----------------` '-----------------` +| meta-repo | | | a | | b | +| master | a [a1] | | refs/commits/a1 | | refs/commits/b1 | +| [m1] | b [b1] | | [a1] | | [b1] | +`---------------------, `-----------------, `-----------------, ``` Where we have new commits, `a2` and `b2` in repos `a` and `b`, respectively, and a new meta-repo commit, `m2` that references them. Note that `a1` and `b1` -have appropriate synthetic-meta-refs +have appropriate synthetic-meta-refs After invoking `git meta push`, the remote repos would look like: ``` -'---------------------` '--------------` '--------------` -| meta-repo | | | a | | b | -| master | a [a2] | | refs/meta/a1 | | refs/meta/b1 | -| [m2] | b [b2] | | [a1] | | [b1] | -`---------------------, | refs/meta/a2 | | refs/meta/b2 | - | [a2] | | [b2] | - `--------------, `--------------, +'---------------------` '-----------------` '-----------------` +| meta-repo | | | a | | b | +| master | a [a2] | | refs/commits/a1 | | refs/commits/b1 | +| [m2] | b [b2] | | [a1] | | [b1] | +`---------------------, | refs/commits/a2 | | refs/commits/b2 | + | [a2] | | [b2] | + `-----------------, `-----------------, ``` Note that `git meta push` created meta-refs in the sub-repos for the new commits before it updated the meta-repo. If the process had been interrupted, -for example, after pushing `refs/meta/a2` but before pushing `refs/meta/b2`, +for example, after pushing `refs/commits/a2` but before pushing `refs/commits/b2`, the mono-repo would still be in a valid state. If no meta-repo commit ever -referenced `a2`, the synthetic-meta-ref `refs/meta/a2` would eventually be +referenced `a2`, the synthetic-meta-ref `refs/commits/a2` would eventually be cleand up. ### Client-side access to sub-repo commits diff --git a/doc/stitch.md b/doc/stitch.md new file mode 100644 index 000000000..e4bc335e5 --- /dev/null +++ b/doc/stitch.md @@ -0,0 +1,69 @@ + + +# Overview + +The stitcher stitches a git-meta repository into a single unified +repository (perhaps leaving behind some submodules, depending on +configuration). + +# Motivation + +You might want this because you are migrating away from git-meta. Or +you might just want a unified repo to power code search or code review +tooling. + +There's also a destitched, which reverses the process. Of course, +that's a little trickier: if you create a new subdirectory which +itself contains two subdirectories, how many sub*modules* do you +create? + +# An implementation note + +The most efficient repository layout that we have yet discovered for +stitching has three repositories: +1. just the meta objects +2. just the submodule objects +3. the stitched commits, which has objects/info/alternates pointed at +(1) and (2). + +This makes fetches from the meta repo faster, without making other +fetches slower. + +If you can managed to remove the alternates for meta commits during +pushes from the local unity repo to a remote one, that's even faster. + +The configuration variable gitmeta.stitchSubmodulesRepository can hold +the path to the submodule-only repo; if it's present, the stitcher +assumes that the alternates configuration is set up correctly. + +We have not yet considered the efficiency of destitching, and it does +not support this configuration variable. \ No newline at end of file diff --git a/node/.gitignore b/node/.gitignore index 25c74d353..de9a96fbc 100644 --- a/node/.gitignore +++ b/node/.gitignore @@ -1,2 +1,5 @@ /node_modules/ /coverage +/yarn/ +.vscode + diff --git a/node/bin/destitch b/node/bin/destitch new file mode 100755 index 000000000..068a35540 --- /dev/null +++ b/node/bin/destitch @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/* + * Copyright (c) 2018, 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. + */ + +require("./../lib/destitch.js"); + diff --git a/node/bin/patch-tree b/node/bin/patch-tree new file mode 100755 index 000000000..126f40adc --- /dev/null +++ b/node/bin/patch-tree @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/* + * Copyright (c) 2022, 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. + */ + +require("./../lib/patch-tree.js"); + diff --git a/node/bin/stitch b/node/bin/stitch new file mode 100755 index 000000000..0d7b9090c --- /dev/null +++ b/node/bin/stitch @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/* + * Copyright (c) 2018, 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. + */ + +require("./../lib/stitch.js"); + diff --git a/node/lib/cmd/add.js b/node/lib/cmd/add.js index 7aeec8df2..19709f90f 100644 --- a/node/lib/cmd/add.js +++ b/node/lib/cmd/add.js @@ -60,6 +60,22 @@ to submodules with new commits in their working directories. `; exports.configureParser = function (parser) { + parser.addArgument(["-u", "--update"], { + required: false, + action: "storeConst", + constant: true, + help: "Update tracked files.", + defaultValue:false + }); + + parser.addArgument(["-v", "--verbose"], { + required: false, + action: "storeConst", + constant: true, + help: `Log which files are added to / removed from the index`, + defaultValue: false, + }); + parser.addArgument(["--meta"], { required: false, action: "storeConst", @@ -69,8 +85,9 @@ Include changes to the meta-repo; disabled by default to improve performance \ and avoid accidental changes to the meta-repo.`, defaultValue: false, }); + parser.addArgument(["paths"], { - nargs: "+", + nargs: "*", type: "string", help: "the paths to add", }); @@ -84,15 +101,44 @@ and avoid accidental changes to the meta-repo.`, * @param {String[]} args.paths */ exports.executeableSubcommand = co.wrap(function *(args) { - const Add = require("../util/add"); - const GitUtil = require("../util/git_util"); + const colors = require("colors"); + + const Add = require("../util/add"); + const GitUtil = require("../util/git_util"); + const StatusUtil = require("../util/status_util"); + const PrintStatusUtil = require("../util/print_status_util"); const repo = yield GitUtil.getCurrentRepo(); const workdir = repo.workdir(); const cwd = process.cwd(); - const paths = yield args.paths.map(filename => { - return GitUtil.resolveRelativePath(workdir, cwd, filename); - }); - yield Add.stagePaths(repo, paths, args.meta); + let userPaths = args.paths; + if (userPaths.length === 0) { + if (args.update) { + const repoStatus = yield StatusUtil.getRepoStatus(repo, { + cwd: cwd, + paths: args.path, + untrackedFilesOption: args.untrackedFilesOption + }); + + const fileStatuses = PrintStatusUtil.accumulateStatus(repoStatus); + const workdirChanges = fileStatuses.workdir; + userPaths = workdirChanges.map(workdirChange => { + return workdirChange.path; + }); + } + else { + const text = "Nothing specified, nothing added.\n" + + `${colors.yellow("hint: Maybe you wanted to say ")}` + + `${colors.yellow("'git meta add .'?")}\n`; + process.stdout.write(text); + return; + } + } + else { + userPaths = args.paths.map(filename => { + return GitUtil.resolveRelativePath(workdir, cwd, filename); + }); + } + yield Add.stagePaths(repo, userPaths, args.meta, args.update, args.verbose); }); diff --git a/node/lib/cmd/add_submodule.js b/node/lib/cmd/add_submodule.js index e9c97e117..b9e73124c 100644 --- a/node/lib/cmd/add_submodule.js +++ b/node/lib/cmd/add_submodule.js @@ -78,8 +78,9 @@ exports.executeableSubcommand = co.wrap(function *(args) { const fs = require("fs-promise"); const path = require("path"); - const GitUtil = require("../util/git_util"); const AddSubmodule = require("../util/add_submodule"); + const GitUtil = require("../util/git_util"); + const Hook = require("../util/hook"); const UserError = require("../util/user_error"); if (null !== args.branch && null === args.import_from) { @@ -121,7 +122,10 @@ The path ${colors.red(args.path)} already exists.`); if (null === importArg) { console.log(`\ Added new sub-repo ${colors.blue(args.path)}. It is currently empty. Please -stage changes and/or make a commit before finishing with 'git meta commit'; +stage changes under sub-repo before finishing with 'git meta commit'; you will not be able to use 'git meta commit' until you do so.`); } + + //Run post-add-submodule hook with submodule names which added successfully. + yield Hook.execHook(repo, "post-add-submodule", [args.path]); }); diff --git a/node/lib/cmd/checkout.js b/node/lib/cmd/checkout.js index 06fcd736d..fb0b20544 100644 --- a/node/lib/cmd/checkout.js +++ b/node/lib/cmd/checkout.js @@ -51,16 +51,22 @@ exports.configureParser = function (parser) { nargs: 1, }); - parser.addArgument(["committish"], { + parser.addArgument(["committish_or_file"], { type: "string", - help: ` -commit to check out. If this is not found, but does \ + help: `if this resolves to a commit, check out that commit. +If this is not found, but does \ match a single remote tracking branch, treat as equivalent to \ -'checkout -b -t /'`, +'checkout -b -t /'. Else, treat \ + as a file`, defaultValue: null, nargs: "?", }); + parser.addArgument(["files"], { + type: "string", + nargs: "*" + }); + parser.addArgument(["-t", "--track"], { help: "Set tracking branch.", action: "storeConst", @@ -75,6 +81,40 @@ match a single remote tracking branch, treat as equivalent to \ }); }; +/* +Argparse literally can't represent git's semantics here. Checkout +takes optional arguments [branch] and [files]. In git, anything after +"--" (hereinafter, "the separator") is part of "files". So if you say +"git checkout -- myfile", it'll assume that last argument is a file. + +There are two types of arguments that argparse recognizes: regular and +positional. Branch can't be a non-required regular argument, because +regular arguments must have names which start with "-". So it must be +a positional argument with nargs='?'. In argparse, the right side of +"--" is always positional arguments. And that means that the branch +will be read from the right side of the separator if it is not present +on the left side. + +(Incidentally, the same is true of Python's argparse, of which node's +argparse is a port.) + +Thanks, git, for using "--" for an utterly non-standard fashion. +*/ +function reanalyzeArgs(args) { + const separatorIndex = process.argv.indexOf("--"); + if (separatorIndex === -1) { + return; + } + + const countAfterSeparator = (process.argv.length - 1) - separatorIndex; + + if (args.files.length !== countAfterSeparator) { + const firstFile = args.committish_or_file; + args.committish_or_file = null; + args.files.splice(0, 0, firstFile); + } +} + /** * Execute the `checkout` command based on the supplied arguments. * @@ -83,10 +123,14 @@ match a single remote tracking branch, treat as equivalent to \ * @param {String} args.committish */ exports.executeableSubcommand = co.wrap(function *(args) { + reanalyzeArgs(args); + const colors = require("colors"); const Checkout = require("../util/checkout"); const GitUtil = require("../util/git_util"); + const Hook = require("../util/hook"); + const UserError = require("../../lib/util/user_error"); let newBranch = null; const newBranchNameArr = args["new branch name"]; @@ -94,13 +138,41 @@ exports.executeableSubcommand = co.wrap(function *(args) { newBranch = newBranchNameArr[0]; } const repo = yield GitUtil.getCurrentRepo(); + const headId = yield repo.getHeadCommit(); + const zeroSha = "0000000000000000000000000000000000000000"; + const oldHead = headId === null ? zeroSha : headId.id().tostrS(); // Validate and determine what operation we're actually doing. + let committish = args.committish_or_file; + let files = args.files; + + if (files.length > 0 && newBranch) { + throw new UserError(`Cannot update paths and switch to branch + '${newBranch}' at the same time.`); + } + const op = yield Checkout.deriveCheckoutOperation(repo, - args.committish, + committish, newBranch, - args.track || false); + args.track || false, + files); + + if (null === op.commit && !op.checkoutFromIndex && null === op.newBranch) { + throw new UserError(`pathspec '${committish}' did not match any \ +file(s) known to git.`); + } + + const newHead = op.commit; + + // If we're going to check out files, just do that + if (op.resolvedPaths !== null && + Object.keys(op.resolvedPaths).length !== 0) { + yield Checkout.checkoutFiles(repo, op); + yield Hook.execHook(repo, "post-checkout", + [oldHead, newHead, "0"]); + return; + } // If we're already on this branch, note it and exit. @@ -131,9 +203,18 @@ exports.executeableSubcommand = co.wrap(function *(args) { // In this case, we're not making a branch; just let the user know what // we checked out. - console.log(`Checked out ${colors.green(args.committish)}.`); + process.stderr.write(`Checked out ${colors.green(committish)}.\n`); } + // Run post-checkout hook. + // Note: The hook is given three parameters: the ref of the previous HEAD, + // the ref of the new HEAD (which may or may not have changed), and a flag + // indicating whether the checkout was a branch checkout (changing + // branches, flag = "1"), or a file checkout (retrieving a file from the + // index, flag = "0"). + + yield Hook.execHook(repo, "post-checkout", + [oldHead, newHead, "1"]); // If we made a new branch, let the user know about it. const newB = op.newBranch; diff --git a/node/lib/cmd/cherry_pick.js b/node/lib/cmd/cherry_pick.js new file mode 100644 index 000000000..725a99bf2 --- /dev/null +++ b/node/lib/cmd/cherry_pick.js @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2022, 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 co = require("co"); +const range = require("git-range"); + +const GitUtil = require("../util/git_util"); +const UserError = require("../util/user_error"); + +/** + * This module contains methods for implementing the `cherry-pick` command. + */ + +/** + * help text for the `cherry-pick` command + * @property {String} + */ +exports.helpText = `copy one or more existing commits, creating new commits.`; + +/** + * description of the `cherry-pick` command + * @property {String} + */ +exports.description =` +Apply a commit to the HEAD of the current branch. This command will not +execute if any visible repositories have uncommitted modifications. +Cherry-pick looks at the changes introduced by this commit and +applies them to HEAD, rewriting new submodule commits if they cannot be +fast-forwarded.`; + +exports.configureParser = function (parser) { + parser.addArgument(["commit"], { + nargs: "*", + type: "string", + help: "the commit to cherry-pick", + }); + parser.addArgument(["--continue"], { + action: "storeConst", + constant: true, + help: "continue an in-progress cherry-pick", + }); + parser.addArgument(["-n", "--no-commit"], { + action: "storeConst", + constant: true, + help: `cherry-picked commits are followed by a soft reset, leaving all + changes as staged instead of as commits.`, + }); + // TODO: Note that ideally we might do something similar to + // `git reset --merge`, but that would be (a) tricky and (b) it can fail, + // leaving the cherry-pick still in progress. + + parser.addArgument(["--abort"], { + action: "storeConst", + constant: true, + help: `\ +abort the cherry-pick and return to previous state, throwing away all changes`, + }); +}; + + +exports.isRange = function(spec) { + // note that this includes ^x, which is not precisely a range + return spec.search("\\.\\.|\\^[@!-]|^\\^") !== -1; +}; + +exports.resolveRange = co.wrap(function *(repo, commitArg) { + const colors = require("colors"); + + // range.parse doesn't offer "--no-walk", so if you pass in an arg + // that doesn't specify a range (["morx", "fleem"], say, as opposed to + // "morx..fleem"), it'll give all of the commits which are parents + // of HEAD. But ^morx fleem is treated as morx..fleem. + + // So we need to do some pre-parsing. + + // I did some basic testing to ensure that git-range matches the + // selection and ordering that git cherry-pick uses, and it seems + // to be correct (including in the surprising case where git + // cherry-pick will use the topological ordering of commits rather + // than the order given on the command-line). + + let commits = []; + if (commitArg.some(exports.isRange)) { + for (const arg of commitArg) { + if (arg.search("^@") !== -1) { + // TODO: patch git-range + throw new UserError(`\ +Could not handle ${arg}, because git-range does not support --no-walk. +Please pre-parse these args using regular git.`); + } + } + const r = yield range.parse(repo, commitArg); + commits = yield r.commits(); + } else { + for (let commitish of commitArg) { + let annotated = yield GitUtil.resolveCommitish(repo, commitish); + if (null === annotated) { + throw new UserError(`\ +Could not resolve ${colors.red(commitish)} to a commit.`); + } + const commit = yield repo.getCommit(annotated.id()); + commits.push(commit); + } + } + if (commits.length === 0) { + throw new UserError(`empty commit set passed`); + } + return commits; +}); + +/** + * Execute the `cherry-pick` command according to the specified `args`. + * + * @async + * @param {Object} args + * @param {String[]} args.commit + */ +exports.executeableSubcommand = co.wrap(function *(args) { + const path = require("path"); + const Reset = require("../util/reset"); + const Hook = require("../util/hook"); + const StatusUtil = require("../util/status_util"); + const PrintStatusUtil = require("../util/print_status_util"); + const CherryPickUtil = require("../util/cherry_pick_util"); + + const repo = yield GitUtil.getCurrentRepo(); + + if (args.continue + args.abort > 1) { + throw new UserError("Cannot use continue and abort together."); + } + + if (args.continue) { + if (args.commit.length) { + throw new UserError("Cannot specify a commit with '--continue'."); + } + const result = yield CherryPickUtil.continue(repo); + if (null !== result.errorMessage) { + throw new UserError(result.errorMessage); + } + return; // RETURN + } + + if (args.abort) { + if (args.commit.length) { + throw new UserError("Cannot specify a commit with '--abort'."); + } + yield CherryPickUtil.abort(repo); + return; // RETURN + } + + if (!args.commit.length) { + throw new UserError("No commit to cherry-pick."); + } + + // TOOD: check if we are mid-rebase already + + const commits = yield exports.resolveRange(repo, args.commit); + const result = yield CherryPickUtil.cherryPick(repo, commits); + + if (null !== result.errorMessage) { + throw new UserError(result.errorMessage); + } + + if (args.no_commit) { + const commitish = `HEAD~${commits.length}`; + const annotated = yield GitUtil.resolveCommitish(repo, commitish); + const commit = yield repo.getCommit(annotated.id()); + yield Reset.reset(repo, commit, Reset.TYPE.SOFT); + + const repoStatus = yield StatusUtil.getRepoStatus(repo); + const cwd = process.cwd(); + const relCwd = path.relative(repo.workdir(), cwd); + const statusText = PrintStatusUtil.printRepoStatus(repoStatus, relCwd); + process.stdout.write(statusText); + + yield Hook.execHook(repo, "post-reset"); + } +}); diff --git a/node/lib/cmd/cherrypick.js b/node/lib/cmd/cherrypick.js deleted file mode 100644 index a00ec89bf..000000000 --- a/node/lib/cmd/cherrypick.js +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (c) 2016, 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 co = require("co"); - -/** - * This module contains methods for implementing the `cherry-pick` command. - */ - -/** - * help text for the `cherry-pick` command - * @property {String} - */ -exports.helpText = `copy one or more existing commits, creating new commits.`; - -/** - * description of the `cherry-pick` command - * @property {String} - */ -exports.description =` -Apply some commits to the HEAD of the current branch. This command will not -execute if any visible repositories, including the meta-repository, have -uncommitted modifications. Each commit must identify a change in the -meta-repository. For each commit specified, cherry-pick the changes identified -in that commit to the meta-repository. If the change indicates new commits -in a sub-repository, cherry-pick those changes in the respective -sub-repository, opening it if necessary. Only after all sub-repository commits -have been "picked" will the commit in the meta-repository be made.`; - -exports.configureParser = function (parser) { - parser.addArgument(["commits"], { - nargs: "+", - type: "string", - help: "the commits to cherry-pick", - }); -}; - -/** - * Execute the `cherry-pick` command according to the specified `args`. - * - * @async - * @param {Object} args - * @param {String[]} args.commits - */ -exports.executeableSubcommand = co.wrap(function *(args) { - // TODO: - // - abort - // - continue - - const colors = require("colors"); - - const CherryPick = require("../util/cherrypick"); - const GitUtil = require("../util/git_util"); - - const repo = yield GitUtil.getCurrentRepo(); - - for (let i = 0; i < args.commits.length; ++i) { - let commitish = args.commits[i]; - let result = yield GitUtil.resolveCommitish(repo, commitish); - if (null === result) { - console.error(`Could not resolve ${colors.red(commitish)} to a \ -commit.`); - process.exit(-1); - } - else { - console.log(`Cherry-picking commit ${colors.green(result.id())}.`); - let commit = yield repo.getCommit(result.id()); - yield CherryPick.cherryPick(repo, commit); - } - } -}); diff --git a/node/lib/cmd/commit-shadow.js b/node/lib/cmd/commit-shadow.js new file mode 100644 index 000000000..9be8e8bc2 --- /dev/null +++ b/node/lib/cmd/commit-shadow.js @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2017, 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 co = require("co"); + +/** + * this module is the entrypoint for the `commit-shadow` command. + */ + +/** + * help text for the `commit-shadow` command + * + * @property {String} + */ +exports.helpText = + `Create a "shadow" commit, leaving index and HEAD unchanged.`; + +/** + * description of the `commit-shadow` comand + * + * @property {String} + */ +exports.description = `Create a "shadow" commit containing all local +modifications (including untracked files if '--include-untracked' is specified, +include only files in the specified directories following '--include-subrepos' +if it is specified, if unspecified all paths are considered) to all sub-repos +and then print the SHA of the created commit. If there are no local +modifications, print the SHA of HEAD. Do not modify the index or update HEAD +to point to the created commit. Note that this command ignores non-submodule +changes to the meta-repo. Note also that this command is meant for programmatic +use and its output format is stable.`; + +/** + * Configure the specified `parser` for the `commit` command. + * + * @param {ArgumentParser} parser + */ +exports.configureParser = function (parser) { + parser.addArgument(["-u", "--include-untracked"], { + required: false, + action: "storeConst", + constant: true, + defaultValue: false, + help: "include untracked files in the shadow commit", + }); + parser.addArgument(["-m", "--message"], { + type: "string", + action: "append", + required: true, + help: "commit message for shadow commits", + }); + parser.addArgument(["-e", "--epoch-timestamp"], { + required: false, + action: "storeConst", + constant: true, + defaultValue: false, + help: "deprecated, but same as '--increment-timestamp'", + }); + + parser.addArgument(["-i", "--increment-timestamp"], { + required: false, + action: "storeConst", + constant: true, + defaultValue: false, + help: "use timestamp of HEAD + 1 instead of current time", + }); + parser.addArgument(["-s", "--include-subrepos"], { + type: "string", + required: false, + nargs: "+", + help: "only include specified sub-repos", + }); +}; + +/** + * Execute the `commit-shadow` command according to the specified `args`. + * + * @async + * @param {Object} args + * @param {String} args.message + */ +exports.executeableSubcommand = co.wrap(function *(args) { + const GitUtil = require("../util/git_util"); + const StashUtil = require("../util/stash_util"); + + const repo = yield GitUtil.getCurrentRepo(); + const incrementTimestamp = + args.increment_timestamp || args.epoch_timestamp; + const includedSubrepos = args.include_subrepos || []; + const message = args.message ? args.message.join("\n\n") : null; + const result = yield StashUtil.makeShadowCommit(repo, + message, + incrementTimestamp, + false, + args.include_untracked, + includedSubrepos, + false); + if (null === result) { + const head = yield repo.getHeadCommit(); + console.log(head.id().tostrS()); + } + else { + console.log(result.metaCommit); + } +}); diff --git a/node/lib/cmd/commit.js b/node/lib/cmd/commit.js index 8f5451c8a..c1e91c40c 100644 --- a/node/lib/cmd/commit.js +++ b/node/lib/cmd/commit.js @@ -64,24 +64,16 @@ exports.configureParser = function (parser) { }); parser.addArgument(["-m", "--message"], { type: "string", - defaultValue: null, + action: "append", required: false, help: "commit message; if not specified will prompt" }); - parser.addArgument(["--meta"], { - required: false, - action: "storeConst", - constant: true, - help: ` -Include changes to the meta-repo; disabled by default to prevent mistakes.`, - defaultValue: false, - }); parser.addArgument(["--amend"], { required: false, action: "storeConst", constant: true, help: `\ -Amend the last commit, including newly staged chnages and, (if -a is \ +Amend the last commit, including newly staged changes and, (if -a is \ specified) modifications. Will fail unless all submodules changed in HEAD \ have matching commits and have no new commits.`, }); @@ -92,6 +84,13 @@ have matching commits and have no new commits.`, constant: true, help: `When amending, reuse previous messages without editing.`, }); + parser.addArgument(["--no-verify"], { + required: false, + action: "storeConst", + defaultValue: false, + constant: true, + help: `This option bypasses the pre-commit hooks.`, + }); parser.addArgument(["-i", "--interactive"], { required: false, action: "storeConst", @@ -116,40 +115,55 @@ the URL of submodules).`, const doCommit = co.wrap(function *(args) { const Commit = require("../util/commit"); const GitUtil = require("../util/git_util"); + const Hook = require("../util/hook"); const repo = yield GitUtil.getCurrentRepo(); const cwd = process.cwd(); + const message = args.message ? args.message.join("\n\n") : null; yield Commit.doCommitCommand(repo, cwd, - args.message, - args.meta, + message, args.all, args.file, args.interactive, - GitUtil.editMessage); + args.no_verify, + true); + yield Hook.execHook(repo, "post-commit"); }); const doAmend = co.wrap(function *(args) { - const Commit = require("../util/commit"); - const GitUtil = require("../util/git_util"); - const UserError = require("../util/user_error"); + const Commit = require("../util/commit"); + const GitUtil = require("../util/git_util"); + const Hook = require("../util/hook"); + const SequencerStateUtil = require("../util/sequencer_state_util"); + const UserError = require("../util/user_error"); const usingPaths = 0 !== args.file.length; + const message = args.message ? args.message.join("\n\n") : null; if (usingPaths) { throw new UserError("Paths not supported with amend yet."); } const repo = yield GitUtil.getCurrentRepo(); + const cwd = process.cwd(); + + const seq = yield SequencerStateUtil.readSequencerState(repo.path()); + if (seq) { + const ty = seq.type.toLowerCase(); + const msg = "You are in the middle of a " + ty + " -- cannot amend."; + throw new UserError(msg); + } yield Commit.doAmendCommand(repo, - process.cwd(), - args.message, - args.meta, + cwd, + message, args.all, args.interactive, - args.no_edit ? null : GitUtil.editMessage); + args.no_verify, + !args.no_edit); + yield Hook.execHook(repo, "post-commit"); }); /** @@ -178,6 +192,10 @@ exports.executeableSubcommand = function (args) { console.error("The use of '-i' and '-m' does not make sense."); process.exit(1); } + if (args.all && args.interactive) { + console.error("The use of '-i' and '-a' does not make sense."); + process.exit(1); + } if (args.amend) { return doAmend(args); // RETURN } diff --git a/node/lib/cmd/forward.js b/node/lib/cmd/forward.js index 7c2e9fdf9..48f131cab 100644 --- a/node/lib/cmd/forward.js +++ b/node/lib/cmd/forward.js @@ -30,53 +30,7 @@ */ "use strict"; -const ArgParse = require("argparse"); -const assert = require("chai").assert; -const co = require("co"); - -/** - * Return an object to be used in configuring the help of the main git-meta - * parwser. - * - * @param {String} name - * @return {Object} - * @return {String} return.helpText help description - * @return {String} return.description detailed description - * @return {Function} configureParser set up parser for this command - * @return {Function} executeableSubcommand function to invoke command - */ -exports.makeModule = function (name) { - - function configureParser(parser) { - parser.addArgument(["args"], { - type: "string", - help: `Arguments to pass to 'git ${name}'.`, - nargs: ArgParse.Const.REMAINDER, - }); - } - - const helpText = `\ -Invoke 'git -C $(git meta root) ${name}' with all arguments.`; - return { - helpText: helpText, - description: `\ -${helpText} See 'git ${name} --help' for more information.`, - configureParser: configureParser, - executeableSubcommand: function () { - assert(false, "should never get here"); - }, - }; -}; - -/** - * @property {Set} set of commands to forward - */ -exports.forwardedCommands = new Set([ - "branch", - "fetch", - "remote", - "tag", -]); +const co = require("co"); /** * Forward the specified `args` to the Git command having the specified `name`. @@ -87,17 +41,22 @@ exports.forwardedCommands = new Set([ exports.execute = co.wrap(function *(name, args) { const ChildProcess = require("child-process-promise"); - const GitUtil = require("../util/git_util"); + const GitUtilFast = require("../util/git_util_fast"); + + if (name === "diff") { + args.splice(0, 0, "--submodule=diff"); + } + const gitArgs = []; + + const rootDir = GitUtilFast.getRootGitDirectory(); + if (rootDir) { + gitArgs.push("-C", rootDir); + } + gitArgs.push(name); - const gitArgs = [ - "-C", - GitUtil.getRootGitDirectory(), - name, - ].concat(args); try { - yield ChildProcess.spawn("git", gitArgs, { + yield ChildProcess.spawn("git", gitArgs.concat(args), { stdio: "inherit", - shell: true, }); } catch (e) { diff --git a/node/lib/cmd/merge.js b/node/lib/cmd/merge.js index d17779b9d..b55407f72 100644 --- a/node/lib/cmd/merge.js +++ b/node/lib/cmd/merge.js @@ -31,6 +31,7 @@ "use strict"; const co = require("co"); +const fs = require("fs-promise"); /** * This module contains methods for implementing the `merge` command. @@ -58,7 +59,13 @@ exports.configureParser = function (parser) { parser.addArgument(["-m", "--message"], { type: "string", help: "commit message", - required: true, + action: "append", + required: false, + }); + parser.addArgument(["-F", "--message-file"], { + type: "string", + help: "commit message file name", + required: false, }); parser.addArgument(["--ff"], { help: "allow fast-forward merges; this is the default", @@ -75,9 +82,28 @@ exports.configureParser = function (parser) { action: "storeConst", constant: true, }); + parser.addArgument(["--do-not-recurse"], { + action: "append", + type: "string", + help: "treat all possible conflicts in a dir and its subdirectories" + + " as actual, without attempting to recursively merge inside the" + + " submodules", + }); parser.addArgument(["commit"], { type: "string", - help: "the commitish to merge" + help: "the commitish to merge", + defaultValue: null, + nargs: "?", + }); + parser.addArgument(["--continue"], { + action: "storeConst", + constant: true, + help: "continue an in-progress merge", + }); + parser.addArgument(["--abort"], { + action: "storeConst", + constant: true, + help: "abort an in-progress merge", }); }; @@ -95,41 +121,103 @@ exports.executeableSubcommand = co.wrap(function *(args) { const colors = require("colors"); - const Merge = require("../util/merge"); - const GitUtil = require("../util/git_util"); - const StatusUtil = require("../util/status_util"); - 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 = Merge.MODE; + const MODE = MergeCommon.MODE; let mode = MODE.NORMAL; - if (args.ff) { - if (args.ff_only) { - throw new UserError("--ff and --ff-only cannot be used together."); - } - if (args.no_ff) { - throw new UserError("--ff and --no-ff cannot be used together."); - } + if (args.message && args.message_file) { + throw new UserError("Cannot use -m and -F together."); } - else if (args.ff_only) { - if (args.no_ff) { - throw new UserError( - "--no-ff and --ff-only cannot be used together."); - } + + if (args.ff + args.continue + args.abort + args.no_ff + args.ff_only > 1) { + throw new UserError( + "Cannot use ff, no-ff, ff-only, abort, or continue together."); + } + + if (args.ff_only) { mode = MODE.FF_ONLY; } else if (args.no_ff) { mode = MODE.FORCE_COMMIT; } - const repo = yield GitUtil.getCurrentRepo(); - const status = yield StatusUtil.getRepoStatus(repo); - const commitish = yield GitUtil.resolveCommitish(repo, args.commit); + if (args.continue) { + if (null !== args.commit) { + throw new UserError("Cannot specify a commit with --continue."); + } + yield MergeUtil.continue(repo); + return; // RETURN + } + if (args.abort) { + if (null !== args.commit) { + throw new UserError("Cannot specify a commit with --abort."); + } + yield MergeUtil.abort(repo); + return; // RETURN + } + let commitName = args.commit; + if (null === commitName) { + commitName = yield GitUtil.getCurrentTrackingBranchName(repo); + } + if (null === commitName) { + throw new UserError("No remote for the current branch."); + } + const commitish = yield GitUtil.resolveCommitish(repo, commitName); if (null === commitish) { throw new UserError(`\ -Could not resolve ${colors.red(args.commit)} to a commit.`); +Could not resolve ${colors.red(commitName)} to a commit.`); } + const editMessage = function () { + const message = `\ +Merge of '${commitName}' + +# please enter a commit message to explain why this merge is necessary, +# especially if it merges an updated upstream into a topic branch. +# +# lines starting with '#' will be ignored, and an empty message aborts +# the commit. +`; + return GitUtil.editMessage(repo, message); + }; + const doNotRecurse = []; + for (const prefix of args.do_not_recurse || []) { + let noSlashPrefix; + if (prefix.endsWith("/")) { + noSlashPrefix = prefix.substring(0, prefix.length - 1); + } else { + noSlashPrefix = prefix; + } + doNotRecurse.push(noSlashPrefix); + } + + let message = args.message ? args.message.join("\n\n") : null; + if (args.message_file) { + message = yield fs.readFile(args.message_file, "utf8"); + } + const commit = yield repo.getCommit(commitish.id()); - yield Merge.merge(repo, status, commit, mode, args.message); + const result = yield MergeUtil.merge(repo, + null, + commit, + mode, + Open.SUB_OPEN_OPTION.FORCE_OPEN, + doNotRecurse, + message, + editMessage); + if (null !== result.errorMessage) { + throw new UserError(result.errorMessage); + } + + // Run post-merge hook if merge successfully. + // Fixme: --squash is not supported yet, once supported, need to parse 0/1 + // as arg into the post-merge hook, 1 means it is a squash merge, 0 means + // not. + yield Hook.execHook(repo, "post-merge", ["0"]); }); diff --git a/node/lib/cmd/merge_bare.js b/node/lib/cmd/merge_bare.js new file mode 100755 index 000000000..69c652a0a --- /dev/null +++ b/node/lib/cmd/merge_bare.js @@ -0,0 +1,169 @@ +/* + * 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 co = require("co"); +const fs = require("fs-promise"); + +/** + * This module contains methods for implementing the `merge-bare` command. + */ + +/** + * help text for the `merge` command + * @property {String} + */ +exports.helpText = `merge two commits with no need for a working directory, \ +print resulting merged commit sha`; + +/** + * description of the `merge` command + * @property {String} + */ +exports.description =` Merge changes from two commits. This command +can work with or without a working tree, resulting a dangling merged commit +that is not pointed by any refs. It will also abort if there are merge conflicts + between two commits.`; + +exports.configureParser = function (parser) { + parser.addArgument(["-m", "--message"], { + type: "string", + action: "append", + help: "commit message", + required: false, + }); + parser.addArgument(["-F", "--message-file"], { + type: "string", + help: "commit message file name", + required: false, + }); + + parser.addArgument(["ourCommit"], { + type: "string", + help: "our side commitish to merge", + }); + parser.addArgument(["theirCommit"], { + type: "string", + help: "their side commitish to merge", + }); + + parser.addArgument(["--no-ff"], { + help: "create a merge commit even if fast-forwarding is possible", + action: "storeConst", + constant: true, + }); + + parser.addArgument(["--do-not-recurse"], { + action: "append", + type: "string", + help: "treat all possible conflicts in a dir and its subdirectories" + + " as actual, without attempting to recursively merge inside the" + + " submodules", + }); +}; + +/** + * Execute the `merge_bare` command according to the specified `args`. + * + * @async + * @param {Object} args + * @param {String} args.commit + */ +exports.executeableSubcommand = co.wrap(function *(args) { + + const colors = require("colors"); + + const MergeUtil = require("../util/merge_util"); + const MergeCommon = require("../util/merge_common"); + const GitUtil = require("../util/git_util"); + const Open = require("../util/open"); + const UserError = require("../util/user_error"); + + if (Boolean(args.message) + Boolean(args.message_file) !== 1) { + throw new UserError("Use exactly one of -m and -F."); + } + + const repo = yield GitUtil.getCurrentRepo(); + const mode = args.no_ff ? + MergeCommon.MODE.FORCE_COMMIT : + MergeCommon.MODE.NORMAL; + let ourCommitName = args.ourCommit; + let theirCommitName = args.theirCommit; + if (null === ourCommitName || null === theirCommitName) { + throw new UserError("Two commits must be given."); + } + const ourCommitish = yield GitUtil.resolveCommitish(repo, ourCommitName); + if (null === ourCommitish) { + throw new UserError(`\ +Could not resolve ${colors.red(ourCommitName)} to a commit.`); + } + + const theirCommitish + = yield GitUtil.resolveCommitish(repo, theirCommitName); + if (null === theirCommitish) { + throw new UserError(`\ +Could not resolve ${colors.red(theirCommitName)} to a commit.`); + } + + const doNotRecurse = []; + for (const prefix of args.do_not_recurse || []) { + let noSlashPrefix; + if (prefix.endsWith("/")) { + noSlashPrefix = prefix.substring(0, prefix.length - 1); + } else { + noSlashPrefix = prefix; + } + doNotRecurse.push(noSlashPrefix); + } + + let message = args.message ? args.message.join("\n\n") : null; + if (args.message_file) { + message = yield fs.readFile(args.message_file, "utf8"); + } + + const ourCommit = yield repo.getCommit(ourCommitish.id()); + const theirCommit = yield repo.getCommit(theirCommitish.id()); + const noopEditor = function() {}; + const result = yield MergeUtil.merge(repo, + ourCommit, + theirCommit, + mode, + Open.SUB_OPEN_OPTION.FORCE_BARE, + doNotRecurse, + message, + noopEditor); + if (null !== result.errorMessage) { + throw new UserError(result.errorMessage); + } + if (null !== result.metaCommit) { + console.log(result.metaCommit); + } +}); diff --git a/node/lib/cmd/open.js b/node/lib/cmd/open.js index 2ea59b575..74bcb0e6b 100644 --- a/node/lib/cmd/open.js +++ b/node/lib/cmd/open.js @@ -34,7 +34,12 @@ * This module is the entry point for the `open` command. */ -const co = require("co"); +const assert = require("chai").assert; +const co = require("co"); +const path = require("path"); +const NodeGit = require("nodegit"); +const GitUtil = require("../util/git_util"); +const UserError = require("../util/user_error"); /** * help text for the `open` command @@ -65,22 +70,112 @@ exports.configureParser = function (parser) { parser.addArgument(["path"], { type: "string", help: "open all submodules at or in 'path'", - nargs: "+", + nargs: "*", + }); + + // TODO: allow revlist specs instead of just single commits in -c, + // which is why we are not also giving it a long argument name of + // --committish, since that would be weird with a revlist. + parser.addArgument(["-c"], { + action: "store", + type: "string", + help: "open all submodules modified in a commit, or half open \ + the submodules from this commit", + }); + parser.addArgument(["-f", "--force"], { + action: "storeTrue", + help: + "open existing submodules even if some requested ones don't exist", + }); + parser.addArgument(["--half"], { + action: "storeTrue", + help:"open the submodule in .git/modules only", }); }; +const parseArgs = co.wrap(function *(repo, args, commit) { + assert.instanceOf(repo, NodeGit.Repository); + + args.path = Array.from(new Set(args.path)); + if (args.path.length > 0) { + if (!args.half && args.c) { + // one can half open a path from a commit, or fully + // open all path in a commit, but cannot fully open + // some paths from a different commit, because that + // will mess up the workspace. + throw new UserError("-c should take a single argument"); + } + return Array.from(new Set(args.path)); + } + if (args.c === null) { + throw new UserError( + "Please supply a submodule to open, or -c $commitish"); + } + const tree = yield commit.getTree(); + const parent = yield commit.parent(0); + let parentTree = null; + if (parent) { + let parentCommit = yield NodeGit.Commit.lookup(repo, parent.id()); + parentTree = yield parentCommit.getTree(); + } + const diff = yield NodeGit.Diff.treeToTree(repo, parentTree, tree); + + const out = new Set(); + for (let i = 0; i < diff.numDeltas(); i ++) { + const delta = diff.getDelta(i); + const newFile = delta.newFile(); + if (newFile.id().iszero()) { + continue; + } + if (newFile.mode() !== NodeGit.TreeEntry.FILEMODE.COMMIT) { + continue; + } + out.add(newFile.path()); + } + return Array.from(out); +}); + +/** + * If commitish is given, return resolved commit object and an in-memory + * index loaded with the corresponding tree. + * + * Otherwise, return the head commit and default repo index. + */ +const getCommitAndIndex = co.wrap(function *(repo, commitish) { + if (commitish) { + const annotated = yield GitUtil.resolveCommitish(repo, commitish); + if (annotated === null) { + throw new UserError("Cannot resolve " + commitish + " to a commit"); + } + const commit = yield NodeGit.Commit.lookup(repo, annotated.id()); + const tree = yield commit.getTree(); + const index = yield repo.refreshIndex(); + yield index.readTree(tree); + return { + commit: commit, + index: index + }; + } else { + return { + index: yield repo.index(), + commit: yield repo.getHeadCommit(), + }; + } +}); + /** * Execute the `open` command according to the specified `args`. * * @param {Object} args * @param {String} args.path + * @param {String} args.c */ exports.executeableSubcommand = co.wrap(function *(args) { const colors = require("colors"); const DoWorkQueue = require("../util/do_work_queue"); - const GitUtil = require("../util/git_util"); const Open = require("../util/open"); + const SparseCheckoutUtil = require("../util/sparse_checkout_util"); const SubmoduleConfigUtil = require("../util/submodule_config_util"); const SubmoduleFetcher = require("../util/submodule_fetcher"); const SubmoduleUtil = require("../util/submodule_util"); @@ -89,32 +184,56 @@ exports.executeableSubcommand = co.wrap(function *(args) { const repo = yield GitUtil.getCurrentRepo(); const workdir = repo.workdir(); const cwd = process.cwd(); - const subs = yield SubmoduleUtil.getSubmoduleNames(repo); - const subsToOpen = yield SubmoduleUtil.resolveSubmoduleNames(workdir, - cwd, - subs, - args.path); - const index = yield repo.index(); + const {commit, index} = yield getCommitAndIndex(repo, args.c); + + const subs = Object.keys( + yield SubmoduleConfigUtil.getSubmodulesFromIndex(repo, index) + ); + + const paths = yield parseArgs(repo, args, commit); + const subsToOpen = yield SubmoduleUtil.resolveSubmodules(workdir, + cwd, + subs, + paths, + !args.force); + const subNames = Object.keys(subsToOpen); const shas = yield SubmoduleUtil.getCurrentSubmoduleShas(index, - subsToOpen); - const head = yield repo.getHeadCommit(); - const fetcher = new SubmoduleFetcher(repo, head); + subNames); + const fetcher = new SubmoduleFetcher(repo, commit); let failed = false; + let subsOpenSuccessfully = []; const openSubs = new Set(yield SubmoduleUtil.listOpenSubmodules(repo)); const templatePath = yield SubmoduleConfigUtil.getTemplatePath(repo); - const opener = co.wrap(function *(name, index) { + const opener = co.wrap(function *(name, idx) { if (openSubs.has(name)) { - console.warn(`Submodule ${colors.cyan(name)} is already open.`); + let provenance = ""; + for (let filename of subsToOpen[name]) { + let resolved = path.relative( + workdir, + path.resolve(cwd, filename)); + if (resolved !== name) { + provenance = ` (for filename ${resolved})`; + break; + } + } + + console.warn( + `Submodule ${colors.cyan(name)}${provenance} is already open.`); + return; // RETURN + } + + if (shas[idx] === null) { + console.warn(`Skipping unmerged submodule ${colors.cyan(name)}`); return; // RETURN } console.log(`\ -Opening ${colors.blue(name)} on ${colors.green(shas[index])}.`); +Opening ${colors.blue(name)} on ${colors.green(shas[idx])}.`); // If we fail to open due to an expected condition, indicated by // the throwing of a `UserError` object, catch and log the error, @@ -122,7 +241,13 @@ Opening ${colors.blue(name)} on ${colors.green(shas[index])}.`); // to open other (probably unaffected) repositories. try { - yield Open.openOnCommit(fetcher, name, shas[index], templatePath); + yield Open.openOnCommit(fetcher, + name, + shas[idx], + templatePath, + args.half); + subsOpenSuccessfully.push(name); + console.log(`Finished opening ${colors.blue(name)}.`); } catch (e) { if (e instanceof UserError) { @@ -134,9 +259,13 @@ Opening ${colors.blue(name)} on ${colors.green(shas[index])}.`); throw e; } } - console.log(`Finished opening ${colors.blue(name)}.`); }); - yield DoWorkQueue.doInParallel(subsToOpen, opener, 30); + yield DoWorkQueue.doInParallel(subNames, opener, 10); + + // Make sure the index entries are updated in case we're in sparse mode. + if (!args.half) { + yield SparseCheckoutUtil.setSparseBitsAndWriteIndex(repo, index); + } if (failed) { process.exit(1); diff --git a/node/lib/cmd/pull.js b/node/lib/cmd/pull.js index 1c4f357a1..afb0d5327 100644 --- a/node/lib/cmd/pull.js +++ b/node/lib/cmd/pull.js @@ -100,7 +100,7 @@ exports.executeableSubcommand = co.wrap(function *(args) { // TODO: move the following code into `util/push.js` and add test - const tracking = (yield GitUtil.getTrackingInfo(branch)) || {}; + const tracking = (yield GitUtil.getTrackingInfo(repo, branch)) || {}; // The source branch is (in order of preference): the name passed on the // commandline, the tracking branch name, or the current branch name. diff --git a/node/lib/cmd/push.js b/node/lib/cmd/push.js index 8062411ba..89f4e2b59 100644 --- a/node/lib/cmd/push.js +++ b/node/lib/cmd/push.js @@ -32,6 +32,8 @@ const co = require("co"); +const ForcePushSpec = require("../util/force_push_spec"); + /** * This module contains methods for pushing. */ @@ -79,9 +81,19 @@ if not specified.`, parser.addArgument(["-f", "--force"], { required: false, action: "storeConst", - constant: true, + constant: ForcePushSpec.Force, + defaultValue: ForcePushSpec.NoForce, help: `Attempt to push even if not a fast-forward change.`, }); + + parser.addArgument(["--force-with-lease"], { + required: false, + action: "storeConst", + constant: ForcePushSpec.ForceWithLease, + dest: "force", + help: `Force-push only if the remote ref is in the expected state +(i.e. matches the local version of that ref)`, + }); }; /** @@ -101,25 +113,39 @@ exports.executeableSubcommand = co.wrap(function *(args) { // TODO: this all needs to move into the `util` and get a test driver. const branch = yield repo.getCurrentBranch(); - const tracking = (yield GitUtil.getTrackingInfo(branch)) || {}; + const tracking = (yield GitUtil.getTrackingInfo(repo, branch)) || {}; + + // The repo is the value passed by the user, the tracking branch's remote, + // or just "origin", in order of preference. + + const remoteName = args.repository || tracking.pushRemoteName || "origin"; let strRefspecs = []; if (0 === args.refspec.length) { + // We will use the `push.default` `upstream` strategy for now: (see + // https://git-scm.com/docs/git-config). + // That is, if there is a tracking (merge) branch configured, and the + // remote for that branch is the one we're pushing to, we'll use it. + // Otherwise, we fall back on the name of the active branch. + // + // TODO: read and adhere to the configured value for `push.default`. + const activeBranchName = branch.shorthand(); - const targetName = tracking.branchName || activeBranchName; + let targetName = activeBranchName; + if (null !== tracking.branchName && + remoteName === tracking.remoteName) { + targetName = tracking.branchName; + } strRefspecs.push(activeBranchName + ":" + targetName); } else { strRefspecs = strRefspecs.concat(args.refspec); } - // The repo is the value passed by the user, the tracking branch's remote, - // or just "origin", in order of preference. - - const remoteName = args.repository || tracking.remoteName || "origin"; - yield strRefspecs.map(co.wrap(function *(strRefspec) { const refspec = GitUtil.parseRefspec(strRefspec); - const force = args.force || refspec.force || false; + // Force-push if the refspec explicitly tells us to do so (i.e. is + // prefixed with a '+'). + const force = refspec.force ? ForcePushSpec.Force : args.force; // If 'src' is empty, this is a deletion. Do not use the normal meta // push; there is no need to, e.g., push submodules, in this case. diff --git a/node/lib/cmd/rebase.js b/node/lib/cmd/rebase.js index 27d252543..dced3fb76 100644 --- a/node/lib/cmd/rebase.js +++ b/node/lib/cmd/rebase.js @@ -96,18 +96,28 @@ exports.executeableSubcommand = co.wrap(function *(args) { yield RebaseUtil.abort(repo); } else if (args.continue) { - yield RebaseUtil.continue(repo); + const result = yield RebaseUtil.continue(repo); + if (null !== result.errorMessage) { + throw new UserError(result.errorMessage); + } } else { - if (null === args.commit) { + let commitName = args.commit; + if (null === commitName) { + commitName = yield GitUtil.getCurrentTrackingBranchName(repo); + } + if (null === commitName) { throw new UserError(`No onto committish specified.`); } - const committish = yield GitUtil.resolveCommitish(repo, args.commit); + const committish = yield GitUtil.resolveCommitish(repo, commitName); if (null === committish) { throw new UserError( - `Could not resolve ${colors.red(args.commit)} to a commit.`); + `Could not resolve ${colors.red(commitName)} to a commit.`); } const commit = yield repo.getCommit(committish.id()); - yield RebaseUtil.rebase(repo, commit); + const result = yield RebaseUtil.rebase(repo, commit); + if (null !== result.errorMessage) { + throw new UserError(result.errorMessage); + } } }); diff --git a/node/lib/cmd/reset.js b/node/lib/cmd/reset.js index a60ab0034..22a08d20b 100644 --- a/node/lib/cmd/reset.js +++ b/node/lib/cmd/reset.js @@ -130,6 +130,7 @@ exports.executeableSubcommand = co.wrap(function *(args) { const path = require("path"); const GitUtil = require("../util/git_util"); + const Hook = require("../util/hook"); const Reset = require("../util/reset"); const UserError = require("../util/user_error"); const StatusUtil = require("../util/status_util"); @@ -206,4 +207,7 @@ exports.executeableSubcommand = co.wrap(function *(args) { const relCwd = path.relative(repo.workdir(), cwd); const statusText = PrintStatusUtil.printRepoStatus(repoStatus, relCwd); process.stdout.write(statusText); + + // Run post-reset hook. + yield Hook.execHook(repo, "post-reset"); }); diff --git a/node/lib/cmd/rm.js b/node/lib/cmd/rm.js new file mode 100644 index 000000000..e074c3600 --- /dev/null +++ b/node/lib/cmd/rm.js @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2018, 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 co = require("co"); + +/** + * This module contains methods for implementing the `rm` command. + */ + +/** + * help text for the `rm` command + * @property {String} + */ +exports.helpText = `Remove files or submodules from the mono-repo.`; + +/** + * description of the `rm` command + * @property {String} + */ +exports.description =` +This command updates the (logical) mono-repo index using the current content +found in the working tree, to prepare the content staged for the next commit. +If the path specified is a submodule, this command will stage the removal of +the submodule entirely (including removing it from .gitmodules). Otherwise, +its removal will be staged in the index. +`; + +exports.configureParser = function (parser) { + parser.addArgument(["-f", "--force"], { + required: false, + action: "storeConst", + constant: true, + help: "Force removal of a commit with stated changes.", + defaultValue:false + }); + + parser.addArgument(["-r", "--recursive"], { + required: false, + action: "storeConst", + constant: true, + help: "Remove a directory recursively", + defaultValue:false + }); + + parser.addArgument(["--cached"], { + required: false, + action: "storeConst", + constant: true, + help: ` + Remove the file from the index, but not from disk. In the case of a + submodule, edits to .gitmodules will staged (but the on-disk version + will not be affected)`, + defaultValue: false, + }); + parser.addArgument(["paths"], { + nargs: "+", + type: "string", + help: "the paths to rm", + }); +}; + +/** + * Execute the `rm` command according to the specified `args`. + * + * @async + * @param {Object} args + * @param {String[]} args.paths + */ +exports.executeableSubcommand = co.wrap(function *(args) { + const Rm = require("../util/rm"); + const GitUtil = require("../util/git_util"); + + const repo = yield GitUtil.getCurrentRepo(); + const workdir = repo.workdir(); + const cwd = process.cwd(); + + const paths = yield args.paths.map(filename => { + return GitUtil.resolveRelativePath(workdir, cwd, filename); + }); + yield Rm.rmPaths(repo, paths, args); +}); diff --git a/node/lib/cmd/root.js b/node/lib/cmd/root.js index bb2542902..e28b49c51 100644 --- a/node/lib/cmd/root.js +++ b/node/lib/cmd/root.js @@ -67,10 +67,10 @@ Print the relative path between current directory and root. E.g., \ exports.executeableSubcommand = co.wrap(function *(args) { const path = require("path"); - const GitUtil = require("../util/git_util"); - const UserError = require("../util/user_error"); + const GitUtilFast = require("../util/git_util_fast"); + const UserError = require("../util/user_error"); - const root = GitUtil.getRootGitDirectory(); + const root = GitUtilFast.getRootGitDirectory(); if (null === root) { throw new UserError("No root repo found."); } diff --git a/node/lib/cmd/stash.js b/node/lib/cmd/stash.js index c93231cb9..76a653287 100644 --- a/node/lib/cmd/stash.js +++ b/node/lib/cmd/stash.js @@ -54,10 +54,18 @@ and unstaged commits to the sub-repos.`; exports.configureParser = function (parser) { + parser.addArgument(["-m", "--message"], { + type: "string", + action: "append", + required: false, + help: "description; if not provided one will be generated", + }); + parser.addArgument("type", { help: ` -'save' to save a stash, 'pop' to restore, 'list' to show stashes; 'save' is -default`, +'save' to save a stash, 'pop' to restore, 'list' to show stashes, 'drop' to \ +discard a stash, 'apply' to apply a change without popping from stashes; \ +'save' is default`, type: "string", nargs: "?", defaultValue: "save", @@ -75,6 +83,13 @@ default`, action: "storeConst", constant: true, }); + + parser.addArgument("--index", { + help: `Reinstate not only the working tree's changes, but also \ +index's ones`, + action: "storeConst", + constant: true, + }); }; const doPop = co.wrap(function *(args) { @@ -83,7 +98,18 @@ const doPop = co.wrap(function *(args) { const repo = yield GitUtil.getCurrentRepo(); const index = (null === args.stash) ? 0 : args.stash; - yield StashUtil.pop(repo, index); + const reinstateIndex = args.index || false; + yield StashUtil.pop(repo, index, reinstateIndex, true); +}); + +const doApply = co.wrap(function *(args){ + const GitUtil = require("../../lib/util/git_util"); + const StashUtil = require("../../lib/util/stash_util"); + + const repo = yield GitUtil.getCurrentRepo(); + const index = (null === args.stash) ? 0 : args.stash; + const reinstateIndex = args.index || false; + yield StashUtil.pop(repo, index, reinstateIndex, false); }); function cleanSubs(status, includeUntracked) { @@ -91,7 +117,20 @@ function cleanSubs(status, includeUntracked) { for (let subName in subs) { const sub = subs[subName]; const wd = sub.workdir; - if (null !== wd && !wd.status.isClean(includeUntracked)) { + if (sub.index === null) { + // This sub was deleted + return false; + } + if (sub.commit.sha !== sub.index.sha) { + // The submodule has a commit which is staged in the meta repo's + // index + return false; + } + if (null === wd) { + continue; + } + if ((!wd.status.isClean(includeUntracked)) || + wd.status.headCommit !== sub.commit.sha) { return false; } } @@ -110,23 +149,48 @@ const doSave = co.wrap(function *(args) { const repo = yield GitUtil.getCurrentRepo(); const status = yield StatusUtil.getRepoStatus(repo); + StatusUtil.ensureReady(status); const includeUntracked = args.include_untracked || false; if (cleanSubs(status, includeUntracked)) { console.warn("Nothing to stash."); return; // RETURN } - yield StashUtil.save(repo, status, includeUntracked || false); + const message = args.message ? args.message.join("\n\n") : null; + yield StashUtil.save(repo, + status, + includeUntracked || false, + message); console.log("Saved working directory and index state."); }); -const doList = co.wrap(function *() { +const doList = co.wrap(function *(args) { const GitUtil = require("../../lib/util/git_util"); const StashUtil = require("../../lib/util/stash_util"); + const UserError = require("../../lib/util/user_error"); + + if (null !== args.message) { + throw new UserError("-m not compatible with list"); + } + const repo = yield GitUtil.getCurrentRepo(); const list = yield StashUtil.list(repo); process.stdout.write(list); }); +const doDrop = co.wrap(function *(args) { + const GitUtil = require("../../lib/util/git_util"); + const StashUtil = require("../../lib/util/stash_util"); + const UserError = require("../../lib/util/user_error"); + + if (null !== args.message) { + throw new UserError("-m not compatible with list"); + } + + const repo = yield GitUtil.getCurrentRepo(); + const index = (null === args.stash) ? 0 : args.stash; + yield StashUtil.removeStash(repo, index); +}); + /** * Execute the `stash` command according to the specified `args`. * @@ -137,8 +201,10 @@ exports.executeableSubcommand = function (args) { switch(args.type) { case "pop" : return doPop(args); + case "apply": return doApply(args); case "save": return doSave(args); case "list": return doList(args); + case "drop": return doDrop(args); default: { console.error(`Invalid type ${colors.red(args.type)}`); process.exit(1); diff --git a/node/lib/cmd/status.js b/node/lib/cmd/status.js index 357b264a0..abcd371ea 100644 --- a/node/lib/cmd/status.js +++ b/node/lib/cmd/status.js @@ -32,6 +32,8 @@ const co = require("co"); +const DiffUtil = require("../util/diff_util"); + /** * This module contains methods for pulling. */ @@ -56,13 +58,26 @@ sub-repo. Also show diagnostic information if the repository is in consistent state, e.g., when a sub-repo is on a different branch than the meta-repo.`; exports.configureParser = function (parser) { - parser.addArgument(["--meta"], { + parser.addArgument(["-s", "--short"], { required: false, action: "storeConst", constant: true, - help: ` -Include changes to the meta-repo; disabled by default to improve performance.`, - defaultValue: false, + help: "Give the output in a short format", + dest: "shortFormat" //"short" is a reserved word in js + }); + parser.addArgument(["-u", "--untracked-files"], { + required: false, + choices: [ + DiffUtil.UNTRACKED_FILES_OPTIONS.ALL, + DiffUtil.UNTRACKED_FILES_OPTIONS.NORMAL, + DiffUtil.UNTRACKED_FILES_OPTIONS.NO, + ], + constant: DiffUtil.UNTRACKED_FILES_OPTIONS.ALL, + defaultValue: DiffUtil.UNTRACKED_FILES_OPTIONS.NORMAL, + help: `show untracked files, optional modes: all, normal, no. + (Default:normal)`, + dest: "untrackedFilesOption", + nargs: "?", }); parser.addArgument(["path"], { type: "string", @@ -72,13 +87,12 @@ Include changes to the meta-repo; disabled by default to improve performance.`, }; /** - * Execute the pull command according to the specified `args`. + * Execute the status command according to the specified `args`. * * @async - * @param {Object} args - * @param {Boolean} args.any - * @param {String} repository - * @param {String} [source] + * @param {Object} args + * @param {Boolean} args.shortFormat + * @param {[String]} args.path */ exports.executeableSubcommand = co.wrap(function *(args) { const path = require("path"); @@ -90,12 +104,10 @@ exports.executeableSubcommand = co.wrap(function *(args) { const repo = yield GitUtil.getCurrentRepo(); const workdir = repo.workdir(); const cwd = process.cwd(); - const paths = yield args.path.map(filename => { - return GitUtil.resolveRelativePath(workdir, cwd, filename); - }); const repoStatus = yield StatusUtil.getRepoStatus(repo, { - showMetaChanges: args.meta, - paths: paths, + cwd: cwd, + paths: args.path, + untrackedFilesOption: args.untrackedFilesOption }); // Compute the current directory relative to the working directory of the @@ -103,6 +115,12 @@ exports.executeableSubcommand = co.wrap(function *(args) { const relCwd = path.relative(workdir, cwd); - const text = PrintStatusUtil.printRepoStatus(repoStatus, relCwd); + let text; + if (args.shortFormat) { + text = PrintStatusUtil.printRepoStatusShort(repoStatus, relCwd); + } else { + text = PrintStatusUtil.printRepoStatus(repoStatus, relCwd); + } + process.stdout.write(text); }); diff --git a/node/lib/cmd/submodule.js b/node/lib/cmd/submodule.js index 09be0cd85..11a0513ce 100644 --- a/node/lib/cmd/submodule.js +++ b/node/lib/cmd/submodule.js @@ -31,6 +31,7 @@ "use strict"; const co = require("co"); +const Forward = require("./forward"); /** * This module contains the command entry point for direct interactions with @@ -84,12 +85,13 @@ that submodule, followed by its name. ` help: "create references in sub-repos matching refs in the meta-repo", description: `\ Create references in sub-repos pointing to the commits indicated by the \ -reference having that name in the meta-repo. The default behavior is to \ -map every reference in the meta-repo into every open sub-repo.`, +reference having that name in the meta-repo.`, }); addRefsParser.addArgument(["path"], { - help: "if provided, open only submodules in selected paths", + help: ` +if provided, apply to submodules in selected paths only, otherwise apply to \ +all`, nargs: "*", }); @@ -119,30 +121,96 @@ ancestor of M (or M itself) that references S (or a descendant of S)`, required: false, defaultValue: "HEAD", }); + + const foreachParser = subParsers.addParser("foreach", { + help: "evaluate a shell command in each open sub-repo", + description: `\ +Evaluate a shell command in each open sub-repo. Any sub-repo in the \ +meta-repo that is not opened is ignored by this command.`, + }); + + // The foreach command is forwarded to vanilla git. + // These arguments exist solely to populate the --help section. + foreachParser.addArgument(["foreach-command"], { + help: "shell command to execute for each open sub-repo", + type: "string", + }); + + foreachParser.addArgument(["--recursive"], { + help: "sub-repos are traversed recursively", + defaultValue: false, + required: false, + action: "storeConst", + constant: true, + }); + + foreachParser.addArgument(["--quiet"], { + help: "only print error messages", + defaultValue: false, + required: false, + action: "storeConst", + constant: true, + }); }; const doStatusCommand = co.wrap(function *(paths, verbose) { - const path = require("path"); + // TODO: this is too big for a cmd; need to move some of this into a + // utility and write a test. - const GitUtil = require("../util/git_util"); - const PrintStatusUtil = require("../util/print_status_util"); - const StatusUtil = require("../util/status_util"); + const path = require("path"); + + const GitUtil = require("../util/git_util"); + const StatusUtil = require("../util/status_util"); + const SubmoduleConfigUtil = require("../util/submodule_config_util"); + const SubmoduleUtil = require("../util/submodule_util"); + const PrintStatusUtil = require("../util/print_status_util"); const repo = yield GitUtil.getCurrentRepo(); const workdir = repo.workdir(); const cwd = process.cwd(); - - paths = yield paths.map(filename => { + const relCwd = path.relative(workdir, cwd); + const head = yield repo.getHeadCommit(); + const tree = yield head.getTree(); + paths = paths.map(filename => { return GitUtil.resolveRelativePath(workdir, cwd, filename); }); const status = yield StatusUtil.getRepoStatus(repo, { paths: paths, showMetaChanges: false, }); - const relCwd = path.relative(workdir, cwd); - process.stdout.write(PrintStatusUtil.printSubmoduleStatus(status, - relCwd, - verbose)); + const urls = yield SubmoduleConfigUtil.getSubmodulesFromCommit(repo, head); + const allSubs = Object.keys(urls); + const subs = status.submodules; + const openList = Object.keys(subs).filter(name => { + return null !== subs[name].workdir; + }); + const open = new Set(openList); + let pathsToUse = allSubs; + if (0 !== paths.length) { + pathsToUse = Object.keys(SubmoduleUtil.resolvePaths(paths, + allSubs, + openList)); + } + const pathsSet = new Set(pathsToUse); + const subShas = {}; + for (let i = 0; i < allSubs.length; ++i) { + const name = allSubs[i]; + if (pathsSet.has(name) && (verbose || open.has(name))) { + const sub = subs[name]; + if (undefined === sub) { + const entry = yield tree.entryByPath(name); + subShas[name] = entry.sha(); + } + else { + subShas[name] = sub.index && sub.index.sha; + } + } + } + const result = PrintStatusUtil.printSubmoduleStatus(relCwd, + subShas, + open, + verbose); + process.stdout.write(result); }); const doFindCommand = co.wrap(function *(path, metaCommittish, subCommittish) { @@ -167,13 +235,12 @@ const doFindCommand = co.wrap(function *(path, metaCommittish, subCommittish) { // Here, we find which submodule `path` refers too. It migt be invalid by // referring to no submodule, or by referring to more than one. - const relPath = yield GitUtil.resolveRelativePath(workdir, - process.cwd(), - path); - const resolvedPaths = yield SubmoduleUtil.resolvePaths(workdir, - [relPath], - subNames, - openSubNames); + const relPath = GitUtil.resolveRelativePath(workdir, + process.cwd(), + path); + const resolvedPaths = SubmoduleUtil.resolvePaths([relPath], + subNames, + openSubNames); const paths = Object.keys(resolvedPaths); if (0 === paths.length) { throw new UserError(`No submodule found in ${colors.red(path)}.`); @@ -200,7 +267,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); + 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 @@ -241,29 +309,9 @@ meta-repo.`); } }); -const doAddRefs = co.wrap(function *(paths) { - const NodeGit = require("nodegit"); - - const GitUtil = require("../util/git_util"); - const SubmoduleUtil = require("../util/submodule_util"); - - const repo = yield GitUtil.getCurrentRepo(); - let subs = yield SubmoduleUtil.listOpenSubmodules(repo); - - if (0 !== paths.length) { - const workdir = repo.workdir(); - const cwd = process.cwd(); - const names = yield SubmoduleUtil.getSubmoduleNames(repo); - - const includedSubs = yield SubmoduleUtil.resolveSubmoduleNames(workdir, - cwd, - names, - paths); - const includedSet = new Set(includedSubs); - subs = subs.filter(name => includedSet.has(name)); - } - const refs = yield repo.getReferenceNames(NodeGit.Reference.TYPE.LISTALL); - yield SubmoduleUtil.addRefs(repo, refs, subs); +const doForeachCommand = co.wrap(function *() { + const args = process.argv.slice(3); + yield Forward.execute("submodule", args); }); /** @@ -280,7 +328,20 @@ exports.executeableSubcommand = function (args) { return doStatusCommand(args.path, args.verbose); } else if ("addrefs" === args.command) { - return doAddRefs(args.path); + const SyncRefs = require("../util/syncrefs.js"); + console.error( + "This command is deprecated -- use git meta sync-refs instead"); + if (args.path.length !== 0) { + console.error(`\ +Also, the paths argument is deprecated. Exiting with no change.`); + /*jshint noyield:true*/ + const fail = co.wrap(function*() {return 1;}); + return fail(); + } + return SyncRefs.doSyncRefs(); + } + else if ("foreach" === args.command) { + return doForeachCommand(); } return doFindCommand(args.path, args.meta_committish, diff --git a/node/lib/cmd/syncrefs.js b/node/lib/cmd/syncrefs.js new file mode 100644 index 000000000..85ca08134 --- /dev/null +++ b/node/lib/cmd/syncrefs.js @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2018, 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 co = require("co"); + +/** + * This module contains methods for implementing the `sync-refs` command. + */ + +/** + * help text for the `sync-refs` command + * @property {String} + */ +exports.helpText = + `Create refs in submodules which correspond to the refs in the meta repo.`; + +/** + * description of the `sync-refs` command + * @property {String} + */ +exports.description =` +For each ref in the monorepo, this command creates a ref in each open submodule +(which exists as-of that meta ref) pointing to the corresponding submodule +commit. +`; + +exports.configureParser = function (parser) { + /* This space intentionally left blank */ + return parser; +}; + +/** + * Execute the `sync-refs` command + * + * @async + * @param {Object} args + */ +exports.executeableSubcommand = co.wrap(function *() { + const SyncRefs = require("../util/syncrefs"); + + return yield SyncRefs.doSyncRefs(); +}); diff --git a/node/lib/destitch.js b/node/lib/destitch.js new file mode 100755 index 000000000..fefc87209 --- /dev/null +++ b/node/lib/destitch.js @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/* + * Copyright (c) 2018, 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. + */ + +const ArgumentParser = require("argparse").ArgumentParser; +const co = require("co"); +const NodeGit = require("nodegit"); + +const DestitchUtil = require("./util/destitch_util"); +const UserError = require("./util/user_error"); + +const description = `\ +Create a meta-repo commit from a stitched history and print its SHA.`; + +const parser = new ArgumentParser({ + addHelp: true, + description: description +}); + +parser.addArgument(["-r", "--remote"], { + required: true, + type: "string", + help: `The name of the remote for the meta-repo`, +}); + +parser.addArgument(["-b", "--branch"], { + required: true, + type: "string", + help: "If set BRANCH to the destitched commit", +}); + +parser.addArgument(["commitish"], { + type: "string", + help: "the commit to destitch", +}); + +co(function *() { + const args = parser.parseArgs(); + try { + const location = yield NodeGit.Repository.discover(".", 0, ""); + const repo = yield NodeGit.Repository.open(location); + yield DestitchUtil.destitch(repo, + args.commitish, + args.remote, + `refs/heads/${args.branch}`); + + } + catch (e) { + if (e instanceof UserError) { + console.error(e.message); + } else { + console.error(e.stack); + } + process.exit(1); + } +}); diff --git a/node/lib/generate-repo.js b/node/lib/generate-repo.js index 814e7c744..505a5e51f 100755 --- a/node/lib/generate-repo.js +++ b/node/lib/generate-repo.js @@ -153,8 +153,8 @@ function makeSubCommits(state, name, madeShas) { const numChanges = randomInt(2) + 1; for (let j = 0; j < numChanges; ++j) { const pathToUpdate = paths[randomInt(paths.length)]; - changes[pathToUpdate] = - state.nextCommitId + generateCharacter(); + changes[pathToUpdate] = new RepoAST.File( + state.nextCommitId + generateCharacter(), false); } } @@ -162,7 +162,8 @@ function makeSubCommits(state, name, madeShas) { if (undefined === lastHead || 0 === randomInt(6)) { const path = generatePath(randomInt(3) + 1); - changes[path] = state.nextCommitId + generateCharacter(); + changes[path] = new RepoAST.File( + state.nextCommitId + generateCharacter(), false); } const parents = undefined === lastHead ? [] : [lastHead]; lastHead = newHead; diff --git a/node/lib/git-meta.js b/node/lib/git-meta.js index 316df738b..47a59fb9c 100755 --- a/node/lib/git-meta.js +++ b/node/lib/git-meta.js @@ -36,35 +36,33 @@ */ const ArgumentParser = require("argparse").ArgumentParser; -const NodeGit = require("nodegit"); const add = require("./cmd/add"); const addSubmodule = require("./cmd/add_submodule"); const checkout = require("./cmd/checkout"); -const cherryPick = require("./cmd/cherrypick"); +const cherryPick = require("./cmd/cherry_pick"); const close = require("./cmd/close"); const commit = require("./cmd/commit"); const Forward = require("./cmd/forward"); const include = require("./cmd/include"); const listFiles = require("./cmd/list_files"); const merge = require("./cmd/merge"); +const mergeBare = require("./cmd/merge_bare"); const open = require("./cmd/open"); const pull = require("./cmd/pull"); const push = require("./cmd/push"); const rebase = require("./cmd/rebase"); const reset = require("./cmd/reset"); +const rm = require("./cmd/rm"); const root = require("./cmd/root"); -const submodule = require("./cmd/submodule"); +const commitShadow = require("./cmd/commit-shadow"); const stash = require("./cmd/stash"); const status = require("./cmd/status"); +const syncrefs = require("./cmd/syncrefs"); +const submodule = require("./cmd/submodule"); const UserError = require("./util/user_error"); const version = require("./cmd/version"); -// see https://github.com/nodegit/nodegit/issues/827 -- this is required -// to prevent random hard crashes with e.g. parallelism in index operations. -// Eventually, this will be nodegit's default. -NodeGit.setThreadSafetyStatus(NodeGit.THREAD_SAFETY.ENABLED_FOR_ASYNC_ONLY); - /** * Configure the specified `parser` to include the command having the specified * `commandName` implemented in the specified `module`. @@ -72,11 +70,9 @@ NodeGit.setThreadSafetyStatus(NodeGit.THREAD_SAFETY.ENABLED_FOR_ASYNC_ONLY); * @param {ArgumentParser} parser * @param {String} commandName * @param {Object} module - * @param {Function} module.configureParser - * @param {Function} module.executeableSubcommand - * @param {String} module.helpText + * @param {Boolean} skipNodeGit */ -function configureSubcommand(parser, commandName, module) { +function configureSubcommand(parser, commandName, module, skipNodeGit) { const subParser = parser.addParser(commandName, { help: module.helpText, description: module.description, @@ -84,6 +80,18 @@ function configureSubcommand(parser, commandName, module) { module.configureParser(subParser); subParser.setDefaults({ func: function (args) { + // This optimization allows to skip importing NodeGit + // if not required (eg. version). + if (!skipNodeGit) { + const NodeGit = require("nodegit"); + // see https://github.com/nodegit/nodegit/issues/827 -- this is + // required to prevent random hard crashes with e.g. + // parallelism in index operations. Eventually, this will be + // nodegit's default. + NodeGit.setThreadSafetyStatus( + NodeGit.THREAD_SAFETY.ENABLED_FOR_ASYNC_ONLY); + } + module.executeableSubcommand(args) .catch(function (error) { @@ -91,7 +99,10 @@ function configureSubcommand(parser, commandName, module) { // diagnostic message because the stack is irrelevant. if (error instanceof UserError) { - console.error(error.message); + if (error.message) { + console.error(error.message); + } + process.exit(error.code); } else { console.error(error.stack); @@ -108,7 +119,9 @@ commands will generally perform that same operation, but across a *meta* repository and the *sub* repositories that are locally *opened*. These commands work on any Git repository (even one without configured submodules); we do not provide duplicate commands for Git functionality that does not need -to be applied across sub-modules such as 'clone' and 'init'.`; +to be applied across sub-modules such as 'clone' and 'init'. Note that +git-meta will forward any subcommand that it does not implement to Git, +as if run with 'git -C $(git meta root) ...'.`; const parser = new ArgumentParser({ addHelp:true, @@ -126,39 +139,75 @@ const commands = { "include": include, "ls-files": listFiles, "merge": merge, + "merge-bare": mergeBare, "add-submodule": addSubmodule, "open": open, "pull": pull, "push": push, "rebase": rebase, "reset": reset, + "rm": rm, "root": root, + "commit-shadow": commitShadow, "stash": stash, "submodule": submodule, "status": status, + "sync-refs": syncrefs, "version": version, }; -// Configure forwarded commands. - -Array.from(Forward.forwardedCommands).forEach(name => { - commands[name] = Forward.makeModule(name); -}); +// These optimized commands do not require NodeGit, and can skip importing it. +const optimized = { + "root": true, + "version": true, +}; // Configure the parser with commands in alphabetical order. Object.keys(commands).sort().forEach(name => { const cmd = commands[name]; - configureSubcommand(subParser, name, cmd); + configureSubcommand(subParser, name, cmd, (undefined !== optimized[name])); }); +const do_not_forward = new Set([ + "--help", + "--version", + "-h", + "am", + "annotate", + "archimport", + "archive", + "blame", + "clean", + "cvsexportcommit", + "cvsimport", + "cvsserver", + "fast-export", + "fast-import", + "filter-branch", + "grep", + "merge-file", + "merge-index", + "merge-tree", + "mv", + "p4", + "quiltimport", + "revert", + "rm", + "shell", + "stage", + "svn", + "worktree", +]); + // If the first argument matches a forwarded sub-command, handle it manually. // I was not able to get ArgParse to allow unknown flags, e.g. // `git meta branch -r` to be passed to the REMAINDER positional argument on a // sub-parser level. if (2 < process.argv.length && - Forward.forwardedCommands.has(process.argv[2])) { + !do_not_forward.has(process.argv[2]) && + !(process.argv[2] in commands)) { const name = process.argv[2]; const args = process.argv.slice(3); Forward.execute(name, args).catch(() => { diff --git a/node/lib/patch-tree.js b/node/lib/patch-tree.js new file mode 100644 index 000000000..0e8c091a6 --- /dev/null +++ b/node/lib/patch-tree.js @@ -0,0 +1,178 @@ +#!/usr/bin/env node +/* + * Copyright (c) 2017, 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. + */ +const argparse = require("argparse"); +const ArgumentParser = argparse.ArgumentParser; +const co = require("co"); +const fs = require("fs"); +const NodeGit = require("nodegit"); +const TreeUtil = require("./util/tree_util"); +const UserError = require("./util/user_error"); + +const FILEMODE = NodeGit.TreeEntry.FILEMODE; +const RE_DIFF_RAW = /^:\d+ (\d+) [0-9a-f]{40} ([0-9a-f]{40}) \w\s(.+)$/; +const RE_SHORT_DIFF = /^(T|C|B|tree|commit|blob):([0-9a-f]{40})?:(.+)$/; +const DELETION_SHA = "0".repeat(40); +const ALLOWED_FILE_MODES = { + "100644": FILEMODE.BLOB, + "040000": FILEMODE.TREE, + "160000": FILEMODE.COMMIT, + "B": FILEMODE.BLOB, + "T": FILEMODE.TREE, + "C": FILEMODE.COMMIT, + "blob": FILEMODE.BLOB, + "tree": FILEMODE.TREE, + "commit": FILEMODE.COMMIT, +}; + +const description = `Creating a new tree by patching an existing one + with git-diff-tree output. Works in bare repository too. + +Suppose you are in a bare repo, and you want to create a new commit by + adding a file, you can run: + +echo ' +:000000 100644 0000000000000000000000000000000000000000 + a86fc6156eafad6fd0c40d17752da3232dded9b0 + A ts/foo/baz.txt' | amend-tree HEAD + +The input should have the same format as the output of "git diff-tree -r --raw". + +amend-tree will first upsert foo/bar/baz.txt as a leaf to HEAD's tree, and then +recursively running mktree for "foo/bar", "foo/" and then root. +ss +Tree entry change can also be defined by short hand diff. + +For example: + t1=$(patch-tree -s "commit::ts/modeling/bamboo/core" HEAD) + patch-tree -s \ + "T:467c15c20ab76a4fc89c6c09b4f047e31d531879:ts/modeling/bamboo/core" $t1 + +means removing the commit at 'ts/modeling/bamboo/core' and adding its tree. +`; + + +const parser = new ArgumentParser({ + addHelp: true, + description: description, +}); + +parser.addArgument(["-F", "--diff-file"], { + type: "string", + help: "File from which to read diff-tree style input.", + required: false, +}); + +parser.addArgument(["-s", "--short"], { + defaultValue: false, + required: false, + action: "storeConst", + constant: true, + help: `Diff format, either raw: '${RE_DIFF_RAW}' ` + + `or short: '${RE_SHORT_DIFF}'.` +}); + +parser.addArgument(["treeish"], { + type: "string", + help: "tree to amend", +}); + + +/** + * Read the output from "git diff-tree --raw" and return a map of + * structured tree entry changes. + * + * @param {str} diff multi-lines of git diff-tree output + * @returns {Object} changes map from path to `TreeUtil.Change` + */ +const getChanges = ( + diff, + isShort +) => diff.split(/\r\n|\r|\n/).reduce((acc, line) => { + const PAT = isShort ? RE_SHORT_DIFF : RE_DIFF_RAW; + const match = PAT.exec(line); + if (!line) { + return acc; + } + if (!match) { + throw new UserError(`'${line}' is invalid, accept format: ` + PAT); + } + const mode = ALLOWED_FILE_MODES[match[1]]; + const blobId = match[2]; + const filePath = match[3]; + if (!mode) { + throw new UserError( + `Unsupported file mode: '${match[1]}', only + ${Object.keys(ALLOWED_FILE_MODES)} are supported` + PAT); + } + acc[filePath] = (blobId === DELETION_SHA || !blobId) ? + null : + new TreeUtil.Change(NodeGit.Oid.fromString(blobId), mode); + return acc; +}, {}); + + +const runCmd = co.wrap(function* (args) { + const diff = fs.readFileSync(args.diff_file || 0, "utf-8"); + const changes = getChanges(diff, args.short); + const location = yield NodeGit.Repository.discover(".", 0, ""); + const repo = yield NodeGit.Repository.open(location); + const treeOrCommitish = yield NodeGit.Revparse.single(repo, args.treeish); + + if (!treeOrCommitish) { + throw new UserError( + `Cannot rev-parse: '${args.treeish}', make sure it is valid`); + } + const baseTreeObj = yield treeOrCommitish.peel(NodeGit.Object.TYPE.TREE); + + if (!baseTreeObj) { + throw new UserError( + `${args.treeish} cannot be resolve to a tree` + + "is should be a commitish or treeish"); + } + const baseTree = yield NodeGit.Tree.lookup(repo, baseTreeObj); + const amendedTree = yield TreeUtil.writeTree(repo, baseTree, changes); + console.log(amendedTree.id().tostrS()); +}); + +co(function* () { + try { + yield runCmd(parser.parseArgs()); + } + catch (e) { + if (e instanceof UserError) { + console.error(e.message); + } else { + console.error(e.stack); + } + process.exit(1); + } +}); diff --git a/node/lib/stitch.js b/node/lib/stitch.js new file mode 100755 index 000000000..a841f1568 --- /dev/null +++ b/node/lib/stitch.js @@ -0,0 +1,165 @@ +#!/usr/bin/env node +/* + * Copyright (c) 2017, 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. + */ + +const ArgumentParser = require("argparse").ArgumentParser; +const co = require("co"); + +const StitchUtil = require("./util/stitch_util"); +const UserError = require("./util/user_error"); + +const description = `Stitch together the specified meta-repo commitish in \ +the specified repo.`; + +const parser = new ArgumentParser({ + addHelp: true, + description: description +}); + +parser.addArgument(["--no-fetch"], { + required: false, + action: "storeConst", + constant: true, + defaultValue: false, + help: `If provided, assume commits are present and do not fetch.`, +}); + +parser.addArgument(["-t", "--target-branch"], { + required: false, + type: "string", + defaultValue: "master", + help: "Branch to update with committed ref; default is 'master'.", +}); + +parser.addArgument(["-j"], { + required: false, + type: "int", + help: "number of parallel operations, default 8", + defaultValue: 8, +}); + +parser.addArgument(["-c", "--commitish"], { + type: "string", + help: "meta-repo commit to stitch, default is HEAD", + defaultValue: "HEAD", + required: false, +}); + +parser.addArgument(["-u", "--url"], { + type: "string", + defaultValue: null, + help: `location of the origin repository where submodules are rooted, \ +required unless '--no-fetch' is specified`, + required: false, +}); + +parser.addArgument(["-r", "--repo"], { + type: "string", + help: "location of the repo, default is \".\"", + defaultValue: ".", +}); + +parser.addArgument(["-k", "--keep-as-submodule"], { + type: "string", + help: `submodules whose paths are matched by this regex are not stitched, \ +but are instead kept as submodules.`, + required: false, + defaultValue: null, +}); + +parser.addArgument(["--skip-empty"], { + required: false, + action: "storeConst", + constant: true, + defaultValue: false, + help: "Skip a commit if its tree would be the same as its first parent.", +}); + +parser.addArgument(["--root"], { + type: "string", + help: "When provided, run a *join* operation that creates a new history \ +joining those of the submodules under the specified 'root' path. \ +In this history those paths will be relative to 'root', i.e., they will not \ +be prefixed by it. This option implies '--skip-empty'.", +}); + +parser.addArgument(["--preload-cache"], { + required: false, + action: "storeConst", + constant: true, + defaultValue: false, + help: "Load, in one shot, information about previously converted commits \ +and submodule changes. Use this option when processing many commits to \ +avoid many individual note reads. Do not use this option when doing \ +incremental updates as the initial load time will be very slow.", +}); + +co(function *() { + const args = parser.parseArgs(); + const keepRegex = (null === args.keep_as_submodule) ? + null : + new RegExp(args.keep_as_submodule); + function keep(name) { + return null !== keepRegex && null !== keepRegex.exec(name); + } + const options = { + numParallel: args.j, + keepAsSubmodule: keep, + fetch: !args.no_fetch, + skipEmpty: args.skip_empty || (null !== args.root), + preloadCache: args.preload_cache, + }; + if (!args.no_fetch && null === args.url) { + console.error("URL is required unless '--no-fetch'"); + process.exit(-1); + } + if (null !== args.url) { + options.url = args.url; + } + if (null !== args.root) { + console.log(`Joining from ${args.root}`); + options.joinRoot = args.root; + } + try { + yield StitchUtil.stitch(args.repo, + args.commitish, + args.target_branch, + options); + } + catch (e) { + if (e instanceof UserError) { + console.error(e.message); + } else { + console.error(e.stack); + } + process.exit(1); + } +}); diff --git a/node/lib/util/add.js b/node/lib/util/add.js index d473dafb4..961df0be6 100644 --- a/node/lib/util/add.js +++ b/node/lib/util/add.js @@ -32,32 +32,58 @@ const assert = require("chai").assert; const co = require("co"); +const colors = require("colors"); +const fs = require("fs-promise"); const NodeGit = require("nodegit"); +const path = require("path"); -const StatusUtil = require("./status_util"); -const SubmoduleUtil = require("./submodule_util"); +const DiffUtil = require("./diff_util"); +const RepoStatus = require("./repo_status"); +const SparseCheckoutUtil = require("./sparse_checkout_util"); +const StatusUtil = require("./status_util"); +const SubmoduleUtil = require("./submodule_util"); +const UserError = require("./user_error"); /** * Stage modified content at the specified `paths` in the specified `repo`. If * a path in `paths` refers to a file, stage it; if it refers to a directory, * stage all modified content rooted at that path, including that in open * submodules. Note that a path of "" is taken to indicate the entire - * repository. The behavior is undefined unless every path in `paths` is a - * valid relative path in `repo.workdir()`. + * repository. Throw a `UserError` if any path in `paths` doesn't exist or + * can't be read. * * @async * @param {NodeGit.Repository} repo * @param {String []} paths */ -exports.stagePaths = co.wrap(function *(repo, paths, stageMetaChanges) { +exports.stagePaths = co.wrap(function *(repo, + paths, + stageMetaChanges, + update, + verbose) { assert.instanceOf(repo, NodeGit.Repository); assert.isArray(paths); assert.isBoolean(stageMetaChanges); + assert.isBoolean(update); + + yield paths.map(co.wrap(function* (name) { + try { + yield fs.stat(path.join(repo.workdir(), name)); + } catch (e) { + throw new UserError(`Invalid path: ${colors.red(name)}`); + } + })); + + function log_verbose(name, filename, op) { + if (verbose) { + process.stdout.write(`${name}: ${op} '${filename}'\n`); + } + } const repoStatus = yield StatusUtil.getRepoStatus(repo, { showMetaChanges: stageMetaChanges, paths: paths, - showAllUntracked: true, + untrackedFilesOption: DiffUtil.UNTRACKED_FILES_OPTIONS.ALL, }); // First, stage submodules. @@ -69,8 +95,31 @@ exports.stagePaths = co.wrap(function *(repo, paths, stageMetaChanges) { const subRepo = yield SubmoduleUtil.getRepo(repo, name); const workdir = subStat.workdir.status.workdir; const index = yield subRepo.index(); - yield Object.keys(workdir).map( - filename => index.addByPath(filename)); + yield Object.keys(workdir).map(filename => { + // if -u flag is provided, update tracked files only. + if (RepoStatus.FILESTATUS.REMOVED === workdir[filename]) { + log_verbose(name, filename, "remove"); + return index.removeByPath(filename); + } else if (update) { + if (RepoStatus.FILESTATUS.ADDED !== workdir[filename]) { + log_verbose(name, filename, "modify"); + return index.addByPath(filename); + } + } else { + log_verbose(name, filename, "add"); + return index.addByPath(filename); + } + }); + + // Add conflicted files. + const staged = subStat.workdir.status.staged; + yield Object.keys(staged).map(co.wrap(function *(filename) { + const change = staged[filename]; + if (change instanceof RepoStatus.Conflict) { + log_verbose(name, filename, "add"); + yield index.addByPath(filename); + } + })); yield index.write(); } })); @@ -81,6 +130,6 @@ exports.stagePaths = co.wrap(function *(repo, paths, stageMetaChanges) { if (0 !== toAdd.length) { const index = yield repo.index(); yield toAdd.map(filename => index.addByPath(filename)); - yield index.write(); + yield SparseCheckoutUtil.setSparseBitsAndWriteIndex(repo, index); } }); diff --git a/node/lib/util/add_submodule.js b/node/lib/util/add_submodule.js index 2e6ec9454..98e7ef589 100644 --- a/node/lib/util/add_submodule.js +++ b/node/lib/util/add_submodule.js @@ -33,11 +33,10 @@ const assert = require("chai").assert; const co = require("co"); const colors = require("colors"); -const fs = require("fs-promise"); const NodeGit = require("nodegit"); -const path = require("path"); const GitUtil = require("./git_util"); +const SparseCheckoutUtil = require("./sparse_checkout_util"); const SubmoduleConfigUtil = require("./submodule_config_util"); const UserError = require("./user_error"); @@ -63,16 +62,12 @@ exports.addSubmodule = co.wrap(function *(repo, url, filename, importArg) { assert.isString(importArg.url); assert.isString(importArg.branch); } - const modulesPath = path.join(repo.workdir(), - SubmoduleConfigUtil.modulesFileName); - fs.appendFileSync(modulesPath, `\ -[submodule "${filename}"] - path = ${filename} - url = ${url} -`); const index = yield repo.index(); - yield index.addByPath(SubmoduleConfigUtil.modulesFileName); - yield index.write(); + const urls = yield SubmoduleConfigUtil.getSubmodulesFromIndex(repo, index); + urls[filename] = url; + yield SubmoduleConfigUtil.writeUrls(repo, index, urls); + yield SparseCheckoutUtil.setSparseBitsAndWriteIndex(repo, index); + const metaUrl = yield GitUtil.getOriginUrl(repo); const templatePath = yield SubmoduleConfigUtil.getTemplatePath(repo); const subRepo = yield SubmoduleConfigUtil.initSubmoduleAndRepo( @@ -80,7 +75,8 @@ exports.addSubmodule = co.wrap(function *(repo, url, filename, importArg) { repo, filename, url, - templatePath); + templatePath, + false); if (null === importArg) { return subRepo; // RETURN } @@ -92,7 +88,8 @@ exports.addSubmodule = co.wrap(function *(repo, url, filename, importArg) { importArg.branch); if (null === remoteBranch) { throw new UserError(` -The requested branch: ${colors.red(importArg.ranch)} does not exist.`); +The requested branch: ${colors.red(importArg.branch)} does not exist; \ +try '-b [BRANCH]' to specify a different branch.`); } const commit = yield subRepo.getCommit(remoteBranch.target()); yield GitUtil.setHeadHard(subRepo, commit); diff --git a/node/lib/util/bulk_notes_util.js b/node/lib/util/bulk_notes_util.js new file mode 100644 index 000000000..53a3ff52f --- /dev/null +++ b/node/lib/util/bulk_notes_util.js @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2018, 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 ChildProcess = require("child-process-promise"); +const co = require("co"); +const NodeGit = require("nodegit"); +const path = require("path"); + +const ConfigUtil = require("./config_util"); +const DoWorkQueue = require("./do_work_queue"); +const GitUtil = require("./git_util"); +const TreeUtil = require("./tree_util"); + +/** + * Return a sharded path for the specified `sha`, e.g. + * "aabbffffffffffffffff" becomes: "aa/bb/ffffffffffffffff". The behavior is + * undefined unless `sha` contains at least five characters. + * + * @param {String} sha + * @return {String} + */ +exports.shardSha = function (sha) { + return path.join(sha.substr(0, 2), sha.substr(2, 2), sha.substr(4)); +}; + +/** + * This is a workaround for a missing libgit2 feature. Normally, we + * would use something like Reference.createMatching, but it doesn't support + * asserting that a ref didn't previously exist. See + * https://github.com/libgit2/libgit2/pull/5842 + */ +const updateRef = co.wrap(function*(repo, refName, commit, old, reflog) { + try { + yield ChildProcess.exec( + `git -C ${repo.path()} update-ref -m '${reflog}' ${refName} \ +${commit} ${old}`); + return true; + } catch (e) { + return false; + } +}); + +const tryWriteNotes = co.wrap(function *(repo, refName, contents) { + // We're going to directly write the tree/commit for a new + // note containing `contents`. + + let currentTree = null; + const parents = []; + const ref = yield GitUtil.getReference(repo, refName); + if (null !== ref) { + const currentCommit = yield repo.getCommit(ref.target()); + parents.push(currentCommit); + currentTree = yield currentCommit.getTree(); + } + const odb = yield repo.odb(); + const changes = {}; + const ODB_BLOB = 3; + const BLOB = NodeGit.TreeEntry.FILEMODE.BLOB; + const writeBlob = co.wrap(function *(sha) { + const content = contents[sha]; + const blobId = yield odb.write(content, content.length, ODB_BLOB); + const sharded = exports.shardSha(sha); + changes[sharded] = new TreeUtil.Change(blobId, BLOB); + }); + yield DoWorkQueue.doInParallel(Object.keys(contents), writeBlob); + + const newTree = yield TreeUtil.writeTree(repo, currentTree, changes); + const sig = yield ConfigUtil.defaultSignature(repo); + const commit = yield NodeGit.Commit.create(repo, + null, + sig, + sig, + null, + "git-meta updating notes", + newTree, + parents.length, + parents); + + let old; + if (null === ref) { + old = "0000000000000000000000000000000000000000"; + } else { + old = ref.target().tostrS(); + } + return yield updateRef(repo, refName, commit.tostrS(), old, "updated"); +}); + +/** + * Write the specified `contents` to the note having the specified `refName` in + * the specified `repo`. + * + * Writing notes oneo-at-a-time is slow. This method let's you write them in + * bulk, far more efficiently. + * + * @param {NodeGit.Repository} repo + * @param {String} refName + * @param {Object} contents SHA to data + */ +exports.writeNotes = co.wrap(function *(repo, refName, contents) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isString(refName); + assert.isObject(contents); + + if (0 === Object.keys(contents).length) { + // Nothing to do if no contents; no point in making an empty commit or + // in making clients check themselves. + return; // RETURN + } + + const retryCount = 3; + let success; + function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + for (let i = 0; i < retryCount; i++) { + success = yield tryWriteNotes(repo, refName, contents); + if (success) { + return; + } else { + let suffix; + if (i === retryCount - 1) { + suffix = "giving up"; + } else { + suffix = "retrying"; + yield sleep(500); + } + console.warn(`Failed to update notes ref ${refName}, ${suffix}`); + } + } + if (!success) { + throw new Error("Failed to update notes ref ${refName} after retries"); + } +}); + + +/** + * Load, into the specified `result`, note entries found in the specified + * `tree`, prefixing their key with the specified `basePath`. If subtrees are + * found, recurse. Use the specified `repo` to read trees from their IDs. + * + * @param {Object} result + * @param {NodeGit.Tree} tree + * @param {String} basePath + */ +const processTree = co.wrap(function *(result, repo, tree, basePath) { + const entries = tree.entries(); + const processEntry = co.wrap(function *(e) { + const fullPath = basePath + e.name(); + if (e.isBlob()) { + const blob = yield e.getBlob(); + result[fullPath] = blob.toString(); + } else if (e.isTree()) { + // Recurse if we find a tree, + + const id = e.id(); + const nextTree = yield repo.getTree(id); + yield processTree(result, repo, nextTree, fullPath); + } + // Ignore anything that's neither blob nor tree. + }); + yield DoWorkQueue.doInParallel(entries, processEntry); +}); + +/** + * Return the contents of the note having the specified `refName` in the + * specified `repo` or an empty object if no such note exists. + * + * Reading notes one-at-a-time is slow. This method lets you read them all at + * once for a given ref. + * + * @param {NodeGit.Repository} repo + * @param {String} refName + * @return {Object} sha to content + */ +exports.readNotes = co.wrap(function *(repo, refName) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isString(refName); + + const ref = yield GitUtil.getReference(repo, refName); + if (null === ref) { + return {}; + } + const result = {}; + const commit = yield repo.getCommit(ref.target()); + const tree = yield commit.getTree(); + yield processTree(result, repo, tree, ""); + return result; +}); + +/** + * Return the result of transforming the specified `map` so that each of the + * (string) values are replaced by the result of JSON parsing them. + * + * @param {Object} map string to string + * @return {Object} map string to object + */ +exports.parseNotes = function (map) { + const result = {}; + for (let key in map) { + result[key] = JSON.parse(map[key]); + } + return result; +}; diff --git a/node/lib/util/checkout.js b/node/lib/util/checkout.js index a263d87a9..66854adfc 100644 --- a/node/lib/util/checkout.js +++ b/node/lib/util/checkout.js @@ -38,12 +38,15 @@ const co = require("co"); const colors = require("colors"); const NodeGit = require("nodegit"); -const GitUtil = require("./git_util"); -const SubmoduleFetcher = require("./submodule_fetcher"); -const RepoStatus = require("./repo_status"); -const StatusUtil = require("./status_util"); -const SubmoduleUtil = require("./submodule_util"); -const UserError = require("./user_error"); +const DoWorkQueue = require("../util/do_work_queue"); +const GitUtil = require("./git_util"); +const Reset = require("./reset"); +const RepoStatus = require("./repo_status"); +const SparseCheckoutUtil = require("./sparse_checkout_util"); +const StatusUtil = require("./status_util"); +const SubmoduleFetcher = require("./submodule_fetcher"); +const SubmoduleUtil = require("./submodule_util"); +const UserError = require("./user_error"); /** * If the specified `name` matches the tracking branch for one and only one @@ -57,7 +60,7 @@ exports.findTrackingBranch = co.wrap(function *(repo, name) { assert.instanceOf(repo, NodeGit.Repository); assert.isString(name); let result = null; - const refs = yield repo.getReferenceNames(NodeGit.Reference.TYPE.LISTALL); + const refs = yield repo.getReferenceNames(NodeGit.Reference.TYPE.ALL); const matcher = new RegExp(`^refs/remotes/(.*)/${name}$`); for (let i = 0; i < refs.length; ++i) { const refName = refs[i]; @@ -81,74 +84,46 @@ exports.findTrackingBranch = co.wrap(function *(repo, name) { * out -- the ones that are both open and also exist on `commit`. * * @param {NodeGit.Repository} repo - * @param {NodeGit.Commit} commit - * @return {Object} map from name to { repo, commit } + * @param {Object} changes map from name to `SubmoduleChange` + * @return {Object} map from name to { repo, [commit] } */ -const loadSubmodulesToCheckout = co.wrap(function *(repo, commit) { - let open = yield SubmoduleUtil.listOpenSubmodules(repo); - const openSet = new Set(open); - - const names = yield SubmoduleUtil.getSubmoduleNamesForCommit(repo, commit); - - // Condense `open` to be open submodules that are also valid submodules on - // `commit`. - - open = names.filter(name => openSet.has(name)); - - const shas = yield SubmoduleUtil.getSubmoduleShasForCommit(repo, - open, - commit); +const loadSubmodulesToCheckout = co.wrap(function *(repo, changes) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isObject(changes); + const openSet = new Set(yield SubmoduleUtil.listOpenSubmodules(repo)); + const toLoad = Object.keys(changes).filter(name => openSet.has(name)); const result = {}; - const subFetcher = new SubmoduleFetcher(repo, commit); - yield open.map(co.wrap(function *(name) { + const head = yield repo.getHeadCommit(); + const subFetcher = new SubmoduleFetcher(repo, head); + const doSub = co.wrap(function *(name) { const subRepo = yield SubmoduleUtil.getRepo(repo, name); - const sha = shas[name]; - yield subFetcher.fetchSha(subRepo, name, sha); - const commit = yield subRepo.getCommit(sha); - result[name] = { repo: subRepo, commit: commit }; - })); + const sha = changes[name].newSha; + if (null !== sha) { + yield subFetcher.fetchSha(subRepo, name, sha); + const commit = yield subRepo.getCommit(sha); + result[name] = { repo: subRepo, commit: commit }; + } + }); + yield DoWorkQueue.doInParallel(toLoad, doSub); return result; }); /** * Return a list of errors that would be encountered if a non-force attempt was - * made to check out the specified `submodules` on the specified `commit` in - * the specified `metaRepo`. + * made to check out the specified `submodules` in the specified `metaRepo`. + * * TODO: consider exposing this and testing it separately * - * @param {NodeGit.Repository} repository - * @param {NodeGit.Commit} commit - * @param {Object} submodules map from name to { repo, commit } - * @return {String []} list of errors + * @param {NodeGit.Repository} + * @param {Object} submodules map from name to {repo, commit} + * @return {String []} list of errors */ -const dryRun = co.wrap(function *(metaRepo, commit, submodules) { - let errors = []; - - /** - * If it is possible to check out the specified `commit` in the specified - * `repo`, return `null`; otherwise, return an error message. - */ - const runOne = co.wrap(function *(repo, commit) { - try { - yield NodeGit.Checkout.tree(repo, commit, { - checkoutStrategy: NodeGit.Checkout.STRATEGY.NONE, - }); - return null; // RETURN - } - catch(e) { - return e.message; // RETURN - } - }); - - // Check meta +const dryRun = co.wrap(function *(metaRepo, submodules) { + assert.instanceOf(metaRepo, NodeGit.Repository); + assert.isObject(submodules); - const metaError = yield SubmoduleUtil.cacheSubmodules( - metaRepo, - () => runOne(metaRepo, commit)); - if (null !== metaError) { - errors.push(`Unable to check out meta-repo: ${metaError}.`); - } + let errors = []; // Check for new commits in submodules @@ -189,17 +164,21 @@ Submodule ${colors.yellow(name)} has a new commit.`); // Try the submodules; store the opened repos and loaded commits for // use in the actual checkout later. - yield Object.keys(submodules).map(co.wrap(function *(name) { + const dryRunSub = co.wrap(function *(name) { const sub = submodules[name]; const repo = sub.repo; - const subCommit = sub.commit; - const error = yield runOne(repo, subCommit); - if (null !== error) { + const commit = sub.commit; + try { + yield NodeGit.Checkout.tree(repo, commit, { + checkoutStrategy: NodeGit.Checkout.STRATEGY.NONE, + }); + } + catch(e) { errors.push(`\ -Unable to checkout submodule ${colors.yellow(name)}: ${error}.`); - } - })); - +Unable to checkout submodule ${colors.yellow(name)}: ${e.message}.`); + } + }); + yield DoWorkQueue.doInParallel(Object.keys(submodules), dryRunSub); return errors; }); @@ -207,25 +186,30 @@ Unable to checkout submodule ${colors.yellow(name)}: ${error}.`); * Checkout the specified `commit` in the specified `metaRepo`, and update all * open submodules to be on the indicated commit, fetching it if necessary. * Throw a `UserError` if one of the submodules or the meta-repo cannot be - * checked out. + * checked out. On successful checkout, leave HEAD detached. * * @async * @param {NodeGit.Repository} repo * @param {NodeGit.Commit} commit * @param {Boolean} force */ -exports.checkoutCommit = co.wrap(function *(metaRepo, commit, force) { - assert.instanceOf(metaRepo, NodeGit.Repository); +exports.checkoutCommit = co.wrap(function *(repo, commit, force) { + assert.instanceOf(repo, NodeGit.Repository); assert.instanceOf(commit, NodeGit.Commit); assert.isBoolean(force); - const subs = yield loadSubmodulesToCheckout(metaRepo, commit); + const head = yield repo.getHeadCommit(); + const changes = yield SubmoduleUtil.getSubmoduleChanges(repo, + commit, + head, + false); + const subs = yield loadSubmodulesToCheckout(repo, changes); // If we're not forcing the commit, attempt a dry run and fail if it // doesn't pass. if (!force) { - const errors = yield dryRun(metaRepo, commit, subs); + const errors = yield dryRun(repo, subs); // Throw an error if any dry-runs failed. @@ -234,10 +218,14 @@ exports.checkoutCommit = co.wrap(function *(metaRepo, commit, force) { } } - /** - * Checkout and set as head the specified `commit` in the specified `repo`. - */ - const doCheckout = co.wrap(function *(repo, commit) { + const index = yield repo.index(); + yield Reset.resetMetaRepo(repo, index, commit, changes, false); + repo.setHeadDetached(commit); + + const doCheckout = co.wrap(function *(name) { + const sub = subs[name]; + const commit = sub.commit; + const repo = sub.repo; const strategy = force ? NodeGit.Checkout.STRATEGY.FORCE : NodeGit.Checkout.STRATEGY.SAFE; @@ -246,18 +234,8 @@ exports.checkoutCommit = co.wrap(function *(metaRepo, commit, force) { }); repo.setHeadDetached(commit); }); - - // Now do the actual checkouts. - - yield SubmoduleUtil.cacheSubmodules(metaRepo, - () => doCheckout(metaRepo, commit)); - - yield Object.keys(subs).map(co.wrap(function *(name) { - const sub = subs[name]; - const repo = sub.repo; - const subCommit = sub.commit; - yield doCheckout(repo, subCommit); - })); + yield DoWorkQueue.doInParallel(Object.keys(subs), doCheckout); + yield SparseCheckoutUtil.setSparseBitsAndWriteIndex(repo, index); }); /** @@ -309,6 +287,7 @@ ${colors.yellow(remoteName)}`); * @param {String|null} committish * @param {String|null} newBranch * @param {Boolean} track + * @param {Array|null} files * @return {Object} * @return {NodeGit.Commit} return.commit to check out * @return {Object|null} return.newBranch to create @@ -317,11 +296,13 @@ ${colors.yellow(remoteName)}`); * @return {String|null} return.newBranch.tracking.remoteName * @return {String} return.newBranch.tracking.branchName * @return {String|null} return.switchBranch to make current + * @return {Array} return.resolvedPaths to check out */ exports.deriveCheckoutOperation = co.wrap(function *(repo, committish, newBranch, - track) { + track, + files) { assert.instanceOf(repo, NodeGit.Repository); if (null !== committish) { assert.isString(committish); @@ -329,12 +310,19 @@ exports.deriveCheckoutOperation = co.wrap(function *(repo, if (null !== newBranch) { assert.isString(newBranch); } + if (null === files || undefined === files) { + files = []; + } else { + assert.isArray(files); + } assert.isBoolean(track); const result = { commit: null, newBranch: null, switchBranch: null, + resolvedPaths: null, + checkoutFromIndex: false }; const ensureBranchDoesntExist = co.wrap(function *(name) { @@ -377,15 +365,15 @@ A branch named ${colors.red(name)} already exists.`); if (null !== committish) { // Now, we have a committish to resolve. - const annotated = yield GitUtil.resolveCommitish(repo, committish); + let annotated = yield GitUtil.resolveCommitish(repo, committish); if (null === annotated) { - // If we are not explicitly setting up a tracking branch and are - // not explicitly createing a new branch, we may implicitly do both + // If we are not explicitly setting up a tracking branch nor + // explicitly creating a new branch, we may implicitly do both // when `committish` is not directly resolveable, but does match a // single remote tracking branch. - if (null === newBranch) { + if (null === newBranch && files.length === 0) { const remote = yield exports.findTrackingBranch(repo, committish); if (null !== remote) { @@ -404,18 +392,20 @@ A branch named ${colors.red(name)} already exists.`); }, }; result.switchBranch = committish; + if (null === result.commit) { + throw new UserError(`\ +Could not resolve ${colors.red(committish)} as a branch or commit.`); + } } } - - // If we didn't resolve anything from `committish`, throw an error. - - if (null === result.commit) { - throw new UserError( - `Could not resolve ${colors.red(committish)}.`); + if (null === annotated && !result.newBranch) { + // If we didn't resolve anything from `committish`, try it + // as a file. + files.splice(0, 0, committish); + result.checkoutFromIndex = true; } } else { - const commit = yield repo.getCommit(annotated.id()); result.commit = commit; @@ -437,15 +427,41 @@ A branch named ${colors.red(name)} already exists.`); } } else { - // If we're implicitly using HEAD, see if it's on a branch and record - // that branch's name. + if (files.length === 0) { + // If we're implicitly using HEAD, see if it's on a branch + // and record that branch's name. + + const head = yield repo.head(); + if (head.isBranch()) { + committishBranch = head; + } + } + } - const head = yield repo.head(); - if (head.isBranch()) { - committishBranch = head; + if (files.length !== 0) { + const indexSubNames = yield SubmoduleUtil.getSubmoduleNames( + repo); + const openSubmodules = yield SubmoduleUtil.listOpenSubmodules( + repo); + + const workdir = repo.workdir(); + const cwd = process.cwd(); + + const absfiles = files.map(filename => + GitUtil.resolveRelativePath(workdir, cwd, + filename)); + + result.resolvedPaths = SubmoduleUtil.resolvePaths( + absfiles, indexSubNames, openSubmodules, true); + + if (null === result.commit) { + result.checkoutFromIndex = true; } + + return result; } + if (null !== newBranch) { // If we have a `newBranch`, we need to make sure it doesn't already // exist. @@ -466,10 +482,10 @@ Cannot setup tracking information; starting point is not a branch.`); } // If the branch is remote, set up remote tracking information, - // otherwise leve the remote name 'null'; + // otherwise leave the remote name 'null'; if (committishBranch.isRemote()) { - const parts = committishBranch.shorthand().split("/"); + const parts = committishBranch.shorthand().split(/\/(.+)/); tracking = { remoteName: parts[0], branchName: parts[1], @@ -563,3 +579,114 @@ exports.executeCheckout = co.wrap(function *(repo, yield repo.setHead(`refs/heads/${switchBranch}`); } }); + +function noSuchFileMessage(subName, path) { + const fn = `${subName}/${path}`; + return `error: pathspec '${fn}' did not match any file(s) known to git.`; +} + +/** + * Checkout files in submodules. + * + * @param {NodeGit.repository} repo + * @param {Object} options + * @param {Object} options.resolvedPaths -- keys are submodules, + * values are files + * @param {NodeGit.boolean} options.checkoutFromIndex Check out from the + index + * @param {NodeGit.Commit} options.commit If not null, checking out + * from a commit + * @param {NodeGit.Stage} options.stage If not null, index stage else 0 + */ +exports.checkoutFiles = co.wrap(function*(repo, options) { + assert.instanceOf(repo, NodeGit.Repository); + const resolvedPaths = options.resolvedPaths; + + // Exception is thrown if we try to get repo info from unopened submodules. + // TODO: handle other use cases besides when a commit is not specified. + const openSubmodules = yield SubmoduleUtil.listOpenSubmodules(repo); + const submodules = Object.keys(resolvedPaths); + const subNames = null === options.commit ? + submodules.filter(submodule => openSubmodules.includes(submodule)) : + submodules; + + let subCommits; + let stage = 0; + if (undefined !== options.stage) { + assert(options.checkoutFromIndex); + stage = options.stage; + assert.isNumber(stage); + } + + if (options.commit) { + assert.instanceOf(options.commit, NodeGit.Commit); + assert(!options.checkoutFromIndex); + const getShas = SubmoduleUtil.getSubmoduleShasForCommit; + subCommits = yield getShas(repo, subNames, options.commit); + } else { + assert(options.checkoutFromIndex); + } + + const errors = []; + const submoduleInfo = {}; + const prepSub = co.wrap(function*(subName) { + const info = {}; + const subRepo = yield SubmoduleUtil.getRepo(repo, subName); + info.repo = subRepo; + + const paths = resolvedPaths[subName]; + if (options.checkoutFromIndex) { + const index = yield subRepo.index(); + info.index = index; + // libgit2 doesn't care if requested paths don't exist, + // but we do. + for (const path of paths) { + if (undefined === index.getByPath(path, stage)) { + errors.push(noSuchFileMessage(subName, path)); + } + } + } else { + const subCommit = subCommits[subName]; + const resolvedSubCommit = yield NodeGit.Commit.lookup(subRepo, + subCommit); + info.resolvedSubCommit = resolvedSubCommit; + // libgit2 doesn't care if requested paths don't exist, + // but we do. + const treeId = resolvedSubCommit.treeId(); + const tree = yield NodeGit.Tree.lookup(subRepo, treeId); + + for (const path of paths) { + const entry = yield tree.entryByPath(path); + if (null === entry) { + errors.push(noSuchFileMessage(subName, path)); + } + } + } + + submoduleInfo[subName] = info; + }); + + yield DoWorkQueue.doInParallel(subNames, prepSub); + if (errors.length !== 0) { + throw new UserError(errors.join("\n")); + } + + const checkoutSub = co.wrap(function*(subName) { + const info = submoduleInfo[subName]; + const subRepo = info.repo; + const paths = resolvedPaths[subName]; + + const opts = new NodeGit.CheckoutOptions(); + opts.checkoutStrategy = NodeGit.Checkout.STRATEGY.FORCE; + if (paths.length > 0) { + opts.paths = paths; + } + if (options.checkoutFromIndex) { + yield NodeGit.Checkout.index(subRepo, info.index, opts); + } else { + yield NodeGit.Checkout.tree(subRepo, info.resolvedSubCommit, opts); + } + }); + + yield DoWorkQueue.doInParallel(subNames, checkoutSub); +}); diff --git a/node/lib/util/cherry_pick_util.js b/node/lib/util/cherry_pick_util.js new file mode 100644 index 000000000..86ee141d7 --- /dev/null +++ b/node/lib/util/cherry_pick_util.js @@ -0,0 +1,941 @@ +/* + * Copyright (c) 2018, 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 mkdirp = require("mkdirp"); +const NodeGit = require("nodegit"); +const path = require("path"); +const rimraf = require("rimraf"); + +const ConflictUtil = require("./conflict_util"); +const DoWorkQueue = require("./do_work_queue"); +const GitUtil = require("./git_util"); +const Hook = require("../util/hook"); +const Open = require("./open"); +const RepoStatus = require("./repo_status"); +const Reset = require("./reset"); +const SequencerState = require("./sequencer_state"); +const SequencerStateUtil = require("./sequencer_state_util"); +const SparseCheckoutUtil = require("./sparse_checkout_util"); +const StatusUtil = require("./status_util"); +const Submodule = require("./submodule"); +const SubmoduleChange = require("./submodule_change"); +const SubmoduleUtil = require("./submodule_util"); +const SubmoduleConfigUtil = require("./submodule_config_util"); +const SubmoduleRebaseUtil = require("./submodule_rebase_util"); +const TreeUtil = require("./tree_util"); +const UserError = require("./user_error"); + +const CommitAndRef = SequencerState.CommitAndRef; +const CHERRY_PICK = SequencerState.TYPE.CHERRY_PICK; +const STAGE = RepoStatus.STAGE; + +/** + * Throw a `UserError` if the specfied `seq` is null or does not indicate a + * cherry-pick. + * + * @param {SequencerState|null} seq + */ +function ensureCherryInProgress(seq) { + if (null !== seq) { + assert.instanceOf(seq, SequencerState); + } + if (null === seq || CHERRY_PICK !== seq.type) { + throw new UserError("No cherry-pick in progress."); + } +} + +/** + * Change the specified `submodules` in the specified index. If a name maps to + * a `Submodule`, update it in the specified `index` in the specified `repo` + * and if that submodule is open, reset its HEAD, index, and worktree to + * reflect that commit. Otherwise, if it maps to `null`, remove it. Obtain + * submodule repositories from the specified `opener`, but do not open any + * closed repositories. The behavior is undefined if any referenced submodule + * is open and has index or workdir modifications. + * + * @param {NodeGit.Repository} repo + * @param {Open.Opener} opener + * @param {NodeGit.Index} index + * @param {Object} submodules name to Submodule + * @param {(null|Object)} urlsInIndex name to sub.url + */ +exports.changeSubmodules = co.wrap(function *(repo, + opener, + index, + submodules, + urlsInIndex) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(opener, Open.Opener); + assert.instanceOf(index, NodeGit.Index); + assert.isObject(submodules); + if (0 === Object.keys(submodules).count) { + return; // RETURN + } + const urls = (urlsInIndex === null || urlsInIndex === undefined) ? + (yield SubmoduleConfigUtil.getSubmodulesFromIndex(repo, index)) : + urlsInIndex; + const changes = {}; + function rmrf(dir) { + return new Promise(callback => { + return rimraf(path.join(repo.workdir(), dir), {}, callback); + }); + } + const fetcher = yield opener.fetcher(); + for (let name in submodules) { + const sub = submodules[name]; + if (null === sub) { + console.log(`Deleting ${name}`); + changes[name] = null; + delete urls[name]; + yield rmrf(name); + } + else if (opener.isOpen(name)) { + console.log(`Fast-forwarding open submodule ${name}`); + 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); + yield index.addByPath(name); + } else { + console.log(`Fast-forwarding closed submodule ${name}`); + changes[name] = new TreeUtil.Change( + NodeGit.Oid.fromString(sub.sha), + NodeGit.TreeEntry.FILEMODE.COMMIT); + urls[name] = sub.url; + const subPath = path.join(repo.workdir(), name); + mkdirp.sync(subPath); + } + } + const parentTreeId = yield index.writeTree(); + const parentTree = yield repo.getTree(parentTreeId); + const newTree = yield TreeUtil.writeTree(repo, parentTree, changes); + yield index.readTree(newTree); + yield SubmoduleConfigUtil.writeUrls(repo, index, urls); +}); + +/** +* Update meta repo index and point the submodule to a commit sha +* +* @param {NodeGit.Index} index +* @param {String} subName +* @param {String} sha +*/ +exports.addSubmoduleCommit = co.wrap(function *(index, subName, sha) { + assert.instanceOf(index, NodeGit.Index); + assert.isString(subName); + assert.isString(sha); + + const entry = new NodeGit.IndexEntry(); + entry.path = subName; + entry.mode = NodeGit.TreeEntry.FILEMODE.COMMIT; + entry.id = NodeGit.Oid.fromString(sha); + entry.flags = entry.flagsExtended = 0; + yield index.add(entry); +}); + +/** + * Similar to exports.changeSubmodules, but it: + * 1. operates in bare repo + * 2. does not make any changes to the working directory + * 3. only deals with simple changes like addition, deletions + * and fast-forwards + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Index} index meta repo's change index + * @param {Object} submodules name to Submodule + */ +exports.changeSubmodulesBare = co.wrap(function *(repo, + index, + submodules) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(index, NodeGit.Index); + assert.isObject(submodules); + if (0 === Object.keys(submodules).count) { + return; // RETURN + } + const urls = yield SubmoduleConfigUtil.getSubmodulesFromIndex(repo, index); + for (let name in submodules) { + // Each sub object is either a {Submodule} object or null, it covers + // three types of change: addition, deletions and fast-forwards. + // (see {computeChangesBetweenTwoCommits}) + // In case of deletion, we remove its url from the urls array, update + // the .gitmodule file with `writeUrls` and skip adding the submodule + // to the meta index. + // In other case we bump the submodule sha to `sub.sha` by adding a + // new index entry to the meta index and add `sub.url` for updates. + const sub = submodules[name]; + if (null === sub) { + delete urls[name]; + continue; + } + yield exports.addSubmoduleCommit(index, name, sub.sha); + urls[name] = sub.url; + } + // write urls to the in-memory index + yield SubmoduleConfigUtil.writeUrls(repo, index, urls, true); +}); + +/** + * Return true if there are URL changes between the specified `commit` and + * `baseCommit` in the specified `repo` and false otherwise. A URL change is + * an alteration to a submodule's URL in the `.gitmodules` file that is not an + * addition or removal. If `undefined === baseCommit`, then use the first + * parent of `commit` as the base. + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} commit + * @param {NodeGit.Commit} [baseCommit] + * @return {Bool} + */ +exports.containsUrlChanges = co.wrap(function *(repo, commit, baseCommit) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(commit, NodeGit.Commit); + if (undefined !== baseCommit) { + assert.instanceOf(baseCommit, NodeGit.Commit); + } else { + const parents = yield commit.getParents(); + if (0 !== parents.length) { + baseCommit = parents[0]; + } + } + + let baseUrls = {}; + if (undefined !== baseCommit) { + baseUrls = + yield SubmoduleConfigUtil.getSubmodulesFromCommit(repo, baseCommit); + } + const commitUrls = + yield SubmoduleConfigUtil.getSubmodulesFromCommit(repo, commit); + for (let name in baseUrls) { + const baseUrl = baseUrls[name]; + const commitUrl = commitUrls[name]; + if (undefined !== commitUrl && baseUrl !== commitUrl) { + return true; // RETURN + } + } + return false; +}); + +const populateLibgit2MergeBugData = co.wrap(function*(repo, data) { + if (data.mergeBases === undefined) { + const head = data.head; + const targetCommit = data.targetCommit; + const mergeBases = yield GitUtil.mergeBases(repo, head, targetCommit); + data.mergeBases = []; + for (const base of mergeBases) { + const commit = yield NodeGit.Commit.lookup(repo, base); + data.mergeBases.push(commit); + } + } + + return data.mergeBases; +}); + +const workAroundLibgit2MergeBug = co.wrap(function *(data, repo, name, + entries) { + let ancestor = entries[STAGE.ANCESTOR]; + const ours = entries[STAGE.OURS]; + const theirs = entries[STAGE.THEIRS]; + if (undefined === ancestor && + undefined !== ours && + undefined !== theirs) { + // This might be a normal conflict that libgit2 is falsely + // telling us is an add-add conflict. I don't yet have a + // libgit2 bug report for this because the only repro is a + // complex case in Two Sigma's proprietary monorepo. + + // We work around this by looking at all merge-bases, and checking + // if any of them have an entry for this name, and if so, filling + // in the ancestor with it + const mergeBases = yield populateLibgit2MergeBugData(repo, data); + for (const base of mergeBases) { + const shas = yield SubmoduleUtil.getSubmoduleShasForCommit( + repo, [ours.path], base); + const sha = shas[name]; + // Avoid creating a synthetic ancestor with the same sha as + // theirs. See more in `SubmoduleChange` + if (sha !== undefined && sha !== theirs.id.tostrS()) { + ancestor = new NodeGit.IndexEntry(); + ancestor.id = NodeGit.Oid.fromString(sha); + ancestor.mode = NodeGit.TreeEntry.FILEMODE.COMMIT; + ancestor.path = ours.path; + ancestor.flags = ours.flags; + ancestor.gid = ours.gid; + ancestor.uid = ours.uid; + ancestor.fileSize = 0; + ancestor.ino = 0; + ancestor.dev = 0; + entries[STAGE.ANCESTOR] = ancestor; + break; + } + } + } +}); + +/** + * + * @param {Object} ancestorUrls urls from the merge base + * @param {Object} ourUrls urls from the left side of a merge + * @param {Object} theirUrls urls from the right side + * @returns {Object} + * @returns {Object} return.url submodule name to URLs + * @returns {Object} return.conflicts: name to a conflict object that contains + * urls of ancestors, ours and theirs. + */ +exports.resolveUrlsConflicts = function(ancestorUrls, ourUrls, theirUrls) { + const allSubNames = new Set(Object.keys(ancestorUrls)); + Object.keys(ourUrls).forEach(x => allSubNames.add(x)); + Object.keys(theirUrls).forEach(x => allSubNames.add(x)); + + const result = { + urls: {}, + conflicts: {}, + }; + const addUrl = function(name, url) { + if (url) { + result.urls[name] = url; + } + }; + for (const sub of allSubNames) { + const ancestorUrl = ancestorUrls[sub]; + const ourUrl = ourUrls[sub]; + const theirUrl = theirUrls[sub]; + if (ancestorUrl === ourUrl) { + addUrl(sub, theirUrl); + } else if (ancestorUrl === theirUrl) { + addUrl(sub, ourUrl); + } else if (ourUrl === theirUrl) { + addUrl(sub, ourUrl); + } else { + result.conflicts[sub] = { + ancestor: ancestorUrl, + our: ourUrl, + their: theirUrl + }; + } + } + return result; +}; + + +/** + * Resolve conflicts to `.gitmodules` file, return the merged list of urls or + * a Conflict object indicating the merge cannot be done automatically. + * + * @param repo repository where blob of `.gitmodules` can be read + * @param {(null|NodeGit.IndexEntry)} ancestorEntry entry of `.gitmodules` + * from merge base + * @param {(null|NodeGit.IndexEntry)} ourEntry entry of `.gitmodules` + * on the left side + * @param {(null|NodeGit.IndexEntry)} theirEntry entry of `.gitmodules` + * on the right side + * @returns {Object} + * @returns {Object} return.urls, list of sub names to urls + * @returns {Object} return.conflicts, object describing conflicts + */ +exports.resolveModuleFileConflicts = co.wrap(function*( + repo, + ancestorEntry, + ourEntry, + theirEntry +) { + assert.instanceOf(repo, NodeGit.Repository); + const getUrls = SubmoduleConfigUtil.getSubmodulesFromIndexEntry; + const ancestorUrls = yield getUrls(repo, ancestorEntry); + const ourUrls = yield getUrls(repo, ourEntry); + const theirUrls = yield getUrls(repo, theirEntry); + return exports.resolveUrlsConflicts(ancestorUrls, ourUrls, theirUrls); +}); + +/** + * Determine how to apply the submodule changes introduced in the + * specified `srcCommit` to the commit `targetCommit` of the specified repo + * as described in the specified in-memory `index`. Return an object + * describing what changes to make, including which submodules cannot be + * updated at all due to a conflicts, such as a change being introduced to a + * submodule that does not exist in HEAD. Throw a `UserError` if non-submodule + * changes are detected. The behavior is undefined if there is no merge base + * between `srcCommit` and the `targetCommit`. + * @param {NodeGit.Repository} repo + * @param {NodeGit.Index} index + * @param {NodeGit.Commit} srcCommit + * @param {NodeGit.Commit} targetCommit + * @return {Object} return + * @return {Object} return.changes from sub name to `SubmoduleChange` + * @return {Object} return.simpleChanges from sub name to `Submodule` or null + * @return {Object} return.conflicts from sub name to `Conflict` + * */ +exports.computeChangesBetweenTwoCommits = co.wrap(function *(repo, + index, + srcCommit, + targetCommit) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(index, NodeGit.Index); + assert.instanceOf(srcCommit, NodeGit.Commit); + assert.instanceOf(targetCommit, NodeGit.Commit); + const conflicts = {}; + + // Group together all parts of conflicted entries. + const conflictEntries = new Map(); // name -> normal, ours, theirs + const entries = index.entries(); + for (const entry of entries) { + const name = entry.path; + const stage = NodeGit.Index.entryStage(entry); + if (STAGE.NORMAL !== stage) { + let subEntry = conflictEntries.get(name); + if (undefined === subEntry) { + subEntry = {}; + conflictEntries.set(name, subEntry); + } + subEntry[stage] = entry; + } + } + + // Now, look at `conflictEntries` and see if any are eligible for further + // work -- basically, submodule changes where there is a conflict that + // could be resolved by an internal merge, cherry-pick, etc. Otherwise, + // log and resolve conflicts. + + const COMMIT = NodeGit.TreeEntry.FILEMODE.COMMIT; + const ConflictEntry = ConflictUtil.ConflictEntry; + const Conflict = ConflictUtil.Conflict; + const changes = {}; + function makeConflict(entry) { + if (undefined === entry) { + return null; + } + return new ConflictEntry(entry.mode, entry.id.tostrS()); + } + + const libgit2MergeBugData = { + head: srcCommit, + targetCommit: targetCommit + }; + for (const [name, entries] of conflictEntries) { + yield workAroundLibgit2MergeBug(libgit2MergeBugData, repo, name, + entries); + const ancestor = entries[STAGE.ANCESTOR]; + const ours = entries[STAGE.OURS]; + const theirs = entries[STAGE.THEIRS]; + if (undefined !== ancestor && + undefined !== ours && + undefined !== theirs && + COMMIT === ours.mode && + COMMIT === theirs.mode) { + changes[name] = new SubmoduleChange(ancestor.id.tostrS(), + theirs.id.tostrS(), + ours.id.tostrS()); + } else if (SubmoduleConfigUtil.modulesFileName !== name) { + conflicts[name] = new Conflict(makeConflict(ancestor), + makeConflict(ours), + makeConflict(theirs)); + } + } + + // Get submodule urls. If there are no merge conflicts to `.gitmodules`, + // parse the file and return its list. If there are, best effort merging + // the urls. Throw user error if merge conflict cannot be resolved. + const modulesFileEntry = + conflictEntries.get(SubmoduleConfigUtil.modulesFileName); + let urls; + if (modulesFileEntry) { + const urlsRes = yield exports.resolveModuleFileConflicts(repo, + modulesFileEntry[STAGE.ANCESTOR], + modulesFileEntry[STAGE.OURS], + modulesFileEntry[STAGE.THEIRS] + ); + if (Object.keys(urlsRes.conflicts).length > 0) { + let errMsg = "Conflicts to submodule URLs: \n" + + JSON.stringify(urlsRes.conflicts); + throw new UserError(errMsg); + } + urls = urlsRes.urls; + } else { + urls = yield SubmoduleConfigUtil.getSubmodulesFromIndex(repo, index); + } + + // Now we handle the changes that Git was able to take care of by itself. + // First, we're going to need to write the index to a tree; this write + // requires that we clean the conflicts. Anything we've already diagnosed + // as either a conflict or a non-simple change will be ignored here. + + yield index.conflictCleanup(); + const simpleChanges = {}; + const treeId = yield index.writeTreeTo(repo); + const tree = yield NodeGit.Tree.lookup(repo, treeId); + const srcTree = yield srcCommit.getTree(); + const diff = yield NodeGit.Diff.treeToTree(repo, srcTree, tree, null); + const treeChanges = + yield SubmoduleUtil.getSubmoduleChangesFromDiff(diff, false); + for (let name in treeChanges) { + // Skip changes we've already taken into account and the `.gitmodules` + // file. + + if (SubmoduleConfigUtil.modulesFileName === name || + name in changes || + name in conflicts) { + continue; // CONTINUE + } + const change = treeChanges[name]; + if (null === change.newSha) { + simpleChanges[name] = null; + } else { + simpleChanges[name] = new Submodule(urls[name], change.newSha); + } + } + return { + simpleChanges: simpleChanges, + changes: changes, + conflicts: conflicts, + urls: urls, + }; +}); + +/** + * Pick the specified `subs` in the specified `metaRepo` having the specified + * `metaIndex`. Stage new submodule commits in `metaRepo`. Return an object + * describing any commits that were generated and conflicted commits. Use the + * specified `opener` to access submodule repos. + * + * @param {NodeGit.Repository} metaRepo + * @param {Open.Opener} opener + * @param {NodeGit.Index} metaIndex + * @param {Object} subs map from name to SubmoduleChange + * @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 + * @returns {Object} return.ffwds map from name to if ffwd happend + */ +exports.pickSubs = co.wrap(function *(metaRepo, opener, metaIndex, subs) { + assert.instanceOf(metaRepo, NodeGit.Repository); + assert.instanceOf(opener, Open.Opener); + assert.instanceOf(metaIndex, NodeGit.Index); + assert.isObject(subs); + const result = { + commits: {}, + conflicts: {}, + ffwds: {}, + }; + const fetcher = yield opener.fetcher(); + const pickSub = co.wrap(function *(name) { + 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) + "]"; + console.log(`Sub-repo ${colors.blue(name)}: applying commits \ +${colors.green(commitText)}.`); + + // Fetch the commit; it may not be present. + + yield fetcher.fetchSha(repo, name, change.newSha); + yield fetcher.fetchSha(repo, name, change.oldSha); + const newCommit = yield repo.getCommit(change.newSha); + const oldCommit = yield repo.getCommit(change.oldSha); + const rewriteResult = yield SubmoduleRebaseUtil.rewriteCommits( + repo, + newCommit, + oldCommit); + result.commits[name] = rewriteResult.commits; + result.ffwds[name] = rewriteResult.ffwd; + yield metaIndex.addByPath(name); + if (null !== rewriteResult.conflictedCommit) { + result.conflicts[name] = rewriteResult.conflictedCommit; + } + }); + yield DoWorkQueue.doInParallel(Object.keys(subs), pickSub); + return result; +}); + +/** + * Determine how to apply the submodule changes introduced in the + * specified `targetCommit` to the commit on the head of the specified `repo` + * as described in the specified in-memory `index`. Return an object + * describing what changes to make, including which submodules cannot be + * updated at all due to a conflicts, such as a change being introduced to a + * submodule that does not exist in HEAD. Throw a `UserError` if non-submodule + * changes are detected. The behavior is undefined if there is no merge base + * between HEAD and `targetCommit`. + * + * Note that this method will cause conflicts in `index` to be cleaned up. + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Index} index + * @param {NodeGit.Commit} targetCommit + * @return {Object} return + * @return {Object} return.changes from sub name to `SubmoduleChange` + * @return {Object} return.simpleChanges from sub name to `Submodule` or null + * @return {Object} return.conflicts from sub name to `Conflict` + */ +exports.computeChanges = co.wrap(function *(repo, index, targetCommit) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(index, NodeGit.Index); + assert.instanceOf(targetCommit, NodeGit.Commit); + + const head = yield repo.getHeadCommit(); + const result = yield exports.computeChangesBetweenTwoCommits(repo, + index, + head, + targetCommit); + return result; +}); + +/** + * Write the specified `conflicts` to the specified `index` in the specified + * `repo`. If `conflicts` is non-empty, return a non-empty string desribing + * them. Otherwise, return the empty string. + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Index} index + * @param {Object} conflicts from sub name to `Conflict` + * @return {String} + */ +exports.writeConflicts = co.wrap(function *(repo, index, conflicts) { + let errorMessage = ""; + const names = Object.keys(conflicts).sort(); + for (let name of names) { + yield ConflictUtil.addConflict(index, name, conflicts[name]); + errorMessage += `\ +Conflicting entries for submodule ${colors.red(name)} +`; + } + return errorMessage; +}); + +/** + * Throw a user error if there are URL-only changes between the specified + * `commit` and `baseCommit` in the specified `repo`. If + * `undefined === baseCommit`, compare against the first parent of `commit`. + * + * TODO: independent test + * + * TODO: Dealing with these would be a huge hassle and is probably not worth it + * at the moment since the recommended policy for monorepo implementations is + * to prevent users from making URL changes anyway. + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} commit + * @param {NodeGit.Commit} [baseCommit] + */ +exports.ensureNoURLChanges = co.wrap(function *(repo, commit, baseCommit) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(commit, NodeGit.Commit); + if (undefined !== baseCommit) { + assert.instanceOf(baseCommit, NodeGit.Commit); + } + + const hasUrlChanges = + yield exports.containsUrlChanges(repo, commit, baseCommit); + if (hasUrlChanges) { + + throw new UserError(`\ +Applying commits with submodule URL changes is not currently supported. +Please try with normal git commands.`); + } +}); + +/** + * Close submodules that have been opened by the specified `opener` but that + * have no mapped commits or conflicts in the specified `changes`. + * + * TODO: independent test + * + * @param {Open.Opener} opener + * @param {Object} changes + * @param {Object} changes.commits from sub path to map from sha to sha + * @param {Object} changes.conflicts from sub path to sha causing conflict + */ +exports.closeSubs = co.wrap(function *(opener, changes) { + const repo = opener.repo; + const toClose = (yield opener.getOpenedSubs()).filter(path => { + const commits = changes.commits[path]; + if ((undefined === commits || 0 === Object.keys(commits).length) && + !(path in changes.conflicts)) { + console.log(`Closing ${colors.green(path)}`); + return true; + } + return false; + }); + yield SubmoduleConfigUtil.deinit(repo, toClose); +}); + +/** + * Rewrite the specified `commit` on top of HEAD in the specified `repo` using + * the specified `opener` to open submodules as needed. The behavior is + * undefined unless the repository is clean. Return an object describing the + * commits that were made and any error message; if no commit was made (because + * there were no changes to commit), `newMetaCommit` will be null. Throw a + * `UserError` if URL changes or direct meta-repo changes are present in + * `commit`. + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} commit + * @param {String} commandName accepts git-meta command that + * gets checked for any conflicts when running rewriteCommit + * for a more elaborated errorMessage + * @return {Object} return + * @return {String|null} return.newMetaCommit + * @return {Object} returm.submoduleCommits + * @return {String|null} return.errorMessage + */ +exports.rewriteCommit = co.wrap(function *(repo, commit, commandName) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(commit, NodeGit.Commit); + + yield exports.ensureNoURLChanges(repo, commit); + + const head = yield repo.getHeadCommit(); + const changeIndex = + yield NodeGit.Cherrypick.commit(repo, commit, head, 0, []); + const changes = yield exports.computeChanges(repo, changeIndex, commit); + const index = yield repo.index(); + + // Perform simple changes that don't require picks -- addition, deletions, + // and fast-forwards. + + const opener = new Open.Opener(repo, null); + yield exports.changeSubmodules(repo, + opener, + index, + changes.simpleChanges, + null); + + // Render any conflicts + + let errorMessage = + yield exports.writeConflicts(repo, index, changes.conflicts); + + // Then do the cherry-picks. + + const picks = yield exports.pickSubs(repo, opener, index, changes.changes); + const conflicts = picks.conflicts; + + yield exports.closeSubs(opener, picks); + + Object.keys(conflicts).sort().forEach(name => { + errorMessage += SubmoduleRebaseUtil.subConflictErrorMessage(name); + }); + + if (Object.keys(conflicts).length !== 0) { + errorMessage += `\ + A ${commandName} is in progress. + (after resolving conflicts mark the corrected paths + with 'git meta add', then run "git meta ${commandName} --continue") + (use "git meta ${commandName} --abort" to `+ + "check out the original branch)"; + } + + + const result = { + pickingCommit: commit, + submoduleCommits: picks.commits, + errorMessage: errorMessage === "" ? null : errorMessage, + newMetaCommit: null, + }; + yield SparseCheckoutUtil.setSparseBitsAndWriteIndex(repo, index); + const nChanges = Object.keys(picks.commits) + .map(name => Object.keys( + picks.commits[name]).length + picks.ffwds[name] ? 1 : 0 + ).reduce((acc, len) => acc + len, 0); + if ("" === errorMessage && + (0 !== Object.keys(changes.simpleChanges).length || 0 !== nChanges)) { + result.newMetaCommit = + yield SubmoduleRebaseUtil.makeCommit(repo, commit); + } + + if (result.errorMessage === null) { + // Run post-commit hook as regular git. + yield Hook.execHook(repo, "post-commit"); + } + return result; +}); + +const pickRemainingCommits = co.wrap(function*(metaRepo, seq) { + const commits = seq.commits; + let result; + for (let i = seq.currentCommit; i < commits.length; ++i) { + const id = commits[i]; + const commit = yield metaRepo.getCommit(id); + console.log(`Cherry-picking commit ${colors.green(id)}.`); + + seq = seq.copy({currentCommit : i}); + yield SequencerStateUtil.writeSequencerState(metaRepo.path(), seq); + result = yield exports.rewriteCommit(metaRepo, commit, "cherry-pick"); + if (null !== result.errorMessage) { + return result; + } + if (null === result.newMetaCommit) { + // TODO: stop and offer the user the option of git commit + // --allow-empty vs cherry-pick --skip. For now, tho, + // empty meta commits are pretty useless so we will just + // skip. + console.log("Nothing to commit."); + } + } + + yield SequencerStateUtil.cleanSequencerState(metaRepo.path()); + return result; +}); + + +/** + * Cherry-pick the specified `commits` in the specified `metaRepo`. + * Return an object with the cherry-picked commits ids for the last + * cherry-picked commit (whether or not that was successful). This + * object contains the id of the newly-generated meta-repo commit and + * for each sub-repo, a map from new (cherry-pick) sha to the original + * commit sha. Throw a `UserError` if the repository is not in a + * state that can allow a cherry-pick (e.g., it's rebasing), if + * `commit` contains changes that we cannot cherry-pick (e.g., + * URL-only changes), or if the cherry-pick would result in no changes + * (TODO: provide support for '--allow-empty' if needed). If the + * cherry-pick is initiated but results in a conflicts, the + * `errorMessage` of the returned object will be non-null and will + * contain a description of the conflicts. + * + * @async + * @param {NodeGit.Repository} metaRepo + * @param [{NodeGit.Commit}] commits + * @return {Object} return + * @return {String} return.newMetaCommit + * @return {Object} returm.submoduleCommits + * @return {String|null} return.errorMessage + */ +exports.cherryPick = co.wrap(function *(metaRepo, commits) { + assert.instanceOf(metaRepo, NodeGit.Repository); + assert.instanceOf(commits[0], NodeGit.Commit); + + const status = yield StatusUtil.getRepoStatus(metaRepo); + StatusUtil.ensureReady(status); + + // First, perform sanity checks to see if the repo is in a state that we + // can pick in and if `commit` is something that we can pick. + + if (!status.isDeepClean(false)) { + // TODO: Git will refuse to run if there are staged changes, but will + // attempt a cherry-pick if there are just workdir changes. We should + // support this in the future, but it basically requires us to dry-run + // the rebases in all the submodules, and I'm uncertain how to do that + // at the moment. + + throw new UserError(`\ +The repository has uncommitted changes. Please stash or commit them before +running cherry-pick.`); + } + + // We're going to attempt a cherry-pick if we've made it this far, record a + // cherry-pick file. + + const head = yield metaRepo.getHeadCommit(); + const commitIdStrs = commits.map(x => x.id().tostrS()); + let lastCommit = commitIdStrs[commitIdStrs.length - 1]; + let seq = new SequencerState({ + type: CHERRY_PICK, + originalHead: new CommitAndRef(head.id().tostrS(), null), + // target is bogus for cherry-picks but must be filled in anyway + target: new CommitAndRef(lastCommit, null), + currentCommit: 0, + commits: commitIdStrs, + }); + yield SequencerStateUtil.writeSequencerState(metaRepo.path(), seq); + + return yield pickRemainingCommits(metaRepo, seq); +}); + +/** + * Continue the in-progress cherry-pick in the specified `repo`. Throw a + * `UserError` if the continue cannot be initiated, e.g., because there is not + * a cherry-pick in progress or there are still conflicts. Return an object + * describing the commits that were made and any errors that were generated. + * + * @async + * @param {NodeGit.Repository} repo + * @return {Object} return + * @return {String|null} return.newMetaCommit + * @return {Object} returm.submoduleCommits + * @return {Object} returm.newSubmoduleCommits + * @return {String|null} return.errorMessage + */ +exports.continue = co.wrap(function *(repo) { + assert.instanceOf(repo, NodeGit.Repository); + + const status = yield StatusUtil.getRepoStatus(repo); + const seq = status.sequencerState; + ensureCherryInProgress(seq); + if (status.isConflicted()) { + throw new UserError("Resolve conflicts then continue cherry-pick."); + } + const index = yield repo.index(); + const commit = yield repo.getCommit(seq.commits[seq.currentCommit]); + const subResult = yield SubmoduleRebaseUtil.continueSubmodules(repo, + index, + status, + commit); + + const result = { + pickingCommit: commit, + newMetaCommit: subResult.metaCommit, + submoduleCommits: subResult.commits, + newSubmoduleCommits: subResult.newCommits, + errorMessage: subResult.errorMessage, + }; + if (subResult.errorMessage !== null || + seq.currentCommit + 1 === seq.commits.length) { + yield SequencerStateUtil.cleanSequencerState(repo.path()); + return result; + } + const newSeq = seq.copy({currentCommit : seq.currentCommit + 1}); + return yield pickRemainingCommits(repo, newSeq); +}); + +/** + * Abort the cherry-pick in progress in the specified `repo` and return the + * repository to exactly the state of the initial commit. Throw a `UserError` + * if no cherry-pick is in progress. + * + * @param {NodeGit.Repository} repo + */ +exports.abort = co.wrap(function *(repo) { + assert.instanceOf(repo, NodeGit.Repository); + + const seq = yield SequencerStateUtil.readSequencerState(repo.path()); + ensureCherryInProgress(seq); + const commit = yield repo.getCommit(seq.originalHead.sha); + yield Reset.reset(repo, commit, Reset.TYPE.MERGE); + yield SequencerStateUtil.cleanSequencerState(repo.path()); + console.log("Cherry-pick aborted."); +}); diff --git a/node/lib/util/cherrypick.js b/node/lib/util/cherrypick.js deleted file mode 100644 index 5126e4fad..000000000 --- a/node/lib/util/cherrypick.js +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright (c) 2016, 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 Open = require("../util/open"); -const SubmoduleUtil = require("../util/submodule_util"); -const UserError = require("../util/user_error"); - -/** - * Cherry-pick the specified `commit` in the specified `metaRepo`. Return an - * object with the cherry-picked commits ids. This object contains the id of - * the newly-generated meta-repo commit and for each sub-repo, a map from - * original commit sha to cherry-picked commit sha. The behavior is undefined - * unless the `metaRepo` is in a consistent state according to - * `Status.ensureCleanAndConsistent`. Throw a `UserError` object if a - * cherry-pick results in confict; do not generate a meta-repo commit in this - * case. - * - * @async - * @param {NodeGit.Repository} metaRepo - * @param {NodeGit.Commit} commit - * @return {Object} return - * @return {String} return.newMetaCommit - * @return {Object} returm.submoduleCommits - */ -exports.cherryPick = co.wrap(function *(metaRepo, commit) { - assert.instanceOf(metaRepo, NodeGit.Repository); - assert.instanceOf(commit, NodeGit.Commit); - - // TODO: handle possibility of a (single) meta-repo commit corresponding to - // multiple commits. - // TODO: See how we do with a variety of edge cases, e.g.: submodules added - // and removed. - // TODO: Deal with conflicts. - - // Basic algorithm: - // - start cherry-pick on meta-repo - // - detect changes in sub-repos - // - cherry-pick changes in sub-repos - // - if any conflicts in sub-repos, bail - // - finalize commit in meta-repo - - const head = yield metaRepo.getHeadCommit(); - const changes = yield SubmoduleUtil.getSubmoduleChanges(metaRepo, commit); - - yield SubmoduleUtil.cacheSubmodules(metaRepo, () => { - return NodeGit.Cherrypick.cherrypick(metaRepo, commit, {}); - }); - - let errorMessage = ""; - let indexChanged = false; - let pickers = []; - - const headSubs = yield SubmoduleUtil.getSubmodulesForCommit(metaRepo, - head); - const commitSubs = yield SubmoduleUtil.getSubmodulesForCommit(metaRepo, - commit); - - const metaIndex = yield metaRepo.index(); - const opener = new Open.Opener(metaRepo, null); - let submoduleCommits = {}; - const subFetcher = yield opener.fetcher(); - - const picker = co.wrap(function *(subName, headSha, commitSha) { - let commitMap = {}; - submoduleCommits[subName] = commitMap; - const repo = yield opener.getSubrepo(subName); - - console.log(`Sub-repo ${colors.blue(subName)}: cherry-picking commit \ -${colors.green(commitSha)}.`); - - // Fetch the commit; it may not be present. - - yield subFetcher.fetchSha(repo, subName, commitSha); - - const commit = yield repo.getCommit(commitSha); - yield NodeGit.Cherrypick.cherrypick(repo, commit, {}); - const index = yield repo.index(); - if (index.hasConflicts()) { - errorMessage += - `Submodule ${colors.red(subName)} is conflicted.\n`; - } - else { - repo.stateCleanup(); - const newCommit = yield repo.createCommitOnHead( - [], - commit.author(), - commit.committer(), - commit.message()); - yield metaIndex.addByPath(subName); - commitMap[commitSha] = newCommit.tostrS(); - indexChanged = true; - } - }); - - - // Cherry-pick each submodule changed in `commit`. - - Object.keys(changes.changed).forEach(subName => { - const headSub = headSubs[subName]; - const commitSub = commitSubs[subName]; - const headSha = headSub.sha; - if (undefined !== commitSub && - headSha !== commitSub.sha) { - pickers.push(picker(subName, headSha, commitSub.sha)); - } - }); - - // Then execute the submodule pickers in parallel. - - yield pickers; - - // If one of the submodules could not be picked, exit. - - if ("" !== errorMessage) { - throw new UserError(errorMessage); - } - - // After all the submodules are picked, write the index, perform cleanup, - // and make the cherry-pick commit on the meta-repo. - - if (indexChanged) { - yield metaIndex.conflictCleanup(); - yield metaIndex.write(); - } - - metaRepo.stateCleanup(); - const metaCommit = yield metaRepo.createCommitOnHead([], - commit.author(), - commit.committer(), - commit.message()); - return { - newMetaCommit: metaCommit.tostrS(), - submoduleCommits: submoduleCommits, - }; -}); diff --git a/node/lib/util/close_util.js b/node/lib/util/close_util.js index 2152b4551..af2032ef0 100644 --- a/node/lib/util/close_util.js +++ b/node/lib/util/close_util.js @@ -35,10 +35,12 @@ const NodeGit = require("nodegit"); const co = require("co"); const colors = require("colors"); -const DeinitUtil = require("../util/deinit_util"); -const StatusUtil = require("../util/status_util"); -const SubmoduleUtil = require("../util/submodule_util"); -const UserError = require("../util/user_error"); +const Hook = require("../util/hook"); +const SparseCheckoutUtil = require("../util/sparse_checkout_util"); +const StatusUtil = require("../util/status_util"); +const SubmoduleConfigUtil = require("../util/submodule_config_util"); +const SubmoduleUtil = require("../util/submodule_util"); +const UserError = require("../util/user_error"); /** @@ -59,23 +61,25 @@ exports.close = co.wrap(function *(repo, cwd, paths, force) { assert.isArray(paths); assert.isBoolean(force); - const repoStatus = yield StatusUtil.getRepoStatus(repo); - const subStats = repoStatus.submodules; - let errorMessage = ""; - const workdir = repo.workdir(); const subs = yield SubmoduleUtil.getSubmoduleNames(repo); + let subsToClose = yield SubmoduleUtil.resolveSubmoduleNames(workdir, + cwd, + subs, + paths); + subsToClose = Array.from(new Set(subsToClose)); - const subsToClose = yield SubmoduleUtil.resolveSubmoduleNames(workdir, - cwd, - subs, - paths); - const closers = subsToClose.map(co.wrap(function *(name) { + const repoStatus = yield StatusUtil.getRepoStatus(repo, { + paths: subsToClose, + }); + const subStats = repoStatus.submodules; + let errorMessage = ""; + const subsClosedSuccessfully = subsToClose.filter(name => { const sub = subStats[name]; - const subWorkdir = sub.workdir; - if (null === subWorkdir) { - return; // RETURN + if (undefined === sub || null === sub.workdir) { + return false; // RETURN } + const subWorkdir = sub.workdir; const subRepo = subWorkdir.status; if (!force) { // Determine if there are any uncommited changes: @@ -88,12 +92,21 @@ exports.close = co.wrap(function *(repo, cwd, paths, force) { Could not close ${colors.cyan(name)} because it is not clean. Pass ${colors.magenta("--force")} to close it anyway. `; - return; // RETURN + return false; // RETURN } } - yield DeinitUtil.deinit(repo, name); - })); - yield closers; + return true; // RETURN + }); + yield SubmoduleConfigUtil.deinit(repo, subsClosedSuccessfully); + + // Write out the meta index to update SKIP_WORKTREE flags for closed + // submodules. + + yield SparseCheckoutUtil.setSparseBitsAndWriteIndex(repo, + yield repo.index()); + + // Run post-close-submodule hook with submodules which closed successfully. + yield Hook.execHook(repo, "post-close-submodule", subsClosedSuccessfully); if ("" !== errorMessage) { throw new UserError(errorMessage); } diff --git a/node/lib/util/commit.js b/node/lib/util/commit.js index 9c0b41a27..92e92844e 100644 --- a/node/lib/util/commit.js +++ b/node/lib/util/commit.js @@ -40,27 +40,47 @@ const assert = require("chai").assert; const co = require("co"); const colors = require("colors"); +const fs = require("fs-promise"); const NodeGit = require("nodegit"); const path = require("path"); +const ConfigUtil = require("./config_util"); +const CherryPickUtil = require("./cherry_pick_util.js"); +const DoWorkQueue = require("../util/do_work_queue"); const DiffUtil = require("./diff_util"); const GitUtil = require("./git_util"); +const Hook = require("../util/hook"); +const Mutex = require("async-mutex").Mutex; const Open = require("./open"); const RepoStatus = require("./repo_status"); const PrintStatusUtil = require("./print_status_util"); +const SequencerState = require("../util/sequencer_state"); +const SequencerStateUtil = require("../util/sequencer_state_util"); +const SparseCheckoutUtil = require("./sparse_checkout_util"); const StatusUtil = require("./status_util"); +const Submodule = require("./submodule"); +const SubmoduleConfigUtil = require("./submodule_config_util"); const SubmoduleUtil = require("./submodule_util"); const TreeUtil = require("./tree_util"); const UserError = require("./user_error"); +const getSubmodulesFromCommit = SubmoduleConfigUtil.getSubmodulesFromCommit; + /** * If the specified `message` does not end with '\n', return the result of * appending '\n' to 'message'; otherwise, return 'message'. * * @param {String} message */ -function ensureEolOnLastLine(message) { +exports.ensureEolOnLastLine = function (message) { + // TODO: test independently return message.endsWith("\n") ? message : (message + "\n"); +}; + +function abortIfNoMessage(message) { + if (message === "") { + throw new UserError("Aborting commit due to empty commit message."); + } } /** @@ -82,7 +102,12 @@ const getHeadParentTree = co.wrap(function *(repo) { const getAmendStatusForRepo = co.wrap(function *(repo, all) { const tree = yield getHeadParentTree(repo); - const normal = yield DiffUtil.getRepoStatus(repo, tree, [], false, true); + const normal = yield DiffUtil.getRepoStatus( + repo, + tree, + [], + false, + DiffUtil.UNTRACKED_FILES_OPTIONS.ALL); if (!all) { return normal; // RETURN @@ -92,7 +117,12 @@ const getAmendStatusForRepo = co.wrap(function *(repo, all) { // We've already got the "normal" comparison now we need to get changes // directly against the workdir. - const toWorkdir = yield DiffUtil.getRepoStatus(repo, tree, [], true, true); + const toWorkdir = yield DiffUtil.getRepoStatus( + repo, + tree, + [], + true, + DiffUtil.UNTRACKED_FILES_OPTIONS.ALL); // And use `calculateAllStatus` to create the final value. @@ -100,7 +130,7 @@ const getAmendStatusForRepo = co.wrap(function *(repo, all) { }); /** - * This class reprents the meta-data associated with a commit. + * This class represents the meta-data associated with a commit. */ class CommitMetaData { /** @@ -185,54 +215,73 @@ exports.stageChange = co.wrap(function *(index, path, change) { } }); +const mutex = new Mutex(); + +const runPreCommitHook = co.wrap(function *(repo, index) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(index, NodeGit.Index); + + const tempIndexPath = repo.path() + "index.gmtmp"; + yield SparseCheckoutUtil.setSparseBitsAndWriteIndex(repo, index, + tempIndexPath); + + let release = null; + try { + if (yield Hook.hasHook(repo, "pre-commit")) { + release = yield mutex.acquire(); + const isOk = yield Hook.execHook(repo, "pre-commit", [], + { GIT_INDEX_FILE: tempIndexPath}); + yield GitUtil.overwriteIndexFromFile(index, tempIndexPath); + + if (!isOk) { + // hooks are responsible for printing their own message + throw new UserError(""); + } + } + } finally { + if (release !== null) { + release(); + } + yield fs.unlink(tempIndexPath); + } +}); + + /** - * Commit changes in the specified `repo`. If the specified `doAll` is true, - * stage files indicated that they are to be committed in the `staged` section. - * Use the specified `message` as the commit message. If there are no files to - * commit and `false === force`, do nothing and return null; otherwise, return - * the created commit object. Ignore submodules. Use the specified - * `signature` to identify the commit creator. + * Prepare a temp index in the specified `repo`, then run the hooks, + * and read back the index. If the specified `doAll` is true, stage + * files indicated that they are to be committed in the `staged` + * section. Ignore submodules. * * @async * @param {NodeGit.Repository} repo * @param {RepoStatus} repoStatus * @param {Boolean} doAll - * @param {String} message - * @param {Boolean} force - * @param {NodeGit.Signature} signature - * @return {NodeGit.Oid|null} + * @param {Boolean} noVerify */ -const commitRepo = co.wrap(function *(repo, - changes, - doAll, - message, - force, - signature) { +const prepareIndexAndRunHooks = co.wrap(function *(repo, + changes, + doAll, + noVerify) { assert.instanceOf(repo, NodeGit.Repository); assert.isObject(changes); assert.isBoolean(doAll); - assert.isString(message); - assert.isBoolean(force); - assert.instanceOf(signature, NodeGit.Signature); + assert.isBoolean(noVerify); - const doCommit = 0 !== Object.keys(changes).length || force; + const index = yield repo.index(); // If we're auto-staging files, loop through workdir and stage them. if (doAll) { - const index = yield repo.index(); for (let path in changes) { yield exports.stageChange(index, path, changes[path]); } - yield index.write(); + yield SparseCheckoutUtil.setSparseBitsAndWriteIndex(repo, index); } - if (doCommit) { - return yield repo.createCommitOnHead([], - signature, - signature, - ensureEolOnLastLine(message)); + + if (!noVerify) { + yield runPreCommitHook(repo, index); } - return null; }); const editorMessagePrefix = `\ @@ -348,18 +397,51 @@ exports.formatEditorPrompt = function (status, cwd) { * `index`. We need to do this whenever generating a meta-repo commit because * otherwise, we could commit a staged commit in a submodule that would have * been reverted in its open repo. - * - * @param {NodeGit.Index} index - * @param {Object} submodules name -> RepoStatus.Submodule + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Index} index + * @param {Object} submodules name -> RepoStatus.Submodule */ -const stageOpenSubmodules = co.wrap(function *(index, submodules) { - yield Object.keys(submodules).map(co.wrap(function *(name) { +const stageOpenSubmodules = co.wrap(function *(repo, index, submodules) { + const entries = yield Object.keys(submodules).map(co.wrap(function *(name) { const sub = submodules[name]; if (null !== sub.workdir) { - yield index.addByPath(name); + /* + We probably shouldn't run addByPath in parallel because + one of the following two things is probably true: + + 1. Updating the index is not be thread-safe. I don't + think this is true, but the docs are useless here, and I + don't see any locking in the libgit2 code, so if it's + present it must be in NodeGit. + + 2. Updating the index is made thread-safe by a too-coarse + lock which locks before reading the submodule HEAD + instead of before the actual index update (this makes + sense given the libgit2 index code). This too-coarse + lock means slow submodule HEAD lookups are effectively + serialized despite this being a parallel map. + + So instead, we'll do the submodule HEAD lookups in + parallel and then add the entries serially, which should + be relatively fast. + */ + + const subRepo = yield SubmoduleUtil.getRepo(repo, name); + const head = yield subRepo.getHeadCommit(); + const newEntry = new NodeGit.IndexEntry(); + newEntry.flags = 0; + newEntry.flagsExtended = 0; + newEntry.mode = NodeGit.TreeEntry.FILEMODE.COMMIT; + newEntry.id = head; + newEntry.path = name; + return newEntry; } })); - yield index.write(); + for (const entry of entries.filter(x => !!x)) { + index.add(entry); + } + yield SparseCheckoutUtil.setSparseBitsAndWriteIndex(repo, index); }); /** @@ -427,6 +509,22 @@ exports.shouldCommit = function (status, skipMeta, subMessages) { return false; }; +const updateHead = co.wrap(function *(repo, sha) { + // Now we need to put the commit on head. We need to unstage + // the changes we've just committed, otherwise we see + // conflicts with the workdir. We do a SOFT reset because we + // don't want to affect index changes for paths you didn't + // touch. + + // This will return the same one that we modified above + const index = yield repo.index(); + // First, we write the new index to the actual index file. + yield SparseCheckoutUtil.setSparseBitsAndWriteIndex(repo, index); + + const commit = yield repo.getCommit(sha); + yield NodeGit.Reset.reset(repo, commit, NodeGit.Reset.TYPE.SOFT); +}); + /** * Create a commit across modified repositories and the specified `metaRepo` * with the specified `message`; if `null === message`, do not create a commit @@ -439,7 +537,7 @@ exports.shouldCommit = function (status, skipMeta, subMessages) { * commit and the shas of any commits generated in submodules. The behavior is * undefined if there are entries in `.gitmodules` for submodules having no * commits, or if `null === message && undefined === subMessages`. The - * behavior is undefined unless there is somthing to commit. + * behavior is undefined unless there is something to commit. * * @async * @param {NodeGit.Repository} metaRepo @@ -447,6 +545,10 @@ exports.shouldCommit = function (status, skipMeta, subMessages) { * @param {RepoStatus} metaStatus * @param {String|null} message * @param {Object} [subMessages] map from submodule to message + * @param {Commit} mergeParent if mid-merge, the commit being + * merged, which will become the right parent + * of this commit (and the equivalent in the + * submodules). * @return {Object} * @return {String|null} return.metaCommit * @return {Object} return.submoduleCommits map submodule name to new commit @@ -454,31 +556,67 @@ exports.shouldCommit = function (status, skipMeta, subMessages) { exports.commit = co.wrap(function *(metaRepo, all, metaStatus, - message, - subMessages) { + messageFunc, + subMessages, + noVerify, + mergeParent) { assert.instanceOf(metaRepo, NodeGit.Repository); assert.isBoolean(all); assert.instanceOf(metaStatus, RepoStatus); - assert(exports.shouldCommit(metaStatus, message === null, subMessages), + assert(exports.shouldCommit(metaStatus, !!subMessages, subMessages), "nothing to commit"); - if (null !== message) { - assert.isString(message); - } + assert.isFunction(messageFunc); if (undefined !== subMessages) { assert.isObject(subMessages); } - assert(null !== message || undefined !== subMessages, - "if no meta message, sub messages must be specified"); + assert.isBoolean(noVerify); + + let mergeTree = null; + if (mergeParent) { + assert.instanceOf(mergeParent, NodeGit.Commit); + mergeTree = yield mergeParent.getTree(); + } - const signature = metaRepo.defaultSignature(); + const signature = yield ConfigUtil.defaultSignature(metaRepo); const submodules = metaStatus.submodules; // Commit submodules. If any changes, remember this so we know to generate // a commit in the meta-repo whether or not the meta-repo has its own // workdir changes. - const subCommits = {}; + const subRepos = {}; + const subStageData = {}; + const writeSubmoduleIndex = co.wrap(function *(name) { + const status = submodules[name]; + const repoStatus = (status.workdir && status.workdir.status) || null; + + if (null !== repoStatus && + 0 !== Object.keys(repoStatus.staged).length) { + const subRepo = yield SubmoduleUtil.getRepo(metaRepo, name); + + yield prepareIndexAndRunHooks(subRepo, + repoStatus.staged, + all, + noVerify); + const index = yield subRepo.index(); + subStageData[name] = { + repo : subRepo, + index : index, + }; + + } + }); + + yield DoWorkQueue.doInParallel(Object.keys(submodules), + writeSubmoduleIndex); + + const message = yield messageFunc(); + const commitSubmodule = co.wrap(function *(name) { + const data = subStageData[name]; + const index = data.index; + const subRepo = data.repo; + let subMessage = message; // If we're explicitly providing submodule messages, look the commit @@ -490,24 +628,49 @@ exports.commit = co.wrap(function *(metaRepo, return; // RETURN } } - const status = submodules[name]; - const repoStatus = (status.workdir && status.workdir.status) || null; - if (null !== repoStatus && - 0 !== Object.keys(repoStatus.staged).length) { - const subRepo = yield SubmoduleUtil.getRepo(metaRepo, name); - const commit = yield commitRepo(subRepo, - repoStatus.staged, - all, - subMessage, - false, - signature); - subCommits[name] = commit.tostrS(); + + const headCommit = yield subRepo.getHeadCommit(); + const parents = []; + if (headCommit !== null) { + parents.push(headCommit); } + + if (mergeTree !== null) { + const mergeSubParent = yield mergeTree.entryByPath(name); + if (mergeSubParent.id() !== headCommit.id()) { + parents.push(mergeSubParent.id()); + } + } + + const tree = yield index.writeTree(); + + const commit = yield subRepo.createCommit( + null, + signature, + signature, + exports.ensureEolOnLastLine(subMessage), + tree, + parents); + + subRepos[name] = subRepo; + subCommits[name] = commit.tostrS(); }); - const subCommitters = Object.keys(submodules).map(commitSubmodule); - yield subCommitters; + yield DoWorkQueue.doInParallel(Object.keys(subStageData), commitSubmodule); + + const index = yield metaRepo.index(); + if (all) { + for (const subName of Object.keys(metaStatus.staged)) { + exports.stageChange(index, subName, metaStatus.staged[subName]); + } + } + + for (const subName of Object.keys(subCommits)) { + const subRepo = subRepos[subName]; + const sha = subCommits[subName]; + yield updateHead(subRepo, sha); + } const result = { metaCommit: null, submoduleCommits: subCommits, @@ -517,36 +680,74 @@ exports.commit = co.wrap(function *(metaRepo, return result; // RETURN } - const index = yield metaRepo.index(); - yield stageOpenSubmodules(index, submodules); + yield stageOpenSubmodules(metaRepo, index, submodules); + if (!noVerify) { + yield runPreCommitHook(metaRepo, index); + } + + const tree = yield index.writeTree(); + const headCommit = yield metaRepo.getHeadCommit(); + const parents = [headCommit]; + if (mergeParent) { + assert(mergeParent !== headCommit); + parents.push(mergeParent); + } - result.metaCommit = yield commitRepo(metaRepo, - metaStatus.staged, - all, - message, - true, - signature); + result.metaCommit = yield metaRepo.createCommit( + "HEAD", + signature, + signature, + exports.ensureEolOnLastLine(message), + tree, + parents); + if (mergeParent) { + yield SequencerStateUtil.cleanSequencerState(metaRepo.path()); + } return result; }); /** - * Write a commit for the specified `repo` having the specified - * `status` using the specified commit `message` and return the ID of the new - * commit. Note that this method records staged commits for submodules but - * does not recurse into their repositories. Note also that changes that would - * involve altering `.gitmodules` -- additions, removals, and URL changes -- - * are ignored. + * Return true if the specified `filename` in the specified `repo` is + * executable and false otherwise. + * + * TODO: move this out somewhere lower-level and make an independent test. + * + * @param {Nodegit.Repository} repo + * @param {String} filename + * @return {Bool} + */ +const isExecutable = co.wrap(function *(repo, filename) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isString(filename); + const fullPath = path.join(repo.workdir(), filename); + try { + yield fs.access(fullPath, fs.constants.X_OK); + return true; + } catch (e) { + // cannot execute + return false; + } +}); + +/** + * Create a temp index and run the pre-commit hooks for the specified + * `repo` having the specified `status` using the specified commit + * `message` and return the ID of the new commit. Note that this + * method records staged commits for submodules but does not recurse + * into their repositories. Note also that changes that would involve + * altering `.gitmodules` -- additions, removals, and URL changes -- + * are ignored. HEAD and the main on-disk index file are not changed, + * although the in-memory index is altered. * * @param {NodeGit.Repository} repo * @param {RepoStatus} status * @param {String} message * @return {String} */ -exports.writeRepoPaths = co.wrap(function *(repo, status, message) { +const writeSubmoduleIndex = co.wrap(function *(repo, status, noVerify) { assert.instanceOf(repo, NodeGit.Repository); assert.instanceOf(status, RepoStatus); - assert.isString(message); const headCommit = yield repo.getHeadCommit(); const changes = {}; @@ -559,18 +760,128 @@ exports.writeRepoPaths = co.wrap(function *(repo, status, message) { // Therefore, all of our files must be staged. const index = yield repo.index(); + yield index.readTree(yield headCommit.getTree()); - // First, handle "normal" file changes. + // Handle "normal" file changes. for (let filename in staged) { const stat = staged[filename]; if (FILESTATUS.REMOVED === stat) { + yield index.removeByPath(filename); changes[filename] = null; } else { - const blobId = TreeUtil.hashFile(repo, filename); - changes[filename] = new Change(blobId, FILEMODE.BLOB); + const blobId = yield TreeUtil.hashFile(repo, filename); + const executable = yield isExecutable(repo, filename); + const mode = executable ? FILEMODE.EXECUTABLE : FILEMODE.BLOB; + changes[filename] = new Change(blobId, mode); + yield index.addByPath(filename); + } + } + + // We ignore submodules, because we assume that there are no nested + // submodules in a git-meta repo. + + if (!noVerify) { + yield runPreCommitHook(repo, index); + } + + + return index; +}); +const createCommitFromIndex = co.wrap(function*(repo, index, message) { + const headCommit = yield repo.getHeadCommit(); + + // Use 'TreeUtil' to create a new tree having the required paths. + const treeId = yield index.writeTree(); + const tree = yield NodeGit.Tree.lookup(repo, treeId); + + // Create a commit with this tree. + + const sig = yield ConfigUtil.defaultSignature(repo); + const parents = [headCommit]; + const commitId = yield NodeGit.Commit.create( + repo, + 0, + sig, + sig, + 0, + exports.ensureEolOnLastLine(message), + tree, + parents.length, + parents); + + // Now, reload the index to get rid of the changes we made to it. + // In theory, this should be index.read(true), but that doesn't + // work for some reason. + yield GitUtil.overwriteIndexFromFile(index, index.path()); + + // ...and apply the changes from the commit that we just made. + // I'm pretty sure this doesn't do rename/copy detection, but the nodegit + // API docs are pretty vague, as are the libgit2 docs. + const commit = yield NodeGit.Commit.lookup(repo, commitId); + const diffs = yield commit.getDiff(); + const diff = diffs[0]; + for (let i = 0; i < diff.numDeltas(); i++) { + const delta = diff.getDelta(i); + const newFile = delta.newFile(); + if (GitUtil.isZero(newFile.id())) { + index.removeByPath(delta.oldFile().path()); + } else { + index.addByPath(newFile.path()); + } + } + + return commitId; +}); + + +/** + * Create a temp index and run the pre-commit hooks for the specified + * `repo` having the specified `status` using the specified commit + * `message` and return the ID of the new commit. Note that this + * method records staged commits for submodules but does not recurse + * into their repositories. Note also that changes that would involve + * altering `.gitmodules` -- additions, removals, and URL changes -- + * are ignored. HEAD and the main on-disk index file are not changed, + * although the in-memory index is altered. + * + * @param {NodeGit.Repository} repo + * @param {RepoStatus} status + * @param {String} message + * @return {String} + */ +exports.writeRepoPaths = co.wrap(function *(repo, status, message, noVerify) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(status, RepoStatus); + + const headCommit = yield repo.getHeadCommit(); + const changes = {}; + const staged = status.staged; + const FILEMODE = NodeGit.TreeEntry.FILEMODE; + const FILESTATUS = RepoStatus.FILESTATUS; + const Change = TreeUtil.Change; + + // We do a soft reset later, which means that we don't touch the index. + // Therefore, all of our files must be staged. + + const index = yield repo.index(); + yield index.readTree(yield headCommit.getTree()); + + // First, handle "normal" file changes. + + for (let filename in staged) { + const stat = staged[filename]; + if (FILESTATUS.REMOVED === stat) { + yield index.removeByPath(filename); + changes[filename] = null; + } + else { + const blobId = yield TreeUtil.hashFile(repo, filename); + const executable = yield isExecutable(repo, filename); + const mode = executable ? FILEMODE.EXECUTABLE : FILEMODE.BLOB; + changes[filename] = new Change(blobId, mode); yield index.addByPath(filename); } } @@ -591,44 +902,59 @@ exports.writeRepoPaths = co.wrap(function *(repo, status, message) { changes[subName] = new Change(id, FILEMODE.COMMIT); // Stage this submodule if it's open. - - if (null !== sub.workdir) { - yield index.addByPath(subName); - } + yield CherryPickUtil.addSubmoduleCommit(index, subName, + sub.index.sha); } } - yield index.write(); + if (!noVerify) { + yield runPreCommitHook(repo, index); + } // Use 'TreeUtil' to create a new tree having the required paths. - const baseTree = yield headCommit.getTree(); - const tree = yield TreeUtil.writeTree(repo, baseTree, changes); + const treeId = yield index.writeTree(); + const tree = yield NodeGit.Tree.lookup(repo, treeId); // Create a commit with this tree. - const sig = repo.defaultSignature(); + const sig = yield ConfigUtil.defaultSignature(repo); const parents = [headCommit]; - const commitId = yield NodeGit.Commit.create(repo, - 0, - sig, - sig, - 0, - ensureEolOnLastLine(message), - tree, - parents.length, - parents); - - // Now we need to put the commit on head. We need to unstage the changes - // we've just committed, otherwise we see conflicts with the workdir. We - // do a SOFT reset because we don't want to affect index changes for paths - // you didn't touch. - - const commit = yield repo.getCommit(commitId); - yield NodeGit.Reset.reset(repo, commit, NodeGit.Reset.TYPE.SOFT); - return commitId.tostrS(); -}); + const commitId = yield NodeGit.Commit.create( + repo, + 0, + sig, + sig, + 0, + exports.ensureEolOnLastLine(message), + tree, + parents.length, + parents); + + //restore the index + // Now, reload the index to get rid of the changes we made to it. + // In theory, this should be index.read(true), but that doesn't + // work for some reason. + yield GitUtil.overwriteIndexFromFile(index, index.path()); + + // ...and apply the changes from the commit that we just made. + // I'm pretty sure this doesn't do rename/copy detection, but the nodegit + // API docs are pretty vague, as are the libgit2 docs. + const commit = yield NodeGit.Commit.lookup(repo, commitId); + const diffs = yield commit.getDiff(); + const diff = diffs[0]; + for (let i = 0; i < diff.numDeltas(); i++) { + const delta = diff.getDelta(i); + const newFile = delta.newFile(); + if (GitUtil.isZero(newFile.id())) { + index.removeByPath(delta.oldFile().path()); + } else { + index.addByPath(newFile.path()); + } + } + return commitId; +}); /** * Commit changes to the files indicated as staged by the specified `status` @@ -644,17 +970,20 @@ exports.writeRepoPaths = co.wrap(function *(repo, status, message) { * @return {String} return.metaCommit * @return {Object} return.submoduleCommits map from sub name to commit id */ -exports.commitPaths = co.wrap(function *(repo, status, message) { +exports.commitPaths = co.wrap(function *(repo, status, messageFunc, noVerify) { assert.instanceOf(repo, NodeGit.Repository); assert.instanceOf(status, RepoStatus); - assert.isString(message); + assert.isFunction(messageFunc); + assert.isBoolean(noVerify); const subCommits = {}; // map from name to sha const committedSubs = {}; // map from name to RepoAST.Submodule const subs = status.submodules; - yield Object.keys(subs).map(co.wrap(function *(subName) { + const subStageData = {}; + + const writeSubIndexes = co.wrap(function*(subName) { const sub = subs[subName]; const workdir = sub.workdir; @@ -674,7 +1003,27 @@ exports.commitPaths = co.wrap(function *(repo, status, message) { const wdStatus = workdir.status; const subRepo = yield SubmoduleUtil.getRepo(repo, subName); - const sha = yield exports.writeRepoPaths(subRepo, wdStatus, message); + const index = yield writeSubmoduleIndex(subRepo, wdStatus, noVerify); + + subStageData[subName] = {sub : sub, + index : index, + repo : subRepo, + wdStatus: wdStatus}; + }); + + yield DoWorkQueue.doInParallel(Object.keys(subs), writeSubIndexes); + + const message = yield messageFunc(); + + const writeSubmoduleCommits = co.wrap(function*(subName) { + const data = subStageData[subName]; + const sub = data.sub; + const index = data.index; + const subRepo = data.repo; + const wdStatus = data.wdStatus; + + const oid = yield createCommitFromIndex(subRepo, index, message); + const sha = oid.tostrS(); subCommits[subName] = sha; const oldIndex = sub.index; const Submodule = RepoStatus.Submodule; @@ -686,7 +1035,15 @@ exports.commitPaths = co.wrap(function *(repo, status, message) { headCommit: sha, }), Submodule.COMMIT_RELATION.SAME) }); - })); + committedSubs[subName].repo = subRepo; + }); + + yield DoWorkQueue.doInParallel(Object.keys(subs), writeSubmoduleCommits); + + for (const subName of Object.keys(committedSubs)) { + const sub = committedSubs[subName]; + yield updateHead(sub.repo, sub.index.sha); + } // We need a `RepoStatus` object containing only the set of the submodules // to commit to pass to `writeRepoPaths`. @@ -694,8 +1051,10 @@ exports.commitPaths = co.wrap(function *(repo, status, message) { const pathStatus = status.copy({ submodules: committedSubs, }); - const id = yield exports.writeRepoPaths(repo, pathStatus, message); + const commit = yield repo.getCommit(id); + yield NodeGit.Reset.reset(repo, commit, NodeGit.Reset.TYPE.SOFT); + return { metaCommit: id, submoduleCommits: subCommits, @@ -720,7 +1079,7 @@ exports.getCommitMetaData = function (commit) { * amended. * * @param {RepoStatus.Submodule} status - * @param {Submodule|null} old + * @param {String|null} old * @return {Object} * @return {CommitMetaData|null} return.oldCommit if sub in last commit * @return {RepoStatus.Submodule|null} return.status null if shouldn't exist @@ -729,6 +1088,10 @@ exports.getSubmoduleAmendStatus = co.wrap(function *(status, old, getRepo, all) { + assert.instanceOf(status, RepoStatus.Submodule); + if (null !== old) { + assert.instanceOf(old, Submodule); + } const index = status.index; const commit = status.commit; const workdir = status.workdir; @@ -771,7 +1134,7 @@ exports.getSubmoduleAmendStatus = co.wrap(function *(status, indexSha = commitSha; - // The read in the staged/workdir changes based on the difference in + // Then read in the staged/workdir changes based on the difference in // this submodule's open repo and the prior commit. repo = yield getRepo(); @@ -803,21 +1166,75 @@ exports.getSubmoduleAmendStatus = co.wrap(function *(status, }; }); +/* Read the state of the commits in the commit before the one to be + * amended, so that we can see what's been changed. + */ + +const computeAmendSubmoduleChanges = co.wrap(function*(repo, head) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(head, NodeGit.Commit); + + const headTree = yield head.getTree(); + const oldUrls = {}; + const parents = yield head.getParents(); + + // `merged` records changes that were present in at least one parent + const merged = {}; + // `changes` records what changes were actually made in this + // commit (as opposed to merged from a parent). + let changes = null; + if (parents.length === 0) { + changes = {}; + } + + for (const parent of parents) { + const parentTree = yield parent.getTree(); + const parentSubmodules = yield getSubmodulesFromCommit(repo, parent); + // TODO: this doesn't really handle URL updates + Object.assign(oldUrls, parentSubmodules); + const diff = + yield NodeGit.Diff.treeToTree(repo, parentTree, headTree, null); + const ourChanges = yield SubmoduleUtil.getSubmoduleChangesFromDiff( + diff, + true); + if (changes === null) { + changes = ourChanges; + } else { + for (const path of Object.keys(changes)) { + if (ourChanges[path] === undefined) { + merged[path] = changes[path]; + delete changes[path]; + } else { + delete ourChanges[path]; + } + } + for (const path of Object.keys(ourChanges)) { + merged[path] = ourChanges[path]; + delete changes[path]; + } + } + } + + return { + changes: changes, + merged: merged, + oldUrls: oldUrls + }; +}); + /** * Return the status object describing an amend commit to be created in the * specified `repo` and a map containing the submodules to have amend * commits created mapped to `CommitMetaData` objects describing their current - * commits; submodules with staged changes not in this map receive - * normal commits. Format paths relative to the specified `cwd`. Include - * changes to the meta-repo if the specified `includeMeta` is true; ignore them - * otherwise. Auto-stage modifications (to tracked files) if the specified - * `all` is true. + * commits; submodules with staged changes not in this map receive normal + * commits. Format paths relative to the specified `cwd`. Ignore + * non-submodule changes to the meta-repo. Auto-stage modifications (to + * tracked files) if the specified `all` is true. * * @param {NodeGit.Repository} repo * @param {Object} [options] * @param {Boolean} [options.all = false] * @param {String} [options.cwd = ""] - * @param {Boolean} [options.includeMeta = false] * * @return {Object} * @return {RepoStatus} return.status @@ -845,64 +1262,100 @@ exports.getAmendStatus = co.wrap(function *(repo, options) { else { assert.isString(cwd); } - let includeMeta = options.includeMeta; - if (undefined === includeMeta) { - includeMeta = false; - } - else { - assert.isBoolean(includeMeta); - } const baseStatus = yield exports.getCommitStatus(repo, cwd, { - showMetaChanges: includeMeta, all: all, }); const head = yield repo.getHeadCommit(); - // If we're including the meta-repo, load its changes. + const newUrls = yield getSubmodulesFromCommit(repo, head); - let metaStaged = {}; - let metaWorkdir = {}; - if (includeMeta) { - const changes = yield getAmendStatusForRepo(repo, all); - metaStaged = changes.staged; - metaWorkdir = changes.workdir; - } - - // Read the state of the commits in the commit before the one to be - // amended. - - let oldSubs = {}; - const parent = yield GitUtil.getParentCommit(repo, head); - let parentTree = null; - if (null !== parent) { - const treeId = parent.treeId(); - parentTree = yield NodeGit.Tree.lookup(repo, treeId); - oldSubs = yield SubmoduleUtil.getSubmodulesForCommit(repo, parent); - } + const amendChanges = yield computeAmendSubmoduleChanges(repo, head); + const changes = amendChanges.changes; + const merged = amendChanges.merged; + const oldUrls = amendChanges.oldUrls; const submodules = baseStatus.submodules; // holds resulting sub statuses const opener = new Open.Opener(repo, null); const subsToAmend = {}; // holds map of subs to amend to their commit info - // Loop through submodules that were currently exist in either the last - // commit or the index, adjusting their "base" status to reflect the amend - // change. + // Loop through submodules that either have changes against the current + // commit, or were changed in the current commit. + + const subsToInspect = Array.from(new Set( + Object.keys(submodules).concat(Object.keys(changes)))); + + const inspectSub = co.wrap(function *(name) { + const change = changes[name]; + let currentSub = submodules[name]; + let old = null; + const isMerged = name in merged; + // TODO: This is pretty complicated -- it might be simpler to + // figure out if a commit is present in any ancestor. + if (isMerged) { + // In this case, the "old" version is actually the merged + // version + old = new Submodule(oldUrls[name], merged[name].newSha); + } + else if (undefined !== change) { + // We handle deleted submodules later. TODO: this should not be a + // special case when we've done the refactoring noted below. + + if (null === change.newSha) { + return; // RETURN + } + // This submodule was affected by the commit; record its old sha + // if it wasn't added. + + if (null !== change.oldSha) { + old = new Submodule(oldUrls[name], change.oldSha); + } + + if (undefined === currentSub) { + // This submodule is not open though; we need to construct a + // `RepoStatus.Submodule` object for it as if it had been + // loaded; the commit and index parts of this object are the + // same as they cannot have been changed. + // + // TODO: refactor this and `getSubmoduleAmendStatus` to be + // less-wonky, specifically to not deal in terms of + // `RepoAST.Submodule` objects. + + const url = newUrls[name]; + const Submodule = RepoStatus.Submodule; + currentSub = new Submodule({ + commit: new Submodule.Commit(change.newSha, url), + index: new Submodule.Index(change.newSha, + url, + Submodule.COMMIT_RELATION.SAME), + }); + } + } + else { + // This submodule was opened but not changed. Populate 'old' with + // current commit value, if it exists. - yield Object.keys(submodules).map(co.wrap(function *(name) { - const currentSub = submodules[name]; - const old = oldSubs[name] || null; - const getRepo = () => opener.getSubrepo(name); + const commit = currentSub.commit; + if (null !== commit) { + old = new Submodule(commit.url, commit.sha); + } + } + const getRepo = + () => opener.getSubrepo(name, + Open.SUB_OPEN_OPTION.FORCE_OPEN); const result = yield exports.getSubmoduleAmendStatus(currentSub, old, getRepo, all); - // If no status was returned, remove this submodule. - if (null === result.status) { + // If no status was returned, or this was a merged + // submodule with no local changes, remove this submodule. + if (null === result.status || + (isMerged && result.status.isWorkdirClean() && + result.status.isIndexClean())) { delete submodules[name]; } else { @@ -915,28 +1368,26 @@ exports.getAmendStatus = co.wrap(function *(repo, options) { if (null !== result.oldCommit) { subsToAmend[name] = result.oldCommit; } - })); - + }); + yield DoWorkQueue.doInParallel(subsToInspect, inspectSub); // Look for subs that were removed in the commit we are amending; reflect // their status. - Object.keys(oldSubs).forEach(name => { + Object.keys(changes).forEach(name => { // If we find one, create a status entry for it reflecting its // deletion. - const sub = submodules[name]; - if (undefined === sub) { - const old = oldSubs[name]; + const change = changes[name]; + if (null === change.newSha) { submodules[name] = new RepoStatus.Submodule({ - commit: new RepoStatus.Submodule.Commit(old.sha, old.url), + commit: new RepoStatus.Submodule.Commit(change.sha, + oldUrls[name]), index: null, }); } }); const resultStatus = baseStatus.copy({ - staged: metaStaged, - workdir: metaWorkdir, submodules: submodules, }); @@ -947,14 +1398,15 @@ exports.getAmendStatus = co.wrap(function *(repo, options) { }); /** - * Amend the specified `repo`, using the specified commit `message`, and return - * the sha of the created commit. + * Create a commit in the specified `repo`, based on the HEAD commit, + * using the specified commit `message`, and return the sha of the + * created commit. * * @param {NodeGit.Repository} repo * @param {String} message - * @return {String} + * @return {NodeGit.Oid} */ -exports.amendRepo = co.wrap(function *(repo, message) { +exports.createAmendCommit = co.wrap(function *(repo, message) { assert.instanceOf(repo, NodeGit.Repository); assert.isString(message); @@ -962,11 +1414,18 @@ exports.amendRepo = co.wrap(function *(repo, message) { const index = yield repo.index(); const treeId = yield index.writeTree(); const tree = yield NodeGit.Tree.lookup(repo, treeId); - const termedMessage = ensureEolOnLastLine(message); - const id = yield head.amend("HEAD", null, null, null, termedMessage, tree); - return id.tostrS(); + const termedMessage = exports.ensureEolOnLastLine(message); + const id = yield repo.createCommit( + null, + head.author(), + head.committer(), + termedMessage, + tree, + head.parents()); + return id; }); + /** * Amend the specified meta `repo` and the shas of the created commits. Amend * the head of `repo` and submodules listed in the specified `subsToAmend` @@ -985,6 +1444,7 @@ exports.amendRepo = co.wrap(function *(repo, message) { * @param {Boolean} all * @param {String|null} message * @param {Object|null} subMessages + * @param {Boolean} noVerify * @return {Object} * @return {String} return.metaCommit sha of new commit on meta-repo * @return {Object} return.submoduleCommits from sub name to sha @@ -994,7 +1454,8 @@ exports.amendMetaRepo = co.wrap(function *(repo, subsToAmend, all, message, - subMessages) { + subMessages, + noVerify) { assert.instanceOf(repo, NodeGit.Repository); assert.instanceOf(status, RepoStatus); assert.isArray(subsToAmend); @@ -1021,9 +1482,9 @@ exports.amendMetaRepo = co.wrap(function *(repo, }); const subCommits = {}; + const subRepos = {}; const subs = status.submodules; const amendSubSet = new Set(subsToAmend); - yield Object.keys(subs).map(co.wrap(function *(subName) { // If we're providing specific sub messages, use it if provided and // skip committing the submodule otherwise. @@ -1035,30 +1496,41 @@ exports.amendMetaRepo = co.wrap(function *(repo, return; // RETURN } } + subMessage = exports.ensureEolOnLastLine(subMessage); const subStatus = subs[subName]; - // If the sub-repo doesn't have an open status, and there are no amend - // changes, there's nothing to do. + // We're working on only open repos (they would have been opened + // earlier if necessary). if (null === subStatus.workdir) { return; // RETURN } + const doAmend = amendSubSet.has(subName); const repoStatus = subStatus.workdir.status; + const staged = repoStatus.staged; + const numStaged = Object.keys(staged).length; + + // If we're not amending and nothing staged, exit now. + + if (!doAmend && 0 === numStaged) { + return; // RETURN + } + const subRepo = yield SubmoduleUtil.getRepo(repo, subName); + subRepos[subName] = subRepo; + const head = yield subRepo.getHeadCommit(); + const subIndex = yield subRepo.index(); // If the submodule is to be amended, we don't do the normal commit // process. - - if (amendSubSet.has(subName)) { + if (doAmend) { // First, we check to see if this submodule needs to have its last // commit stripped. That will be the case if we have no files // staged indicated as staged. assert.isNotNull(repoStatus); - const staged = repoStatus.staged; - if (0 === Object.keys(staged).length) { - const head = yield subRepo.getHeadCommit(); + if (0 === numStaged) { const parent = yield GitUtil.getParentCommit(subRepo, head); const TYPE = NodeGit.Reset.TYPE; const type = all ? TYPE.HARD : TYPE.MIXED; @@ -1066,30 +1538,80 @@ exports.amendMetaRepo = co.wrap(function *(repo, return; // RETURN } - const subIndex = yield subRepo.index(); - yield stageFiles(subRepo, staged, subIndex); - subCommits[subName] = yield exports.amendRepo(subRepo, subMessage); + if (all) { + const actualStatus = yield StatusUtil.getRepoStatus(subRepo, { + showMetaChanges: true, + }); + + // TODO: factor this out. We cannot use `repoStatus` to + // determine what to stage as it shows the status vs. HEAD^ + // and so some things that should be changed will not be in it. + // We cannot call `Index.addAll` because it will stage + // untracked files. Therefore, we need to use our normal + // status routine to examine the workdir and stage changed + // files. + + const workdir = actualStatus.workdir; + for (let path in actualStatus.workdir) { + const change = workdir[path]; + if (RepoStatus.FILESTATUS.ADDED !== change) { + yield exports.stageChange(subIndex, path, change); + } + } + } + if (!noVerify) { + yield runPreCommitHook(subRepo, subIndex); + } + subCommits[subName] = yield exports.createAmendCommit(subRepo, + subMessage); return; // RETURN } - const commit = yield commitRepo(subRepo, - repoStatus.staged, - all, - subMessage, - false, - signature); + if (all) { + const actualStatus = yield StatusUtil.getRepoStatus(subRepo, { + showMetaChanges: true, + }); + + const workdir = actualStatus.workdir; + for (let path in actualStatus.workdir) { + const change = workdir[path]; + if (RepoStatus.FILESTATUS.ADDED !== change) { + yield exports.stageChange(subIndex, path, change); + } + } + } + + if (!noVerify) { + yield runPreCommitHook(subRepo, subIndex); + } + const tree = yield subIndex.writeTree(); + const commit = yield subRepo.createCommit( + null, + signature, + signature, + subMessage, + tree, + [head]); + if (null !== commit) { - subCommits[subName] = commit.tostrS(); + subCommits[subName] = commit; } })); + for (const subName of Object.keys(subCommits)) { + const subCommit = subCommits[subName]; + const subRepo = subRepos[subName]; + yield updateHead(subRepo, subCommit.tostrS()); + } + let metaCommit = null; if (null !== message) { const index = yield repo.index(); - yield stageOpenSubmodules(index, subs); + yield stageOpenSubmodules(repo, index, subs); yield stageFiles(repo, status.staged, index); - metaCommit = yield exports.amendRepo(repo, message); + metaCommit = yield exports.createAmendCommit(repo, message); + yield updateHead(repo, metaCommit.tostrS()); } return { metaCommit: metaCommit, @@ -1261,19 +1783,16 @@ exports.calculatePathCommitStatus = function (current, requested) { Object.keys(currentSubs).forEach(subName => { const currentSub = currentSubs[subName]; const requestedSub = requestedSubs[subName]; + // If this submodule was not requested, don't include it. + if (undefined === requestedSub) { + return; + } const curWd = currentSub.workdir; if (null !== curWd) { const curStatus = curWd.status; - // If this submodule was not requested (i.e., - // `undefined === requestedSubs`, default to an empty repo status; - // this will cause all current status files to be moved to the - // workdir. + const reqStatus = requestedSub.workdir.status; - let reqStatus = new RepoStatus(); - if (undefined !== requestedSub) { - reqStatus = requestedSub.workdir.status; - } const newSubFiles = calculateOneRepo(curStatus.staged, curStatus.workdir, reqStatus.staged, @@ -1310,7 +1829,7 @@ exports.calculatePathCommitStatus = function (current, requested) { * impossible to ignore or target those commits. We can't use them with * configuration changes due to the complexity of manipulating the * `.gitmodules` file. - * + * * TODO: * (a) Consider allowing previously-staged commits to be included with a * flag. @@ -1471,12 +1990,10 @@ exports.calculateAllRepoStatus = function (normalStatus, toWorkdirStatus) { /** * Return the status of the specified `repo` indicating a commit that would be * performed, including all (tracked) modified files if the specified `all` is - * provided (default false) and the state of them meta-repo if the specified - * `showMetaChanges` is true (default is false). Restrict status to the - * specified `paths` if nonempty (default []), using the specified `cwd` to - * resolve their meaning. The behavior undefined unless - * `0 === paths.length || !all`. - * + * provided (default false). Restrict status to the specified `paths` if + * nonempty (default []), using the specified `cwd` to resolve their meaning. + * The behavior undefined unless `0 === paths.length || !all`. + * * @param {NodeGit.Repository} repo * @param {String} cwd * @param {Object} [options] @@ -1523,17 +2040,11 @@ exports.getCommitStatus = co.wrap(function *(repo, cwd, options) { if (0 !== options.paths.length) { // Doing path-based status. First, we need to compute the // `commitStatus` object that reflects the paths requested by the user. - // First, we need to resolve the relative paths. - - const paths = yield options.paths.map(filename => { - return GitUtil.resolveRelativePath(repo.workdir(), cwd, filename); - }); - - // Now we get the path-based status. const requestedStatus = yield StatusUtil.getRepoStatus(repo, { + cwd: cwd, + paths: options.paths, showMetaChanges: options.showMetaChanges, - paths: paths, }); return exports.calculatePathCommitStatus(baseStatus, requestedStatus); @@ -1546,6 +2057,7 @@ exports.getCommitStatus = co.wrap(function *(repo, cwd, options) { const workdirStatus = yield StatusUtil.getRepoStatus(repo, { showMetaChanges: options.showMetaChanges, ignoreIndex: true, + untrackedFilesOption: DiffUtil.UNTRACKED_FILES_OPTIONS.ALL, }); return exports.calculateAllRepoStatus(baseStatus, workdirStatus); } @@ -1628,7 +2140,8 @@ exports.formatSplitCommitEditorPrompt = function (status, } result += `\ -# <*> enter meta-repo message above this line; delete to commit only submodules +# <*> enter meta-repo message above this line; delete this line to \ +commit only submodules `; if (metaCommitData) { @@ -1798,10 +2311,6 @@ configuration changes.`); } } -function abortForNoMessage() { - throw new UserError("Aborting commit due to empty commit message."); -} - /** * Perform the commit command in the specified `repo`. Consider the values in * the specified `paths` to be relative to the specified `cwd`, and format @@ -1811,8 +2320,7 @@ function abortForNoMessage() { * modified but unstaged changes. If `paths` is non-empty, include only the * files indicated in those `paths` in the commit. If the specified * `interactive` is true, prompt the user to create an "interactive" message, - * allowing for different commit messages for each changed submodules. Use the - * specified `editMessage` function to invoke an editor when needed. The + * allowing for different commit messages for each changed submodules. The * behavior is undefined if `null !== message && true === interactive` or if `0 * !== paths.length && all`. * @@ -1823,7 +2331,8 @@ function abortForNoMessage() { * @param {Boolean} all * @param {String[]} paths * @param {Boolean} interactive - * @param {(repo, txt) -> Promise(String)} editMessage + * @param {Boolean} noVerify + * @param {Boolean} editMessage * @return {Object} * @return {String} return.metaCommit * @return {Object} return.submoduleCommits map from sub name to commit id @@ -1831,26 +2340,25 @@ function abortForNoMessage() { exports.doCommitCommand = co.wrap(function *(repo, cwd, message, - meta, all, paths, interactive, + noVerify, editMessage) { assert.instanceOf(repo, NodeGit.Repository); assert.isString(cwd); if (null !== message) { assert.isString(message); } - assert.isBoolean(meta); assert.isBoolean(all); assert.isArray(paths); assert.isBoolean(interactive); - assert.isFunction(editMessage); + assert.isBoolean(noVerify); + assert.isBoolean(editMessage); const workdir = repo.workdir(); const relCwd = path.relative(workdir, cwd); const repoStatus = yield exports.getCommitStatus(repo, cwd, { - showMetaChanges: meta, all: all, paths: paths, }); @@ -1884,6 +2392,20 @@ exports.doCommitCommand = co.wrap(function *(repo, if (usingPaths) { checkForPathIncompatibleSubmodules(repoStatus, relCwd); } + const seq = yield SequencerStateUtil.readSequencerState(repo.path()); + + if (usingPaths || !all || interactive) { + if (seq) { + const ty = seq.type.toLowerCase(); + const msg = "Cannot do a partial commit during a " + ty; + throw new UserError(msg); + } + } + + let mergeParent = null; + if (seq && seq.type === SequencerState.TYPE.MERGE) { + mergeParent = NodeGit.Commit.lookup(repo, seq.target); + } // If there is nothing possible to commit, exit early. @@ -1894,19 +2416,21 @@ exports.doCommitCommand = co.wrap(function *(repo, } let subMessages; + let messageFunc; if (interactive) { // If 'interactive' mode is requested, ask the user to specify which // repos are committed and with what commit messages. - const sig = repo.defaultSignature(); + const sig = yield ConfigUtil.defaultSignature(repo); const prompt = exports.formatSplitCommitEditorPrompt(repoStatus, sig, null, {}); - const userText = yield editMessage(repo, prompt); + const userText = yield GitUtil.editMessage(repo, prompt, editMessage, + !noVerify); const userData = exports.parseSplitCommitMessages(userText); - message = userData.metaMessage; + messageFunc = co.wrap(function*() { return userData.metaMessage; }); subMessages = userData.subMessages; // Check if there's actually anything to commit. @@ -1915,31 +2439,40 @@ exports.doCommitCommand = co.wrap(function *(repo, console.log("Nothing to commit."); return; } - } - else if (null === message) { - // If no message on the command line, prompt for one. - - const initialMessage = exports.formatEditorPrompt(repoStatus, cwd); - const rawMessage = yield editMessage(repo, initialMessage); - message = GitUtil.stripMessage(rawMessage); - } + } else if (message === null) { + // If no message on the command line, later we will prompt for one. + messageFunc = co.wrap(function*() { + const initialMessage = exports.formatEditorPrompt(repoStatus, cwd); + let message = yield GitUtil.editMessage(repo, initialMessage, + editMessage, !noVerify); + message = GitUtil.stripMessage(message); + abortIfNoMessage(message); + return message; + }); + } else { + // we strip the message here even though we may need to strip + // it later, just so that we can abort correctly before + // running hooks. + message = GitUtil.stripMessage(message); - if ("" === message) { - abortForNoMessage(); + abortIfNoMessage(message); + messageFunc = co.wrap(function*() { return message; }); } if (usingPaths) { return yield exports.commitPaths(repo, repoStatus, - message, - subMessages); + messageFunc, + noVerify); } else { return yield exports.commit(repo, all, repoStatus, - message, - subMessages); + messageFunc, + subMessages, + noVerify, + mergeParent); } }); @@ -1952,43 +2485,40 @@ exports.doCommitCommand = co.wrap(function *(repo, * prompt the user to create an "interactive" message, allowing for different * commit messages for each changed submodules. Use the specified * `editMessage` function to invoke an editor when needed. If - * `null === editMessage`, use the message of the previous commit. The - * behavior is undefined if `null !== message && true === interactive`. + * `!editMessage`, use the message of the previous commit. The + * behavior is undefined if `null !== message && true === interactive`. Do not + * generate a commit if it would be empty. * * @param {NodeGit.Repository} repo * @param {String} cwd * @param {String|null} message - * @param {Boolean} meta * @param {Boolean} all * @param {Boolean} interactive - * @param {(repo, txt) -> Promise(String) | null} editMessage + * @param {Boolean} noVerify + * @param {Boolean} editMessage * @return {Object} - * @return {String} return.metaCommit + * @return {String|null} return.metaCommit * @return {Object} return.submoduleCommits map from sub name to commit id */ exports.doAmendCommand = co.wrap(function *(repo, cwd, message, - meta, all, interactive, + noVerify, editMessage) { assert.instanceOf(repo, NodeGit.Repository); assert.isString(cwd); if (null !== message) { assert.isString(message); } - assert.isBoolean(meta); assert.isBoolean(all); assert.isBoolean(interactive); - if (null !== editMessage) { - assert.isFunction(editMessage); - } + assert.isBoolean(editMessage); const workdir = repo.workdir(); const relCwd = path.relative(workdir, cwd); const amendStatus = yield exports.getAmendStatus(repo, { - includeMeta: meta, all: all, cwd: relCwd, }); @@ -1997,7 +2527,7 @@ exports.doAmendCommand = co.wrap(function *(repo, const subsToAmend = amendStatus.subsToAmend; const head = yield repo.getHeadCommit(); - const defaultSig = repo.defaultSignature(); + const defaultSig = yield ConfigUtil.defaultSignature(repo); const headMeta = exports.getCommitMetaData(head); let subMessages = null; if (interactive) { @@ -2008,7 +2538,8 @@ exports.doAmendCommand = co.wrap(function *(repo, defaultSig, headMeta, subsToAmend); - const userText = yield editMessage(repo, prompt); + const userText = yield GitUtil.editMessage(repo, prompt, editMessage, + !noVerify); const userData = exports.parseSplitCommitMessages(userText); message = userData.metaMessage; subMessages = userData.subMessages; @@ -2020,40 +2551,46 @@ exports.doAmendCommand = co.wrap(function *(repo, }); if (0 !== mismatched.length) { let error = `\ -Cannot amend because the signatures of the affected commits in the -following sub-repos do not match that of the meta-repo: +The last meta-repo commit (message or author) +does not match that of the last commit in the following sub-repos: `; mismatched.forEach(name => { error += ` ${colors.red(name)}\n`; }); error += `\ -You can make this commit using the interactive ('-i') commit option.`; +To prevent errors, you must make this commit using the interactive ('-i') +option, which will allow you to see and edit the commit messages for each +repository independently.`; throw new UserError(error); } - - if (null === editMessage) { - // If no `editMessage` function, use the message of the previous - // commit. - - message = head.message(); - } if (null === message) { - // If no message, use editor. - + // If no message, use editor / hooks. const prompt = exports.formatAmendEditorPrompt(defaultSig, headMeta, status, relCwd); - const rawMessage = yield editMessage(repo, prompt); + const rawMessage = yield GitUtil.editMessage(repo, prompt, + editMessage, + !noVerify); message = GitUtil.stripMessage(rawMessage); } } - if ("" === message) { - abortForNoMessage(); - } + abortIfNoMessage(message); + if (!exports.shouldCommit(status, + null === message, + subMessages || undefined)) { + process.stdout.write(PrintStatusUtil.printRepoStatus(status, relCwd)); + process.stdout.write(` +You asked to amend the most recent commit, but doing so would make +it empty. You can remove the commit entirely with "git meta reset HEAD^".`); + return { + metaCommit: null, + submoduleCommits: {} + }; + } // Finally, perform the operation. return yield exports.amendMetaRepo(repo, @@ -2061,6 +2598,7 @@ You can make this commit using the interactive ('-i') commit option.`; Object.keys(subsToAmend), all, message, - subMessages); - + subMessages, + noVerify); }); + diff --git a/node/lib/util/config_util.js b/node/lib/util/config_util.js new file mode 100644 index 000000000..8e1cf9ed2 --- /dev/null +++ b/node/lib/util/config_util.js @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2018, 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 NodeGit = require("nodegit"); +const UserError = require("../../lib/util/user_error"); + +/** + * This module contains methods for interacting with git configuration entries. + */ + +/** + * Return the string in the specified `config` for the specified `key`, or null + * if `key` does not exist in `config`. + * + * @param {NodeGit.Config} config + * @param {String} key + * @return {String|null} + */ +exports.getConfigString = co.wrap(function *(config, key) { + assert.instanceOf(config, NodeGit.Config); + assert.isString(key); + + try { + return yield config.getStringBuf(key); + } + catch (e) { + // Unfortunately, no other way to handle a missing config entry + } + return null; +}); + +/** + * Returns whether a config variable is, according to git's reckoning, + * true. That is, it's set to 'true', 'yes', or 'on'. If the variable is not + * set at all, return null. + * @async + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} configVar + * @return {Bool|null} + * @throws if the configuration variable doesn't exist + */ +exports.configIsTrue = co.wrap(function*(repo, configVar) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isString(configVar); + + const config = yield repo.config(); + const configured = yield exports.getConfigString(config, configVar); + if (null === configured) { + return configured; // RETURN + } + return configured === "true" || configured === "yes" || + configured === "on"; +}); + + +/** + * Returns the default Signature for a repo. Replaces repo.defaultSignature, + * which occasionally returns unknown@example.com for unknown reasons. + * @async + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} configVar + * @return {Bool|null} + * @throws if the configuration variable doesn't exist +*/ +exports.defaultSignature = co.wrap(function*(repo) { + assert.instanceOf(repo, NodeGit.Repository); + + const config = yield repo.config(); + const email = yield exports.getConfigString(config, "user.email"); + const name = yield exports.getConfigString(config, "user.name"); + if (name && email) { + const now = new Date(); + const tz = now.getTimezoneOffset(); + // libgit's timezone offset convention is inverted from JS. + return NodeGit.Signature.create(name, email, now.getTime() / 1000, -tz); + } + throw new UserError("Git config vars user.email and user.name are unset"); +}); + diff --git a/node/lib/util/conflict_util.js b/node/lib/util/conflict_util.js new file mode 100644 index 000000000..186ca162d --- /dev/null +++ b/node/lib/util/conflict_util.js @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2018, 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 NodeGit = require("nodegit"); + +/** + * @class ConflictEntry + * This class represents a part of a conflict. + */ +class ConflictEntry { + /** + * @constructor + * Create a new `ConflictEntry` having the specified `mode` and `id`. + * + * @param {Number} mode + * @param {String} id + */ + constructor(mode, id) { + assert.isNumber(mode); + assert.isString(id); + this.d_mode = mode; + this.d_id = id; + Object.freeze(this); + } + + /** + * @property {Number} mode + * the type of entry to create, determining if it is a file, commit, etc. + */ + get mode() { + return this.d_mode; + } + + /** + * @property {String} id + * the id of the entry to create, e.g., commit SHA or blob hash + */ + get id() { + return this.d_id; + } +} + +exports.ConflictEntry = ConflictEntry; + +/** + * @class Conflict + * This class represents a conflict in its three parts. + */ +class Conflict { + constructor(ancestor, our, their) { + if (null !== ancestor) { + assert.instanceOf(ancestor, ConflictEntry); + } + if (null !== our) { + assert.instanceOf(our, ConflictEntry); + } + if (null !== their) { + assert.instanceOf(their, ConflictEntry); + } + this.d_ancestor = ancestor; + this.d_our = our; + this.d_their = their; + Object.freeze(this); + } + + get ancestor() { + return this.d_ancestor; + } + + get our() { + return this.d_our; + } + + get their() { + return this.d_their; + } +} + +exports.Conflict = Conflict; + +/** + * Create the specified `conflict` in the specified `index` at the specified + * `path`. Note that this method does not flush the index to disk. + * + * @param {NodeGit.Index} index + * @param {String} path + * @param {Conflict} conflict + */ +exports.addConflict = co.wrap(function *(index, path, conflict) { + assert.instanceOf(index, NodeGit.Index); + assert.isString(path); + assert.instanceOf(conflict, Conflict); + + function makeEntry(entry) { + if (null === entry) { + return null; // RETURN + } + const result = new NodeGit.IndexEntry(); + result.path = path; + result.mode = entry.mode; + result.id = NodeGit.Oid.fromString(entry.id); + return result; + } + + const ancestorEntry = makeEntry(conflict.ancestor); + const ourEntry = makeEntry(conflict.our); + const theirEntry = makeEntry(conflict.their); + yield index.conflictAdd(ancestorEntry, ourEntry, theirEntry); +}); diff --git a/node/lib/util/deinit_util.js b/node/lib/util/deinit_util.js deleted file mode 100644 index da5eb5e30..000000000 --- a/node/lib/util/deinit_util.js +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright (c) 2016, 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"; - -/** - * This module contains methods for de-initializing local sub repositories. - */ - -const co = require("co"); -const rimraf = require("rimraf"); -const path = require("path"); -const fs = require("fs-promise"); - -/** - * De-initialize the repository having the specified `submoduleName` in the - * specified `repo`. - * - * @async - * @param {NodeGit.Repository} repo - * @param {String} submoduleName - */ -exports.deinit = co.wrap(function *(repo, submoduleName) { - - // This operation is a major pain, first because libgit2 does not provide - // any direct methods to do the equivalent of 'git deinit', and second - // because nodegit does not expose the method that libgit2 does provide to - // delete an entry from the config file. - - // De-initting a submodule requires the following things: - // 1. Confirms there are no unpushed (to any remote) commits - // or uncommited changes (including new files). - // 2. Remove all files under the path of the submodule, but not the - // directory itself, which would look to Git as if we were trying - // to remove the submodule. - // 3. Remove the entry for the submodule from the '.git/config' file. - // 4. Remove the directory .git/modules/ - - // We will clear out the path for the submodule. - - const rootDir = repo.workdir(); - const submodulePath = path.join(rootDir, submoduleName); - const files = yield fs.readdir(submodulePath); - - // Use 'rimraf' on each top level entry in the submodule'. - - const removeFiles = files.map(filename => { - return new Promise(callback => { - return rimraf(path.join(submodulePath, filename), {}, callback); - }); - }); - - yield removeFiles; - - // Using a very stupid algorithm here to find and remove the submodule - // entry. This logic could be smarter (maybe use regexes) and more - // efficition (stream in and out). - - const configPath = path.join(rootDir, ".git", "config"); - const configText = fs.readFileSync(configPath, "utf8"); - const configLines = configText.split("\n"); - const newConfigLines = []; - const searchString = "[submodule \"" + submoduleName + "\"]"; - - let found = false; - let inSubmoduleConfig = false; - - // Loop through the file and push, onto 'newConfigLines' any lines that - // aren't part of the bad submodule section. - - for (let i = 0; i < configLines.length; ++i) { - let line = configLines[i]; - if (!found && !inSubmoduleConfig && line === searchString) { - inSubmoduleConfig = true; - found = true; - } - else if (inSubmoduleConfig) { - // If we see a line starting with "[" while we're in the submodule - // section, we can get out of it. - - if (0 !== line.length && line[0] === "[") { - inSubmoduleConfig = false; - } - } - if (!inSubmoduleConfig) { - newConfigLines.push(line); - } - } - - // If we didn't find the submodule, don't write the data back out. - - if (found) { - newConfigLines.push(""); // one more new line - fs.writeFileSync(configPath, newConfigLines.join("\n")); - } -}); diff --git a/node/lib/util/destitch_util.js b/node/lib/util/destitch_util.js new file mode 100644 index 000000000..c6b3596b8 --- /dev/null +++ b/node/lib/util/destitch_util.js @@ -0,0 +1,517 @@ +/* + * Copyright (c) 2018, 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 NodeGit = require("nodegit"); +const path = require("path"); + +const BulkNotesUtil = require("./bulk_notes_util"); +const DoWorkQueue = require("./do_work_queue"); +const ForcePushSpec = require("./force_push_spec"); +const GitUtil = require("./git_util"); +const StitchUtil = require("./stitch_util"); +const SubmoduleConfigUtil = require("./submodule_config_util"); +const SubmoduleUtil = require("./submodule_util"); +const SyntheticBranchUtil = require("./synthetic_branch_util"); +const TreeUtil = require("./tree_util"); +const UserError = require("./user_error"); + +const FILEMODE = NodeGit.TreeEntry.FILEMODE; + +/** + * @property {String} local record of stitched commits + */ +exports.localReferenceNoteRef = "refs/notes/stitched/local-reference"; + +/** + * Return the destitched data corresponding to the specified `stitchedSha` in + * the specified `repo` if it can be found in `refs/notes/stitched/reference` + * or `refs/notes/stitched/local-reference` or in the specified + * `newlyStitched`, and null if it has not been destitched. + * + * @param {NodeGit.Repository} repo + * @param {Object} newlyStitched + * @param {String} stitchedSha + * @return {Object} + * @return {String} metaRepoCommit + * @return {Object} subCommits name to sha + */ +exports.getDestitched = co.wrap(function *(repo, newlyStitched, stitchedSha) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isObject(newlyStitched); + assert.isString(stitchedSha); + if (stitchedSha in newlyStitched) { + return newlyStitched[stitchedSha]; + } + let note = yield GitUtil.readNote(repo, + exports.localReferenceNoteRef, + stitchedSha); + if (null === note) { + note = yield GitUtil.readNote(repo, + StitchUtil.referenceNoteRef, + stitchedSha); + } + return note && JSON.parse(note.message()); +}); + +/** + * Return the name in the specified `submodules` to which the specified + * `filename` maps or null if it maps to no submodule. A filename maps to a + * submodule if the submodule's path contains the filename. * + * @param {Object} submodules name to URL + * @param {String} filename + * @return {String|null} + */ +exports.findSubmodule = function (submodules, filename) { + assert.isObject(submodules); + assert.isString(filename); + + while ("." !== filename) { + if (filename in submodules) { + return filename; // RETURN + } + filename = path.dirname(filename); + } + return null; +}; + +/** + * Return the names of the specified `submodules` in the specified `repo` that + * are affected by the changes introduced in the specified `stitchedCommit` as + * compared against the specified `parentCommit`. + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} submodules name to URL + * @param {NodeGit.Commit} stitchedCommit + * @param {NodeGit.Commit} parentCommit + * @return {Set String} + */ +exports.computeChangedSubmodules = co.wrap(function *(repo, + submodules, + stitchedCommit, + parentCommit) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isObject(submodules); + assert.instanceOf(stitchedCommit, NodeGit.Commit); + assert.instanceOf(parentCommit, NodeGit.Commit); + + // We're going to take a diff between `stitchedCommit` and `parentCommit`, + // and return a set of all submodule names that map to the modified files. + + const result = new Set(); + const tree = yield stitchedCommit.getTree(); + const parentTree = yield parentCommit.getTree(); + const diff = yield NodeGit.Diff.treeToTree(repo, parentTree, tree, null); + const numDeltas = diff.numDeltas(); + const modulesFileName = SubmoduleConfigUtil.modulesFileName; + for (let i = 0; i < numDeltas; ++i) { + const delta = diff.getDelta(i); + const file = delta.newFile(); + const filename = file.path(); + if (modulesFileName === filename) { + continue; // CONTINUE + } + const subname = exports.findSubmodule(submodules, filename); + if (null === subname) { + throw new UserError(`\ +Could not map ${filename} to a submodule, and additions are not supported.`); + } + result.add(subname); + } + return result; +}); + +/** + * Make a destitched commit created by applying changes to the specified + * `changedSubmodules` from the specified `stitchedCommit` on top of the + * specified `metaRepoCommits` in the specified `repo`. Use the specified + * `subUrls` to compute a new `.gitmodules` file when necessary (e.g., a + * submodule is deleted). Return an object describing commits that were + * created. The behavior is undefined all commits referenced in + * `changedSubmodules` exist in `repo`. + * + * @param {NodeGit.Repository} repo + * @param {[NodeGit.Commit]} metaRepoCommits + * @param {NodeGit.Commit} stitchedCommit + * @param {Object} subUrls name to URL + * @param {Object} changedSubmodules name to SHA + * @return {Object} + * @return {String} return.metaRepoCommit + * @return {Object} subCommits name to String + */ +exports.makeDestitchedCommit = co.wrap(function *(repo, + metaRepoCommits, + stitchedCommit, + changedSubmodules, + subUrls) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isArray(metaRepoCommits); + assert.instanceOf(stitchedCommit, NodeGit.Commit); + assert.isObject(changedSubmodules); + assert.isObject(subUrls); + + const subCommits = {}; // created submodule commits + const tree = yield stitchedCommit.getTree(); + let baseTree = null; // tree of first parent if there is one + if (0 !== metaRepoCommits.length) { + const metaRepoCommit = metaRepoCommits[0]; + baseTree = yield metaRepoCommit.getTree(); + } + const author = stitchedCommit.author(); + const committer = stitchedCommit.committer(); + const messageEncoding = stitchedCommit.messageEncoding(); + const message = stitchedCommit.message(); + const changes = {}; // changes from the meta parent's tree + const commitUrls = Object.assign({}, subUrls); + + const computeSubChanges = co.wrap(function *(sub) { + const sha = changedSubmodules[sub]; + let stitchedEntry = null; + try { + stitchedEntry = yield tree.entryByPath(sub); + } catch(e) { + delete commitUrls[sub]; + changes[sub] = null; + return; // RETURN + } + const mode = stitchedEntry.filemode(); + if (FILEMODE.TREE !== mode) { + // Changes must reside in submodules; we're not going to put files + // directly in the meta-repo. + // TBD: allow COMMIT changes. + + throw new UserError(`\ +Change change of mode ${mode} to '${sub}' is not supported.`); + } + + // Now we have an entry that's a tree. We're going to make a new + // commit whose contents are exactly that tree. + + const treeId = stitchedEntry.id(); + const stitchedTree = yield NodeGit.Tree.lookup(repo, treeId); + const parent = yield repo.getCommit(sha); + const commitId = yield NodeGit.Commit.create(repo, + null, + author, + committer, + messageEncoding, + message, + stitchedTree, + 1, + [parent]); + const commit = yield repo.getCommit(commitId); + subCommits[sub] = commit.id().tostrS(); + changes[sub] = new TreeUtil.Change(commit, FILEMODE.COMMIT); + }); + yield DoWorkQueue.doInParallel(Object.keys(changedSubmodules), + computeSubChanges); + + // Update the modules file if we've removed one. + + const modulesContent = SubmoduleConfigUtil.writeConfigText(commitUrls); + const modulesId = yield GitUtil.hashObject(repo, modulesContent); + changes[SubmoduleConfigUtil.modulesFileName] = + new TreeUtil.Change(modulesId, FILEMODE.BLOB); + + // Now we make a new commit using the changes we've computed. + + const newTree = yield TreeUtil.writeTree(repo, baseTree, changes); + const commitId = yield NodeGit.Commit.create(repo, + null, + author, + committer, + messageEncoding, + message, + newTree, + metaRepoCommits.length, + metaRepoCommits); + return { + metaRepoCommit: commitId.tostrS(), + subCommits: subCommits, + }; +}); + +/** + * Destitch the specified `stitchedCommit` and (recursively) any of its + * ancestors, but do nothing if the SHA for `stitchedCommit` exists in the + * specified `newlyDestitched` map or in reference notes. If a destitched + * commit is created, record it in `newlyDestitched`. Both of these map from + * SHA to { metaRepoCommit, subCommits: submodule name to commit}. Use the + * specified `baseUrl` to fetch needed meta-repo commits and to resolve + * submodule URLs. Return the SHA of the destitched version of + * `stitchedCommit`. + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} stitchedCommit + * @param {String} baseUrl + * @param {Object} newlyDestitched + * @return {String} + */ +exports.destitchChain = co.wrap(function *(repo, + stitchedCommit, + baseUrl, + newlyDestitched) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(stitchedCommit, NodeGit.Commit); + assert.isString(baseUrl); + assert.isObject(newlyDestitched); + + const stitchedSha = stitchedCommit.id().tostrS(); + const done = yield exports.getDestitched(repo, + newlyDestitched, + stitchedSha); + if (null !== done) { + // Nothing to do here if it's been destitched. + + return done.metaRepoCommit; // RETURN + } + + // Make sure all destitched parents are available and load their commits. + + const parents = yield stitchedCommit.getParents(); + if (0 === parents.length) { + throw new UserError(`Cannot destitch orphan commit ${stitchedSha}`); + } + const destitchedParents = []; + for (const stitchedParent of parents) { + const stitchedSha = stitchedParent.id().tostrS(); + const destitched = yield exports.getDestitched(repo, + newlyDestitched, + stitchedSha); + let destitchedSha; + if (null === destitched) { + // If a parent has yet to be destiched, recurse. + + destitchedSha = yield exports.destitchChain(repo, + stitchedParent, + baseUrl, + newlyDestitched); + } else { + // If the parent was already destitched, make sure its meta-repo + // commit is present,. + + destitchedSha = destitched.metaRepoCommit; + console.log(`Fetching meta-repo commit, ${destitchedSha}.`); + yield GitUtil.fetchSha(repo, baseUrl, destitchedSha); + } + const commit = yield repo.getCommit(destitchedSha); + destitchedParents.push(commit); + } + + const firstParent = destitchedParents[0]; + const urls = + yield SubmoduleConfigUtil.getSubmodulesFromCommit(repo, firstParent); + const changes = yield exports.computeChangedSubmodules(repo, + urls, + stitchedCommit, + parents[0]); + + const names = Array.from(changes); + const shas = yield SubmoduleUtil.getSubmoduleShasForCommit(repo, + names, + firstParent); + + // Make sure we have all the commits needed for each changed submodule, and + // do the fetch before processing ancestor commits to minimize the number + // of fetches. + + const fetchSub = co.wrap(function *(name) { + const url = SubmoduleConfigUtil.resolveUrl(baseUrl, urls[name]); + const sha = shas[name]; + if (undefined !== sha) { + console.log(`Fetching submodule ${name}.`); + yield GitUtil.fetchSha(repo, url, sha); + } + }); + yield DoWorkQueue.doInParallel(names, fetchSub); + + const result = yield exports.makeDestitchedCommit(repo, + destitchedParents, + stitchedCommit, + shas, + urls); + newlyDestitched[stitchedSha] = result; + return result.metaRepoCommit; +}); + +/** + * Push synthetic refs for the submodule commits described in the specified + * `newCommits` created for the specified `destitchedCommit` in the specified + * `repo`. Use the specified `baseUrl` to resolve relative URLS + * + * TBD: Minimize the number of pushes so that we do not push a commit and its + * ancestor. + * + * @param {NodeGit.Repository} repo + * @param {String} baseUrl + * @param {NodeGit.Commit} destitchedCommit + * @param {Object} commits map from sha to { metaRepoCommit, subCommits } + */ +exports.pushSyntheticRefs = co.wrap(function *(repo, + baseUrl, + destitchedCommit, + newCommits) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isString(baseUrl); + assert.instanceOf(destitchedCommit, NodeGit.Commit); + assert.isObject(newCommits); + + const urls = yield SubmoduleConfigUtil.getSubmodulesFromCommit( + repo, + destitchedCommit); + + const toPush = []; // Array of url and sha + Object.keys(newCommits).forEach(sha => { + const subs = newCommits[sha].subCommits; + Object.keys(subs).forEach(sub => { + const subUrl = SubmoduleConfigUtil.resolveSubmoduleUrl(baseUrl, + urls[sub]); + toPush.push({ + url: subUrl, + sha: subs[sub], + }); + }); + }); + const pushOne = co.wrap(function *(push) { + const sha = push.sha; + const refName = SyntheticBranchUtil.getSyntheticBranchForCommit(sha); + yield GitUtil.push( + repo, + push.url, + sha, + refName, + ForcePushSpec.Force, + true); + }); + yield DoWorkQueue.doInParallel(toPush, pushOne); +}); + +/** + * Record the specified `newCommits` to local reference notes in the specified + * `repo`. + * + * @param {NodeGit.Repository} repo + * @param {Object} newCommits sha to { metaRepoCommit, subCommits} + */ +exports.recordLocalNotes = co.wrap(function *(repo, newCommits) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isObject(newCommits); + const content = {}; + Object.keys(newCommits).forEach(sha => { + content[sha] = JSON.stringify(newCommits[sha], null, 4); + }); + yield BulkNotesUtil.writeNotes(repo, + exports.localReferenceNoteRef, + content); +}); + +/** + * Create a destitched version of the specified `commitish`, including any + * ancestors for which destitched versions cannot be found, in the specified + * `repo`. Use the specified `metaRemote` to fetch neede meta-repo commits and + * to resolve submodule URLs. Use the notes stored in + * `refs/notes/stitched/reference` and `refs/notes/stitched/local-reference` to + * match source meta-repo commits to stitched commits. The behavior is + * undefined if `stitchedCommit` or any of its (transitive) ancestors is a root + * commit (having no parents) that cannot be mapped to a destitched commit in + * `refs/notes/stitched/reference`. Create and push synthetic references to + * root all sub-module commits created as part of this operation. If the + * specified `targetRefName` is provided, create or update the reference with + * that name to point to the destitched version of `stitchedCommit`. Write, to + * `refs/notes/stitched/local-reference` a record of the destitched notes + * generated and return an object that describes them. Throw a `UserError` if + * `commitish` cannot be resolved or if `metaRemoteName` does not map to a + * valid remote. + * + * @param {NodeGit.Repository} repo + * @param {String} commitish + * @param {String} metaRemoteName + * @param {String|null} targetRefName + * @return {Object} map from stitched sha to + * { metaRepoCommit, submoduleCommits (from name to sha)} + */ +exports.destitch = co.wrap(function *(repo, + commitish, + metaRemoteName, + targetRefName) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isString(commitish); + assert.isString(metaRemoteName); + if (null !== targetRefName) { + assert.isString(targetRefName); + } + + const annotated = yield GitUtil.resolveCommitish(repo, commitish); + if (null === annotated) { + throw new UserError(`\ +Could not resolve '${commitish}' to a commit.`); + } + const commit = yield repo.getCommit(annotated.id()); + if (!(yield GitUtil.isValidRemoteName(repo, metaRemoteName))) { + throw new UserError(`Invalid remote name: '${metaRemoteName}'.`); + } + const remote = yield NodeGit.Remote.lookup(repo, metaRemoteName); + const baseUrl = remote.url(); + + const newlyStitched = {}; + + console.log("Destitching"); + + const result = yield exports.destitchChain(repo, + commit, + baseUrl, + newlyStitched); + const resultCommit = yield repo.getCommit(result); + + // Push synthetic-refs + + console.log("Pushing synthetic refs"); + yield exports.pushSyntheticRefs(repo, baseUrl, resultCommit, newlyStitched); + + // Record local notes + + console.log("Recording local note"); + yield exports.recordLocalNotes(repo, newlyStitched); + + // Update the branch if requested. + + if (null !== targetRefName) { + console.log(`Updating ${targetRefName}`); + yield NodeGit.Reference.create(repo, + targetRefName, + resultCommit, + 1, + "destitched"); + } + return newlyStitched; +}); diff --git a/node/lib/util/diff_util.js b/node/lib/util/diff_util.js index 3c20a993c..3674e98b0 100644 --- a/node/lib/util/diff_util.js +++ b/node/lib/util/diff_util.js @@ -35,7 +35,14 @@ const co = require("co"); const NodeGit = require("nodegit"); const RepoStatus = require("./repo_status"); -const SubmoduleConfigUtil = require("./submodule_config_util"); + +const DELTA = NodeGit.Diff.DELTA; + +exports.UNTRACKED_FILES_OPTIONS = { + ALL: "all", + NORMAL: "normal", + NO: "no", +}; /** * Return the `RepoStatus.FILESTATUS` value that corresponds to the specified @@ -46,13 +53,11 @@ const SubmoduleConfigUtil = require("./submodule_config_util"); * @return {RepoStatus.FILESTATUS} */ exports.convertDeltaFlag = function (flag) { - const DELTA = NodeGit.Diff.DELTA; const FILESTATUS = RepoStatus.FILESTATUS; switch (flag) { case DELTA.MODIFIED: return FILESTATUS.MODIFIED; case DELTA.ADDED: return FILESTATUS.ADDED; case DELTA.DELETED: return FILESTATUS.REMOVED; - case DELTA.CONFLICTED: return FILESTATUS.CONFLICTED; case DELTA.RENAMED: return FILESTATUS.RENAMED; case DELTA.TYPECHANGE: return FILESTATUS.TYPECHANGED; @@ -62,7 +67,7 @@ exports.convertDeltaFlag = function (flag) { case DELTA.UNTRACKED: return FILESTATUS.ADDED; } - assert(`Unrecognized DELTA type: ${flag}.`); + assert(false, `Unrecognized DELTA type: ${flag}.`); }; function readDiff(diff) { @@ -72,17 +77,25 @@ function readDiff(diff) { for (let i = 0; i < numDeltas; ++i) { const delta = diff.getDelta(i); const diffStatus = delta.status(); + if (DELTA.CONFLICTED === diffStatus) { + continue; // CONTINUE + } const fileStatus = exports.convertDeltaFlag(diffStatus); const file = FILESTATUS.REMOVED === fileStatus ? delta.oldFile() : delta.newFile(); const path = file.path(); - // Skip the .gitmodules file and all submodule changes; they're handled - // separately. + if (FILESTATUS.MODIFIED === fileStatus && + delta.newFile().id().equal(delta.oldFile().id())) { + // This file isn't actually changed -- it's just being reported + // as changed because it differs from the index (even though + // the index isn't really for contents). + continue; + } - if (SubmoduleConfigUtil.modulesFileName !== path && - NodeGit.TreeEntry.FILEMODE.COMMIT !== file.mode()) { + // Skip the all submodule changes; they're handled separately. + if (NodeGit.TreeEntry.FILEMODE.COMMIT !== file.mode()) { result[path] = fileStatus; } } @@ -90,22 +103,27 @@ function readDiff(diff) { } /** + * Do not use this on the meta repo because it uses libgit2 operations + * with bad performance and without the ability to handle sparse checkouts. + * * Return differences for the specified `paths` in the specified `repo` between * the current index and working directory, and the specified `tree`, if - * not null. If the specified `allUntracked` is true, include all untracked - * files rather than accumulating them by directory. If `paths` is empty, - * check the entire `repo`. If the specified `ignoreIndex` is true, - * return, in the `workdir` field, the status difference between the workdir - * and `tree`, ignoring the state of the index. Otherwise, return, in the - * `workdir` field, the difference between the workir and the index; and in the - * `staged` field, the difference between the index and `tree`. Note that when - * `ignoreIndex` is true, the returned `staged` field will always be `{}`. + * not null. If the specified `untrackedFilesOption` is ALL, include all + * untracked files. If it is NORMAL, accumulate them by directory. If it is NO, + * don't show untracked files. If `paths` is empty, check the entire `repo`. + * If the specified `ignoreIndex` is true, return, in the `workdir` field, the + * status difference between the workdir and `tree`, ignoring the state of the + * index. Otherwise, return, in the `workdir` field, the difference between + * the workir and the index; and in the `staged` field, the difference between + * the index and `tree`. Note that when `ignoreIndex` is true, the returned + * `staged` field will always be `{}`. Note also that conflicts are ignored; we + * don't have enough information here to handle them properly. * * @param {NodeGit.Repository} repo * @param {NodeGit.Tree|null} tree * @param {String []} paths * @param {Boolean} ignoreIndex - * @param {Boolean} allUntracked + * @param {String} untrackedFilesOption * @return {Object} * @return {Object} return.staged path to FILESTATUS of staged changes * @return {Object} return.workdir path to FILESTATUS of workdir changes @@ -114,30 +132,44 @@ exports.getRepoStatus = co.wrap(function *(repo, tree, paths, ignoreIndex, - allUntracked) { + untrackedFilesOption) { assert.instanceOf(repo, NodeGit.Repository); if (null !== tree) { assert.instanceOf(tree, NodeGit.Tree); } assert.isArray(paths); assert.isBoolean(ignoreIndex); - assert.isBoolean(allUntracked); + if (!untrackedFilesOption) { + untrackedFilesOption = exports.UNTRACKED_FILES_OPTIONS.NORMAL; + } const options = { ignoreSubmodules: 1, - flags: NodeGit.Diff.OPTION.INCLUDE_UNTRACKED | - NodeGit.Diff.OPTION.EXCLUDE_SUBMODULES, + flags: NodeGit.Diff.OPTION.IGNORE_SUBMODULES, }; if (0 !== paths.length) { options.pathspec = paths; } - if (allUntracked) { - options.flags = options.flags | - NodeGit.Diff.OPTION.RECURSE_UNTRACKED_DIRS; + + switch (untrackedFilesOption) { + case exports.UNTRACKED_FILES_OPTIONS.ALL: + options.flags = options.flags | + NodeGit.Diff.OPTION.INCLUDE_UNTRACKED | + NodeGit.Diff.OPTION.RECURSE_UNTRACKED_DIRS; + break; + case exports.UNTRACKED_FILES_OPTIONS.NORMAL: + options.flags = options.flags | + NodeGit.Diff.OPTION.INCLUDE_UNTRACKED; + break; + case exports.UNTRACKED_FILES_OPTIONS.NO: + break; } + if (ignoreIndex) { const workdirToTreeDiff = - yield NodeGit.Diff.treeToWorkdir(repo, tree, options); + yield NodeGit.Diff.treeToWorkdirWithIndex(repo, + tree, + options); const workdirToTreeStatus = readDiff(workdirToTreeDiff); return { staged: {}, diff --git a/node/lib/util/do_work_queue.js b/node/lib/util/do_work_queue.js index a728e4e92..4ae4f2c69 100644 --- a/node/lib/util/do_work_queue.js +++ b/node/lib/util/do_work_queue.js @@ -36,27 +36,34 @@ const co = require("co"); /** * Call the specified `getWork` function to create a promise to do work for * each element in the specified `queue`, limiting the amount of parallel work - * to the optionally specified `limit`, if provided, or 20 otherwise. Return - * an array containing the result of the work *in the order that it was + * to the optionally specified `options.limit`, if provided, or 20 otherwise. + * Return an array containing the result of the work *in the order that it was * received*, which may not be the same as the order in which the work was - * completed. + * completed. If `options.failMsg` is provided, it will print an error message + * with element name if the work of the element fails. * * @async * @param {Array} queue * @param {(_, Number) => Promise} getWork - * @param {Number} [limit] - * @param {Array} + * @param {Object} [options] + * @param {Number} options.limit + * @param {String} options.failMsg */ -exports.doInParallel = co.wrap(function *(queue, getWork, limit) { +exports.doInParallel = co.wrap(function *(queue, getWork, options) { assert.isArray(queue); assert.isFunction(getWork); - if (undefined === limit) { - limit = 20; + let limit = 20; + if (options && options.limit) { + assert.isNumber(options.limit); + limit = options.limit; } - else { - assert.isNumber(limit); + let failMsg = ""; + if (options && options.failMsg) { + assert.isString(options.failMsg); + failMsg = options.failMsg; } + const total = queue.length; const result = new Array(total); let next = 0; @@ -64,8 +71,17 @@ exports.doInParallel = co.wrap(function *(queue, getWork, limit) { const doWork = co.wrap(function *() { while (next !== total) { const current = next++; - const currentResult = yield getWork(queue[current], current); - result[current] = currentResult; + try { + const currentResult = yield getWork(queue[current], current); + result[current] = currentResult; + } catch(err) { + if (failMsg) { + console.log( + `'${queue[current]}': ${failMsg}` + ); + } + throw err; + } } }); diff --git a/node/lib/util/force_push_spec.js b/node/lib/util/force_push_spec.js new file mode 100644 index 000000000..2217abfdb --- /dev/null +++ b/node/lib/util/force_push_spec.js @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020, 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"; + +class ForcePushSpec { + constructor(flag) { + this.d_flag = flag; + } + + toString() { + return this.d_flag; + } +} +ForcePushSpec.NoForce = new ForcePushSpec(""); +ForcePushSpec.Force = new ForcePushSpec("--force"); +ForcePushSpec.ForceWithLease = new ForcePushSpec("--force-with-lease"); + +module.exports = ForcePushSpec; diff --git a/node/lib/util/git_util.js b/node/lib/util/git_util.js old mode 100644 new mode 100755 index d92daaffc..638f7ed4b --- a/node/lib/util/git_util.js +++ b/node/lib/util/git_util.js @@ -31,7 +31,8 @@ "use strict"; /** - * This module contains common git utility methods. + * This module contains common git utility methods that require NodeGit. + * Otherwise, put them in git_util_fast to optimize their load time. */ const assert = require("chai").assert; @@ -42,31 +43,14 @@ const fs = require("fs-promise"); const NodeGit = require("nodegit"); const path = require("path"); -const UserError = require("../util/user_error"); +const ConfigUtil = require("./config_util"); +const Hook = require("./hook"); +const ForcePushSpec = require("./force_push_spec"); +const GitUtilFast = require("./git_util_fast"); +const SyntheticBranchUtil = require("./synthetic_branch_util"); +const UserError = require("./user_error"); -/** - * If the directory identified by the specified `dir` contains a ".git" - * directory, return it. Otherwise, return the first parent directory of `dir` - * containing a `.git` directory. If no such directory exists, return `None`. - * - * @private - * @param {String} dir - * @return {String} - */ -function getContainingGitDir(dir) { - const gitDir = path.join(dir, ".git"); - if (fs.existsSync(gitDir) && fs.statSync(gitDir).isDirectory()) { - return dir; // RETURN - } - - const base = path.dirname(dir); - - if ("" === base || "/" === base) { - return null; // RETURN - } - - return getContainingGitDir(base); -} +const EXEC_BUFFER = 1024*1024*100; /** * Create a branch having the specified `branchName` in the specified `repo` @@ -84,9 +68,7 @@ exports.createBranchFromHead = co.wrap(function *(repo, branchName) { const head = yield repo.getHeadCommit(); return yield repo.createBranch(branchName, head, - 0, - repo.defaultSignature(), - "git-meta branch"); + 0); }); /** @@ -114,13 +96,19 @@ exports.findBranch = co.wrap(function *(repo, branchName) { * Return the tracking information for the specified `branch`, or null if it * has none. * + * @param {NodeGit.Repository} repo * @param {NodeGit.Reference} branch * @return {Object|null} * @return {String|null} return.remoteName + * @return {String|null} return.pushRemoteName * @return {String} return.branchName */ -exports.getTrackingInfo = co.wrap(function *(branch) { +exports.getTrackingInfo = co.wrap(function *(repo, branch) { + assert.instanceOf(repo, NodeGit.Repository); assert.instanceOf(branch, NodeGit.Reference); + if (!branch.isBranch()) { + return null; + } let upstream; try { upstream = yield NodeGit.Branch.upstream(branch); @@ -131,19 +119,58 @@ exports.getTrackingInfo = co.wrap(function *(branch) { } const name = upstream.shorthand(); const parts = name.split("/"); + const config = yield repo.config(); + + // Try to read a 'pushRemote' for the branch. + + let pushRemote = yield ConfigUtil.getConfigString( + config, + `branch.${branch.shorthand()}.pushRemote`); + + // If no 'pushRemote', try to read a 'pushDefault' for the repo. + + if (null === pushRemote) { + pushRemote = yield ConfigUtil.getConfigString(config, + "remote.pushDefault"); + } + if (1 === parts.length) { return { branchName: parts[0], remoteName: null, + pushRemoteName: pushRemote, }; } const remoteName = parts.shift(); return { branchName: parts.join("/"), remoteName: remoteName, + pushRemoteName: pushRemote || remoteName, }; }); +/** + * If the current branch branch in the specified `repo` tracks another branch, + * return its name (qualified by $remote-name if it's tracking a remote + * branch), or return null if it tracks no branch. + * + * @param {NodeGit.Repository} repo + * @return {String|null} + */ +exports.getCurrentTrackingBranchName = co.wrap(function *(repo) { + assert.instanceOf(repo, NodeGit.Repository); + + const head = yield repo.head(); + const tracking = yield exports.getTrackingInfo(repo, head); + if (null === tracking) { + return null; // RETURN + } + if (null === tracking.remoteName) { + return tracking.branchName; // RETURN + } + return `${tracking.remoteName}/${tracking.branchName}`; +}); + /** * Return the remote associated with the upstream reference of the specified * `branch` in the specified `repo`. @@ -155,7 +182,7 @@ exports.getTrackingInfo = co.wrap(function *(branch) { exports.getRemoteForBranch = co.wrap(function *(repo, branch) { assert.instanceOf(repo, NodeGit.Repository); assert.instanceOf(branch, NodeGit.Reference); - const trackingInfo = yield exports.getTrackingInfo(branch); + const trackingInfo = yield exports.getTrackingInfo(repo, branch); if (null === trackingInfo || null === trackingInfo.remoteName) { return null; } @@ -183,7 +210,7 @@ exports.isValidRemoteName = co.wrap(function *(repo, name) { assert.instanceOf(repo, NodeGit.Repository); assert.isString(name); - const remotes = yield repo.getRemotes(); + const remotes = yield repo.getRemoteNames(); return remotes.find(x => x === name) !== undefined; }); @@ -273,17 +300,6 @@ exports.findRemoteBranch = co.wrap(function *(repo, remoteName, branchName) { return yield exports.findBranch(repo, shorthand); }); - -/** - * Return the root of the repository in which the current working directory - * resides, or null if the working directory contains no git repository. - * - * @return {String|null} - */ -exports.getRootGitDirectory = function () { - return getContainingGitDir(process.cwd()); -}; - /** * Return the current repository (as located from the current working * directory) or throw a `UserError` exception if no git repository can be @@ -293,7 +309,7 @@ exports.getRootGitDirectory = function () { * @return {NodeGit.Repository} */ exports.getCurrentRepo = function () { - const path = exports.getRootGitDirectory(); + const path = GitUtilFast.getRootGitDirectory(); if (null === path) { throw new UserError( `Could not find Git directory from ${colors.red(process.cwd())}.`); @@ -306,14 +322,14 @@ exports.getCurrentRepo = function () { * `target` branch in the specified `remote` repository. Return null if the * push succeeded and string containing an error message if the push failed. * Attempt to allow a non-ffwd push if the specified `force` is `true`. - * Silence console output if the specified `quiet` is provided and is true. + * If `quiet` is true, and push was successful, silence console output. * * @async * @param {NodeGit.Repository} repo * @param {String} remote * @param {String} source * @param {String} target - * @param {String} force + * @param {ForcePushSpec} force * @param {Boolean} [quiet] * @return {String} [return] */ @@ -326,7 +342,7 @@ exports.push = co.wrap(function *(repo, remote, source, target, force, quiet) { assert.isString(remote); assert.isString(source); assert.isString(target); - assert.isBoolean(force); + assert.instanceOf(force, ForcePushSpec); if (undefined === quiet) { quiet = false; @@ -335,20 +351,52 @@ exports.push = co.wrap(function *(repo, remote, source, target, force, quiet) { assert.isBoolean(quiet); } - let forceStr = ""; - if (force) { - forceStr = "-f"; - } + let forceStr = force ? force.toString() : ""; + + const { execString, environ } = (() => { + if (repo.workdir()) { + return { + execString: `\ +git -C '${repo.workdir()}' push ${forceStr} ${remote} ${source}:${target}`, + environ: Object.assign({}, process.env) + }; + } + + // Hack: set a fake work-tree because the repo's core.worktree might + // be set to a directory that, due to sparseness, doesn't exist, and + // git push has a bug which requires it to have a worktree. + return { + execString: `\ +git -C '${repo.path()}' push ${forceStr} ${remote} ${source}:${target}`, + environ: Object.assign( + {}, + process.env, + { GIT_WORK_TREE: repo.path() } + ) + }; + })(); - const execString = `\ -git -C '${repo.workdir()}' push ${forceStr} ${remote} ${source}:${target}`; try { - const result = yield ChildProcess.exec(execString); - if (result.stdout && !quiet) { - console.log(result.stdout); + let result = yield ChildProcess.exec(execString, { + env : environ, + maxBuffer: EXEC_BUFFER + }); + if (result.error && + result.stderr.indexOf("reference already exists") !== -1) { + // GitLab has a race condition that somehow causes this to + // happen spuriously -- let's retry. + result = yield ChildProcess.exec(execString, { + env : environ, + maxBuffer: EXEC_BUFFER + }); } - if (result.stderr && !quiet) { - console.error(result.stderr); + if (result.error || !quiet) { + if (result.stdout) { + console.log(result.stdout); + } + if (result.stderr) { + console.error(result.stderr); + } } return null; } @@ -367,7 +415,17 @@ exports.getCurrentBranchName = co.wrap(function *(repo) { assert.instanceOf(repo, NodeGit.Repository); if (!repo.isEmpty() && 1 !== repo.headDetached()) { - const branch = yield repo.getCurrentBranch(); + let branch; + try { + branch = yield repo.getCurrentBranch(); + } catch (e) { + // TODO: raise an issue with libgit2. If your repository is in a + // newly initialized state, but not fully empty (e.g., it has + // configured remotes), this method throws an exception: + // "reference 'refs/heads/master' not found". Either it should + // return null or `isEmpty` should return true. + return null; + } return branch.shorthand(); } return null; @@ -446,7 +504,9 @@ exports.fetch = co.wrap(function *(repo, remoteName) { const execString = `git -C '${repo.path()}' fetch -q '${remoteName}'`; try { - return yield ChildProcess.exec(execString); + return yield ChildProcess.exec(execString, { + maxBuffer: EXEC_BUFFER + }); } catch (e) { throw new UserError(e.message); @@ -475,7 +535,9 @@ exports.fetchBranch = co.wrap(function *(repo, remoteName, branch) { const execString = `\ git -C '${repo.path()}' fetch -q '${remoteName}' '${branch}'`; try { - return yield ChildProcess.exec(execString); + return yield ChildProcess.exec(execString, { + maxBuffer: EXEC_BUFFER + }); } catch (e) { throw new UserError(e.message); @@ -484,132 +546,61 @@ git -C '${repo.path()}' fetch -q '${remoteName}' '${branch}'`; /** * Fetch the specified `sha` from the specified `url` into the specified - * `repo`, if it does not already exist in `repo`. + * `repo`, if it does not already exist in `repo`; return true if a fetch + * happened and false if the commit already existed in `repo`. * * @async * @param {NodeGit.Repository} repo * @param {String} url * @param {String} sha + * @return {Bool} */ -exports.fetchSha = co.wrap(function *(repo, url, sha) { +exports.fetchSha = co.wrap(function *(repo, url, sha, prefix) { assert.instanceOf(repo, NodeGit.Repository); assert.isString(url); assert.isString(sha); + if (prefix === undefined) { + prefix = ""; + } // First, try to get the commit. If we succeed, no need to fetch. try { yield repo.getCommit(sha); - return; // RETURN + return false; // RETURN } catch (e) { } - const execString = `git -C '${repo.path()}' fetch -q '${url}' ${sha}`; + const refPrefix = `${SyntheticBranchUtil.SYNTHETIC_BRANCH_BASE}${prefix}`; + let negotiationTip = ""; + let negotiationAlgorithm = ""; + if (prefix) { + const refCountCommand = `git -C '${repo.path()}' for-each-ref \ +--count 1 ${refPrefix}`; + let result = yield ChildProcess.exec(refCountCommand); + const anyRefs = !!result.stdout.trim(); + if (anyRefs) { + negotiationTip = `--negotiation-tip '${refPrefix}*'`; + } else { + // If there are no existing refs, negotiation-tip will be ignored. + // In this case, we would still prefer not to negotiate + negotiationAlgorithm = "-c fetch.negotiationAlgorithm=noop"; + } + } + + const execString = `git -C '${repo.path()}' ${negotiationAlgorithm} fetch \ + -q '${url}' ${negotiationTip} ${sha}:${refPrefix}${sha}`; try { - return yield ChildProcess.exec(execString); + yield ChildProcess.exec(execString, { + maxBuffer: EXEC_BUFFER + }); + yield repo.getCommit(sha); } catch (e) { - throw new UserError(e.message); + throw new UserError(e.message, UserError.CODES.FETCH_ERROR); } -}); - - - -/** - * Return a list the shas of commits in the history of the specified `commit` - * not present in the history of the specified `remote` in the specified - * `repo`. Note that this command does not do a *fetch*; the check is made - * against what commits are locally known. - * - * async - * @param {NodeGit.Repository} repo - * @param {String} remote - * @param {String} commit - * @return {NodeGit.Oid []} - */ -exports.listUnpushedCommits = co.wrap(function *(repo, remote, commit) { - // I wish there were a simpler way to do this. Our algorithm: - // 1. List all the refs for 'remote'. - // 2. Compute the list of different commits between each the head of each - // matching remote ref. - // 3. Return the shortest list. - // 4. If no matching refs return a list of all commits that are in the - // history of 'commit'. - - assert.instanceOf(repo, NodeGit.Repository); - assert.isString(remote); - assert.isString(commit); - - const refs = yield repo.getReferenceNames(NodeGit.Reference.TYPE.LISTALL); - - const commitId = NodeGit.Oid.fromString(commit); - - let bestResult = null; - - const regex = new RegExp(`^refs/remotes/${remote}/`); - - // The `fastWalk` method takes a max count for the number of items it will - // return. We should investigate why some time because I don't think it - // should be necessary. My guess is that they are creating a fixed size - // array to populate with the commits; an exponential growth algorithm - // like that used by `std::vector` would provide the same (amortized) - // performance. See http://www.nodegit.org/api/revwalk/#fastWalk. - // - // For now, I'm choosing the value 1000000 as something not big enough to - // blow out memory but more than large enough for any repo we're likely to - // encounter. - - const MAX_COMMIT_COUNT = 1000000; - - const checkRef = co.wrap(function *(name) { - - // If we've already matched the commit, no need to do any checking. - - if ([] === bestResult) { - return; // RETURN - } - - // Check to see if the name of the ref indicates that it is for - // 'remote'. - - const nameResult = regex.exec(name); - if (!nameResult) { - return; // RETURN - } - - const refHeadCommit = yield repo.getReferenceCommit(name); - const refHead = refHeadCommit.id(); - - // Use 'RevWalk' to generate the list of commits different between the - // head of the remote branch and our commit. - - let revWalk = repo.createRevWalk(); - revWalk.pushRange(`${refHead}..${commit}`); - const commitDiff = yield revWalk.fastWalk(MAX_COMMIT_COUNT); - - // If this list is shorter than the current best list (or there is no - // current best), store it as the best so far. - - if (null === bestResult || bestResult.length > commitDiff.length) { - bestResult = commitDiff; - } - }); - - const refCheckers = refs.map(checkRef); - - yield refCheckers; - - // If we found no results (no branches for 'remote', return a list - // containing 'commit' and all its history. - - if (null === bestResult) { - let revWalk = repo.createRevWalk(); - revWalk.push(commitId); - return yield revWalk.fastWalk(MAX_COMMIT_COUNT); // RETURN - } - - return bestResult; + return true; }); /** @@ -646,10 +637,20 @@ exports.setHeadHard = co.wrap(function *(repo, commit) { assert.instanceOf(repo, NodeGit.Repository); assert.instanceOf(commit, NodeGit.Commit); + const headId = yield repo.getHeadCommit(); + let oldHead; + if (headId === null) { + oldHead = "0000000000000000000000000000000000000000"; + } else { + oldHead = headId.id().tostrS(); + } + const newHead = commit.sha(); + yield NodeGit.Checkout.tree(repo, commit, { checkoutStrategy: NodeGit.Checkout.STRATEGY.FORCE, }); repo.setHeadDetached(commit); + yield Hook.execHook(repo, "post-checkout", [oldHead, newHead, "1"]); }); /** @@ -726,7 +727,7 @@ exports.parseRefspec = function(str) { /** * Resolve the specified `filename` against the specified `cwd` and return the * relative value for that resulting path to the specified `workdir`. Throw a - * `UserError` if the path lies outsied `workdir` or does not refer to a file + * `UserError` if the path lies outside `workdir` or does not refer to a file * in `workdir`. Note that if `filename` resolves to `workdir`, the result is * `""`. * @@ -735,24 +736,21 @@ exports.parseRefspec = function(str) { * @param {String} dir * @return {String} */ -exports.resolveRelativePath = co.wrap(function *(workdir, cwd, filename) { +exports.resolveRelativePath = function (workdir, cwd, filename) { assert.isString(workdir); assert.isString(cwd); assert.isString(filename); - const absPath = path.resolve(cwd, filename); - try { - yield fs.stat(absPath); - } - catch (e) { - throw new UserError(`${colors.red(filename)} does not exist.`); + if (filename === "") { + throw new UserError(`Empty path`); } + const absPath = path.resolve(cwd, filename); const relPath = path.relative(workdir, absPath); if ("" !== relPath && "." === relPath[0]) { throw new UserError(`${colors.red(filename)} is outside the workdir.`); } return relPath; -}); +}; /* * Return the editor command to use for the specified `repo`. @@ -767,7 +765,8 @@ exports.getEditorCommand = co.wrap(function *(repo) { // `git`. const result = - yield ChildProcess.exec(`git -C '${repo.path()}' var GIT_EDITOR`); + yield ChildProcess.exec(`git -C '${repo.path()}' var GIT_EDITOR`, + {maxBuffer: EXEC_BUFFER}); return result.stdout.split("\n")[0]; }); @@ -782,19 +781,39 @@ exports.getEditorCommand = co.wrap(function *(repo) { * @param {String} initialContents * @return {String} */ -exports.editMessage = co.wrap(function *(repo, initialContents) { +exports.editMessage = co.wrap(function *(repo, initialContents, + runEditor, runHooks) { const messagePath = path.join(repo.path(), "COMMIT_EDITMSG"); yield fs.writeFile(messagePath, initialContents); - const editorCommand = yield exports.getEditorCommand(repo); + if (runEditor) { + const editorCommand = yield exports.getEditorCommand(repo); - // TODO: if we ever need this to work on Windows, we'll need to do - // something else. The `ChildProcess.exec` method doesn't provide for a - // way to auto-redirect stdio or I'd use it. + // TODO: if we ever need this to work on Windows, we'll need to do + // something else. The `ChildProcess.exec` method doesn't provide for a + // way to auto-redirect stdio or I'd use it. + + try { + const args = ["-c", `${editorCommand} '${messagePath}'`]; + yield ChildProcess.spawn("/bin/sh", args, { + stdio: "inherit", + }); + } catch(e) { + throw new UserError( + `There was a problem with the editor '${editorCommand}'. +Please supply the message using the -m option.`); + } + + } + + if (runHooks && (yield Hook.hasHook(repo, "commit-msg"))) { + const isOk = yield Hook.execHook(repo, "commit-msg", [messagePath]); + + if (!isOk) { + // hooks are responsible for printing their own message + throw new UserError(""); + } + } - yield ChildProcess.spawn("/bin/sh", - ["-c", `${editorCommand} '${messagePath}'`], { - stdio: "inherit", - }); return yield fs.readFile(messagePath, "utf8"); }); @@ -826,7 +845,7 @@ exports.isBlank = function (line) { /** * Return the text contained in the specified array of `lines` after removing * all comment (i.e., those whose first non-whitespace character is a '#') and - * leading and trailing blank (i.e., those containing only whitespce) lines. + * leading and trailing blank (i.e., those containing only whitespace) lines. * * @param {String[]} lines * @return {String} @@ -895,17 +914,152 @@ exports.getParentCommit = co.wrap(function *(repo, commit) { }); /** - * Returns whether a config variable is, according to git's reckoning, - * true. That is, it's set to 'true', 'yes', or 'on'. + * Returns the URL for the specified remote. If the remote is already + * a URL, it is returned unmodified. * @async * @param {NodeGit.Repository} repo - * @param {NodeGit.Commit} configVar - * @return boolean - * @throws if the configuration variable doesn't exist + * @param {String} remoteName + * @return String + * @throws if there's no such named remote */ -exports.configIsTrue = co.wrap(function*(repo, configVar) { - const config = yield repo.config(); - const configured = yield config.getStringBuf(configVar); - return (configured === "true" || configured === "yes" || - configured === "on"); +exports.getUrlFromRemoteName = co.wrap(function *(repo, remoteName) { + if (remoteName.startsWith("http:") || remoteName.startsWith("https:") || + remoteName.includes("@")) { + return remoteName; + } else { + let remote; + try { + remote = yield repo.getRemote(remoteName); + } + catch (e) { + throw new UserError(`No remote named ${colors.red(remoteName)}.`); + } + return yield exports.getRemoteUrl(repo, remote); + } }); + +/** + * Return the merge base between the specifed `x` and `y` commits in the + * specified `repo`, or null if there is no base. + * + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} x + * @param {NodeGit.Commit} y + * @return {NodeGit.Commit|null} + */ +exports.getMergeBase = co.wrap(function *(repo, x, y) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(x, NodeGit.Commit); + assert.instanceOf(y, NodeGit.Commit); + + let baseId; + try { + baseId = yield NodeGit.Merge.base(repo, x.id(), y.id()); + } catch (e) { + // only way to detect lack of base + return null; + } + return yield repo.getCommit(baseId); +}); + +/* + * Update the HEAD of of the specified `repo` to point to the specified + * `commit`. If HEAD points to a branch, update that branch to point to + * `commit` as well, and supply the specified `reason` for the reflog. + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} commit + * @param {String} reason + */ +exports.updateHead = co.wrap(function *(repo, commit, reason) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(commit, NodeGit.Commit); + assert.isString(reason); + + if (repo.headDetached()) { + repo.setHeadDetached(commit.id()); + } else { + const headRef = yield repo.head(); + yield headRef.setTarget(commit, reason); + } +}); + +/* + * Write the specified `data` to the specified `repo` and return its hash + * value. + * + * @async + * @private + * @param {NodeGit.Repository} repo + * @param {String} data + * @return {String} + */ +exports.hashObject = co.wrap(function *(repo, data) { + const BLOB = 3; + const db = yield repo.odb(); + const res = yield db.write(data, data.length, BLOB); + return res; +}); + +/** + * You would like to use NodeGit.Merge.bases, but unfortunately, + * https://github.com/nodegit/nodegit/issues/1231 + * @async + */ +exports.mergeBases = co.wrap(function *(repo, commit1, commit2) { + const id1 = commit1.id().tostrS(); + const id2 = commit2.id().tostrS(); + const execString = `\ +git -C '${repo.path()}' merge-base ${id1} ${id2}`; + const result = yield ChildProcess.exec(execString, { + maxBuffer: EXEC_BUFFER + }); + if (result.error) { + throw new UserError("Couldn't run git merge-base: " + + result.stderr); + } + return result.stdout.split("\n").filter(x => x); +}); + +/** + * Return the `Reference` object having the specified `name` in the specified + * `repo`, or null if no such reference exists. + */ +exports.getReference = co.wrap(function *(repo, name) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isString(name); + + try { + return yield NodeGit.Reference.lookup(repo, name); + } catch (e) { + // only way to tell `name` doesn't exist. + } + return null; +}); + +/** + * Read an index file, and overwrite an in-memory index with its contents. + * + * @param {NodeGit.Index} index + * @param {String|undefined} path if set, whence to read the index + */ +exports.overwriteIndexFromFile = co.wrap(function*(index, path) { + assert.instanceOf(index, NodeGit.Index); + assert.isString(path); + + // TODO: in theory, it might be possible to just check the checksum + // on the index to avoid reloading it in the common case where nothing + // has changed. + const newIndex = yield NodeGit.Index.open(path); + yield index.removeAll(); + for (const e of newIndex.entries()) { + yield index.add(e); + } +}); + + +// This is documented as oid.isZero() but that doesn't actually work. +exports.isZero = function(oid) { + return oid.tostrS() === "0000000000000000000000000000000000000000"; +}; diff --git a/node/lib/util/git_util_fast.js b/node/lib/util/git_util_fast.js new file mode 100644 index 000000000..43c07f981 --- /dev/null +++ b/node/lib/util/git_util_fast.js @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2018, 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"; + +/** + * This module contains common git utility methods that do not require NodeGit + * to accomplish. This is an optimization since loading NodeGit results in + * unsatisfactory performance in the CLI. + */ + +const fs = require("fs-promise"); +const path = require("path"); +// DO NOT REQUIRE NODEGIT + +/** + * If the directory identified by the specified `dir` contains a ".git" + * directory, return it. Otherwise, return the first parent directory of `dir` + * containing a `.git` directory. If no such directory exists, return `None`. + * + * @private + * @param {String} dir + * @return {String} + */ +function getContainingGitDir(dir) { + const gitPath = path.join(dir, ".git"); + if (fs.existsSync(gitPath)) { + if (fs.statSync(gitPath).isDirectory()) { + return dir; // RETURN + } + + // If `.git` is a file, it is a git link. If the link is to a submodule + // it will be relative. If it's not relative, and therefore not a + // submodule, we stop with this directory. + + const content = fs.readFileSync(gitPath, "utf8"); + const parts = content.split(" "); + if (1 < parts.length && parts[1].startsWith("/")) { + return dir; + } + } + + const base = path.dirname(dir); + + if ("" === base || "/" === base) { + return null; // RETURN + } + + return getContainingGitDir(base); +} + +/** + * Return the root of the repository in which the current working directory + * resides, or null if the working directory contains no git repository. + * + * @return {String|null} + */ +exports.getRootGitDirectory = function () { + return getContainingGitDir(process.cwd()); +}; \ No newline at end of file diff --git a/node/lib/util/hook.js b/node/lib/util/hook.js new file mode 100644 index 000000000..d3e5a71b5 --- /dev/null +++ b/node/lib/util/hook.js @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2018, 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 spawn = require("child-process-promise").spawn; +const co = require("co"); +const path = require("path"); +const process = require("process"); +const fs = require("fs"); +const ConfigUtil = require("./config_util"); + + +/** + * Check if git-meta hook with given hook name exists + * Return hooks path. + * @async + * @return {String} + */ +const getHooksPath = co.wrap(function*(repo) { + const rootDirectory = repo.path(); + const config = yield repo.config(); + const worktreeConfig = yield ConfigUtil.getConfigString( + config, + `core.worktree` + ); + const hooksPathConfig = yield ConfigUtil.getConfigString( + config, + `core.hooksPath` + ); + return path.join( + rootDirectory, + worktreeConfig && hooksPathConfig ? + `${worktreeConfig}/${hooksPathConfig}` : "hooks" + ); +}); + + +/** + * Check if git-meta hook with given hook name exists + * Return true if hook exists. + * @async + * @param {String} name + * @return {Boolean} + */ +exports.hasHook = co.wrap(function*(repo, name) { + assert.isString(name); + const hookPath = yield getHooksPath(repo); + const absPath = path.resolve(hookPath, name); + + return fs.existsSync(absPath); +}); + +/** + * Run git-meta hook with given hook name. + * Return true on success or hook does not exist, false otherwise. + * @async + * @param {String} name + * @param {String[]} args + * @return {Boolean} + */ +exports.execHook = co.wrap(function*(repo, name, args=[], env={}) { + assert.isString(name); + + const hookPath = yield getHooksPath(repo); + const absPath = path.resolve(hookPath, name); + + if (!fs.existsSync(absPath)) { + return true; + } + + try { + const subEnv = {}; + Object.assign(subEnv, process.env); + Object.assign(subEnv, env); + yield spawn(absPath, args, { stdio: "inherit", env: subEnv, + cwd: repo.workdir()}); + return true; + } catch (e) { + if (e.code === "EACCES") { + console.log("EACCES: Cannot execute: " + absPath); + } else if (e.stdout) { + console.log(e.stdout); + } + return false; + } +}); diff --git a/node/lib/util/log_util.js b/node/lib/util/log_util.js index 2ab6185de..b27123730 100644 --- a/node/lib/util/log_util.js +++ b/node/lib/util/log_util.js @@ -81,6 +81,7 @@ exports.findMetaCommit = co.wrap(function *(repo, let toCheck = [metaCommit]; // commits left to check const checked = new Set(); // SHAs checked const existsInSha = new Map(); // repo sha to bool if target included + const isDescended = new Map(); // cache of descendant check const doesExistInCommit = co.wrap(function *(commit) { const sha = commit.id().tostrS(); @@ -108,15 +109,25 @@ exports.findMetaCommit = co.wrap(function *(repo, result = true; } else { - // Ensure that the commit we're checking against is present. - - yield subFetcher.fetchSha(subRepo, - submoduleName, - subShaForCommit); - result = (yield NodeGit.Graph.descendantOf( + // Check to see if the commit we're looking for is descended + // from the current commit. First, look in the cache. + + if (isDescended.has(subShaForCommit)) { + result = isDescended.get(subShaForCommit); + } + else { + // Ensure that the commit we're checking against is + // present; we can't do a descendant check otherwise. + + yield subFetcher.fetchSha(subRepo, + submoduleName, + subShaForCommit); + result = (yield NodeGit.Graph.descendantOf( subRepo, NodeGit.Oid.fromString(subShaForCommit), subCommit.id())) !== 0; + isDescended.set(subShaForCommit, result); + } } } existsInSha.set(sha, result); diff --git a/node/lib/util/merge.js b/node/lib/util/merge.js deleted file mode 100644 index a7f7fb560..000000000 --- a/node/lib/util/merge.js +++ /dev/null @@ -1,362 +0,0 @@ -/* - * Copyright (c) 2016, 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 GitUtil = require("./git_util"); -const Open = require("./open"); -const RepoStatus = require("./repo_status"); -const SubmoduleUtil = require("./submodule_util"); -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; - -/** - * Merge the specified `commit` in the specified `metaRepo` having the - * specified `metaRepoStatus`, using the specified `mode` to control whether or - * not a merge commit will be generated. The specified `commitMessage` will be - * recorded as the message for merge commits. Throw a `UserError` exception if - * a fast-forward merge is requested and cannot be completed. - * - * Note that this method will open closed submodules having changes recorded in - * `commit` compared to HEAD. - * - * @async - * @param {NodeGit.Repository} metaRepo - * @param {RepoStatus} metaRepoStatus - * @param {NodeGit.Commit} commit - * @param {MODE} mode - * @param {String} commitMessage - * @return {Object|null} - * @return {String} return.metaCommit - * @return {Object} return.submoduleCommits map from submodule to commit - */ -exports.merge = co.wrap(function *(metaRepo, - metaRepoStatus, - commit, - mode, - commitMessage) { - assert.instanceOf(metaRepo, NodeGit.Repository); - assert.instanceOf(metaRepoStatus, RepoStatus); - assert.isNumber(mode); - assert.instanceOf(commit, NodeGit.Commit); - assert.isString(commitMessage); - - // TODO: See how we do with a variety of edge cases, e.g.: submodules added - // and removed. - // TODO: Deal with conflicts. - - // Basic algorithm: - // - start merge on meta-repo - // - detect changes in sub-repos - // - merge changes in sub-repos - // - if any conflicts in sub-repos, bail - // - finalize commit in meta-repo - // - // The actual problem is complicated by a couple of things: - // - // - oddities with and/or poor support of submodules - // - unlike rebase and cherry-pick, which seem similar on the surface, the - // merge operation doesn't operate directly on the current HEAD, index, - // or working directory: it creates a weird virtual index - // - // I haven't created issues for nodegit or libgit2 yet as I'm not sure how - // many of these problems are real problems or "by design". If this - // project moves out of the prototype phase, we should resolve these - // issues as much of the code below feels like a hackish workaround. - // - // details to follow: - - // If the target commit is an ancestor of the derived commit, then we have - // nothing to do; the target commit is already part of the current history. - - const commitSha = commit.id().tostrS(); - - if (yield GitUtil.isUpToDate(metaRepo, - metaRepoStatus.headCommit, - commitSha)) { - return null; - } - - let canFF = yield NodeGit.Graph.descendantOf(metaRepo, - commitSha, - metaRepoStatus.headCommit); - - if (MODE.FF_ONLY === mode && !canFF) { - throw new UserError(`The meta-repositor cannot be fast-forwarded to \ -${colors.red(commitSha)}.`); - } - - const sig = metaRepo.defaultSignature(); - - // Kick off the merge. It is important to note is that `Merge.commit` does - // not directly modify the working directory or index. The `metaIndex` - // object it returns is magical, virtual, does not operate on HEAD or - // anything, has no effect. - - const head = yield metaRepo.getCommit(metaRepoStatus.headCommit); - - const metaIndex = yield SubmoduleUtil.cacheSubmodules(metaRepo, () => { - return NodeGit.Merge.commits(metaRepo, - head, - commit, - null); - }); - - let errorMessage = ""; - - // `toAdd` will contain a list of paths that need to be added to the final - // index when it's ready. Adding them to the "virtual", `metaIndex` object - // turns out to have no effect. This complication is caused by a a - // combination of merge/index weirdness and submodule weirdness. - - const toAdd = []; - - const subCommits = {}; // Record of merge commits in submodules. - - const subs = metaRepoStatus.submodules; - - const opener = new Open.Opener(metaRepo, null); - const subFetcher = yield opener.fetcher(); - - const mergeEntry = co.wrap(function *(entry) { - const path = entry.path; - const stage = RepoStatus.getStage(entry.flags); - - // If the entry is not on the "other" side of the merge move on. - - if (RepoStatus.STAGE.THEIRS !== stage && - RepoStatus.STAGE.NORMAL !== stage) { - return; // RETURN - } - - // If it's not a submodule move on. - - if (!(path in subs)) { - return; // RETURN - } - - // Otherwise, we have a submodule that needs to be merged. - - const subSha = entry.id.tostrS(); - const subCommitId = NodeGit.Oid.fromString(subSha); - const sub = subs[path]; - const subHeadSha = sub.commit.sha; - const subCommitSha = subCommitId.tostrS(); - - // Exit early without opening if we have the same commit as the one - // we're supposed to merge to. - - if (subCommitSha === subHeadSha) { - return; // RETURN - } - - let subRepo; - if (null === sub.workdir) { - // If this submodule's not open, open it. - - console.log(`Opening ${colors.blue(path)}.`); - subRepo = yield opener.getSubrepo(path); - } - else { - subRepo = yield opener.getSubrepo(path); - } - - // Fetch commit to merge. - - yield subFetcher.fetchSha(subRepo, path, subSha); - - const subCommit = yield subRepo.getCommit(subCommitId); - - // If this submodule is up-to-date with the merge commit, exit. - - if (yield GitUtil.isUpToDate(subRepo, subHeadSha, subCommitSha)) { - console.log(`Submodule ${colors.blue(path)} is up-to-date with \ -commit ${colors.green(subCommitSha)}.`); - return; // RETURN - } - - // If we can fast-forward, we don't need to do a merge. - - const canSubFF = yield NodeGit.Graph.descendantOf(subRepo, - subCommitSha, - subHeadSha); - if (canSubFF && MODE.FORCE_COMMIT !== mode) { - console.log(`Submodule ${colors.blue(path)}: fast-forward to -${colors.green(subCommitSha)}.`); - yield NodeGit.Reset.reset(subRepo, - subCommit, - NodeGit.Reset.TYPE.HARD); - - // We still need to add this submodule's name to the list to add so - // that it will be recorded to the index if the meta-repo ends up - // generating a commit. - - toAdd.push(path); - return; // RETURN - } - else if (MODE.FF_ONLY === mode) { - // If non-ff merge is disallowed, bail. - errorMessage += `Submodule ${colors.red(path)} could not be \ -fast-forwarded.\n`; - return; // RETURN - } - - // We're going to generate a commit. Note that the meta-repo cannot be - // fast-forwarded. - - canFF = false; - - console.log(`Submodule ${colors.blue(path)}: merging commit \ -${colors.green(subCommitSha)}.\n`); - - // Start the merge. - - const subHead = yield subRepo.getCommit(subHeadSha); - let index = yield NodeGit.Merge.commits(subRepo, - subHead, - subCommit, - null); - - // Abort if conflicted. - - if (index.hasConflicts()) { - errorMessage += `Submodule ${colors.red(path)} is conflicted.\n`; - return; // RETURN - } - - // Otherwise, finish off the merge. - - yield index.writeTreeTo(subRepo); - yield NodeGit.Checkout.index(subRepo, index, { - checkoutStrategy: NodeGit.Checkout.STRATEGY.FORCE, - }); - index = yield subRepo.index(); - const treeId = yield index.writeTreeTo(subRepo); - const mergeCommit = yield subRepo.createCommit("HEAD", - sig, - sig, - commitMessage, - treeId, - [subHead, subCommit]); - subCommits[path] = mergeCommit.tostrS(); - - // And add this sub-repo to the list of sub-repos that need to be added - // to the index later. - - toAdd.push(path); - }); - - // Createa a submodule merger for each submodule in the index. - - const entries = metaIndex.entries(); - yield entries.map(mergeEntry); - - // If one of the submodules could not be merged, exit. - - if ("" !== errorMessage) { - throw new UserError(errorMessage); - } - - // If we've made it through the submodules and can still fast-forward, just - // reset the head to the right commit and return. - - if (canFF && MODE.FORCE_COMMIT !== mode) { - console.log( - `Fast-forwarding meta-repo to ${colors.green(commitSha)}.`); - yield NodeGit.Reset.reset(metaRepo, commit, NodeGit.Reset.TYPE.HARD); - return { - metaCommit: commitSha, - submoduleCommits: subCommits, - }; - } - - console.log(`Merging meta-repo commit ${colors.green(commitSha)}.`); - - // This bit gets a little nasty. First, we need to put `metaIndex` into a - // proper state and write it out. - - yield metaIndex.conflictCleanup(); - yield metaIndex.writeTreeTo(metaRepo); - - // Having committed the index with changes, we need to check it out so that - // it's applied to the current index and working directory. Only there - // will we be able to properly reflect the changes to the submodules. We - // need to get to a point where we have a "real" index to work with. - - const checkoutOpts = { - checkoutStrategy: NodeGit.Checkout.STRATEGY.FORCE - }; - yield NodeGit.Checkout.index(metaRepo, metaIndex, checkoutOpts); - - // Now that the changes are applied to the current working directory and - // index, we can open the current index and work with it. - - const newIndex = yield metaRepo.index(); - - // We've made changes to (merges into) some of the submodules; now we can - // finally stage them into the index. - - yield toAdd.map(subName => newIndex.addByPath(subName)); - - // And write that index out. - - yield newIndex.write(); - const id = yield newIndex.writeTreeTo(metaRepo); - - // And finally, commit it. - - const metaCommit = yield metaRepo.createCommit("HEAD", - sig, - sig, - commitMessage, - id, - [head, commit]); - - return { - metaCommit: metaCommit.tostrS(), - submoduleCommits: subCommits, - }; -}); diff --git a/node/lib/util/merge_common.js b/node/lib/util/merge_common.js new file mode 100755 index 000000000..26c005b38 --- /dev/null +++ b/node/lib/util/merge_common.js @@ -0,0 +1,371 @@ +/* + * 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]} doNotRecurse + * @param {String|null} commitMessage + * @param {() -> Promise(String)} editMessage + */ + constructor(metaRepo, + ourCommit, + theirCommit, + mode, + openOption, + doNotRecurse, + commitMessage, + editMessage, + authorName, + authorEmail, + committerName, + committerEmail) { + 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_doNotRecurse = doNotRecurse; + this.d_commitMessage = commitMessage; + this.d_editMessage = editMessage; + this.d_opener = new Open.Opener(metaRepo, ourCommit); + this.d_changeIndex = null; + this.d_changes = null; + this.d_conflictsMessage = ""; + this.d_authorName = authorName; + this.d_authorEmail = authorEmail; + this.d_committerName = committerName; + this.d_committerEmail = committerEmail; + } + + /** + * @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 {[String]} + */ + get doNotRecurse() { + return this.d_doNotRecurse; + } + + /** + * @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, + this.d_doNotRecurse); + } + 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 {NodeGit.Signature} author to be set with merge commit + */ +MergeContext.prototype.getAuthor = co.wrap(function *() { + if (this.d_authorName && this.d_authorEmail) { + return NodeGit.Signature.now( + this.d_authorName, + this.d_authorEmail); + } + return yield ConfigUtil.defaultSignature(this.d_metaRepo); +}); + +/** + * @async + * @returns {NodeGit.Signature} committer to be set with merge commit + */ +MergeContext.prototype.getCommitter = co.wrap(function *() { + if (this.d_committerName && this.d_committerEmail) { + return NodeGit.Signature.now( + this.d_committerName, + this.d_committerEmail); + } + 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 new file mode 100644 index 000000000..27775a1ad --- /dev/null +++ b/node/lib/util/merge_util.js @@ -0,0 +1,865 @@ +/* + * Copyright (c) 2017, 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 ChildProcess = require("child-process-promise"); +const co = require("co"); +const colors = require("colors"); +const NodeGit = require("nodegit"); + +const Checkout = require("./checkout"); +const CherryPickUtil = require("./cherry_pick_util"); +const ConfigUtil = require("./config_util"); +const ConflictUtil = require("./conflict_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 SubmoduleConfigUtil = require("./submodule_config_util"); +const UserError = require("./user_error"); + +const CommitAndRef = SequencerState.CommitAndRef; +const Conflict = ConflictUtil.Conflict; +const ConflictEntry = ConflictUtil.ConflictEntry; +const FILEMODE = NodeGit.TreeEntry.FILEMODE; +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, + * otherwise, return null. + * + * @param {String} path + * @return {String|null} + */ +const getSequencerIfMerge = co.wrap(function *(path) { + const seq = yield SequencerStateUtil.readSequencerState(path); + if (null !== seq && MERGE === seq.type) { + return seq; + } + return null; +}); + +/** + * If there is a sequencer with a merge in the specified `path` return it, + * otherwise, throw a `UserError` indicating that there is no merge. + * + * @param {String} path + * @return {String} + */ +const checkForMerge = co.wrap(function *(path) { + const seq = yield getSequencerIfMerge(path); + if (null === seq) { + throw new UserError("No merge in progress."); + } + return seq; +}); + +/** + * 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 getBareMergeConflictsMessage = 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) { + const conflict = conflicts[name]; + if (Array.isArray(conflict)) { + for (const path of conflict) { + errorMessage += `\tconflicted: ${name}/${path}\n`; + } + } else { + errorMessage += `Merge conflict in submodule '${name}' itself +(e.g. delete/modify or add/modify)\n`; + } + } + errorMessage += "\nAutomatic merge failed\n"; + return errorMessage; +}; + + +/** + * Perform a fast-forward merge in the specified `repo` to the + * specified `commit`. The behavior is undefined unless `commit` + * is different from but descendant of the HEAD commit in `repo`. + * + * @param {NodeGit.Repository} repo + * @param {MergeCommon.MODE} mode + * @param {NodeGit.Commit} commit + */ +exports.fastForwardMerge = co.wrap(function *(repo, commit) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(commit, NodeGit.Commit); + + // Remember the current branch; the checkoutCommit function will move it. + + const branch = yield repo.getCurrentBranch(); + + yield Checkout.checkoutCommit(repo, commit, false); + + // If we were on a branch, make it current again. + + if (branch.isBranch()) { + yield branch.setTarget(commit, "ffwd merge"); + yield repo.setHead(branch.name()); + } +}); + +/** + * 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 + * @param {NodeGit.Signature} author + * @param {NodeGit.Signature} committer + * @return {Object} + * @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. + */ +exports.makeMetaCommit = co.wrap(function *(repo, + indexToWrite, + ourCommit, + theirCommit, + commitMessage, + refToUpdate, + author, + committer) { + const id = yield indexToWrite.writeTreeTo(repo); + const metaCommit = yield repo.createCommit(refToUpdate, + author, + committer, + commitMessage, + id, + [ourCommit, theirCommit]); + const commitSha = metaCommit.tostrS(); + return { + metaCommit: commitSha, + infoMessage: `Merge commit created at ` + + `${colors.green(commitSha)}.`, + }; +}); + +/** + * Merge the specified `subName` and update the in memory `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} author author signature + * @param {NodeGit.Signature} author committer signature + * @param {Open.Opener} opener helper to open a sub + * @param {SUB_OPEN_OPTION} openOption option to open a sub + * @return {Object} + * @return {String|null} return.mergeSha + * @return {String|null} return.conflictSha + * @return {String []} return.conflictPaths + */ +exports.mergeSubmodule = co.wrap(function *(metaIndex, + subName, + change, + message, + opener, + fetcher, + author, + committer, + 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(author, NodeGit.Signature); + assert.instanceOf(committer, 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; + try { + yield fetcher.fetchSha(subRepo, subName, theirSha); + if (null !== change.ourSha) { + yield fetcher.fetchSha(subRepo, subName, change.ourSha); + } + } catch (e) { + console.log( + `Unable to fetch changes in submodule '${subName}', ` + + "abort merging." + ); + throw e; + } + const theirCommit = yield subRepo.getCommit(theirSha); + + const ourSha = change.ourSha; + const ourCommit = yield subRepo.getCommit(ourSha); + + const result = { + mergeSha: null, + conflictSha: null, + conflictPaths: [], + }; + + // See if up-to-date + if (yield NodeGit.Graph.descendantOf(subRepo, ourSha, theirSha)) { + yield CherryPickUtil.addSubmoduleCommit(metaIndex, subName, ourSha); + 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.error(`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, + }); + } + + // 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()) { + result.conflictPaths = + Object.keys(StatusUtil.readConflicts(subIndex, [])); + 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, + }); + } + 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. + if (!isHalfOpened) { + subIndex = yield subRepo.index(); + } + + const refToUpdate = isHalfOpened ? null : "HEAD"; + const treeId = yield subIndex.writeTreeTo(subRepo); + const mergeCommit = yield subRepo.createCommit(refToUpdate, + author, + committer, + 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 metaIndex.conflictRemove(subName); + } + return result; +}); + +/** + * Perform preparation work before merge, including + * 1. locate merge base + * 2. 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 {MergeContext} context + * @return {MergeStepResult} + */ +const mergeStepPrepare = co.wrap(function *(context) { + assert.instanceOf(context, MergeContext); + + 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) { + errorMessage = "No commits in common with" + + `${colors.red(GitUtil.shortSha(ourCommitSha))} and ` + + `${colors.red(GitUtil.shortSha(theirCommitSha))}`; + return MergeStepResult.error(errorMessage); // RETURN + } + + 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 + } + } + + if (ourCommitSha === theirCommitSha) { + infoMessage = "Nothing to do."; + return MergeStepResult.justMeta(infoMessage, theirCommit); // RETURN + } + + const upToDate = yield NodeGit.Graph.descendantOf(metaRepo, + ourCommitSha, + theirCommitSha); + + if (upToDate) { + return MergeStepResult.justMeta(infoMessage, ourCommitSha); // RETURN + } + return MergeStepResult.empty(); +}); + +/** + * 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(); +}); + +/** + * @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 author = yield context.getAuthor(); + const committer = yield context.getCommitter(); + const theirCommit = context.theirCommit; + const theirCommitSha = theirCommit.id().tostrS(); + const doNotRecurse = context.doNotRecurse; + + let conflictMessage = ""; + // abort merge if conflicted under FORCE_BARE mode + if (forceBare && Object.keys(changes.conflicts).length > 0) { + conflictMessage = getBareMergeConflictsMessage(changes.conflicts); + return MergeStepResult.error(conflictMessage); // RETURN + } + + // deal with simple changes + if (forceBare) { + // for merge-bare, no need to open or delete submodules, directly + // writes the post merge urls to .gitmodules file. + yield SubmoduleConfigUtil.writeUrls(repo, index, changes.urls, true); + } else { + yield CherryPickUtil.changeSubmodules(repo, + opener, + index, + changes.simpleChanges, + changes.urls); + } + + const message = yield context.getCommitMessage(); + if ("" === message) { + return MergeStepResult.empty(); + } + + const merges = { + conflicts: {}, + conflictPaths: {}, + commits: {}, + }; + const mergeSubmoduleRunner = co.wrap(function *(subName) { + for (const prefix of doNotRecurse) { + if (subName.startsWith(prefix + "/") || subName === prefix) { + const change = changes.changes[subName]; + const sha = change.newSha; + merges.conflicts[subName] = sha; + merges.conflictPaths[subName] = [""]; + const old = new ConflictEntry(FILEMODE.COMMIT, change.oldSha); + const our = new ConflictEntry(FILEMODE.COMMIT, change.ourSha); + const new_ = new ConflictEntry(FILEMODE.COMMIT, change.newSha); + changes.conflicts[subName] = new Conflict(old, our, new_); + return; + } + } + const subResult = + yield exports.mergeSubmodule(index, + subName, + changes.changes[subName], + message, + opener, + fetcher, + author, + committer, + openOption); + if (null !== subResult.mergeSha) { + merges.commits[subName] = subResult.mergeSha; + } + if (null !== subResult.conflictSha) { + merges.conflicts[subName] = subResult.conflictSha; + merges.conflictPaths[subName] = subResult.conflictPaths; + } + }); + yield DoWorkQueue.doInParallel(Object.keys(changes.changes), + mergeSubmoduleRunner); + // Render any conflicts + if (forceBare) { + conflictMessage = getBareMergeConflictsMessage(merges.conflictPaths); + } else { + conflictMessage = + yield CherryPickUtil.writeConflicts(repo, + index, + changes.conflicts); + /// + Object.keys(merges.conflicts).sort().forEach(name => { + conflictMessage += + SubmoduleRebaseUtil.subConflictErrorMessage(name); + }); + } + + // finishing merge for interactive merges + // 1. close unnecessarily 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.setSparseBitsAndWriteIndex(repo, index); + } + + 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); + } + + let infoMessage = `Merging meta-repo commits ` + + `${colors.green(ourCommitSha)} and ` + + `${colors.green(theirCommitSha)}`; + const metaCommitRet = yield exports.makeMetaCommit(repo, + index, + ourCommit, + theirCommit, + message, + refToUpdate, + author, + committer); + infoMessage += "\n" + metaCommitRet.infoMessage; + return new MergeStepResult(infoMessage, + null, + metaCommitRet.metaCommit, + merges.commits); +}); + +/** + * 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, + doNotRecurse, + commitMessage, + editMessage) { + // pack and validate merging objects + const context = new MergeContext(repo, + ourCommit, + theirCommit, + mode, + openOption, + doNotRecurse, + commitMessage, + editMessage, + process.env.GIT_AUTHOR_NAME, + process.env.GIT_AUTHOR_EMAIL, + process.env.GIT_COMMITTER_NAME, + process.env.GIT_COMMITTER_EMAIL); + // + 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.error(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; +}); + +/** + * Throw a `UserError` if the specified `index` has non-submodule conflicts and + * do nothing otherwise. + * + * @param {NodeGit.Index} index + */ +const checkForConflicts = function (index) { + assert.instanceOf(index, NodeGit.Index); + const entries = index.entries(); + for (let i = 0; i < entries.length; ++i) { + const entry = entries[i]; + const stage = NodeGit.Index.entryStage(entry); + if (RepoStatus.STAGE.OURS === stage && + FILEMODE.COMMIT !== entry.mode) { + throw new UserError("Meta-repo has conflicts."); + } + } +}; + +/** + * Continue the merge in the specified `repo`. Throw a `UserError` if there is + * no merge in progress in `repo` or if `repo` still has outstanding conflicts. + * Return an object describing generated commits. + * + * @param {NodeGit.Repository} repo + * @return {Object|null} + * @return {String} return.metaCommit + * @return {Object} return.submoduleCommits map from submodule to commit + */ +exports.continue = co.wrap(function *(repo) { + assert.instanceOf(repo, NodeGit.Repository); + const result = { + metaCommit: null, + submoduleCommits: {}, + }; + const seq = yield checkForMerge(repo.path()); + const index = yield repo.index(); + + checkForConflicts(index); + + // We have to do this because there may have been outsanding submodule + // conflicts. We validated in `checkForConflicts` that there are no "real" + // conflicts. + + console.log(`Continuing with merge of ${colors.green(seq.target.sha)}.`); + + let errorMessage = ""; + + const continueSub = co.wrap(function *(subPath) { + const subRepo = yield SubmoduleUtil.getRepo(repo, subPath); + const subIndex = yield subRepo.index(); + if (subIndex.hasConflicts()) { + errorMessage += + `Submodule ${colors.red(subPath)} has conflicts.\n`; + return; // RETURN + } + const sig = yield ConfigUtil.defaultSignature(subRepo); + const subSeq = yield getSequencerIfMerge(subRepo.path()); + if (null === subSeq) { + // There is no merge in this submodule, but if there are staged + // changes we need to make a commit. + + const status = yield StatusUtil.getRepoStatus(subRepo, { + showMetaChanges: true, + }); + if (!status.isIndexClean()) { + const id = yield subRepo.createCommitOnHead([], + sig, + sig, + seq.message); + result.submoduleCommits[subPath] = id.tostrS(); + } + } + else { + // Now, we have a submodule that was in the middle of merging. + // Continue it and then clean up the merge. + + const head = yield subRepo.getHeadCommit(); + const mergeHead = yield subRepo.getCommit(subSeq.target.sha); + const treeId = yield subIndex.writeTreeTo(subRepo); + const id = yield subRepo.createCommit("HEAD", + sig, + sig, + subSeq.message, + treeId, + [head, mergeHead]); + yield SequencerStateUtil.cleanSequencerState(subRepo.path()); + result.submoduleCommits[subPath] = id.tostrS(); + } + yield index.addByPath(subPath); + yield index.conflictRemove(subPath); + }); + const openSubs = yield SubmoduleUtil.listOpenSubmodules(repo); + yield DoWorkQueue.doInParallel(openSubs, + continueSub, + {failMsg: "Merge in submodule failed."}); + yield SparseCheckoutUtil.setSparseBitsAndWriteIndex(repo, index); + + if ("" !== errorMessage) { + throw new UserError(errorMessage); + } + const treeId = yield index.writeTreeTo(repo); + + const sig = yield ConfigUtil.defaultSignature(repo); + const head = yield repo.getHeadCommit(); + const mergeHead = yield repo.getCommit(seq.target.sha); + const metaCommit = yield repo.createCommit("HEAD", + sig, + sig, + seq.message, + treeId, + [head, mergeHead]); + console.log( + `Finished with merge commit ${colors.green(metaCommit.tostrS())}`); + yield SequencerStateUtil.cleanSequencerState(repo.path()); + result.metaCommit = metaCommit.tostrS(); + return result; +}); + +const resetMerge = co.wrap(function *(repo) { + // TODO: add this to libgit2 + const execString = `git -C '${repo.workdir()}' reset --merge`; + yield ChildProcess.exec(execString, { + maxBuffer: 1024*1024*100 + }); +}); + +/** + * Abort the merge in progress in the specified `repo`, or throw a `UserError` + * if no merge is in progress. + * + * @param {NodeGit.Repository} repo + */ +exports.abort = co.wrap(function *(repo) { + assert.instanceOf(repo, NodeGit.Repository); + + yield checkForMerge(repo.path()); + + const head = yield repo.getHeadCommit(repo); + const openSubs = yield SubmoduleUtil.listOpenSubmodules(repo); + const shas = yield SubmoduleUtil.getSubmoduleShasForCommit(repo, + openSubs, + head); + const index = yield repo.index(); + const abortSub = co.wrap(function *(subName) { + // Our goal here is to do a 'git reset --merge'. Ideally, we'd do a + // soft reset first to put the submodule on the right sha, but you + // can't do a soft reset "in the middle of a merge", so we do an + // initial 'git reset --merge' once, then if we're not on the right sha + // we can do the soft reset -- the 'git reset --merge' cleans up any + // merge conflicts -- then do one final 'git reset --merge'. + + const subRepo = yield SubmoduleUtil.getRepo(repo, subName); + yield resetMerge(subRepo); + const subHead = yield subRepo.getHeadCommit(); + if (subHead === null) { + throw new UserError( + `HEAD not found in submodule ${subName}. ` + + "It is likely broken, please try to recover it first." + + "Hint: try to close and then reopen it." + ); + } + if (subHead.id().tostrS() !== shas[subName]) { + const commit = yield subRepo.getCommit(shas[subName]); + yield NodeGit.Reset.reset(subRepo, + commit, + NodeGit.Reset.TYPE.SOFT); + yield resetMerge(subRepo); + } + yield SequencerStateUtil.cleanSequencerState(subRepo.path()); + yield index.addByPath(subName); + }); + yield DoWorkQueue.doInParallel(openSubs, abortSub); + yield index.conflictCleanup(); + yield SparseCheckoutUtil.setSparseBitsAndWriteIndex(repo, index); + yield resetMerge(repo); + yield SequencerStateUtil.cleanSequencerState(repo.path()); +}); diff --git a/node/lib/util/open.js b/node/lib/util/open.js index 0d21492b6..6e2d3663d 100644 --- a/node/lib/util/open.js +++ b/node/lib/util/open.js @@ -35,14 +35,58 @@ const assert = require("chai").assert; const co = require("co"); const colors = require("colors"); +const fs = require("fs-promise"); +const path = require("path"); const NodeGit = require("nodegit"); const GitUtil = require("./git_util"); -const DeinitUtil = require("./deinit_util"); +const Hook = require("../util/hook"); +const SparseCheckoutUtil = require("./sparse_checkout_util"); 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; + +/** + * @class {Opener} + * class for opening and retrieving submodule repositories on-demand + */ +class Opener { + /** + * Create a new object for retreiving submodule repositories on-demand in + * the specified `repo`. + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} commit + */ + constructor(repo, commit) { + assert.instanceOf(repo, NodeGit.Repository); + if (null !== commit) { + assert.instanceOf(commit, NodeGit.Commit); + } + this.d_repo = repo; + this.d_commit = commit; + this.d_initialized = false; + } + + /** + * @property {NodeGit.Repository} the repo associated with this object + */ + get repo() { + return this.d_repo; + } +} + /** * Open the submodule having the specified `submoduleName` in the meta-repo * associated with the specified `fetcher`; fetch the specified `submoduleSha` @@ -50,27 +94,39 @@ const SubmoduleFetcher = require("./submodule_fetcher"); * to the `url` configured in the meta-repo. If the specified `templatePath` * is provided, use it to configure the newly-opened submodule's repository. * + * Note that after opening one or more submodules, + * `SparseCheckoutUtil.setSparseBitsAndWriteIndex` must be called so that + * `SKIP_WORKTREE` is *unset*; since this operation is expensive, we cannot do + * it automatically each time a submodule is opened. + * * @async * @param {SubmoduleFetcher} fetcher * @param {String} submoduleName * @param {String} submoduleSha * @param {String|null} templatePath + * @param {boolean} bare * @return {NodeGit.Repository} */ exports.openOnCommit = co.wrap(function *(fetcher, submoduleName, submoduleSha, - templatePath) { + templatePath, + bare) { assert.instanceOf(fetcher, SubmoduleFetcher); assert.isString(submoduleName); assert.isString(submoduleSha); + assert.isBoolean(bare); if (null !== templatePath) { assert.isString(templatePath); } + const metaRepoUrl = yield fetcher.getMetaOriginUrl(); const metaRepo = fetcher.repo; const submoduleUrl = yield fetcher.getSubmoduleUrl(submoduleName); + + const wasOpen = new Opener(metaRepo, null).isOpen(submoduleName); + // Set up the submodule. const submoduleRepo = yield SubmoduleConfigUtil.initSubmoduleAndRepo( @@ -78,8 +134,16 @@ exports.openOnCommit = co.wrap(function *(fetcher, metaRepo, submoduleName, submoduleUrl, - templatePath); + templatePath, + bare); + // Turn off GC for the submodule + const config = yield submoduleRepo.config(); + config.setInt64("gc.auto", 0); + + if (bare) { + return submoduleRepo; // RETURN + } // Fetch the needed sha. Close if the fetch fails; otherwise, the // repository ends up in a state where it things the submodule is open, but // it's actually not. @@ -88,7 +152,7 @@ exports.openOnCommit = co.wrap(function *(fetcher, yield fetcher.fetchSha(submoduleRepo, submoduleName, submoduleSha); } catch (e) { - yield DeinitUtil.deinit(metaRepo, submoduleName); + yield SubmoduleConfigUtil.deinit(metaRepo, [submoduleName]); throw e; } @@ -97,48 +161,44 @@ exports.openOnCommit = co.wrap(function *(fetcher, const commit = yield submoduleRepo.getCommit(submoduleSha); yield GitUtil.setHeadHard(submoduleRepo, commit); - return submoduleRepo; -}); + // If we're in sparse mode, we need to add a submodule to the + // `.git/info/sparse-checkout` file so that it's "visible". -/** - * @class {Opener} - * class for opening and retrieving submodule repositories on-demand - */ -class Opener { - /** - * Create a new object for retreiving submodule repositories on-demand in - * the specified `repo`. - * - * @param {NodeGit.Repository} repo - * @param {NodeGit.Commit} commit - */ - constructor(repo, commit) { - assert.instanceOf(repo, NodeGit.Repository); - if (null !== commit) { - assert.instanceOf(commit, NodeGit.Commit); - } - this.d_repo = repo; - this.d_commit = commit; - this.d_initialized = false; + if (yield SparseCheckoutUtil.inSparseMode(metaRepo)) { + yield SparseCheckoutUtil.addToSparseCheckoutFile(metaRepo, + submoduleName); } - /** - * @property {NodeGit.Repository} the repo associated with this object - */ - get repo() { - return this.d_repo; + if (!wasOpen) { + // Run post-open-submodule hook with successfully-opened submodules + yield Hook.execHook(metaRepo, "post-open-submodule", [submoduleName]); } -} + + return submoduleRepo; +}); Opener.prototype._initialize = co.wrap(function *() { if (null === this.d_commit) { this.d_commit = yield this.d_repo.getHeadCommit(); } - this.d_subRepos = {}; - const openSubsList = yield SubmoduleUtil.listOpenSubmodules(this.d_repo); - this.d_openSubs = new Set(openSubsList); + + // 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); + this.d_openSubs = new Set(openSubsList); + } + const absorbedSubsList + = yield SubmoduleUtil.listAbsorbedSubmodules(this.d_repo); + this.d_absorbedSubs = new Set(absorbedSubsList); this.d_templatePath = - yield SubmoduleConfigUtil.getTemplatePath(this.d_repo); + yield SubmoduleConfigUtil.getTemplatePath(this.d_repo); this.d_fetcher = new SubmoduleFetcher(this.d_repo, this.d_commit); this.d_initialized = true; this.d_tree = yield this.d_commit.getTree(); @@ -173,53 +233,166 @@ 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} + */ +Opener.prototype.isOpen = function (subName) { + if (this.d_initialized) { + return this.d_openSubs.has(subName) || (subName in this.d_cachedSubs); + } else { + const modulesDir = path.join(this.d_repo.path(), "modules"); + const submodulePath = path.join(modulesDir, subName); + const headPath = path.join(submodulePath, "HEAD"); + if (!fs.existsSync(headPath)) { + return false; + } + const gitlinkPath = path.join(this.d_repo.workdir(), subName, ".git"); + return fs.existsSync(gitlinkPath); + } +}; + +/** + * Return true if the submodule is opened nor half opened. * + * @async + * @param {String} subName + * @return {Boolean} + */ +Opener.prototype.isAtLeastHalfOpen = co.wrap(function *(subName) { + if (!this.d_initialized) { + yield this._initialize(); + } + return this.d_absorbedSubs.has(subName) || + this.d_openSubs.has(subName) || + (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.isOpen = co.wrap(function *(subName) { +Opener.prototype.isHalfOpened = co.wrap(function *(subName) { if (!this.d_initialized) { yield this._initialize(); } - return this.d_openSubs.has(subName) || (subName in this.d_subRepos); + 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 * - * @param {String} subName + * Note that after opening one or more submodules, + * `SparseCheckoutUtil.setSparseBitsAndWriteIndex` must be called so that + * `SKIP_WORKTREE` is *unset*; since this operation is expensive, we cannot do + * it automatically each time a submodule is opened. + * + * @param {String} subName + * @param {SUB_OPEN_OPTION} openOption * @return {NodeGit.Repository} */ -Opener.prototype.getSubrepo = co.wrap(function *(subName) { +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 (this.d_openSubs.has(subName)) { - subRepo = yield SubmoduleUtil.getRepo(this.d_repo, subName); + if (SUB_OPEN_OPTION.FORCE_OPEN !== openOption) { + subRepo = this.d_cachedAbsorbedSubs[subName]; + if (undefined !== subRepo) { + return subRepo; + } } - 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); + const openable = 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/print_status_util.js b/node/lib/util/print_status_util.js index a6c9c258e..157583ebb 100644 --- a/node/lib/util/print_status_util.js +++ b/node/lib/util/print_status_util.js @@ -40,9 +40,10 @@ const assert = require("chai").assert; const colors = require("colors/safe"); const path = require("path"); -const GitUtil = require("../util/git_util"); -const Rebase = require("../util/rebase"); -const RepoStatus = require("../util/repo_status"); +const GitUtil = require("./git_util"); +const SequencerState = require("./sequencer_state"); +const RepoStatus = require("./repo_status"); +const TextUtil = require("./text_util"); /** * This value-semantic class describes a line entry to be printed in a status @@ -50,9 +51,9 @@ const RepoStatus = require("../util/repo_status"); */ class StatusDescriptor { /** - * @param {RepoStatus.FILESTATUS} status - * @param {String} path - * @param {String} detail + * @param {RepoStatus.FILESTATUS|RepoStatus.Conflict} status + * @param {String} path + * @param {String} detail */ constructor(status, path, detail) { this.status = status; @@ -71,27 +72,34 @@ class StatusDescriptor { print(color, cwd) { let result = ""; const FILESTATUS = RepoStatus.FILESTATUS; - switch(this.status) { - case FILESTATUS.ADDED: - result += "new file: "; - break; - case FILESTATUS.MODIFIED: - result += "modified: "; - break; - case FILESTATUS.REMOVED: - result += "deleted: "; - break; - case FILESTATUS.CONFLICTED: - result += "conflicted: "; - break; - case FILESTATUS.RENAMED: - result += "renamed: "; - break; - case FILESTATUS.TYPECHANGED: - result += "type changed: "; - break; + if (this.status instanceof RepoStatus.Conflict) { + // TODO: more detail, e.g., "we added" + result += "conflicted: "; } - result += path.relative(cwd, this.path); + else { + switch(this.status) { + case FILESTATUS.ADDED: + result += "new file: "; + break; + case FILESTATUS.MODIFIED: + result += "modified: "; + break; + case FILESTATUS.REMOVED: + result += "deleted: "; + break; + case FILESTATUS.RENAMED: + result += "renamed: "; + break; + case FILESTATUS.TYPECHANGED: + result += "type changed: "; + break; + } + } + let filename = path.relative(cwd, this.path); + if ("" === filename) { + filename = "."; + } + result += filename; result = color(result); if ("" !== this.detail) { result += ` (${this.detail})`; @@ -109,11 +117,7 @@ exports.StatusDescriptor = StatusDescriptor; * @return {StatusDescriptor []} */ exports.sortDescriptorsByPath = function (descriptors) { - return descriptors.sort((l, r) => { - const lPath = l.path; - const rPath = r.path; - return lPath === rPath ? 0 : (lPath < rPath ? -1 : 1); - }); + return descriptors.sort((l, r) => TextUtil.strcmp(l.path, r.path)); }; /** @@ -340,19 +344,35 @@ exports.accumulateStatus = function (status) { }; /** - * Return a message describing the specified `rebase`. + * Return the command to which the specified sequencer `type` corresponds. * - * @param {Rebase} + * @param {SequencerState.TYPE} type * @return {String} */ -exports.printRebase = function (rebase) { - assert.instanceOf(rebase, Rebase); - const shortSha = GitUtil.shortSha(rebase.onto); - return `${colors.red("rebase in progress; onto ", shortSha)} -You are currently rebasing branch '${rebase.headName}' on '${shortSha}'. - (fix conflicts and then run "git meta rebase --continue") - (use "git meta rebase --skip" to skip this patch) - (use "git meta rebase --abort" to check out the original branch) +exports.getSequencerCommand = function (type) { + const TYPE = SequencerState.TYPE; + switch (type) { + case TYPE.CHERRY_PICK: return "cherry-pick"; + case TYPE.MERGE: return "merge"; + case TYPE.REBASE: return "rebase"; + default: assert(false, `unhandled sequencer type: ${type}`); + } +}; + +/** + * Return a message describing the specified `sequencer`. + * + * @param {SequencerState} sequencer + * @return {String} + */ +exports.printSequencer = function (sequencer) { + assert.instanceOf(sequencer, SequencerState); + const command = exports.getSequencerCommand(sequencer.type); + return `\ +A ${command} is in progress. + (after resolving conflicts mark the corrected paths + with 'git meta add', then run "git meta ${command} --continue") + (use "git meta ${command} --abort" to check out the original branch) `; }; @@ -367,6 +387,9 @@ exports.printCurrentBranch = function (status) { if (null !== status.currentBranchName) { return `On branch ${colors.green(status.currentBranchName)}.\n`; } + if (status.headCommit === null) { + return `No commits yet\n`; + } return `\ On detached head ${colors.red(GitUtil.shortSha(status.headCommit))}.\n`; }; @@ -385,12 +408,12 @@ exports.printRepoStatus = function (status, cwd) { let result = ""; - if (null !== status.rebase) { - result += exports.printRebase(status.rebase); - } - result += exports.printCurrentBranch(status); + if (null !== status.sequencerState) { + result += exports.printSequencer(status.sequencerState); + } + let changes = ""; const fileStatuses = exports.accumulateStatus(status); const staged = fileStatuses.staged; @@ -437,34 +460,102 @@ Untracked files: }; /** - * Print a string describing the status of the submodules in the specified - * `status`; show closed submodules if the specified `showClosed` is true; show - * names relative to the specified `relCwd`. + * Return a short description of the specified `status`, displaying + * paths relative to the specified `cwd`. Note that a value of "" for + * `cwd` indicates the root of the repository. + * + * @param {RepoStatus} status + * @param {String} cwd + */ +exports.printRepoStatusShort = function (status, cwd) { + assert.instanceOf(status, RepoStatus); + assert.isString(cwd); + + let result = ""; + + const indexChangesByPath = {}; + const workdirChangesByPath = {}; + const allFiles = new Set(); + + const fileStatuses = exports.accumulateStatus(status); + + const statusFlagByMode = "MAD"; + + for (const p of fileStatuses.staged) { + indexChangesByPath[p.path] = colors.green(statusFlagByMode[p.status]); + allFiles.add(p.path); + } + + for (const p of fileStatuses.workdir) { + workdirChangesByPath[p.path] = colors.red(statusFlagByMode[p.status]); + allFiles.add(p.path); + } + + for (const u of fileStatuses.untracked) { + indexChangesByPath[u] = colors.red("?"); + workdirChangesByPath[u] = colors.red("?"); + allFiles.add(u); + } + + const allFilesArray = []; + allFilesArray.push(...allFiles); + exports.sortDescriptorsByPath(allFilesArray); + for (const f of allFilesArray) { + let index = " "; + if (f in indexChangesByPath) { + index = indexChangesByPath[f]; + } + let workdir = " "; + if (f in workdirChangesByPath) { + workdir = workdirChangesByPath[f]; + } + result += index + workdir + " " + f + "\n"; + } + + if (result.length === 0) { + result = "\n"; + } + + return result; +}; + + +/** + * Print a string describing the status of the specified `subsToPrint`. Use + * the specified `openSubs` set to determine which submodules are open. Unless + * the specified `true === showClosed`, do not print closed sub modules. Use + * the specified `relCwd` to display relative paths. * - * @param {RepoStatus} status * @param {String} relCwd + * @param {Object} subsToPrint map from name to sha or null if deleted + * @param {Set(String)} openSubs set of names of open submodules * @param {Boolean} showClosed * @return {String} */ -exports.printSubmoduleStatus = function (status, relCwd, showClosed) { - assert.instanceOf(status, RepoStatus); +exports.printSubmoduleStatus = function (relCwd, + subsToPrint, + openSubs, + showClosed) { assert.isString(relCwd); + assert.isObject(subsToPrint); assert.isBoolean(showClosed); + let result = ""; - const subStats = status.submodules; if (showClosed) { result = `${colors.grey("All submodules:")}\n`; } else { result = `${colors.grey("Open submodules:")}\n`; } - const names = Object.keys(subStats).sort(); + const names = Object.keys(subsToPrint).sort(); names.forEach(name => { const relName = path.relative(relCwd, name); - const sub = subStats[name]; - const isVis = null !== sub.workdir; + const isVis = openSubs.has(name); const visStr = isVis ? " " : "-"; - const sha = (sub.index && sub.index.sha) || ""; + let sha = subsToPrint[name]; + if (null === sha) { + sha = ""; + } if (isVis || showClosed) { result += `${visStr} ${sha} ${colors.cyan(relName)}\n`; } diff --git a/node/lib/util/pull.js b/node/lib/util/pull.js index 443cead5f..28c11f39d 100644 --- a/node/lib/util/pull.js +++ b/node/lib/util/pull.js @@ -32,13 +32,13 @@ const assert = require("chai").assert; const co = require("co"); -const colors = require("colors"); const NodeGit = require("nodegit"); -const GitUtil = require("../util/git_util"); -const RebaseUtil = require("../util/rebase_util"); -const StatusUtil = require("../util/status_util"); -const UserError = require("../util/user_error"); +const ConfigUtil = require("./config_util"); +const GitUtil = require("./git_util"); +const RebaseUtil = require("./rebase_util"); +const StatusUtil = require("./status_util"); +const UserError = require("./user_error"); /** * Pull the specified `source` branch from the remote having the specified @@ -54,40 +54,26 @@ exports.pull = co.wrap(function *(metaRepo, remoteName, source) { assert.isString(remoteName); assert.isString(source); - // First do some sanity checking on the repos to see if they have a remote - // with `remoteName` and are clean. - - const validRemote = yield GitUtil.isValidRemoteName(metaRepo, remoteName); - - if (!validRemote) { - throw new UserError(`Invalid remote name ${colors.red(remoteName)}.`); - } - const status = yield StatusUtil.getRepoStatus(metaRepo); // Just fetch the meta-repo; rebase will trigger necessary fetches in // sub-repos. yield GitUtil.fetchBranch(metaRepo, remoteName, source); - const remoteBranch = yield GitUtil.findRemoteBranch(metaRepo, - remoteName, - source); - if (null === remoteBranch) { - throw new UserError(`The meta-repo does not have a branch named \ -${colors.red(source)} in the remote ${colors.yellow(remoteName)}.`); - } - const remoteCommitId = remoteBranch.target(); - const remoteCommit = yield NodeGit.Commit.lookup(metaRepo, remoteCommitId); + const ref = yield NodeGit.Reference.lookup(metaRepo, "FETCH_HEAD"); + const remoteCommit = yield NodeGit.Commit.lookup(metaRepo, ref.target()); - yield RebaseUtil.rebase(metaRepo, remoteCommit, status); + const result = yield RebaseUtil.rebase(metaRepo, remoteCommit, status); + if (null !== result.errorMessage) { + throw new UserError(result.errorMessage); + } }); /** - * Return true if the user has requested a rebase (explicitly or - * via config). + * Return true if the user has requested a rebase (explicitly or via config). * * @param {Object} args * @param {Boolean} args.rebase @@ -97,21 +83,14 @@ ${colors.red(source)} in the remote ${colors.yellow(remoteName)}.`); * @return bool */ exports.userWantsRebase = co.wrap(function*(args, repo, branch) { - if (args.rebase !== undefined) { + if (args.rebase !== undefined && args.rebase !== null) { return args.rebase; } - try { - const configVar = `branch.${branch.shorthand()}.rebase`; - return yield GitUtil.configIsTrue(repo, configVar); - } catch (e) { - // no branch config, try global config - } - - try { - return yield GitUtil.configIsTrue(repo, "pull.rebase"); - } catch (e) { - // no config, default is false - return false; + const branchVar = `branch.${branch.shorthand()}.rebase`; + const branchVal = yield ConfigUtil.configIsTrue(repo, branchVar); + if (null !== branchVal) { + return branchVal; } + return (yield ConfigUtil.configIsTrue(repo, "pull.rebase")) || false; }); diff --git a/node/lib/util/push.js b/node/lib/util/push.js index b4c438386..04ea6fe1f 100644 --- a/node/lib/util/push.js +++ b/node/lib/util/push.js @@ -34,18 +34,155 @@ * This module contains methods for pushing. */ -const assert = require("chai").assert; -const co = require("co"); -const colors = require("colors"); -const NodeGit = require("nodegit"); +const assert = require("chai").assert; +const ChildProcess = require("child-process-promise"); +const co = require("co"); +const colors = require("colors"); +const NodeGit = require("nodegit"); const DoWorkQueue = require("./do_work_queue"); +const ForcePushSpec = require("./force_push_spec"); const GitUtil = require("./git_util"); const SubmoduleUtil = require("./submodule_util"); const SubmoduleConfigUtil = require("./submodule_config_util"); const SyntheticBranchUtil = require("./synthetic_branch_util"); const UserError = require("./user_error"); +// This magic SHA represents an empty tree in Git. + +const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + +/** + * For a given commit, determine a reasonably close oid that has + * already been pushed. This does *not* do a fetch, but rather just + * compares against all available remote refs. + * + * This works by building a revwalk and removing all ancestors of remote refs + * for the given remote. It then reverses the result, and returns the first + * parent of the oldest, unpushed commit. This is not an absolute last pushed + * oid, but is a very good proxy to determine what to push. + * + * Returns a commit that exists in a remote ref, or null if no such commit + * exists. If the given commit exists in a remote ref, will return itself. + * + * @async + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} commit + */ +exports.getClosePushedCommit = co.wrap(function*(repo, commit) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(commit, NodeGit.Commit); + + // Search for the first commit that is not an ancestor of a remote ref, ie. + // the first commit that is unpushed. NodeGit Revwalk is too slow, so + // we shell out. + + // First, get the list of remote refs to exclude + const excludeRefs = []; + for (const ref of (yield NodeGit.Reference.list(repo))) { + if (ref.startsWith("refs/remotes/")) { + excludeRefs.push("^" + ref); + } + } + if (excludeRefs.length === 0) { + return null; + } + + const args = ["-C", repo.workdir(), "rev-list", "--reverse", + "--topo-order", commit, ...excludeRefs]; + let result = yield ChildProcess.execFile("git", args, { + maxBuffer: 1024*1024*100 + }); + if (result.error) { + throw new Error( + `Unexpected error figuring out what to push: +stderr: +{result.stderr} +stdout: +{result.stdout}`); + } + const out = result.stdout; + if (!out) { + // Nothing new to push + return commit; + } + + const firstNewOid = out.substring(0, out.indexOf("\n")); + + const firstNewCommit = yield repo.getCommit(firstNewOid); + + // If the first unpushed commit has no parents, then the entire set of + // commits to push is new. + + if (0 === firstNewCommit.parentcount()) { + return null; + } + return yield repo.getCommit(firstNewCommit.parentId(0)); +}); + +/** + * For a given proposed push, return a map from submodule to sha, + * excluding any submodules that the server likely already has. + * + * Return in the map only those submodules that (1) exist locally, in + * `.git/modules` and (2) are changed between `commit` and the merge base of + * `commit` and each relevant branch. + * + * @async + * @param {NodeGit.Repository} repo + * @param {String} source + * @param {NodeGit.Commit} commit + */ +exports.getPushMap = co.wrap(function*(repo, source, commit) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isString(source); + assert.instanceOf(commit, NodeGit.Commit); + + const baseCommit = yield exports.getClosePushedCommit(repo, commit); + let baseTree; + if (null !== baseCommit) { + baseTree = yield baseCommit.getTree(); + } else { + baseTree = EMPTY_TREE; + } + + const tree = yield commit.getTree(); + const diff = yield NodeGit.Diff.treeToTree(repo, baseTree, tree, null); + const changes = SubmoduleUtil.getSubmoduleChangesFromDiff(diff, true); + + const pushMap = {}; + for (const path of Object.keys(changes)) { + const change = changes[path]; + if (!change.deleted) { + pushMap[path] = change.newSha; + } + } + + const openSubmodules = yield SubmoduleUtil.listOpenSubmodules(repo); + const absorbedSubmodules = + yield SubmoduleUtil.listAbsorbedSubmodules(repo); + + const availableSubmodules = new Set([...openSubmodules, + ...absorbedSubmodules]); + + // Make sure we have the repositories and commits we want to push. + for (const sub of Object.keys(pushMap)) { + if (!availableSubmodules.has(sub)) { + delete pushMap[sub]; + continue; + } + + const subRepo = yield SubmoduleUtil.getBareRepo(repo, sub); + try { + yield subRepo.getCommit(pushMap[sub]); + } catch (e) { + delete pushMap[sub]; + } + } + + return pushMap; +}); + /** * For each open submodule that exists in the commit indicated by the specified * `source`, push a synthetic-meta-ref for the `source` commit. @@ -79,59 +216,60 @@ exports.push = co.wrap(function *(repo, remoteName, source, target, force) { assert.isString(remoteName); assert.isString(source); assert.isString(target); - assert.isBoolean(force); + assert.instanceOf(force, ForcePushSpec); - let remote; - try { - remote = yield repo.getRemote(remoteName); - } - catch (e) { - throw new UserError(`No remote named ${colors.red(remoteName)}.`); + let remoteUrl = yield GitUtil.getUrlFromRemoteName(repo, remoteName); + + const annotatedCommit = yield GitUtil.resolveCommitish(repo, source); + if (annotatedCommit === null) { + throw new UserError(`No such ref: ${source}`); } - const remoteUrl = yield GitUtil.getRemoteUrl(repo, remote); + const sha = annotatedCommit.id(); + const commit = yield repo.getCommit(sha); // First, push the submodules. + const pushMap = yield exports.getPushMap(repo, source, commit); let errorMessage = ""; - const shas = yield SubmoduleUtil.getSubmoduleShasForBranch(repo, source); - const annotatedCommit = yield GitUtil.resolveCommitish(repo, source); - const commit = yield repo.getCommit(annotatedCommit.id()); + const urls = yield SubmoduleConfigUtil.getSubmodulesFromCommit(repo, commit); const pushSub = co.wrap(function *(subName) { - // If no commit for a submodule on this branch, skip it. - if (!(subName in shas)) { - return; // RETURN - } // Push to a synthetic branch; first, calculate name. - const sha = shas[subName]; + const sha = pushMap[subName]; const syntheticName = SyntheticBranchUtil.getSyntheticBranchForCommit(sha); - const subRepo = yield SubmoduleUtil.getRepo(repo, subName); + const subRepo = yield SubmoduleUtil.getBareRepo(repo, subName); // Resolve the submodule's URL against the URL of the meta-repo, // ignoring the remote that is configured in the open submodule. + if (!(subName in urls)) { + throw new UserError( + `The submodule ${subName} doesn't have an entry in .gitmodules` + ); + } const subUrl = SubmoduleConfigUtil.resolveSubmoduleUrl(remoteUrl, urls[subName]); // Always force push synthetic refs. It should not be necessary, but // if something does go wrong forcing will allow us to auto-correct. + // If they succeed, no need to print the output inside the submodules. const pushResult = yield GitUtil.push(subRepo, subUrl, sha, syntheticName, + ForcePushSpec.Force, true); if (null !== pushResult) { errorMessage += `Failed to push submodule ${colors.yellow(subName)}: ${pushResult}`; } }); - const subRepos = yield SubmoduleUtil.listOpenSubmodules(repo); - yield DoWorkQueue.doInParallel(subRepos, pushSub); + yield DoWorkQueue.doInParallel(Object.keys(pushMap), pushSub); // Throw an error if there were any problems pushing submodules; don't push // the meta-repo. diff --git a/node/lib/util/read_repo_ast_util.js b/node/lib/util/read_repo_ast_util.js index c9c214835..7cdc1fdbd 100644 --- a/node/lib/util/read_repo_ast_util.js +++ b/node/lib/util/read_repo_ast_util.js @@ -44,8 +44,13 @@ const path = require("path"); const RepoAST = require("./repo_ast"); const RebaseFileUtil = require("./rebase_file_util"); +const SparseCheckoutUtil = require("./sparse_checkout_util"); +const SequencerStateUtil = require("./sequencer_state_util"); const SubmoduleConfigUtil = require("./submodule_config_util"); +const FILEMODE = NodeGit.TreeEntry.FILEMODE; +const File = RepoAST.File; + /** * Load the submodules objects from the specified `repo` on the specified * `commitId`. @@ -81,16 +86,49 @@ const loadIndexAndWorkdir = co.wrap(function *(repo, headCommit) { const workdir = {}; const submodules = - yield SubmoduleConfigUtil.getSubmodulesFromIndex(repo, repoIndex); - + yield SubmoduleConfigUtil.getSubmodulesFromIndex(repo, repoIndex); const referencedSubmodules = new Set(); // set of changed submodules const STATUS = NodeGit.Status.STATUS; + const readWorkdir = co.wrap(function *(filePath) { + const absPath = path.join(repo.workdir(), filePath); + let data; + try { + data = yield fs.readFile(absPath, { encoding: "utf8" }); + } catch (e) { + // no file + } + let isExecutable = false; + try { + yield fs.access(absPath, fs.constants.X_OK); + isExecutable = true; + } catch (e) { + // cannot execute + } + if (undefined !== data) { + workdir[filePath] = new File(data, isExecutable); + } + }); + + const readEntryFile = co.wrap(function *(entry) { + if (undefined === entry) { + return null; // RETURN + } + const oid = entry.id; + if (FILEMODE.COMMIT === entry.mode) { + return new RepoAST.Submodule("", oid.tostrS()); + } + const isExecutable = FILEMODE.EXECUTABLE === entry.mode; + const blob = yield repo.getBlob(oid); + return new File(blob.toString(), isExecutable); + }); + const stats = yield repo.getStatusExt(); for (let i = 0; i < stats.length; ++i) { - const stat = stats[i].statusBit(); - let filePath = stats[i].path(); + const statusFile = stats[i]; + const stat = statusFile.statusBit(); + let filePath = statusFile.path(); // If the path ends with a slash, knock it off so we'll be able to // match it to submodules. @@ -107,6 +145,23 @@ const loadIndexAndWorkdir = co.wrap(function *(repo, headCommit) { // Check index. + if (statusFile.isConflicted()) { + // If the file is conflicted, read the contents for each stage, and + // the contents of the file in the workdir. + + const ancestorEntry = repoIndex.getByPath(filePath, 1); + const ourEntry = repoIndex.getByPath(filePath, 2); + const theirEntry = repoIndex.getByPath(filePath, 3); + const ancestorData = yield readEntryFile(ancestorEntry); + const ourData = yield readEntryFile(ourEntry); + const theirData = yield readEntryFile(theirEntry); + index[filePath] = new RepoAST.Conflict(ancestorData, + ourData, + theirData); + yield readWorkdir(filePath); + continue; // CONTINUE + } + if (stat & STATUS.INDEX_DELETED) { index[filePath] = null; } @@ -125,12 +180,8 @@ const loadIndexAndWorkdir = co.wrap(function *(repo, headCommit) { else { // Otherwise, read the blob for the file from the index. - const entry = repoIndex.getByPath(filePath, 0); - const oid = entry.id; - const blob = yield repo.getBlob(oid); - const data = blob.toString(); - index[filePath] = data; + index[filePath] = yield readEntryFile(entry); } } @@ -141,11 +192,7 @@ const loadIndexAndWorkdir = co.wrap(function *(repo, headCommit) { } else if (stat & STATUS.WT_NEW || stat & STATUS.WT_MODIFIED) { if (!(filePath in submodules)) { - const absPath = path.join(repo.workdir(), filePath); - const data = yield fs.readFile(absPath, { - encoding: "utf8" - }); - workdir[filePath] = data; + yield readWorkdir(filePath); } } } @@ -184,19 +231,34 @@ const loadIndexAndWorkdir = co.wrap(function *(repo, headCommit) { }; }); +const syntheticRefRegexp = new RegExp("^refs/commits/[0-9a-f]{40}$"); +const isSyntheticRef = function(refName) { + return syntheticRefRegexp.test(refName); +}; + /** * Return a representation of the specified `repo` encoded in an `AST` object. * * @async * @param {NodeGit.Repository} repo + * @param boolean includeRefsCommits if true, refs from the + * refs/commits namespace are read. Ordinarily, these are ignored + * because they are created any time a submodule is fetched as part of + * a git meta open, which is often to be done as part of repo writing. + * But some tests rely on refs in this namespace, and these tests need + * to include them. * @return {RepoAST} */ -exports.readRAST = co.wrap(function *(repo) { +exports.readRAST = co.wrap(function *(repo, includeRefsCommits) { // We're going to list all the branches in `repo`, and walk each of their // histories to generate a complete set of commits. assert.instanceOf(repo, NodeGit.Repository); - const branches = yield repo.getReferences(NodeGit.Reference.TYPE.LISTALL); + if (includeRefsCommits === undefined) { + includeRefsCommits = false; + } + assert.instanceOf(repo, NodeGit.Repository); + const branches = yield repo.getReferences(); let commits = {}; let branchTargets = {}; let refTargets = {}; @@ -269,8 +331,10 @@ exports.readRAST = co.wrap(function *(repo) { else if (!(path in submodules)) { // Skip submodules; we handle them later. const entry = yield commit.getEntry(path); + const isExecutable = + FILEMODE.EXECUTABLE === entry.filemode(); const blob = yield entry.getBlob(); - changes[path] = blob.toString(); + changes[path] = new File(blob.toString(), isExecutable); } } @@ -350,7 +414,12 @@ exports.readRAST = co.wrap(function *(repo) { new RepoAST.Branch(id.tostrS(), tracking); } else if (!branch.isNote()) { - refTargets[branch.shorthand()] = id.tostrS(); + if (includeRefsCommits || + !isSyntheticRef(branch.name())) { + refTargets[branch.shorthand()] = id.tostrS(); + } else { + return; + } } else { return; // RETURN @@ -390,10 +459,17 @@ exports.readRAST = co.wrap(function *(repo) { headCommitId = headCommit.id().tostrS(); } + const sparse = yield SparseCheckoutUtil.inSparseMode(repo); + if (!bare) { const current = yield loadIndexAndWorkdir(repo, headCommit); index = current.index; - workdir = current.workdir; + + // Ignore the workdir if it's sparse. + + if (!sparse) { + workdir = current.workdir; + } } // Now we can actually build the remote objects. @@ -440,12 +516,15 @@ exports.readRAST = co.wrap(function *(repo) { let openSubmodules = {}; if (!bare) { - const subNames = yield repo.getSubmoduleNames(); + const index = yield repo.index(); + const subs = yield SubmoduleConfigUtil.getSubmodulesFromIndex(repo, + index); + const subNames = Object.keys(subs); for (let i = 0; i < subNames.length; ++i) { const subName = subNames[i]; const status = yield NodeGit.Submodule.status(repo, subName, 0); - const WD_UNINITIALIZED = (1 << 7); // means "closed" - if (!(status & WD_UNINITIALIZED)) { + if (status & NodeGit.Submodule.STATUS.IN_WD && + !(status & NodeGit.Submodule.STATUS.WD_UNINITIALIZED)) { const sub = yield NodeGit.Submodule.lookup(repo, subName); const subRepo = yield sub.open(); const subAST = yield exports.readRAST(subRepo); @@ -503,6 +582,15 @@ exports.readRAST = co.wrap(function *(repo) { yield loadCommit(NodeGit.Oid.fromString(rebase.onto)); } + const sequencer = yield SequencerStateUtil.readSequencerState(repo.path()); + + if (null !== sequencer) { + yield loadCommit(NodeGit.Oid.fromString(sequencer.originalHead.sha)); + yield loadCommit(NodeGit.Oid.fromString(sequencer.target.sha)); + yield sequencer.commits.map( + sha => loadCommit(NodeGit.Oid.fromString(sha))); + } + return new RepoAST({ commits: commits, branches: branchTargets, @@ -515,6 +603,8 @@ exports.readRAST = co.wrap(function *(repo) { workdir: workdir, openSubmodules: openSubmodules, rebase: rebase, + sequencerState: sequencer, bare: bare, + sparse: sparse, }); }); diff --git a/node/lib/util/rebase_util.js b/node/lib/util/rebase_util.js index b58bcad53..05e25653b 100644 --- a/node/lib/util/rebase_util.js +++ b/node/lib/util/rebase_util.js @@ -33,681 +33,367 @@ const assert = require("chai").assert; const co = require("co"); const colors = require("colors"); -const fs = require("fs-promise"); const NodeGit = require("nodegit"); -const path = require("path"); -const rimraf = require("rimraf"); -const DeinitUtil = require("./deinit_util"); -const Open = require("./open"); +const Checkout = require("./checkout"); +const CherryPickUtil = require("./cherry_pick_util"); +const Reset = require("./reset"); const GitUtil = require("./git_util"); -const RepoStatus = require("./repo_status"); -const RebaseFileUtil = require("./rebase_file_util"); -const SubmoduleConfigUtil = require("./submodule_config_util"); -const SubmoduleUtil = require("./submodule_util"); +const Hook = require("./hook"); +const SequencerState = require("./sequencer_state"); +const SequencerStateUtil = require("./sequencer_state_util"); +const StatusUtil = require("./status_util"); +const SubmoduleRebaseUtil = require("./submodule_rebase_util"); const UserError = require("./user_error"); -/** - * Put the head of the specified `repo` on the specified `commitSha`. - */ -const setHead = co.wrap(function *(repo, commitSha) { - const commit = yield repo.getCommit(commitSha); - yield GitUtil.setHeadHard(repo, commit); -}); +const CommitAndRef = SequencerState.CommitAndRef; /** - * Call `next` on the specified `rebase`; return the rebase operation for the - * rebase or null if there is no further operation. + * Accumulate specfied `intermediate` result into `result`, gathering new and + * rewritten submodule commits generated from a single rebase operation. * - * @async - * @private - * @param {NodeGit.Rebase} rebase - * @return {RebaseOperation|null} + * @param {Object} result + * @param {Object} result.submoduleCommits path to map from sha to sha + * @param {Object} intermediate + * @param {Object} intermediate.submoduleCommits path to map from sha to sha */ -const callNext = co.wrap(function *(rebase) { - try { - return yield rebase.next(); - } - catch (e) { - // It's cumbersome, but the way the nodegit library indicates - // that you are at the end of the rebase is by throwing an - // exception. At this point we call `finish` on the rebase and - // break out of the contaiing while loop. - - if (e.errno === NodeGit.Error.CODE.ITEROVER) { - return null; - } - throw e; - } -}); - -const cleanupRebaseDir = co.wrap(function *(repo) { - const gitDir = repo.path(); - const rebaseDir = yield RebaseFileUtil.findRebasingDir(gitDir); - if (null !== rebaseDir) { - const rebasePath = path.join(gitDir, rebaseDir); - yield (new Promise(callback => { - return rimraf(rebasePath, {}, callback); - })); +function accumulateRebaseResult(result, intermediate) { + assert.isObject(result); + assert.isObject(intermediate); + + for (let name in intermediate.submoduleCommits) { + const commits = Object.assign(result.submoduleCommits[name] || {}, + intermediate.submoduleCommits[name]); + result.submoduleCommits[name] = commits; } -}); + result.errorMessage = intermediate.errorMessage; +} /** - * Finish the specified `rebase` in the specified `repo`. Note that this - * method is necessary only as a workaround for: - * https://github.com/twosigma/git-meta/issues/115. + * If the specified `seq` has a non-null ref that is a branch, make it the + * current branch in the specified `repo`. * * @param {NodeGit.Repository} repo - * @param {NodeGit.Rebase} rebase + * @param {SequencerState} seq */ -const callFinish = co.wrap(function *(repo, rebase) { - const result = rebase.finish(); - const CLEANUP_FAILURE = -15; - if (CLEANUP_FAILURE === result) { - yield cleanupRebaseDir(repo); - } -}); - -/** - * Process the specified `rebase` for the submodule having the specified - * `name`and open `repo`, beginning with the specified `op` and proceeding - * until there are no more commits to rebase. Return an object describing any - * encountered error and commits made. - * - * @param {NodeGit.Repository} rep - * @param {String} name - * @param {NodeGit.Rebase} rebase - * @param {NodeGit.RebaseOperation} op - * @return {Object} - * @return {Object} return.commits - * @return {String|null} return.error - */ -const processSubmoduleRebase = co.wrap(function *(repo, - name, - rebase, - op) { - const result = { - commits: {}, - error: null, - }; - const signature = repo.defaultSignature(); - while (null !== op) { - console.log(`Submodule ${colors.blue(name)}: applying \ -commit ${colors.green(op.id().tostrS())}.`); - const index = yield repo.index(); - if (index.hasConflicts()) { - result.error = `\ -Conflict rebasing the submodule ${colors.red(name)}.`; - break; // BREAK +const restoreHeadBranch = co.wrap(function *(repo, seq) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(seq, SequencerState); + + const originalRefName = seq.originalHead.ref; + if (null !== originalRefName) { + const ref = yield NodeGit.Reference.lookup(repo, originalRefName); + if (ref.isBranch()) { + const head = yield repo.getHeadCommit(); + yield ref.setTarget(head, "git-meta rebase"); + yield repo.setHead(originalRefName); } - const newCommit = rebase.commit(null, signature, null); - const originalCommit = op.id().tostrS(); - result.commits[newCommit.tostrS()] = originalCommit; - op = yield callNext(rebase); - } - if (null === result.error) { - console.log(`Submodule ${colors.blue(name)}: finished \ -rebase.`); - yield callFinish(repo, rebase); } - return result; }); /** - * Return an object indicating the commits that were created during rebasing - * and/or an error message indicating that the rebase was stopped due to a - * conflict. - * - * @param {Open.Opener} opener - * @param {String} name of the submodule - * @param {String} from commit rebasing from - * @param {String} onto commit rebasing onto - * @return {Object} - * @return {Object} return.commits map from original to created commit - * @return {Strring|null} return.error failure message if non-null + * Throw a `UserError` unlessn the specified `seq` is non-null and has type + * `REBASE`. + * @param {SequencerState} seq */ -const rebaseSubmodule = co.wrap(function *(opener, name, from, onto) { - const fetcher = yield opener.fetcher(); - const repo = yield opener.getSubrepo(name); - yield fetcher.fetchSha(repo, name, from); - yield fetcher.fetchSha(repo, name, onto); - const fromCommit = yield repo.getCommit(from); - yield NodeGit.Reset.reset(repo, fromCommit, NodeGit.Reset.TYPE.HARD); - const head = yield repo.head(); - const fromAnnotated = yield NodeGit.AnnotatedCommit.fromRef(repo, head); - const ontoCommitId = NodeGit.Oid.fromString(onto); - const ontoAnnotated = - yield NodeGit.AnnotatedCommit.lookup(repo, ontoCommitId); - const rebase = yield NodeGit.Rebase.init(repo, - fromAnnotated, - ontoAnnotated, - null, - null); - console.log(`Submodule ${colors.blue(name)}: starting \ -rebase; rewinding to ${colors.green(ontoCommitId.tostrS())}.`); - - let op = yield callNext(rebase); - return yield processSubmoduleRebase(repo, name, rebase, op); -}); +function ensureRebaseInProgress(seq) { + if (null !== seq) { + assert.instanceOf(seq, SequencerState); + } -/** - * Attempt to handle a conflicted `.gitmodules` file in the specified `repo` - * having the specified `index`, with changes coming from the specified - * `fromCommit` and `ontoCommit` commits. Return true if the conflict was - * resolved and false otherwise. - * - * @param {NodeGit.Repository} repo - * @param {NodeGit.Index} index - * @param {NodeGit.Commit} fromCommit - * @param {NodeGit.Commit} ontoCommit - * @return {Boolean} - */ -const mergeModulesFile = co.wrap(function *(repo, - index, - fromCommit, - ontoCommit) { - // If there is a conflict in the '.gitmodules' file, attempt to resolve it - // by comparing the current change against the original onto commit and the - // merge base between the base and onto commits. - - const Conf = SubmoduleConfigUtil; - const getSubs = Conf.getSubmodulesFromCommit; - const fromNext = yield getSubs(repo, fromCommit); - - const baseId = yield NodeGit.Merge.base(repo, - fromCommit.id(), - ontoCommit.id()); - const mergeBase = yield repo.getCommit(baseId); - const baseSubs = - yield SubmoduleConfigUtil.getSubmodulesFromCommit(repo, mergeBase); - - const ontoSubs = yield SubmoduleConfigUtil.getSubmodulesFromCommit( - repo, - ontoCommit); - - const merged = Conf.mergeSubmoduleConfigs(fromNext, ontoSubs, baseSubs); - // If it was resolved, write out and stage the new - // modules state. - - if (null !== merged) { - const newConf = Conf.writeConfigText(merged); - yield fs.writeFile(path.join(repo.workdir(), Conf.modulesFileName), - newConf); - yield index.addByPath(Conf.modulesFileName); - return true; + if (null === seq || SequencerState.TYPE.REBASE !== seq.type) { + throw new UserError("Error: no rebase in progress"); } - return false; -}); + return seq; +} /** - * Process the specified `entry` from the specified `index` for the specified - * `metaRepo` during a rebase from the specified `fromCommit` on the specified - * `ontoCommit`. Use the specified `opener` to open submodules as needed. - * Return an object indicating that an error occurred, that a submodule needs - * to be rebased, or neither. + * Apply the remaining rebase operations described in the specified `seq` to + * the specified `repo`. Return an object describing any created commits. + * Before applying a commit, record a sequencer representing the current state. * - * @return {Object} - * @return {String|null} return.error - * @return {String|null} return.subToRebase - * @return {String|undefined} return.rebaseFrom only if `subToRebase !== null` + * @param {NodeGit.Repository} repo + * @param {SequencerState} seq + * @return {Object} [return] + * @return {Object} return.metaCommits maps from new to rebased commits + * @return {Object} return.submoduleCommits maps from submodule name to + * a map from new to rebased commits + * @return {String|null} return.errorMessage */ -const processMetaRebaseEntry = co.wrap(function *(metaRepo, - index, - entry, - opener, - fromCommit, - ontoCommit) { - - const id = entry.id; - const isSubmodule = entry.mode === NodeGit.TreeEntry.FILEMODE.COMMIT; - const fetcher = yield opener.fetcher(); - const stage = RepoStatus.getStage(entry.flags); - +exports.runRebase = co.wrap(function *(repo, seq) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(seq, SequencerState); const result = { - error: null, - subToRebase: null, + metaCommits: {}, + submoduleCommits: {}, + errorMessage: null, }; - - switch (stage) { - case RepoStatus.STAGE.NORMAL: - // If this is an unchanged, visible sub, make sure its sha is in the - // right place in case it was ffwded. - - const open = yield opener.isOpen(entry.path); - if (open) { - const name = entry.path; - const fromSha = id.tostrS(); - const subRepo = yield opener.getSubrepo(name); - const subHead = yield subRepo.getHeadCommit(); - if (subHead.id().tostrS() !== fromSha) { - yield fetcher.fetchSha(subRepo, name, fromSha); - yield setHead(subRepo, fromSha); - } + for (let i = seq.currentCommit; i !== seq.commits.length; ++i) { + const nextSeq = seq.copy({ currentCommit: i, }); + yield SequencerStateUtil.writeSequencerState(repo.path(), nextSeq); + const sha = nextSeq.commits[i]; + const commit = yield repo.getCommit(sha); + SubmoduleRebaseUtil.logCommit(commit); + const cherryResult = yield CherryPickUtil.rewriteCommit(repo, commit, + "rebase"); + if (null !== cherryResult.newMetaCommit) { + result.metaCommits[cherryResult.newMetaCommit] = sha; } - break; - case RepoStatus.STAGE.OURS: - if (isSubmodule) { - result.subToRebase = entry.path; - result.rebaseFrom = id.tostrS(); + accumulateRebaseResult(result, cherryResult); + if (null !== result.errorMessage) { + return result; } - else { - if (SubmoduleConfigUtil.modulesFileName === entry.path) { - const succeeded = yield mergeModulesFile(metaRepo, - index, - fromCommit, - ontoCommit); - if (succeeded) { - break; // BREAK - } - } - result.error = - `There is a conflict in ${colors.red(entry.path)}.\n`; - } - break; } - return result; -}); -/** - * Close the submodules opened by the specified `opener` that have no entry in - * the specified `subCommits` map or the specified `conflicted` set. - * - * @param {Open.Opener} opener - * @param {Object} subCommits sub name to commit map - * @param {Set} conflicted name of subs with conflicts. - */ -const closeAutoOpenedSubmodules = co.wrap(function *(opener, - subCommits, - conflicted) { - const repo = opener.repo; - const opened = yield opener.getOpenedSubs(); - yield opened.map(co.wrap(function *(name) { - const commits = subCommits[name]; - if ((undefined === commits || 0 === Object.keys(commits).length) && - !conflicted.has(name)) { - console.log(`Closing ${colors.green(name)} -- no commit created.`); - yield DeinitUtil.deinit(repo, name); - } - })); + yield restoreHeadBranch(repo, seq); + yield SequencerStateUtil.cleanSequencerState(repo.path()); + console.log("Finished rebase."); + yield Hook.execHook(repo, "post-rewrite", ["rebase"]); + return result; }); /** - * Process the specified `op` for the specified `rebase` in the specified - * `metaRepo` that maps to the specified `ontoCommit`. Load the generated - * commits into the specified `result`. + * Rebase the current branch onto the specified `onto` commit in the specified + * `repo` having the specified `status`. Throw a `UserError` if the rebase + * cannot proceed due to unclean state or because another operation is in + * progress. * - * @param {NodeGit.Repository} metaRepo - * @param {NodeGit.Commit} ontoCommit - * @param {NodeGit.Rebase} rebase - * @param {NodeGit.RebaseOperation} op - * @param {Object} result - * @param {Object} result.submoduleCommits - * @param {Object} result.metaCommits + * @async + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} onto + * @return {Object} [return] + * @return {Object} return.metaCommits maps from new to rebased commits + * @return {Object} return.submoduleCommits maps from submodule name to + * a map from new to rebased commits */ -const processMetaRebaseOp = co.wrap(function *(metaRepo, - ontoCommit, - rebase, - op, - result) { - // We're going to loop over the entries of the index for the rebase - // operation. We have several tasks (I'll repeat later): - // - // 1. Stage "normal", un-conflicted, non-submodule changes. This - // process requires that we set the submodule to the correct commit. - // 2. When a conflict is detected in a submodule, call `init` on the - // rebaser for that submodule. - // 3. Pass any change in a submodule off to the appropriate submodule - // rebaser. - - const fromCommit = yield metaRepo.getCommit(op.id()); - const urls = yield SubmoduleConfigUtil.getSubmodulesFromCommit( - metaRepo, - fromCommit); - const names = Object.keys(urls); - const ontoShas = yield SubmoduleUtil.getSubmoduleShasForCommit( - metaRepo, - names, - fromCommit); - const opener = new Open.Opener(metaRepo, fromCommit); - - const index = yield metaRepo.index(); - const subsToRebase = {}; // map from name to sha to rebase onto - - let errorMessage = ""; - - const entries = index.entries(); - for (let i = 0; i < entries.length; ++i) { - const ret = yield processMetaRebaseEntry(metaRepo, - index, - entries[i], - opener, - fromCommit, - ontoCommit); - if (null !== ret.error) { - console.log("E:", ret.error); - errorMessage += ret.error + "\n"; - } - else if (null !== ret.subToRebase) { - subsToRebase[ret.subToRebase] = ret.rebaseFrom; - } - } +exports.rebase = co.wrap(function *(repo, onto) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(onto, NodeGit.Commit); - // Clean up conflicts unless we found one in the meta-repo that was not - // a submodule change. + // First, make sure we're in a state in which we can run a rebase. - if ("" === errorMessage) { - yield index.conflictCleanup(); + const status = yield StatusUtil.getRepoStatus(repo); + StatusUtil.ensureReady(status); + if (!status.isDeepClean(false)) { + throw new UserError(`\ +The repository has uncommitted changes. Please stash or commit them before +running rebase.`); } - const conflicted = new Set(); + const result = { + metaCommits: {}, + submoduleCommits: {}, + errorMessage: null, + }; - // Process submodule rebases. + const headCommit = yield repo.getHeadCommit(); + const headRef = yield repo.head(); + const headSha = headCommit.id().tostrS(); + const ontoSha = onto.id().tostrS(); - for (let name in subsToRebase) { - const from = subsToRebase[name]; - const ret = yield rebaseSubmodule(opener, - name, - ontoShas[name], - from); - yield index.addByPath(name); - if (name in result.submoduleCommits) { - Object.assign(result.submoduleCommits[name], ret.commits); - } - else { - result.submoduleCommits[name] = ret.commits; - } + // First, see if 'commit' already exists in the current history. If so, we + // can exit immediately. - if (null !== ret.error) { - errorMessage += ret.error + "\n"; - conflicted.add(name); - } + if (yield GitUtil.isUpToDate(repo, headSha, ontoSha)) { + const name = headRef.shorthand(); + console.log(`${colors.green(name)} is up-to-date.`); + return result; // RETURN } - yield closeAutoOpenedSubmodules(opener, - result.submoduleCommits, - conflicted); - - if ("" !== errorMessage) { - throw new UserError(errorMessage); + const canFF = yield NodeGit.Graph.descendantOf(repo, ontoSha, headSha); + if (canFF) { + yield Reset.reset(repo, onto, Reset.TYPE.HARD); + console.log(`Fast-forwarded to ${GitUtil.shortSha(ontoSha)}`); + yield Hook.execHook(repo, "post-checkout", [headSha, ontoSha, "1"]); + return result; // RETURN } - // Write the index and new commit, recording a mapping from the - // original commit ID to the new one. + console.log("First, rewinding head to replay your work on top of it..."); + + yield Checkout.checkoutCommit(repo, onto, true); - yield index.write(); - const newCommit = yield SubmoduleUtil.cacheSubmodules(metaRepo, () => { - const signature = metaRepo.defaultSignature(); - const commit = rebase.commit(null, signature, null); - return Promise.resolve(commit); + const commits = yield exports.listRebaseCommits(repo, headCommit, onto); + const headName = headRef.isBranch() ? headRef.name() : null; + const seq = new SequencerState({ + type: SequencerState.TYPE.REBASE, + originalHead: new CommitAndRef(headSha, headName), + target: new CommitAndRef(ontoSha, null), + currentCommit: 0, + commits, }); - const newCommitSha = newCommit.tostrS(); - const originalSha = op.id().tostrS(); - if (originalSha !== newCommitSha) { - result.metaCommits[newCommitSha] = originalSha; - } + return yield exports.runRebase(repo, seq); }); /** - * Drive a rebase operation in the specified `metaRepo` from the specified - * `fromCommit` to the specified `ontoCommit`. Call the specified - * `initializer` to set up the rebase initially. - * - * Essentially, this function factors out the core rebase logic to be shared - * between normal rebase and continued rebases. + * Abort the rebase in progress on the specified `repo` and all open + * submodules, returning them to their previous heads and checking them out. + * The behavior is undefined unless the specified `repo` has a rebase in + * progress. * - * @param {NodeGit.Repository} metaRepo - * @param {(openSubs, getSubmoduleRebaser) => NodeGit.Rebase} initializer - * @param {NodeGit.Commit} fromCommit - * @param {NodeGit.Commit} ontoCommit + * @param {NodeGit.Repository} repo */ -const driveRebase = co.wrap(function *(metaRepo, - initializer, - fromCommit, - ontoCommit) { - assert.instanceOf(metaRepo, NodeGit.Repository); - assert.isFunction(initializer); - assert.instanceOf(fromCommit, NodeGit.Commit); - assert.instanceOf(ontoCommit, NodeGit.Commit); - - const init = yield initializer(metaRepo); - const rebase = init.rebase; - const result = { - metaCommits: {}, - submoduleCommits: init.submoduleCommits, - }; - - // Now, iterate over the rebase commits. We pull the operation out into a - // separate function to avoid problems associated with creating functions - // in loops. - - let idx = rebase.operationCurrent(); - const total = rebase.operationEntrycount(); - function makeCallNext() { - return callNext(rebase); - } - while (idx < total) { - const rebaseOper = rebase.operationByIndex(idx); - console.log(`Applying ${colors.green(rebaseOper.id().tostrS())}.`); - yield processMetaRebaseOp(metaRepo, - ontoCommit, - rebase, - rebaseOper, - result); - yield SubmoduleUtil.cacheSubmodules(metaRepo, makeCallNext); - ++idx; - } - - // If this was a fast-forward rebase, we need to set the heads of the - // submodules correctly. - - const wasFF = yield NodeGit.Graph.descendantOf(metaRepo, - ontoCommit.id(), - fromCommit.id()); - if (wasFF) { - const opener = new Open.Opener(metaRepo, ontoCommit); - const fetcher = yield opener.fetcher(); - const openSubs = Array.from(yield opener.getOpenSubs()); - const shas = yield SubmoduleUtil.getSubmoduleShasForCommit(metaRepo, - openSubs, - ontoCommit); - yield openSubs.map(co.wrap(function *(name) { - const subRepo = yield opener.getSubrepo(name); - const head = yield subRepo.head(); - const sha = shas[name]; - if (head.target().tostrS() !== sha) { - yield fetcher.fetchSha(subRepo, name, sha); - yield setHead(subRepo, sha); - } - })); - } +exports.abort = co.wrap(function *(repo) { + assert.instanceOf(repo, NodeGit.Repository); - yield callFinish(metaRepo, rebase); - return result; + const seq = yield SequencerStateUtil.readSequencerState(repo.path()); + ensureRebaseInProgress(seq); + const commit = yield repo.getCommit(seq.originalHead.sha); + yield Reset.reset(repo, commit, Reset.TYPE.MERGE); + yield restoreHeadBranch(repo, seq); + yield SequencerStateUtil.cleanSequencerState(repo.path()); }); /** - * Rebase the current branch onto the specified `commit` in the specified - * `metaRepo` having the specified `status`. The behavior is undefined unless - * the `metaRepo` is in a consistent state according to - * `Status.ensureCleanAndConsistent`. Return an object describing generated - * commits. + * Continue the rebase in progress on the specified `repo`. Return an object + * describng the commits that were created an an error message if the operation + * could not be completed. * - * @async - * @param {NodeGit.Repository} metaRepo - * @param {NodeGit.Commit} commit + * @param {NodeGit.Repository} repo * @return {Object} [return] * @return {Object} return.metaCommits maps from new to rebased commits * @return {Object} return.submoduleCommits maps from submodule name to * a map from new to rebased commits + * @return {Object} return.newCommits commits made in non-rebasing + * submodules, path to sha */ -exports.rebase = co.wrap(function *(metaRepo, commit) { - assert.instanceOf(metaRepo, NodeGit.Repository); - assert.instanceOf(commit, NodeGit.Commit); - // It's important to note that we will be rebasing the sub-repos on top of - // commits identified in the meta-repo, not those from their upstream - // branches. Main steps: - - // 1. Check to see if meta-repo is up-to-date; if it is we can exit. - // 2. Start the rebase operation in the meta-repo. - // 3. When we encounter a conflict with a submodules, this indicates that - // we need to perform a rebase on that submodule as well. This - // operation is complicated by the need to sync meta-repo commits with - // commits to the submodules, which may or may not be one-to-one. - - let currentBranchName = "HEAD"; - try { - const currentBranch = yield metaRepo.getCurrentBranch(); - currentBranchName = currentBranch.name(); - } - catch (e) { +exports.continue = co.wrap(function *(repo) { + assert.instanceOf(repo, NodeGit.Repository); + + const status = yield StatusUtil.getRepoStatus(repo); + const seq = status.sequencerState; + ensureRebaseInProgress(seq); + + if (status.isConflicted()) { + throw new UserError("Cannot continue rebase due to conflicts."); } - const currentBranch = yield metaRepo.getBranch(currentBranchName); - const fromCommitId = currentBranch.target(); - const fromCommit = yield metaRepo.getCommit(fromCommitId); - const commitId = commit.id(); + const currentSha = seq.commits[seq.currentCommit]; + const currentCommit = yield repo.getCommit(currentSha); + const index = yield repo.index(); - // First, see if 'commit' already exists in the current history. If so, we - // can exit immediately. + // First, continue in-progress rebases in the submodules and generate a + // commit for the curren operation. + + const continueResult = yield SubmoduleRebaseUtil.continueSubmodules( + repo, + index, + status, + currentCommit); + const result = { + metaCommits: {}, + newCommits: continueResult.newCommits, + submoduleCommits: continueResult.commits, + errorMessage: continueResult.errorMessage, + }; + if (null !== continueResult.metaCommit) { + result.metaCommits[continueResult.metaCommit] = + seq.commits[seq.currentCommit]; + } + if (null !== result.errorMessage) { + // Stop if there was a problem finishing the current operation. - if (yield GitUtil.isUpToDate(metaRepo, - fromCommitId.tostrS(), - commitId.tostrS())) { - console.log(`${colors.green(currentBranch.shorthand())} is \ -up-to-date.`); - return { - metaCommits: {}, - submoduleCommits: {}, - }; + return result; // RETURN } - const initialize = co.wrap(function *() { - const fromAnnotedCommit = - yield NodeGit.AnnotatedCommit.fromRef(metaRepo, currentBranch); - const ontoAnnotatedCommit = - yield NodeGit.AnnotatedCommit.lookup(metaRepo, commitId); - return yield SubmoduleUtil.cacheSubmodules(metaRepo, - co.wrap(function *() { - const rebase = yield NodeGit.Rebase.init(metaRepo, - fromAnnotedCommit, - ontoAnnotatedCommit, - null, - null); - console.log(`Rewinding to ${colors.green(commitId.tostrS())}.`); - yield callNext(rebase); - return { - rebase: rebase, - submoduleCommits: {}, - }; - })); + // Then, call back to `runRebase` to complete any remaining commits. + + const nextSeq = seq.copy({ + currentCommit: seq.currentCommit + 1, }); - return yield driveRebase(metaRepo, initialize, fromCommit, commit); + const nextResult = yield exports.runRebase(repo, nextSeq); + Object.assign(result.metaCommits, nextResult.metaCommits); + accumulateRebaseResult(result, nextResult); + return result; }); /** - * Abort the rebase in progress on the specified `repo` and all open - * submodules, returning them to their previous heads and checking them out. - * The behavior is undefined unless the specified `repo` has a rebase in - * progress. + * From the specified `repo`, return a list of non-merge commits that are part + * of the history of `from` but not of `onto` (inclusive of `from`), in + * depth-first order from left-to right. Note that this will include commits + * that could be fast-forwarded; if you need to do something else when `onto` + * can be fast-forwarded from `from`, you must check beforehand. * * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} from + * @param {NodeGit.Commit} onto + * @return [String] */ -exports.abort = co.wrap(function *(repo) { +exports.listRebaseCommits = co.wrap(function *(repo, from, onto) { assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(from, NodeGit.Commit); + assert.instanceOf(onto, NodeGit.Commit); - yield SubmoduleUtil.cacheSubmodules(repo, co.wrap(function*() { - const rebase = yield NodeGit.Rebase.open(repo); - rebase.abort(); - })); + const ontoSha = onto.id().tostrS(); + const seen = new Set([ontoSha]); // shas that stop traversal + const result = []; + const todo = []; // { sha: String | null, parents: [Commit]} - const head = yield repo.head(); - console.log(`Set HEAD back to ${colors.green(head.target().tostrS())}.`); + // We proceed as follows: + // + // 1. Each item in the `todo` list represents a child commit with + // zero or more parents left to process. + // 2. If the list of parents is empty in the last element of `todo`, + // record the sha of the child commit of this element into `result` + // (unless it was null, which would indicate a skipped merge commit). + // 3. Otherwise, pop the last parent off and "enqueue" it onto the todo + // list. + // 4. The `enqueue` function will skip any commits that have been + // previously seen, or that are in the history of `onto`. + // 5. We start things off by enqueuing `from`. + // + // Note that (2) ensures that all parents of a commit are added to `result` + // (where appropriate) before the commit itself, and (3) that a commit and + // all of its ancestors are processed before any of its siblings. - // This is a little "heavy-handed'. TODO: abort active rebases in only - // those open submodueles whose rebases are associated with the one in the - // meta-repo. It's possible (though unlikely) that the user could have an - // independent rebase going in an open submodules. + const enqueue = co.wrap(function *(commit) { + const sha = commit.id().tostrS(); - const openSubs = yield SubmoduleUtil.listOpenSubmodules(repo); - yield openSubs.map(co.wrap(function *(name) { - const subRepo = yield SubmoduleUtil.getRepo(repo, name); - if (!subRepo.isRebasing()) { + // If we've seen a commit already, do not process it or any of its + // children. Otherwise, record that we've seen it. + + if (seen.has(sha)) { return; // RETURN } - const rebaseInfo = yield RebaseFileUtil.readRebase(subRepo.path()); - const subRebase = yield NodeGit.Rebase.open(subRepo); - subRebase.abort(); - console.log(`Submodule ${colors.blue(name)}: reset to \ -${colors.green(rebaseInfo.originalHead)}.`); - })); -}); + seen.add(sha); -/** - * Continue the rebase in progress on the specified `repo`. - * - * @param {NodeGit.Repository} repo - */ -exports.continue = co.wrap(function *(repo) { - assert.instanceOf(repo, NodeGit.Repository); + // Skip this commit if it's an ancestor of `onto`. - const rebaseInfo = yield RebaseFileUtil.readRebase(repo.path()); - if (null === rebaseInfo) { - throw new UserError("Error: no rebase in progress"); - } - const fromCommit = yield repo.getCommit(rebaseInfo.originalHead); - const ontoCommit = yield repo.getCommit(rebaseInfo.onto); + const inHistory = yield NodeGit.Graph.descendantOf(repo, ontoSha, sha); + if (inHistory) { + return; // RETURN + } + const parents = yield commit.getParents(); - let errorMessage = ""; + // Record a null as the `sha` if this was a merge commit so that we + // know not to add it to `result` after processing its parents. We + // work from the back, so reverse the parents to get left first. - const initializer = co.wrap(function *(metaRepo) { - console.log(`Continuing rebase from \ -${colors.green(rebaseInfo.originalHead)} onto \ -${colors.green(rebaseInfo.onto)}.`); - const rebase = yield SubmoduleUtil.cacheSubmodules(repo, () => { - return NodeGit.Rebase.open(repo); + todo.push({ + sha: 1 >= parents.length ? sha : null, + parents: parents.reverse(), }); - const index = yield repo.index(); - const subs = yield SubmoduleUtil.listOpenSubmodules(metaRepo); - const subCommits = {}; - for (let i = 0; i !== subs.length; ++i) { - const name = subs[i]; - const subRepo = yield SubmoduleUtil.getRepo(metaRepo, name); - - // If this submodule isn't rebasing, continue to the next one. - - if (!subRepo.isRebasing()) { - yield index.addByPath(name); - continue; // CONTINUE - } - yield index.addByPath(name); - - const subInfo = yield RebaseFileUtil.readRebase(repo.path()); - console.log(`Submodule ${colors.blue(name)} continuing \ -rebase from ${colors.green(subInfo.originalHead)} onto \ -${colors.green(subInfo.onto)}.`); - const rebase = yield NodeGit.Rebase.open(subRepo); - const idx = rebase.operationCurrent(); - const op = rebase.operationByIndex(idx); - const result = yield processSubmoduleRebase(subRepo, - name, - rebase, - op); - subCommits[name] = result.commits; - if (null !== result.error) { - errorMessage += result.error + "\n"; - } - else { - yield index.addByPath(name); - yield index.conflictRemove(name); + }); + + yield enqueue(from); // Kick it off with the first, `from`, commit. + + while (0 !== todo.length) { + const back = todo[todo.length - 1]; + const parents = back.parents; + if (0 === parents.length) { + // If nothing to do for last item, pop it off, record the child sha + // in the result list if non-null (indicating non-merge), and move + // on. + + if (null !== back.sha) { + result.push(back.sha); } + todo.pop(); + } else { + // Otherwise, pop off the last parent and attempt to enqueue it. + + const next = parents.pop(); + yield enqueue(next); } - if ("" !== errorMessage) { - throw new UserError(errorMessage); - } - return { - rebase: rebase, - submoduleCommits: subCommits, - }; - }); - return yield driveRebase(repo, initializer, fromCommit, ontoCommit); + } + return result; }); diff --git a/node/lib/util/repo_ast.js b/node/lib/util/repo_ast.js index ee0e97fe2..f58739d0a 100644 --- a/node/lib/util/repo_ast.js +++ b/node/lib/util/repo_ast.js @@ -37,7 +37,63 @@ const assert = require("chai").assert; const deeper = require("deeper"); const deepCopy = require("deepcopy"); -const Rebase = require("./rebase"); +const Rebase = require("./rebase"); +const SequencerState = require("./sequencer_state"); + +/** + * @class {File} + * + * This class represents a file in a repository. + */ +class File { + /** + * Create a `File` object having the specified `contents` and + * `isExecutable` bit. + * + * @constructor + * @param {String} contents + * @param {Bool} isExecutable + */ + constructor(contents, isExecutable) { + assert.isString(contents); + assert.isBoolean(isExecutable); + this.d_contents = contents; + this.d_isExecutable = isExecutable; + Object.freeze(this); + } + + /** + * @property {String} contents the contents of the file + */ + get contents() { + return this.d_contents; + } + + /** + * @property {Bool} isExecutable true if the file is executable + */ + get isExecutable() { + return this.d_isExecutable; + } + + /** + * Return true if this object represents the same value as the specified + * `rhs` and false otherwise. Two `File` objects represent the same + * value if they have the same `contents` and `isExecutable` properties. + * + * @param {File} rhs + * @return {Bool} + */ + equal(rhs) { + return this.d_contents === rhs.d_contents && + this.d_isExecutable === rhs.d_isExecutable; + } +} + +File.prototype.toString = function () { + return `File(content=${this.d_contents}, \ +isExecutable=${this.d_isExecutable})`; +}; /** * @class {Branch} @@ -114,12 +170,98 @@ class Submodule { get sha() { return this.d_sha; } + + /** + * Return true if the specified `rhs` represents the same value as this + * `Submodule` object and false otherwise. Two `Submodule` objects + * represent the same value if they have the same `url` and `sha`. + * + * @param {Submodule} rhs + * @return {Bool} + */ + equal(rhs) { + assert.instanceOf(rhs, Submodule); + return this.d_url === rhs.d_url && this.d_sha === rhs.d_sha; + } } Submodule.prototype.toString = function () { return `Submodule(url=${this.d_url}, sha=${this.d_sha || ""})`; }; +/** + * @class Conflict + * + * This class is used to represent a file that is conflicted in the index. + */ +class Conflict { + + /** + * Create a new `Conflict` having the specified `ancestor`, `our`, and + * `their` file content. Null content indicates a deletion. + * + * @constructor + * @param {String|Submodule|null} ancestor + * @param {String|Submodule|null} our + * @param {String||Submodule|null} their + */ + constructor(ancestor, our, their) { + assert(null === ancestor || + ancestor instanceof File || + ancestor instanceof Submodule, ancestor); + assert(null === our || + our instanceof File || + our instanceof Submodule, our); + assert(null === their || + their instanceof File || + their instanceof Submodule, their); + this.d_ancestor = ancestor; + this.d_our = our; + this.d_their = their; + } + + /** + * @property {String|Submodule|null} ancestor + * the content of the file in the common ancestor commit + */ + get ancestor() { + return this.d_ancestor; + } + + /** + * @property {String|Submodule|null} our + * the content of the file in the "current" commit being integrated into + */ + get our() { + return this.d_our; + } + + /** + * @property {String|Submodule|null} their + * the content of the file in the commit being integrated + */ + get their() { + return this.d_their; + } + + /** + * Return true if the specified `rhs` represents the same value as this + * `Conflict` and false otherwise. Two `Conflict` objects represent the + * same value if the have the same `ancestor`, `our`, and `their`. + * + * @param {Conflict} rhs + * @return {Bool} + */ + equal(rhs) { + assert.instanceOf(rhs, Conflict); + return deeper(this, rhs); + } +} + +Conflict.prototype.toString = function () { + return `Conflict(ancestor=${this.d_ancestor}, our=${this.d_our} \ +their=${this.d_their})`; +}; /** * This module provides the RepoAST class and associated classes. Supported @@ -159,12 +301,12 @@ class Commit { if ("changes" in args) { assert.isObject(args.changes); for (let path in args.changes) { - const content = args.changes[path]; - - if (null !== content && !(content instanceof Submodule)) { - assert.isString(content, path); - } - this.d_changes[path] = content; + const file = args.changes[path]; + assert(file === null || + file instanceof File || + file instanceof Submodule, + `commit change at ${path} has invalid content ${file}`); + this.d_changes[path] = file; } } this.d_message = ""; @@ -290,20 +432,23 @@ class AST { * - If provided, the `onto` and `originalHead` commits in `rebase` exist * in `commits`. * - if 'bare', `index` and `workdir` are empty, and `rebase` is null + * - any conflicted path in the index has a value specified in the workdir * - * @param {Object} args - * @param {Object} [args.commits] - * @param {Object} [args.branches] - * @param {Object} [args.refs] - * @param {String|null} [args.head] - * @param {Boolean} [args.bare] - * @param {String|null} [args.currentBranchName] - * @param {Object} [args.remotes] - * @param {Object} [args.index] - * @param {Object} [args.workdir] - * @param {Object} [args.notes] - * @param {Object} [args.openSubmodules] - * @param {Rebase} [args.Rebase] + * @param {Object} args + * @param {Object} [args.commits] + * @param {Object} [args.branches] + * @param {Object} [args.refs] + * @param {String|null} [args.head] + * @param {Boolean} [args.bare] + * @param {String|null} [args.currentBranchName] + * @param {Object} [args.remotes] + * @param {Object} [args.index] + * @param {Object} [args.workdir] + * @param {Object} [args.notes] + * @param {Object} [args.openSubmodules] + * @param {Rebase} [args.rebase] + * @param {SequencerState} [args.sequencerState] + * @param {Boolean} [args.sparse] */ constructor(args) { if (undefined === args) { @@ -469,6 +614,21 @@ in commit ${id}.`); } } + this.d_sequencerState = null; + if ("sequencerState" in args) { + const sequencerState = args.sequencerState; + if (null !== sequencerState) { + assert.instanceOf(sequencerState, SequencerState); + assert.isFalse(this.d_bare); + checkAndTraverse(sequencerState.originalHead.sha, + "original head of sequencer"); + checkAndTraverse(sequencerState.target.sha, + "target commit of sequencer"); + sequencerState.commits.forEach( + sha => checkAndTraverse(sha, "sequencer commit")); + this.d_sequencerState = sequencerState; + } + } // Validate that all commits have been reached. @@ -476,22 +636,6 @@ in commit ${id}.`); assert(seen.has(key), `Commit '${key}' is not reachable.`); } - // Copy and validate index changes. - - this.d_index = {}; - if ("index" in args) { - const index = args.index; - assert.isObject(index); - for (let path in index) { - assert.isFalse(this.d_bare); - const change = index[path]; - if (!(change instanceof Submodule) && null !== change) { - assert.isString(change); - } - this.d_index[path] = change; - } - } - // Copy and validate notes changes. this.d_notes = {}; @@ -517,16 +661,36 @@ in commit ${id}.`); const workdir = args.workdir; assert.isObject(workdir); for (let path in workdir) { - assert.isNotNull(this.d_head); assert.isFalse(this.d_bare); const change = workdir[path]; if (null !== change) { - assert.isString(change); + assert.instanceOf(change, + File, + `workdir change at ${path}`); } this.d_workdir[path] = change; } } + // Copy and validate index changes. + + this.d_index = {}; + if ("index" in args) { + const index = args.index; + assert.isObject(index); + for (let path in index) { + assert.isFalse(this.d_bare); + const change = index[path]; + assert(null === change || + change instanceof File || + change instanceof Submodule || + change instanceof Conflict, + `Invalid value in index for ${path} -- ${change}`); + this.d_index[path] = change; + } + } + + // Copy and validate open submodules. Each open submodule must be an // instance of `AST` and there must be a `Submodule` defined in the // current index for that path. @@ -549,6 +713,13 @@ in commit ${id}.`); } } } + + this.d_sparse = false; + if ("sparse" in args) { + this.d_sparse = args.sparse; + assert.isBoolean(this.d_sparse); + } + Object.freeze(this); } @@ -640,6 +811,20 @@ in commit ${id}.`); return this.d_rebase; } + /** + * @property {SequencerState} null unless a sequence operation is ongoing + */ + get sequencerState() { + return this.d_sequencerState; + } + + /** + * @property {Boolean} true if repo is sparse and false otherwise + */ + get sparse() { + return this.d_sparse; + } + /** * Accumulate the specified `changes` into the specified `dest` map. A * non-null value in `changes` overrides any existing value in `dest`; a @@ -699,7 +884,10 @@ in commit ${id}.`); openSubmodules: ("openSubmodules" in args) ? args.openSubmodules : this.d_openSubmodules, rebase: ("rebase" in args) ? args.rebase : this.d_rebase, + sequencerState: ("sequencerState" in args) ? + args.sequencerState: this.d_sequencerState, bare: ("bare" in args) ? args.bare : this.d_bare, + sparse: ("sparse" in args) ? args.sparse : this.d_sparse, }); } @@ -764,16 +952,26 @@ in commit ${id}.`); assert.isObject(commitMap); assert.isString(commitId); assert.isObject(indexChanges); - let cache = {}; - let fromCommit = AST.renderCommit(cache, commitMap, commitId); - AST.accumulateDirChanges(fromCommit, indexChanges); + const cache = {}; + const fromCommit = AST.renderCommit(cache, commitMap, commitId); + const filteredIndex = {}; + for (let key in indexChanges) { + const value = indexChanges[key]; + if (!(value instanceof Conflict)) { + filteredIndex[key] = value; + } + } + AST.accumulateDirChanges(fromCommit, filteredIndex); return fromCommit; } } AST.Branch = Branch; AST.Commit = Commit; +AST.Conflict = Conflict; +AST.File = File; AST.Rebase = Rebase; AST.Remote = Remote; +AST.SequencerState = SequencerState; AST.Submodule = Submodule; module.exports = AST; diff --git a/node/lib/util/repo_ast_test_util.js b/node/lib/util/repo_ast_test_util.js index 21345e3ef..9d38757b0 100644 --- a/node/lib/util/repo_ast_test_util.js +++ b/node/lib/util/repo_ast_test_util.js @@ -215,6 +215,7 @@ exports.createMultiRepos = co.wrap(function *(input) { * @param {Object} options.expectedTransformer.mapping.reverseCommitMap * @param {Object} options.expectedTransformer.mapping.reverseUrlMap * @param {Object} options.expectedTransformer.return + * @param {Boolean} options.ignoreRefsCommits */ exports.testMultiRepoManipulator = co.wrap(function *(input, expected, manipulator, shouldFail, options) { @@ -245,6 +246,7 @@ exports.testMultiRepoManipulator = else { assert.isFunction(options.actualTransformer); } + const includeRefsCommits = options.includeRefsCommits || false; const inputASTs = createMultiRepoASTMap(input); // Write the repos in their initial states. @@ -282,9 +284,11 @@ exports.testMultiRepoManipulator = // Copy over and verify (that they are not duplicates) remapped commits and // urls output by the manipulator. + let manipulatorRemap = {}; if (undefined !== manipulated) { if ("commitMap" in manipulated) { + manipulatorRemap = manipulated.commitMap; assert.isObject(manipulated.commitMap, "manipulator must return object"); for (let commit in manipulated.commitMap) { @@ -292,7 +296,7 @@ exports.testMultiRepoManipulator = commitMap, commit, `commit already mapped to ${commitMap[commit]}.`); - const newVal = manipulated.commitMap[commit]; + const newVal = manipulatorRemap[commit]; commitMap[commit] = newVal; mappings.reverseCommitMap[newVal] = commit; } @@ -325,6 +329,9 @@ exports.testMultiRepoManipulator = // Read in the states of the repos. + const seen = new Set(); + function rememberCommit(sha) { seen.add(sha); } + let actualASTs = {}; for (let repoName in expectedASTs) { let repo; @@ -339,15 +346,71 @@ exports.testMultiRepoManipulator = const path = manipulated.urlMap[repoName]; repo = yield NodeGit.Repository.open(path); } - const newAST = yield ReadRepoASTUtil.readRAST(repo); + const newAST = yield ReadRepoASTUtil.readRAST(repo, includeRefsCommits); + const commits = RepoASTUtil.listCommits(newAST); + Object.keys(commits).forEach(rememberCommit); actualASTs[repoName] = RepoASTUtil.mapCommitsAndUrls(newAST, commitMap, urlMap); } + // Make sure we didn't get garbage in the remap set. + + for (let sha in manipulatorRemap) { + assert(seen.has(sha), + `Remap for unseen commit ${sha} to ${manipulatorRemap[sha]}`); + } + // Allow mapping of actual ASTs. actualASTs = options.actualTransformer(actualASTs, mappings); RepoASTUtil.assertEqualRepoMaps(actualASTs, expectedASTs); }); + +function addNewCommit(result, commitMap, newCommit, oldCommit, suffix) { + assert.property(commitMap, oldCommit); + const oldLogicalCommit = commitMap[oldCommit]; + result[newCommit] = oldLogicalCommit + suffix; +} + +/** + * Populate the specified `result` with translation of the specified `commits` + * using the specified `commitMap` such that each sha becomes ${logical + * sha}${suffix}. + * + * @param {Object} result output, new physical to new logical sha + * @param {Object} commits from new physical to old physical sha + * @param {Object} commitMap from old physical to old logical sha + * @param {String} suffix + */ +exports.mapCommits = function (result, commits, commitMap, suffix) { + assert.isObject(result); + assert.isObject(commits); + assert.isObject(commitMap); + assert.isString(suffix); + + Object.keys(commits).forEach(newCommit => { + addNewCommit(result, commitMap, newCommit, commits[newCommit], suffix); + }); +}; + +/** + * Populate the specified `result` with translations of the specified + * `subCommits` based on the specified `commitMap` such that each submodule + * commit becomes ${logical id}${sub name}. + * + * @param {Object} result output, new physical to new logical sha + * @param {Object} subCommits from sub name to map from new to old sha + * @param {Object} commitMap from old physical to old logical sha + */ +exports.mapSubCommits = function (result, subCommits, commitMap) { + assert.isObject(result); + assert.isObject(subCommits); + assert.isObject(commitMap); + + Object.keys(subCommits).forEach(subName => { + const commits = subCommits[subName]; + exports.mapCommits(result, commits, commitMap, subName); + }); +}; diff --git a/node/lib/util/repo_ast_util.js b/node/lib/util/repo_ast_util.js index 7d7d2449f..a330aca2f 100644 --- a/node/lib/util/repo_ast_util.js +++ b/node/lib/util/repo_ast_util.js @@ -40,6 +40,10 @@ const colors = require("colors"); const deeper = require("deeper"); const RepoAST = require("../util/repo_ast"); +const TextUtil = require("../util/text_util"); + +const Sequencer = RepoAST.SequencerState; +const CommitAndRef = Sequencer.CommitAndRef; // Begin module-local methods @@ -131,12 +135,26 @@ function diffChanges(actual, expected) { function compare(path) { const actualChange = actual[path]; const expectedChange = expected[path]; - let different = actualChange !== expectedChange; - if (different && - actualChange instanceof RepoAST.Submodule && - expectedChange instanceof RepoAST.Submodule) { - different = actualChange.url !== expectedChange.url || - actualChange.sha !== expectedChange.sha; + let different; + if (actualChange instanceof RepoAST.File && + expectedChange instanceof RepoAST.File) { + if (expectedChange.contents.startsWith("^")) { + const exp = expectedChange.contents.substr(1); + const matcher = new RegExp(exp); + const match = matcher.exec(actualChange.contents); + different = null === match || + actualChange.isExecutable !== expectedChange.isExecutable; + } else { + different = !actualChange.equal(expectedChange); + } + } else if (actualChange instanceof RepoAST.Submodule && + expectedChange instanceof RepoAST.Submodule) { + different = !actualChange.equal(expectedChange); + } else if (actualChange instanceof RepoAST.Conflict && + expectedChange instanceof RepoAST.Conflict) { + different = !actualChange.equal(expectedChange); + } else { + different = actualChange !== null || expectedChange !== null; } if (different) { result.push(`\ @@ -195,15 +213,15 @@ expected url to be ${colorExp(expected.url)} but got ${colorAct(actual.url)}` ); } function missingActual(branch) { - result.push(`missing branch ${colorBad(branch)}`); + result.push(`missing remote branch ${colorBad(branch)}`); } function missingExpected(branch) { - result.push(`unexpected branch ${colorBad(branch)}`); + result.push(`unexpected remote branch ${colorBad(branch)}`); } function compare(branch) { if (actual.branches[branch] !== expected.branches[branch]) { result.push(`\ -for branch ${colorBad(branch)} expected \ +for remote branch ${colorBad(branch)} expected \ ${colorExp(expected.branches[branch])} but got \ ${colorAct(actual.branches[branch])}` ); @@ -227,7 +245,6 @@ ${colorAct(actual.branches[branch])}` */ function diffASTs(actual, expected) { let result = []; - const indent = "".repeat(4); // First, check the commits @@ -241,9 +258,7 @@ function diffASTs(actual, expected) { const diffs = diffCommits(actual.commits[id], expected.commits[id]); if (0 !== diffs.length) { result.push(`for commit ${colorBad(id)}`); - diffs.forEach(diff => { - result.push(indent + diff); - }); + result.push(...diffs.map((d) => TextUtil.indent(d))); } } diffObjects(actual.commits, @@ -265,9 +280,7 @@ function diffASTs(actual, expected) { expected.remotes[remote]); if (0 !== diffs.length) { result.push(`for remote ${colorBad(remote)}`); - diffs.forEach(diff => { - result.push(indent + diff); - }); + result.push(...diffs.map((d) => TextUtil.indent(d))); } } diffObjects(actual.remotes, @@ -408,9 +421,7 @@ ${colorExp(expected.currentBranchName)}` const indexChanges = diffChanges(actual.index, expected.index); if (0 !== indexChanges.length) { result.push(`In ${colorBad("index")}`); - indexChanges.forEach(diff => { - result.push(indent + diff); - }); + result.push(...indexChanges.map((d) => TextUtil.indent(d))); } // Then, check the working directory. @@ -418,9 +429,7 @@ ${colorExp(expected.currentBranchName)}` const workdirChanges = diffChanges(actual.workdir, expected.workdir); if (0 !== workdirChanges.length) { result.push(`In ${colorBad("workdir")}`); - workdirChanges.forEach(diff => { - result.push(indent + diff); - }); + result.push(...workdirChanges.map((d) => TextUtil.indent(d))); } // Check open submodules @@ -436,9 +445,7 @@ ${colorExp(expected.currentBranchName)}` expected.openSubmodules[name]); if (0 !== diffs.length) { result.push(`for open submodule ${colorBad(name)}`); - diffs.forEach(diff => { - result.push(indent + diff); - }); + result.push(...diffs.map((d) => TextUtil.indent(d))); } } diffObjects(actual.openSubmodules, @@ -472,6 +479,33 @@ ${colorExp(expected.rebase.onto)} but got ${colorAct(actual.rebase.onto)}.`); } } + // Check sequencer + + if (null === actual.sequencerState && null !== expected.sequencerState) { + result.push("Missing sequencer."); + } + else if (null !== actual.sequencerState && + null === expected.sequencerState) { + result.push("Unexpected sequencer."); + } + else if (null !== actual.sequencerState && + !actual.sequencerState.equal(expected.sequencerState)) { + result.push(`\ +Expected sequencer to be ${actual.sequencerState} but got \ +${expected.sequencerState}`); + } + + // Check sparse + + if (actual.sparse !== expected.sparse) { + if (expected.sparse) { + result.push(`Expected repository to be sparse.`); + } + else { + result.push(`Expected repository not to be sparse.`); + } + } + return result; } @@ -537,7 +571,6 @@ exports.assertEqualRepoMaps = function (actual, expected, message) { assert.isString(message); } let result = []; - const indent = "".repeat(4); // First, check the commits @@ -553,9 +586,7 @@ exports.assertEqualRepoMaps = function (actual, expected, message) { const diffs = diffASTs(actualRepo, expectedRepo); if (0 !== diffs.length) { result.push(`for repo ${colorBad(name)}`); - diffs.forEach(diff => { - result.push(indent + diff); - }); + result.push(...diffs.map((d) => TextUtil.indent(d))); } } diffObjects(actual, @@ -575,7 +606,8 @@ exports.assertEqualRepoMaps = function (actual, expected, message) { * except that each commit ID is replaced with the value that it maps to in the * specified `commitMap`. And each remote url that exists in the specified * `urlMap` is replaced by the value in that map. URLs and commits missing - * from the maps are kept as-is. + * from the maps are kept as-is. The behavior is undefined if any commits are + * unmapped. * * @param {RepoAST} ast * @param {Object} commitMap string to string @@ -588,10 +620,8 @@ exports.mapCommitsAndUrls = function (ast, commitMap, urlMap) { assert.isObject(urlMap); function mapCommitId(commitId) { - if (commitId in commitMap) { - return commitMap[commitId]; - } - return commitId; + assert.property(commitMap, commitId); + return commitMap[commitId]; } function mapSubmodule(submodule) { @@ -600,18 +630,29 @@ exports.mapCommitsAndUrls = function (ast, commitMap, urlMap) { url = urlMap[url]; } let sha = submodule.sha; - if (null !== sha && (sha in commitMap)) { - sha = commitMap[sha]; + if (null !== sha) { + sha = mapCommitId(sha); } return new RepoAST.Submodule(url, sha); } + function mapData(data) { + if (data instanceof RepoAST.Submodule) { + return mapSubmodule(data); + } + return data; + } + function mapChanges(input) { let changes = {}; for (let path in input) { let change = input[path]; - if (change instanceof RepoAST.Submodule) { - change = mapSubmodule(change); + if (change instanceof RepoAST.Conflict) { + change = new RepoAST.Conflict(mapData(change.ancestor), + mapData(change.our), + mapData(change.their)); + } else { + change = mapData(change); } changes[path] = change; } @@ -628,6 +669,10 @@ exports.mapCommitsAndUrls = function (ast, commitMap, urlMap) { }); } + function mapCommitAndRef(car) { + return new CommitAndRef(mapCommitId(car.sha), car.ref); + } + // Copy and transform commit map. Have to transform the key (commit id) // and the commits themselves which also contain commit ids. @@ -697,6 +742,21 @@ exports.mapCommitsAndUrls = function (ast, commitMap, urlMap) { mapCommitId(rebase.onto)); } + let sequencer = ast.sequencerState; + if (null !== sequencer) { + const original = mapCommitAndRef(sequencer.originalHead); + const target = mapCommitAndRef(sequencer.target); + const commits = sequencer.commits.map(mapCommitId); + sequencer = new Sequencer({ + type: sequencer.type, + originalHead: original, + target: target, + commits: commits, + currentCommit: sequencer.currentCommit, + message: sequencer.message, + }); + } + return ast.copy({ commits: commits, branches: branches, @@ -709,6 +769,7 @@ exports.mapCommitsAndUrls = function (ast, commitMap, urlMap) { workdir: mapChanges(ast.workdir), openSubmodules: openSubmodules, rebase: rebase, + sequencerState: sequencer, }); }; @@ -796,3 +857,22 @@ exports.cloneRepo = function (original, url) { currentBranchName: original.currentBranchName, }); }; + +/** + * Return all commits in the specified `repo`. + * TODO: independent test + * + * @param {RepoAST} repo + * @return {Object} sha to `RepoAST.Commit` + */ +exports.listCommits = function (repo) { + assert.instanceOf(repo, RepoAST); + const commits = repo.commits; + + // Also, commits from open submodules. + + for (let subName in repo.openSubmodules) { + Object.assign(commits, repo.openSubmodules[subName].commits); + } + return commits; +}; diff --git a/node/lib/util/repo_status.js b/node/lib/util/repo_status.js index fe54eae84..f3e40f6e8 100644 --- a/node/lib/util/repo_status.js +++ b/node/lib/util/repo_status.js @@ -32,12 +32,62 @@ const assert = require("chai").assert; const Rebase = require("./rebase"); +const SequencerState = require("./sequencer_state"); /** * This modules defines the type `RepoStatus`, used to describe modifications * to a repo. */ +/** + * @class Conflict + * describes a conflict in a file or submodule + */ +class Conflict { + /** + * Create a new `Conflict` object having the specified `ancestorMode`, + * `ourMode`, and `theirMode` modes. + * + * @param {Number|null} ancestorMode + * @param {Number|null} ourMode + * @param {Number|null} theirMode + */ + constructor(ancestorMode, ourMode, theirMode) { + if (null !== ancestorMode) { + assert.isNumber(ancestorMode); + } + if (null !== ourMode) { + assert.isNumber(ourMode); + } + if (null !== theirMode) { + assert.isNumber(theirMode); + } + + this.d_ancestorMode = ancestorMode; + this.d_ourMode = ourMode; + this.d_theirMode = theirMode; + Object.freeze(this); + } + + /** + * @property {Number} ancestorMode file mode of the ancestor part + */ + get ancestorMode() { + return this.d_ancestorMode; + } + + /** + * @property {Number} ourMode file mode of our part of conflict + */ + get ourMode() { + return this.d_ourMode; + } + + get theirMode() { + return this.d_theirMode; + } +} + /** * @enum {RepoStatus.STAGE} * indicates the meaning of an entry in the index @@ -46,6 +96,7 @@ const Rebase = require("./rebase"); // the nodegit or libgit2 documentation. const STAGE = { NORMAL: 0, // normally staged file + ANCESTOR: 1, OURS : 2, // our side of a stage THEIRS: 3, // their side of a stage }; @@ -59,7 +110,6 @@ const FILESTATUS = { MODIFIED: 0, ADDED: 1, REMOVED: 2, - CONFLICTED: 3, RENAMED: 4, TYPECHANGED: 5 }; @@ -315,14 +365,15 @@ class Submodule { } /** - * Return true if this submodule can be committed, either being not new, or - * having some staged changes or commits. + * Return true if this submodule can be committed, because it is both not + * new and has some staged changes or commits. * * @return {Boolean} */ isCommittable () { return !(this.isNew() && - null === this.d_index.sha && + (null === this.d_index || + null === this.d_index.sha) && (null === this.d_workdir || null === this.d_workdir.status.headCommit && this.d_workdir.status.isIndexClean())); @@ -384,29 +435,19 @@ class Submodule { */ class RepoStatus { - /** - * Return the `STAGE` for the specified `flags`. - * - * @static - * @param {Number} flags - * @return {STAGE} - */ - static getStage(flags) { - const GIT_IDXENTRY_STAGESHIFT = 12; - return flags >> GIT_IDXENTRY_STAGESHIFT; - } - /** * Create a new status object having the specified properties. * @constructor * - * @param {Object} [args] - * @param {String} [args.currentBranchName] - * @param {String} [args.headCommit] - * @param {Object} [args.staged] map from name to `FILESTATUS` - * @param {Object} [args.submodules] map from name to `Submodule` - * @param {Object} [args.workdir] map from name to `FILESTATUS` - * @param {Rebase} [args.rebase] rebase, if one is in progress + * @param {Object} [args] + * @param {String} [args.currentBranchName] + * @param {String} [args.headCommit] + * @param {Object} [args.staged] map from name to `FILESTATUS` + * @param {Object} [args.submodules] map from name to `Submodule` + * @param {Object} [args.workdir] map from name to `FILESTATUS` + * @param {Rebase} [args.rebase] rebase, if one is in progress + * @param {SequencerState} + * [args.sequencerState] state of sequencer */ constructor(args) { if (undefined === args) { @@ -421,6 +462,7 @@ class RepoStatus { this.d_workdir = {}; this.d_submodules = {}; this.d_rebase = null; + this.d_sequencerState = null; if ("currentBranchName" in args) { if (null !== args.currentBranchName) { @@ -439,7 +481,9 @@ class RepoStatus { assert.isObject(staged); for (let name in staged) { const value = staged[name]; - assert.isNumber(value); + if ("number" !== typeof(value)) { + assert.instanceOf(value, Conflict); + } this.d_staged[name] = value; } } @@ -463,6 +507,14 @@ class RepoStatus { } this.d_rebase = rebase; } + + if ("sequencerState" in args) { + const sequencerState = args.sequencerState; + if (null !== sequencerState) { + assert.instanceOf(sequencerState, SequencerState); + } + this.d_sequencerState = sequencerState; + } Object.freeze(this); } @@ -566,6 +618,27 @@ class RepoStatus { }); } + /** + * Return true if there are any conflicts in the repository represented by + * this object and false otherwise. + * + * @return {Bool} + */ + isConflicted() { + for (let name in this.d_staged) { + if (this.staged[name] instanceof Conflict) { + return true; // RETURN + } + } + for (let name in this.d_submodules) { + const sub = this.d_submodules[name]; + if (null !== sub.workdir && sub.workdir.status.isConflicted()) { + return true; // RETURN + } + } + return false; + } + // PROPERTIES /** @@ -607,12 +680,18 @@ class RepoStatus { } /** - * @property {Rebase} rebase if non-null, state of in-progress rebase + * @property {rebase} rebase if non-null, state of in-progress rebase */ get rebase() { return this.d_rebase; } + /** + * @property {SequencerState} sequencerState if ~null, state of sequencer + */ + get sequencerState() { + return this.d_sequencerState; + } /** * Return a new `RepoStatus` object having the same value as this one, but * with replacing properties defined in the specified `args`. @@ -637,11 +716,14 @@ class RepoStatus { args.submodules: this.d_submodules, workdir: ("workdir" in args) ? args.workdir : this.d_workdir, rebase: ("rebase" in args) ? args.rebase : this.d_rebase, + sequencerState: ("sequencerState" in args) ? + args.sequencerState : this.d_sequencerState, }); } } module.exports = RepoStatus; +RepoStatus.Conflict = Conflict; RepoStatus.STAGE = STAGE; RepoStatus.FILESTATUS = FILESTATUS; RepoStatus.Submodule = Submodule; diff --git a/node/lib/util/reset.js b/node/lib/util/reset.js index 120b43235..a559be6b2 100644 --- a/node/lib/util/reset.js +++ b/node/lib/util/reset.js @@ -32,18 +32,26 @@ const assert = require("chai").assert; const co = require("co"); +const fs = require("fs-promise"); +const mkdirp = require("mkdirp"); const NodeGit = require("nodegit"); +const path = require("path"); -const GitUtil = require("./git_util"); -const StatusUtil = require("./status_util"); -const SubmoduleFetcher = require("./submodule_fetcher"); -const SubmoduleUtil = require("./submodule_util"); -const UserError = require("./user_error"); +const DoWorkQueue = require("./do_work_queue"); +const GitUtil = require("./git_util"); +const Open = require("./open"); +const SparseCheckoutUtil = require("./sparse_checkout_util"); +const StatusUtil = require("./status_util"); +const SubmoduleConfigUtil = require("./submodule_config_util"); +const SubmoduleFetcher = require("./submodule_fetcher"); +const SubmoduleUtil = require("./submodule_util"); +const UserError = require("./user_error"); const TYPE = { - SOFT: "soft", - MIXED: "mixed", - HARD: "hard", + SOFT: "SOFT", + MIXED: "MIXED", + HARD: "HARD", + MERGE: "MERGE", }; Object.freeze(TYPE); exports.TYPE = TYPE; @@ -54,14 +62,96 @@ exports.TYPE = TYPE; * @return {NodeGit.Reset.TYPE} */ function getType(type) { + assert.property(TYPE, type); switch (type) { case TYPE.SOFT : return NodeGit.Reset.TYPE.SOFT; case TYPE.MIXED: return NodeGit.Reset.TYPE.MIXED; case TYPE.HARD : return NodeGit.Reset.TYPE.HARD; + + // TODO: real implementation of `reset --merge`. For now, this behaves + // just like `HARD` except that we ignore the check for modified open + // submodules. + + case TYPE.MERGE: return NodeGit.Reset.TYPE.HARD; } - assert(false, `Bad type: ${type}`); } +/** + * Reset the specified `repo` of having specified `index` to have the contents + * of the tree of the specified `commit`. Update the `.gitmodules` file in the + * worktree to haave the same contents as in the index. If the repo is not in + * sparse mode, create empty directories for added submodules and remove the + * directories of deleted submodules as indicated in the specified `changes`. + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Index} index + * @param {NodeGit.Commit} commit + * @param {Object} changes from path to `SubmoduleChange` + * @param {Boolean} mixed do not change the working tree + */ +exports.resetMetaRepo = co.wrap(function *(repo, index, commit, changes, + mixed) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(index, NodeGit.Index); + assert.instanceOf(commit, NodeGit.Commit); + assert.isObject(changes); + + const tree = yield commit.getTree(); + yield index.readTree(tree); + + if (mixed) { + return; + } + + // Render modules file + + const modulesFileName = SubmoduleConfigUtil.modulesFileName; + const modulesPath = path.join(repo.workdir(), modulesFileName); + const modulesEntry = index.getByPath(modulesFileName); + if (undefined !== modulesEntry) { + const oid = modulesEntry.id; + const blob = yield repo.getBlob(oid); + const data = blob.toString(); + yield fs.writeFile(modulesPath, data); + } else { + // If it's not in the index, remove it. + try { + yield fs.unlink(modulesPath); + } catch (e) { + if ("ENOENT" !== e.code) { + throw e; + } + } + } + + // Tidy up directories when not in sparse mode. We don't need to do this + // when in sparse mode because in sparse mode we have a directory for a + // submodule iff it's open. Thus, when a new submodule comes into + // existence we do not need to make a directory for it. When a submodule + // is deleted and it's not open, there's no directory to clean up; when it + // is open, we don't want to remove the directory anyway -- this is a + // behavior that libgit2 gets wrong. + + if (!(yield SparseCheckoutUtil.inSparseMode(repo))) { + const tidySub = co.wrap(function *(name) { + const change = changes[name]; + if (null === change.oldSha) { + mkdirp.sync(path.join(repo.workdir(), name)); + } else if (null === change.newSha) { + try { + yield fs.rmdir(path.join(repo.workdir(), name)); + } catch (e) { + // If we can't remove the directory, it's OK. Git warns + // here, but I think that would just be noise as most of + // the time this happens when someone rebases a change that + // created a submodule. + } + } + }); + yield DoWorkQueue.doInParallel(Object.keys(changes), tidySub); + } +}); + /** * Change the `HEAD` commit to the specified `commit` in the specified `repo`, * unstaging any staged changes. Reset all open submodule in the same way to @@ -78,77 +168,115 @@ exports.reset = co.wrap(function *(repo, commit, type) { assert.instanceOf(repo, NodeGit.Repository); assert.instanceOf(commit, NodeGit.Commit); assert.isString(type); + assert.property(TYPE, type); + + const head = yield repo.getHeadCommit(); + const changedSubs = yield SubmoduleUtil.getSubmoduleChanges(repo, + commit, + head, + false); + + // Prep the opener to open submodules on HEAD; otherwise, our resets will + // be noops. + + const opener = new Open.Opener(repo, null); + const fetcher = yield opener.fetcher(); + const openSubsSet = yield opener.getOpenSubs(); + + yield GitUtil.updateHead(repo, commit, `reset`); + const index = yield repo.index(); + + // With a soft reset we don't need to do anything to the meta-repo. We're + // not going to touch the index or the `.gitmodules` file. + + if (TYPE.SOFT !== type) { + yield exports.resetMetaRepo(repo, index, commit, changedSubs, + TYPE.MIXED === type); + } const resetType = getType(type); - // First, reset the meta-repo. + const removedSubmodules = []; - yield SubmoduleUtil.cacheSubmodules(repo, () => { - return NodeGit.Reset.reset(repo, commit, resetType); - }); + const resetSubmodule = co.wrap(function *(name) { + const change = changedSubs[name]; - // Then, all open subs. + // Nothing to do if the change was an addition or deletion. - const openNames = yield SubmoduleUtil.listOpenSubmodules(repo); - const index = yield repo.index(); - const shas = yield SubmoduleUtil.getCurrentSubmoduleShas(index, openNames); - const fetcher = new SubmoduleFetcher(repo, commit); + if (undefined !== change && + (null === change.oldSha || null === change.newSha)) { + return; // RETURN + } + + // When doing a hard or merge reset, we don't need to open closed + // submodules because we would be throwing away the changes anyway. + + if ((TYPE.HARD === type || TYPE.MERGE === type) && + !openSubsSet.has(name)) { + return; // RETURN + } - yield openNames.map(co.wrap(function *(name, index) { - const sha = shas[index]; - const subRepo = yield SubmoduleUtil.getRepo(repo, name); + // Open the submodule and fetch the sha of the commit to which we're + // resetting in case we don't have it. - // Fetch the sha in case we don't already have it. + const subRepo = + yield opener.getSubrepo(name, + Open.SUB_OPEN_OPTION.FORCE_OPEN); - yield fetcher.fetchSha(subRepo, name, sha); + let subCommitSha; + + if (undefined === change) { + // If there's no change, use what's been configured in the index. + + const entry = index.getByPath(name); + // It is possible that this submodule exists in + // .gitmodules but not in the index. Probably this is + // because it is newly-created, but not yet git-added. + if (undefined === entry) { + removedSubmodules.push(name); + return; + } + + subCommitSha = entry.id.tostrS(); + } else { + subCommitSha = change.newSha; + } + + yield fetcher.fetchSha(subRepo, name, subCommitSha); + const subCommit = yield subRepo.getCommit(subCommitSha); + + // We've already put the meta-repo index on the right commit; read it + // and reset to it. - const subCommit = yield subRepo.getCommit(sha); yield NodeGit.Reset.reset(subRepo, subCommit, resetType); - })); -}); -/** - * Helper method for `resolvePaths` to simplify use of `cacheSubmodules`. - */ -const resetPathsHelper = co.wrap(function *(repo, commit, resolvedPaths) { - // Get a `Status` object reflecting only the values in `paths`. + // Set the index to have the commit to which we just set the submodule; + // otherwise, Git will see a staged change and worktree modifications + // for the submodule. - const status = yield StatusUtil.getRepoStatus(repo, { - showMetaChanges: true, - paths: resolvedPaths, + yield index.addByPath(name); }); - // Reset the meta-repo. + // Make a list of submodules to reset, including all that have been changed + // between HEAD and 'commit', and all that are open. + const openSubs = Array.from(openSubsSet); + const changedSubNames = Object.keys(changedSubs); + const subsToTry = Array.from(new Set(changedSubNames.concat(openSubs))); + yield DoWorkQueue.doInParallel(subsToTry, resetSubmodule); - const metaStaged = Object.keys(status.staged); - if (0 !== metaStaged.length) { - yield NodeGit.Reset.default(repo, commit, metaStaged); + // remove added submodules from .gitmodules + const modules = yield SubmoduleConfigUtil.getSubmodulesFromIndex(repo, + index); + for (const file of removedSubmodules) { + delete modules[file]; } - const subs = status.submodules; - const fetcher = new SubmoduleFetcher(repo, commit); - const subNames = Object.keys(subs); - const shas = - yield SubmoduleUtil.getSubmoduleShasForCommit(repo, subNames, commit); + yield SubmoduleConfigUtil.writeUrls(repo, index, modules, + type === TYPE.MIXED); - yield subNames.map(co.wrap(function *(subName) { - const sub = subs[subName]; - const workdir = sub.workdir; - const sha = shas[subName]; - - // If the submodule isn't open (no workdir) or didn't exist on `commit` - // (i.e., it had no sha there), skip it. + // Write the index in case we've had to stage submodule changes. - if (null !== workdir && undefined !== sha) { - const subRepo = yield SubmoduleUtil.getRepo(repo, subName); - yield fetcher.fetchSha(subRepo, subName, sha); - const subCommit = yield subRepo.getCommit(sha); - const staged = Object.keys(workdir.status.staged); - if (0 !== staged.length) { - yield NodeGit.Reset.default(subRepo, subCommit, staged); - } - } - })); + yield SparseCheckoutUtil.setSparseBitsAndWriteIndex(repo, index); }); /** @@ -179,10 +307,37 @@ exports.resetPaths = co.wrap(function *(repo, cwd, commit, paths) { throw new UserError("Cannot reset files to a commit that is not HEAD"); } - const resolvedPaths = yield paths.map(filename => { + const resolvedPaths = paths.map(filename => { return GitUtil.resolveRelativePath(repo.workdir(), cwd, filename); }); - yield SubmoduleUtil.cacheSubmodules(repo, () => { - return resetPathsHelper(repo, commit, resolvedPaths); + + const status = yield StatusUtil.getRepoStatus(repo, { + paths: resolvedPaths, }); + + const subs = status.submodules; + const fetcher = new SubmoduleFetcher(repo, commit); + const subNames = Object.keys(subs); + const shas = + yield SubmoduleUtil.getSubmoduleShasForCommit(repo, subNames, commit); + + yield subNames.map(co.wrap(function *(subName) { + const sub = subs[subName]; + const workdir = sub.workdir; + const sha = shas[subName]; + + // If the submodule isn't open (no workdir) or didn't exist on `commit` + // (i.e., it had no sha there), skip it. + + if (null !== workdir && undefined !== sha) { + const subRepo = yield SubmoduleUtil.getRepo(repo, subName); + yield fetcher.fetchSha(subRepo, subName, sha); + const subCommit = yield subRepo.getCommit(sha); + const staged = Object.keys(workdir.status.staged); + if (0 !== staged.length) { + yield NodeGit.Reset.default(subRepo, subCommit, staged); + } + } + })); + }); diff --git a/node/lib/util/rm.js b/node/lib/util/rm.js new file mode 100644 index 000000000..b27860337 --- /dev/null +++ b/node/lib/util/rm.js @@ -0,0 +1,498 @@ +/* + * Copyright (c) 2018, 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 binarySearch = require("binary-search"); +const co = require("co"); +const fs = require("fs-promise"); +const groupBy = require("group-by"); +const path = require("path"); +const NodeGit = require("nodegit"); + +const CloseUtil = require("./close_util"); +const SparseCheckoutUtil = require("./sparse_checkout_util"); +const SubmoduleUtil = require("./submodule_util"); +const SubmoduleConfigUtil = require("./submodule_config_util"); +const TextUtil = require("./text_util"); +const UserError = require("./user_error"); + +const FILEMODE = NodeGit.TreeEntry.FILEMODE; +const STATUS = NodeGit.Status.STATUS; + +function needToRequestRecursive(path) { + throw new UserError( + `not removing '${path}' recursively without -r`); +} + +function pluralizeHas(n) { + return n === 1 ? "has" : "have"; +} + +const errorsByCause = { + unstaged: { + problem: "local modifications", + solution: "use --cached to keep the file, or -f to force removal" + }, + staged: { + problem: "changes staged in the index", + solution: "use -f to force removal" + }, + stagedAndUnstaged: { + problem: "staged content different from both the file and the HEAD", + solution: "use -f to force removal" + } +}; + +function problemMessage(badFiles, cause, entityType) { + const error = errorsByCause[cause]; + assert.isDefined(error); + return `the following ${TextUtil.pluralize(entityType, badFiles.length)} \ +${pluralizeHas(badFiles.length)} ${error.problem}: +${TextUtil.listToIndentedString(badFiles)} +(${error.solution})`; +} + +/** + * @class Problem + * describes a problem which prevents removal of an object + */ +class Problem { + constructor(path, cause, entityType) { + if (undefined === entityType) { + entityType = "file"; + } + + this.d_path = path; + this.d_cause = cause; + this.d_entityType = entityType; + Object.freeze(this); + } + get path() { + return this.d_path; + } + get cause() { + return this.d_cause; + } + get entityType() { + return this.d_entityType; + } +} + +const checkIndexIsHead = co.wrap(function*(headTree, indexId, entryPath) { + if (headTree === null) { + return false; + } + else { + let inRepo; + try { + inRepo = yield headTree.entryByPath(entryPath); + } catch (e) { + return false; + } + if (!indexId.equal(inRepo.id())) { + return false; + } + } + return true; +}); + +/** + * Return null if check index == HEAD || index == workdir. Else, + * return a Problem + */ +const checkIndexIsHeadOrWorkdir = co.wrap(function*(repo, headTree, entry, + entryPath, displayPath) { + const inIndex = entry.id; + + const indexIsHead = yield checkIndexIsHead(headTree, inIndex, entryPath); + if (indexIsHead) { + return null; + } + const filePath = path.join(repo.workdir(), entryPath); + try { + yield fs.access(filePath); + } catch (e) { + assert.equal("ENOENT", e.code); + //a file that doesn't exist is considered OK for rm --cached, too + return null; + } + + if (entry.mode === FILEMODE.COMMIT) { + // this is a submodule so we don't check the working tree + return new Problem(displayPath, "staged", "submodule"); + } + + const index = yield repo.index(); + const diff = yield NodeGit.Diff.indexToWorkdir(repo, index, + {"pathspec" : [entryPath]}); + if (diff.numDeltas() === 0) { + return null; + } + else { + return new Problem(displayPath, "unstaged", "file"); + } +}); + +function setDefault(options, arg, def) { + if (undefined === options[arg]) { + options[arg] = def; + } + else { + assert.isBoolean(options[arg]); + } +} + +/** + * Check that a file is clean enough to delete, according to Git's + * rules. An already-deleted file is always clean. If --cached + * is supplied, index may match either HEAD or the worktree; otherwise, + * only the worktree is permitted. For unclean files, return a Problem + * describing how the file is unclean. + */ +const checkCleanliness = co.wrap(function *(repo, headTree, index, pathname, + options) { + if (options.force) { + return null; + } + + let displayPath = pathname; + if (options.prefix !== undefined) { + displayPath = path.join(options.prefix, pathname); + } + const entry = index.getByPath(pathname); + + if (options.cached) { + return yield checkIndexIsHeadOrWorkdir(repo, headTree, entry, + pathname, displayPath); + } + let status; + try { + // Status.file throws errors after 0.22.0 + status = yield NodeGit.Status.file(repo, pathname); + } catch (err) { + return null; + } + + if (status === 0 || status & STATUS.WT_DELETED !== 0) { + //git considers these OK regardless + return null; + } + if ((status & STATUS.WT_MODIFIED) !== 0) { + if ((status & STATUS.INDEX_MODIFIED) !== 0) { + return new Problem(displayPath, "stagedAndUnstaged"); + } + else { + return new Problem(displayPath, "unstaged"); + } + } + else { + if ((status & STATUS.INDEX_MODIFIED) !== 0) { + return new Problem(displayPath, "staged"); + } + } + return null; +}); + +const deleteEmptyParents = co.wrap(function*(fullpath) { + const parent = path.dirname(fullpath); + try { + yield fs.rmdir(parent); + } catch (e) { + //We only want to remove empty parent dirs, but it's + //cheaper just to try to remove them and ignore errors + return; + } + yield deleteEmptyParents(parent); +}); + +function checkAllPathsResolved(paths, resolved) { + const allResolvedPaths = []; + for (const rootLevel of Object.keys(resolved)) { + const subPaths = resolved[rootLevel]; + if (subPaths.length === 0) { + allResolvedPaths.push(rootLevel); + } else { + for (const sub of subPaths) { + allResolvedPaths.push(path.join(rootLevel, sub)); + } + } + } + allResolvedPaths.sort(); + for (const spec of paths) { + let stripped = spec; + if (spec.endsWith("/")) { + stripped = spec.substring(0, spec.length - 1); + } + const idx = binarySearch(allResolvedPaths, stripped, TextUtil.strcmp); + if (idx < 0) { + //spec is a/b, next item is a/b/c, OK + const insertionPoint = -idx - 1; + const nextResolved = allResolvedPaths[insertionPoint]; + if (insertionPoint >= allResolvedPaths.length || + !nextResolved.startsWith(stripped + "/")) { + throw new UserError(`\ +pathspec '${spec}' did not match any files`); + } + } + } +} + +/** + * Remove the specified `paths` in the specified `repo`. If a path in + * `paths` refers to a file, remove it. If it refers to a directory, + * and recursive is true, remove it recursively. If it's false, throw + * a UserError. If a path to be removed does not exist in the index, + * throw a UserError. If a path to be removed is not clean, and force + * is false, throw a UserError. + * + * If --cached is supplied, the paths will be removed from the index + * but not from disk. + * + * If there are any dirty paths, the meta or submodule indexes may be + * dirty upon return from this function. Unfortunately due to a + * nodegit bug, we can't reload the index: + * https://github.com/nodegit/nodegit/issues/1478 + * + * @async + * @param {NodeGit.Repository} repo + * @param {String []} paths + * @param {Object} [options] + * @param {Boolean} [options.recursive] + * @param {Boolean} [options.cached] (remove from index not disk) + * @param {Boolean} [options.force] + * @param {String} [options.prefix] Path prefix for file lookup + */ +exports.rmPaths = co.wrap(function *(repo, paths, options) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isArray(paths); + if (undefined === options) { + options = {}; + } + else { + assert.isObject(options); + } + setDefault(options, "recursive", false); + setDefault(options, "cached", false); + setDefault(options, "force", false); + if (undefined === options.prefix) { + options.prefix = ""; + } + else { + assert.isString(options.prefix); + } + + for (const p of paths) { + if (p === "") { + throw new UserError("warning: empty strings as pathspecs are " + + "invalid. Please use . instead if you " + + "meant to match all paths."); + } + } + + const index = yield repo.index(); + const headCommit = yield repo.getHeadCommit(); + + const indexUrls = + yield SubmoduleConfigUtil.getSubmodulesFromIndex(repo, index); + const headUrls = + yield SubmoduleConfigUtil.getSubmodulesFromCommit(repo, headCommit); + + // In theory, it would be be possible for a sub to be in the + // workdir (that is, in an unstaged change to .gitmodules) as + // well, but since git rm generally doesn't deal with workdir + // changes, we ignore that possibility. + + // We might, at least for easy testing, want to handle changes to + // root-level files, so we add those files to the root level items + + const rootLevelItemSet = new Set(); + for (const submodules of [indexUrls, headUrls]) { + for (const sub of Object.keys(submodules)) { + rootLevelItemSet.add(sub); + } + } + + for (const entry of index.entries()) { + rootLevelItemSet.add(entry.path); + } + + const rootLevelItems = []; + rootLevelItems.push(...rootLevelItemSet); + const openArray = yield SubmoduleUtil.listOpenSubmodules(repo); + const openSubmoduleSet = new Set(openArray); + + // TODO: consider changing resolvePaths to support root-level items + // items, which would enable removal of checkAllPathsResolved + const resolved = yield SubmoduleUtil.resolvePaths(paths, + rootLevelItems, + openArray); + + checkAllPathsResolved(paths, resolved); + + let headTree = null; + if (headCommit !== null) { + const treeId = headCommit.treeId(); + headTree = yield repo.getTree(treeId); + } + + // Check that everything is clean enough to remove + const pathSet = new Set(paths); + const toRemove = []; + const problems = []; + const removedSubmodules = []; + const toRecurse = []; + for (const rootLevel of Object.keys(resolved)) { + const items = resolved[rootLevel]; + if (items.length === 0) { + if (!(options.recursive || pathSet.has(rootLevel))) { + return needToRequestRecursive([rootLevel]); + } + toRemove.push(rootLevel); + const problem = yield checkCleanliness(repo, headTree, index, + rootLevel, options); + if (problem !== null) { + problems.push(problem); + } + const entry = index.getByPath(rootLevel); + if (entry === undefined || entry.mode === FILEMODE.COMMIT) { + removedSubmodules.push(rootLevel); + } + } + else { + // recurse into submodule + const subRepo = yield SubmoduleUtil.getRepo(repo, rootLevel); + const subOptions = {}; + Object.assign(subOptions, options); + subOptions.prefix = rootLevel; + // We set dryRun = true here because we're just checking + // for clean. If there are no problems, we'll later + // go through toRecurse and do a full run. + subOptions.dryRun = true; + toRecurse.push({repo : subRepo, items : items, + options : subOptions}); + yield exports.rmPaths(subRepo, items, subOptions); + } + } + + //report any problems + if (problems.length !== 0) { + let msg = ""; + const byType = groupBy(problems, "entityType"); + + for (const type in byType) { + const byCause = groupBy(byType[type], "cause"); + for (const cause in byCause) { + msg += problemMessage(byCause[cause].map(x => x.path), + cause, type); + } + } + throw new UserError(msg); + } + + if (options.dryRun) { + return; + } + + // Now do the full run on submodules + for (const r of toRecurse) { + r.options.dryRun = false; + yield exports.rmPaths(r.repo, r.items, r.options); + } + + // This "if" is necessary due to + // https://github.com/nodegit/nodegit/issues/1487 + if (toRemove.length !== 0) { + yield index.removeAll(toRemove); + yield SparseCheckoutUtil.setSparseBitsAndWriteIndex(repo, index); + } + + // close to-be-deleted submodules + const submodulesToClose = []; + for (const submodule of removedSubmodules) { + if (openSubmoduleSet.has(submodule)) { + submodulesToClose.push(submodule); + } + } + + yield CloseUtil.close(repo, repo.workdir(), submodulesToClose, + options.force); + + + // Clean up the workdir + const root = repo.workdir(); + if (!options.cached) { + for (const file of toRemove) { + const fullpath = path.join(root, file); + let stat = null; + try { + stat = yield fs.stat(fullpath); + } catch (e) { + //it is possible that e doesn't exist on disk + assert.equal("ENOENT", e.code); + } + if (stat !== null) { + if (stat.isDirectory()) { + try { + yield fs.rmdir(fullpath); + } catch (e) { + if ("ENOTEMPTY" === e.code) { + // Repo still exists for some reason -- + // perhaps it was reported as deleted + // because it's not in the index, but it + // does exist on disk. For safety, do not + // delete it; this is a weird case. + console.error( + `Could not remove ${fullpath} -- it's not +empty. If you are sure it is not needed, you can remove it yourself.` + ); + } else { + throw e; + } + } + } else { + yield fs.unlink(fullpath); + } + } + yield deleteEmptyParents(fullpath); + } + } + + // And write any gitmodules files changes + const modules = yield SubmoduleConfigUtil.getSubmodulesFromIndex(repo, + index); + for (const file of removedSubmodules) { + delete modules[file]; + } + + yield SubmoduleConfigUtil.writeUrls(repo, index, modules, options.cached); + yield index.write(); +}); diff --git a/node/lib/util/sequencer_state.js b/node/lib/util/sequencer_state.js new file mode 100644 index 000000000..ed4e845b1 --- /dev/null +++ b/node/lib/util/sequencer_state.js @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2018, 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 deeper = require("deeper"); + +const TYPE = { + CHERRY_PICK: "CHERRY_PICK", + MERGE: "MERGE", + REBASE: "REBASE", +}; + +/** + * This module defines the `SequencerState` value-semantic type. + */ + +/** + * @class CommitAndRef + * + * This class describes a commit and optionally the ref it came from. + */ +class CommitAndRef { + /** + * Create a new `CommitAndRef` object. + * + * @param {String} sha + * @param {String|null} ref + */ + constructor(sha, ref) { + assert.isString(sha); + if (null !== ref) { + assert.isString(ref); + } + this.d_sha = sha; + this.d_ref = ref; + + Object.freeze(this); + } + + /** + * @property {String} sha the unique identifier for this commit + */ + get sha() { + return this.d_sha; + } + + /** + * @property {String|null} ref + * + * If the commit was referenced by a ref, this is its name. + */ + get ref() { + return this.d_ref; + } + + /** + * Return true if the specified `rhs` represents the same value as this + * object. Two `CommitAndRef` objects represet the same value if they have + * the same `sha` and `ref` properties. + * + * @param {CommitAndRef} rhs + * @return {Bool} + */ + equal(rhs) { + assert.instanceOf(rhs, CommitAndRef); + return this.d_sha === rhs.d_sha && this.d_ref === rhs.d_ref; + } +} + +CommitAndRef.prototype.toString = function () { + let result = `CommitAndRef(sha=${this.d_sha}`; + if (null !== this.d_ref) { + result += `, ref=${this.d_ref}`; + } + return result + ")"; +}; + +/** + * @class SequencerState + * + * This class represents the state of an in-progress sequence operation such as + * a merge, cherry-pick, or rebase. + */ +class SequencerState { + /** + * Create a new `SequencerState` object. The behavior is undefined unless + * `0 <= currentCommit` and `commits.length >= currentCommit`. If + * `commits.length === currentCommit`, there are no more commits left on + * which to operate. + * + * @param {Object} properties + * @param {TYPE} properties.type + * @param {CommitAndRef} properties.originalHead + * @param {CommitAndRef} properties.target + * @param {[String]} properties.commits + * @param {Number} properties.currentCommit + * @param {String|null} [properties.message] + */ + constructor(properties) { + assert.isString(properties.type); + assert.property(TYPE, properties.type); + assert.instanceOf(properties.originalHead, CommitAndRef); + assert.instanceOf(properties.target, CommitAndRef); + assert.isArray(properties.commits); + assert.isNumber(properties.currentCommit); + assert(0 <= properties.currentCommit); + assert(properties.commits.length >= properties.currentCommit); + + this.d_message = null; + if ("message" in properties) { + if (null !== properties.message) { + assert.isString(properties.message); + this.d_message = properties.message; + } + } + + this.d_type = properties.type; + this.d_originalHead = properties.originalHead; + this.d_target = properties.target; + this.d_commits = properties.commits; + this.d_currentCommit = properties.currentCommit; + + Object.freeze(this); + } + + /** + * @property {TYPE} the type of operation in progress + */ + get type() { + return this.d_type; + } + + /** + * @property {CommitAndRef} originalHead + * what HEAD pointed to when the operation started + */ + get originalHead() { + return this.d_originalHead; + } + + /** + * @property {CommitAndRef} target + * the commit that was the target of the operation + */ + get target() { + return this.d_target; + } + + /** + * @property {[String]} commits the sequence of commits to operate on + */ + get commits() { + return this.d_commits; + } + + /** + * @property {Number} currentCommit index of the current commit + */ + get currentCommit() { + return this.d_currentCommit; + } + + /** + * @property {String|null} message commit message to be used + */ + get message() { + return this.d_message; + } + + /** + * Return true if the specified `rhs` represents the same value as this + * `SequencerState` object and false otherwise. Two `SequencerState` + * objects represent the same value if they have the same `type`, + * `originalHead`, `target`, `commits`, and `currentCommit` properties. + * + * @param {SequencerState} rhs + * @return {Bool} + */ + equal(rhs) { + assert.instanceOf(rhs, SequencerState); + return this.d_type === rhs.d_type && + this.d_originalHead.equal(rhs.d_originalHead) && + this.d_target.equal(rhs.d_target) && + deeper(this.d_commits, rhs.d_commits) && + this.d_currentCommit === rhs.d_currentCommit && + this.d_message === rhs.d_message; + } + + /** + * Return a new `SequencerState` object having the same value as this + * object except where overriden by the fields in the optionally specified + * `properties`. + * + * @param {Object} [properties] + * @param {String} [type] + * @param {CommitAndRef} [originalHead] + * @param {CommitAndRef} [target] + * @param {Number} [currentCommit] + * @param {[String]} [commits] + * @param {String|null} [message] + * @return {SequencerState} + */ + copy(properties) { + if (undefined === properties) { + properties = {}; + } else { + assert.isObject(properties); + } + return new SequencerState({ + type: ("type" in properties) ? properties.type : this.d_type, + originalHead: ("originalHead" in properties) ? + properties.originalHead : this.d_originalHead, + target: ("target" in properties) ? + properties.target : this.d_target, + currentCommit: ("currentCommit" in properties) ? + properties.currentCommit : this.d_currentCommit, + commits: ("commits" in properties) ? + properties.commits : this.d_commits, + message: ("message" in properties) ? + properties.message : this.d_message, + }); + } + +} + +SequencerState.prototype.toString = function () { + return `\ +SequencerState(type=${this.d_type}, originalHead=${this.d_originalHead}, \ +target=${this.d_target}, commits=${JSON.stringify(this.d_commits)}, \ +currentCommit=${this.d_currentCommit}, msg=${this.d_message})`; +}; + +SequencerState.TYPE = TYPE; +SequencerState.CommitAndRef = CommitAndRef; + +module.exports = SequencerState; diff --git a/node/lib/util/sequencer_state_util.js b/node/lib/util/sequencer_state_util.js new file mode 100644 index 000000000..0f8c77c59 --- /dev/null +++ b/node/lib/util/sequencer_state_util.js @@ -0,0 +1,303 @@ +/* + * Copyright (c) 2018, 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 fs = require("fs-promise"); +const path = require("path"); +const rimraf = require("rimraf"); + +const SequencerState = require("./sequencer_state"); + +const CommitAndRef = SequencerState.CommitAndRef; + +/** + * This module contains methods for accessing, reading, and rendering + * `SequencerState` objects on disk. The disk structure is (note, all files + * are eol-terminated): + * + * .git/meta_sequencer/TYPE -- Single line containing the type, e.g. + * "REBASE". + * /ORIGINAL_HEAD -- SHA and optional ref name of what was + * -- on head. The first line is the SHA. + * If there is one line, there is no + * ref name. If there are two lines, + * the second is the ref name. + * /TARGET -- SHA and optional ref name of the + * -- commit to rebase onto, merge, etc. + * The format is same as ORIGINAL_HEAD. + * /COMMITS -- List of commits to rebase, + * cherry-pick, etc., one per line. + * /CURRENT_COMMIT -- Single line containing the index of + * current commit in COMMITS to operate + * on. + * /MESSAGE -- commit message file, if any + */ + +const SEQUENCER_DIR = "meta_sequencer"; +const TYPE_FILE = "TYPE"; +const ORIGINAL_HEAD_FILE = "ORIGINAL_HEAD"; +const TARGET_FILE = "TARGET"; +const COMMITS_FILE = "COMMITS"; +const CURRENT_COMMIT_FILE = "CURRENT_COMMIT"; +const MESSAGE_FILE = "MESSAGE"; + +/** + * Return the contents of the file in the sequencer directory from the + * specified `gitDir` having the specified `name`, or null if the file cannot + * be read. + * + * @param {String} gitDir + * @param {String} name + * @return {String|null} + */ +exports.readFile = co.wrap(function *(gitDir, name) { + assert.isString(gitDir); + assert.isString(name); + const filePath = path.join(gitDir, SEQUENCER_DIR, name); + try { + return yield fs.readFile(filePath, "utf8"); + } + catch (e) { + return null; + } +}); + +/** + * Read the `CommitAndRef` object from the specified `fileName` in the + * sequencer director in the specified `gitDir` if it exists, or null if it + * does not. The format + * + * @param {String} gitDir + * @param {String} fileName + * @return {CommitAndRef|null} + */ +exports.readCommitAndRef = co.wrap(function *(gitDir, fileName) { + assert.isString(gitDir); + assert.isString(fileName); + const content = yield exports.readFile(gitDir, fileName); + if (null !== content) { + const lines = content.split("\n"); + lines.pop(); + const numLines = lines.length; + if (1 === numLines || 2 === numLines) { + const ref = 2 === numLines ? lines[1] : null; + return new CommitAndRef(lines[0], ref); + } + } + return null; +}); + +/** + * Return the array of commit stored in the specified `gitDir`, or null if + * the file is missing or malformed. + * + * @param {String} gitDir + * @return {[String]|null} + */ +exports.readCommits = co.wrap(function *(gitDir) { + assert.isString(gitDir); + const content = yield exports.readFile(gitDir, COMMITS_FILE); + if (null !== content) { + const lines = content.split("\n"); + const nonEmpty = lines.filter(line => line.length > 0); + if (0 !== nonEmpty.length) { + return nonEmpty; + } + } + return null; +}); + +/** + * Return the index of the current commit stored in the specified `gitDir`, or + * null if the file is missing, malformed, or the index is out-of-range. It is + * out-of-range if it is >= the specified `numCommits`, which is the size of + * the array of commit operations stored in the sequencer in `gitDir`, e.g., if + * there is 1 commit operation, the only valid index is 0. + * + * @param {String} gitDir + * @param {Number} numCommits + * @return {Number} + */ +exports.readCurrentCommit = co.wrap(function *(gitDir, numCommits) { + assert.isString(gitDir); + assert.isNumber(numCommits); + const content = yield exports.readFile(gitDir, CURRENT_COMMIT_FILE); + if (null !== content) { + const lines = content.split("\n"); + if (0 !== lines.length) { + const index = Number.parseInt(lines[0]); + if (!Number.isNaN(index) && index < numCommits) { + return index; + } + } + } + return null; +}); + +/** + * Return the sequencer state if it exists in the specified `gitDir`, or null + * if it is missing or malformed. + * TODO: emit diagnostic when malformed? + * + * @param {String} gitDir + * @return {SequencerState|null} + */ +exports.readSequencerState = co.wrap(function *(gitDir) { + assert.isString(gitDir); + + const typeContent = yield exports.readFile(gitDir, TYPE_FILE); + if (null === typeContent) { + return null; // RETURN + } + const typeLines = typeContent.split("\n"); + if (2 !== typeLines.length) { + return null; // RETURN + } + const type = typeLines[0]; + if (!(type in SequencerState.TYPE)) { + return null; // RETURN + } + const original = yield exports.readCommitAndRef(gitDir, + ORIGINAL_HEAD_FILE); + if (null === original) { + return null; // RETURN + } + const target = yield exports.readCommitAndRef(gitDir, TARGET_FILE); + if (null === target) { + return null; // RETURN + } + const commits = yield exports.readCommits(gitDir); + if (null === commits) { + return null; // RETURN + } + const currentCommit = yield exports.readCurrentCommit(gitDir, + commits.length); + if (null === currentCommit) { + return null; // RETURN + } + const message = yield exports.readFile(gitDir, MESSAGE_FILE); + return new SequencerState({ + type: type, + originalHead: original, + target: target, + commits: commits, + currentCommit: currentCommit, + message: message, + }); +}); + +/** + * Remote the sequencer directory and all its content in the specified + * `gitDir`, or do nothing if this directory doesn't exist. + * + * @param {String} gitDir + */ +exports.cleanSequencerState = co.wrap(function *(gitDir) { + assert.isString(gitDir); + const root = path.join(gitDir, SEQUENCER_DIR); + const promise = new Promise(callback => { + return rimraf(root, {}, callback); + }); + yield promise; +}); + +const writeCommitAndRef = co.wrap(function *(dir, name, commitAndRef) { + const filePath = path.join(dir, name); + let content = commitAndRef.sha + "\n"; + if (null !== commitAndRef.ref) { + content += commitAndRef.ref + "\n"; + } + yield fs.writeFile(filePath, content); +}); + +/** + * Clear out any existing sequencer and write the specified `state` to the + * specified `gitDir`. + * + * @param {String} gitDir + * @param {SequencerState} state + */ +exports.writeSequencerState = co.wrap(function *(gitDir, state) { + assert.isString(gitDir); + assert.instanceOf(state, SequencerState); + + yield exports.cleanSequencerState(gitDir); + const root = path.join(gitDir, SEQUENCER_DIR); + yield fs.mkdir(root); + yield fs.writeFile(path.join(root, TYPE_FILE), state.type + "\n"); + yield writeCommitAndRef(root, ORIGINAL_HEAD_FILE, state.originalHead); + yield writeCommitAndRef(root, TARGET_FILE, state.target); + const commitsContent = state.commits.join("\n"); + yield fs.writeFile(path.join(root, COMMITS_FILE), commitsContent); + yield fs.writeFile(path.join(root, CURRENT_COMMIT_FILE), + "" + state.currentCommit); + if (null !== state.message) { + yield fs.writeFile(path.join(root, MESSAGE_FILE), state.message); + } +}); + +/** + * Return a new `SequencerState` object having the same value as the specified + * `sequencer`, but with each commit sha it contains replaced with the value it + * mapts to in the specified `commitMap`. The behavior is undefined unless + * every commit sha is mapped. + * + * @param {SequencerState} sequencer + * @param {Object} commitMap sha to sha + * @return {Sequencer} + */ +exports.mapCommits = function (sequencer, commitMap) { + assert.instanceOf(sequencer, SequencerState); + assert.isObject(commitMap); + + function map(sha) { + assert.property(commitMap, sha); + return commitMap[sha]; + } + + function mapCommitAndRef(old) { + return new CommitAndRef(map(old.sha), old.ref); + } + + const newOriginal = mapCommitAndRef(sequencer.originalHead); + const newTarget = mapCommitAndRef(sequencer.target); + const newCommits = sequencer.commits.map(map); + return new SequencerState({ + type: sequencer.type, + originalHead: newOriginal, + target: newTarget, + currentCommit: sequencer.currentCommit, + commits: newCommits, + message: sequencer.message, + }); +}; diff --git a/node/lib/util/shorthand_parser_util.js b/node/lib/util/shorthand_parser_util.js index a5e2f2dcd..c5799d3fb 100644 --- a/node/lib/util/shorthand_parser_util.js +++ b/node/lib/util/shorthand_parser_util.js @@ -31,9 +31,13 @@ "use strict"; const assert = require("chai").assert; + const RepoAST = require("../util/repo_ast"); const RepoASTUtil = require("../util/repo_ast_util"); +const File = RepoAST.File; +const SequencerState = RepoAST.SequencerState; + /** * @module {ShorthandParserUtil} * @@ -53,11 +57,11 @@ const RepoASTUtil = require("../util/repo_ast_util"); * * The shorthand syntax for describing a repository: * - * shorthand = [':'(';\s*')*] + * shorthand = ['%'] [':'(';\s*')*] * base repo type = 'S' | 'B' | ('C') | 'A' | 'N' * override = | | | | * | | | | - * | + * | | * head = 'H='| nothing means detached * nothing = * commit = + @@ -67,14 +71,21 @@ const RepoASTUtil = require("../util/repo_ast_util"); * current branch = '*=' * new commit = 'C'[message#]['-'(,)*] * [' '(',\s*'*)] - * change = path ['=' | "~" | ] + * change = path ['=' | "~" | ] | + * *path='*''*' + * conflict item = | * path = (|'/')+ * submodule = Surl:[] + * file = (['^'] | ['+']) * data = ('0-9'|'a-z'|'A-Z'|' ')* basically non-delimiter ascii * remote = R=[] * [' '=[](',\s*'=[])*] * note = N =message * rebase = E,, + * sequencer = Q[message'#']' '' ' + * ' '' '(',')* + * sequencer type = C | M | R + * commit and ref = commit':'[] * index = I [,]* * workdir = W [,]* * open submodule = 'O'[' '('!')*] @@ -90,7 +101,7 @@ const RepoASTUtil = require("../util/repo_ast_util"); * "s" with a url of "a" on commit `1` * - N -- for "null", a completely empty repo * - * Whitespace is skipped at the beginning of shorthanad, and also after some, + * Whitespace is skipped at the beginning of shorthand, and also after some, * but not all delimitors. We can't skip it in places where the space * character is itself a separator, such as after the parent commit ids. It is * skipped after separators for: @@ -120,10 +131,14 @@ const RepoASTUtil = require("../util/repo_ast_util"); * commit specifies removal of the branch from the remote in the base repo. * * A change generally indicates: - * - textual data + * - a file * - a submodule definition * - if the '=' is omitted, a deletion * - if `~` removes the change from the base, if an override + * - Use '+' at the beginning of the data to indicate an executable file. + * - When the text for the file in shorthand used to specify *expected* values + * starts with '^', the rest of the content will be treated as a regular + * expression when validating the actual contents. * * An Index override indicates changes staged in the repository index. * @@ -140,9 +155,19 @@ const RepoASTUtil = require("../util/repo_ast_util"); * testing of relative URLs, a relative URL in the form of "../foo" will be * translated into "foo" in a open submodule. * + * In some cases, it's difficult to specify the exact contents of a file. For + * example, a file with a conflict that contains the SHA of a commit. In such + * cases, you can specify a regex for the contents of a file by using "^" as + * the first character in the data, e.g. `^ab$` would match a file with a line + * ending in `ab`. + * + * If the repo type begins with a percent ('%') sign, it is configured to be + * sparse. + * * Examples: * * S -- same as RepoType.S + * %S -- A sparse repo of type 'S' * A33 -- Like S but the single commit is named 33 and * contains a file named 33 with the contents 33. * S:H= -- removes head, bare repo @@ -156,13 +181,15 @@ const RepoASTUtil = require("../util/repo_ast_util"); * be a submodule with a url 'baz' at commit '1' * S:Chello#2-1,3 -- a commit, "2", with two parents: "1" and "3", * and a message of "hello" + * S:C*#2-1,3 -- same as above, but ignore the commit message + * during validation * S:Rorigin=/foo.git -- 'S' repo with an origin of /foo.git * S:Rorigin=/foo.git master=1 -- same as above but with remote branch * -- named 'master' pointing to commit 1 * C/foo/bar:Bfoo=1 -- a clone of /foo/bar overriding branch foo to 1 * S:I foo=bar,x=y -- staged changes to 'foo' and 'x' * S:I foo=bar,x=y;W foo,q=z -- as above but `foo` is deleted in the - * workding directory and `q` has been added + * working directory and `q` has been added * with content set to `z`. * S:I foo=S/a:1;Ofoo -- A submodule added to the index at `foo` * -- that is open but no local changes @@ -171,6 +198,24 @@ const RepoASTUtil = require("../util/repo_ast_util"); * -- file `x` to be `y`. * S:Emaster,1,2 -- rebase in progress started on "master", * original head "1", onto commit "2" + * S:I *foo=a*b*c -- The file 'foo' is flagged in the index + * as conflicted, having a base content of 'a', + * "our" content as 'b', and "their" content as + * 'c'. + * S:I foo=+bar -- A file named foo with its executable bit set + * and the contents of "bar" has been staged. + * QR a:refs/heads/master q: 1 c,d,a + * -- A sequencer is in progress that is a + * -- rebase. When the rebase started, HEAD + * -- was on `master` pointing to commit "a". The + * -- commit being rebased to is "q", and it was + * -- not referenced through a ref name. The + * -- list of commits to be rebased are "c", "d", + * -- and "a", and we're currently on the + * -- second commit, at index 1: "d". + * Qmerge of 'foo'#M a:refs/heads/master q: 1 c,d,a + * -- similar to above but a merge with a saved + * -- commit message: "merge of 'foo'" * * Note that the "clone' type may not be used with single-repo ASTs, and the * url must map to the name of another repo. A cloned repository has the @@ -242,6 +287,22 @@ function findChar(str, c, begin, end) { return (-1 === index || end <= index) ? null : index; } +/** + * Return the submodule described by the specified `data`. + * @param {String} data + * @return {Submodule} + */ +function parseSubmodule(data) { + const end = data.length; + const urlEnd = findChar(data, ":", 0, end); + assert.isNotNull(urlEnd); + const commitIdBegin = urlEnd + 1; + const sha = (commitIdBegin === end) ? + null : + data.substr(commitIdBegin, end - commitIdBegin); + return new RepoAST.Submodule(data.substr(0, urlEnd), sha); +} + /** * Return the path change data described by the specified `commitData`. If * `commitData` begins with an `S` it is a submodule description; otherwise, it @@ -257,22 +318,28 @@ function parseChangeData(commitData) { } const end = commitData.length; if (0 === end || "S" !== commitData[0]) { - return commitData; // RETURN + let contents = commitData; + let isExecutable = false; + if (contents.startsWith("+")) { + isExecutable = true; + contents = contents.substr(1); + } + return new RepoAST.File(contents, isExecutable); // RETURN } - - // Must have room for 'S', ':', and at least one char for the url and - // commit id - - const urlBegin = 1; - const urlEnd = findChar(commitData, ":", urlBegin, end); - assert.isNotNull(urlEnd); - const commitIdBegin = urlEnd + 1; - const sha = (commitIdBegin === end) ? - null : - commitData.substr(commitIdBegin, end - commitIdBegin); - return new RepoAST.Submodule(commitData.substr(urlBegin, - urlEnd - urlBegin), - sha); + assert(1 !== commitData.length); + return parseSubmodule(commitData.substr(1)); +} +/** + * Return the path change data described by the specified `data`. If + * `data` begins with an `S` it is a submodule description; if it is empty, it + * is null; otherwise, it is just 'data'. + * @private + * @param {String} data + * @return {String|RepoAST.Submodule|null} + */ +function parseConflictData(data) { + const result = parseChangeData(data); + return result === undefined ? null : result; } /** @@ -295,6 +362,24 @@ function copyOverrides(dest, source) { } } +/** + * Return the `CommitAndRef` object encoded in the specified `str`. + * @param {String} str + * @return {CommitAndRef} + */ +exports.parseCommitAndRef = function (str) { + const parts = str.split(":"); + assert.equal(2, parts.length, `malformed commit and ref: ${str}`); + const ref = "" === parts[1] ? null : parts[1]; + return new SequencerState.CommitAndRef(parts[0], ref); +}; + +const sequencerTypes = { + C: SequencerState.TYPE.CHERRY_PICK, + M: SequencerState.TYPE.MERGE, + R: SequencerState.TYPE.REBASE, +}; + /** * Return the result of merging the data in the specified `baseAST` with data * returned from `parseRepoShorthandRaw` into an object suitable for passing to @@ -320,7 +405,9 @@ function prepareASTArguments(baseAST, rawRepo) { workdir: baseAST.workdir, openSubmodules: baseAST.openSubmodules, rebase: baseAST.rebase, + sequencerState: baseAST.sequencerState, bare: baseAST.bare, + sparse: baseAST.sparse, }; // Process HEAD. @@ -432,6 +519,14 @@ function prepareASTArguments(baseAST, rawRepo) { resultArgs.rebase = rawRepo.rebase; } + if ("sequencerState" in rawRepo) { + resultArgs.sequencerState = rawRepo.sequencerState; + } + + if ("sparse" in rawRepo) { + resultArgs.sparse = rawRepo.sparse; + } + return resultArgs; } @@ -509,6 +604,7 @@ function parseOverrides(shorthand, begin, end, delimiter) { let notes = {}; let openSubmodules = {}; let rebase; + let sequencer; /** * Parse a set of changes from the specified `begin` character to the @@ -516,6 +612,7 @@ function parseOverrides(shorthand, begin, end, delimiter) { * * @param {Number} begin * @param {Number} end + * @param {Bool} allowConflicts */ function parseChanges(begin, end) { let changes = {}; @@ -525,16 +622,29 @@ function parseOverrides(shorthand, begin, end, delimiter) { const assign = findChar(shorthand, "=", begin, currentEnd.begin); assert.notEqual(begin, assign, "no path"); let change = null; - let pathEnd = currentEnd.begin; + const pathEnd = assign || currentEnd.begin; + let path = shorthand.substr(begin, pathEnd - begin); if (null !== assign) { - pathEnd = assign; const dataBegin = assign + 1; - const rawChange = - shorthand.substr(dataBegin, - currentEnd.begin - dataBegin); - change = parseChangeData(rawChange); + const rawChange = shorthand.substr( + dataBegin, + currentEnd.begin - dataBegin); + + if (path.startsWith("*")) { + // Handle 'Conflict'. + + assert.notEqual(path.length, 1, "empty conflict path"); + path = path.substr(1); + const changes = rawChange.split("*"); + assert.equal(changes.length, 3, "missing conflict parts"); + const parts = changes.map(parseConflictData); + change = new RepoAST.Conflict(parts[0], + parts[1], + parts[2]); + } else { + change = parseChangeData(rawChange); + } } - const path = shorthand.substr(begin, pathEnd - begin); changes[path] = change; begin = currentEnd.end; } @@ -703,7 +813,7 @@ function parseOverrides(shorthand, begin, end, delimiter) { changes = parseChanges(parentsEnd + 1, end); } else { - changes[commitId] = commitId; + changes[commitId] = new File(commitId, false); } commits[commitId] = new RepoAST.Commit({ parents: parents, @@ -861,6 +971,53 @@ function parseOverrides(shorthand, begin, end, delimiter) { rebase = new RepoAST.Rebase(parts[0], parts[1], parts[2]); } + /** + * Parse the sequencer definition beginning at the specified `begin` and + * terminating at the specified `end`. + * + * @param {Number} begin + * @param {Number} end + */ + function parseSequencer(begin, end) { + if (begin === end) { + sequencer = null; + return; // RETURN + } + const sequencerDef = shorthand.substr(begin, end - begin); + const allParts = sequencerDef.split("#"); + let message = null; + let mainPart; + if (1 === allParts.length) { + mainPart = allParts[0]; + } else { + assert.equal(2, + allParts.length, + `Malformed sequencer ${sequencerDef}`); + message = allParts[0]; + mainPart = allParts[1]; + } + const parts = mainPart.split(" "); + assert.equal(parts.length, + 5, + `Wrong number of sequencer parts in ${sequencerDef}`); + assert.property(sequencerTypes, parts[0], "sequencer type"); + const type = sequencerTypes[parts[0]]; + const originalHead = exports.parseCommitAndRef(parts[1]); + const target = exports.parseCommitAndRef(parts[2]); + const currentCommit = Number.parseInt(parts[3]); + assert(!Number.isNaN(currentCommit), + `bad current commit: ${currentCommit}`); + const commits = parts[4].split(","); + assert.notEqual(0, commits.length, `Bad commits: ${parts[4]}`); + sequencer = new SequencerState({ + type: type, + originalHead: originalHead, + target: target, + currentCommit: currentCommit, + commits: commits, + message: message, + }); + } /** * Parse the override beginning at the specified `begin` and finishing at * the specified `end`. @@ -885,6 +1042,7 @@ function parseOverrides(shorthand, begin, end, delimiter) { case "W": return parseWorkdir; case "O": return parseOpenSubmodule; case "E": return parseRebase; + case "Q": return parseSequencer; default: assert.isNull(`Invalid override ${override}.`); break; @@ -926,6 +1084,9 @@ function parseOverrides(shorthand, begin, end, delimiter) { if (undefined !== rebase) { result.rebase = rebase; } + if (undefined !== sequencer) { + result.sequencerState = sequencer; + } return result; } @@ -952,17 +1113,31 @@ exports.parseRepoShorthandRaw = function (shorthand) { // Manually process the base repository type; there is only one and it must // be at the beginning. - let typeEnd = findChar(shorthand, ":", 0, shorthand.length) || + let sparse = false; + let typeBegin = 0; + + if ("%" === shorthand[0]) { + typeBegin = 1; + sparse = true; + } + + const typeEnd = findChar(shorthand, ":", typeBegin, shorthand.length) || shorthand.length; - let typeStr = shorthand[0]; - let typeData = - (typeEnd > 1) ? shorthand.substr(1, typeEnd - 1) : undefined; + const typeStr = shorthand[typeBegin]; + const dataBegin = typeBegin + 1; + const typeData = (typeEnd - typeBegin > 1) ? + shorthand.substr(dataBegin, typeEnd - dataBegin) : + undefined; - let result = { + const result = { type: typeStr, }; + if (sparse) { + result.sparse = sparse; + } + // If there is data after the base type description, recurse. let overridesBegin = typeEnd; @@ -980,7 +1155,6 @@ exports.parseRepoShorthandRaw = function (shorthand) { if (undefined !== typeData) { result.typeData = typeData; } - return result; }; @@ -1001,7 +1175,7 @@ function getBaseRepo(type, data) { if ("A" === type) { let commits = {}; let changes = {}; - changes[data] = data; + changes[data] = new File(data, false); commits[data] = new RepoAST.Commit({ changes: changes, message: `changed ${data}`, @@ -1100,10 +1274,11 @@ exports.parseMultiRepoShorthand = function (shorthand, existingRepos) { function addCommit(id, commit) { if (id in commits) { const oldCommit = commits[id]; + const msg = `different submodule commit for meta commit ${id}`; RepoASTUtil.assertEqualCommits( commit, oldCommit, - `diffferent duplicate for commit ${id}`); + msg); } else { commits[id] = commit; @@ -1181,6 +1356,11 @@ exports.parseMultiRepoShorthand = function (shorthand, existingRepos) { includeCommit(resultArgs.rebase.originalHead); includeCommit(resultArgs.rebase.onto); } + if (resultArgs.sequencerState) { + includeCommit(resultArgs.sequencerState.originalHead.sha); + includeCommit(resultArgs.sequencerState.target.sha); + resultArgs.sequencerState.commits.forEach(includeCommit); + } for (let remoteName in resultArgs.remotes) { const remote = resultArgs.remotes[remoteName]; for (let branch in remote.branches) { @@ -1365,7 +1545,7 @@ exports.RepoType = (() => { commits: { "1": new RepoAST.Commit({ changes: { - "README.md": "hello world" + "README.md": new File("hello world", false), }, message: "the first commit", }), @@ -1389,7 +1569,7 @@ exports.RepoType = (() => { commits: { "1": new RepoAST.Commit({ changes: { - "README.md": "hello world", + "README.md": new File("hello world", false), }, message: "the first commit", }), diff --git a/node/lib/util/sparse_checkout_util.js b/node/lib/util/sparse_checkout_util.js new file mode 100644 index 000000000..cae476417 --- /dev/null +++ b/node/lib/util/sparse_checkout_util.js @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2018, 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 fs = require("fs-promise"); +const NodeGit = require("nodegit"); +const path = require("path"); + +const ConfigUtil = require("./config_util"); + +/** + * This module contains methods for interacting with Git's sparse checkout + * facility, that is not supported by libgit2. + */ + +/** + * Return the path to the sparse checkout file for the specified `repo`. + * + * @param {NodeGit.Repository} repo + * @return {String} + */ +exports.getSparseCheckoutPath = function (repo) { + assert.instanceOf(repo, NodeGit.Repository); + + return path.join(repo.path(), "info", "sparse-checkout"); +}; + +/** + * Return true if the specified `repo` is in sparse mode and false otherwise. + * A repo is in sparse mode iff: `core.sparsecheckout` is true. + * + * @param {NodeGit.Repository} repo + * @return {Bool} + */ +exports.inSparseMode = co.wrap(function *(repo) { + assert.instanceOf(repo, NodeGit.Repository); + + return (yield ConfigUtil.configIsTrue(repo, "core.sparsecheckout")) || + false; +}); + +/** + * Configure the specified `repo` to be in sparse-checkout mode -- + * specifically, our sparse checkout mode where everything but `.gitmodules` is + * excluded. Note that this method is just for testing; to make it work in a + * real environment you'd also need to udpate the index entries and the + * sparse-checkout file for open submodules. + * + * @param {NodeGit.Repository} repo + */ +exports.setSparseMode = co.wrap(function *(repo) { + assert.instanceOf(repo, NodeGit.Repository); + + const config = yield repo.config(); + yield config.setString("core.sparsecheckout", "true"); + yield fs.writeFile(exports.getSparseCheckoutPath(repo), ".gitmodules\n"); +}); + +/** + * This bit is set in the `flagsExtended` field of a `NodeGit.Index.Entry` for + * paths that should be skipped due to sparse checkout. + */ +const SKIP_WORKTREE = 1 << 14; + +/** + * Return the contents of the `.git/info/sparse-checkout` file for the + * specified `repo`. + * + * @param {NodeGit.Repository} repo + * @return {String} + */ +exports.readSparseCheckout = function (repo) { + assert.instanceOf(repo, NodeGit.Repository); + const filePath = exports.getSparseCheckoutPath(repo); + try { + return fs.readFileSync(filePath, "utf8"); + } catch (e) { + if ("ENOENT" !== e.code) { + throw e; + } + return ""; + } +}; + +/** + * Add the specified `filename` to the set of files visible in the sparse + * checkout in the specified `repo`. + * + * @param {NodeGit.Repository} repo + * @param {String} filename + */ +exports.addToSparseCheckoutFile = co.wrap(function *(repo, filename) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isString(filename); + + const sparsePath = exports.getSparseCheckoutPath(repo); + yield fs.appendFile(sparsePath, filename + "\n"); +}); + +/** + * Remove the specified `filenames` from the set of files visible in the sparse + * checkout in the specified `repo`. + * + * @param {NodeGit.Repository} repo + * @param {String[]} filenames + */ +exports.removeFromSparseCheckoutFile = function (repo, filenames) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isArray(filenames); + const sparseFile = exports.readSparseCheckout(repo); + const toRemoveSet = new Set(filenames); + const newContent = sparseFile.split("\n").filter( + name => !toRemoveSet.has(name)); + fs.writeFileSync(exports.getSparseCheckoutPath(repo), + newContent.join("\n")); +}; + +/** + * Write out the specified `index` for the specified meta-repo, `repo`, set the + * index flags to the correct values based on the contents of + * `.git/info/sparse-checkout`, which libgit2 does not do. + * + * TODO: independent test + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Index} index + * @param {String|undefined} path if set, where to write the index + */ +exports.setSparseBitsAndWriteIndex = co.wrap(function *(repo, index, + path = undefined) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(index, NodeGit.Index); + + // If we're in sparse mode, manually set bits to skip the worktree since + // libgit2 will not. + + if (yield exports.inSparseMode(repo)) { + const sparseCheckout = exports.readSparseCheckout(repo); + const sparseSet = new Set(sparseCheckout.split("\n")); + const NORMAL = 0; + for (const e of index.entries()) { + if (NORMAL === NodeGit.Index.entryStage(e)) { + if (sparseSet.has(e.path)) { + e.flagsExtended &= ~SKIP_WORKTREE; + } else { + e.flagsExtended |= SKIP_WORKTREE; + } + yield index.add(e); + } + } + } + // This is a horrible hack that we need because nodegit doesn't + // have a way to write the index to anywhere other than the + // location from whence it was opened. + if (path !== undefined) { + const indexPath = repo.path() + "index"; + yield fs.copy(indexPath, path); + const newIndex = yield NodeGit.Index.open(path); + yield newIndex.removeAll(); + for (const e of index.entries()) { + yield newIndex.add(e); + } + index = newIndex; + } + yield index.write(); +}); diff --git a/node/lib/util/stash_util.js b/node/lib/util/stash_util.js index 26e1fc1ee..ba5e75eb8 100644 --- a/node/lib/util/stash_util.js +++ b/node/lib/util/stash_util.js @@ -33,16 +33,32 @@ const assert = require("chai").assert; const co = require("co"); const colors = require("colors"); +const fs = require("fs-promise"); const NodeGit = require("nodegit"); -const GitUtil = require("./git_util"); -const Open = require("./open"); -const PrintStatusUtil = require("./print_status_util"); -const RepoStatus = require("./repo_status"); -const StatusUtil = require("./status_util"); -const SubmoduleUtil = require("./submodule_util"); -const TreeUtil = require("./tree_util"); -const UserError = require("./user_error"); +const CloseUtil = require("./close_util"); +const ConfigUtil = require("./config_util"); +const DiffUtil = require("./diff_util"); +const GitUtil = require("./git_util"); +const Open = require("./open"); +const PrintStatusUtil = require("./print_status_util"); +const RepoStatus = require("./repo_status"); +const SparseCheckoutUtil = require("./sparse_checkout_util"); +const StatusUtil = require("./status_util"); +const SubmoduleUtil = require("./submodule_util"); +const SubmoduleConfigUtil = require("./submodule_config_util"); +const SubmoduleRebaseUtil = require("./submodule_rebase_util"); +const TreeUtil = require("./tree_util"); +const UserError = require("./user_error"); + +const Commit = NodeGit.Commit; +const Change = TreeUtil.Change; +const FILEMODE = NodeGit.TreeEntry.FILEMODE; + +const MAGIC_DELETED_SHA = NodeGit.Oid.fromString( + "de1e7ed0de1e7ed0de1e7ed0de1e7ed0de1e7ed0"); + +const GITMODULES = SubmoduleConfigUtil.modulesFileName; /** * Return the IDs of tress reflecting the current state of the index and @@ -69,7 +85,7 @@ exports.stashRepo = co.wrap(function *(repo, status, includeUntracked) { // Create a tree for the workdir based on the index. const indexTree = yield NodeGit.Tree.lookup(repo, indexId); - const changes = TreeUtil.listWorkdirChanges(repo, + const changes = yield TreeUtil.listWorkdirChanges(repo, status, includeUntracked); const workdirTree = yield TreeUtil.writeTree(repo, indexTree, changes); @@ -101,6 +117,62 @@ exports.makeLogMessage = co.wrap(function *(repo) { WIP on ${branchDesc}: ${GitUtil.shortSha(head.id().tostrS())} ${message}`; }); + +function getNewGitModuleSha(diff) { + const numDeltas = diff.numDeltas(); + for (let i = 0; i < numDeltas; ++i) { + const delta = diff.getDelta(i); + // We assume that the user hasn't deleted the .gitmodules file. + // That would be bonkers. + const file = delta.newFile(); + const path = file.path(); + if (path === GITMODULES) { + return delta.newFile().id(); + } + } + // diff does not include .gitmodules + return null; +} + + +const stashGitModules = co.wrap(function *(repo, headTree) { + assert.instanceOf(repo, NodeGit.Repository); + + const result = {}; + // RepoStatus throws away the diff new sha, and rather than hack + // it, since it's used all over the codebase, we'll just redo the + // diffs for this one file. + + const workdirToTreeDiff = + yield NodeGit.Diff.treeToWorkdir(repo, + headTree, + {pathspec: [GITMODULES]}); + + + const newWorkdir = getNewGitModuleSha(workdirToTreeDiff); + if (newWorkdir !== null) { + result.workdir = newWorkdir; + } + + const indexToTreeDiff = + yield NodeGit.Diff.treeToIndex(repo, + headTree, + yield repo.index(), + {pathspec: [GITMODULES]}); + + const newIndex = getNewGitModuleSha(indexToTreeDiff); + if (newIndex !== null) { + result.staged = newIndex; + } + + yield NodeGit.Checkout.tree(repo, headTree, { + paths: [GITMODULES], + checkoutStrategy: NodeGit.Checkout.STRATEGY.FORCE, + }); + + return result; +}); + /** * Save the state of the submodules in the specified, `repo` having the * specified `status` and clean the sub-repositories to match their respective @@ -115,21 +187,36 @@ WIP on ${branchDesc}: ${GitUtil.shortSha(head.id().tostrS())} ${message}`; * Return a map from submodule name to stashed commit for each submodule that * was stashed. * + * Normal stashes have up to two parents: + * 1. HEAD at stash time + * 2. a new commit, with tree = index at stash time + * + * Our stashes can have up to two additional parents: + * + * 3. If the user has a commit inside the submodule, that commit + * 4. If the user has staged a commit in the meta index, that commit + * * @param {NodeGit.Repository} repo * @param {RepoStatus} status * @param {Boolean} includeUntracked + * @param {String|null} message * @return {Object} submodule name to stashed commit */ -exports.save = co.wrap(function *(repo, status, includeUntracked) { +exports.save = co.wrap(function *(repo, status, includeUntracked, message) { assert.instanceOf(repo, NodeGit.Repository); assert.instanceOf(status, RepoStatus); assert.isBoolean(includeUntracked); + if (null !== message) { + assert.isString(message); + } const subResults = {}; // name to sha const subChanges = {}; // name to TreeUtil.Change const subRepos = {}; // name to submodule open repo - const sig = repo.defaultSignature(); + const sig = yield ConfigUtil.defaultSignature(repo); + const head = yield repo.getHeadCommit(); + const headTree = yield head.getTree(); // First, we process the submodules. If a submodule is open and dirty, // we'll create the stash commits in its repo, populate `subResults` with @@ -142,41 +229,217 @@ exports.save = co.wrap(function *(repo, status, includeUntracked) { yield Object.keys(submodules).map(co.wrap(function *(name) { const sub = submodules[name]; const wd = sub.workdir; - if (null === wd || - (wd.status.isClean() && - (!includeUntracked || - 0 === Object.keys(wd.status.workdir).length))) { - // Nothing to do for closed or clean subs - return; // RETURN + let stashId; + + if (sub.commit === null) { + // I genuinely have no idea when this happens -- it's not: + // (a) a closed submodule with a change staged in the meta repo + // (b) a submodule yet to be born -- that is, a submodule + // added to .gitmodules but without any commits. + // (c) a submodule which does not even appear inside the + // .gitmodules (e.g. one that you meant to add but didn't) + console.error(`BUG: ${name} is in an unexpected state. Please \ +report this. Continuing stash anyway.`); + return; } - const subRepo = yield SubmoduleUtil.getRepo(repo, name); - subRepos[name] = subRepo; - const FLAGS = NodeGit.Stash.FLAGS; - const flags = includeUntracked ? + + if (null === wd) { + // closed submodule + if (sub.index === null || sub.index.sha === null) { + // deleted submodule + stashId = MAGIC_DELETED_SHA; + yield NodeGit.Checkout.tree(repo, headTree, { + paths: [name], + checkoutStrategy: NodeGit.Checkout.STRATEGY.FORCE, + }); + } else { + if (sub.commit.sha === sub.index.sha) { + // ... with no staged changes + return; // RETURN + } + // This is a case that regular git stash doesn't really have + // to handle. In a normal stash commit, the tree points + // to the working directory tree, but here, there is no working + // directory. But if there were, we would want to have + // this commit checked out. + + const subRepo = yield SubmoduleUtil.getRepo(repo, name); + + const subCommit = yield Commit.lookup(subRepo, sub.commit.sha); + const indexCommit = yield Commit.lookup(subRepo, sub.index.sha); + const indexTree = yield indexCommit.getTree(); + stashId = yield Commit.create(subRepo, + null, + sig, + sig, + null, + "stash", + indexTree, + 4, + [subCommit, + indexCommit, + indexCommit, + indexCommit]); + } + } else { + // open submodule + if (sub.commit.sha !== sub.index.sha && + sub.index.sha !== wd.status.headCommit) { + // Giant mess case: the user has commit staged in the + // meta index, and new commits in the submodule (which + // may or may not be related to those staged in the + // index). + + // In theory, our data structures support writing this case, + // but since we don't yet support reading it, we probably + // shouldn't let the user get into a state that they can't + // easily get out of. + + throw new UserError(`${name} is in a state that is too \ +complicated for git-meta to handle right now. There is a commit inside the \ +submodule, and also a different commit staged in the index. Consider either \ +staging or unstaging ${name} in the meta repository`); + } + + const untrackedFiles = Object.keys(wd.status.workdir).length > 0; + + const uncommittedChanges = (!wd.status.isClean() || + (includeUntracked && untrackedFiles)); + + if (!uncommittedChanges && + wd.status.headCommit === sub.commit.sha && + sub.commit.sha === sub.index.sha) { + // Nothing to do for fully clean subs + return; // RETURN + } + + const subRepo = yield SubmoduleUtil.getRepo(repo, name); + subRepos[name] = subRepo; + + if (uncommittedChanges) { + const FLAGS = NodeGit.Stash.FLAGS; + const flags = includeUntracked ? FLAGS.INCLUDE_UNTRACKED : FLAGS.DEFAULT; - const stashId = yield NodeGit.Stash.save(subRepo, sig, "stash", flags); - subResults[name] = stashId.tostrS(); + stashId = yield NodeGit.Stash.save(subRepo, sig, "stash", + flags); + if (wd.status.headCommit !== sub.commit.sha || + sub.commit.sha !== sub.index.sha) { + // That stashed the local changes in the submodule, if + // any. So now we need to mangle this commit to + // include more parents. + + const stashCommit = yield Commit.lookup(subRepo, stashId); + const stashTree = yield stashCommit.getTree(); + if (stashCommit.parentcount() !== 2) { + throw new Error(`BUG: expected newly-created stash \ +commit to have two parents`); + } + const parent1 = yield stashCommit.parent(0); + const parent2 = yield stashCommit.parent(1); + + const metaHeadSha = sub.commit.sha; + const headCommit = yield Commit.lookup(subRepo, + metaHeadSha); + const indexCommit = yield Commit.lookup(subRepo, + sub.index.sha); + + const parents = [parent1, parent2, headCommit, + indexCommit]; + stashId = yield Commit.create(subRepo, + null, + sig, + sig, + null, + "stash", + stashTree, + 4, + parents); + } + + } else { + // we need to manually create the commit here. + const metaHead = yield Commit.lookup(subRepo, sub.commit.sha); + const head = yield Commit.lookup(subRepo, wd.status.headCommit); + const indexCommit = yield Commit.lookup(subRepo, + sub.index.sha); + + const parents = [metaHead, + metaHead, + head, + indexCommit]; + const headCommit = yield Commit.lookup(subRepo, + wd.status.headCommit); + const headTree = yield headCommit.getTree(); + + stashId = yield Commit.create(subRepo, + null, + sig, + sig, + null, + "stash", + headTree, + 4, + parents); + } + const subCommit = yield Commit.lookup(subRepo, + sub.commit.sha); + yield NodeGit.Checkout.tree(subRepo, subCommit, { + checkoutStrategy: NodeGit.Checkout.STRATEGY.FORCE, + }); + subRepo.setHeadDetached(subCommit); + } + subResults[name] = stashId.tostrS(); // Record the values we've created. - subChanges[name] = new TreeUtil.Change( - stashId, - NodeGit.TreeEntry.FILEMODE.COMMIT); + subChanges[name] = new TreeUtil.Change(stashId, FILEMODE.COMMIT); })); - const head = yield repo.getHeadCommit(); - const headTree = yield head.getTree(); + + const parents = [head]; + + const gitModulesChanges = yield stashGitModules(repo, headTree); + if (gitModulesChanges) { + if (gitModulesChanges.workdir) { + subChanges[GITMODULES] = new Change(gitModulesChanges.workdir, + FILEMODE.BLOB); + } + if (gitModulesChanges.staged) { + const indexChanges = {}; + Object.assign(indexChanges, subChanges); + + indexChanges[GITMODULES] = new Change(gitModulesChanges.staged, + FILEMODE.BLOB); + + + const indexTree = yield TreeUtil.writeTree(repo, headTree, + indexChanges); + const indexParent = yield Commit.create(repo, + null, + sig, + sig, + null, + "stash", + indexTree, + 1, + [head]); + + const indexParentCommit = yield Commit.lookup(repo, indexParent); + parents.push(indexParentCommit); + } + } + const subsTree = yield TreeUtil.writeTree(repo, headTree, subChanges); - const stashId = yield NodeGit.Commit.create(repo, - null, - sig, - sig, - null, - "stash", - subsTree, - 1, - [head]); + const stashId = yield Commit.create(repo, + null, + sig, + sig, + null, + "stash", + subsTree, + parents.length, + parents); const stashSha = stashId.tostrS(); @@ -194,7 +457,9 @@ exports.save = co.wrap(function *(repo, status, includeUntracked) { // Update the stash ref and the ref log - const message = yield exports.makeLogMessage(repo); + if (null === message) { + message = yield exports.makeLogMessage(repo); + } yield NodeGit.Reference.create(repo, metaStashRef, stashId, @@ -226,8 +491,8 @@ exports.createReflogIfNeeded = co.wrap(function *(repo, const log = yield NodeGit.Reflog.read(repo, reference); if (0 === log.entrycount()) { const id = NodeGit.Oid.fromString(sha); - log.append(id, repo.defaultSignature(), message); - log.write(); + log.append(id, yield ConfigUtil.defaultSignature(repo), message); + yield log.write(); } }); @@ -270,13 +535,15 @@ exports.setStashHead = co.wrap(function *(repo, sha) { * * @param {NodeGit.Repository} repo * @param {String} id - * @return {Boolean} + * @param {Boolean} reinstateIndex + * @return {Object} submodule name to stashed commit */ -exports.apply = co.wrap(function *(repo, id) { +exports.apply = co.wrap(function *(repo, id, reinstateIndex) { assert.instanceOf(repo, NodeGit.Repository); assert.isString(id); const commit = yield repo.getCommit(id); + const repoIndex = yield repo.index(); // TODO: patch libgit2/nodegit: the commit object returned from `parent` // isn't properly configured with a `repo` object, and attempting to use it @@ -284,10 +551,58 @@ exports.apply = co.wrap(function *(repo, id) { const parentId = (yield commit.parent(0)).id(); const parent = yield repo.getCommit(parentId); - const baseSubs = yield SubmoduleUtil.getSubmodulesForCommit(repo, parent); - const newSubs = yield SubmoduleUtil.getSubmodulesForCommit(repo, commit); + const parentTree = yield parent.getTree(); + + const baseSubs = yield SubmoduleUtil.getSubmodulesForCommit(repo, + parent, + null); + + let indexSubs = baseSubs; + if (commit.parentcount() > 1) { + const parent2Id = (yield commit.parent(1)).id(); + const parent2 = yield repo.getCommit(parent2Id); + indexSubs = yield SubmoduleUtil.getSubmodulesForCommit(repo, + parent2, + null); + } + + const newSubs = yield SubmoduleUtil.getSubmodulesForCommit(repo, + commit, + null); + + const toDelete = []; + yield Object.keys(baseSubs).map(co.wrap(function *(name) { + if (newSubs[name] === undefined) { + if (fs.existsSync(name)) { + // sub deleted in working tree + toDelete.push(name); + } + } + })); + + CloseUtil.close(repo, repo.workdir(), toDelete, false); + for (const name of toDelete) { + yield fs.rmdir(name); + } + + yield Object.keys(baseSubs).map(co.wrap(function *(name) { + if (indexSubs[name] === undefined) { + // sub deleted in the index + yield repoIndex.removeByPath(name); + } + })); + + // apply gitmodules diff + const headTree = yield commit.getTree(); + yield NodeGit.Checkout.tree(repo, headTree, { + paths: [GITMODULES], + baseline: parentTree, + checkoutStrategy: NodeGit.Checkout.STRATEGY.MERGE, + }); + const opener = new Open.Opener(repo, null); let result = {}; + const index = {}; yield Object.keys(newSubs).map(co.wrap(function *(name) { const stashSha = newSubs[name].sha; if (baseSubs[name].sha === stashSha) { @@ -295,14 +610,15 @@ exports.apply = co.wrap(function *(repo, id) { return; // RETURN } - const subRepo = yield opener.getSubrepo(name); - - // Try to get the comit for the stash; if it's missing, fail. + let subRepo = + yield opener.getSubrepo(name, + Open.SUB_OPEN_OPTION.FORCE_OPEN); + // Try to get the commit for the stash; if it's missing, fail. + let stashCommit; try { - yield subRepo.getCommit(stashSha); - } - catch (e) { + stashCommit = yield Commit.lookup(subRepo, stashSha); + } catch (e) { console.error(`\ Stash commit ${colors.red(stashSha)} is missing from submodule \ ${colors.red(name)}`); @@ -310,6 +626,56 @@ ${colors.red(name)}`); return; // RETURN } + const indexCommit = yield stashCommit.parent(1); + + if (stashCommit.parentcount() > 2) { + const oldHead = yield stashCommit.parent(2); + if (stashCommit.parentcount() > 3) { + const stagedCommit = yield stashCommit.parent(3); + index[name] = stagedCommit.id(); + } + + // Before we get started, we might need to rebase the + // commits from oldHead..commitBeforeStash + + const commitBeforeStash = yield stashCommit.parent(0); + + const rebaseError = function(resolution) { + console.error(`The stash for submodule ${name} had one or \ +more commits, ending with ${commitBeforeStash.id()}. We tried to rebase these \ +commits onto the current commit, but this failed. ${resolution} +After you are done with this rebase, you may need to apply working tree \ +and index changes. To restore the index, try (inside ${name}) 'git read-tree \ +${indexCommit.id()}'. To restore the working tree, try (inside ${name}) \ +'git checkout ${stashCommit} -- .'`); + }; + + try { + const res = yield SubmoduleRebaseUtil.rewriteCommits( + subRepo, + commitBeforeStash, + oldHead); + if (res.errorMessage) { + rebaseError(res.errorMessage); + result = null; + return; + } + } catch (e) { + // We expect these errors to be caught, but if + // something goes wrong, wWe are leaving the user in a + // pretty yucky state. the alternative is to try to + // back out the whole stash apply, which seems worse. + + rebaseError(`We are leaving the rebase half-finished, so \ +that you can fix the conflicts and continue by running 'git rebase \ +--continue' inside of ${name} (note: not 'git meta rebase').`); + console.error(`The underlying error, which might be useful \ +for debugging, is:`, e); + result = null; + return; + } + } + // Make sure this sha is the current stash. yield exports.setStashHead(subRepo, stashSha); @@ -317,10 +683,12 @@ ${colors.red(name)}`); // And then apply it. const APPLY_FLAGS = NodeGit.Stash.APPLY_FLAGS; + const flag = reinstateIndex ? + APPLY_FLAGS.APPLY_REINSTATE_INDEX : APPLY_FLAGS.APPLY_DEFAULT; try { yield NodeGit.Stash.pop(subRepo, 0, { - flags: APPLY_FLAGS.APPLY_REINSTATE_INDEX, + flags: flag, }); } catch (e) { @@ -330,9 +698,48 @@ ${colors.red(name)}`); result[name] = stashSha; } })); + + if (null !== result) { + for (let name of Object.keys(index)) { + const entry = new NodeGit.IndexEntry(); + entry.flags = 0; + entry.flagsExtended = 0; + entry.id = index[name]; + repoIndex.add(entry); + } + } + + yield SparseCheckoutUtil.setSparseBitsAndWriteIndex(repo, repoIndex); return result; }); +/** + * Return the sha of the stash at the specified `index` in the specified + * `repo`. Throw a `UserError` if there is no stash at the specified index. + * + * @param {NodeGit.Repository} repo + * @param {Number} index + * @return {String} + */ +const getStashSha = co.wrap(function *(repo, index) { + + let stashRef; + try { + stashRef = yield NodeGit.Reference.lookup(repo, metaStashRef); + } + catch (e) { + throw new UserError("No stash found."); + } + + const log = yield NodeGit.Reflog.read(repo, metaStashRef); + const count = log.entrycount(); + if (count <= index) { + throw new UserError( +`Invalid stash index: ${colors.red(index)}, max is ${count - 1}.`); + } + return log.entryByIndex(index).idNew().tostrS(); +}); + /** * Remove, from the stash queue for the specified `repo`, the stash at the * specified `index`. Throw a `UserError` if no such stash exists. If @@ -346,29 +753,37 @@ ${colors.red(name)}`); exports.removeStash = co.wrap(function *(repo, index) { assert.instanceOf(repo, NodeGit.Repository); assert.isNumber(index); + + const log = yield NodeGit.Reflog.read(repo, metaStashRef); + const stashSha = yield getStashSha(repo, index); const count = log.entrycount(); - if (count <= index) { - throw new UserError(`Invalid stash index: ${colors.red(index)}.`); - } log.drop(index, 1 /* rewrite previous entry */); - log.write(); + yield log.write(); // We dropped the first element. We need to update `refs/meta-stash` if (0 === index) { if (count > 1) { const entry = log.entryByIndex(0); - NodeGit.Reference.create(repo, - metaStashRef, - entry.idNew(), - 1, - "removeStash"); + yield NodeGit.Reference.create(repo, + metaStashRef, + entry.idNew(), + 1, + "removeStash"); + // But then in doing so, we've written a new entry for the ref, + // remove the old one. + + log.drop(1, 1 /* rewrite previous entry */); + yield log.write(); } else { NodeGit.Reference.remove(repo, metaStashRef); } } + const refText = `${metaStashRef}@{${index}}`; + console.log(`\ +Dropped ${colors.green(refText)} ${colors.blue(stashSha)}`); }); /** @@ -377,29 +792,15 @@ exports.removeStash = co.wrap(function *(repo, index) { * remove `refs/meta-stash`. * * @param {NodeGit.Repository} repo + * @param {int} index + * @param {Boolean} reinstateIndex */ -exports.pop = co.wrap(function *(repo, index) { +exports.pop = co.wrap(function *(repo, index, reinstateIndex, shouldDrop) { assert.instanceOf(repo, NodeGit.Repository); assert.isNumber(index); - // Try to look up the meta stash; return early if not found. - - let stashRef; - try { - stashRef = yield NodeGit.Reference.lookup(repo, metaStashRef); - } - catch (e) { - console.warn("No meta stash found."); - return; // RETURN - } - - const log = yield NodeGit.Reflog.read(repo, metaStashRef); - const count = log.entrycount(); - if (count <= index) { - throw new UserError(`Invalid stash index: ${colors.red(index)}.`); - } - const stashSha = log.entryByIndex(index).idNew().tostrS(); - const applyResult = yield exports.apply(repo, stashSha); + const stashSha = yield getStashSha(repo, index); + const applyResult = yield exports.apply(repo, stashSha, reinstateIndex); const status = yield StatusUtil.getRepoStatus(repo); process.stdout.write(PrintStatusUtil.printRepoStatus(status, "")); @@ -407,17 +808,17 @@ exports.pop = co.wrap(function *(repo, index) { // If the application succeeded, remove it. if (null !== applyResult) { - yield exports.removeStash(repo, index); - console.log(`\ -Dropped ${colors.green(metaStashRef + "@{0}")} ${colors.blue(stashSha)}`); + if (shouldDrop) { + yield exports.removeStash(repo, index); - // Clean up sub-repo meta-refs + // Clean up sub-repo meta-refs - Object.keys(applyResult).forEach(co.wrap(function *(subName) { - const subRepo = yield SubmoduleUtil.getRepo(repo, subName); - const refName = makeSubRefName(applyResult[subName]); - NodeGit.Reference.remove(subRepo, refName); - })); + Object.keys(applyResult).forEach(co.wrap(function* (subName) { + const subRepo = yield SubmoduleUtil.getRepo(repo, subName); + const refName = makeSubRefName(applyResult[subName]); + NodeGit.Reference.remove(subRepo, refName); + })); + } } else { throw new UserError(`\ @@ -441,3 +842,187 @@ exports.list = co.wrap(function *(repo) { } return result; }); + +/** + * Make a shadow commit for the specified `repo` having the specified `status`; + * use the specified commit `message`. Ignored untracked files unless the + * specified `includeUntracked` is true. Return the sha of the created commit. + * Note that this method does not recurse into submodules. If the specified + * `incrementTimestamp` is true, use the timestamp of HEAD + 1; otherwise, use + * the current time. + * + * @param {NodeGit.Repository} repo + * @param {RepoStatus} status + * @param {String} message + * @param {Bool} incrementTimestamp + * @param {Bool} includeUntracked + * @return {String} + */ +const makeShadowCommitForRepo = co.wrap(function *(repo, + status, + message, + incrementTimestamp, + includeUntracked, + indexOnly) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(status, RepoStatus); + assert.isString(message); + assert.isBoolean(includeUntracked); + assert.isBoolean(incrementTimestamp); + assert.isBoolean(indexOnly); + + if (indexOnly) { + const index = yield repo.index(); + const tree = yield index.writeTree(); + const sig = yield ConfigUtil.defaultSignature(repo); + const subCommit = yield repo.createCommit(null, sig, sig, + message, tree, []); + return subCommit.tostrS(); + } + + const changes = yield TreeUtil.listWorkdirChanges(repo, + status, + includeUntracked); + const head = yield repo.getHeadCommit(); + const parents = []; + + const index = yield repo.index(); + const treeOid = yield index.writeTree(); + const indexTree = yield repo.getTree(treeOid); + + const newTree = yield TreeUtil.writeTree(repo, indexTree, changes); + if (null !== head) { + parents.push(head); + const headTree = yield head.getTree(); + if (newTree.id().equal(headTree.id())) { + return head.sha(); + } + } + + let sig = yield ConfigUtil.defaultSignature(repo); + if (incrementTimestamp && null !== head) { + sig = NodeGit.Signature.create(sig.name(), + sig.email(), + head.time() + 1, + head.timeOffset()); + } + const id = yield Commit.create(repo, + null, + sig, + sig, + null, + message, + newTree, + parents.length, + parents); + return id.tostrS(); +}); + +/** + * Generate a shadow commit in the specified 'repo' with the specified + * 'message' and return an object describing the created commits. Ignore + * untracked files unless the specified 'includeUntracked' is true. + * When 'includedSubrepos' is non-empty only consider files contained within the + * paths specified in includedSubrepos. If the + * repository is clean, return null. Note that this command does not affect + * the state of 'repo' other than to generate commits. + * + * TODO: Note that we cannot really support `includeMeta === true` due to the + * fact that `getRepoStatus` is broken when `true === ignoreIndex` and there + * are new submodules (see TODO in `StatusUtil.getRepoStatus`). + * + * @param {NodeGit.Repository} repo + * @param {String} message + * @param {Bool} useEpochTimestamp + * @param {Bool} includeMeta + * @param {Bool} includeUntracked + * @param {Object} includedSubrepos + * @param {Bool} indexOnly include only staged changes + * @return {Object|null} + * @return {String} return.metaCommit + * @return {Object} return.subCommits path to sha of generated subrepo commits + */ +exports.makeShadowCommit = co.wrap(function *(repo, + message, + useEpochTimestamp, + includeMeta, + includeUntracked, + includedSubrepos, + indexOnly) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isString(message); + assert.isBoolean(includeMeta); + assert.isBoolean(includeUntracked); + assert.isArray(includedSubrepos); + assert.isBoolean(useEpochTimestamp); + if (indexOnly === undefined) { + indexOnly = false; + } else { + assert.isBoolean(indexOnly); + } + + if (!message.endsWith("\n")) { + message += "\n"; + } + + const status = yield StatusUtil.getRepoStatus(repo, { + showMetaChanges: includeMeta, + untrackedFilesOption: DiffUtil.UNTRACKED_FILES_OPTIONS.ALL, + ignoreIndex: false, + paths: includedSubrepos, + }); + if (status.isDeepClean(includeUntracked)) { + return null; // RETURN + } + const subCommits = {}; + const subStats = status.submodules; + const Submodule = RepoStatus.Submodule; + yield Object.keys(subStats).map(co.wrap(function *(name) { + const subStatus = subStats[name]; + const wd = subStatus.workdir; + + // If the submodule is closed or its workdir is clean, we don't need to + // do anything for it. + + if (null === wd || ((subStatus.commit === null || + wd.status.headCommit === subStatus.commit.sha) && + wd.status.isClean(includeUntracked))) { + return; // RETURN + } + const subRepo = yield SubmoduleUtil.getRepo(repo, name); + const subSha = yield makeShadowCommitForRepo(subRepo, + wd.status, + message, + useEpochTimestamp, + includeUntracked, + indexOnly); + const newSubStat = new Submodule({ + commit: subStatus.commit, + index: subStatus.index, + workdir: new Submodule.Workdir(new RepoStatus({ + headCommit: subSha, + }), Submodule.COMMIT_RELATION.AHEAD), + }); + + // Update the status for this submodule so that it will be written + // correctly. + + subStats[name] = newSubStat; + subCommits[name] = subSha; + })); + + // Update the submodules in the status object to reflect newly-generated + // commits. + + const newStatus = status.copy({ submodules: subStats }); + const metaCommit = yield makeShadowCommitForRepo(repo, + newStatus, + message, + useEpochTimestamp, + includeUntracked, + false); + return { + metaCommit: metaCommit, + subCommits: subCommits, + }; +}); diff --git a/node/lib/util/status_util.js b/node/lib/util/status_util.js index 124a222e2..9d9075666 100644 --- a/node/lib/util/status_util.js +++ b/node/lib/util/status_util.js @@ -42,11 +42,14 @@ const NodeGit = require("nodegit"); const DiffUtil = require("./diff_util"); const GitUtil = require("./git_util"); +const PrintStatusUtil = require("./print_status_util"); const Rebase = require("./rebase"); const RebaseFileUtil = require("./rebase_file_util"); const RepoStatus = require("./repo_status"); +const SequencerStateUtil = require("./sequencer_state_util"); const SubmoduleUtil = require("./submodule_util"); const SubmoduleConfigUtil = require("./submodule_config_util"); +const UserError = require("./user_error"); /** * Return a new `RepoStatus.Submodule` object having the same value as the @@ -97,6 +100,7 @@ exports.remapSubmodule = function (sub, commitMap, urlMap) { * * @param {Rebase} rebase * @param {Object} commitMap from sha to sha + * @return {Rebase} */ function remapRebase(rebase, commitMap) { assert.instanceOf(rebase, Rebase); @@ -148,6 +152,10 @@ exports.remapRepoStatus = function (status, commitMap, urlMap) { workdir: status.workdir, rebase: status.rebase === null ? null : remapRebase(status.rebase, commitMap), + sequencerState: status.sequencerState === null ? + null : + SequencerStateUtil.mapCommits(status.sequencerState, + commitMap), }); }; @@ -205,7 +213,7 @@ exports.getRelation = co.wrap(function *(repo, from, to) { * optionally specified `repo`, and workdir `status` if open, the optionally * specified `indexUrl` and `indexSha` if the submodule exists in the index, * and the optionally specified `commitUrl` and `commitSha` if it exists in the - * HEAD commit. + * HEAD commit. Return `undefined` if the submodule is misconfigured. * @async * @private * @param {NodeGit.Repository|null} repo @@ -222,6 +230,11 @@ exports.getSubmoduleStatus = co.wrap(function *(repo, commitUrl, indexSha, commitSha) { + // If there is no index URL or commit URL, this submodule cannot be deleted + // or added. + if (null === commitUrl && null === indexUrl) { + return undefined; + } const Submodule = RepoStatus.Submodule; const COMMIT_RELATION = Submodule.COMMIT_RELATION; @@ -234,8 +247,8 @@ exports.getSubmoduleStatus = co.wrap(function *(repo, commit = new Submodule.Commit(commitSha, commitUrl); } - // A null indexUrl indicates that the submodule was removed. If that is - // the case, we're done. + // A null indexUrl indicates that the submodule doesn't exist in + // the staged .gitmodules. if (null === indexUrl) { return new Submodule({ commit: commit }); // RETURN @@ -282,23 +295,202 @@ exports.getSubmoduleStatus = co.wrap(function *(repo, }); }); +/** + * Return the conflicts listed in the specified `index` and + * the specified `paths`. You can specify an empty Array if + * you want to list conflicts for the whole index. + * + * @param {NodeGit.Index} index + * @param {String []} paths + * @return {Object} map from entry name to `RepoStatus.Conflict` + */ +exports.readConflicts = function (index, paths) { + assert.instanceOf(index, NodeGit.Index); + assert.isArray(paths); + paths = new Set(paths); + const conflicted = {}; + function getConflict(path) { + let obj = conflicted[path]; + if (undefined === obj) { + obj = { + ancestor: null, + our: null, + their: null, + }; + conflicted[path] = obj; + } + return obj; + } + const entries = index.entries(); + const STAGE = RepoStatus.STAGE; + for (let entry of entries) { + const stage = NodeGit.Index.entryStage(entry); + switch (stage) { + case STAGE.NORMAL: + break; + case STAGE.ANCESTOR: + getConflict(entry.path).ancestor = entry.mode; + break; + case STAGE.OURS: + getConflict(entry.path).our = entry.mode; + break; + case STAGE.THEIRS: + getConflict(entry.path).their = entry.mode; + break; + } + } + const result = {}; + const COMMIT = NodeGit.TreeEntry.FILEMODE.COMMIT; + for (let name in conflicted) { + const c = conflicted[name]; + if (paths.size !== 0 && !paths.has(name)) { + continue; + } + // Ignore the conflict if it's just between submodule SHAs + + if (COMMIT !== c.our || COMMIT !== c.their) { + result[name] = new RepoStatus.Conflict(c.ancestor, c.our, c.their); + } + } + return result; +}; + +/** + * Return the status of submodules. + * + * @async + * @param {NodeGit.Repository} repo + * @param {Object} [options] see `getRepoStatus` for option fields + * @param {NodeGit.Commit} headCommit HEAD commit + * @return {Object} status of the submodule + * @returns {Object} return.conflicts list of conflicts in the index + * @returns {Object} return.submodule list of submodule names to status + */ +const getSubRepoStatus = co.wrap(function *(repo, options, headCommit) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(headCommit, NodeGit.Commit); + + const openArray = yield SubmoduleUtil.listOpenSubmodules(repo); + const openSet = new Set(openArray); + const index = yield repo.index(); + const headTree = yield headCommit.getTree(); + const diff = yield NodeGit.Diff.treeToIndex(repo, headTree, index); + const changes = + yield SubmoduleUtil.getSubmoduleChangesFromDiff(diff, true); + const indexUrls = + yield SubmoduleConfigUtil.getSubmodulesFromIndex(repo, index); + const headUrls = + yield SubmoduleConfigUtil.getSubmodulesFromCommit(repo, headCommit); + + const result = { + conflicts: exports.readConflicts(index, options.paths), + submodules: {}, + }; + + // No paths specified, so we'll do all submodules, restricting to open + // ones based on options. + let filterPaths; // map from sub name to paths to use + const filtering = 0 !== options.paths.length; + + // Will look at submodules that are open or have changes. TODO: we're + // ignoring changes affecting only the `.gitmodules` file for now. + let subsToList = Array.from(new Set( + openArray.concat(Object.keys(changes)))); + + if (filtering) { + filterPaths = SubmoduleUtil.resolvePaths(options.paths, + subsToList, + openArray); + subsToList = Object.keys(filterPaths); + } + + // Make a list of promises to read the status for each submodule, then + // evaluate them in parallel. + const subStatMakers = subsToList.map(co.wrap(function* (name) { + const headUrl = headUrls[name] || null; + const indexUrl = indexUrls[name] || null; + let headSha = null; + let indexSha = null; + let subRepo = null; + + // Load commit information available based on whether the submodule + // was added, removed, changed, or just open. + const change = changes[name]; + if (undefined !== change) { + headSha = change.oldSha; + indexSha = change.newSha; + } + else { + // Just open, we need to load its sha. Unfortunately, the diff + // we did above doesn't catch new submodules with unstaged + // commits; validate that we have commit and index URLs and + // entries before trying to read them. + if (null !== headUrl) { + headSha = (yield headTree.entryByPath(name)).sha(); + } + if (null !== indexUrl) { + const indexEntry = index.getByPath(name); + if (undefined !== indexEntry) { + indexSha = indexEntry.id.tostrS(); + } + } + } + let status = null; + if (openSet.has(name)) { + subRepo = yield SubmoduleUtil.getRepo(repo, name); + status = yield exports.getRepoStatus(subRepo, { + paths: filtering ? filterPaths[name] : [], + untrackedFilesOption: options.untrackedFilesOption, + ignoreIndex: options.ignoreIndex, + showMetaChanges: true, + }); + } + return yield exports.getSubmoduleStatus(subRepo, + status, + indexUrl, + headUrl, + indexSha, + headSha); + })); + + const subStats = yield subStatMakers; + + // And copy them into the arguments. + subsToList.forEach((name, i) => { + const subStat = subStats[i]; + if (undefined !== subStat) { + result.submodules[name] = subStats[i]; + } + }); + return result; +}); + /** * Return a description of the status of changes to the specified `repo`. If - * the optionally specified `options.showAllUntracked` is true (default false), - * return each untracked file individually rather than rolling up to the - * directory. If the optionally specified `options.paths` is non-empty - * (default []), list the status only of the files contained in `paths`. If - * the optionally specified `options.showMetaChanges` is provided (default - * true), return the status of changes in `repo`; otherwise, show only changes - * in submobules. If the optionally specified `ignoreIndex` is specified, - * calculate the status matching the workdir to the underlying commit rather - * than against the index. + * the optionally specified `options.untrackedFilesOption` is ALL (default + * ALL), return each untracked file individually. If it is NORMAL, roll + * untracked files up to the directory. If it is NO, don't show untracked files. + * If the optionally specified `options.paths` is non-empty (default []), list + * the status only of the files contained in `paths`. If the optionally + * specified `options.showMetaChanges` is provided (default true), return the + * status of changes in `repo`; otherwise, show only changes in submodules. If + * the optionally specified `ignoreIndex` is specified, calculate the status + * matching the workdir to the underlying commit rather than against the index. + * If the specified `options.cwd` is provided, resolve paths in the context of + * that directory. + * + * TODO: Note that this function is broken when + * `true === ignoreIndex && true === showMetaChanges` and + * there are new submodules. It erroneously reports that the path with the new + * submodule is an untracked file. We need to put some logic in that + * recognizes these paths as being inside a submodule and filters them out. * * @async * @param {NodeGit.Repository} repo * @param {Object} [options] - * @param {Boolean} [options.showAllUntracked] + * @param {String} [options.untrackedFilesOption] * @param {String []} [options.paths] + * @param {String} [options.cwd] * @param {Boolean} [options.showMetaChanges] * @param {Boolean} [options.ignoreIndex] * @return {RepoStatus} @@ -314,11 +506,11 @@ exports.getRepoStatus = co.wrap(function *(repo, options) { else { assert.isObject(options); } - if (undefined === options.showAllUntracked) { - options.showAllUntracked = false; + if (undefined === options.untrackedFilesOption) { + options.untrackedFilesOption = DiffUtil.UNTRACKED_FILES_OPTIONS.NORMAL; } else { - assert.isBoolean(options.showAllUntracked); + assert.isString(options.untrackedFilesOption); } if (undefined === options.paths) { options.paths = []; @@ -327,7 +519,7 @@ exports.getRepoStatus = co.wrap(function *(repo, options) { assert.isArray(options.paths); } if (undefined === options.showMetaChanges) { - options.showMetaChanges = true; + options.showMetaChanges = false; } else { assert.isBoolean(options.showMetaChanges); @@ -338,7 +530,14 @@ exports.getRepoStatus = co.wrap(function *(repo, options) { else { assert.isBoolean(options.ignoreIndex); } - + if (undefined !== options.cwd) { + assert.isString(options.cwd); + options.paths = options.paths.map(filename => { + return GitUtil.resolveRelativePath(repo.workdir(), + options.cwd, + filename); + }); + } const headCommit = yield repo.getHeadCommit(); let args = { @@ -362,106 +561,98 @@ exports.getRepoStatus = co.wrap(function *(repo, options) { args.rebase = rebase; } - if (options.showMetaChanges && !repo.isBare()) { + args.sequencerState = + yield SequencerStateUtil.readSequencerState(repo.path()); + + if (!repo.isBare()) { const head = yield repo.getHeadCommit(); let tree = null; if (null !== head) { const treeId = head.treeId(); tree = yield NodeGit.Tree.lookup(repo, treeId); } - const status = yield SubmoduleUtil.cacheSubmodules(repo, () => { - return DiffUtil.getRepoStatus(repo, - tree, - options.paths, - options.ignoreIndex, - options.showAllUntracked); - }); - args.staged = status.staged; - args.workdir = status.workdir; - } - - // Now do the submodules. First, list the submodules visible in the head - // commit and index. - // - // TODO: For now, we're just not going to return the status of submodules - // in a headless repository (which is better than our previous behavior of - // crashing); we should fix it so that we can accurately reflect staged - // submodules in the index. - - if (null !== headCommit) { - // Now we need to figure out which subs to list, and what paths to - // inspect in them. - - const openArray = yield SubmoduleUtil.listOpenSubmodules(repo); - const openSet = new Set(openArray); - const index = yield repo.index(); - const indexUrls = - yield SubmoduleConfigUtil.getSubmodulesFromIndex(repo, index); - const indexNames = Object.keys(indexUrls); - const headUrls = - yield SubmoduleConfigUtil.getSubmodulesFromCommit(repo, headCommit); - - // No paths specified, so we'll do all submodules, restricing to open - // ones based on options. - - let filterPaths; // map from sub name to paths to use - let subsToList; // array of subs that will be in result - const filtering = 0 !== options.paths.length; - if (filtering) { - filterPaths = yield SubmoduleUtil.resolvePaths(repo.workdir(), - options.paths, - indexNames, - openArray); - subsToList = Object.keys(filterPaths); - } - else { - // Compute the list by joining the list of submodules listed in the - // index and on head. - subsToList = Array.from(new Set( - Object.keys(headUrls).concat(indexNames))); + let paths = options.paths; + if (!options.showMetaChanges && (!paths || paths.length === 0)) { + paths = [SubmoduleConfigUtil.modulesFileName]; } - const commitTree = yield headCommit.getTree(); - - // Make a list of promises to read the status for each submodule, then - // evaluate them in parallel. - - const subStatMakers = subsToList.map(co.wrap(function *(name) { - const headUrl = headUrls[name]; - let headSha = null; - if (undefined !== headUrl) { - headSha = (yield commitTree.entryByPath(name)).sha(); + const status = yield DiffUtil.getRepoStatus( + repo, + tree, + paths, + options.ignoreIndex, + options.untrackedFilesOption); + // if showMetaChanges is off, keep .gitmodules changes only + if (options.showMetaChanges) { + args.staged = status.staged; + args.workdir = status.workdir; + } else { + const gitmodules = SubmoduleConfigUtil.modulesFileName; + if (gitmodules in status.staged) { + args.staged[gitmodules] = status.staged[gitmodules]; } - let indexSha = null; - const entry = index.getByPath(name); - if (entry) { - indexSha = entry.id.tostrS(); + if (gitmodules in status.workdir) { + args.workdir[gitmodules] = status.workdir[gitmodules]; } - let subRepo = null; - let status = null; - if (openSet.has(name)) { - subRepo = yield SubmoduleUtil.getRepo(repo, name); - status = yield exports.getRepoStatus(subRepo, { - paths: filtering ? filterPaths[name] : [], - showAllUntracked: options.showAllUntracked, - ignoreIndex: options.ignoreIndex, - }); - } - return yield exports.getSubmoduleStatus(subRepo, - status, - indexUrls[name] || null, - headUrls[name] || null, - indexSha, - headSha); - })); - - const subStats = yield subStatMakers; - - // And copy them into the arguments. + } + } - subsToList.forEach((name, i) => { - args.submodules[name] = subStats[i]; - }); + // Now do the submodules. + if (null !== headCommit) { + const {conflicts, submodules} = + yield getSubRepoStatus(repo, options, headCommit); + Object.assign(args.staged, conflicts); + args.submodules = submodules; } return new RepoStatus(args); }); + +/** + * 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.checkReadiness = function (status) { + assert.instanceOf(status, RepoStatus); + + if (null !== status.rebase) { + return (`\ +You're in the middle of a regular (not git-meta) rebase. +Before proceeding, you must complete the rebase in progress (by running +'git rebase --continue') or abort it (by running +'git rebase --abort').`); + } + if (status.isConflicted()) { + return (`\ +Please resolve outstanding conflicts before proceeding: +${PrintStatusUtil.printRepoStatus(status, "")}`); + } + if (null !== status.sequencerState) { + const command = + PrintStatusUtil.getSequencerCommand(status.sequencerState.type); + 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/stitch_util.js b/node/lib/util/stitch_util.js new file mode 100644 index 000000000..58d913242 --- /dev/null +++ b/node/lib/util/stitch_util.js @@ -0,0 +1,1102 @@ +/* + * Copyright (c) 2017, 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 NodeGit = require("nodegit"); + +const BulkNotesUtil = require("./bulk_notes_util"); +const Commit = require("./commit"); +const ConfigUtil = require("./config_util"); +const DoWorkQueue = require("./do_work_queue"); +const GitUtil = require("./git_util"); +const SubmoduleConfigUtil = require("./submodule_config_util"); +const SubmoduleUtil = require("./submodule_util"); +const TreeUtil = require("./tree_util"); +const UserError = require("./user_error"); + +const FILEMODE = NodeGit.TreeEntry.FILEMODE; + +/** + * @property {String} + */ +exports.allowedToFailNoteRef = "refs/notes/stitched/allowed_to_fail"; + +/** + * Return a set of meta-repo commits that are allowed to fail. + * + * @param {NodeGit.Repository} repo + * @return {Set} + */ +exports.readAllowedToFailList = co.wrap(function *(repo) { + assert.instanceOf(repo, NodeGit.Repository); + + const notes = + yield BulkNotesUtil.readNotes(repo, + exports.allowedToFailNoteRef); + return new Set(Object.keys(notes)); +}); + +/** + * The name of the note used to record conversion information. + * + * @property {String} + */ +exports.convertedNoteRef = "refs/notes/stitched/converted"; + +/** + * Return the content of the note used to record that a commit was stitched + * into the specified `stitchedSha`, or, if `null === stitchedSha`, that the + * commit could not be stitched. + * + * @param {String|null} stitchedSha + */ +exports.makeConvertedNoteContent = function (stitchedSha) { + if (null !== stitchedSha) { + assert.isString(stitchedSha); + } + return null === stitchedSha ? "" : stitchedSha; +}; + +/** + * Return the commit message to use for a stitch commit coming from the + * specified `metaCommit` that introduces the specified `subCommits`. + * + * @param {NodeGit.Commit} metaCommit + * @param {Object} subCommits from name to NodeGit.Commit + * @return {String} + */ +exports.makeStitchCommitMessage = function (metaCommit, subCommits) { + assert.instanceOf(metaCommit, NodeGit.Commit); + assert.isObject(subCommits); + + const metaAuthor = metaCommit.author(); + const metaName = metaAuthor.name(); + const metaEmail = metaAuthor.email(); + const metaWhen = metaAuthor.when(); + const metaTime = metaWhen.time(); + const metaOffset = metaWhen.offset(); + + // Sometimes (I don't know if this is libgit2 vs. git or what) otherwise + // matching messages may be missing line endings. + + const metaMessage = Commit.ensureEolOnLastLine(metaCommit.message()); + let result = metaCommit.message(); + + // Add information from submodule commits that differs from the the + // meta-repo commit. When all info (author, time, message) in a sub commit + // matches that of the meta-repo commit, skip it completely. + + Object.keys(subCommits).forEach(subName => { + const commit = subCommits[subName]; + const author = commit.author(); + const name = author.name(); + const email = author.email(); + let authorText = ""; + if (name !== metaName || email !== metaEmail) { + authorText = `Author: ${name} <${email}>\n`; + } + const when = author.when(); + let whenText = ""; + if (when.time() !== metaTime || when.offset() !== metaOffset) { + whenText = `Date: ${Commit.formatCommitTime(when)}\n`; + } + const message = Commit.ensureEolOnLastLine(commit.message()); + let messageText = ""; + if (message !== metaMessage) { + messageText = "\n" + message; + } + if ("" !== authorText || "" !== whenText || "" !== messageText) { + if (!result.endsWith("\n")) { + result += "\n"; + } + result += "\n"; + result += `From '${subName}'\n`; + result += authorText; + result += whenText; + result += messageText; + } + }); + return result; +}; + +/** + * The name of the note used to record conversion information. + * + * @property {String} + */ +exports.referenceNoteRef = "refs/notes/stitched/reference"; + +/** + * Return the content to be used for a note indicating that a stitched commit + * originated from the specified `metaRepoSha` and `subCommits`. + * + * @param {NodeGit.Repository} repo + * @param {String} metaRepoSha + * @param {Object} subCommits name to NodeGit.Commit + */ +exports.makeReferenceNoteContent = function (metaRepoSha, subCommits) { + assert.isString(metaRepoSha); + assert.isObject(subCommits); + const object = { + metaRepoCommit: metaRepoSha, + submoduleCommits: {}, + }; + Object.keys(subCommits).forEach(name => { + object.submoduleCommits[name] = subCommits[name].id().tostrS(); + }); + return JSON.stringify(object, null, 4); +}; + +/** + * From a map containing a shas mapped to sets of direct parents, and the + * specified starting `entry` sha, return a list of all shas ordered from least + * to most dependent, that is, no sha will appear in the list before any of its + * ancestors. If no relation exists between two shas, they will be ordered + * alphabetically. Note that it is valid for a sha to exist as a parent from a + * sha in `parentMap`, however, the behavior is undefined if there are entries + * in 'parentMap' that are not reachable from 'entry'. + * + * @param {String} entry + * @param {Object} parentMap from sha to Set of its direct parents + * @return {[String]} + */ +exports.listCommitsInOrder = function (entry, parentMap) { + assert.isString(entry); + assert.isObject(parentMap); + + // First, compute the generations of the commits. A generation '0' means + // that a commit has no parents. A generation '1' means that a commit + // depends only on commits with 0 parents, a generation N means that a + // commit depends only on commits with a generation less than N. + + const generations = {}; + let queue = [entry]; + while (0 !== queue.length) { + const next = queue[queue.length - 1]; + + // Exit if we've already computed this one; can happen if one gets into + // the queue more than once. + + if (next in generations) { + queue.pop(); + continue; // CONTINUE + } + let generation = 0; + const parents = parentMap[next] || []; + for (const parent of parents) { + const parentGeneration = generations[parent]; + if (undefined === parentGeneration) { + generation = undefined; + queue.push(parent); + } + else if (undefined !== generation) { + // If all parents computed thus far, recompute the max. It can + // not be less than or equal to any parent. + + generation = Math.max(generation, parentGeneration + 1); + } + } + if (undefined !== generation) { + // We were able to compute it, store and pop. + + generations[next] = generation; + queue.pop(); + } + } + + // Now we sort, placing lowest generation commits first. + + function compareCommits(a, b) { + const aGeneration = generations[a]; + const bGeneration = generations[b]; + if (aGeneration !== bGeneration) { + return aGeneration - bGeneration; // RETURN + } + + // 'a' can never be equal to 'b' because we're sorting keys. + + return a < b ? -1 : 1; + } + return Object.keys(parentMap).sort(compareCommits); +}; + +/** + * Return the `TreeUtilChange` object corresponding to the `.gitmodules` file + * synthesized in the specified `repo` from an original commit that had the + * specified `urls`; this modules will will contain only those urls that are + * being kept as submodules, i.e., for which the specified `keepAsSubmodule` + * returns true. + * + * @param {NodeGit.Repository} repo + * @param {Object} urls submodule name to url + * @param {(String) => Boolean} keepAsSubmodule + * @param {(String) => String|null} adjustPath + * @pram {TreeUtil.Change} + */ +exports.computeModulesFile = co.wrap(function *(repo, + urls, + keepAsSubmodule, + adjustPath) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isObject(urls); + assert.isFunction(keepAsSubmodule); + assert.isFunction(adjustPath); + + const keptUrls = {}; + for (let name in urls) { + const adjusted = adjustPath(name); + if (null !== adjusted && keepAsSubmodule(name)) { + keptUrls[adjusted] = urls[name]; + } + } + const modulesText = SubmoduleConfigUtil.writeConfigText(keptUrls); + const db = yield repo.odb(); + const BLOB = 3; + const id = yield db.write(modulesText, modulesText.length, BLOB); + return new TreeUtil.Change(id, FILEMODE.BLOB); +}); + +exports.changeCacheRef = "refs/notes/stitched/submodule-change-cache"; + +/** + * Add the specified `submoduleChanges` to the changed cache in the specified + * `repo`. + * + * @param {NodeGit.Repository} repo + * @param {Object} changes from sha to path to SubmoduleChange + */ +exports.writeSubmoduleChangeCache = co.wrap(function *(repo, changes) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isObject(changes); + + const cache = {}; + for (let sha in changes) { + const shaChanges = changes[sha]; + const cachedChanges = {}; + for (let path in shaChanges) { + const change = shaChanges[path]; + cachedChanges[path] = { + oldSha: change.oldSha, + newSha: change.newSha + }; + } + cache[sha] = JSON.stringify(cachedChanges, null, 4); + } + yield BulkNotesUtil.writeNotes(repo, exports.changeCacheRef, cache); +}); + +/** + * Read the cached list of submodule changes per commit in the specified + * `repo`. + * + * @param {NodeGit.Repository} repo + * @return {Object} sha to path to SubmoduleChange-like object + */ +exports.readSubmoduleChangeCache = co.wrap(function *(repo) { + assert.instanceOf(repo, NodeGit.Repository); + + const cached = yield BulkNotesUtil.readNotes(repo, exports.changeCacheRef); + return BulkNotesUtil.parseNotes(cached); +}); + +/** + * Return a map of the submodule changes for the specified `commits` in the + * specified `repo`. + * + * @param {[NodeGit.Commit]} commits + * @return {Object} sha -> name -> SubmoduleChange + */ +exports.listSubmoduleChanges = co.wrap(function *(repo, commits) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isArray(commits); + + const result = yield exports.readSubmoduleChangeCache(repo); + let numListed = Object.keys(result).length; + console.log("Loaded", numListed, "from cache"); + + console.log("Listing submodule changes"); + + let toCache = {}; // bulk up changes that we'll write out periodically + let caching = false; // true if we're in the middle of writing the cache + const writeCache = co.wrap(function *() { + + // This code is reentrant, so we skip out if we're already writing some + // notes. Also, we don't want to write tiny note changes, so we + // arbitrarily wait until we've got 1,000 changes. + + if (caching || 1000 > Object.keys(toCache).length) { + return; // RETURN + } + + + caching = true; + const oldCache = toCache; + toCache = {}; + yield exports.writeSubmoduleChangeCache(repo, oldCache); + caching = false; + }); + + const listForCommit = co.wrap(function *(commit) { + const sha = commit.id().tostrS(); + if (sha in result) { + // was cached + return; // RETURN + } + const parents = yield commit.getParents(); + let parentCommit = null; + if (0 !== parents.length) { + parentCommit = parents[0]; + } + const changes = yield SubmoduleUtil.getSubmoduleChanges(repo, + commit, + parentCommit, + true); + result[sha] = changes; + toCache[sha] = changes; + ++numListed; + if (0 === numListed % 100) { + console.log("Listed", numListed, "of", commits.length); + } + yield writeCache(); + }); + yield DoWorkQueue.doInParallel(commits, listForCommit); + + // If there's anything left in the cache, write it out now. + + yield exports.writeSubmoduleChangeCache(repo, toCache); + + return result; +}); + +/** + * Return a map from submodule name to list of objects containing the + * fields: + * - `metaSha` -- the meta-repo sha from which this subodule sha came + * - `url` -- url configured for the submodule + * - `sha` -- sha to fetch for the submodule + * this map contains entries for all shas introduced in the specified `toFetch` + * list in the specified `repo`. Note that the behavior is undefined unless + * `toFetch` is ordered from least to most dependent commits. Perform at most + * the specified `numParallel` operations in parallel. Do not process entries + * for submodules for which the specified `keepAsSubmodule` returns true or the + * specified `adjustPath` returns null. + * + * @param {NodeGit.Repository} repo + * @param {[NodeGit.Commit]} toFetch + * @param {Object} commitChanges sha->name->SubmoduleChange + * @param {(String) => Boolean} keepAsSubmodule + * @param {(String) => String|null} adjustPath + * @param {Number} numParallel + * @return {Object} map from submodule name -> { metaSha, url, sha } + */ +exports.listFetches = co.wrap(function *(repo, + toFetch, + commitChanges, + keepAsSubmodule, + adjustPath) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isArray(toFetch); + assert.isObject(commitChanges); + assert.isFunction(keepAsSubmodule); + assert.isFunction(adjustPath); + + let urls = {}; + + // So that we don't have to continuously re-read the `.gitmodules` file, we + // will assume that submodule URLs never change. + + const getUrl = co.wrap(function *(commit, sub) { + let subUrl = urls[sub]; + + // If we don't have the url for this submodule, load them. + + if (undefined === subUrl) { + const newUrls = + yield SubmoduleConfigUtil.getSubmodulesFromCommit(repo, commit); + urls = Object.assign(urls, newUrls); + subUrl = urls[sub]; + } + return subUrl; + }); + + const result = {}; + + const addTodo = co.wrap(function *(commit, subName, sha) { + let subTodos = result[subName]; + if (undefined === subTodos) { + subTodos = []; + result[subName] = subTodos; + } + const subUrl = yield getUrl(commit, subName); + subTodos.push({ + metaSha: commit.id().tostrS(), + url: subUrl, + sha: sha, + }); + }); + + toFetch = toFetch.slice().reverse(); + for (const commit of toFetch) { + const changes = commitChanges[commit.id().tostrS()]; + + // look for added or modified submodules + + for (let name in changes) { + const change = changes[name]; + if (null !== adjustPath(name) && !keepAsSubmodule(name)) { + if (null !== change.newSha) { + yield addTodo(commit, name, change.newSha); + } + } + } + } + return result; +}); + +/** + * Return true if any parent of the specified `commit` other than the first has + * the specified `sha` for the submodule having the specified `name` in the + * specified repo. + * + * @param {NodeGit.Repository} repo + * @param {NodEGit.Commit} commit + * @param {String} name + * @param {String} sha + */ +exports.sameInAnyOtherParent = co.wrap(function *(repo, commit, name, sha) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(commit, NodeGit.Commit); + assert.isString(name); + assert.isString(sha); + + const parents = yield commit.getParents(); + for (const parent of parents.slice(1)) { + const tree = yield parent.getTree(); + try { + const entry = yield tree.entryByPath(name); + if (entry.sha() === sha) { + return true; + } + } catch (e) { + // missing in parent + } + } + return false; +}); + +/** + * Write and return a new "stitched" commit for the specified `commit` in the + * specified `repo`. If the specified `keepAsSubmodule` function returns true + * for the path of a submodule, continue to treat it as a submodule in the new + * commit and do not stitch it. The specified `adjustPath` function may be + * used to move the contents of a submodule in the worktree and/or request that + * its changes be omitted completely (by returning `null`); this function is + * applied to the paths of submodules that are stitched and those that are kept + * as submodules. + * + * If the specified `skipEmpty` is true and the generated commit would be empty + * because either: + * + * 1. It would have an empty tree and no parents + * 2. It would have the same tree as its first parent + * + * Then do not generate a commit; instead, return the first parent (and an + * empty `subCommits` map), or null if there are no parents. + * + * Allow commits in the specified `allowed_to_fail` to reference invalid + * submodule commits; skip those (submodule) commits. + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} commit + * @param {Object} subChanges path to SubmoduleChange + * @param {[NodeGit.Commit]} parents + * @param {(String) => Boolean} keepAsSubmodule + * @param {(String) => String|null} adjustPath + * @param {Bool} skipEmpty + * @param {Set of String} allowed_to_fail + * @return {Object} + * @return {NodeGit.Commit} [return.stitchedCommit] + * @return {Object} return.subCommits path to NodeGit.Commit + */ +exports.writeStitchedCommit = co.wrap(function *(repo, + commit, + subChanges, + parents, + keepAsSubmodule, + adjustPath, + skipEmpty, + allowed_to_fail) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(commit, NodeGit.Commit); + assert.isObject(subChanges); + assert.isArray(parents); + assert.isFunction(keepAsSubmodule); + assert.isFunction(adjustPath); + assert.isBoolean(skipEmpty); + assert.instanceOf(allowed_to_fail, Set); + + let updateModules = false; // if any kept subs added or removed + const changes = {}; // changes and additions + let subCommits = {}; // included submodule commits + const stitchSub = co.wrap(function *(name, oldName, sha) { + let subCommit; + try { + subCommit = yield repo.getCommit(sha); + } + catch (e) { + const metaSha = commit.id().tostrS(); + if (allowed_to_fail.has(metaSha)) { + return; // RETURN + } + throw new UserError(`\ +On meta-commit ${metaSha}, ${name} is missing ${sha}. +To add to allow this submodule change to be skipped, run: +git notes --ref ${exports.allowedToFailNoteRef} add -m skip ${metaSha}`); + } + const subTreeId = subCommit.treeId(); + changes[name] = new TreeUtil.Change(subTreeId, FILEMODE.TREE); + + // Now, record this submodule change as introduced by this commit, + // unless it already existed in another of its parents, i.e., it was + // merged in. + + const alreadyExisted = + yield exports.sameInAnyOtherParent(repo, commit, oldName, sha); + if (!alreadyExisted) { + subCommits[name] = subCommit; + } + }); + + function changeKept(name, newSha) { + const id = NodeGit.Oid.fromString(newSha); + changes[name] = new TreeUtil.Change(id, FILEMODE.COMMIT); + } + + for (let name in subChanges) { + const mapped = adjustPath(name); + if (null === mapped) { + continue; // CONTINUE + } + const newSha = subChanges[name].newSha; + changes[mapped] = null; + + if (keepAsSubmodule(name)) { + updateModules = true; + if (null !== newSha) { + changeKept(name, newSha); + } + } else if (null !== newSha) { + yield stitchSub(mapped, name, newSha); + } + } + + // If any kept submodules were added or removed, rewrite the modules + // file. + + if (updateModules) { + const newUrls = + yield SubmoduleConfigUtil.getSubmodulesFromCommit(repo, commit); + const content = yield exports.computeModulesFile(repo,newUrls, + keepAsSubmodule, + adjustPath); + changes[SubmoduleConfigUtil.modulesFileName] = content; + } + + let newCommit = null; + + if (!skipEmpty || 0 !== Object.keys(changes).length) { + // If we've got changes or are not skipping commits, we make one. + + let parentTree = null; + if (0 !== parents.length) { + const parentCommit = parents[0]; + parentTree = yield parentCommit.getTree(); + } + + const newTree = yield TreeUtil.writeTree(repo, parentTree, changes); + + const commitMessage = exports.makeStitchCommitMessage(commit, + subCommits); + const newCommitId = yield NodeGit.Commit.create( + repo, + null, + commit.author(), + commit.committer(), + commit.messageEncoding(), + commitMessage, + newTree, + parents.length, + parents); + newCommit = yield repo.getCommit(newCommitId); + } else if (0 !== parents.length) { + // If we skip this commit, map to its parent to indicate that whenever + // we see this commit in the future, substitute its parent. + + newCommit = parents[0]; + subCommits = {}; + } + return { + stitchedCommit: newCommit, + subCommits: subCommits, + }; +}); + +/** + * In the specified `repo`, perform the specified `subFetches`. Use the + * specified `url` to resolve relative submodule urls. Each entry in the + * `subFetches` array is an object containing the fields: + * + * - url -- submodule configured url + * - sha -- submodule sha + * - metaSha -- sha it was introcued on + * + * @param {NodeGit.Repository} repo + * @param {String} url + * @param {[Object]} subFetches + */ +exports.fetchSubCommits = co.wrap(function *(repo, name, url, subFetches) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isString(url); + assert.isArray(subFetches); + + for (const fetch of subFetches) { + const subUrl = SubmoduleConfigUtil.resolveSubmoduleUrl(url, fetch.url); + + const sha = fetch.sha; + let fetched; + try { + fetched = yield GitUtil.fetchSha(repo, subUrl, sha, name + "/"); + } + catch (e) { + console.log("Fetch of", subUrl, "failed:", e.message); + return; // RETURN + } + + // Make a ref to protect fetched submodule commits from GC. + + if (fetched) { + console.log("Fetched:", sha, "from", subUrl); + } + } +}); + +/** + * Return a function for adjusting paths while performing a join operation. + * For paths that start with the specified `root` path, return the part of that + * path following `root/`; for other paths return null. If `root` is null, + * return the identity function. + * + * @param {String|null} root + * @return {(String) => String|null} + */ +exports.makeAdjustPathFunction = function (root) { + if (null !== root) { + assert.isString(root); + } + if (null === root) { + return (x) => x; // RETURN + } + if (!root.endsWith("/")) { + root += "/"; + } + return function (filename) { + if (filename.startsWith(root)) { + return filename.substring(root.length); + } + return null; + }; +}; + +/** + * Return the SHA in the specified `content` or null if it represents no sha. + * + * @param {String} content + * @retiurm {String|null} + */ +exports.readConvertedContent = function (content) { + assert.isString(content); + return content === "" ? null : content; +}; + +/** + * Return the converted commit for the specified `sha` in the specified `repo`, + * null if it could not be converted, or undefined if it has not been + * attempted. + * + * @param {NodeGit.Repository} repo + * @param {String} sha + * @return {String|null|undefined} + */ +exports.readConvertedCommit = co.wrap(function *(repo, sha) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isString(sha); + const result = yield GitUtil.readNote(repo, exports.convertedNoteRef, sha); + return result === null ? + undefined : + exports.readConvertedContent(result.message()); +}); + +/** + * Return the previously converted commits in the specified `repo`. + * + * @param {Repo} + * @return {Object} map from string to string or null + */ +exports.readConvertedCommits = co.wrap(function *(repo) { + + // We use "" to indicate that a commit could not be converted. + + const result = + yield BulkNotesUtil.readNotes(repo, exports.convertedNoteRef); + for (const [key, oldSha] of Object.entries(result)) { + const sha = exports.readConvertedContent(oldSha); + try { + yield repo.getCommit(sha); + result[key] = sha; + } catch (e) { + // We have the note but not the commit, delete from cache + delete result[key]; + } + } + return result; +}); + +/** + * Return a function that can return the result of previous attempts to convert + * the specified `commit` in the specified `repo`. If there is a value in + * `cache`, return it, otherwise try to read the note and then cache that + * result. + * + * @param {NodeGit.Repository} repo + * @param {Object} cache + * @return {(String) => Promise} + */ +exports.makeGetConvertedCommit = function (repo, cache) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isObject(cache); + return co.wrap(function *(sha) { + assert.isString(sha); + + if (sha in cache) { + return cache[sha]; + } + const result = yield exports.readConvertedCommit(repo, sha); + + try { + yield repo.getCommit(result); + } catch (e) { + // We have the note but not the commit; treat as missing + return undefined; + } + + cache[sha] = result; + return result; + }); +}; + +/** + * List, in order of least to most dependent, the specified `commit` and its + * ancestors in the specified `repo`. + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} commit + * @param {(String) => Promise(String|null|undefined)} + * @return {[NodeGit.Commit]} + */ +exports.listCommitsToStitch = co.wrap(function *(repo, + commit, + getConvertedCommit) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(commit, NodeGit.Commit); + assert.isFunction(getConvertedCommit); + + const toList = [commit]; + const allParents = {}; + const commitMap = {}; + + while (0 !== toList.length) { + const next = toList[toList.length - 1]; + const nextSha = next.id().tostrS(); + toList.pop(); + + // Skip processing commits we've seen. + + if (nextSha in allParents) { + continue; // CONTINUE + } + + // If it's converted, so, implicitly, are its parents. + + const converted = yield getConvertedCommit(nextSha); + if (undefined !== converted) { + continue; // CONTINUE + } + const parents = yield next.getParents(); + const parentShas = []; + for (const parent of parents) { + toList.push(parent); + const parentSha = parent.id().tostrS(); + parentShas.push(parentSha); + } + allParents[nextSha] = parentShas; + commitMap[nextSha] = next; + } + const commitShas = exports.listCommitsInOrder(commit.id().tostrS(), + allParents); + return commitShas.map(sha => commitMap[sha]); +}); + +/** + * Stitch the repository at the specified `repoPath` starting with the + * specified `commitish` and point the specified `targetBranchName` to the + * result. Use the specified `options.keepAsSubmodule` function to determine + * which paths to keep as submodule rather than stitching. If the optionally + * specified `options.joinRoot` is provided, create a history containing only + * commits touching that tree of the meta-repo, rooted relative to + * `options.joinRoot`. Perform at most the specified `options.numParallel` + * fetch operations at once. If the specified `options.fetch` is provied and + * true, fetch submodule commits as needed using the specified `url` to resolve + * relative submodule URLs. If the specified `skipEmpty` is provided and true, + * omit, from the generated history, commits whose trees do not differ from + * their first parents. The behavior is undefined if `true === fetch` and + * `options.url` is not provided. + * + * TBD: Write a unit test for this; this logic used to be in the command + * handler, and all the pieces are tested. + * + * @param {String} repoPath + * @param {String} commitish + * @param {String} targetBranchName + * @param {Object} options + * @param {Bool} [options.fetch] + * @param {String} [options.url] + * @param {(String) => Bool} options.keepAsSubmodule + * @param {String} [options.joinRoot] + * @param {Number} numParallel + * @param {Bool} [skipEmpty] + */ +exports.stitch = co.wrap(function *(repoPath, + commitish, + targetBranchName, + options) { + assert.isString(repoPath); + assert.isString(commitish); + assert.isString(targetBranchName); + assert.isObject(options); + + let fetch = false; + let url = null; + if ("fetch" in options) { + assert.isBoolean(options.fetch); + fetch = options.fetch; + assert.isString(options.url, "url required with fetch"); + url = options.url; + } + + assert.isFunction(options.keepAsSubmodule); + + let joinRoot = null; + if ("joinRoot" in options) { + assert.isString(options.joinRoot); + joinRoot = options.joinRoot; + } + + assert.isNumber(options.numParallel); + + let skipEmpty = false; + if ("skipEmpty" in options) { + assert.isBoolean(options.skipEmpty); + skipEmpty = options.skipEmpty; + } + + const repo = yield NodeGit.Repository.open(repoPath); + const annotated = yield GitUtil.resolveCommitish(repo, commitish); + if (null === annotated) { + throw new Error(`Could not resolve ${commitish}.`); + } + const commit = yield repo.getCommit(annotated.id()); + + console.log("Listing previously converted commits."); + + let convertedCommits = {}; + if (options.preloadCache) { + convertedCommits = yield exports.readConvertedCommits(repo); + } + const getConverted = exports.makeGetConvertedCommit(repo, + convertedCommits); + + console.log("listing unconverted ancestors of", commit.id().tostrS()); + + const commitsToStitch = + yield exports.listCommitsToStitch(repo, commit, getConverted); + + console.log("listing submodule changes"); + + const changes = yield exports.listSubmoduleChanges(repo, commitsToStitch); + + const adjustPath = exports.makeAdjustPathFunction(joinRoot); + + console.log(commitsToStitch.length, "to stitch"); + + if (fetch) { + console.log("listing fetches"); + const fetches = yield exports.listFetches(repo, + commitsToStitch, + changes, + options.keepAsSubmodule, + adjustPath); + console.log("Found", Object.keys(fetches).length, "subs to fetch."); + const subNames = Object.keys(fetches); + let subsRepo = repo; + const config = yield repo.config(); + /* + * The stitch submodules repository is a separate repository + * to hold just the submodules that are being stitched (no + * meta repository commits). It must be an alternate + * of this repository (the repository that stitching is + * being done in), so that we can access the objects + * that we fetch to it. This lets us have a second alternate + * repository for just the meta repository commits. And that + * saves a few seconds on meta repository fetches. + */ + const subsRepoPath = yield ConfigUtil.getConfigString( + config, + "gitmeta.stitchSubmodulesRepository"); + if (subsRepoPath !== null) { + subsRepo = yield NodeGit.Repository.open(subsRepoPath); + } + const doFetch = co.wrap(function *(name, i) { + const subFetches = fetches[name]; + const fetchTimeMessage = `\ +(${i + 1}/${subNames.length}) -- fetched ${subFetches.length} SHAs for \ +${name}`; + console.time(fetchTimeMessage); + yield exports.fetchSubCommits(subsRepo, name, url, subFetches); + console.timeEnd(fetchTimeMessage); + }); + yield DoWorkQueue.doInParallel(subNames, + doFetch, + {limit: options.numParallel}); + } + + console.log("Now stitching"); + let lastCommit = null; + + let records = {}; + + const writeNotes = co.wrap(function *() { + console.log( + `Writing notes for ${Object.keys(records).length} commits.`); + const convertedNotes = {}; + const referenceNotes = {}; + for (let sha in records) { + const record = records[sha]; + const stitchedCommit = record.stitchedCommit; + const stitchedSha = + null === stitchedCommit ? null : stitchedCommit.id().tostrS(); + convertedNotes[sha] = + exports.makeConvertedNoteContent(stitchedSha); + if (null !== stitchedSha) { + referenceNotes[stitchedSha] = + exports.makeReferenceNoteContent(sha, record.subCommits); + } + } + yield BulkNotesUtil.writeNotes(repo, + exports.referenceNoteRef, + referenceNotes); + yield BulkNotesUtil.writeNotes(repo, + exports.convertedNoteRef, + convertedNotes); + records = {}; + }); + + const allowed_to_fail = yield exports.readAllowedToFailList(repo); + + for (let i = 0; i < commitsToStitch.length; ++i) { + const next = commitsToStitch[i]; + + const nextSha = next.id().tostrS(); + const parents = yield next.getParents(); + const newParents = []; + for (const parent of parents) { + const newParentSha = yield getConverted(parent.id().tostrS()); + if (null !== newParentSha && undefined !== newParentSha) { + const newParent = yield repo.getCommit(newParentSha); + newParents.push(newParent); + } + } + + const result = yield exports.writeStitchedCommit( + repo, + next, + changes[nextSha], + newParents, + options.keepAsSubmodule, + adjustPath, + skipEmpty, + allowed_to_fail); + records[nextSha] = result; + const newCommit = result.stitchedCommit; + const newSha = null === newCommit ? null : newCommit.id().tostrS(); + convertedCommits[nextSha] = newSha; + const desc = null === newCommit ? "skipped" : newCommit.id().tostrS(); + const log = `\ +Of [${commitsToStitch.length}] done [${i + 1}] : ${nextSha} -> ${desc}`; + console.log(log); + + if (10000 <= Object.keys(records).length) { + yield writeNotes(); + } + + // If `writeStitchedCommit` returned null to indicate that it did not + // make a commit (because it would have been empty), leave `lastCommit` + // unchanged. + + lastCommit = newCommit || lastCommit; + } + yield writeNotes(); + + // Delete submodule change cache if we succeeded; we won't need these + // submodules again. + + if (0 !== Object.keys(changes)) { + NodeGit.Reference.remove(repo, exports.changeCacheRef); + } + + if (null !== lastCommit) { + console.log( + `Updating ${targetBranchName} to ${lastCommit.id().tostrS()}.`); + yield NodeGit.Branch.create(repo, targetBranchName, lastCommit, 1); + } +}); + diff --git a/node/lib/util/submodule_change.js b/node/lib/util/submodule_change.js new file mode 100644 index 000000000..5790d577e --- /dev/null +++ b/node/lib/util/submodule_change.js @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2017, 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; + +/** + * @class SubmoduleChanges.Change + * + * This class describes the changes from `oldSha` to `newSha` as well as + * the destination that the changes will be appliedd: `ourSha` + */ +class SubmoduleChange { + + /** + * Creat a new `Changed` object having the specified `oldSha` and `newSha` + * values. The behavior is undefined if `oldSha === newSha`. Note that a + * null `oldSha` implies that the submodule was added, a null `newSha` + * implies that it was removed, and if neither is null, the submodule was + * changed. In a 3 way merge, `oldSha` is the merge base, `newSha` is the + * right side of the merge and `ourSha` is the left side. + * + * @param {String | null} oldSha sha from which changes are computed + * @param {String | null} newSha sha to which changes are computed + * @param {String | null} ourSha sha against which changes will be applied + */ + constructor(oldSha, newSha, ourSha) { + assert.notEqual(oldSha, newSha); + if (null !== oldSha) { + assert.isString(oldSha); + } + if (null !== newSha) { + assert.isString(newSha); + } + + if (null !== ourSha) { + assert.isString(ourSha); + } + + this.d_oldSha = oldSha; + this.d_newSha = newSha; + this.d_ourSha = ourSha; + Object.freeze(this); + } + + /** + * This property represents the previous value of the sha for a submodule + * change. If this value is null, then the submodule was added. + * + * @property {String | null} oldSha + */ + get oldSha() { + return this.d_oldSha; + } + + /** + * This property represents the new value of a sha for a submodule. If it + * is null, then the submodule was removed. + * + * @property {String | null} newSha + */ + get newSha() { + return this.d_newSha; + } + + /** + * This property represents the value of a sha to which a submodule change + * is applying. If it is null, then the change can only be applied to the + * current head. If it not null, it depends on users to choose to its value + * or head sha to apply submodule changes. + * + * @property {String | null} ourSha + */ + get ourSha() { + return this.d_ourSha; + } + + /** + * True if the submodule has been deleted in this change. + */ + get deleted() { + return this.d_newSha === null; + } +} + +module.exports = SubmoduleChange; diff --git a/node/lib/util/submodule_config_util.js b/node/lib/util/submodule_config_util.js index e3b8c85af..a298efe70 100644 --- a/node/lib/util/submodule_config_util.js +++ b/node/lib/util/submodule_config_util.js @@ -50,9 +50,19 @@ const co = require("co"); const fs = require("fs-promise"); const NodeGit = require("nodegit"); const path = require("path"); +const rimraf = require("rimraf"); const url = require("url"); -const UserError = require("./user_error"); +const DoWorkQueue = require("./do_work_queue"); +const GitUtil = require("./git_util"); +const SparseCheckoutUtil = require("./sparse_checkout_util"); +const UserError = require("./user_error"); + +function doRimRaf(fileName) { + return new Promise(callback => { + return rimraf(fileName, {}, callback); + }); +} const CONFIG_FILE_NAME = "config"; @@ -70,6 +80,139 @@ const CONFIG_FILE_NAME = "config"; */ exports.modulesFileName = ".gitmodules"; +/** + * Remove the first found configuration entry for the submodule having the + * specified `submoduleName` in the config file in the specified `repoPath`; do + * nothing if no entry is found. + * + * @param {String} repoPath + * @param {String} submoduleName + */ +exports.clearSubmoduleConfigEntry = + co.wrap(function *(repoPath, submoduleName) { + assert.isString(repoPath); + assert.isString(submoduleName); + + // Using a very stupid algorithm here to find and remove the submodule + // entry. This logic could be smarter (maybe use regexes) and more + // efficition (stream in and out). Note that we use only synchronous + // when mutating the config file to avoid race conditions. + + const configPath = path.join(repoPath, "config"); + const configText = fs.readFileSync(configPath, "utf8"); + const configLines = configText.split("\n"); + const newConfigLines = []; + const searchString = "[submodule \"" + submoduleName + "\"]"; + + let found = false; + let inSubmoduleConfig = false; + + // Loop through the file and push, onto 'newConfigLines' any lines that + // aren't part of the bad submodule section. + + for (const line of configLines) { + if (!found && !inSubmoduleConfig && line === searchString) { + inSubmoduleConfig = true; + found = true; + } + else if (inSubmoduleConfig) { + // If we see a line starting with "[" while we're in the submodule + // section, we can get out of it. + + if (0 !== line.length && line[0] === "[") { + inSubmoduleConfig = false; + } + } + if (!inSubmoduleConfig) { + newConfigLines.push(line); + } + } + + // If we didn't find the submodule, don't write the data back out. + + if (found) { + newConfigLines.push(""); // one more new line + fs.writeFileSync(configPath, newConfigLines.join("\n")); + } + + // Silence warning about no yield statement. + yield (Promise.resolve()); +}); + +/** + * De-initialize the repositories having the specified `submoduleNames` in the + * specified `repo`. + * + * Note that after calling this method, + * `SparseCheckoutUtil.setSparseBitsAndWriteIndex` must be called to update + * the SKIP_WORKTREE flags for closed submodules. + * + * @async + * @param {NodeGit.Repository} repo + * @param {String[]} submoduleNames + */ +exports.deinit = co.wrap(function *(repo, submoduleNames) { + assert.instanceOf(repo, NodeGit.Repository); + assert.isArray(submoduleNames); + + const sparse = yield SparseCheckoutUtil.inSparseMode(repo); + + const deinitOne = co.wrap(function *(submoduleName) { + + // This operation is a major pain, first because libgit2 does not + // provide any direct methods to do the equivalent of 'git deinit', and + // second because nodegit does not expose the method that libgit2 does + + // De-initting a submodule requires the following things: + // 1. Confirms there are no unpushed (to any remote) commits + // or uncommited changes (including new files). + // 2. Remove all files under the path of the submodule, but not the + // directory itself, which would look to Git as if we were trying + // to remove the submodule. + // 3. Remove the entry for the submodule from the '.git/config' file. + // 4. Remove the directory .git/modules/ + + // We will clear out the path for the submodule. + + const rootDir = repo.workdir(); + const submodulePath = path.join(rootDir, submoduleName); + + if (sparse) { + // Clear out submodule's contents. + + yield doRimRaf(submodulePath); + + // Clear parent directories until they're all gone or we find one + // that's non-empty. + + let next = path.dirname(submoduleName); + try { + while ("." !== next) { + yield fs.rmdir(path.join(rootDir, next)); + next = path.dirname(next); + } + } catch (e) { + // It's possible that we're closing d/x and d/y, and + // that in doing so, we end up trying to delete d + // twice, which would give ENOENT. + if ("ENOTEMPTY" !== e.code && "ENOENT" !== e.code) { + throw e; + } + } + } else { + const files = yield fs.readdir(submodulePath); + yield files.map(co.wrap(function *(filename) { + yield doRimRaf(path.join(submodulePath, filename)); + })); + } + yield exports.clearSubmoduleConfigEntry(repo.path(), submoduleName); + }); + yield DoWorkQueue.doInParallel(submoduleNames, deinitOne); + if (yield SparseCheckoutUtil.inSparseMode(repo)) { + SparseCheckoutUtil.removeFromSparseCheckoutFile(repo, submoduleNames); + } +}); + /** * Return the relative path from the working directory of a submodule having * the specified `subName` to its .git directory. @@ -116,7 +259,16 @@ exports.resolveUrl = function (baseUrl, relativeUrl) { if (!baseUrl.endsWith("/")) { baseUrl += "/"; } - const res = url.resolve(baseUrl, relativeUrl); + + // Handle prefixed urls like cache::https://... + const prefixStart = baseUrl.lastIndexOf("::"); + let prefix = ""; + if (prefixStart !== -1) { + prefix = baseUrl.substring(0, prefixStart + 2); + baseUrl = baseUrl.substring(prefixStart + 2); + } + + const res = prefix + url.resolve(baseUrl, relativeUrl); // Trim trailing "/" which will stick around in some situations (e.g., "." // for relativeUrl) but not others, to give uniform results. @@ -234,6 +386,26 @@ exports.getSubmodulesFromCommit = co.wrap(function *(repo, commit) { return exports.parseSubmoduleConfig(data); }); +/** + * Return a map from submodule name to url in the specified `repo` by parsing + * the blob content of `.gitmodules` + * + * @private + * @async + * @param {NodeGit.Repository} repo repo where blob can be read + * @param {NodeGit.IndexEntry} entry index entry for `.gitmodules` + * @return {Object} map from name to url + */ +exports.getSubmodulesFromIndexEntry = co.wrap(function *(repo, entry) { + assert.instanceOf(repo, NodeGit.Repository); + if (!entry) { + return {}; // RETURN + } + assert.instanceOf(entry, NodeGit.IndexEntry); + const blob = yield repo.getBlob(entry.id); + return exports.parseSubmoduleConfig(blob.toString()); +}); + /** * Return a map from submodule name to url in the specified `repo`. * @@ -248,15 +420,34 @@ exports.getSubmodulesFromIndex = co.wrap(function *(repo, index) { assert.instanceOf(index, NodeGit.Index); const entry = index.getByPath(exports.modulesFileName); - if (undefined === entry) { - return {}; // RETURN - } - const oid = entry.id; - const blob = yield repo.getBlob(oid); - const data = blob.toString(); - return exports.parseSubmoduleConfig(data); + return yield exports.getSubmodulesFromIndexEntry(repo, entry); }); +/** + * Return a map from submodule name to url in the specified `repo`. + * + * @private + * @async + * @param {NodeGit.Repository} repo + * @return {Object} map from name to url + */ +exports.getSubmodulesFromWorkdir = function (repo) { + assert.instanceOf(repo, NodeGit.Repository); + + const modulesPath = path.join(repo.workdir(), exports.modulesFileName); + let data; + try { + data = fs.readFileSync(modulesPath, "utf8"); + } + catch (e) { + // File doesn't exist, no submodules configured. + } + if (undefined === data) { + return {}; + } + return exports.parseSubmoduleConfig(data); +}; + /** * Return the path to the config file for the specified `repo`. * @@ -287,16 +478,11 @@ exports.getConfigLines = function (name, url) { /** * Write the entry to set up the the submodule having the specified `name` to - * and `url` to the `.git/config` file for the repo at the specified + * and `url` to the `config` file for the repo at the specified * `repoPath`. The behavior is undefined if there is already an entry for * `name` in the config file. * * @async - * @param {NodeGit.Repository} repo - * @param {String} name - * @param {String} url - * - * @async * @param {String} repoPath * @param {String} name * @param {String} url @@ -305,7 +491,8 @@ exports.initSubmodule = co.wrap(function *(repoPath, name, url) { assert.isString(repoPath); assert.isString(name); assert.isString(url); - const configPath = path.join(repoPath, ".git", CONFIG_FILE_NAME); + yield exports.clearSubmoduleConfigEntry(repoPath, name); + const configPath = path.join(repoPath, CONFIG_FILE_NAME); const lines = exports.getConfigLines(name, url); // Do this sync to avoid race conditions for now. @@ -354,27 +541,32 @@ exports.getTemplatePath = co.wrap(function *(repo) { * @param {String} name * @param {String} url * @param {String|null} templatePath + * @param {Boolean} bare * @return {NodeGit.Repository} */ exports.initSubmoduleAndRepo = co.wrap(function *(repoUrl, metaRepo, name, url, - templatePath) { + templatePath, + bare) { if (null !== repoUrl) { assert.isString(repoUrl); } assert.instanceOf(metaRepo, NodeGit.Repository); assert.isString(name); assert.isString(url); + assert.isBoolean(bare); if (null !== templatePath) { assert.isString(templatePath); } // Update the `.git/config` file. - const repoPath = metaRepo.workdir(); - yield exports.initSubmodule(repoPath, name, url); + const repoPath = metaRepo.isBare ? + path.dirname(metaRepo.path()) : + metaRepo.workdir(); + yield exports.initSubmodule(metaRepo.path(), name, url); // Then, initialize the repository. We pass `initExt` the right set of // flags so that it will set it up as a git link. @@ -383,23 +575,36 @@ exports.initSubmoduleAndRepo = co.wrap(function *(repoUrl, const FLAGS = NodeGit.Repository.INIT_FLAG; + const initRepo = co.wrap(function *() { + return bare ? + yield NodeGit.Repository.init(subRepoDir, 1) : + yield NodeGit.Repository.initExt(subRepoDir, { + workdirPath: exports.computeRelativeWorkDir(name), + flags: FLAGS.NO_DOTGIT_DIR | FLAGS.MKPATH | + FLAGS.RELATIVE_GITLINK | + (null === templatePath ? 0 : FLAGS.EXTERNAL_TEMPLATE), + templatePath: templatePath + }); + }); // See if modules repo exists. - + let subRepo = null; try { - yield NodeGit.Repository.open(subRepoDir); + subRepo = bare ? + yield NodeGit.Repository.openBare(subRepoDir) : + yield NodeGit.Repository.open(subRepoDir); + // re-init if previously opened as bare + if (!bare && subRepo.isBare()) { + subRepo = yield initRepo(); + } } catch (e) { // Or, make it if not. - - yield NodeGit.Repository.initExt(subRepoDir, { - workdirPath: exports.computeRelativeWorkDir(name), - flags: FLAGS.NO_DOTGIT_DIR | FLAGS.MKPATH | - FLAGS.RELATIVE_GITLINK | - (null === templatePath ? 0 : FLAGS.EXTERNAL_TEMPLATE), - templatePath: templatePath - }); + subRepo = yield initRepo(); } + if (bare) { + return subRepo; + } // Write out the .git file. Note that `initExt` configured to write a // relative .git directory will not write this file successfully if the @@ -433,81 +638,6 @@ exports.initSubmoduleAndRepo = co.wrap(function *(repoUrl, return result; }); -/** - * Return a dictionary mapping from submodule name to URL for the describes the - * submodule state resulting from merging the specified `lhs` and `rhs` - * dictionaries, who have the specified `mergeBase` dictionary as their merge - * base; or, `null` if there is a conflict between the two that cannot be - * resolved. - * - * @param {Object} lhs - * @param {Object} rhs - * @param {Object} mergeBase - * @return {Object|null} - */ -exports.mergeSubmoduleConfigs = function (lhs, rhs, mergeBase) { - assert.isObject(lhs); - assert.isObject(rhs); - assert.isObject(mergeBase); - - let result = {}; - let lhsValue; - let rhsValue; - let mergeBaseValue; - - // First, loop through `lhs`. For each value, if we do not find a - // conflict, and the value hasn't been removed in `rhs`, copy it into - // `result`. - - for (let key in lhs) { - lhsValue = lhs[key]; - rhsValue = rhs[key]; - mergeBaseValue = mergeBase[key]; - - // If the value has changed between left and right, neither is the same - // as what was in the mergeBase, we have a conflict. - - if (lhsValue !== rhsValue && - rhsValue !== mergeBaseValue && - lhsValue !== mergeBaseValue) { - return null; - } - - // If the value exists in `rhs` (it wasn't deleted), or it wasn't in - // `mergeBase` (meaning it wasn't in `rhs` because `lhs` added it), - // then copy it to `result`. - - if (undefined !== rhsValue || undefined === mergeBaseValue) { - result[key] = lhsValue; - } - - } - for (let key in rhs) { - lhsValue = result[key]; // use 'result' as it may be smaller - rhsValue = rhs[key]; - mergeBaseValue = mergeBase[key]; - - // We will have a conflict only when the value doesn't exist in 'lhs' - // -- otherwise, it would have been detected already. So, a conflict - // exists when it's gone from the `lhs` (deleted), but present in the - // `rhs`, and there is a value in `mergeBase` that's different from - // `rhs`. - - if (undefined === lhsValue && - undefined !== mergeBaseValue && - rhsValue !== mergeBaseValue) { - return null; - } - - // Otherwise, we want to copy the value over if it's a change. - - else if (rhsValue !== mergeBaseValue) { - result[key] = rhsValue; - } - } - return result; -}; - /** * Return the text for a `.gitmodules` file containing the specified * `submodules` definitions. @@ -524,7 +654,7 @@ exports.writeConfigText = function (urls) { for (let i = 0; i < keys.length; ++i) { name = keys[i]; url = urls[name]; - result += `\ + result += `\n\ [submodule "${name}"] \tpath = ${name} \turl = ${url} @@ -532,3 +662,78 @@ exports.writeConfigText = function (urls) { } return result; }; + +/** + * Write, to the `.gitmodules` file, the specified `urls` in the specified + * `index`, in the specified `repo` and stage the change to the index. + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Index} index + * @param {Object} urls submodule name to url + * @param {Boolean} cached do not write to the working tree + */ +exports.writeUrls = co.wrap(function *(repo, index, urls, cached) { + if (undefined === cached) { + cached = false; + } + else { + assert.isBoolean(cached); + } + + const repoPath = repo.isBare ? + path.dirname(repo.path()) : + repo.workdir(); + const modulesPath = path.join(repoPath, + exports.modulesFileName); + const newConf = exports.writeConfigText(urls); + if (newConf.length === 0) { + if (!cached) { + try { + yield fs.unlink(modulesPath); + } catch (e) { + //maybe it didn't exist prior to this + } + } + try { + yield index.removeByPath(exports.modulesFileName); + } catch (e) { + // ditto + } + } + else { + if (!cached) { + yield fs.writeFile(modulesPath, newConf); + yield index.addByPath(exports.modulesFileName); + } else { + // If we use this method of staging the change along with the + // `fs.writeFile` above in the `!cached` case, it will randomly + // confuse Git into thinking the `.gitmodules` file is modified + // even though a `git diff` shows no changes. I suspect we're + // writing some garbage flags somwewhere. You can replicate (after + // reverting the change that introduces this comment: + //```bash + //$ while :; + //> do + //> write-repos -o 'a=B|x=U:C3-2 t=Sa:1;Bmaster=3;Bfoo=2' + //> git -C x meta cherry-pick foo + //> git -C x status + //> sleep 0.01 + //> done + // + // and wait, about 1/4 times the `status` command will show a dirty + // `.gitmodules` file. + // + // TODO: track down why this confuses libgit2, *or* get rid of the + // caching logic; I don't think it buys us anything. + + const oid = yield GitUtil.hashObject(repo, newConf); + const sha = oid.toString(); + const entry = new NodeGit.IndexEntry(); + entry.path = exports.modulesFileName; + entry.mode = NodeGit.TreeEntry.FILEMODE.BLOB; + entry.id = NodeGit.Oid.fromString(sha); + entry.flags = entry.flagsExtended = 0; + yield index.add(entry); + } + } +}); diff --git a/node/lib/util/submodule_fetcher.js b/node/lib/util/submodule_fetcher.js index 7268c9a8c..ed6823785 100644 --- a/node/lib/util/submodule_fetcher.js +++ b/node/lib/util/submodule_fetcher.js @@ -53,17 +53,25 @@ class SubmoduleFetcher { * if provided, or the URL of the remote named "origin" in `repo` * otherwise. If `repo` has no remote named "origin", and `fetchSha` is * called for a submodule that has a relativre URL, throw a `UserError`. + * If `null === commit`, no URLS are available. * - * @param {NodeGit.Repository} repo - * @param {NodeGit.Commit} commit + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit|null} commit */ constructor(repo, commit) { assert.instanceOf(repo, NodeGit.Repository); - assert.instanceOf(commit, NodeGit.Commit); + if (null !== commit) { + assert.instanceOf(commit, NodeGit.Commit); + } this.d_repo = repo; this.d_commit = commit; - this.d_urls = null; + + if (null === commit) { + this.d_urls = {}; + } else { + this.d_urls = null; + } // d_metaOrigin may have three types of values: // 1. undefined -- we haven't tried to access it yet @@ -81,7 +89,7 @@ class SubmoduleFetcher { } /** - * @param {NodeGit.Commit} commit commit associated with this fetcher + * @param {NodeGit.Commit|null} commit commit associated with this fetcher */ get commit() { return this.d_commit; diff --git a/node/lib/util/submodule_rebase_util.js b/node/lib/util/submodule_rebase_util.js new file mode 100644 index 000000000..efa7dca6a --- /dev/null +++ b/node/lib/util/submodule_rebase_util.js @@ -0,0 +1,385 @@ +/* + * Copyright (c) 2018, 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 path = require("path"); +const rimraf = require("rimraf"); + +const ConfigUtil = require("./config_util"); +const GitUtil = require("./git_util"); +const DoWorkQueue = require("./do_work_queue"); +const RebaseFileUtil = require("./rebase_file_util"); +const RepoStatus = require("./repo_status"); +const SparseCheckoutUtil = require("./sparse_checkout_util"); +const SubmoduleUtil = require("./submodule_util"); + +/** + * Continue the rebase in the specified `repo` and return an object describing + * any generated commits and the sha of the conflicted commit if there was one. + * The behavior is undefined unless `true === repo.isRebasing()`. + * + * @param {NodeGit.Repository} repo + * @return {Object} return + * @return {Object} return.commits + * @return {String|null} return.conflictedCommit + */ +const continueRebase = co.wrap(function *(repo) { + assert.instanceOf(repo, NodeGit.Repository); + assert(repo.isRebasing()); + + const rebase = yield NodeGit.Rebase.open(repo); + const idx = rebase.operationCurrent(); + const op = rebase.operationByIndex(idx); + return yield exports.processRebase(repo, rebase, op); +}); + +const cleanupRebaseDir = co.wrap(function *(repo) { + assert.instanceOf(repo, NodeGit.Repository); + + const gitDir = repo.path(); + const rebaseDir = yield RebaseFileUtil.findRebasingDir(gitDir); + if (null !== rebaseDir) { + const rebasePath = path.join(gitDir, rebaseDir); + yield (new Promise(callback => { + return rimraf(rebasePath, {}, callback); + })); + } +}); + +/** + * Make a new commit on the head of the specified `repo` having the same + * committer and message as the specified original `commit`, and return its + * sha. + * + * TODO: independent test + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} commit + * @return {String} + */ +exports.makeCommit = co.wrap(function *(repo, commit) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(commit, NodeGit.Commit); + + const defaultSig = yield ConfigUtil.defaultSignature(repo); + const metaCommit = yield repo.createCommitOnHead([], + commit.author(), + defaultSig, + commit.message()); + return metaCommit.tostrS(); +}); + +/** + * Finish the specified `rebase` in the specified `repo`. Note that this + * method is necessary only as a workaround for: + * https://github.com/twosigma/git-meta/issues/115. + * + * TODO: independent test + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Rebase} rebase + */ +exports.callFinish = co.wrap(function *(repo, rebase) { + const result = rebase.finish(); + const CLEANUP_FAILURE = -15; + if (CLEANUP_FAILURE === result) { + yield cleanupRebaseDir(repo); + } +}); + +/** + * Call `next` on the specified `rebase`; return the rebase operation for the + * rebase or null if there is no further operation. + * + * @async + * @private + * @param {NodeGit.Rebase} rebase + * @return {RebaseOperation|null} + */ +exports.callNext = co.wrap(function *(rebase) { + try { + return yield rebase.next(); + } + catch (e) { + // It's cumbersome, but the way the nodegit library indicates + // that you are at the end of the rebase is by throwing an + // exception. At this point we call `finish` on the rebase and + // break out of the contaiing while loop. + + if (e.errno === NodeGit.Error.CODE.ITEROVER) { + return null; + } + throw e; + } +}); + +/** + * Process the specified `rebase` for the specified `repo`, beginning with the + * specified `op`. Return an object describing any encountered error and + * commits made. If successful, clean up and finish the rebase. If + * `null === op`, finish the rebase and return. + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Rebase} rebase + * @param {NodeGit.RebaseOperation|null} op + * @return {Object} + * @return {Object} return.commits + * @return {String|null} return.conflictedCommit + * @returns {Boolean} return.ffwd + */ +exports.processRebase = co.wrap(function *(repo, rebase, op) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(rebase, NodeGit.Rebase); + if (null !== op) { + assert.instanceOf(op, NodeGit.RebaseOperation); + } + const result = { + commits: {}, + conflictedCommit: null, + ffwd: false, + }; + const signature = yield ConfigUtil.defaultSignature(repo); + while (null !== op) { + const index = yield repo.index(); + if (index.hasConflicts()) { + result.conflictedCommit = op.id().tostrS(); + return result; // RETURN + } + let newCommit; + try { + newCommit = yield rebase.commit(null, signature, null); + } catch (e) { + // If there's nothing to commit, `NodeGit.Rebase.commit` will throw + // an error. If that's the case, we want to just ignore the + // operation and move on, as Git does. + } + if (undefined !== newCommit) { + const originalCommit = op.id().tostrS(); + result.commits[newCommit.tostrS()] = originalCommit; + } + op = yield exports.callNext(rebase); + } + yield exports.callFinish(repo, rebase); + return result; +}); + +/** + * Rebase the commits from the specified `branch` commit on the HEAD of the + * specified `repo`. If the optionally specified `upstream` is provided, + * rewrite only commits beginning with `upstream`; otherwise, rewrite all + * reachable commits. Return an object containing a map that describes any + * written commits and an error message if some part of the rewrite failed. + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} commit + * @param {NodeGit.Commit|null} upstream + * @return {Object} + * @return {Object} return.commits new sha to original sha + * @return {String|null} return.conflictedCommit error message if failed + * @return {Boolean} return.ffwd true if fast-forwarded + */ +exports.rewriteCommits = co.wrap(function *(repo, branch, upstream) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(branch, NodeGit.Commit); + if (null !== upstream) { + assert.instanceOf(upstream, NodeGit.Commit); + } + const head = yield repo.head(); + const headSha = head.target().tostrS(); + const branchSha = branch.id().tostrS(); + const upstreamSha = (upstream && upstream.id().tostrS()) || null; + + const result = { + commits: {}, + conflictedCommit: null, + ffwd: false, + }; + + // If we're up-to-date with the commit to be rebased onto, return + // immediately. Detach head as this is the normal behavior. + + if (headSha === branchSha || + (yield NodeGit.Graph.descendantOf(repo, headSha, branchSha))) { + repo.detachHead(); + return result; // RETURN + } + + // If the upstream is non-null, but is an ancestor of HEAD or equal to it, + // libgit2 will try to rewrite commits that should not be rewritten and + // fail. In this case, we set upstream to null, indicating at all commits + // should be included (as they should). + + if (null !== upstream) { + if (upstreamSha === headSha || + (yield NodeGit.Graph.descendantOf(repo, headSha, upstreamSha))) { + upstream = null; + } + } + + // We can do a fast-forward if `branch` and its entire history should be + // included. This requires two things to be true: + // 1. `branch` is a descendant of `head` or equal to `head` + // 2. `null === upstream` (implying that all ancestors are to be included) + + if (null === upstream) { + if (yield NodeGit.Graph.descendantOf(repo, branchSha, headSha)) { + yield GitUtil.setHeadHard(repo, branch); + result.ffwd = true; + return result; // RETURN + } + } + + const ontoAnnotated = yield NodeGit.AnnotatedCommit.fromRef(repo, head); + const branchAnnotated = + yield NodeGit.AnnotatedCommit.lookup(repo, branch.id()); + let upstreamAnnotated = null; + if (null !== upstream) { + upstreamAnnotated = + yield NodeGit.AnnotatedCommit.lookup(repo, upstream.id()); + } + const rebase = yield NodeGit.Rebase.init(repo, + branchAnnotated, + upstreamAnnotated, + ontoAnnotated, + null); + const op = yield exports.callNext(rebase); + return yield exports.processRebase(repo, rebase, op); +}); + +/** + * Return a conflict description for the submodule having the specified `name`. + * + * TODO: independent test + * + * @param {String} name + * @return {String} + */ +exports.subConflictErrorMessage = function (name) { + return `Submodule ${colors.red(name)} is conflicted.\n`; +}; + +/** + * Log a message indicating that the specified `commit` is being applied. + * + * @param {NodeGit.Commit} commit + */ +exports.logCommit = function (commit) { + assert.instanceOf(commit, NodeGit.Commit); + console.log(`Applying '${commit.message().split("\n")[0]}'`); +}; + +/** + * Continue rebases in the submodules in the specifed `repo` having the + * `index and `status`. If staged changes are found in submodules that don't + * have in-progress rebases, commit them using the specified message and + * signature from the specified original `commit`. If there are any changes to + * commit, make a new commit in the meta-repo. Return an object describing + * any commits that were generated along with an error message if any continues + * failed. + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Index} index + * @param {RepoStatus} status + * @param {NodeGit.Commit} commit + * @return {Object} + * @return {String|null} metaCommit + * @return {Object} return.commits map from name to sha map + * @return {Object} return.newCommits from name to newly-created commits + * @return {String|null} return.errorMessage + */ +exports.continueSubmodules = co.wrap(function *(repo, index, status, commit) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(index, NodeGit.Index); + assert.instanceOf(status, RepoStatus); + assert.instanceOf(commit, NodeGit.Commit); + + exports.logCommit(commit); + const commits = {}; + const newCommits = {}; + const subs = status.submodules; + let errorMessage = ""; + const continueSub = co.wrap(function *(name) { + const sub = subs[name]; + const workdir = sub.workdir; + if (null === workdir) { + // Return early if the submodule is closed. + return; // RETURN + } + const subStatus = workdir.status; + const rebaseInfo = subStatus.rebase; + const subRepo = yield SubmoduleUtil.getRepo(repo, name); + if (null === rebaseInfo) { + if (0 !== Object.keys(subStatus.staged).length) { + const id = yield subRepo.createCommitOnHead([], + commit.author(), + commit.committer(), + commit.message()); + newCommits[name] = id.tostrS(); + } + yield index.addByPath(name); + + // Return early if no rebase in this submodule. + return; // RETURN + } + console.log(`Submodule ${colors.blue(name)} continuing \ +rewrite from ${colors.green(rebaseInfo.originalHead)} onto \ +${colors.green(rebaseInfo.onto)}.`); + const result = yield continueRebase(subRepo); + commits[name] = result.commits; + if (null !== result.conflictedCommit) { + errorMessage += exports.subConflictErrorMessage(name); + } + else { + yield index.addByPath(name); + yield index.conflictRemove(name); + } + }); + yield DoWorkQueue.doInParallel(Object.keys(subs), continueSub); + yield SparseCheckoutUtil.setSparseBitsAndWriteIndex(repo, index); + const result = { + errorMessage: "" === errorMessage ? null : errorMessage, + commits: commits, + newCommits: newCommits, + metaCommit: null, + }; + if (null === result.errorMessage) { + if (status.isIndexDeepClean()) { + console.log("Nothing to commit."); + } else { + result.metaCommit = yield exports.makeCommit(repo, commit); + } + } + return result; +}); diff --git a/node/lib/util/submodule_util.js b/node/lib/util/submodule_util.js index b1549bd6c..8467e628d 100644 --- a/node/lib/util/submodule_util.js +++ b/node/lib/util/submodule_util.js @@ -41,10 +41,14 @@ const NodeGit = require("nodegit"); const fs = require("fs-promise"); const path = require("path"); +const DoWorkQueue = require("../util/do_work_queue"); const GitUtil = require("./git_util"); const Submodule = require("./submodule"); +const SubmoduleChange = require("./submodule_change"); const SubmoduleFetcher = require("./submodule_fetcher"); const SubmoduleConfigUtil = require("./submodule_config_util"); +const UserError = require("./user_error"); +const Walk = require("./walk"); /** * Return the names of the submodules (visible or otherwise) for the index @@ -128,7 +132,7 @@ exports.getSubmoduleShasForCommit = // believes is the proper commit for that submodule. const tree = yield commit.getTree(); - const shaGetters = submoduleNames.map(co.wrap(function *(name) { + const shaGetter = co.wrap(function *(name) { try { const entry = yield tree.entryByPath(name); return entry.sha(); @@ -136,8 +140,8 @@ exports.getSubmoduleShasForCommit = catch (e) { return null; } - })); - const shas = yield shaGetters; + }); + const shas = yield DoWorkQueue.doInParallel(submoduleNames, shaGetter); let result = {}; for (let i = 0; i < submoduleNames.length; ++i) { const sha = shas[i]; @@ -151,18 +155,19 @@ exports.getSubmoduleShasForCommit = /** * Return a map from submodule name to string representing the expected sha1 - * for its repository in the specified `repo` on the branch having the - * specified `branchName`. + * for its repository in the specified `repo` on the specified `commitish`. * * @async * @param {NodeGit.Repository} repo * @param {String} branchName * @return {Object} */ -exports.getSubmoduleShasForBranch = co.wrap(function *(repo, branchName) { +exports.getSubmoduleShasForCommitish = co.wrap(function *(repo, commitish) { assert.instanceOf(repo, NodeGit.Repository); - assert.isString(branchName); - const commit = yield repo.getBranchCommit(branchName); + assert.isString(commitish); + const annotated = yield NodeGit.AnnotatedCommit.fromRevspec(repo, + commitish); + const commit = yield NodeGit.Commit.lookup(repo, annotated.id()); const submoduleNames = yield exports.getSubmoduleNamesForCommit(repo, commit); @@ -191,12 +196,60 @@ exports.getCurrentSubmoduleShas = function (index, submoduleNames) { if (entry) { result.push(entry.id.tostrS()); } else { - result.push(`${colors.red("missing entry")}`); + // Probably a merge conflict + result.push(null); } } return result; }; +const gitReservedNames = new Set(["HEAD", "FETCH_HEAD", "ORIG_HEAD", + "COMMIT_EDITMSG", "index", "config", + "logs", "rr-cache", "hooks", "info", + "objects", "refs"]); +/** + * Return a list of submodules from .git/modules -- that is, + * approximately, those which we have ever opened. + */ +exports.listAbsorbedSubmodules = co.wrap(function*(repo) { + const modules_dir = path.join(repo.path(), "modules"); + const out = []; + + if (!fs.existsSync(modules_dir)) { + return out; + } + yield Walk.walk(modules_dir, function*(root, files, dirs) { + if (files.indexOf("HEAD") !== -1) { + // We've hit an actual git module -- don't recurse + // further. It's possible that our module contains other + // modules (e.g. if foo/bar/baz gets moved to + // foo/bar/baz/fleem). If so, really weird things could + // happen -- e.g. .git/modules/foo/bar/baz/objects could + // secretly contain another entire git repo. There are + // cases here that regular git can't handle (for instance, + // if you move a submodule to a subdirectory of itself + // named "config"). But the vast majority of the time, + // nested repos won't have name conflicts with git + // reserved dir names, so we'll just eliminate those + // reserved name, and recurse the rest if any. + + const filtered = []; + for (const name of dirs) { + if (!gitReservedNames.has(name)) { + filtered.push(name); + } + } + dirs.splice(0, dirs.length, ...filtered); + out.push(root.substring(modules_dir.length + 1)); + } + }); + + + return out; + +}); + + /** * Return true if the submodule having the specified `submoduleName` in the * specified `repo` is visible and false otherwise. @@ -235,6 +288,25 @@ exports.getRepo = function (metaRepo, name) { return NodeGit.Repository.open(submodulePath); }; +/** + * Return the `Repository` for the absorbed bare repo for th submodule + * having the specified `name` in the specified `metaRepo`. That's + * the one in meta/.git/modules/... + * + * @async + * @param {NodeGit.Repository} metaRepo + * @param {String} name + * @return {NodeGit.Repository} + */ +exports.getBareRepo = function (metaRepo, name) { + assert.instanceOf(metaRepo, NodeGit.Repository); + assert.isString(name); + + // metaRepo.path() returns the path to the gitdir. + const submodulePath = path.join(metaRepo.path(), "modules", name); + return NodeGit.Repository.openBare(submodulePath); +}; + /** * Return an array containing a list of the currently open submodules of the * specified `repo`. @@ -258,14 +330,20 @@ exports.listOpenSubmodules = co.wrap(function *(repo) { // In at least one situation -- rebase -- Git will add a submodule to // the `.git/config` file without actually opening it, meaning that the // `.git/config` file cannot be used as the single source of truth and we - // must verify with `isVisble`, which looks for a repositories `.git` file. - + // must verify with `isVisible`, which looks for a repositories `.git` file. + // Also, we need to make sure that the submodule is included in the + // `.gitmodules` file. If a user abandons a submodule while adding it, it + // may have a lingering reference in `.git/config` even though it's been + // removed from `.gitmodules`. + + const configuredSubmodules = + SubmoduleConfigUtil.getSubmodulesFromWorkdir(repo); const openInConfig = SubmoduleConfigUtil.parseOpenSubmodules(text); const visCheckers = openInConfig.map(sub => exports.isVisible(repo, sub)); const visFlags = yield visCheckers; let result = []; openInConfig.forEach((name, i) => { - if (visFlags[i]) { + if ((name in configuredSubmodules) && visFlags[i]) { result.push(name); } }); @@ -289,7 +367,7 @@ exports.getSubmoduleRepos = co.wrap(function *(repo) { const openSet = new Set(openArray); const submoduleNames = yield exports.getSubmoduleNames(repo); - const openers = submoduleNames.map(co.wrap(function *(name) { + const opener = co.wrap(function *(name) { const isVisible = openSet.has(name); if (!isVisible) { return null; @@ -299,49 +377,30 @@ exports.getSubmoduleRepos = co.wrap(function *(repo) { name: name, repo: subRepo, }; - })); - const repos = yield openers; + }); + const repos = yield DoWorkQueue.doInParallel(submoduleNames, opener); return repos.filter(x => x !== null); }); /** - * Return a summary of the submodule SHAs changed by the specified `commitId` in - * the specified `repo`, and flag denoting whether or not the `.gitmodules` - * file was changed. + * Return a summary of the submodule SHA changes in the specified `diff`. Fail + * if the specified 'allowMetaChanges' is not true and `diff` contains + * non-submodule changes to the meta-repo. * * @asycn - * @param {NodeGit.Repository} repo - * @param {NodeGit.Commit} commit - * @return {Object} - * @return {Object} return.added map from path to SHA - * @return {Object} return.changed map from path to new and old SHAs - * @return {Object} return.removed map from path to SHA - * @return {Boolean} return.modulesFileChanged true if modules file changed + * @param {NodeGit.Diff} diff + * @param {Bool} allowMetaChanges + * @return {Object} map from name to `SubmoduleChange` */ -exports.getSubmoduleChanges = co.wrap(function *(repo, commit) { - assert.instanceOf(repo, NodeGit.Repository); - assert.instanceOf(commit, NodeGit.Commit); - - // We calculate the changes of a commit against it's first parent. If it - // has no parents, then the calculation is against an empty tree. +exports.getSubmoduleChangesFromDiff = function (diff, allowMetaChanges) { + assert.instanceOf(diff, NodeGit.Diff); + assert.isBoolean(allowMetaChanges); - let parentTree = null; - const parents = yield commit.getParents(); - if (0 !== parents.length) { - parentTree = yield parents[0].getTree(); - } - - const tree = yield commit.getTree(); - const diff = yield NodeGit.Diff.treeToTree(repo, parentTree, tree, null); const num = diff.numDeltas(); - const result = { - added: {}, - changed: {}, - removed: {}, - modulesFileChanged: false, - }; + const result = {}; const DELTA = NodeGit.Diff.DELTA; const COMMIT = NodeGit.TreeEntry.FILEMODE.COMMIT; + const modulesFileName = SubmoduleConfigUtil.modulesFileName; for (let i = 0; i < num; ++i) { const delta = diff.getDelta(i); switch (delta.status()) { @@ -358,55 +417,114 @@ exports.getSubmoduleChanges = co.wrap(function *(repo, commit) { const newFile = delta.newFile(); const path = newFile.path(); if (COMMIT === newFile.mode()) { - result.changed[path] = { - "new": newFile.id().tostrS(), - "old": delta.oldFile().id().tostrS(), - }; - } - else if (SubmoduleConfigUtil.modulesFileName === path) { - result.modulesFileChanged = true; + result[path] = new SubmoduleChange( + delta.oldFile().id().tostrS(), + newFile.id().tostrS(), + null); + } else if (!allowMetaChanges && path !== modulesFileName) { + throw new UserError(`\ +Modification to meta-repo file ${colors.red(path)} is not supported.`); } } break; case DELTA.ADDED: { const newFile = delta.newFile(); const path = newFile.path(); if (COMMIT === newFile.mode()) { - result.added[newFile.path()] = newFile.id().tostrS(); - } - else if (SubmoduleConfigUtil.modulesFileName === path) { - result.modulesFileChanged = true; + result[path] = new SubmoduleChange(null, + newFile.id().tostrS(), + null); + } else if (!allowMetaChanges && path !== modulesFileName) { + throw new UserError(`\ +Addition to meta-repo of file ${colors.red(path)} is not supported.`); } } break; case DELTA.DELETED: { const oldFile = delta.oldFile(); const path = oldFile.path(); if (COMMIT === oldFile.mode()) { - result.removed[oldFile.path()] = oldFile.id().tostrS(); - } - else if (SubmoduleConfigUtil.modulesFileName === path) { - result.modulesFileChanged = true; + result[path] = new SubmoduleChange(oldFile.id().tostrS(), + null, + null); + } else if (!allowMetaChanges && path !== modulesFileName) { + throw new UserError(`\ +Deletion of meta-repo file ${colors.red(path)} is not supported.`); } } break; } } return result; +}; + +/** + * Return a summary of the submodule SHAs changed by the specified `commit` + * in the specified `repo`, and flag denoting whether or not the `.gitmodules` + * file was changed. If 'commit' contains changes to the meta-repo and the + * specified 'allowMetaChanges' is not true, throw a 'UserError'. If the + * specified `baseCommit` is provided, calculate changes between it and + * `commit`; otherwise, calculate changes between `commit` and its first + * parent. + * + * @asycn + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} commit + * @param {NodeGit.Commit|null} baseCommit + * @param {Bool} allowMetaChanges + * @return {Object} map from name to `SubmoduleChange` + */ +exports.getSubmoduleChanges = co.wrap(function *(repo, + commit, + baseCommit, + allowMetaChanges) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(commit, NodeGit.Commit); + if (null !== baseCommit) { + assert.instanceOf(baseCommit, NodeGit.Commit); + } + assert.isBoolean(allowMetaChanges); + + // We calculate the changes of a commit against its first parent. If it + // has no parents, then the calculation is against an empty tree. + + let baseTree = null; + if (null !== baseCommit) { + baseTree = yield baseCommit.getTree(); + } else { + const parents = yield commit.getParents(); + if (0 !== parents.length) { + baseTree = yield parents[0].getTree(); + } + } + + const tree = yield commit.getTree(); + const diff = yield NodeGit.Diff.treeToTree(repo, baseTree, tree, null); + return yield exports.getSubmoduleChangesFromDiff(diff, allowMetaChanges); }); /** * Return the states of the submodules in the specified `commit` in the - * specified `repo`. + * specified `repo`. If the specified 'names' is not null, return only + * submodules in 'names'; otherwise, return all submodules. * * @async * @param {NodeGit.Repository} repo * @param {NodeGit.Commit} commit + * @param {String[]|null} names * @return {Object} map from submodule name to `Submodule` object */ -exports.getSubmodulesForCommit = co.wrap(function *(repo, commit) { +exports.getSubmodulesForCommit = co.wrap(function *(repo, commit, names) { assert.instanceOf(repo, NodeGit.Repository); assert.instanceOf(commit, NodeGit.Commit); + if (null !== names) { + assert.isArray(names); + } const urls = yield SubmoduleConfigUtil.getSubmodulesFromCommit(repo, commit); - const names = Object.keys(urls); + if (null === names) { + names = Object.keys(urls); + } + else { + names = names.filter(n => n in urls); + } const shas = yield exports.getSubmoduleShasForCommit(repo, names, commit); let result = {}; names.forEach(name => { @@ -418,19 +536,23 @@ exports.getSubmodulesForCommit = co.wrap(function *(repo, commit) { /** * Return the list of submodules, listed in the specified `indexSubNames`, that * are a descendant of the specified `dir`, including (potentially) `dir` - * itself (unless `dir` is suffixed with '/'), in the specified `repo`. The - * behavior is undefined unless `dir` is empty or refers to a valid path within - * `repo`. Note that if `"" === dir`, the result will be all submodules. + * itself (unless `dir` is suffixed with '/'). + * + * if includeParents is true, submodules that would be parent + * directories of `dir` are are also included * * @param {NodeGit.Repository} repo * @param {String} dir * @param {String []} indexSubNames + * @param {Boolean} includeParents * @return {String[]} */ -exports.getSubmodulesInPath = co.wrap(function *(workdir, dir, indexSubNames) { - assert.isString(workdir); +exports.getSubmodulesInPath = function (dir, indexSubNames, includeParents) { assert.isString(dir); assert.isArray(indexSubNames); + if (includeParents === undefined) { + includeParents = false; + } if ("" !== dir) { assert.notEqual("/", dir[0]); assert.notEqual(".", dir); @@ -439,106 +561,181 @@ exports.getSubmodulesInPath = co.wrap(function *(workdir, dir, indexSubNames) { if ("" === dir) { return indexSubNames; } - const subs = new Set(indexSubNames); - const result = []; - const listForFilename = co.wrap(function *(filepath) { - if (subs.has(filepath)) { - result.push(filepath); - } - else { - const absPath = path.join(workdir, filepath); - const stat = yield fs.stat(absPath); - if (stat.isDirectory()) { - const subdirs = yield fs.readdir(absPath); - yield subdirs.map(filename => { - return listForFilename(path.join(filepath, filename)); - }); - } + // test if the short path is a parent dir of the long path + const isParentDir = (shortPath, longPath) => { + return longPath.startsWith(shortPath) && ( + shortPath[shortPath.length-1] === "/" || + longPath[shortPath.length] === "/" + ); + }; + const result = []; + for (const subPath of indexSubNames) { + if (subPath === dir) { + return [dir]; // RETURN + } else if (isParentDir(dir, subPath)) { + result.push(subPath); + } else if (includeParents && isParentDir(subPath, dir)) { + result.push(subPath); } - }); - yield listForFilename(dir); + } return result; -}); +}; /** * Return the list of submodules found in the specified `paths` in the * specified meta-repo `workdir`, containing the submodules having the * specified `submoduleNames`. Treat paths as being relative to the specified - * `cwd`. Throw a `UserError` if an invalid path is encountered, and log - * warnings for valid paths containing no submodules. + * `cwd`. Throw a `UserError` if an path outside of the workdir is + * encountered. If a path inside the workdir contains no submodules, + * either log a warning, or, if throwOnMissing is set, throw a `UserError`. * - * @async * @param {String} workdir * @param {String} cwd * @param {String[]} submoduleNames * @param {String[]} paths + * @param {Boolean} throwOnMissing * @return {String[]} */ -exports.resolveSubmoduleNames = co.wrap(function *(workdir, - cwd, - submoduleNames, - paths) { +exports.resolveSubmoduleNames = function (workdir, + cwd, + submoduleNames, + paths, + throwOnMissing) { assert.isString(workdir); assert.isString(cwd); assert.isArray(submoduleNames); assert.isArray(paths); - const subLists = yield paths.map(co.wrap(function *(filename) { + const subLists = paths.map(filename => { // Compute the relative path for `filename` from the root of the repo, // and check for invalid values. - const relPath = yield GitUtil.resolveRelativePath(workdir, - cwd, - filename); - const result = yield exports.getSubmodulesInPath(workdir, - relPath, - submoduleNames); + const relPath = GitUtil.resolveRelativePath(workdir, + cwd, + filename); + const result = exports.getSubmodulesInPath(relPath, + submoduleNames, + false); if (0 === result.length) { - console.warn(`\ -No submodules found from ${colors.yellow(filename)}.`); + const msg = `\ +No submodules found from ${colors.yellow(filename)}.`; + if (throwOnMissing) { + throw new UserError(msg); + } else { + console.warn(msg); + } } return result; - })); + }); return subLists.reduce((a, b) => a.concat(b), []); -}); +}; + +/** + * Return a map from `paths` to the list of of submodules found under those + * paths in the specified meta-repo `workdir`, containing the submodules + * having the specified `submoduleNames`. Treat paths as being relative to + * the specified `cwd`. Throw a `UserError` if an path outside of the workdir + * is encountered. If a path inside the workdir contains no submodules, + * either log a warning, or, if throwOnMissing is set, throw a `UserError`. + * + * @param {String} workdir + * @param {String} cwd + * @param {String[]} submoduleNames + * @param {String[]} paths + * @param {Boolean} throwOnMissing + * @return {String[]} + */ +exports.resolveSubmodules = function (workdir, + cwd, + submoduleNames, + paths, + throwOnMissing) { + assert.isString(workdir); + assert.isString(cwd); + assert.isArray(submoduleNames); + assert.isArray(paths); + + const byFilename = {}; + paths.forEach(filename => { + // Compute the relative path for `filename` from the root of the repo, + // and check for invalid values. + const relPath = GitUtil.resolveRelativePath(workdir, + cwd, + filename); + const result = exports.getSubmodulesInPath(relPath, + submoduleNames, + true); + if (0 === result.length) { + const msg = `\ +No submodules found from ${colors.yellow(filename)}.`; + if (throwOnMissing) { + throw new UserError(msg); + } else { + console.warn(msg); + } + } + byFilename[filename] = result; + }); + + const out = {}; + for (let [filename, paths] of Object.entries(byFilename)) { + for (const path of paths) { + if (out[path]) { + if (!out[path].includes(filename)) { + out[path].push(filename); + } + } else { + out[path] = [filename]; + } + } + } + return out; +}; + /** * Return a map from submodule name to an array of paths (relative to the root - * of each submodule) identified by the specified `paths` relative to the root - * of the specified `workdir`, indicating one of the submodule names in the - * specified `indexSubNames`. Check each path to see if it points into one of - * the specified `openSubmodules`, and add the relative offset to the paths for - * that submodule if it does. If any path in `paths` contains a submodule - * entirely (as opposed to a sub-path within it), it will be mappped to an - * empty array (regardless of whether or not any sub-path in that submodule is - * identified). + * of each submodule) identified by the specified `paths`, indicating one of + * the submodule names in the specified `indexSubNames`. Check each path to + * see if it points into one of the specified `openSubmodules`, and add the + * relative offset to the paths for that submodule if it does. If any path in + * `paths` contains a submodule entirely (as opposed to a sub-path within it), + * it will be mappped to an empty array (regardless of whether or not any + * sub-path in that submodule is identified). * - * @param {String} workdir * @param {String []} paths * @param {String []} indexSubNames * @param {String []} openSubmodules + * @param {Boolean} failOnUnprefixed * @return {Object} map from submodule name to array of paths */ -exports.resolvePaths = co.wrap(function *(workdir, - paths, - indexSubNames, - openSubmodules) { - assert.isString(workdir); +exports.resolvePaths = function (paths, indexSubNames, openSubmodules, + failOnUnprefixed) { assert.isArray(paths); assert.isArray(indexSubNames); assert.isArray(openSubmodules); + if (failOnUnprefixed === undefined) { + failOnUnprefixed = false; + } else { + assert.isBoolean(failOnUnprefixed); + } const result = {}; // First, populate 'result' with all the subs that are completely - // contained. - - yield paths.map(co.wrap(function *(path) { - const subs = yield exports.getSubmodulesInPath(workdir, - path, - indexSubNames); - subs.forEach(subName => result[subName] = []); - })); + // contained, and clean the relevant specs out of paths + + const remainingPaths = []; + const add = subName => result[subName] = []; + for (const path of paths) { + const subs = exports.getSubmodulesInPath(path, indexSubNames); + if (subs.length > 0) { + subs.forEach(add); + } else { + remainingPaths.push(path); + } + } + paths = remainingPaths; // Now check to see which paths refer to a path inside a submodule. // Checking each file against the name of each open submodule has @@ -555,9 +752,14 @@ exports.resolvePaths = co.wrap(function *(workdir, for (let i = 0; i < paths.length; ++i) { const filename = paths[i]; - for (let j = 0; j < subsToCheck.length; ++j) { + let found = false; + for (let j = 0; j < subsToCheck.length; ++j) { const subName = subsToCheck[j]; - if (filename.startsWith(subName + "/")) { + if (filename === subName) { + found = true; + result[subName] = []; + } else if (filename.startsWith(subName + "/")) { + found = true; const pathInSub = filename.slice(subName.length + 1, filename.length); const subPaths = result[subName]; @@ -569,10 +771,14 @@ exports.resolvePaths = co.wrap(function *(workdir, } } } + if (!found && failOnUnprefixed) { + throw new UserError(`\ +pathspec '${filename}' did not match any files`); + } } return result; -}); +}; /** * Create references having the specified `refs` names in each of the specified @@ -588,7 +794,7 @@ exports.resolvePaths = co.wrap(function *(workdir, * @param {String[]} refs * @param {String[]} submodules */ -exports.addRefs = co.wrap(function *(repo, refs, submodules) { +exports.syncRefs = co.wrap(function *(repo, refs, submodules) { assert.instanceOf(repo, NodeGit.Repository); assert.isArray(refs); assert.isArray(submodules); @@ -630,32 +836,8 @@ exports.addRefs = co.wrap(function *(repo, refs, submodules) { name, NodeGit.Oid.fromString(sha), 1, - "addRefs"); + "syncRefs"); } })); })); }); - -/** - * Cache the submodules before invoking the specified `operation` and uncache - * them after the operation is completed, or before allowing an exception to - * propagte. Return the result of `operation`. - * - * @param {NodeGit.Repository} repo - * @param {(repo ) => Promise} operation - */ -exports.cacheSubmodules = co.wrap(function *(repo, operation) { - assert.instanceOf(repo, NodeGit.Repository); - assert.isFunction(operation); - repo.submoduleCacheAll(); - let result; - try { - result = yield operation(repo); - } - catch (e) { - repo.submoduleCacheClear(); - throw e; - } - repo.submoduleCacheClear(); - return result; -}); diff --git a/node/lib/util/syncrefs.js b/node/lib/util/syncrefs.js new file mode 100644 index 000000000..20095abfe --- /dev/null +++ b/node/lib/util/syncrefs.js @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2017, 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 co = require("co"); +const NodeGit = require("nodegit"); + +const GitUtil = require("./git_util"); +const SubmoduleUtil = require("./submodule_util"); + +/** + * Create refs in submodules which correspond to the refs in the meta repo. + */ +exports.doSyncRefs = co.wrap(function *() { + const repo = yield GitUtil.getCurrentRepo(); + let subs = yield SubmoduleUtil.listOpenSubmodules(repo); + + const refs = yield repo.getReferenceNames(NodeGit.Reference.TYPE.ALL); + yield SubmoduleUtil.syncRefs(repo, refs, subs); +}); diff --git a/node/lib/util/synthetic_branch_util.js b/node/lib/util/synthetic_branch_util.js index 76375bc6d..455da8e4e 100644 --- a/node/lib/util/synthetic_branch_util.js +++ b/node/lib/util/synthetic_branch_util.js @@ -35,6 +35,7 @@ the meta-repo should use this hook. */ +const ConfigUtil = require("./config_util"); const NodeGit = require("nodegit"); const GitUtil = require("./git_util"); const SubmoduleUtil = require("./submodule_util"); @@ -45,6 +46,7 @@ const assert = require("chai").assert; const NOTES_REF = "refs/notes/git-meta/subrepo-check"; const SYNTHETIC_BRANCH_BASE = "refs/commits/"; +exports.SYNTHETIC_BRANCH_BASE = SYNTHETIC_BRANCH_BASE; /** * The identity function @@ -53,6 +55,26 @@ function identity(v) { return v; } +function SyntheticBranchConfig(urlSkipPattern, pathSkipPattern) { + if (urlSkipPattern.length > 0) { + const urlSkipRE = new RegExp(urlSkipPattern); + this.urlSkipTest = function(url) { + return urlSkipRE.test(url); + }; + } else { + this.urlSkipTest = function() { return false; }; + } + + if (pathSkipPattern.length > 0) { + const pathSkipRE = new RegExp(pathSkipPattern); + this.pathSkipTest = function(path) { + return pathSkipRE.test(path); + }; + } else { + this.pathSkipTest = function() { return false; }; + } +} + /** * (This has to be public so we can mock it for testing) * @param {NodeGit.Commit} commit @@ -61,89 +83,181 @@ exports.getSyntheticBranchForCommit = function(commit) { return SYNTHETIC_BRANCH_BASE + commit; }; -function *urlToLocalPath(repo, url) { + /** + * Public for testing. Gets the local path corresponding to + * a submodule's URL. + * @param {NodeGit.Repository} repo + * @param {String} url + */ +exports.urlToLocalPath = function *(repo, url) { assert.instanceOf(repo, NodeGit.Repository); assert.isString(url); const config = yield repo.config(); - let subrepoUrlBase = ""; - try { - subrepoUrlBase = - yield config.getStringBuf("gitmeta.subrepourlbase"); - } catch (e) { - //It's OK for this to be missing, but nodegit lacks an - //API that expresses this. - - } + const subrepoUrlBase = + (yield ConfigUtil.getConfigString(config, "gitmeta.subrepourlbase")) || ""; const subrepoRootPath = yield config.getStringBuf("gitmeta.subreporootpath"); - let subrepoSuffix = ""; - try { - subrepoSuffix = yield config.getStringBuf("gitmeta.subreposuffix"); - } catch (e) { - //It's OK for this to be missing, but nodegit lacks an - //API that expresses this. - } + let subrepoSuffix = + (yield ConfigUtil.getConfigString(config, "gitmeta.subreposuffix")) || ""; if (!url.startsWith(subrepoUrlBase)) { throw "Your git configuration gitmeta.subrepoUrlBase, '" + subrepoUrlBase + "', must be a prefix of all submodule " + "urls. Submodule url '" + url + "' fails."; } const remotePath = url.slice(subrepoUrlBase.length); + if (remotePath.endsWith(subrepoSuffix)) { + subrepoSuffix = ""; + } const localPath = path.join(subrepoRootPath, remotePath + subrepoSuffix); if (localPath[0] === "/") { return localPath; } else { return path.normalize(path.join(repo.path(), localPath)); } +}; + +/** + * Check that a given path is on the path synthetic-ref-check skiplist, if + * such a skiplist exists. + * @async + * @param {SyntheticBranchConfig} cfg The configuration for + * synthetic_branch_util + * @param {String} url The path of the submodule + * in the meta tree. + */ +function skipCheckForPath(cfg, path) { + assert.instanceOf(cfg, SyntheticBranchConfig); + assert.isString(path); + return cfg.pathSkipTest(path); +} + +/** + * Check that a given URL is on the URLs synthetic-ref-check skiplist, if + * such a skiplist exists. + * @async + * @param {SyntheticBranchConfig} cfg The configuration for + * synthetic_branch_util + * @param {String} url The configured URL of the submodule + * in the meta tree. + */ +function skipCheckForURL(cfg, url) { + assert.instanceOf(cfg, SyntheticBranchConfig); + assert.isString(url); + return cfg.urlSkipTest(url); } /** - * Check that a synthetic branch exists for a given submodule + * Check that a commit exists exists for a given submodule * at a given commit. * @async - * @param {NodeGit.Repostory} repo The meta repository - * @param {NodeGit.TreeEntry} submoduleEntry the submodule's tree entry - * @param {String} url the configured URL of the submodule + * @param {NodeGit.Repostory} repo The meta repository + * @param {SyntheticBranchConfig} cfg The configuration for + * synthetic_branch_util + * @param {NodeGit.TreeEntry} submoduleEntry the submodule's tree entry + * @param {String} url the configured URL of the submodule * in the meta tree. */ -function* checkSubmodule(repo, metaCommit, submoduleEntry, url) { +function* checkSubmodule(repo, cfg, metaCommit, submoduleEntry, url, path) { assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(cfg, SyntheticBranchConfig); assert.instanceOf(submoduleEntry, NodeGit.TreeEntry); - const localPath = yield *urlToLocalPath(repo, url); + if (skipCheckForURL(cfg, url)) { + return true; + } + + if (skipCheckForPath(cfg, path)) { + return true; + } + + const localPath = yield *exports.urlToLocalPath(repo, url); const submoduleRepo = yield NodeGit.Repository.open(localPath); const submoduleCommitId = submoduleEntry.id(); - const branch = exports.getSyntheticBranchForCommit(submoduleCommitId); try { const subrepoCommit = - yield submoduleRepo.getReferenceCommit(branch); - return subrepoCommit.id().equal(submoduleEntry.id()); + yield NodeGit.Object.lookup(submoduleRepo, submoduleCommitId, + NodeGit.Object.TYPE.COMMIT); + return subrepoCommit !== null; } catch (e) { - console.error("Could not look up ", branch, " in ", localPath, - ": ", e); + console.error("Could not look up ", submoduleCommitId, " in ", + localPath, ": ", e); return false; } } +/** + * Return a list of submodules changed or added in between `commit` + * and `parent`. Exclude deleted submodules. + * + * If parent is null, return null. + */ +function* computeChangedSubmodules(repo, commit, parent) { + if (parent === null) { + return null; + } + const changed = []; + const changes = yield SubmoduleUtil.getSubmoduleChanges(repo, commit, + parent, true); + for (const sub of Object.keys(changes)) { + const change = changes[sub]; + if (!change.deleted) { + changed.push(sub); + } + } + return changed; +} + function* checkSubmodules(repo, commit) { assert.instanceOf(repo, NodeGit.Repository); assert.instanceOf(commit, NodeGit.Commit); + const config = yield repo.config(); + const urlSkipPattern = ( + yield ConfigUtil.getConfigString( + config, "gitmeta.skipsyntheticrefpattern")) || ""; + const pathSkipPattern = ( + yield ConfigUtil.getConfigString( + config, "gitmeta.skipsyntheticrefpathpattern")) || ""; + + const cfg = new SyntheticBranchConfig(urlSkipPattern, + pathSkipPattern); + + const parent = yield GitUtil.getParentCommit(repo, commit); + const names = yield computeChangedSubmodules(repo, + commit, + parent); + const submodules = yield SubmoduleUtil.getSubmodulesForCommit(repo, - commit); + commit, + names); const getChanges = SubmoduleUtil.getSubmoduleChanges; - const changes = yield getChanges(repo, commit); + const changes = yield getChanges(repo, commit, null, true); const allChanges = [ - Object.keys(changes.added), - Object.keys(changes.changed) + Object.keys(changes).filter(changeName => { + const change = changes[changeName]; + return null === change.oldSha; + }), + Object.keys(changes).filter(changeName => { + const change = changes[changeName]; + return null !== change.oldSha && null !== change.newSha; + }), ]; const result = allChanges.map(function *(changeSet) { const result = changeSet.map(function *(path) { const entry = yield commit.getEntry(path); const submodulePath = entry.path(); - const url = submodules[submodulePath].url; - return yield *checkSubmodule(repo, commit, entry, url); + const submodule = submodules[submodulePath]; + if (!submodule) { + console.error( + "A submodule exists in the tree but not the .gitmodules."); + console.error( + `The commit ${commit.id().tostrS()} is corrupt`); + return false; + } + const url = submodule.url; + return yield *checkSubmodule(repo, cfg, commit, entry, url, + submodulePath); }); return (yield result).every(identity); }); @@ -158,14 +272,19 @@ function* checkSubmodules(repo, commit) { * Returns true if the check passes on all branches; false if any * fail. * + * On success, create a note reflecting the work done to save time + * on future updates. + * @async * @param {NodeGit.Repostory} repo The meta repository + * @param {NodeGit.Repostory} notesRepo The repo to store notes for already + checked shas * @param {NodeGit.Commit} commit The meta branch's commit to check * @param {String} oldSha the previous (known-good) value of this ref * @param {Object} handled the commit ids that have been already * processed (and the result of processing them). */ -function* parentLoop(repo, commit, oldSha, handled) { +function* parentLoop(repo, notesRepo, commit, oldSha, handled) { assert.instanceOf(repo, NodeGit.Repository); assert.instanceOf(commit, NodeGit.Commit); assert.isString(oldSha); @@ -180,8 +299,8 @@ function* parentLoop(repo, commit, oldSha, handled) { return true; } - const ok = yield GitUtil.readNote(repo, NOTES_REF, commit.id()); - if (ok !== null && ok.message() === "ok") { + const ok = yield GitUtil.readNote(notesRepo, NOTES_REF, commit.id()); + if (ok !== null && (ok.message() === "ok" || ok.message() === "ok\n")) { handled[commit.id()] = true; return true; } @@ -198,33 +317,44 @@ function* parentLoop(repo, commit, oldSha, handled) { } const parents = yield commit.getParents(commit.parentcount()); - const parentChecks = yield parents.map(function *(parent) { - return yield *parentLoop(repo, parent, oldSha, handled); - }); - const result = parentChecks.every(identity); - handled[commit.id()] = result; - return result; + let success = true; + for (const parent of parents) { + if (!(yield *parentLoop(repo, notesRepo, parent, oldSha, handled))) { + success = false; + break; + } + } + if (success) { + yield NodeGit.Note.create(notesRepo, NOTES_REF, commit.committer(), + commit.committer(), commit.id(), + "ok", 1); + } + handled[commit.id()] = success; + return success; } /** * Main entry point. Check that a proposed ref update from oldSha * to newSha has synthetic branches for all submodule updates. * - * On success, create a note reflecting the work done to save time - * on future updates. - * * @async * @param {NodeGit.Repostory} repo The meta repository * @param {String} oldSha the previous (known-good) value of this ref * @param {String} newSha the new value of this ref */ -function* checkUpdate(repo, oldSha, newSha, handled) { +function* checkUpdate(repo, notesRepo, oldSha, newSha, handled) { assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(notesRepo, NodeGit.Repository); assert.isString(oldSha); assert.isString(newSha); assert.isObject(handled); + // deleting is OK + if (newSha === "0000000000000000000000000000000000000000") { + return true; + } + const newAnnotated = yield GitUtil.resolveCommitish(repo, newSha); if (newAnnotated === null) { @@ -233,14 +363,8 @@ function* checkUpdate(repo, oldSha, newSha, handled) { } const newCommit = yield repo.getCommit(newAnnotated.id()); - const success = yield parentLoop(repo, newCommit, oldSha, - handled); - if (success) { - yield NodeGit.Note.create(repo, NOTES_REF, newCommit.committer(), - newCommit.committer(), newAnnotated.id(), - "ok", 1); - } - return success; + return yield parentLoop(repo, notesRepo, newCommit, oldSha, + handled); } /** @@ -249,22 +373,26 @@ function* checkUpdate(repo, oldSha, newSha, handled) { * * @async * @param {NodeGit.Repostory} repo The meta repository + * @param {NodeGit.Repostory} notesRepo The repo to store notes for already + checked shas * @param [{Object}] updates. Each object has fields oldSha, newSha, and ref, * @return true if the update should be rejected. * all strings. */ -function* metaUpdateIsBad(repo, updates) { +function* metaUpdateIsBad(repo, notesRepo, updates) { const handled = {}; const checkFailures = updates.map(function*(update) { if (!update.ref.startsWith("refs/heads/")) { return false; } - const ok = yield checkUpdate(repo, update.oldSha, update.newSha, - handled); + const ok = yield checkUpdate(repo, notesRepo, update.oldSha, + update.newSha, handled); if (!ok) { console.error( - "Ref update failed synthetic branch check for " + - update.ref); + "Update to ref '" + + update.ref + + "' failed synthetic branch check. " + + "Did you forget to use `git meta push`?"); } return !ok; }); @@ -279,11 +407,12 @@ function* metaUpdateIsBad(repo, updates) { * * @async * @param {NodeGit.Repostory} repo The meta repository + * @param {NodeGit.Repostory} repo ignored * @param [{Object}] updates. Each object has fields oldSha, newSha, and ref, * all strings. * @return true if the submodule update should be rejected */ -function* submoduleIsBad(repo, updates) { +function* submoduleIsBad(repo, notesRepo, updates) { const checkFailures = updates.map(function*(update) { /*jshint noyield:true*/ if (!update.ref.startsWith(SYNTHETIC_BRANCH_BASE)) { @@ -313,6 +442,11 @@ function* initAltOdb(repo) { } } +function* getNotesRepoPath(config) { + const configVar = "gitmeta.syntheticrefnotesrepopath"; + return (yield ConfigUtil.getConfigString(config, configVar)) || "."; +} + /** * A git pre-receive hook, which reads from stdin and checks * each updated ref. @@ -337,8 +471,19 @@ function doPreReceive(check) { }).on("end", function() { co(function *() { const repo = yield NodeGit.Repository.open("."); + + // To avoid processing the same metadata commits over and over + // again when the hook is used in multiple forks of the same + // repo, we want to store notes in the "base fork", wich + // is determined by a config setting. If no such setting exists, + // we fall back to using the current repo. + const config = yield repo.config(); + const notesRepoPath = yield getNotesRepoPath(config); + + const notesRepo = yield NodeGit.Repository.open(notesRepoPath); + yield initAltOdb(repo); - return yield check(repo, updates); + return yield check(repo, notesRepo, updates); }).then(function(res) { process.exit(+res); }, function(e) { diff --git a/node/lib/util/test_util.js b/node/lib/util/test_util.js index 6f40651c7..ca551371e 100644 --- a/node/lib/util/test_util.js +++ b/node/lib/util/test_util.js @@ -34,6 +34,8 @@ * This module contains methods used in testing other git-meta components. */ +const ConfigUtil = require("./config_util"); + const assert = require("chai").assert; const co = require("co"); const fs = require("fs-promise"); @@ -106,7 +108,7 @@ exports.createSimpleRepository = co.wrap(function *(repoPath) { const fileName = "README.md"; const filePath = path.join(repoPath, fileName); yield fs.writeFile(filePath, ""); - const sig = repo.defaultSignature(); + const sig = yield ConfigUtil.defaultSignature(repo); yield repo.createCommitOnHead([fileName], sig, sig, "first commit"); return repo; }); @@ -130,8 +132,7 @@ exports.createSimpleRepositoryOnBranch = co.wrap(function *(branchName) { const repo = yield exports.createSimpleRepository(); const commit = yield repo.getHeadCommit(); - const sig = repo.defaultSignature(); - const publicBranch = yield repo.createBranch(branchName, commit, 0, sig); + const publicBranch = yield repo.createBranch(branchName, commit, 0); yield repo.setHead(publicBranch.name()); return repo; @@ -211,7 +212,7 @@ exports.makeCommit = co.wrap(function *(repo, files) { assert.isArray(files); files.forEach((name, i) => assert.isString(name, i)); - const sig = repo.defaultSignature(); + const sig = yield ConfigUtil.defaultSignature(repo); const commitId = yield repo.createCommitOnHead(files, sig, sig, @@ -255,13 +256,13 @@ exports.makeBareCopy = co.wrap(function *(repo, path) { // Record the branches that exist in the bare repo. let existingBranches = {}; - const bareRefs = yield bare.getReferences(NodeGit.Reference.TYPE.LISTALL); + const bareRefs = yield bare.getReferences(); bareRefs.forEach(r => existingBranches[r.shorthand()] = true); // Then create all the branches that weren't copied initially. - const refs = yield repo.getReferences(NodeGit.Reference.TYPE.LISTALL); - const sig = bare.defaultSignature(); + const refs = yield repo.getReferences(); + const sig = yield ConfigUtil.defaultSignature(bare); for (let i = 0; i < refs.length; ++i) { const ref = refs[i]; const shorthand = ref.shorthand(); diff --git a/node/lib/util/text_util.js b/node/lib/util/text_util.js new file mode 100644 index 000000000..2828a4ab5 --- /dev/null +++ b/node/lib/util/text_util.js @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2018, 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; + +/** + * Compare two strings for Array.prototype.sort. This is a useful + * building-block for more complex comparison functions. + */ +exports.strcmp = function(a, b) { + return (a < b) ? -1 : ((a > b) ? 1 : 0); +}; + +/** + * Indent a single string + * @param {String} str + * @param {Integer} count number of spaces to indent (default 4) + * @return {String} The string, indented + */ +exports.indent = function(str, count) { + assert.isString(str); + if (undefined !== count) { + assert.isNumber(count); + assert(count > 0); + } + else { + count = 4; + } + return " ".repeat(count) + str; +}; + +/** + * Convert a list of strings to a newline-delimited, indented string. + * + * @param {Array} The strings + * @param {Integer} count (default 4) + * @return {String} + */ +exports.listToIndentedString = function(strings, count) { + return strings.map(s => exports.indent(s, count)).join("\n"); +}; + +/** + * Pluralize a noun (if necessary). This is kind of a hack: + * it doesn't handle 'children', 'wolves', 'gees', or 'oxen'. + * + * @param {String} The noun + * @param {Integer} the count -- if it's not 1, the noun will be pluralized. + * @return {String} + */ +exports.pluralize = function(noun, count) { + if (count === 1) { + return noun; + } + if (noun.match("(?:s|sh|ch|z|x)$")) { + return noun + "es"; + } + if (noun.endsWith("y")) { + return noun.substring(0, noun.length - 1) + "ies"; + } + return noun + "s"; +}; diff --git a/node/lib/util/tree_util.js b/node/lib/util/tree_util.js index 03b95fcab..f721160d5 100644 --- a/node/lib/util/tree_util.js +++ b/node/lib/util/tree_util.js @@ -32,10 +32,12 @@ const assert = require("chai").assert; const co = require("co"); +const fs = require("fs-promise"); const NodeGit = require("nodegit"); const path = require("path"); -const RepoStatus = require("./repo_status"); +const RepoStatus = require("./repo_status"); +const SubmoduleConfigUtil = require("./submodule_config_util"); /** * Return a nested tree mapping the flat structure in the specified `flatTree`, @@ -51,8 +53,8 @@ const RepoStatus = require("./repo_status"); exports.buildDirectoryTree = function (flatTree) { let result = {}; - for (let path in flatTree) { - const paths = path.split("/"); + for (let subpath in flatTree) { + const paths = subpath.split("/"); let tree = result; // Navigate/build the tree until there is only one path left in paths, @@ -60,20 +62,33 @@ exports.buildDirectoryTree = function (flatTree) { for (let i = 0; i + 1 < paths.length; ++i) { const nextPath = paths[i]; - if (nextPath in tree) { + let nextTree = tree[nextPath]; + + // If we have a null entry for something that we need to be a tree, + // that means we've changed something that was an object into a + // parent directory. Otherwise, we need to build a new object for + // this directory. + + if (undefined !== nextTree && null !== nextTree) { tree = tree[nextPath]; - assert.isObject(tree, `for path ${path}`); } else { - const nextTree = {}; + nextTree = {}; tree[nextPath] = nextTree; tree = nextTree; } } const leafPath = paths[paths.length - 1]; - assert.notProperty(tree, leafPath, `duplicate entry for ${path}`); - const data = flatTree[path]; - tree[leafPath] = data; + const leafData = tree[leafPath]; + const data = flatTree[subpath]; + + // Similar to above, if we see something changed to null where we + // alreaduy have data, we can ignore it. This just means that + // something we are removing is turning into a tree. + + if (undefined === leafData || null !== data) { + tree[leafPath] = data; + } } return result; @@ -143,13 +158,27 @@ exports.writeTree = co.wrap(function *(repo, baseTree, changes) { const directory = exports.buildDirectoryTree(changes); + // TODO: This is a workaround for a bug in nodegit. The contract for + // libgit2's `treebuilder` is to not free the `tree_entry` objects that its + // methods return until your done with the `treebuilder` object that + // returned them -- then you must free them else leak. Nodegit, however, + // appears to be freeing them as each `tree_entry` is GC'd; this seems to + // be the normal way the bindings work and I imagine it's a mechanical + // thing. The workaround is to stick all the `tree_entry` objects we see + // into an array whose lifetime is scoped to that of this method. + // + // Nodegit issue on github: https://github.com/nodegit/nodegit/issues/1333 + + const treeEntries = []; + // This method does the real work, but assumes an already aggregated // directory structure. - const writeSubtree = co.wrap(function *(parentTree, subDir) { + const writeSubtree = co.wrap(function *(parentTree, subDir, basePath) { const builder = yield NodeGit.Treebuilder.create(repo, parentTree); for (let filename in subDir) { const entry = subDir[filename]; + const fullPath = path.join(basePath, filename); if (null === entry) { // Null means the entry was deleted. @@ -157,12 +186,18 @@ exports.writeTree = co.wrap(function *(repo, baseTree, changes) { builder.remove(filename); } else if (entry instanceof Change) { - yield builder.insert(filename, entry.id, entry.mode); + const inserted = + builder.insert(filename, entry.id, entry.mode); + treeEntries.push(inserted); } else { let subtree; let treeEntry = null; - if (null !== parentTree) { + + // If we have a directory that was removed in `changes`, we do + // not want to base it on the original parent tree. + + if (null !== changes[fullPath] && null !== parentTree) { try { treeEntry = yield parentTree.entryByPath(filename); } @@ -170,29 +205,31 @@ exports.writeTree = co.wrap(function *(repo, baseTree, changes) { // 'filename' didn't exist in 'parentTree' } } - if (null !== treeEntry) { - assert(treeEntry.isTree(), `${filename} should be a tree`); + if (null !== treeEntry && treeEntry.isTree()) { + treeEntries.push(treeEntry); const treeId = treeEntry.id(); const curTree = yield repo.getTree(treeId); - subtree = yield writeSubtree(curTree, entry); + subtree = yield writeSubtree(curTree, entry, fullPath); } else { - subtree = yield writeSubtree(null, entry); + subtree = yield writeSubtree(null, entry, fullPath); } if (0 === subtree.entryCount()) { builder.remove(filename); } else { - yield builder.insert(filename, - subtree.id(), - NodeGit.TreeEntry.FILEMODE.TREE); + const inserted = builder.insert( + filename, + subtree.id(), + NodeGit.TreeEntry.FILEMODE.TREE); + treeEntries.push(inserted); } } } - const id = builder.write(); + const id = yield builder.write(); return yield repo.getTree(id); }); - return yield writeSubtree(baseTree, directory); + return yield writeSubtree(baseTree, directory, ""); }); /** @@ -202,20 +239,13 @@ exports.writeTree = co.wrap(function *(repo, baseTree, changes) { * @param {String} filename * @return {NodeGit.Oid} */ -exports.hashFile = function (repo, filename) { +exports.hashFile = co.wrap(function* (repo, filename) { assert.instanceOf(repo, NodeGit.Repository); assert.isString(filename); - // 'createFromDisk' is unfinished; instead of returning an id, it takes an - // buffer and writes into it, unlike the rest of its brethern on `Blob`. - // TODO: patch nodegit with corrected API. - - const placeholder = - NodeGit.Oid.fromString("0000000000000000000000000000000000000000"); const filepath = path.join(repo.workdir(), filename); - NodeGit.Blob.createFromDisk(placeholder, repo, filepath); - return placeholder; -}; + return yield NodeGit.Blob.createFromDisk(repo, filepath); +}); /** * Return a map from path to `Change` for the working directory of the @@ -227,11 +257,12 @@ exports.hashFile = function (repo, filename) { * @param {Boolean} includeUnstaged * @return {Object} */ -exports.listWorkdirChanges = function (repo, status, includeUnstaged) { +exports.listWorkdirChanges = co.wrap(function *(repo, status, includeUnstaged) { assert.instanceOf(repo, NodeGit.Repository); assert.instanceOf(status, RepoStatus); assert.isBoolean(includeUnstaged); + let touchedModules = false; const FILESTATUS = RepoStatus.FILESTATUS; const FILEMODE = NodeGit.TreeEntry.FILEMODE; @@ -240,20 +271,28 @@ exports.listWorkdirChanges = function (repo, status, includeUnstaged) { // first, plain files. const workdir = status.workdir; - for (let path in workdir) { - switch (workdir[path]) { + for (let subpath in workdir) { + let filemode = FILEMODE.EXECUTABLE; + const fullpath = path.join(repo.workdir(), subpath); + try { + yield fs.access(fullpath, fs.constants.X_OK); + } catch (e) { + // if unable to execute, use BLOB. + filemode = FILEMODE.BLOB; + } + switch (workdir[subpath]) { case FILESTATUS.ADDED: if (includeUnstaged) { - result[path] = new Change(exports.hashFile(repo, path), - FILEMODE.BLOB); + const sha = yield exports.hashFile(repo, subpath); + result[subpath] = new Change(sha, filemode); } break; case FILESTATUS.MODIFIED: - result[path] = new Change(exports.hashFile(repo, path), - FILEMODE.BLOB); + const sha = yield exports.hashFile(repo, subpath); + result[subpath] = new Change(sha,filemode); break; case FILESTATUS.REMOVED: - result[path] = null; + result[subpath] = null; break; } } @@ -262,16 +301,39 @@ exports.listWorkdirChanges = function (repo, status, includeUnstaged) { // commits. const submodules = status.submodules; + const SAME = RepoStatus.Submodule.COMMIT_RELATION.SAME; for (let name in submodules) { const sub = submodules[name]; const wd = sub.workdir; - if (null !== wd && - RepoStatus.Submodule.COMMIT_RELATION.SAME !== wd.relation) { - result[name] = new Change( - NodeGit.Oid.fromString(wd.status.headCommit), - FILEMODE.COMMIT); + let sha = null; + touchedModules = touchedModules || + null === sub.commit || + null === sub.index || + sub.index.url !== sub.commit.url; + if (null !== wd && SAME !== wd.relation) { + sha = wd.status.headCommit; + } + else if (null === sub.commit && null !== sub.index) { + sha = sub.index.sha; } + else if (null === wd && null === sub.index) { + result[name] = null; + } + else if (null === wd && SAME !== sub.index.relation) { + sha = sub.index.sha; + } + if (null !== sha) { + result[name] = new Change(NodeGit.Oid.fromString(sha), + FILEMODE.COMMIT); + + } + } + + if (touchedModules) { + const modulesName = SubmoduleConfigUtil.modulesFileName; + const id = yield exports.hashFile(repo, modulesName); + result[modulesName] = new Change(id, FILEMODE.BLOB); } return result; -}; +}); diff --git a/node/lib/util/user_error.js b/node/lib/util/user_error.js index 249e3ef30..d8813eb7b 100644 --- a/node/lib/util/user_error.js +++ b/node/lib/util/user_error.js @@ -30,6 +30,11 @@ */ "use strict"; +const ERROR_CODES = Object.freeze({ + GENERIC: -1, + FETCH_ERROR: -128, +}); + /** * This module contains the `util.UserError` class. */ @@ -44,11 +49,17 @@ * @class UserError */ class UserError extends Error { - constructor(message) { + constructor(message, code) { super(message); this.message = message; this.name = "UserError"; + this.code = code; + if (!this.code) { + this.code = ERROR_CODES.GENERIC; + } } } +UserError.CODES = ERROR_CODES; + module.exports = UserError; diff --git a/node/lib/util/walk.js b/node/lib/util/walk.js new file mode 100644 index 000000000..be9cf5d0a --- /dev/null +++ b/node/lib/util/walk.js @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2021, 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"; + +/** + * This is a fast, simple replacement for walk. Normally, we would + * use something like @root/walk, but (a) that requires Node 10 and + * (b) it doesn't support fine-grained filtering of subdirs. + */ + +const co = require("co"); +const path = require("path"); +const readdirWith = require("readdir-withfiletypes"); + +exports.walk = co.wrap(function*(pathname, callback) { + const dirents = readdirWith.readdirSync(pathname, { withFileTypes: true }); + + const files = []; + const dirs = []; + for (const dirent of dirents) { + if (dirent.isDirectory()) { + dirs.push(dirent.name); + } else { + files.push(dirent.name); + } + } + + // Note that caller can edit dirs in-place to change the traversal + yield callback(pathname, files, dirs); + for (let dir of dirs) { + yield exports.walk(path.join(pathname, dir), callback); + } +}); diff --git a/node/lib/util/write_repo_ast_util.js b/node/lib/util/write_repo_ast_util.js index 8f64ce0ed..1db6274a6 100644 --- a/node/lib/util/write_repo_ast_util.js +++ b/node/lib/util/write_repo_ast_util.js @@ -45,31 +45,22 @@ const mkdirp = require("mkdirp"); const NodeGit = require("nodegit"); const path = require("path"); +const ConfigUtil = require("./config_util"); +const ConflictUtil = require("./conflict_util"); const DoWorkQueue = require("./do_work_queue"); +const GitUtil = require("./git_util"); const RebaseFileUtil = require("./rebase_file_util"); const RepoAST = require("./repo_ast"); const RepoASTUtil = require("./repo_ast_util"); +const SequencerStateUtil = require("./sequencer_state_util"); +const SparseCheckoutUtil = require("./sparse_checkout_util"); const SubmoduleConfigUtil = require("./submodule_config_util"); const TestUtil = require("./test_util"); const TreeUtil = require("./tree_util"); - // Begin module-local methods +const FILEMODE = NodeGit.TreeEntry.FILEMODE; -/** - * Write the specified `data` to the specified `repo` and return its hash - * value. - * - * @async - * @private - * @param {NodeGit.Repository} repo - * @param {String} data - * @return {String} - */ -const hashObject = co.wrap(function *(db, data) { - const BLOB = 3; - const res = yield db.write(data, data.length, BLOB); - return res.tostrS(); -}); + // Begin module-local methods /** * Configure the specified `repo` to have settings needed by git-meta tests. @@ -86,12 +77,10 @@ const configRepo = co.wrap(function *(repo) { * Return the tree and a map of associated subtrees corresponding to the * specified `changes` in the specified `repo`, and based on the optionally * specified `parent`. Use the specified `shaMap` to resolve logical shas to - * actual written shas (such as for submodule heads). Use the specified `db` - * to write objects. + * actual written shas (such as for submodule heads). * * @async * @param {NodeGit.Repository} repo - * @param {NodeGit.Odb} db * @param {Object} shaMap maps logical to physical ID * @param {Object} changes map of changes * @param {Object} [parent] @@ -102,7 +91,6 @@ const configRepo = co.wrap(function *(repo) { * @return {NodeGit.Tree} return.tree */ const writeTree = co.wrap(function *(repo, - db, shaMap, changes, parent) { @@ -116,7 +104,6 @@ const writeTree = co.wrap(function *(repo, Object.assign(submodules, parent.submodules); } - const FILEMODE = NodeGit.TreeEntry.FILEMODE; const wereSubs = 0 !== Object.keys(submodules).length; const pathToChange = {}; // name to `TreeUtil.Change` @@ -124,6 +111,11 @@ const writeTree = co.wrap(function *(repo, for (let filename in changes) { const entry = changes[filename]; + if (entry instanceof RepoAST.Conflict) { + // Skip conflicts + continue; // CONTINUE + } + let isSubmodule = false; if (null === entry) { @@ -131,11 +123,12 @@ const writeTree = co.wrap(function *(repo, pathToChange[filename] = null; } - else if ("string" === typeof entry) { - // A string is just plain data. - - const id = yield hashObject(db, entry); - pathToChange[filename] = new TreeUtil.Change(id, FILEMODE.BLOB); + else if (entry instanceof RepoAST.File) { + const id = + (yield GitUtil.hashObject(repo, entry.contents)).tostrS(); + const mode = + entry.isExecutable ? FILEMODE.EXECUTABLE : FILEMODE.BLOB; + pathToChange[filename] = new TreeUtil.Change(id, mode); } else if (entry instanceof RepoAST.Submodule) { // For submodules, we must map the logical sha it contains to the @@ -172,7 +165,7 @@ const writeTree = co.wrap(function *(repo, \turl = ${url} `; } - const dataId = yield hashObject(db, data); + const dataId = (yield GitUtil.hashObject(repo, data)).tostrS(); pathToChange[SubmoduleConfigUtil.modulesFileName] = new TreeUtil.Change(dataId, FILEMODE.BLOB); } @@ -214,10 +207,9 @@ exports.writeCommits = co.wrap(function *(oldCommitMap, assert.isObject(commits); assert.isArray(shas); - const db = yield repo.odb(); let newCommitMap = {}; // from new to old sha - const sig = repo.defaultSignature(); + const sig = yield ConfigUtil.defaultSignature(repo); const commitObjs = {}; // map from new id to `Commit` object @@ -243,7 +235,6 @@ exports.writeCommits = co.wrap(function *(oldCommitMap, // by the commit at `sha` and has caches for subtrees and submodules. const trees = yield writeTree(repo, - db, oldCommitMap, commit.changes, parentTrees); @@ -324,6 +315,24 @@ const writeAllCommits = co.wrap(function *(repo, commits, treeCache) { * @param {Object} treeCache map of tree entries */ const configureRepo = co.wrap(function *(repo, ast, commitMap, treeCache) { + const makeConflictEntry = co.wrap(function *(data) { + assert.instanceOf(repo, NodeGit.Repository); + if (null === data) { + return null; + } + if (data instanceof RepoAST.Submodule) { + //TODO: some day support putting conflicts in the .gitmodules file. + assert.equal("", + data.url, + "submodule conflicts must have empty URL"); + const sha = commitMap[data.sha]; + return new ConflictUtil.ConflictEntry(FILEMODE.COMMIT, sha); + } + const id = yield GitUtil.hashObject(repo, data.contents); + const mode = data.isExecutable ? FILEMODE.EXECUTABLE : FILEMODE.BLOB; + return new ConflictUtil.ConflictEntry(mode, id.tostrS()); + }); + const makeRef = co.wrap(function *(name, commit) { const newSha = commitMap[commit]; const newId = NodeGit.Oid.fromString(newSha); @@ -373,25 +382,35 @@ const configureRepo = co.wrap(function *(repo, ast, commitMap, treeCache) { if (ast.bare) { if (null !== ast.currentBranchName) { - repo.setHead("refs/heads/" + ast.currentBranchName); + yield repo.setHead("refs/heads/" + ast.currentBranchName); } else { repo.setHeadDetached(newHeadSha); } } - else if (null !== ast.currentBranchName) { - const currentBranch = - yield repo.getBranch("refs/heads/" + ast.currentBranchName); - yield repo.checkoutBranch(currentBranch); - } - else if (null !== ast.head) { - const headCommit = yield repo.getCommit(newHeadSha); - repo.setHeadDetached(newHeadSha); - yield NodeGit.Reset.reset(repo, headCommit, NodeGit.Reset.TYPE.HARD); + else if (null !== ast.currentBranchName || null !== ast.head) { + // If we use NodeGit to checkout, it will not respect the + // sparse-checkout settings. + + if (null === ast.currentBranchName) { + repo.detachHead(); + } + + const toCheckout = ast.currentBranchName || newHeadSha; + const checkoutStr = `\ +git -C '${repo.workdir()}' checkout ${toCheckout} +`; + try { + yield exec(checkoutStr); + } catch (e) { + // This can fail if there is no .gitmodules file to checkout and + // it's sparse. Git will complain that it cannot do the checkout + // because the worktree is empty. + } } const notes = ast.notes; - const sig = repo.defaultSignature(); + const sig = yield ConfigUtil.defaultSignature(repo); for (let notesRef in notes) { const commits = notes[notesRef]; for (let commit in commits) { @@ -437,6 +456,13 @@ const configureRepo = co.wrap(function *(repo, ast, commitMap, treeCache) { indexHead = rebase.onto; } + // Write out sequencer state if there is one. + const sequencer = ast.sequencerState; + if (null !== sequencer) { + const mapped = SequencerStateUtil.mapCommits(sequencer, commitMap); + yield SequencerStateUtil.writeSequencerState(repo.path(), mapped); + } + // Set up the index. We render the current commit and apply the index // on top of it. @@ -444,24 +470,48 @@ const configureRepo = co.wrap(function *(repo, ast, commitMap, treeCache) { if (null !== indexHead) { indexParent = treeCache[indexHead]; } - const db = yield repo.odb(); const trees = yield writeTree(repo, - db, commitMap, ast.index, indexParent); const index = yield repo.index(); const treeObj = trees.tree; yield index.readTree(treeObj); + for (let filename in ast.index) { + const data = ast.index[filename]; + if (data instanceof RepoAST.Conflict) { + const ancestor = yield makeConflictEntry(data.ancestor); + const our = yield makeConflictEntry(data.our); + const their = yield makeConflictEntry(data.their); + const conflict = new ConflictUtil.Conflict(ancestor, + our, + their); + yield ConflictUtil.addConflict(index, filename, conflict); + } + } + yield index.write(); // TODO: Firgure out if this can be done with NodeGit; extend if // not. I didn't see anything about `clean` and `Checkout.index` // didn't seem to work.. + let checkoutStr; + if (ast.sparse) { + const index = yield repo.index(); + if (index.getByPath(".gitmodules")) { + checkoutStr = ` +git -C '${repo.workdir()}' checkout-index -f .gitmodules`; + } else { + checkoutStr = ""; + } + } else { + checkoutStr = `git -C '${repo.workdir()}' checkout-index -f -a`; + } const checkoutIndexStr = `\ +git -C '${repo.workdir()}' checkout -- git -C '${repo.workdir()}' clean -f -d -git -C '${repo.workdir()}' checkout-index -a -f +${checkoutStr} `; yield exec(checkoutIndexStr); @@ -477,7 +527,10 @@ git -C '${repo.workdir()}' checkout-index -a -f else { const dirname = path.dirname(absPath); mkdirp.sync(dirname); - yield fs.writeFile(absPath, change); + yield fs.writeFile(absPath, change.contents); + if (change.isExecutable) { + yield fs.chmod(absPath, "755"); + } } } } @@ -577,6 +630,10 @@ exports.writeRAST = co.wrap(function *(ast, path) { const repo = yield NodeGit.Repository.init(path, ast.bare ? 1 : 0); + if (ast.sparse) { + yield SparseCheckoutUtil.setSparseMode(repo); + } + yield configRepo(repo); const treeCache = {}; @@ -593,6 +650,21 @@ exports.writeRAST = co.wrap(function *(ast, path) { }; }); +/** + * Return all the `Commit` objects in the specified `repos`. + * + * @param {Object} name to `RepoAST` + * @return {Object} sha to `Commit` + */ +function listCommits(repos) { + const commits = {}; + for (let repoName in repos) { + const repo = repos[repoName]; + Object.assign(commits, RepoASTUtil.listCommits(repo)); + } + return commits; +} + /** * Write the repositories described in the specified `repos` map to a the * specified `rootDirectory`. Return a map from repo name to @@ -635,26 +707,28 @@ exports.writeMultiRAST = co.wrap(function *(repos, rootDirectory) { urlMap[repoPath] = repoName; } + // First, collect all the commits: + + let commits = listCommits(repos); + + // Make an id map so that we can rewrite just URLs + + const map = {}; + for (let sha in commits) { + map[sha] = sha; + } + // Now, rewrite all the repo ASTs to have the right urls. + for (let repoName in repos) { const repoAST = repos[repoName]; repos[repoName] = - RepoASTUtil.mapCommitsAndUrls(repoAST, {}, repoPaths); + RepoASTUtil.mapCommitsAndUrls(repoAST, map, repoPaths); } - // First, collect all the commits: + // Re-list commits now that URLs are updated. - let commits = {}; - for (let repoName in repos) { - const repo = repos[repoName]; - Object.assign(commits, repo.commits); - - // Also, commits from open submodules. - - for (let subName in repo.openSubmodules) { - Object.assign(commits, repo.openSubmodules[subName].commits); - } - } + commits = listCommits(repos); const commitRepoPath = yield TestUtil.makeTempDir(); const commitRepo = yield NodeGit.Repository.init(commitRepoPath, 0); @@ -691,7 +765,7 @@ exports.writeMultiRAST = co.wrap(function *(repos, rootDirectory) { repo.detachHead(); - const refs = yield repo.getReferences(NodeGit.Reference.TYPE.LISTALL); + const refs = yield repo.getReferences(); for (let i = 0; i < refs.length; ++i) { NodeGit.Branch.delete(refs[i]); } @@ -716,6 +790,9 @@ git -C '${repo.path()}' -c gc.reflogExpire=0 -c gc.reflogExpireUnreachable=0 \ repoPath, { bare: ast.bare ? 1 : 0 }); + if (ast.sparse) { + yield SparseCheckoutUtil.setSparseMode(repo); + } yield configRepo(repo); yield writeRepo(repo, ast, repoPath); resultRepos[repoName] = repo; @@ -736,7 +813,7 @@ git -C '${repo.path()}' -c gc.reflogExpire=0 -c gc.reflogExpireUnreachable=0 \ if (null === index) { index = - yield RepoAST.renderIndex(ast.commits, ast.head, ast.index); + RepoAST.renderIndex(ast.commits, ast.head, ast.index); } const sub = index[subName]; const openSubAST = ast.openSubmodules[subName]; @@ -746,7 +823,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/package.json b/node/package.json index 08759cbf6..15d4e2126 100644 --- a/node/package.json +++ b/node/package.json @@ -27,24 +27,29 @@ "preferGlobal": true, "homepage": "https://github.com/twosigma/git-meta#readme", "dependencies": { - "argparse": "", + "argparse": "^1.0.0", + "async-mutex": "^0.2.4", + "binary-search": "", "chai": "", "child-process-promise": "", "co": "", "colors": "", + "deeper": "", "fs-promise": "", - "nodegit": "^0.19.0", + "git-range": "", + "group-by": "", + "nodegit": "^0.25.0", + "readdir-withfiletypes": "^1.0.2", "rimraf": "", "split": "" }, "devDependencies": { "deepcopy": "", - "deeper": "", "istanbul": "", - "mkdirp": "", - "mocha": "^3.1.2", + "mkdirp": "0.5.1", + "mocha": "^6.1.4", "mocha-jshint": "", - "mocha-parallel-tests": "^1.2.3", + "mocha-parallel-tests": "^2.1.2", "temp": "" } } diff --git a/node/test/util/add.js b/node/test/util/add.js index 89b2a0ff1..f8b1e6580 100644 --- a/node/test/util/add.js +++ b/node/test/util/add.js @@ -42,6 +42,11 @@ describe("add", function () { initial: "x=S", paths: [], }, + "bad": { + initial: "x=S", + paths: ["foo"], + fails: true, + }, "nothing to add": { initial: "x=S", paths: [""], @@ -129,17 +134,80 @@ a=B|x=S:C2-1 a/b=Sa:1;Oa/b W x/y/z=a,x/r/z=b;Bmaster=2`, paths: [], stageMeta: false, }, + "add only tracked files": { + initial: "a=B|x=U:Os W README.md=foo,newFile=a", + paths: ["s"], + expected: "x=E:Os I README.md=foo!W newFile=a", + update: true, + }, + "add only tracked files with no path": { + initial: "a=B|x=U:Os W README.md=foo,newFile=a", + paths: [], + expected: "x=E:Os I README.md=foo!W newFile=a", + update: true, + }, + "add existing file without providing path": { + initial: "x=S:W README.md=foo", + paths: [], + expected: "x=S:I README.md=foo", + update: true, + }, + "nothing to add with update and no path": { + initial: "x=S", + paths: [], + expected: "x=S", + update: true, + }, + "nothing to add with update and no path and untracked change": { + initial: "a=B|x=U:Os W newFile=a", + paths: [], + expected: "a=B|x=U:Os W newFile=a", + update: true, + }, + "add all(tracked and not tracked) files": { + initial: "a=B|x=U:Os W README.md=foo,newFile=a", + paths: ["s"], + expected: "x=E:Os I README.md=foo,newFile=a", + update: false, + }, + "deleted": { + initial: "a=B|x=U:Os W README.md", + expected: "x=U:Os I README.md", + paths: [""], + }, + "sub with conflict": { + initial: "a=B|x=U:Os I *README.md=a*b*c!W README.md=foo", + paths: ["s"], + expected: "x=E:Os I README.md=foo", + }, + "sub with conflict multiple files": { + initial: `a=B|x=U:Os I *README.md=a*b*c, + *lol.txt=a*b*c!W README.md=foo,lol.txt=bar`, + paths: ["s"], + expected: "x=E:Os I README.md=foo, lol.txt=bar", + }, + "sub with conflict multiple by path": { + initial: `a=B|x=U:Os I *README.md=a*b*c, + *lol.txt=a*b*c!W README.md=foo,lol.txt=bar`, + paths: ["s/README.md"], + expected: `x=E:Os I README.md=foo, + *lol.txt=a*b*c!W lol.txt=bar`, + }, }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; it(caseName, co.wrap(function *() { let stageMeta = c.stageMeta; + let update = c.update; if (undefined === stageMeta) { stageMeta = true; } + if (undefined === update) { + update = false; + } const doAdd = co.wrap(function *(repos) { const repo = repos.x; - yield Add.stagePaths(repo, c.paths, stageMeta); + yield Add.stagePaths(repo, c.paths, stageMeta, update); }); yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, c.expected, diff --git a/node/test/util/add_submodule.js b/node/test/util/add_submodule.js index c973b2b6a..3f528353c 100644 --- a/node/test/util/add_submodule.js +++ b/node/test/util/add_submodule.js @@ -37,42 +37,47 @@ const AddSubmodule = require("../../lib/util/add_submodule"); const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); describe("AddSubmodule", function () { - const cases = { - "simple": { - input: "a=B|x=Ca", - name: "s", - url: "/foo", - expected: "x=E:I s=S/foo:;Os", - }, - "nested": { - input: "a=B|x=Ca", - name: "s/t/u", - url: "/foo/bar", - expected: "x=E:I s/t/u=S/foo/bar:;Os/t/u", - }, - "import": { - input: "a=B|h=B:Cy-1;Bmaster=y|x=Ca", - name: "s", - url: "/foo/bar", - import: { url: "h", branch: "master" }, - expected: "x=E:I s=S/foo/bar:;Os Rupstream=h master=y!H=y", - }, - }; - Object.keys(cases).forEach(caseName => { - const c = cases[caseName]; - it(caseName, co.wrap(function *() { - const doNew = co.wrap(function *(repos) { - let imp = c.import || null; - if (null !== imp) { - const url = yield fs.realpath(repos[imp.url].path()); - imp = { url: url, branch: imp.branch}; - } - yield AddSubmodule.addSubmodule(repos.x, c.url, c.name, imp); - }); - yield RepoASTTestUtil.testMultiRepoManipulator(c.input, - c.expected, - doNew, - c.fails); - })); + describe("addSubmodule", function () { + const cases = { + "simple": { + input: "a=B|x=Ca", + name: "s", + url: "/foo", + expected: "x=E:I s=S/foo:;Os", + }, + "nested": { + input: "a=B|x=Ca", + name: "s/t/u", + url: "/foo/bar", + expected: "x=E:I s/t/u=S/foo/bar:;Os/t/u", + }, + "import": { + input: "a=B|h=B:Cy-1;Bmaster=y|x=Ca", + name: "s", + url: "/foo/bar", + import: { url: "h", branch: "master" }, + expected: "x=E:I s=S/foo/bar:;Os Rupstream=h master=y!H=y", + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const doNew = co.wrap(function *(repos) { + let imp = c.import || null; + if (null !== imp) { + const url = yield fs.realpath(repos[imp.url].path()); + imp = { url: url, branch: imp.branch}; + } + yield AddSubmodule.addSubmodule(repos.x, + c.url, + c.name, + imp); + }); + yield RepoASTTestUtil.testMultiRepoManipulator(c.input, + c.expected, + doNew, + c.fails); + })); + }); }); }); diff --git a/node/test/util/bulk_notes_util.js b/node/test/util/bulk_notes_util.js new file mode 100644 index 000000000..d3fa6e4f2 --- /dev/null +++ b/node/test/util/bulk_notes_util.js @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2018, 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 NodeGit = require("nodegit"); +const path = require("path"); + +const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); +const BulkNotesUtil = require("../../lib/util/bulk_notes_util"); + +describe("BulkNotesUtil", function () { +describe("shardSha", function () { + it("breathing", function () { + assert.equal(BulkNotesUtil.shardSha("aabbffffffffffffffff"), + path.join("aa", "bb", "ffffffffffffffff")); + }); +}); +describe("writeNotes", function () { + it("with a parent", co.wrap(function *() { + const ref = "refs/notes/foo/bar"; + const written = yield RepoASTTestUtil.createRepo(` +S:C2-1;H=2;N ${ref} 1=hello`); + const repo = written.repo; + const foo = yield repo.getHeadCommit(); + const fooSha = foo.id().tostrS(); + const first = (yield foo.getParents())[0]; + const firstSha = first.id().tostrS(); + const newNotes = {}; + newNotes[fooSha] = "foobar"; + yield BulkNotesUtil.writeNotes(repo, ref, newNotes); + const result = {}; + const shas = []; + yield NodeGit.Note.foreach(repo, ref, (_, sha) => { + shas.push(sha); + }); + yield shas.map(co.wrap(function *(sha) { + const note = yield NodeGit.Note.read(repo, ref, sha); + result[sha] = note.message(); + })); + const expected = newNotes; + newNotes[firstSha] = "hello"; + assert.deepEqual(result, expected); + })); + it("no parents", co.wrap(function *() { + const ref = "refs/notes/foo/bar"; + const written = yield RepoASTTestUtil.createRepo("S:C2-1;H=2"); + const repo = written.repo; + const foo = yield repo.getHeadCommit(); + const fooSha = foo.id().tostrS(); + const first = (yield foo.getParents())[0]; + const firstSha = first.id().tostrS(); + const newNotes = {}; + newNotes[firstSha] = "hello"; + newNotes[fooSha] = "foobar"; + yield BulkNotesUtil.writeNotes(repo, ref, newNotes); + const result = {}; + const shas = []; + yield NodeGit.Note.foreach(repo, ref, (_, sha) => { + shas.push(sha); + }); + yield shas.map(co.wrap(function *(sha) { + const note = yield NodeGit.Note.read(repo, ref, sha); + result[sha] = note.message(); + })); + const expected = newNotes; + assert.deepEqual(result, expected); + })); +}); +describe("readNotes", function () { + it("empty", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S:C2-1;Bfoo=2"); + const repo = written.repo; + const refName = "refs/notes/foo/bar"; + const result = yield BulkNotesUtil.readNotes(repo, refName); + assert.deepEqual(result, {}); + })); + it("breathing", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S:C2-1;Bfoo=2"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const headSha = head.id().tostrS(); + const foo = yield repo.getBranchCommit("foo"); + const fooSha = foo.id().tostrS(); + const refName = "refs/notes/foo/bar"; + const sig = yield repo.defaultSignature(); + yield NodeGit.Note.create(repo, refName, sig, sig, fooSha, "foo", 1); + yield NodeGit.Note.create(repo, refName, sig, sig, headSha, "bar", 1); + const result = yield BulkNotesUtil.readNotes(repo, refName); + const expected = {}; + expected[fooSha] = "foo"; + expected[headSha] = "bar"; + assert.deepEqual(result, expected); + })); + it("sharded, from `writeNotes`", co.wrap(function *() { + const ref = "refs/notes/foo/bar"; + const written = yield RepoASTTestUtil.createRepo("S:C2-1;H=2"); + const repo = written.repo; + const foo = yield repo.getHeadCommit(); + const fooSha = foo.id().tostrS(); + const first = (yield foo.getParents())[0]; + const firstSha = first.id().tostrS(); + const newNotes = {}; + newNotes[firstSha] = "hello"; + newNotes[fooSha] = "foobar"; + yield BulkNotesUtil.writeNotes(repo, ref, newNotes); + const result = yield BulkNotesUtil.readNotes(repo, ref); + assert.deepEqual(result, newNotes); + })); +}); +describe("parseNotes", function () { + it("breathing", function () { + const obj = { foo: "bar" }; + const str = JSON.stringify(obj, null, 4); + const input = { yay: str }; + const result = BulkNotesUtil.parseNotes(input); + assert.deepEqual(result, { yay: obj }); + }); +}); +}); diff --git a/node/test/util/checkout.js b/node/test/util/checkout.js index 414227de5..f597244b2 100644 --- a/node/test/util/checkout.js +++ b/node/test/util/checkout.js @@ -32,10 +32,12 @@ const assert = require("chai").assert; const co = require("co"); +const NodeGit = require("nodegit"); const Checkout = require("../../lib/util/checkout"); const GitUtil = require("../../lib/util/git_util"); const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); +const SubmoduleUtil = require("../../lib/util/submodule_util"); const UserError = require("../../lib/util/user_error"); describe("Checkout", function () { @@ -91,30 +93,51 @@ describe("Checkout", function () { // We will operate on the repository `x`. const cases = { + "meta change": { + input: "x=S:C2-1 README.md=8;Bfoo=2", + committish: "foo", + fails: true, + }, + "from empty": { + input: "a=B:C2 s=Sb:1;Bfoo=2|b=B|x=N:Rtrunk=a foo=2", + committish: "trunk/foo", + expected: "x=E:H=2", + }, + "conflict": { + input: `a=B:Ca-1;Ba=a|x=U:C3-2 s=Sa:a;Bfoo=3;Os I a=9`, + committish: "foo", + fails: true, + }, + "removal when changes to open sub being removed": { + input: ` +a=B|x=U:Os I a=b! W README.md=8;C3-2 s;Bfoo=3`, + committish: "foo", + expected: "x=U:C3-2 s;H=3;Bfoo=3", + }, "simple checkout with untracked file": { input: "x=S:Bfoo=1;W car=bmw", expected: "x=E:H=1", committish: "foo", }, "simple checkout with unrelated change": { - input: "x=S:C2-1;Bfoo=2;W README.md=meh", + input: `a=B|x=U:C3-2 t=Sa:1;Bfoo=3;Os I a=8`, committish: "foo", - expected: "x=E:H=2", + expected: "x=E:H=3", }, - "checkout new commit": { - input: "x=S:C2-1;Bfoo=2", + "simple checkout to branch missing sub": { + input: `a=B|x=U:C3-1 t=Sa:1;Bfoo=3;Os I a=8`, committish: "foo", - expected: "x=E:H=2", + expected: "x=U:C3-1 t=Sa:1;Bfoo=3;H=3", }, - "checkout with conflict": { - input: "x=S:C2-1;Bfoo=2;W 2=meh", + "checkout new commit": { + input: "a=B|x=S:C2-1 s=Sa:1;Bfoo=2", committish: "foo", - fails: true, + expected: "x=E:H=2", }, "checkout with conflict, but forced": { - input: "x=S:C2-1;Bfoo=2;W 2=meh", + input: `a=B:Ca-1;Ba=a|x=U:C3-2 s=Sa:a;Bfoo=3;Os I a=9`, committish: "foo", - expected: "x=E:H=2;W 2=~", + expected: "x=E:H=3;Os", force: true, }, "sub closed": { @@ -133,7 +156,7 @@ describe("Checkout", function () { expected: "x=E:H=2", }, "open sub but no change to sub": { - input: "a=S|x=U:C4-2;Os;Bfoo=4", + input: "a=S|x=U:C4-2 q=Sa:1;Os;Bfoo=4", committish: "foo", expected: "x=E:H=4", }, @@ -290,12 +313,37 @@ a=B|x=S:C2-1 s=Sa:1;C3-2 r=Sa:1,t=Sa:1;Os;Bmaster=3;Bfoo=2;H=2`, }, expectedSwitchBranch: "bar", }, - "no match to committish, no new branch": { + "no match to committish, nor to file, no new branch": { state: "x=S", committish: "bar", track: false, fails: true, }, + "no match to committish, no new branch, but ok, a submodule": { + state: "a=B|x=S:C2-1 s=Sa:1;Bmaster=2;Os", + committish: "s", + track: false, + expectedCheckoutFromIndex: true, + expectedNewBranch: null, + expectedSwitchBranch: null, + }, + "no match to committish, no new branch, but ok, some files": { + state: "a=B|x=S:C2-1 s=Sa:1;Bmaster=2;Os", + committish: "s/no-such-file-but-we-will-detect-that-later", + track: false, + expectedCheckoutFromIndex: true, + expectedNewBranch: null, + expectedSwitchBranch: null, + }, + "some files after --": { + state: "a=B|x=S:C2-1 s=Sa:1;Bmaster=2;Os", + committish: null, + track: false, + expectedCheckoutFromIndex: true, + expectedNewBranch: null, + expectedSwitchBranch: null, + files: ["s/no-such-file-but-we-will-detect-that-later"] + }, "commit, no new branch, nameless": { state: "x=S", committish: "1", @@ -405,6 +453,21 @@ a=B|x=S:C2-1 s=Sa:1;C3-2 r=Sa:1,t=Sa:1;Os;Bmaster=3;Bfoo=2;H=2`, }, expectedSwitchBranch: "foo", }, + "new branch(nested name) with remote tracking": { + state: "x=S:Rhar=/a managed/hey=1", + committish: "har/managed/hey", + newBranch: "foo", + track: true, + expectedSha: "1", + expectedNewBranch: { + name: "foo", + tracking: { + remoteName: "har", + branchName: "managed/hey", + }, + }, + expectedSwitchBranch: "foo", + }, "tracking on new branch but commit not a branch": { state: "x=S", committish: "1", @@ -437,12 +500,15 @@ a=B|x=S:C2-1 s=Sa:1;C3-2 r=Sa:1,t=Sa:1;Os;Bmaster=3;Bfoo=2;H=2`, } const newBranch = c.newBranch || null; const track = c.track || false; + let files = c.files; let result; + process.chdir(repo.workdir()); try { result = yield Checkout.deriveCheckoutOperation(repo, committish, newBranch, - track); + track, + files); } catch (e) { if (!c.fails || !(e instanceof UserError)) { @@ -453,7 +519,7 @@ a=B|x=S:C2-1 s=Sa:1;C3-2 r=Sa:1,t=Sa:1;Os;Bmaster=3;Bfoo=2;H=2`, assert(!c.fails, "was supposed to fail"); const expectedSha = c.expectedSha; const commit = result.commit; - if (null !== expectedSha) { + if (!!expectedSha) { assert.isNotNull(commit); const commitId = commit.id().tostrS(); const sha = written.commitMap[commitId]; @@ -462,6 +528,10 @@ a=B|x=S:C2-1 s=Sa:1;C3-2 r=Sa:1,t=Sa:1;Os;Bmaster=3;Bfoo=2;H=2`, else { assert.isNull(commit); } + if (undefined !== c.expectedCheckoutFromIndex) { + assert.equal(c.expectedCheckoutFromIndex, + result.checkoutFromIndex); + } assert.deepEqual(result.newBranch, c.expectedNewBranch); assert.equal(result.switchBranch, c.expectedSwitchBranch); })); @@ -476,7 +546,7 @@ a=B|x=S:C2-1 s=Sa:1;C3-2 r=Sa:1,t=Sa:1;Os;Bmaster=3;Bfoo=2;H=2`, switchBranch: null, }, "just a commit": { - input: "x=S:C2-1;Bfoo=2", + input: "a=B|x=S:C2-1 s=Sa:1;Bfoo=2", committish: "foo", newBranch: null, switchBranch: null, @@ -493,7 +563,7 @@ a=B|x=S:C2-1 s=Sa:1;C3-2 r=Sa:1,t=Sa:1;Os;Bmaster=3;Bfoo=2;H=2`, expected: "x=E:Bfoo=1;*=foo", }, "commit and a branch": { - input: "x=S:C2-1;Bfoo=2", + input: "a=B|x=S:C2-1 s=Sa:1;Bfoo=2", committish: "foo", newBranch: { name: "bar", @@ -556,4 +626,87 @@ a=B|x=S:C2-1 s=Sa:1;C3-2 r=Sa:1,t=Sa:1;Os;Bmaster=3;Bfoo=2;H=2`, })); }); }); + describe("checkoutFiles", function () { + const cases = { + //TODO: bad pathspecs + "index: one file": { + input: "a=S|x=S:I s=Sa:1;Os I foo=bar!W foo=baz", + paths: ["s/foo"], + commit: ":0", + expected: "x=S:I s=Sa:1;Os I foo=bar" + }, + "index: two files, one spec": { + input: `a=S| + x=S:I s=Sa:1;Os I foo=bar,foo2=bar2!W foo=baz,foo2=baz2`, + paths: ["s/foo"], + commit: ":0", + expected: "x=S:I s=Sa:1;Os I foo=bar,foo2=bar2!W foo2=baz2" + }, + "index: two files, two specs": { + input: `a=S| + x=S:I s=Sa:1;Os I foo=bar,foo2=bar2!W foo=baz,foo2=baz2`, + paths: ["s/foo", "s/foo2"], + commit: ":0", + expected: "x=S:I s=Sa:1;Os I foo=bar,foo2=bar2" + }, + "index: two files, wide spec": { + input: `a=S| + x=S:I s=Sa:1;Os I foo=bar,foo2=bar2!W foo=baz,foo2=baz2`, + paths: ["s"], + commit: ":0", + expected: "x=S:I s=Sa:1;Os I foo=bar,foo2=bar2" + }, + "index: two files, two submodules, two specs": { + input: `a=S|b=S:C2-1;Bmaster=2| + x=S:I a=Sa:1,b=Sb:2;Oa I foo=bar!W foo=baz; + Ob I foo=bar!W foo=baz`, + paths: ["a/foo", "b/foo"], + commit: ":0", + expected: `x=S:I a=Sa:1,b=Sb:2;Oa I foo=bar; + Ob I foo=bar` + }, + "some commit: one file": { + input: `a=S:C2-1 foo=c2;C3-2 foo=c3;Bmaster=3| + x=S:C4-1 a=Sa:2;C5-4 a=Sa:3;Bmaster=5;Oa`, + paths: ["a/foo"], + commit: "4", + expected: `x=S:C4-1 a=Sa:2;C5-4 a=Sa:3;Bmaster=5;Oa I foo=c2` + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const manipulator = co.wrap(function*(repos, maps) { + const repo = repos.x; + const index = yield SubmoduleUtil.getSubmoduleNames( + repo); + const open = yield SubmoduleUtil.listOpenSubmodules( + repo); + const resolvedPaths = SubmoduleUtil.resolvePaths(c.paths, + index, + open, + true); + let checkoutFromIndex; + let annotated; + if (c.commit === ":0") { + checkoutFromIndex = true; + } else { + const mapped = maps.reverseCommitMap[c.commit]; + annotated = yield NodeGit.Commit.lookup(repo, mapped); + } + + yield Checkout.checkoutFiles(repo, { + commit: annotated, + resolvedPaths: resolvedPaths, + checkoutFromIndex: checkoutFromIndex + }); + }); + yield RepoASTTestUtil.testMultiRepoManipulator(c.input, + c.expected, + manipulator, + c.fails); + + })); + }); + }); }); diff --git a/node/test/util/cherry_pick.js b/node/test/util/cherry_pick.js new file mode 100644 index 000000000..bc508d33c --- /dev/null +++ b/node/test/util/cherry_pick.js @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2021, 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 CherryPick = require("../../lib/cmd/cherry_pick"); +const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); + +describe("isRange", function () { + it("handles non-ranges", function () { + assert(!CherryPick.isRange("")); + assert(!CherryPick.isRange("branch")); + assert(!CherryPick.isRange("refs/heads/branch")); + assert(!CherryPick.isRange("HEAD^3")); + }); + it("handles ranges", function () { + assert(CherryPick.isRange("x..y")); + assert(CherryPick.isRange("x...y")); + assert(CherryPick.isRange("x^@")); + assert(CherryPick.isRange("x^-1")); + assert(CherryPick.isRange("x^!")); + assert(CherryPick.isRange("^x")); + }); +}); + +describe("CherryPick", function () { + it("handles some common cases", co.wrap(function *() { + const start = `x=S:Cm1-1;Cm2-m1;Cm3-m2;Cm4-m3;Cm5-m4;Cm6-m5; + Bmaster=m6;Bm3=m3;Bm4=m4;Bm5=m5`; + const repoMap = yield RepoASTTestUtil.createMultiRepos(start); + const repo = repoMap.repos.x; + const byNumber = {}; + for (let i = 1; i <= 6; i++) { + byNumber[i] = yield repo.getCommit( + repoMap.reverseCommitMap["m" + i]); + } + let actual = yield CherryPick.resolveRange(repo, ["m5"]); + assert.deepEqual([byNumber[5]], actual); + + actual = yield CherryPick.resolveRange(repo, ["m4", "m5"]); + assert.deepEqual([byNumber[4], byNumber[5]], actual); + + actual = yield CherryPick.resolveRange(repo, ["m3..m5"]); + assert.deepEqual([byNumber[4], byNumber[5]], actual); + + actual = yield CherryPick.resolveRange(repo, ["^m3", "m5"]); + assert.deepEqual([byNumber[4], byNumber[5]], actual); + + actual = yield CherryPick.resolveRange(repo, ["m5^!"]); + assert.deepEqual([byNumber[5]], actual); + + actual = yield CherryPick.resolveRange(repo, ["m5^-"]); + assert.deepEqual([byNumber[5]], actual); + + assert.throws(() => CherryPick.resolveRange(repo, ["m5^@"]).done()); + })); +}); diff --git a/node/test/util/cherry_pick_util.js b/node/test/util/cherry_pick_util.js new file mode 100644 index 000000000..e00e35cab --- /dev/null +++ b/node/test/util/cherry_pick_util.js @@ -0,0 +1,1098 @@ +/* + * Copyright (c) 2018, 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 fs = require("fs-promise"); +const NodeGit = require("nodegit"); +const path = require("path"); + + +const Add = require("../../lib/util/add"); +const ConflictUtil = require("../../lib/util/conflict_util"); +const CherryPickUtil = require("../../lib/util/cherry_pick_util"); +const Open = require("../../lib/util/open"); +const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); +const ReadRepoASTUtil = require("../../lib/util/read_repo_ast_util"); +const Submodule = require("../../lib/util/submodule"); +const SubmoduleChange = require("../../lib/util/submodule_change"); +const UserError = require("../../lib/util/user_error"); + + +/** + * Return a commit map as expected from a manipulator for `RepoASTTestUtil` + * from a result having the `newMetaCommit` and `submoduleCommits` properties + * as returned by `rewriteCommit`. We remap as follows: + * + * * new meta commit will be called "9" (we generally cherry-pick from "8") + * * new submodule commits will be $old-logical-name + submoduleName, e.g.: + * "as" would be the name of a commit named "a" cherry-picked for submodule + * "s". + * + * @param {Object} maps + * @param {Object} result + * @return {Object} + */ +function mapCommits(maps, result) { + const oldMap = maps.commitMap; + + let commitMap = {}; + if (null !== result.newMetaCommit) { + // By convention, we name the cherry-pick generated meta-repo commit, + // if it exists, "9". + + commitMap[result.newMetaCommit] = "9"; + } + + // For the submodules, we need to first figure out what the old + // logical commit (the one from the shorthand) was, then create the + // new logical commit id by appending the submodule name. We map + // the new (physical) commit id to this new logical id. + + Object.keys(result.submoduleCommits).forEach(name => { + const subCommits = result.submoduleCommits[name]; + Object.keys(subCommits).forEach(newPhysicalId => { + const oldId = subCommits[newPhysicalId]; + const oldLogicalId = oldMap[oldId]; + const newLogicalId = oldLogicalId + name; + commitMap[newPhysicalId] = newLogicalId; + }); + }); + return { + commitMap: commitMap, + }; +} + +describe("CherryPickUtil", function () { +const Conflict = ConflictUtil.Conflict; +const ConflictEntry = ConflictUtil.ConflictEntry; +const FILEMODE = NodeGit.TreeEntry.FILEMODE; + +describe("changeSubmodules", function () { + const cases = { + "noop": { + input: "x=S", + submodules: {}, + }, + "simple": { + input: "x=S", + submodules: { + "s": new Submodule("/a", "1"), + }, + expected: "x=S:I s=S/a:1", + }, + "update open": { + input: "a=B|x=U:Os Ca-1!Ba=a", + submodules: { + "s": new Submodule("a", "a"), + }, + expected: "x=E:I s=Sa:a;Os Ba=a", + }, + "update open, need fetch": { + input: "a=B:Ca-1;Ba=a|x=U:Os", + submodules: { + "s": new Submodule("a", "a"), + }, + expected: "x=E:I s=Sa:a;Os H=a", + }, + "simple and update open": { + input: "a=B:Ca-1;Ba=a|x=U:Os", + submodules: { + "a": new Submodule("a", "1"), + "s": new Submodule("a", "a"), + }, + expected: "x=E:I a=Sa:1,s=Sa:a;Os H=a", + }, + "nested": { + input: "x=S", + submodules: { + "s/t/u": new Submodule("/a", "1"), + }, + expected: "x=S:I s/t/u=S/a:1", + }, + "multiple": { + input: "x=S", + submodules: { + "s/t/u": new Submodule("/a", "1"), + "z/t/u": new Submodule("/b", "1"), + }, + expected: "x=S:I s/t/u=S/a:1,z/t/u=S/b:1", + }, + "added": { + input: "a=B|x=U", + submodules: { + "s": new Submodule("/a", "1"), + }, + expected: "x=E:I s=S/a:1", + }, + "removed": { + input: "a=B|x=U", + submodules: { + "s": null, + }, + expected: "x=E:I s", + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const adder = co.wrap(function *(repos, maps) { + const repo = repos.x; + const subs = {}; + Object.keys(c.submodules).forEach(name => { + const sub = c.submodules[name]; + if (null !== sub) { + const sha = maps.reverseCommitMap[sub.sha]; + const url = maps.reverseUrlMap[sub.url] || sub.url; + subs[name] = new Submodule(url, sha); + } + else { + subs[name] = null; + } + }); + const opener = new Open.Opener(repo, null); + const index = yield repo.index(); + yield CherryPickUtil.changeSubmodules(repo, + opener, + index, + subs); + yield index.write(); + }); + yield RepoASTTestUtil.testMultiRepoManipulator(c.input, + c.expected, + adder, + c.fails); + })); + }); +}); + +describe("containsUrlChanges", function () { + const cases = { + "no subs, no parent": { + input: "S", + expected: false, + }, + "added a sub": { + input: "S:C2-1 s=Sa:1;H=2", + expected: false, + }, + "removed a sub": { + input: "S:C2-1 s=Sa:1;C3-2 s;H=3", + expected: false, + }, + "changed a sha": { + input: "S:Ca-1;C2-1 s=Sa:1;C3-2 s=Sa:a;H=3;Ba=a", + expected: false, + }, + "changed a URL": { + input: "S:C2-1 s=Sa:1;C3-2 s=Sb:1;H=3", + expected: true, + }, + "with ancestor": { + input: "S:C2-1 s=Sa:1;C3-2 s=Sb:1;C4-3;H=4", + expected: true, + base: "2", + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo(c.input); + const repo = written.repo; + const oldCommitMap = written.oldCommitMap; + let base; + if ("base" in c) { + assert.property(oldCommitMap, c.base); + base = yield repo.getCommit(oldCommitMap[c.base]); + } + const head = yield repo.getHeadCommit(); + const result = + yield CherryPickUtil.containsUrlChanges(repo, head, base); + assert.equal(result, c.expected); + })); + }); +}); +describe("computeChangesFromIndex", function () { + const Conflict = ConflictUtil.Conflict; + const ConflictEntry = ConflictUtil.ConflictEntry; + const FILEMODE = NodeGit.TreeEntry.FILEMODE; + const cases = { + "ffwd change": { + input: "a=B:Ca-1;Ba=a|x=U:Ct-2 s=Sa:a;Bt=t", + simpleChanges: { + "s": new Submodule("a", "a"), + }, + }, + "non-ffwd change": { + input: ` +a=B:Ca-1;Cb-1;Ba=a;Bb=b| +x=U:C3-2 s=Sa:a;Ct-2 s=Sa:b;Bt=t;Bmaster=3`, + changes: { + "s": new SubmoduleChange("1", "b", null), + }, + }, + "addition": { + input: "a=B|x=S:Ct-1 s=Sa:1;Bt=t", + simpleChanges: { + "s": new Submodule("a", "1"), + }, + }, + "addition in ancestor": { + input: "a=B|x=S:Ct-2 s=Sa:1;C2-1 t=Sa:1;Bt=t", + simpleChanges: { + "s": new Submodule("a", "1"), + }, + }, + "double addition": { + input: ` +a=B:Ca-1;Cb-1;Ba=a;Bb=b| +x=S:C2-1 s=Sa:a;Ct-1 s=Sa:b;Bmaster=2;Bt=t`, + conflicts: { + "s": new Conflict(null, + new ConflictEntry(FILEMODE.COMMIT, "a"), + new ConflictEntry(FILEMODE.COMMIT, "b")), + }, + }, + "same double addition": { + input: ` +a=B:Ca-1;Ba=a| +x=S:C2-1 s=Sa:a;Ct-1 s=Sa:a;Bmaster=2;Bt=t`, + }, + "deletion": { + input: "a=B|x=U:Ct-2 s;Bt=t", + simpleChanges: { + "s": null, + }, + }, + "double deletion": { + input: "a=B|x=U:C3-2 s;Ct-2 s;Bt=t;Bmaster=3", + }, + "change, but gone on HEAD": { + input: "a=B:Ca-1;Ba=a|x=U:C3-2 s;Ct-2 s=Sa:a;Bt=t;Bmaster=3", + conflicts: { + "s": new Conflict(new ConflictEntry(FILEMODE.COMMIT, "1"), + null, + new ConflictEntry(FILEMODE.COMMIT, "a")), + }, + }, + "change, but never on HEAD": { + input: "a=B:Ca-1;Ba=a|x=U:Bmaster=1;Ct-2 s=Sa:a;Bt=t", + conflicts: { + "s": new Conflict(new ConflictEntry(FILEMODE.COMMIT, "1"), + null, + new ConflictEntry(FILEMODE.COMMIT, "a")), + }, + }, + "deletion, but not a submodule any more": { + input: "a=B|x=U:C3-2 s=foo;Ct-2 s;Bmaster=3;Bt=t", + conflicts: { + "s": new Conflict(new ConflictEntry(FILEMODE.COMMIT, "1"), + new ConflictEntry(FILEMODE.BLOB, "foo"), + null), + }, + fails: true, + }, + "deletion, but was changed on HEAD": { + input: ` +a=B:Ca-1;Ba=a| +x=U:C3-2 s=Sa:a;Ct-2 s;Bt=t;Bmaster=3`, + conflicts: { + "s": new Conflict(new ConflictEntry(FILEMODE.COMMIT, "1"), + new ConflictEntry(FILEMODE.COMMIT, "a"), + null), + }, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const w = yield RepoASTTestUtil.createMultiRepos(c.input); + const repos = w.repos; + const repo = repos.x; + const commitMap = w.commitMap; + const reverseCommitMap = w.reverseCommitMap; + const urlMap = w.urlMap; + const head = yield repo.getHeadCommit(); + const target = yield repo.getCommit(reverseCommitMap.t); + const index = + yield NodeGit.Cherrypick.commit(repo, target, head, 0, []); + let result; + let exception; + try { + result = yield CherryPickUtil.computeChanges(repo, + index, + target); + } catch (e) { + exception = e; + } + if (undefined !== exception) { + if (!c.fails || !(exception instanceof UserError)) { + throw exception; + } + return; // RETURN + } + assert.equal(c.fails || false, false); + const changes = {}; + for (let name in result.changes) { + const change = result.changes[name]; + changes[name] = new SubmoduleChange(commitMap[change.oldSha], + commitMap[change.newSha], + null); + } + assert.deepEqual(changes, c.changes || {}); + + const simpleChanges = {}; + for (let name in result.simpleChanges) { + const change = result.simpleChanges[name]; + let mapped = null; + if (null !== change) { + mapped = new Submodule(urlMap[change.url], + commitMap[change.sha]); + } + simpleChanges[name] = mapped; + } + + const mapConflict = co.wrap(function *(entry) { + if (null === entry) { + return entry; + } + if (FILEMODE.COMMIT === entry.mode) { + return new ConflictEntry(entry.mode, + commitMap[entry.id]); + } + const data = + yield repo.getBlob(NodeGit.Oid.fromString(entry.id)); + return new ConflictEntry(entry.mode, data.toString()); + }); + assert.deepEqual(simpleChanges, c.simpleChanges || {}, "simple"); + + const conflicts = {}; + for (let name in result.conflicts) { + const conflict = result.conflicts[name]; + const ancestor = yield mapConflict(conflict.ancestor); + const our = yield mapConflict(conflict.our); + const their = yield mapConflict(conflict.their); + conflicts[name] = new Conflict(ancestor, our, their); + } + assert.deepEqual(conflicts, c.conflicts || {}); + })); + }); + it("works around a libgit2 bug", co.wrap(function*() { + const w = yield RepoASTTestUtil.createMultiRepos(` +a=B:Ca-1;Cb-1;Ba=a;Bb=b| +x=S:C2-1 s=Sa:1;C3-2 s=Sa:a;Ct-2 s=Sa:b;Bmaster=3;Bt=t` + ); + const repos = w.repos; + const repo = repos.x; + const reverseCommitMap = w.reverseCommitMap; + const head = yield repo.getHeadCommit(); + const target = yield repo.getCommit(reverseCommitMap.t); + const index = + yield NodeGit.Cherrypick.commit(repo, target, head, 0, []); + yield index.remove("s", 1); + const result = yield CherryPickUtil.computeChanges(repo, + index, + target); + + const change = result.changes.s; + const expect = new SubmoduleChange(reverseCommitMap["1"], + reverseCommitMap.b, + reverseCommitMap.a); + assert.deepEqual(expect, change); + })); +}); + +describe("pickSubs", function () { + // Most of the logic is done via `RebaseUtil.rewriteCommits`. We need to + // validate that we invoke that method correctly and that we fetch commits + // as needed. + + const cases = { + "no subs": { + state: "x=S", + subs: {}, + }, + "pick a sub": { + state: `a=B:Ca-1;Cb-1;Ba=a;Bb=b|x=U:C3-2 s=Sa:a;H=3`, + subs: { + "s": new SubmoduleChange("1", "b", null), + }, + expected: `x=E:Os Cbs-a b=b!H=bs;I s=Sa:bs`, + }, + "pick two": { + state: ` +a=B:Ca-1;Caa-1;Cb-1;Cc-b;Ba=a;Bb=b;Bc=c;Baa=aa| +x=U:C3-2 s=Sa:a,t/u=Sa:a;H=3 +`, + subs: { + "s": new SubmoduleChange("1", "aa", null), + "t/u": new SubmoduleChange("1", "c", null), + }, + expected: ` +x=E:Os Caas-a aa=aa!H=aas;Ot/u Cct/u-bt/u c=c!Cbt/u-a b=b!H=ct/u; + I s=Sa:aas,t/u=Sa:ct/u`, + }, + "a conflict": { + state: `a=B:Ca-1;Cb-1 a=foo;Ba=a;Bb=b|x=U:C3-2 s=Sa:a;H=3`, + subs: { + "s": new SubmoduleChange("1", "b", null), + }, + conflicts: { + "s": "b", + }, + expected: `x=E:Os I *a=~*a*foo!Edetached HEAD,b,a!W a=\ +<<<<<<< HEAD +a +======= +foo +>>>>>>> message +;`, + }, + "commit and a conflict": { + state: ` +a=B:Ca-1;Cb-1;Cc-b a=foo;Ba=a;Bb=b;Bc=c| +x=U:C3-2 s=Sa:a;H=3`, + subs: { + "s": new SubmoduleChange("1", "c", null), + }, + conflicts: { + "s": "c", + }, + expected: ` +x=E:I s=Sa:bs;Os Cbs-a b=b!H=bs!I *a=~*a*foo!Edetached HEAD,c,a!W a=\ +<<<<<<< HEAD +a +======= +foo +>>>>>>> message +;`, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + const picker = co.wrap(function *(repos, maps) { + const repo = repos.x; + const index = yield repo.index(); + const commitMap = maps.commitMap; + const reverseMap = maps.reverseCommitMap; + const subs = {}; + Object.keys(c.subs).forEach(name => { + const change = c.subs[name]; + subs[name] = new SubmoduleChange(reverseMap[change.oldSha], + reverseMap[change.newSha], + null); + }); + const opener = new Open.Opener(repo, null); + const result = yield CherryPickUtil.pickSubs(repo, + opener, + index, + subs); + yield index.write(); + const conflicts = {}; + Object.keys(result.conflicts).forEach(name => { + const sha = result.conflicts[name]; + conflicts[name] = commitMap[sha]; + }); + assert.deepEqual(conflicts, c.conflicts || {}, "conflicts"); + const mappedCommits = {}; + Object.keys(result.commits).forEach(name => { + const subCommits = result.commits[name]; + Object.keys(subCommits).forEach(newSha => { + const oldSha = subCommits[newSha]; + const oldLogicalSha = commitMap[oldSha]; + const newLogicalSha = oldLogicalSha + name; + mappedCommits[newSha] = newLogicalSha; + }); + }); + return { + commitMap: mappedCommits, + }; + }); + + it(caseName, co.wrap(function *() { + yield RepoASTTestUtil.testMultiRepoManipulator(c.state, + c.expected, + picker, + c.fails); + })); + }); +}); +describe("writeConflicts", function () { + const cases = { + "trivial": { + state: "x=S", + conflicts: {}, + result: "", + }, + "with a conflict": { + state: "x=S", + conflicts: { + "README.md": new Conflict(null, + null, + new ConflictEntry(FILEMODE.COMMIT, + "1")), + }, + expected: "x=E:I *README.md=~*~*S:1;W README.md=hello world", + result: `\ +Conflicting entries for submodule ${colors.red("README.md")} +`, + }, + "two conflicts": { + state: "x=S", + conflicts: { + z: new Conflict(null, + null, + new ConflictEntry(FILEMODE.COMMIT, "1")), + a: new Conflict(null, + null, + new ConflictEntry(FILEMODE.COMMIT, "1")), + }, + expected: "x=E:I *z=~*~*S:1,*a=~*~*S:1", + result: `\ +Conflicting entries for submodule ${colors.red("a")} +Conflicting entries for submodule ${colors.red("z")} +`, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const writer = co.wrap(function *(repos, maps) { + const repo = repos.x; + const index = yield repo.index(); + const conflicts = {}; + function mapEntry(entry) { + if (null === entry || FILEMODE.COMMIT !== entry.mode) { + return entry; + } + return new ConflictEntry(entry.mode, + maps.reverseCommitMap[entry.id]); + } + for (let name in c.conflicts) { + const con = c.conflicts[name]; + conflicts[name] = new Conflict(mapEntry(con.ancestor), + mapEntry(con.our), + mapEntry(con.their)); + } + const result = + yield CherryPickUtil.writeConflicts(repo, index, conflicts); + yield index.write(); + assert.equal(result, c.result); + }); + yield RepoASTTestUtil.testMultiRepoManipulator(c.state, + c.expected, + writer, + c.fails); + })); + }); +}); +describe("rewriteCommit", function () { + // We will always cherry-pick commit 8 from repo x and will name the + // meta-repo cherry-picked commit 9. Cherry-picked commits from + // submodules will have the submodule name appended to the commit. + // + // One concern with these tests is that the logic used by Git to generate + // new commit ids can result in collision when the exact same commit would + // be generated very quickly. For example, if you have this setup: + // + // S:C2-1;Bfoo=2 + // + // And try to cherry-pick "2" to master, it will likely generate the exact + // same physical commit id already mapped to "2" and screw up the test, + // which expects a new commit id. This problem means that we cannot easily + // test that case (without adding timers), which is OK as we're not + // actually testing libgit2's cherry-pick facility, but also that we need + // to have unique commit histories in our submodules -- we can't have 'S' + // everywhere for these tests. + // + // Cases to check: + // * add when already exists + // * delete when already delete + // * change when deleted + // * conflicts + + const cases = { + "picking one sub": { + input: ` +a=Ax:Cz-y;Cy-x;Bfoo=z| +x=S:C8-3 s=Sa:z;C3-2 s=Sa:y;C2-1 s=Sa:x;Bfoo=8;Bmaster=2;Os H=x`, + expected: "x=E:C9-2 s=Sa:zs;Bmaster=9;Os Czs-x z=z!H=zs", + }, + "nothing to commit": { + input: "a=B|x=S:C2-1;C8-1 ;Bmaster=2;B8=8", + }, + "URL change will fail": { + input: ` +a=Ax:Cz-y;Cy-x;Bfoo=z|b=B| +x=S:C8-3 s=Sb:z;C3-2 s=Sa:y;C2-1 s=Sa:x;Bfoo=8;Bmaster=2`, + fails: true, + }, + "meta change will fail": { + input: ` +a=Ax:Cz-y;Cy-x;Bfoo=z| +x=S:C8-3 s=Sa:z,README.md=9;C3-2 s=Sa:y;C2-1 s=Sa:x;Bfoo=8;Bmaster=2`, + fails: true, + }, + "picking one ffwd sub": { + input: ` +a=Ax:Cz-x;Bfoo=z| +x=S:C8-2 s=Sa:z;C3-2;C2-1 s=Sa:x;Bfoo=8;Bmaster=3;Os H=x`, + expected: "x=E:C9-3 s=Sa:z;Bmaster=9;Os", + }, + "picking one non-trivial ffwd sub": { + input: ` +a=Ax:Cz-y;Cy-x;Bfoo=z| +x=S:C8-2 s=Sa:z;C3-2 s=Sa:y;C2-1 s=Sa:x;Bfoo=8;Bmaster=3;Os`, + expected: "x=E:C9-3 s=Sa:z;Bmaster=9;Os H=z", + }, + "picking one non-trivial ffwd sub, closes": { + input: ` +a=Ax:Cz-y;Cy-x;Bfoo=z| +x=S:C8-2 s=Sa:z;C3-2 s=Sa:y;C2-1 s=Sa:x;Bfoo=8;Bmaster=3`, + expected: "x=E:C9-3 s=Sa:z;Bmaster=9", + }, + "picking one sub introducing two commits": { + input: ` +a=Aw:Cz-y;Cy-x;Cx-w;Bfoo=z| +x=S:C8-3 s=Sa:z;C3-2 s=Sa:x;C2-1 s=Sa:w;Bfoo=8;Bmaster=2;Os H=w`, + expected: ` +x=E:C9-2 s=Sa:zs;Bmaster=9;Os Czs-ys z=z!Cys-w y=y!H=zs`, + }, + "picking closed sub": { + input: ` +a=Ax:Cz-y;Cy-x;Bfoo=z| +x=S:C8-3 s=Sa:z;C3-2 s=Sa:y;C2-1 s=Sa:x;Bfoo=8;Bmaster=2`, + expected: "x=E:C9-2 s=Sa:zs;Bmaster=9;Os Czs-x z=z!H=zs", + }, + "picking closed sub with change": { + input: "\ +a=Ax:Cw-x;Cz-x;Cy-x;Bfoo=z;Bbar=y;Bbaz=w|\ +x=S:C4-2 s=Sa:w;C8-3 s=Sa:z;C3-2;C2-1 s=Sa:y;Bfoo=8;Bmaster=4", + expected: "x=E:C9-4 s=Sa:zs;Bmaster=9;Os Czs-w z=z!H=zs", + }, + "picking two closed subs": { + input: ` +a=Ax:Cz-y;Cy-x;Bfoo=z| +b=Aa:Cc-b;Cb-a;Bfoo=c| +x=S:C8-3 s=Sa:z,t=Sb:c;C3-2 s=Sa:y,t=Sb:b;C2-1 s=Sa:x,t=Sb:a; +Bfoo=8;Bmaster=2`, + expected: ` +x=E:C9-2 s=Sa:zs,t=Sb:ct; +Bmaster=9; +Os Czs-x z=z!H=zs; +Ot Cct-a c=c!H=ct`, + }, + "new sub on head": { + input: ` +a=B| +x=U:C8-2 r=Sa:1;C4-2 t=Sa:1;Bmaster=4;Bfoo=8`, + expected: "x=E:C9-4 r=Sa:1;Bmaster=9", + }, + "don't pick subs from older commit": { + input: ` +a=B:Cr-1;Cq-r;Bq=q| +x=S:C2-1 s=Sa:1,t=Sa:1;C3-2 s=Sa:q,t=Sa:r;C8-3 t=Sa:q;Bmaster=2;Bfoo=8`, + expected: ` +x=E:C9-2 t=Sa:qt;Bmaster=9;Ot Cqt-1 q=q!H=qt`, + }, + "remove a sub": { + input: "a=B|x=U:C3-2;Bmaster=3;C8-2 s;Bfoo=8", + expected: "a=B|x=E:C9-3 s;Bmaster=9", + }, + "conflict in a sub pick": { + input: ` +a=B:Ca-1;Cb-1 a=8;Ba=a;Bb=b| +x=U:C3-2 s=Sa:a;C8-2 s=Sa:b;Bmaster=3;Bfoo=8`, + expected: ` +x=E:Os Edetached HEAD,b,a!I *a=~*a*8!W a=\ +<<<<<<< HEAD +a +======= +8 +>>>>>>> message +; +`, + errorMessage: `\ +Submodule ${colors.red("s")} is conflicted. + A testCommand is in progress. + (after resolving conflicts mark the corrected paths + with 'git meta add', then run "git meta testCommand --continue") + (use "git meta testCommand --abort" to check out the original branch)`, + }, + "conflict in a sub pick, success in another": { + input: ` +a=B:Ca-1;Cb-1 a=8;Cc-1;Ba=a;Bb=b;Bc=c| +x=S:C2-1 s=Sa:1,t=Sa:1;C3-2 s=Sa:a,t=Sa:a;C8-2 s=Sa:b,t=Sa:c;Bmaster=3;Bfoo=8`, + expected: ` +x=E:I t=Sa:ct;Ot Cct-a c=c!H=ct; +Os Edetached HEAD,b,a!I *a=~*a*8!W a=\ +<<<<<<< HEAD +a +======= +8 +>>>>>>> message +; +`, + errorMessage: `\ +Submodule ${colors.red("s")} is conflicted. + A testCommand is in progress. + (after resolving conflicts mark the corrected paths + with 'git meta add', then run "git meta testCommand --continue") + (use "git meta testCommand --abort" to check out the original branch)`, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + const picker = co.wrap(function *(repos, maps) { + const x = repos.x; + const reverseCommitMap = maps.reverseCommitMap; + assert.property(reverseCommitMap, "8"); + const eightCommitSha = reverseCommitMap["8"]; + const eightCommit = yield x.getCommit(eightCommitSha); + const result = yield CherryPickUtil.rewriteCommit(x, eightCommit, + "testCommand"); + assert.equal(result.errorMessage, c.errorMessage || null); + return mapCommits(maps, result); + }); + + it(caseName, co.wrap(function *() { + yield RepoASTTestUtil.testMultiRepoManipulator(c.input, + c.expected, + picker, + c.fails); + })); + }); +}); +describe("cherryPick", function () { + // Most of the work of cherry-pick is done by `rewriteCommit` and other + // methods. We just need to validate here that we're ensuring the contract + // that we're in a good state, that we properly record and cleanup the + // sequencer. + + const cases = { + "picking one sub": { + input: ` +a=Ax:Cz-y;Cy-x;Bfoo=z| +x=S:C8-3 s=Sa:z;C3-2 s=Sa:y;C2-1 s=Sa:x;Bfoo=8;Bmaster=2;Os H=x`, + expected: "x=E:C9-2 s=Sa:zs;Bmaster=9;Os Czs-x z=z!H=zs", + }, + "skip duplicated cherry picks": { + input: ` +a=Ax:Cz-y;Cy-x;Bfoo=z| +x=S:C8-3 s=Sa:z;C3-2 s=Sa:y;C2-1 s=Sa:x;Bfoo=8;Bmaster=2;Os H=x`, + expected: "x=E:C9-2 s=Sa:zs;Bmaster=9;Os Czs-x z=z!H=zs", + duplicate: true, + }, + "nothing to commit": { + input: "a=B|x=S:C2-1;C8-1 ;Bmaster=2;B8=8", + }, + "in-progress will fail": { + input: ` +a=Ax:Cz-y;Cy-x;Bfoo=z| +x=S:QC 2: 1: 0 2;C8-3 s=Sa:z;C3-2 s=Sa:y;C2-1 s=Sa:x;Bfoo=8;Bmaster=2`, + fails: true, + }, + "dirty will fail": { + input: ` +a=Ax:Cz-y;Cy-x;Bfoo=z| +x=S:C8-3 s=Sa:z;C3-2 s=Sa:y;C2-1 s=Sa:x;Bfoo=8;Bmaster=2;Os W x=9`, + fails: true, + }, + "conflict in a sub pick": { + input: ` +a=B:Ca-1;Cb-1 a=8;Ba=a;Bb=b| +x=U:C3-2 s=Sa:a;C8-2 s=Sa:b;Bmaster=3;Bfoo=8`, + expected: ` +x=E:QC 3: 8: 0 8;Os Edetached HEAD,b,a!I *a=~*a*8!W a=\ +<<<<<<< HEAD +a +======= +8 +>>>>>>> message +; +`, + errorMessage: `\ +Submodule ${colors.red("s")} is conflicted. + A cherry-pick is in progress. + (after resolving conflicts mark the corrected paths + with 'git meta add', then run "git meta cherry-pick --continue") + (use "git meta cherry-pick --abort" to check out the original branch)`, + }, + }; + + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + const picker = co.wrap(function *(repos, maps) { + const x = repos.x; + const reverseCommitMap = maps.reverseCommitMap; + assert.property(reverseCommitMap, "8"); + const eightCommitSha = reverseCommitMap["8"]; + const eightCommit = yield x.getCommit(eightCommitSha); + const result = yield CherryPickUtil.cherryPick(x, [eightCommit]); + + if (c.duplicate) { + const res = yield CherryPickUtil.cherryPick(x, [eightCommit]); + assert.isNull(res.newMetaCommit); + } + assert.equal(result.errorMessage, c.errorMessage || null); + + return mapCommits(maps, result); + }); + it(caseName, co.wrap(function *() { + yield RepoASTTestUtil.testMultiRepoManipulator(c.input, + c.expected, + picker, + c.fails); + })); + }); +}); +describe("continue", function () { + const cases = { + "no cherry-pick": { + input: "a=B|x=U:Os I foo=bar;Cfoo#g;Bg=g", + fails: true, + }, + "conflicted": { + input: "a=B|x=U:Os I foo=bar,*x=a*b*c;Cfoo#g;Bg=g;QC 2: g: 0 g", + fails: true, + }, + "continue with a staged submodule commit": { + input: "a=B:Ca-1;Ba=a|x=U:I s=Sa:a;Cmoo#g;Bg=g;QC 2: g: 0 g", + expected: "x=E:Cmoo#CP-2 s=Sa:a;Bmaster=CP;Q;I s=~", + }, + "regular continue": { + input: ` +a=B:Ca-1;Cb-1;Ba=a;Bb=b| +x=U:C3-2 s=Sa:b;Cfoo#g-2;Bg=g;QC 1: g: 0 g;Bmaster=3;Os EHEAD,b,a!I b=b`, + expected: ` +x=E:Q;Cfoo#CP-3 s=Sa:bs;Bmaster=CP;Os Cbs-a b=b!E`, + }, + "nothing to do": { + input: "a=B|x=U:Os;Cfoo#g;Bg=g;QC 2: g: 0 g", + expected: "x=E:Q", + }, + "continue with staged files": { + input: "a=B|x=U:Os I foo=bar;Cfoo#g;Bg=g;QC 2: g: 0 g", + expected: ` +x=E:Cfoo#CP-2 s=Sa:Ns;Bmaster=CP;Os Cfoo#Ns-1 foo=bar;Q`, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const continuer = co.wrap(function *(repos, maps) { + const repo = repos.x; + const result = yield CherryPickUtil.continue(repo); + const commitMap = {}; + RepoASTTestUtil.mapSubCommits(commitMap, + result.submoduleCommits, + maps.commitMap); + Object.keys(result.newSubmoduleCommits).forEach(name => { + const sha = result.newSubmoduleCommits[name]; + commitMap[sha] = `N${name}`; + }); + if (null !== result.newMetaCommit) { + commitMap[result.newMetaCommit] = "CP"; + } + assert.equal(result.errorMessage, c.errorMessage || null); + return { + commitMap: commitMap, + }; + }); + yield RepoASTTestUtil.testMultiRepoManipulator(c.input, + c.expected, + continuer, + c.fails); + })); + }); + + it("handles multiple commits", co.wrap(function*() { + const start = `a=B:C2-1 a=a1; + C3-2 a=a3; + C4-3 a=a4; + C5-3 a=a5; + C6-3 a=a6; + Bb3=3;Bb4=4;Bb5=5;Bb6=6| + x=S:Cm2-1 s=Sa:2; + Cm3-m2 s=Sa:3; + Cm4-m2 s=Sa:4; + Cm5-m2 s=Sa:5; + Cm6-m2 s=Sa:6; + Bmaster=m3;Bm4=m4;Bm5=m5;Bm6=m6;Os`; + const repoMap = yield RepoASTTestUtil.createMultiRepos(start); + const repo = repoMap.repos.x; + const commits = [ + (yield repo.getCommit(repoMap.reverseCommitMap.m4)), + (yield repo.getCommit(repoMap.reverseCommitMap.m5)), + (yield repo.getCommit(repoMap.reverseCommitMap.m6)), + ]; + const result = yield CherryPickUtil.cherryPick(repo, commits); + // I expect, here, that commit m4 has successfully applied, and + // then m5 has hit a conflict... + assert.equal("m5", repoMap.commitMap[result.pickingCommit.id()]); + assert.isNull(result.newMetaCommit); + let rast = yield ReadRepoASTUtil.readRAST(repo, false); + const remainingCommits = [ + commits[0].id().tostrS(), + commits[1].id().tostrS(), + commits[2].id().tostrS() + ]; + + assert.deepEqual(remainingCommits, rast.sequencerState.commits); + assert.equal(1, rast.sequencerState.currentCommit); + + //now, let's resolve & continue + yield fs.writeFile(path.join(repo.workdir(), "s", "a"), "resolv"); + yield Add.stagePaths(repo, ["s/a"], true, false); + const contResult = yield CherryPickUtil.continue(repo); + + assert.equal("m6", repoMap.commitMap[contResult.pickingCommit.id()]); + assert.isNull(contResult.newMetaCommit); + + rast = yield ReadRepoASTUtil.readRAST(repo, false); + assert.deepEqual(remainingCommits, rast.sequencerState.commits); + assert.equal(2, rast.sequencerState.currentCommit); + + //finally, we'll do it again, which should finish this up + yield fs.writeFile(path.join(repo.workdir(), "s", "a"), "resolv2"); + try { + yield CherryPickUtil.continue(repo); + assert.equal(1, 2); //fail + } catch (e) { + //can't continue until we resolve + } + yield Add.stagePaths(repo, ["s/a"], true, false); + const contResult2 = yield CherryPickUtil.continue(repo); + assert.isNull(contResult2.errorMessage); + rast = yield ReadRepoASTUtil.readRAST(repo, false); + assert.isNull(rast.sequencerState); + })); +}); + +describe("abort", function () { + const cases = { + "no cherry": { + input: "x=S", + fails: true, + }, + "some changes in meta": { + input: "x=S:C2-1 s=S/a:1;Bmaster=2;QC 1: 2: 0 2", + expected: "x=S", + }, + "some conflicted changes in meta": { + input: ` +x=S:C2-1 s=S/a:1;Bmaster=2;QC 1: 2: 0 2;I *s=~*~*s=S:1`, + expected: "x=S", + }, + + "sub with a conflict": { + input: ` +a=B:Ca-1;Cb-1 a=8;Ba=a;Bb=b| +x=U:QC 3: 8: 0 8;C3-2 s=Sa:a;C8-2 s=Sa:b;Bmaster=3;Bfoo=8; + Os Ba=a!Bb=b!Edetached HEAD,b,a!I *a=~*a*8!W a=\ +<<<<<<< HEAD +a +======= +8 +>>>>>>> message +; +`, + expected: `x=E:Q;Os E!I a=~!W a=~!Ba=a!Bb=b`, + } + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const aborter = co.wrap(function *(repos) { + const repo = repos.x; + yield CherryPickUtil.abort(repo); + }); + yield RepoASTTestUtil.testMultiRepoManipulator(c.input, + c.expected, + aborter, + c.fails); + })); + }); +}); +}); + +describe("resolveUrlConflicts", function() { + const cases = { + "choose our urls": { + ancestors: { a: "a", b: "b", c: "c" }, + ours: { a: "../a", c: "c", d: "d" }, + theirs: { a: "a", b: "b", c: "c" }, + expected: { a: "../a", c: "c", d: "d" }, + numConflict: 0 + }, + "choose their urls": { + ancestors: { a: "a", b: "b", c: "c" }, + theirs: { a: "../a", c: "c", d: "d" }, + ours: { a: "a", b: "b", c: "c" }, + expected: { a: "../a", c: "c", d: "d" }, + numConflict: 0 + }, + "choose new and consensus": { + ancestors: { a: "a", b: "b", c: "c" }, + ours: { a: "../a", c: "c", d: "d" }, + theirs: { a: "../a", c: "c", d: "d" }, + expected: { a: "../a", c: "c", d: "d" }, + numConflict: 0 + }, + conflict: { + ancestors: { a: "a" }, + ours: { a: "x" }, + theirs: { a: "y" }, + expected: {}, + numConflict: 1 + } + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it( + caseName, + function() { + const result = CherryPickUtil.resolveUrlsConflicts( + c.ancestors, + c.ours, + c.theirs + ); + assert.equal( + Object.keys(result.conflicts).length, + c.numConflict + ); + assert.deepEqual(result.urls, c.expected); + } + ); + }); +}); diff --git a/node/test/util/cherrypick.js b/node/test/util/cherrypick.js deleted file mode 100644 index 65ba45457..000000000 --- a/node/test/util/cherrypick.js +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright (c) 2016, 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 Cherrypick = require("../../lib/util/cherrypick"); -const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); - -describe("cherrypick", function () { - // We will always cherry-pick commit 8 from repo x and will name the - // meta-repo cherry-picked commit 9. Cherry-picked commits from - // submodules will have the submodule name appended to the commit. - // - // One concern with these tests is that the logic used by Git to generate - // new commit ids can result in collision when the exact same commit would - // be generated very quickly. For example, if you have this setup: - // - // S:C2-1;Bfoo=2 - // - // And try to cherry-pick "2" to master, it will likely generate the exact - // same physical commit id already mapped to "2" and screw up the test, - // which expects a new commit id. This problem means that we cannot easily - // test that case (without adding timers), which is OK as we're not - // actually testing libgit2's cherry-pick facility, but also that we need - // to have unique commit histories in our submodules -- we can't have 'S' - // everywhere for these tests. - - const picker = co.wrap(function *(repos, maps) { - const x = repos.x; - const oldMap = maps.commitMap; - const reverseCommitMap = maps.reverseCommitMap; - assert.property(reverseCommitMap, "8"); - const eightCommitSha = reverseCommitMap["8"]; - const eightCommit = yield x.getCommit(eightCommitSha); - const result = yield Cherrypick.cherryPick(x, eightCommit); - - // Now we need to build a map from new physical commit id to new - // logical commit id. For the meta commit, this is easy: we map the - // new id to the hard-coded value of "9". - - let commitMap = {}; - commitMap[result.newMetaCommit] = "9"; - - // For the submodules, we need to first figure out what the old logical - // commit (the one from the shorthand) was, then create the new logical - // commit id by appending the submodule name. We map the new - // (physical) commit id to this new logical id. - - Object.keys(result.submoduleCommits).forEach(name => { - const subCommits = result.submoduleCommits[name]; - Object.keys(subCommits).forEach(oldId => { - const oldLogicalId = oldMap[oldId]; - const newLogicalId = oldLogicalId + name; - const newPhysicalId = subCommits[oldId]; - commitMap[newPhysicalId] = newLogicalId; - }); - }); - return { - commitMap: commitMap, - }; - }); - - const cases = { - "simplest": { - input: "x=S:C8-2;C2-1;Bfoo=8", - expected:"x=E:C9-1 8=8;Bmaster=9", - }, - "no change to sub": { - input: "a=Ax|x=S:C8-2;C2-1 s=Sa:x;Bfoo=8", - expected: "x=E:C9-1 8=8;Bmaster=9", - }, - "picking one sub": { - input: "\ -a=Ax:Cz-y;Cy-x;Bfoo=z|\ -x=S:C8-3 s=Sa:z;C3-2;C2-1 s=Sa:x;Bfoo=8;Bmaster=2;Os H=x", - expected: "x=E:C9-2 s=Sa:zs;Bmaster=9;Os Czs-x z=z!H=zs", - }, - "picking closed sub": { - input: "\ -a=Ax:Cz-y;Cy-x;Bfoo=z|\ -x=S:C8-3 s=Sa:z;C3-2;C2-1 s=Sa:x;Bfoo=8;Bmaster=2", - expected: "x=E:C9-2 s=Sa:zs;Bmaster=9;Os Czs-x z=z!H=zs", - }, - "picking closed sub with change": { - input: "\ -a=Ax:Cw-x;Cz-x;Cy-x;Bfoo=z;Bbar=y;Bbaz=w|\ -x=S:C4-2 s=Sa:w;C8-3 s=Sa:z;C3-2;C2-1 s=Sa:y;Bfoo=8;Bmaster=4", - expected: "x=E:C9-4 s=Sa:zs;Bmaster=9;Os Czs-w z=z!H=zs", - }, - "picking two closed subs": { - input: "\ -a=Ax:Cz-y;Cy-x;Bfoo=z|\ -b=Aa:Cc-b;Cb-a;Bfoo=c|\ -x=S:\ -C8-3 s=Sa:z,t=Sb:c;C3-2;C2-1 s=Sa:x,t=Sb:a;\ -Bfoo=8;Bmaster=2", - expected: "\ -x=E:C9-2 s=Sa:zs,t=Sb:ct;\ -Bmaster=9;\ -Os Czs-x z=z!H=zs;\ -Ot Cct-a c=c!H=ct", - }, - "new sub on head": { - input: ` -a=B| -x=U:C8-2;C4-2 t=Sa:1;Bmaster=4;Bfoo=8`, - expected: "x=E:C9-4 8=8;Bmaster=9", - }, - "don't pick subs from older commit": { - input: ` -a=B:Cr-1;Cq-r;Bq=q| -x=S:C2-1 s=Sa:1,t=Sa:1;C3-2 s=Sa:q;C8-3 t=Sa:q;Bmaster=2;Bfoo=8`, - expected: ` -x=E:C9-2 t=Sa:qt;Bmaster=9;Ot Cqt-1 q=q!H=qt`, - }, - }; - - Object.keys(cases).forEach(caseName => { - const c = cases[caseName]; - it(caseName, co.wrap(function *() { - yield RepoASTTestUtil.testMultiRepoManipulator(c.input, - c.expected, - picker, - c.fails); - })); - }); -}); diff --git a/node/test/util/close_util.js b/node/test/util/close_util.js index 300910b23..c37659bb1 100644 --- a/node/test/util/close_util.js +++ b/node/test/util/close_util.js @@ -39,8 +39,8 @@ const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); describe("close_util", function () { describe("close", function () { // Don't need to test that deinitialization works, that's tested in - // deinit_util; just need to see that we handle paths and dirty - // sumobulde situations. + // submodule_config_util; just need to see that we handle paths and + // dirty submodule situations. const cases = { "trivial": { @@ -53,6 +53,10 @@ describe("close_util", function () { paths: ["s"], expected: "a=B|x=U", }, + "with path and closed sub": { + state: "a=B|x=U", + paths: ["."], + }, "simple with resolved paths": { state: "a=B|x=U:Os", paths: ["s"], @@ -74,6 +78,24 @@ describe("close_util", function () { paths: ["s","t"], expected: "x=S:C2-1 s=Sa:1,t=Sa:1;Bmaster=2", }, + // This tests something only triggered by a race condition, so + // it might be hard to trigger a failure + "multiple, in a subdir": { + state: `a=B|x=S:C2-1 a/b/c/d/e/s1=Sa:1, +a/b/c/d/e/s2=Sa:1, +a/b/c/d/e/s3=Sa:1, +a/b/c/d/e/s4=Sa:1; +Bmaster=2; +Oa/b/c/d/e/s1; +Oa/b/c/d/e/s2; +Oa/b/c/d/e/s3; +Oa/b/c/d/e/s4`, + paths: ["a"], + expected: `x=S:C2-1 a/b/c/d/e/s1=Sa:1, +a/b/c/d/e/s2=Sa:1, +a/b/c/d/e/s3=Sa:1, +a/b/c/d/e/s4=Sa:1;Bmaster=2`, + }, "dirty fail staged": { state: "a=B|x=U:Os I a=b", paths: ["s"], @@ -96,24 +118,39 @@ describe("close_util", function () { expected: "x=S:C2-1 s=Sa:1,t=Sa:1;Bmaster=2;Os I a=b", fails: true, }, + "a/b doesn't close a": { + state: "a=B|x=U:Os", + paths: ["s/t"], + expected: "a=B|x=U:Os", + }, }; Object.keys(cases).forEach(caseName => { - const c = cases[caseName]; - const closer = co.wrap(function *(repos) { - const x = repos.x; - let cwd = x.workdir(); - if (undefined !== c.cwd) { - cwd = path.join(cwd, c.cwd); - } - yield CloseUtil.close(x, cwd, c.paths, c.force || false); - }); - it(caseName, co.wrap(function *() { - yield RepoASTTestUtil.testMultiRepoManipulator(c.state, - c.expected, - closer, - c.fails); + for (const sparse of [true, false]) { + const c = cases[caseName]; + const closer = co.wrap(function *(repos) { + const x = repos.x; + let cwd = x.workdir(); + if (undefined !== c.cwd) { + cwd = path.join(cwd, c.cwd); + } + yield CloseUtil.close(x, cwd, c.paths, c.force || false); + }); + it(caseName + (sparse ? " sparse" : ""), co.wrap(function *() { + let state = c.state; + let expected = c.expected; + if (sparse) { + state = state.replace("x=S", "x=%S"); + if (expected !== undefined) { + expected = expected.replace("x=S", "x=%S"); + } + } + yield RepoASTTestUtil.testMultiRepoManipulator(state, + expected, + closer, + c.fails); - })); + })); + } }); }); }); diff --git a/node/test/util/commit.js b/node/test/util/commit.js index 7c57b62a5..26b72c3d0 100644 --- a/node/test/util/commit.js +++ b/node/test/util/commit.js @@ -32,6 +32,7 @@ const assert = require("chai").assert; const co = require("co"); +const fs = require("fs-promise"); const NodeGit = require("nodegit"); const path = require("path"); @@ -45,6 +46,16 @@ const TestUtil = require("../../lib/util/test_util"); const UserError = require("../../lib/util/user_error"); +function msgfunc(msg) { + return co.wrap(function*() { + // This bogosity is to satisfy jshint + yield new Promise(function(resolve) { + resolve(null); + }); + return msg; + }); +} + function mapCommitResult(commitResult) { // Return a map from physical to computed logical sha for the commit ids in // the specified `commitResul` (as returned by `Commit.commit` and @@ -73,7 +84,8 @@ const committer = co.wrap(function *(doAll, message, repos, subMessages) { showMetaChanges: true, all: doAll, }); - const result = yield Commit.commit(x, doAll, status, message, subMessages); + const result = yield Commit.commit(x, doAll, status, msgfunc(message), + subMessages, false); return { commitMap: mapCommitResult(result), }; @@ -683,7 +695,6 @@ Untracked files: expected: base, options: { all: false, - showMetaChanges: true, paths: [], }, }, @@ -691,110 +702,34 @@ Untracked files: state: "x=S:W foo=bar", expected: base, }, - "with meta": { + "with meta, ignored": { state: "x=S:W foo=bar", - options: { - showMetaChanges: true, - }, - expected: base.copy({ - workdir: { foo: FILESTATUS.ADDED }, - }), - }, - "staged": { - state: "x=S:I a=b", - options: { - showMetaChanges: true, - all: true, - }, - expected: base.copy({ - staged: { - a: FILESTATUS.ADDED, - }, - }), - }, - "without paths": { - state: "x=S:I foo=bar,baz=bam", - options: { - showMetaChanges: true, - }, - expected: base.copy({ - staged: { - foo: FILESTATUS.ADDED, - baz: FILESTATUS.ADDED, - }, - }), - }, - "with paths": { - state: "x=S:I foo/bar=bar,baz=bam", - options: { - showMetaChanges: true, - paths: [ "foo" ], - }, - expected: base.copy({ - staged: { - "foo/bar": FILESTATUS.ADDED, - }, - workdir: { - "baz": FILESTATUS.ADDED, - }, - }), - }, - "without relative path": { - state: "x=S:I foo/bar=bar,bar=bam", - options: { - showMetaChanges: true, - paths: [ "bar" ], - }, - expected: base.copy({ - staged: { - "bar": FILESTATUS.ADDED, - }, - workdir: { - "foo/bar": FILESTATUS.ADDED, - }, - }), - }, - "with relative path": { - state: "x=S:I foo/bar=bar,bar=bam", - options: { - showMetaChanges: true, - paths: [ "bar" ], - }, - workdir: "foo", - expected: base.copy({ - staged: { - "foo/bar": FILESTATUS.ADDED, - }, - workdir: { - "bar": FILESTATUS.ADDED, - }, - }), - }, - "without all": { - state: "x=S:W README.md=88", - options: { - showMetaChanges: true, - }, - expected: base.copy({ - workdir: { - "README.md": FILESTATUS.MODIFIED, - }, - }), + expected: base, }, - "with all": { - state: "x=S:W README.md=88", + "sub staged": { + state: "a=B|x=U:C3-2;Bmaster=3;Os I a=b", options: { - showMetaChanges: true, all: true, }, - expected: base.copy({ - staged: { - "README.md": FILESTATUS.MODIFIED, + expected: new RepoStatus({ + currentBranchName: "master", + headCommit: "3", + submodules: { + s: new Submodule({ + commit: new Submodule.Commit("1", "a"), + index: new Submodule.Index("1", "a", SAME), + workdir: new Submodule.Workdir(new RepoStatus({ + headCommit: "1", + staged: { + a: FILESTATUS.ADDED, + }, + }), SAME), + }), }, }), }, - "sub staged": { - state: "a=B|x=U:C3-2;Bmaster=3;Os I a=b", + "sub staged in new dir with all": { + state: "a=B|x=U:C3-2;Bmaster=3;Os I a/b/c=b", options: { all: true, }, @@ -808,7 +743,7 @@ Untracked files: workdir: new Submodule.Workdir(new RepoStatus({ headCommit: "1", staged: { - a: FILESTATUS.ADDED, + "a/b/c": FILESTATUS.ADDED, }, }), SAME), }), @@ -1000,11 +935,14 @@ x=E:Cx-2 x=Sq:1;Bmaster=x;I s=~,x=~`, // Will always use subrepo 's' in repo 'x' const cases = { "unchanged": { - input: "a=B|x=U:C3-2;Bmaster=3", + input: "a=B|x=U:C3-2;Bmaster=3;Os", expected: { status: new Submodule({ commit: new Submodule.Commit("1", "a"), index: new Submodule.Index("1", "a", RELATION.SAME), + workdir: new Submodule.Workdir(new RepoStatus({ + headCommit: "1", + }), RELATION.SAME), }), }, }, @@ -1022,11 +960,14 @@ x=E:Cx-2 x=Sq:1;Bmaster=x;I s=~,x=~`, }, }, "added in commit": { - input: "a=B|x=U", + input: "a=B|x=U:Os", expected: { status: new Submodule({ commit: null, index: new Submodule.Index("1", "a", null), + workdir: new Submodule.Workdir(new RepoStatus({ + headCommit: "1", + }), RELATION.SAME), }), }, }, @@ -1143,7 +1084,9 @@ a=B:Chi#a-1;Ba=a|x=U:C3-2 s=Sa:a;Bmaster=3;Os W README.md=888`, let oldSubs = {}; if (null !== parent) { oldSubs = - yield SubmoduleUtil.getSubmodulesForCommit(repo, parent); + yield SubmoduleUtil.getSubmodulesForCommit(repo, + parent, + null); } const old = oldSubs.s || null; const getRepo = co.wrap(function *() { @@ -1187,51 +1130,13 @@ a=B:Chi#a-1;Ba=a|x=U:C3-2 s=Sa:a;Bmaster=3;Os W README.md=888`, }), }, }, - "include meta": { - state: "x=S:C2-1;Bmaster=2;I a=b;W README.md=888", - includeMeta: true, - expected: { - status: new RepoStatus({ - currentBranchName: "master", - headCommit: "2", - staged: { - a: FILESTATUS.ADDED, - "2": FILESTATUS.ADDED, - }, - workdir: { - "README.md": FILESTATUS.MODIFIED, - }, - }), - }, - }, - "include meta, all": { - state: "x=S:C2-1;Bmaster=2;I a=b;W README.md=888", - includeMeta: true, - all: true, - expected: { - status: new RepoStatus({ - currentBranchName: "master", - headCommit: "2", - staged: { - a: FILESTATUS.ADDED, - "2": FILESTATUS.ADDED, - "README.md": FILESTATUS.MODIFIED, - }, - }), - }, - }, "sub, no amend": { state: "a=B|x=U:C3-2;Bmaster=3", expected: { status: new RepoStatus({ currentBranchName: "master", headCommit: "3", - submodules: { - s: new Submodule({ - commit: new Submodule.Commit("1", "a"), - index: new Submodule.Index("1", "a", SAME), - }), - }, + submodules: {}, }), }, }, @@ -1288,6 +1193,9 @@ a=B:Chi#a-1;Ba=a|x=U:C3-2 s=Sa:a;Bmaster=3;Os W README.md=888`, status: new RepoStatus({ currentBranchName: "master", headCommit: "2", + staged: { + ".gitmodules": FILESTATUS.REMOVED + } }), }, }, @@ -1391,6 +1299,96 @@ a=B:Chi#a-1;Ba=a|x=U:C3-2 s=Sa:a;Bmaster=3;Os W README.md=888`, }, }, }, + "no-op amend of a merge": { + state: `a=B:Chi#a-1;Cbranch2#b-1;Ba=a;Bb=b| + x=U:C3-1 s2=Sa:b;Cm-2,3 s2=Sa:b;Bmaster=m;Os`, + expected: { + status: new RepoStatus({ + currentBranchName: "master", + headCommit: "m", + submodules: {}, + }), + }, + toAmend: {} + }, + "amend of a merge (left)": { + state: `a=B:Chi#a-1;Cbranch2#b-1;Ba=a;Bb=b| + x=U:C3-1 s2=Sa:b;Cm-2,3 s2=Sa:b;Bmaster=m;Os I a=b`, + all: true, + expected: { + status: new RepoStatus({ + currentBranchName: "master", + headCommit: "m", + submodules: { + s : new Submodule({ + commit: new Submodule.Commit("1", "a"), + index: new Submodule.Index("1", "a", SAME), + workdir: new Submodule.Workdir(new RepoStatus({ + headCommit: "1", + staged: { + a: FILESTATUS.ADDED, + }, + }), SAME), + }), + }, + }), + }, + }, + "amend of a merge (right)": { + state: `a=B:Chi#a-1;Cbranch2#b-1;Ba=a;Bb=b| + x=U:C3-1 s2=Sa:b;Cm-2,3 s2=Sa:b;Bmaster=m;Os2 I a=b`, + all: true, + expected: { + status: new RepoStatus({ + currentBranchName: "master", + headCommit: "m", + submodules: { + s2 : new Submodule({ + commit: new Submodule.Commit("b", "a"), + index: new Submodule.Index("b", "a", SAME), + workdir: new Submodule.Workdir(new RepoStatus({ + headCommit: "b", + staged: { + a: FILESTATUS.ADDED, + }, + }), SAME), + }), + }, + }), + }, + }, + "no-op amend of a merge (changed in merge)": { + state: `a=B:Chi#a-1;Cbranch2#b-1;Cthird#c-a;Ba=a;Bb=b;Bc=c| + x=U:C3-1 s2=Sa:b;Cm-2,3 s2=Sa:c;Bmaster=m`, + all: true, + expected: { + status: new RepoStatus({ + currentBranchName: "master", + headCommit: "m", + submodules: { + s2 : new Submodule({ + index: new Submodule.Index("c", "a", null), + }), + }, + }), + }, + }, + "actual amend of a merge (changed in merge)": { + state: `a=B:Chi#a-1;Cbranch2#b-1;Cc-a;Cd-a;Ba=a;Bb=b;Bc=c;Bd=d| + x=U:C3-1 s2=Sa:b;Cm-2,3 s2=Sa:c;Bmaster=m;I s2=Sa:d`, + all: true, + expected: { + status: new RepoStatus({ + currentBranchName: "master", + headCommit: "m", + submodules: { + s2 : new Submodule({ + index: new Submodule.Index("d", "a", null), + }), + }, + }), + }, + }, }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; @@ -1442,9 +1440,13 @@ x=N:Cfoo\n#x README.md=hello world;*=master;Bmaster=x`, it(caseName, co.wrap(function *() { const amend = co.wrap(function *(repos) { const repo = repos.x; - const newSha = yield Commit.amendRepo(repo, c.message); + const newOid = yield Commit.createAmendCommit(repo, + c.message); + const newCommit = yield NodeGit.Commit.lookup(repo, + newOid); + yield GitUtil.updateHead(repo, newCommit, "amend"); const commitMap = {}; - commitMap[newSha] = "x"; + commitMap[newOid.tostrS()] = "x"; return { commitMap: commitMap }; }); yield RepoASTTestUtil.testMultiRepoManipulator(c.input, @@ -1470,15 +1472,6 @@ x=N:Cfoo\n#x README.md=hello world;*=master;Bmaster=x`, input: "x=N:Cm#1;H=1", expected: "x=N:Cx 1=1;H=x", }, - "meta change": { - input: "x=S:C2-1;Bmaster=2;I README.md=3", - expected: "x=S:Cx-1 README.md=3,2=2;Bmaster=x", - }, - "meta staged": { - input: "x=S:C2-1;Bmaster=2;W README.md=3", - expected: "x=S:Cx-1 README.md=3,2=2;Bmaster=x", - all: true, - }, "repo with new sha in index": { input: "a=B:Ca-1;Bmaster=a|x=U:C3-2;I s=Sa:a;Bmaster=3", expected: "x=U:Cx-2 3=3,s=Sa:a;Bmaster=x", @@ -1607,6 +1600,14 @@ a=B:Ca-1;Bx=a|x=U:C3-2 s=Sa:a;Bmaster=3;Os;I foo=moo`, expected: ` x=U:Chi\n#x-2 foo=moo,s=Sa:s;Bmaster=x;Os Cmeh\n#s-1 a=a!H=s`, }, + "one reverted, one not": { + input: ` +a=B:Cb-1 a=1,b=2;Cc-b a=2;Bc=c| +x=U:C3-2 s=Sa:b;C4-3 s=Sa:c;Bmaster=4;Os W a=1,b=1`, + all: true, + expected: ` +x=U:C3-2 s=Sa:b;Cx-3 s=Sa:s;Bmaster=x;Os Cs-b b=1`, + }, }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; @@ -1630,7 +1631,8 @@ x=U:Chi\n#x-2 foo=moo,s=Sa:s;Bmaster=x;Os Cmeh\n#s-1 a=a!H=s`, subsToAmend, all, message, - subMessages); + subMessages, + false); return { commitMap: mapCommitResult(result) }; }); yield RepoASTTestUtil.testMultiRepoManipulator(c.input, @@ -1897,22 +1899,7 @@ my message }, }), req: new RepoStatus(), - exp: new RepoStatus({ - submodules: { - xxx: new Submodule({ - commit: new Submodule.Commit("1", "/a"), - index: new Submodule.Index("1", - "/a", - RELATION.SAME), - workdir: new Submodule.Workdir(new RepoStatus({ - headCommit: "1", - workdir: { - xxx: FILESTATUS.MODIFIED, - }, - }), RELATION.SAME), - }), - }, - }), + exp: new RepoStatus(), }, }; Object.keys(cases).forEach(caseName => { @@ -1935,6 +1922,13 @@ my message }, expected: "x=S:Cx-1 README.md=haha;Bmaster=x", }, + "staged change with exec bit": { + state: "x=S:I README.md=+haha", + fileChanges: { + "README.md": FILESTATUS.MODIFIED, + }, + expected: "x=S:Cx-1 README.md=+haha;Bmaster=x", + }, "simple staged change, added eol": { state: "x=S:I README.md=haha", message: "hah", @@ -1948,6 +1942,11 @@ my message fileChanges: { "README.md": FILESTATUS.MODIFIED, }, expected: "x=S:Cx-1 README.md=haha;Bmaster=x", }, + "workdir change with exec bit": { + state: "x=S:W README.md=+haha", + fileChanges: { "README.md": FILESTATUS.MODIFIED, }, + expected: "x=S:Cx-1 README.md=+haha;Bmaster=x", + }, "simple change with message": { state: "x=S:I README.md=haha", fileChanges: { "README.md": FILESTATUS.MODIFIED, }, @@ -1959,6 +1958,11 @@ my message fileChanges: { "q": FILESTATUS.ADDED }, expected: "x=S:Cx-1 q=3;Bmaster=x", }, + "removed file": { + state: "x=S:C2-1 foo=bar;C3-2;W foo;Bmaster=3", + fileChanges: { "foo": FILESTATUS.REMOVED }, + expected: "x=S:Cx-3 foo;Bmaster=x", + }, "added two files, but mentioned only one": { state: "x=S:W q=3,r=4", fileChanges: { "q": FILESTATUS.ADDED }, @@ -2029,6 +2033,10 @@ x=S:C2-1 q/r/s=Sa:1;Bmaster=2;Oq/r/s H=a`, const result = yield Commit.writeRepoPaths(repo, status, message); + const commit = yield repo.getCommit(result); + yield NodeGit.Reset.reset(repo, commit, + NodeGit.Reset.TYPE.SOFT); + const commitMap = {}; commitMap[result] = "x"; return { commitMap: commitMap, }; @@ -2083,7 +2091,8 @@ x=S:C2-1 q/r/s=Sa:1;Bmaster=2;Oq/r/s H=a`, const message = c.message || "message"; const result = yield Commit.commitPaths(repo, status, - message); + msgfunc(message), + false); const commitMap = {}; commitMap[result.metaCommit] = "x"; Object.keys(result.submoduleCommits).forEach(name => { @@ -2633,7 +2642,8 @@ x=S:C2-1 q/r/s=Sa:1;Bmaster=2;Oq/r/s H=a`, }), expected: `\ -# <*> enter meta-repo message above this line; delete to commit only submodules +# <*> enter meta-repo message above this line; delete this line to commit \ +only submodules # On branch master. # # Please enter the commit message(s) for your changes. The message for a @@ -2652,7 +2662,8 @@ x=S:C2-1 q/r/s=Sa:1;Bmaster=2;Oq/r/s H=a`, metaCommitData: new Commit.CommitMetaData(sig, "hiya"), expected: `\ hiya -# <*> enter meta-repo message above this line; delete to commit only submodules +# <*> enter meta-repo message above this line; delete this line to commit \ +only submodules # Date: 12/31/1969, 23:00:03 -100 # # On branch master. @@ -2675,7 +2686,8 @@ hiya }), expected: `\ -# <*> enter meta-repo message above this line; delete to commit only submodules +# <*> enter meta-repo message above this line; delete this line to commit \ +only submodules # On branch foo. # Changes to be committed: # \tnew file: baz @@ -2703,7 +2715,8 @@ hiya }), expected: `\ -# <*> enter meta-repo message above this line; delete to commit only submodules +# <*> enter meta-repo message above this line; delete this line to commit \ +only submodules # On branch foo. # Changes to be committed: # \tmodified: bar (submodule, new commits) @@ -2740,7 +2753,8 @@ hiya }), expected: `\ -# <*> enter meta-repo message above this line; delete to commit only submodules +# <*> enter meta-repo message above this line; delete this line to commit \ +only submodules # On branch foo. # # Please enter the commit message(s) for your changes. The message for a @@ -2775,7 +2789,8 @@ hiya }), expected: `\ -# <*> enter meta-repo message above this line; delete to commit only submodules +# <*> enter meta-repo message above this line; delete this line to commit \ +only submodules # On branch foo. # ----------------------------------------------------------------------------- @@ -2819,7 +2834,8 @@ committing 'bar' }, expected: `\ -# <*> enter meta-repo message above this line; delete to commit only submodules +# <*> enter meta-repo message above this line; delete this line to commit \ +only submodules # On branch foo. # ----------------------------------------------------------------------------- yoyoyo @@ -2868,7 +2884,8 @@ committing 'bar' }, expected: `\ yoyoyo -# <*> enter meta-repo message above this line; delete to commit only submodules +# <*> enter meta-repo message above this line; delete this line to commit \ +only submodules # Date: 12/31/1969, 23:00:03 -100 # # On branch foo. @@ -3052,41 +3069,28 @@ and for d "nothing to commit": { initial: "x=S", }, - "no meta, no commit": { - initial: "x=S:I a=b", - meta: false, - }, - "meta commit": { + "meta changes, ignored": { initial: "x=S:I a=b", message: "foo\n", - expected: "x=S:Cfoo\n#x-1 a=b;Bmaster=x", - }, - "meta commit, with editor": { - initial: "x=S:I a=b", - editor: () => Promise.resolve("haha"), - expected: "x=S:Chaha\n#x-1 a=b;Bmaster=x", - }, - "interactive meta commit, but do nothing": { - initial: "x=S:I a=b", - interactive: true, - editor: (_, content) => Promise.resolve(content), - fails: true, }, "no all": { - initial: "x=S:W README.md=2", + initial: "a=B|x=U:Os W README.md=2", + message: "foo", }, "all": { - initial: "x=S:W README.md=2", - all: true, + initial: "a=B|x=U:Os W README.md=2", message: "foo", - expected: "x=S:Cfoo\n#x-1 README.md=2;Bmaster=x", + all: true, + expected: ` +x=S:Cfoo\n#x-2 s=Sa:s;Os Cfoo\n#s-1 README.md=2!H=s;Bmaster=x`, }, "paths, cwd": { - initial: "x=S:I a/b=b,b=d", + initial: "a=B|x=U:Os I a/b=b,b=d", message: "foo", paths: ["b"], - cwd: "a", - expected: "x=S:Cfoo\n#x-1 a/b=b;I b=d;Bmaster=x", + cwd: "s/a", + expected: ` +x=S:Cfoo\n#x-2 s=Sa:s;Os Cfoo\n#s-1 a/b=b!I b=d!H=s;Bmaster=x`, }, "uncommitable": { initial: "a=B|x=S:I a=Sa:;Oa", @@ -3096,9 +3100,19 @@ and for d "not path-compatible": { initial: "x=S:I s=S.:1,a=b", message: "foo", - paths: ["a"], + paths: ["s"], fails: true, }, + "path-compatible (submodule)": { + initial: "x=S:C2-1 g=S I s=S.:1,a=b;Bmaster=2", + message: "foo", + paths: ["s"], + }, + "path-compatible": { + initial: "x=S:I s=S.:1,a=b", + message: "foo", + paths: ["a"], + }, "interactive": { initial: "a=B|x=U:Os I a=b", interactive: true, @@ -3120,32 +3134,41 @@ x=U:Cfoo\n#x-2 s=Sa:s;Os Cbar\n#s-1 a=b!H=s;Bmaster=x`, Object.keys(cases).forEach(caseName => { const c = cases[caseName]; const doCommit = co.wrap(function *(repos) { - const repo = repos.x; - let cwd = ""; - if (undefined !== c.cwd) { - cwd = path.join(repo.workdir(), c.cwd); - } - else { - cwd = repo.workdir(); - } - const editor = c.editor || (() => { - assert(false, "no editor"); - }); - const meta = undefined === c.meta ? true : false; - const result = yield Commit.doCommitCommand( - repo, - cwd, - c.message || null, - meta, - c.all || false, - c.paths || [], - c.interactive || false, - editor); - if (undefined !== result) { - return { - commitMap: mapCommitResult(result), + const old = GitUtil.editMessage; + try { + const repo = repos.x; + let cwd = ""; + if (undefined !== c.cwd) { + cwd = path.join(repo.workdir(), c.cwd); + } + else { + cwd = repo.workdir(); + } + if (c.editor) { + GitUtil.editMessage = c.editor; + } else { + GitUtil.editMessage = () => { + assert(false, "no editor"); }; } + + const result = yield Commit.doCommitCommand( + repo, + cwd, + c.message || null, + c.all || false, + c.paths || [], + c.interactive || false, + false, + true); + if (undefined !== result) { + return { + commitMap: mapCommitResult(result), + }; + } + } finally { + GitUtil.editMessage = old; + } }); it(caseName, co.wrap(function *() { yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, @@ -3176,32 +3199,50 @@ x=U:Chola\n#x-2 s=Sa:s;Bmaster=x;Os Cthere\n#s-1 a=a!H=s`, }, "simple amend": { - initial: "x=S:C2-1 README.md=foo;Bmaster=2", + initial: ` +a=B:Ca-1 README.md=foo;Bmaster=a| +x=U:C3-2 s=Sa:a;Bmaster=3;Os`, + message: "foo", + expected: ` +x=U:Cfoo\n#x-2 s=Sa:s;Bmaster=x;Os Cfoo\n#s-1 README.md=foo` + }, + "would be empty": { + initial: "x=S:C2-1 foo=bar;C3-2 foo=foo;Bmaster=3;W foo=bar", message: "foo", - expected: "x=S:Cfoo\n#x-1 README.md=foo;Bmaster=x", }, "amend with change": { - initial: "x=S:C2-1 README.md=foo;Bmaster=2;I a=b", + initial: ` +a=B:Ca-1 README.md=foo;Bmaster=a| +x=U:C3-2 s=Sa:a;Bmaster=3;Os I a=b`, message: "foo", - expected: "x=S:Cfoo\n#x-1 README.md=foo,a=b;Bmaster=x", + expected: ` +x=U:Cfoo\n#x-2 s=Sa:s;Bmaster=x;Os Cfoo\n#s-1 README.md=foo,a=b` }, "amend with no all": { - initial: "x=S:C2-1;Bmaster=2;W README.md=8", + initial: ` +a=B:Ca-1 README.md=foo;Bmaster=a| +x=U:C3-2 s=Sa:a;Bmaster=3;Os W README.md=8`, message: "foo", - expected: "x=S:Cfoo\n#x-1 2=2;Bmaster=x;W README.md=8", + expected: ` +x=U:Cfoo\n#x-2 s=Sa:s;Bmaster=x;Os Cfoo\n#s-1 README.md=foo!W README.md=8` }, "amend with all": { - initial: "x=S:C2-1;Bmaster=2;W README.md=8", + initial: ` +a=B:Ca-1 README.md=foo;Bmaster=a| +x=U:C3-2 s=Sa:a;Bmaster=3;Os W README.md=8`, message: "foo", all: true, - expected: "x=S:Cfoo\n#x-1 2=2,README.md=8;Bmaster=x", + expected: ` +x=U:Cfoo\n#x-2 s=Sa:s;Bmaster=x;Os Cfoo\n#s-1 README.md=8`, }, - "amend with all but no meta": { - initial: "x=S:C2-1;Bmaster=2;W README.md=8", + "amend with all and untracked": { + initial: ` +a=B:Ca-1 README.md=foo;Bmaster=a| +x=U:C3-2 s=Sa:a;Bmaster=3;Os W README.md=8,foo=bar`, message: "foo", all: true, - meta: false, - expected: "x=S:Cfoo\n#x-1 2=2;Bmaster=x;W README.md=8", + expected: ` +x=U:Cfoo\n#x-2 s=Sa:s;Bmaster=x;Os Cfoo\n#s-1 README.md=8!W foo=bar`, }, "mismatch": { initial: ` @@ -3224,9 +3265,12 @@ there x=U:Chola\n#x-2 s=Sa:s;Bmaster=x;Os Cthere\n#s-1 a=a!H=s`, }, "simple amend with editor": { - initial: "x=S:C2-1 README.md=foo;Bmaster=2", + initial: ` +a=B:Ca-1 README.md=foo;Bmaster=a| +x=U:C3-2 s=Sa:a;Bmaster=3;Os`, editor: () => Promise.resolve("heya"), - expected: "x=S:Cheya\n#x-1 README.md=foo;Bmaster=x", + expected: ` +x=U:Cheya\n#x-2 s=Sa:s;Bmaster=x;Os Cheya\n#s-1 README.md=foo` }, "simple amend with editor, no message": { initial: "x=S:C2-1 README.md=foo;Bmaster=2", @@ -3234,38 +3278,47 @@ x=U:Chola\n#x-2 s=Sa:s;Bmaster=x;Os Cthere\n#s-1 a=a!H=s`, fails: true, }, "reuse old message": { - initial: "x=S:Cbar\n#2-1 README.md=foo;I a=b;Bmaster=2", - expected: "x=S:Cbar\n#x-1 README.md=foo, a=b;Bmaster=x", - editor: null, + initial: ` +a=B:Cbar\n#a-1 README.md=foo;Bmaster=a| +x=U:Cbar\n#3-2 s=Sa:a;Bmaster=3;Os I a=b`, + expected: ` +x=U:Cbar\n#x-2 s=Sa:s;Bmaster=x;Os Cbar\n#s-1 README.md=foo, a=b`, + editor: null }, }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; const doAmend = co.wrap(function *(repos) { - const repo = repos.x; - let cwd = ""; - if (undefined !== c.cwd) { - cwd = path.join(repo.workdir(), c.cwd); - } - else { - cwd = repo.workdir(); - } - let editor = c.editor; - if (undefined === editor) { - editor = () => assert(false, "no editor"); + const old = GitUtil.editMessage; + try { + const repo = repos.x; + let cwd = ""; + if (undefined !== c.cwd) { + cwd = path.join(repo.workdir(), c.cwd); + } + else { + cwd = repo.workdir(); + } + let editor = c.editor; + if (editor) { + GitUtil.editMessage = editor; + } else { + GitUtil.editMessage = old; + } + const result = yield Commit.doAmendCommand( + repo, + cwd, + c.message || null, + c.all || false, + c.interactive || false, + false, + editor === undefined); + return { + commitMap: mapCommitResult(result), + }; + } finally { + GitUtil.editMessage = old; } - const meta = undefined === c.meta ? true : false; - const result = yield Commit.doAmendCommand( - repo, - cwd, - c.message || null, - meta, - c.all || false, - c.interactive || false, - editor); - return { - commitMap: mapCommitResult(result), - }; }); it(caseName, co.wrap(function *() { yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, @@ -3275,4 +3328,170 @@ x=U:Chola\n#x-2 s=Sa:s;Bmaster=x;Os Cthere\n#s-1 a=a!H=s`, })); }); }); + + describe("submoduleHooksAreRun", function () { + const addNewFileHook = `#!/bin/bash +echo -n bar > addedbyhook +git add addedbyhook +`; + const message = "msg"; + + it("runs the hook on amend", co.wrap(function*() { + const initial = ` +a=B:Ca-1 README.md=foo;Bmaster=a| +x=U:C3-2 s=Sa:a;Bmaster=3;Os`; + const expected = ` +x=U:Cmsg\n#x-2 s=Sa:s;Bmaster=x;Os Cmsg\n#s-1 README.md=foo,addedbyhook=bar`; + + const f = co.wrap(function*(repos) { + const repo = repos.x; + const cwd = repo.workdir(); + + const hookPath = repo.path() + "modules/s/hooks/pre-commit"; + fs.writeFileSync(hookPath, addNewFileHook); + yield fs.chmod(hookPath, 493); //0755 + + const result = yield Commit.doAmendCommand( + repo, + cwd, + message, + true, // all + false, // interactive + false, + false); + + return { + commitMap: mapCommitResult(result), + }; + + }); + + yield RepoASTTestUtil.testMultiRepoManipulator(initial, + expected, + f, + false); + })); + + it("runs the hook on commit-with-paths", co.wrap(function*() { + const initial = "a=B:Ca-1;Bm=a|x=U:Os I q=r"; + const expected = `x=E:Cmsg\n#x-2 s=Sa:s;Os Cmsg +#s-1 addedbyhook=bar,q=r!H=s;Bmaster=x +`; + + const f = co.wrap(function*(repos) { + const repo = repos.x; + + const hookPath = repo.path() + "modules/s/hooks/pre-commit"; + fs.writeFileSync(hookPath, addNewFileHook); + yield fs.chmod(hookPath, 493); //0755 + + const status = yield Commit.getCommitStatus( + repo, + repo.workdir(), { + showMetaChanges: true, + paths: ["s"], + }); + + const result = yield Commit.commitPaths(repo, + status, + msgfunc(message), + false); + const commitMap = {}; + commitMap[result.metaCommit] = "x"; + Object.keys(result.submoduleCommits).forEach(name => { + const sha = result.submoduleCommits[name]; + commitMap[sha] = name; + }); + return { commitMap: commitMap }; + }); + + yield RepoASTTestUtil.testMultiRepoManipulator(initial, + expected, + f, + false); + })); + + }); +}); + +describe("commit mid-merge", function() { + it("handles a commit mid-merge", co.wrap(function*() { + const MergeUtil = require("../../lib/util/merge_util"); + const MergeCommon = require("../../lib/util/merge_common"); + const Open = require("../../lib/util/open"); + const SeqStateUtil = require("../../lib/util/sequencer_state_util"); + + const start = `a=B:C2-1 a=a1; + C3-2 a=a3; + C4-2 a=a4; + Bb3=3;Bb4=4;| + x=S:Cm2-1 s=Sa:2; + Cm3-m2 s=Sa:3; + Cm4-m2 s=Sa:4; + Bmaster=m3;Bm4=m4;`; + const repoMap = yield RepoASTTestUtil.createMultiRepos(start); + const repo = repoMap.repos.x; + + const m4 = yield NodeGit.Commit.lookup(repo, + repoMap.reverseCommitMap.m4); + try { + yield MergeUtil.merge(repo, + null, + m4, + MergeCommon.MODE.NORMAL, + Open.SUB_OPEN_OPTION.FORCE_OPEN, + [], + "msg", + function() {}); + } catch (e) { + // we expect the merge to fail, since we have a conflict + // and we want to test that git meta commit in the middle + // of merge conflict resolution continues the merge. + } + + const sRepo = yield NodeGit.Repository.open(repo.workdir() + "s"); + const index = yield sRepo.index(); + const sEntry = index.getByIndex(1); + const newEntry = new NodeGit.IndexEntry(); + // mask off the stage bits here, so that the new entry we write is + // in stage zero. + newEntry.flags = sEntry.flags & ~0x3000; + newEntry.flagsExtended = sEntry.flagsExtended; + newEntry.mode = sEntry.mode; + newEntry.id = sEntry.id; + newEntry.path = sEntry.path; + newEntry.fileSize = sEntry.fileSize; + newEntry.gid = 0; + newEntry.uid = 0; + newEntry.ino = 0; + newEntry.dev = 0; + newEntry.mtime = sEntry.mtime; + newEntry.ctime = sEntry.ctime; + yield index.conflictCleanup(); + yield index.add(newEntry); + yield index.write(); + + const repoStatus = yield Commit.getCommitStatus(repo, repo.path(), { + all: true, + paths: [], + }); + + yield Commit.commit(repo, true, repoStatus, + msgfunc("commit message"), + undefined, true, m4); + + const head = yield repo.getHeadCommit(); + const left = yield head.parent(0); + const right = yield head.parent(1); + assert.equal(left.id().tostrS(), repoMap.reverseCommitMap.m3); + assert.equal(right.id().tostrS(), repoMap.reverseCommitMap.m4); + const tree = yield head.getTree(); + const subEntry = yield tree.entryByPath("s"); + const subCommit = yield NodeGit.Commit.lookup(sRepo, + subEntry.oid()); + assert.equal(2, subCommit.parentcount()); + const seq = yield SeqStateUtil.readSequencerState(repo.path()); + assert.isNull(seq); + + })); }); diff --git a/node/test/util/config_util.js b/node/test/util/config_util.js new file mode 100644 index 000000000..27fdbc872 --- /dev/null +++ b/node/test/util/config_util.js @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2018, 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 fs = require("fs-promise"); +const path = require("path"); + +const ConfigUtil = require("../../lib/util/config_util"); +const TestUtil = require("../../lib/util/test_util"); + +describe("ConfigUtil", function () { +describe("getConfigString", function () { + it("breathing test", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const configPath = path.join(repo.path(), "config"); + yield fs.appendFile(configPath, `\ +[foo] + bar = baz +`); + const config = yield repo.config(); + const goodResult = + yield ConfigUtil.getConfigString(config, "foo.bar"); + assert.equal(goodResult, "baz"); + const badResult = yield ConfigUtil.getConfigString(config, "yyy.zzz"); + assert.isNull(badResult); + })); +}); +describe("configIsTrue", function () { + const cases = { + "missing": { + expected: null, + }, + "true": { + value: "true", + expected: true, + }, + "false": { + value: "false", + expected: false, + }, + "yes": { + value: "yes", + expected: true, + }, + "on": { + value: "on", + expected: true, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + if ("value" in c) { + const configPath = path.join(repo.path(), "config"); + yield fs.appendFile(configPath, `\ +[foo] + bar = ${c.value} +`); + } + const result = yield ConfigUtil.configIsTrue(repo, "foo.bar"); + assert.equal(result, c.expected); + })); + }); +}); + +describe("defaultSignature", function () { + it("works", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const actual = yield ConfigUtil.defaultSignature(repo); + const sig = yield repo.defaultSignature(); + assert.equal(actual.toString(), sig.toString()); + })); + +}); + +["America/New_York", "UTC", "Asia/Tokyo"].forEach(function (tz) { + describe("defaultSignature tz handling " + tz, function() { + let env; + before(function() { + env = process.env; + process.env.TZ = tz; + }); + + it("correctly sets the TZ offset", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const sig = yield ConfigUtil.defaultSignature(repo); + const time = sig.when(); + + const dtOffset = new Date().getTimezoneOffset(); + assert.equal(time.offset(), -dtOffset); + assert.equal(time.sign(), dtOffset > 0 ? "-" : "+"); + })); + + after(function (){ + process.env = env; + }); + }); +}); +}); diff --git a/node/test/util/conflict_util.js b/node/test/util/conflict_util.js new file mode 100644 index 000000000..504e58f93 --- /dev/null +++ b/node/test/util/conflict_util.js @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2018, 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 fs = require("fs-promise"); +const NodeGit = require("nodegit"); +const path = require("path"); + +const ConflictUtil = require("../../lib/util/conflict_util"); +const GitUtil = require("../../lib/util/git_util"); +const TestUtil = require("../../lib/util/test_util"); + +describe("ConflictUtil", function () { +const ConflictEntry = ConflictUtil.ConflictEntry; +const Conflict = ConflictUtil.Conflict; +describe("ConflictEntry", function () { + it("breathe", function () { + const entry = new ConflictUtil.ConflictEntry(2, "1"); + assert.equal(entry.mode, 2); + assert.equal(entry.id, "1"); + }); +}); +describe("Conflict", function () { + it("breathe", function () { + const ancestor = new ConflictEntry(2, "1"); + const our = new ConflictEntry(3, "1"); + const their = new ConflictEntry(4, "1"); + const conflict = new Conflict(ancestor, our, their); + assert.equal(conflict.ancestor, ancestor); + assert.equal(conflict.our, our); + assert.equal(conflict.their, their); + + const nullC = new Conflict(null, null, null); + assert.isNull(nullC.ancestor); + assert.isNull(nullC.our); + assert.isNull(nullC.their); + }); +}); +describe("addConflict", function () { + const FILEMODE = NodeGit.TreeEntry.FILEMODE; + it("existing", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const makeEntry = co.wrap(function *(data) { + const id = yield GitUtil.hashObject(repo, data); + return new ConflictEntry(FILEMODE.BLOB, id.tostrS()); + }); + const ancestor = yield makeEntry("xxx"); + const our = yield makeEntry("yyy"); + const their = yield makeEntry("zzz"); + const index = yield repo.index(); + const conflict = new Conflict(ancestor, our, their); + const filename = "README.md"; + yield ConflictUtil.addConflict(index, filename, conflict); + yield index.write(); + yield fs.writeFile(path.join(repo.workdir(), filename), "foo"); + const ancestorEntry = index.getByPath(filename, 1); + assert.equal(ancestorEntry.id, ancestor.id); + const ourEntry = index.getByPath(filename, 2); + assert.equal(ourEntry.id, our.id); + const theirEntry = index.getByPath(filename, 3); + assert.equal(theirEntry.id, their.id); + })); + it("multiple values", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const makeEntry = co.wrap(function *(data) { + const id = yield GitUtil.hashObject(repo, data); + return new ConflictEntry(FILEMODE.BLOB, id.tostrS()); + }); + const ancestor = yield makeEntry("xxx"); + const our = yield makeEntry("yyy"); + const their = yield makeEntry("zzz"); + const conflict = new Conflict(ancestor, our, their); + const index = yield repo.index(); + const path = "foo/bar.md"; + yield ConflictUtil.addConflict(index, path, conflict); + const ancestorEntry = index.getByPath(path, 1); + assert.equal(ancestorEntry.id, ancestor.id); + const ourEntry = index.getByPath(path, 2); + assert.equal(ourEntry.id, our.id); + const theirEntry = index.getByPath(path, 3); + assert.equal(theirEntry.id, their.id); + })); + it("with null", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const makeEntry = co.wrap(function *(data) { + const id = yield GitUtil.hashObject(repo, data); + return new ConflictEntry(FILEMODE.BLOB, id.tostrS()); + }); + const ancestor = yield makeEntry("xxx"); + const their = yield makeEntry("zzz"); + const conflict = new Conflict(ancestor, null, their); + const index = yield repo.index(); + const path = "foo/bar.md"; + yield ConflictUtil.addConflict(index, path, conflict); + const ancestorEntry = index.getByPath(path, 1); + assert.equal(ancestorEntry.id, ancestor.id); + const ourEntry = index.getByPath(path, 2); + assert.equal(ourEntry, null); + const theirEntry = index.getByPath(path, 3); + assert.equal(theirEntry.id, their.id); + })); +}); +}); diff --git a/node/test/util/deinit_util.js b/node/test/util/deinit_util.js deleted file mode 100644 index c983333f4..000000000 --- a/node/test/util/deinit_util.js +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright (c) 2016, 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 NodeGit = require("nodegit"); - -const DeinitUtil = require("../../lib/util/deinit_util"); -const TestUtil = require("../../lib/util/test_util"); - -describe("deinit_util", function () { - - // Going to do a simple test here to verify that after closing a submodule: - // - // - the submodule dir contains only the `.git` line file. - // - the git repo is in a clean state - - it("breathing", co.wrap(function *() { - - // Create and set up repos. - - const repo = yield TestUtil.createSimpleRepository(); - const baseSubRepo = yield TestUtil.createSimpleRepository(); - const baseSubPath = baseSubRepo.workdir(); - const subHead = yield baseSubRepo.getHeadCommit(); - - // Set up the submodule. - - const sub = yield NodeGit.Submodule.addSetup(repo, - baseSubPath, - "x/y", - 1); - const subRepo = yield sub.open(); - const origin = yield subRepo.getRemote("origin"); - yield origin.connect(NodeGit.Enums.DIRECTION.FETCH, - new NodeGit.RemoteCallbacks(), - function () {}); - yield subRepo.fetch("origin", {}); - subRepo.setHeadDetached(subHead.id().tostrS()); - yield sub.addFinalize(); - - // Commit the submodule it. - - yield TestUtil.makeCommit(repo, ["x/y", ".gitmodules"]); - - // Verify that the status currently indicates a visible submodule. - - const addedStatus = yield NodeGit.Submodule.status(repo, "x/y", 0); - const WD_UNINITIALIZED = (1 << 7); // means "closed" - assert(!(addedStatus & WD_UNINITIALIZED)); - - // Then close it and recheck status. - - yield DeinitUtil.deinit(repo, "x/y"); - const closedStatus = yield NodeGit.Submodule.status(repo, "x/y", 0); - assert(closedStatus & WD_UNINITIALIZED); - })); -}); diff --git a/node/test/util/destitch_util.js b/node/test/util/destitch_util.js new file mode 100644 index 000000000..48220855d --- /dev/null +++ b/node/test/util/destitch_util.js @@ -0,0 +1,762 @@ +/* + * Copyright (c) 2018, 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 NodeGit = require("nodegit"); + +const BulkNotesUtil = require("../../lib/util/bulk_notes_util"); +const DestitchUtil = require("../../lib/util/destitch_util"); +const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); +const StitchUtil = require("../../lib/util/stitch_util"); +const SyntheticBranchUtil = require("../../lib/util/synthetic_branch_util"); +const UserError = require("../../lib/util/user_error"); + +/** + * Replace refs and notes with their equivalent logical mapping and otherwise + * handle things we can't map well with our shorthand. + */ +function refMapper(actual, mapping) { + const fetchedSubRe = /(commits\/)(.*)/; + const commitMap = mapping.commitMap; + let result = {}; + + // Map refs + + Object.keys(actual).forEach(repoName => { + const ast = actual[repoName]; + const refs = ast.refs; + const newRefs = {}; + Object.keys(refs).forEach(refName => { + const ref = refs[refName]; + const fetchedSubMatch = fetchedSubRe.exec(refName); + if (null !== fetchedSubMatch) { + const sha = fetchedSubMatch[2]; + const logical = commitMap[sha]; + const newRefName = refName.replace(fetchedSubRe, + `$1${logical}`); + newRefs[newRefName] = ref; + return; // RETURN + } + newRefs[refName] = ref; + }); + + // Wipe out notes, we validate these by expicitly reading and + // processing the new notes. + + result[repoName] = ast.copy({ + refs: newRefs, + notes: {}, + }); + }); + return result; +} + +/** + * Return the result of mapping logical commit IDs to actual commit SHAs in the + * specified `map` using the specified `revMap`. + * + * @param {Object} revMap + * @param {Object} map SHA -> { metaRepoCommit, subCommits } + */ +function mapDestitched(revMap, map) { + const result = {}; + Object.keys(map).forEach(id => { + const commitData = map[id]; + const sha = revMap[id]; + const metaSha = revMap[commitData.metaRepoCommit]; + const subCommits = {}; + Object.keys(commitData.subCommits).forEach(sub => { + const subId = commitData.subCommits[sub]; + subCommits[sub] = revMap[subId]; + }); + result[sha] = { + metaRepoCommit: metaSha, + subCommits: subCommits, + }; + }); + return result; +} + +describe("destitch_util", function () { +describe("findSubmodule", function () { + const cases = { + "empty": { + subs: {}, + filename: "foo", + expected: null, + }, + "direct match": { + subs: { + "foo/bar": "", + "bam": "", + }, + filename: "foo/bar", + expected: "foo/bar", + }, + "inside it": { + subs: { + "foo/bar": "", + "bam": "", + }, + filename: "foo/bar/bam/baz/ttt.xx", + expected: "foo/bar", + }, + "missed": { + subs: { + "f/bar": "", + "bam": "", + }, + filename: "foo/bar/bam/baz/ttt.xx", + expected: null, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, function () { + const result = DestitchUtil.findSubmodule(c.subs, c.filename); + assert.equal(result, c.expected); + }); + }); +}); +describe("computeChangedSubmodules", function () { + const cases = { + "no changes": { + state: "S", + subs: { "foo/bar": "" }, + stitched: "1", + parent: "1", + expected: [], + }, + "sub not found": { + state: "S:C2-1 heya=baa;B2=2", + subs: { "foo/bar": "" }, + stitched: "2", + parent: "1", + expected: [], + fails: true, + }, + "sub found": { + state: "S:C2-1 hey/there/bob=baa;B2=2", + subs: { "hey/there": "" }, + stitched: "2", + parent: "1", + expected: ["hey/there"], + }, + "removal": { + state: "S:C2-1 hey/there/bob=baa;C3-2 hey/there/bob;B3=3", + subs: { "hey/there": "" }, + stitched: "3", + parent: "2", + expected: ["hey/there"], + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo(c.state); + const repo = written.repo; + const revMap = written.oldCommitMap; + const stitched = yield repo.getCommit(revMap[c.stitched]); + const parent = yield repo.getCommit(revMap[c.parent]); + let result; + let error; + try { + result = yield DestitchUtil.computeChangedSubmodules(repo, + c.subs, + stitched, + parent); + } catch (e) { + error = e; + } + if (undefined !== error) { + if (!c.fails || !(error instanceof UserError)) { + throw error; + } + return; // RETURN + } + assert(!c.fails, "did not fail"); + const sorted = Array.from(result).sort(); + assert.deepEqual(sorted, c.expected.sort()); + })); + }); +}); +describe("makeDestitchedCommit", function () { + const cases = { + "no changes": { + state: "x=S", + metaRepoCommits: [], + stitchedCommit: "1", + changedSubmodules: {}, + subUrls: {}, + expected: "x=E:Cthe first commit#d ;Bd=d", + }, + "with a base commit": { + state: "x=S", + metaRepoCommits: ["1"], + stitchedCommit: "1", + changedSubmodules: {}, + subUrls: {}, + expected: "x=E:Cthe first commit#d-1 ;Bd=d", + }, + "bad sub": { + state: "x=S:C2 foo=bar;B2=2", + metaRepoCommits: [], + stitchedCommit: "2", + changedSubmodules: { + "foo": "1", + }, + subUrls: {"foo": "bam"}, + fails: true, + }, + "deletion": { + state: "x=S:C2-1 s=Sa:1;Cw s/a=ss;Cx-w s/a;H=2;Bx=x", + metaRepoCommits: ["2"], + stitchedCommit: "x", + changedSubmodules: { "s": "1" }, + subUrls: {s: "a"}, + expected: "x=E:Cd-2 s;Bd=d", + }, + "actual change": { + state: ` +a=B:Ca foo=bar;Ba=a| +x=S:C2-1 s=Sa:a;B2=2;Cx s/foo=bam;Bx=x;Ba=a`, + metaRepoCommits: ["2"], + stitchedCommit: "x", + changedSubmodules: { s: "a" }, + subUrls: { s: "a" }, + expected: `x=E:Cs-a foo=bam;Cd-2 s=Sa:s;Bd=d;Bs=s` + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + const destitcher = co.wrap(function *(repos, maps) { + const repo = repos.x; + const revMap = maps.reverseCommitMap; + const metaRepoCommits = + yield c.metaRepoCommits.map(co.wrap(function *(metaRepoCommit) { + const sha = revMap[metaRepoCommit]; + return yield repo.getCommit(sha); + })); + const stitchedSha = revMap[c.stitchedCommit]; + const stitchedCommit = yield repo.getCommit(stitchedSha); + const subUrls = {}; + Object.keys(c.subUrls).forEach(sub => { + const url = c.subUrls[sub]; + subUrls[sub] = maps.reverseUrlMap[url] || url; + }); + const changedSubmodules = {}; + Object.keys(c.changedSubmodules).forEach(sub => { + const sha = c.changedSubmodules[sub]; + changedSubmodules[sub] = revMap[sha]; + }); + const result = yield DestitchUtil.makeDestitchedCommit( + repo, + metaRepoCommits, + stitchedCommit, + changedSubmodules, + subUrls); + const commits = {}; + + // Need to anchor the destitched commit + + yield NodeGit.Reference.create(repo, + "refs/heads/d", + result.metaRepoCommit, + 1, + "destitched"); + + commits[result.metaRepoCommit] = "d"; + for (let sub in result.subCommits) { + const sha = result.subCommits[sub]; + commits[sha] = sub; + yield NodeGit.Reference.create(repo, + `refs/heads/${sub}`, + sha, + 1, + "destitched"); + } + return { + commitMap: commits, + }; + }); + it(caseName, co.wrap(function *() { + yield RepoASTTestUtil.testMultiRepoManipulator(c.state, + c.expected, + destitcher, + c.fails); + })); + }); +}); +describe("destitchChain", function () { + const cases = { + "already stitched, noop": { + state: "x=S", + commit: "1", + url: "foo", + already: { "1": { metaRepoCommit: "1", subCommits: {}}}, + newly: {}, + expectedNewly: {}, + result: "1", + }, + "newly stitched, noop": { + state: "x=S", + commit: "1", + url: "foo", + already: {}, + newly: { "1": { metaRepoCommit: "1", subCommits: {}}}, + expectedNewly: { "1": { metaRepoCommit: "1", subCommits: {}}}, + result: "1", + }, + "bad, orphan": { + state: ` +a=B:Ca;Ba=a| +x=B:C2 foo/bar=Sa:a;B2=2;Cy foo/bar/a=baz;By=y`, + commit: "y", + url: "a", + already: {}, + newly: {}, + expectedNewly: {}, + fails: true, + }, + "destitch one": { + state: ` +a=B:Ca;Ba=a;C2 s=Sa:a;B2=2| +x=B:Cx s/a=bam;Cy-x s/a=baz;By=y`, + commit: "y", + url: "a", + already: { "x": { metaRepoCommit: "2", subCommits: {}}}, + newly: {}, + expectedNewly: { + "y": { + metaRepoCommit: "d.y", + subCommits: { + "s": "s.y.s", + }, + }, + }, + result: "d.y", + expected: ` +x=E:Cs.y.s-a a=baz;Cd.y-2 s=Sa:s.y.s;Bs.y.s=s.y.s;Bd.y=d.y`, + }, + "destitch one, some unchanged": { + state: ` +a=B:Ca;Ba=a;C2 s=Sa:a,t=Sa:a;B2=2| +x=B:Cx s/a=a,t/a=a;Cy-x s/a=baz;By=y`, + commit: "y", + url: "a", + already: { "x": { metaRepoCommit: "2", subCommits: {}}}, + newly: {}, + expectedNewly: { + "y": { + metaRepoCommit: "d.y", + subCommits: { + "s": "s.y.s", + }, + }, + }, + result: "d.y", + expected: ` +x=E:Cs.y.s-a a=baz;Cd.y-2 s=Sa:s.y.s;Bs.y.s=s.y.s;Bd.y=d.y`, + }, + "destitch with an ancestor": { + state: ` +a=B:Ca;Ba=a;C2 s=Sa:a;B2=2| +x=B:Cx s/a=bam;Cy-x s/a=baz;Cz-y s/a=ya;Bz=z`, + commit: "z", + url: "a", + already: { "x": { metaRepoCommit: "2", subCommits: {}}}, + newly: {}, + expectedNewly: { + "y": { + metaRepoCommit: "d.y", + subCommits: { + "s": "s.y.s", + }, + }, + "z": { + metaRepoCommit: "d.z", + subCommits: { + "s": "s.z.s", + }, + }, + }, + result: "d.z", + expected: ` +x=E:Cs.y.s-a a=baz;Cd.y-2 s=Sa:s.y.s;Bs.y.s=s.y.s;Bd.y=d.y; + Cs.z.s-s.y.s a=ya;Cd.z-d.y s=Sa:s.z.s;Bd.z=d.z;Bs.z.s=s.z.s`, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + const destitcher = co.wrap(function *(repos, maps) { + const repo = repos.x; + const revMap = maps.reverseCommitMap; + const commitMap = maps.commitMap; + const sha = revMap[c.commit]; + const commit = yield repo.getCommit(sha); + const baseUrl = maps.reverseUrlMap[c.url] || c.url; + const already = mapDestitched(revMap, c.already); + for (let sha in already) { + already[sha] = JSON.stringify(already[sha], null, 4); + } + yield BulkNotesUtil.writeNotes(repo, + StitchUtil.referenceNoteRef, + already); + const originalNewly = mapDestitched(revMap, c.newly); + const newly = Object.assign({}, originalNewly); + const result = yield DestitchUtil.destitchChain(repo, + commit, + baseUrl, + newly); + + // clean up the ref so we don't get cofused when checking final + // state. + + NodeGit.Reference.remove(repo, StitchUtil.referenceNoteRef); + const commits = {}; + + // Anchor generated commits and generate commit map. + + const actualNewly = {}; + yield Object.keys(newly).map(co.wrap(function *(stitchedSha) { + const commitInfo = newly[stitchedSha]; + const stitchedId = commitMap[stitchedSha]; + const newMetaSha = commitInfo.metaRepoCommit; + let newMetaId; + const inOriginal = stitchedSha in originalNewly; + + // Only make ref and add to commit map if the commit was + // created. + + if (inOriginal) { + newMetaId = commitMap[newMetaSha]; + } else { + newMetaId = `d.${stitchedId}`; + yield NodeGit.Reference.create(repo, + `refs/heads/${newMetaId}`, + newMetaSha, + 1, + "destitched"); + commits[newMetaSha] = newMetaId; + } + const actualSubCommits = {}; + const subCommits = commitInfo.subCommits; + yield Object.keys(subCommits).map(co.wrap(function *(sub) { + const newSubSha = subCommits[sub]; + let newSubId; + if (inOriginal) { + newSubId = commitMap[newSubSha]; + } else { + newSubId = `s.${stitchedId}.${sub}`; + yield NodeGit.Reference.create( + repo, + `refs/heads/${newSubId}`, + newSubSha, + 1, + "destitched"); + commits[newSubSha] = newSubId; + } + actualSubCommits[sub] = newSubId; + })); + actualNewly[stitchedId] = { + metaRepoCommit: newMetaId, + subCommits: actualSubCommits, + }; + })); + const resultId = commitMap[result] || commits[result]; + assert.equal(resultId, c.result); + assert.deepEqual(actualNewly, c.expectedNewly); + return { + commitMap: commits, + }; + }); + it(caseName, co.wrap(function *() { + yield RepoASTTestUtil.testMultiRepoManipulator(c.state, + c.expected, + destitcher, + c.fails); + })); + }); +}); +describe("getDestitched", function () { + it("nowhere", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const headSha = head.id().tostrS(); + const result = yield DestitchUtil.getDestitched(repo, {}, headSha); + assert.isNull(result); + })); + it("in newly", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const headSha = head.id().tostrS(); + const newly = {}; + newly[headSha] = { + metaCommit: "1", + subCommits: {}, + }; + const result = yield DestitchUtil.getDestitched(repo, newly, headSha); + assert.deepEqual(result, newly[headSha]); + })); + it("in local", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const headSha = head.id().tostrS(); + const destitched = { + metaCommit: "1", + subCommits: {}, + }; + const sig = yield repo.defaultSignature(); + const refName = DestitchUtil.localReferenceNoteRef; + const data = JSON.stringify(destitched, null, 4); + yield NodeGit.Note.create(repo, refName, sig, sig, headSha, data, 1); + const result = yield DestitchUtil.getDestitched(repo, {}, headSha); + assert.deepEqual(result, destitched); + })); + it("in remote", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const headSha = head.id().tostrS(); + const destitched = { + metaCommit: "1", + subCommits: {}, + }; + const sig = yield repo.defaultSignature(); + const refName = StitchUtil.referenceNoteRef; + const data = JSON.stringify(destitched, null, 4); + yield NodeGit.Note.create(repo, refName, sig, sig, headSha, data, 1); + const result = yield DestitchUtil.getDestitched(repo, {}, headSha); + assert.deepEqual(result, destitched); + })); +}); +describe("pushSyntheticRefs", function () { + it("breathing", co.wrap(function *() { + + // We're going to set up a typical looking state where we've destitched + // two commits: + // + // s -- first to destitch + // t -- second + // 2 -- destitched version of s, introduces commit b in sub s + // 3 -- destitched version of t, introduces commit c in sub t + // + // After the push, we should see synthetic refs in b and c. + + const state = ` +a=B|b=B|c=B| +x=S:Cs s/b=b;Ct-s t/c=c;Bs=s;Bt=t; + Cb;Cc;Bb=b;Bc=c;C2-1 s=S../b:b;C3-2 t=S../c:c;H=3;B2=2`; + const written = yield RepoASTTestUtil.createMultiRepos(state); + const repos = written.repos; + const x = repos.x; + const sCommit = yield x.getBranchCommit("s"); + const sSha = sCommit.id().tostrS(); + const tCommit = yield x.getBranchCommit("t"); + const tSha = tCommit.id().tostrS(); + const twoCommit = yield x.getHeadCommit(); + const twoSha = twoCommit.id().tostrS(); + const threeCommit = yield x.getHeadCommit(); + const threeSha = threeCommit.id().tostrS(); + const bCommit = yield x.getBranchCommit("b"); + const bSha = bCommit.id().tostrS(); + const cCommit = yield x.getBranchCommit("c"); + const cSha = cCommit.id().tostrS(); + const newCommits = {}; + newCommits[sSha] = { + metaRepoCommit: twoSha, + subCommits: { + s: bSha, + }, + }; + newCommits[tSha] = { + metaRepoCommit: threeSha, + subCommits: { + t: cSha, + }, + }; + const baseUrl = written.reverseUrlMap.a; + yield DestitchUtil.pushSyntheticRefs(x, + baseUrl, + twoCommit, + newCommits); + const b = repos.b; + const bRefName = SyntheticBranchUtil.getSyntheticBranchForCommit(bSha); + const bRef = yield NodeGit.Reference.lookup(b, bRefName); + assert(undefined !== bRef); + assert.equal(bRef.target().tostrS(), bSha); + + const c = repos.c; + const cRefName = SyntheticBranchUtil.getSyntheticBranchForCommit(cSha); + const cRef = yield NodeGit.Reference.lookup(c, cRefName); + assert.equal(cRef.target().tostrS(), cSha); + })); +}); +describe("recordLocalNotes", function () { + it("breathing", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S:C2-1;B2=2"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const headSha = head.id().tostrS(); + const two = yield repo.getBranchCommit("2"); + const twoSha = two.id().tostrS(); + const newCommits = {}; + newCommits[headSha] = { + metaRepoCommit: twoSha, + subCommits: { s: headSha }, + }; + yield DestitchUtil.recordLocalNotes(repo, newCommits); + const notes = yield BulkNotesUtil.readNotes( + repo, + DestitchUtil.localReferenceNoteRef); + const expected = {}; + Object.keys(newCommits).forEach(sha => { + expected[sha] = JSON.stringify(newCommits[sha], null, 4); + }); + assert.deepEqual(notes, expected); + })); +}); +describe("destitch", function () { + const cases = { + "already done": { + state: "a=B|x=S:Ra=a;C2;B2=2", + already: { + "1": { + metaRepoCommit: "2", + subCommits: {} + }, + }, + commitish: "HEAD", + remote: "a", + ref: "refs/heads/foo", + expected: "x=E:Bfoo=2", + }, + "already done, no ref": { + state: "a=B|x=S:Ra=a;C2;B2=2", + already: { + "1": { + metaRepoCommit: "2", + subCommits: {} + }, + }, + commitish: "HEAD", + remote: "a", + }, + "destitch one": { + state: ` +a=B|b=B| +x=B:Ra=a;Cb foo=bar;Bb=b;C2 s=S../b:b;B2=2;Cy s/foo=bar;Cx-y s/foo=bam;Bx=x`, + already: { + "y": { + metaRepoCommit: "2", + subCommits: { s: "b" }, + }, + }, + commitish: "x", + remote: "a", + ref: "refs/heads/foo", + expected: ` +x=E:Cs.x.s-b foo=bam;Cd.x-2 s=S../b:s.x.s;Bfoo=d.x;Bs.x.s=s.x.s| +b=E:Fcommits/s.x.s=s.x.s`, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + const destitcher = co.wrap(function *(repos, maps) { + const repo = repos.x; + const revMap = maps.reverseCommitMap; + const commitMap = maps.commitMap; + const already = mapDestitched(revMap, c.already); + const alreadyContent = {}; + Object.keys(already).forEach(sha => { + alreadyContent[sha] = JSON.stringify(already[sha], null, 4); + }); + + // We're going to prime the remote refs based on `c.already` so we + // can exercise this capability, but we'll remove them afterwards + // so that we don't see a state change. + + yield BulkNotesUtil.writeNotes(repo, + StitchUtil.referenceNoteRef, + alreadyContent); + yield DestitchUtil.destitch(repo, + c.commitish, + c.remote, + c.ref || null); + NodeGit.Reference.remove(repo, StitchUtil.referenceNoteRef); + + // At this point, the only stored ones are those newly created. + const localNotesRef = DestitchUtil.localReferenceNoteRef; + const localNotes = + yield BulkNotesUtil.readNotes(repo, localNotesRef); + const notes = BulkNotesUtil.parseNotes(localNotes); + const commits = {}; + for (let stitchedSha in notes) { + const data = notes[stitchedSha]; + const stitchedId = commitMap[stitchedSha]; + const metaId = `d.${stitchedId}`; + commits[data.metaRepoCommit] = metaId; + for (let subName in data.subCommits) { + const newSubSha = data.subCommits[subName]; + const id = `s.${stitchedId}.${subName}`; + + // We have to anchor these commits with a branch. + + yield NodeGit.Reference.create(repo, + `refs/heads/${id}`, + newSubSha, + 1, + "testing"); + + commits[newSubSha] = id; + } + } + return { + commitMap: commits, + }; + }); + it(caseName, co.wrap(function *() { + yield RepoASTTestUtil.testMultiRepoManipulator(c.state, + c.expected, + destitcher, + c.fails, { + includeRefsCommits : true, + actualTransformer: refMapper, + }); + })); + }); +}); +}); diff --git a/node/test/util/diff_util.js b/node/test/util/diff_util.js index 396ca92bd..7da94254c 100644 --- a/node/test/util/diff_util.js +++ b/node/test/util/diff_util.js @@ -56,10 +56,6 @@ describe("DiffUtil", function () { input: DELTA.DELETED, expected: FILESTATUS.REMOVED, }, - "conflicted": { - input: DELTA.CONFLICTED, - expected: FILESTATUS.CONFLICTED, - }, "renamed": { input: DELTA.RENAMED, expected: FILESTATUS.RENAMED, @@ -88,6 +84,9 @@ describe("DiffUtil", function () { "trivial": { input: "x=S", }, + "conflict ignored": { + input: "x=S:I *READMEmd=a*b*c", + }, "index - modified": { input: "x=S:I README.md=hhh", staged: { "README.md": FILESTATUS.MODIFIED }, @@ -130,7 +129,7 @@ describe("DiffUtil", function () { }, "workdir - added deep all untracked": { input: "x=S:W x/y=y", - all: true, + untrackedFilesOption: DiffUtil.UNTRACKED_FILES_OPTIONS.ALL, workdir: { "x/y": FILESTATUS.ADDED }, }, "workdir - removed": { @@ -208,7 +207,7 @@ describe("DiffUtil", function () { "workdir dir path": { input: "x=S:W x/y/z=foo,x/r/z=bar,README.md", paths: [ "x" ], - all: true, + untrackedFilesOption: DiffUtil.UNTRACKED_FILES_OPTIONS.ALL, workdir: { "x/y/z": FILESTATUS.ADDED, "x/r/z": FILESTATUS.ADDED @@ -217,7 +216,7 @@ describe("DiffUtil", function () { "workdir dir paths": { input: "x=S:W x/y/z=foo,x/r/z=bar,README.md", paths: [ "x/y", "x/r" ], - all: true, + untrackedFilesOption: DiffUtil.UNTRACKED_FILES_OPTIONS.ALL, workdir: { "x/y/z": FILESTATUS.ADDED, "x/r/z": FILESTATUS.ADDED @@ -226,7 +225,7 @@ describe("DiffUtil", function () { "workdir all paths": { input: "x=S:W x/y/z=foo,x/r/z=bar,README.md", paths: [ "x/y/z", "x/r/z", "README.md" ], - all: true, + untrackedFilesOption: DiffUtil.UNTRACKED_FILES_OPTIONS.ALL, workdir: { "x/y/z": FILESTATUS.ADDED, "x/r/z": FILESTATUS.ADDED, @@ -236,7 +235,7 @@ describe("DiffUtil", function () { "many changes": { input: ` x=S:C2 a/b=c,a/c=d,t=u;H=2;I a/b,a/q=r,f=x;W a/b=q,a/c=f,a/y=g,f`, - all: true, + untrackedFilesOption: DiffUtil.UNTRACKED_FILES_OPTIONS.ALL, workdir: { "a/b": FILESTATUS.ADDED, "a/c": FILESTATUS.MODIFIED, @@ -252,7 +251,7 @@ x=S:C2 a/b=c,a/c=d,t=u;H=2;I a/b,a/q=r,f=x;W a/b=q,a/c=f,a/y=g,f`, "many changes with path": { input: ` x=S:C2 a/b=c,a/c=d,t=u;H=2;I a/b,a/q=r,f=x;W a/b=q,a/c=f,a/y=g,f`, - all: true, + untrackedFilesOption: DiffUtil.UNTRACKED_FILES_OPTIONS.ALL, paths: ["f"], workdir: { "f": FILESTATUS.REMOVED, @@ -282,7 +281,7 @@ x=S:C2 a/b=c,a/c=d,t=u;H=2;I a/b,a/q=r,f=x;W a/b=q,a/c=f,a/y=g,f`, input: "x=S:C2-1;W README.md=3;Bmaster=2", staged: { "2": FILESTATUS.ADDED }, workdir: { "README.md": FILESTATUS.MODIFIED }, - all: true, + untrackedFilesOption: DiffUtil.UNTRACKED_FILES_OPTIONS.ALL, from: "HEAD^", }, "HEAD^ changed in index": { @@ -326,7 +325,7 @@ x=S:C2 a/b=c,a/c=d,t=u;H=2;I a/b,a/q=r,f=x;W a/b=q,a/c=f,a/y=g,f`, "2": FILESTATUS.ADDED, "README.md": FILESTATUS.REMOVED, }, - all: true, + untrackedFilesOption: DiffUtil.UNTRACKED_FILES_OPTIONS.ALL, from: "HEAD^", }, "HEAD^ removed in workdir": { @@ -347,13 +346,16 @@ x=S:C2 a/b=c,a/c=d,t=u;H=2;I a/b,a/q=r,f=x;W a/b=q,a/c=f,a/y=g,f`, workdir: { "README.md": FILESTATUS.REMOVED, }, - all: true, + untrackedFilesOption: DiffUtil.UNTRACKED_FILES_OPTIONS.ALL, from: "HEAD^", }, "HEAD^ ignore submodule add": { input: ` a=B:Ca-1;Bmaster=a| x=S:C2-1 s=Sa:1;Bmaster=2`, + staged: { + ".gitmodules": FILESTATUS.ADDED, + }, from: "HEAD^", }, "HEAD^ ignore submodule change": { @@ -366,11 +368,15 @@ x=S:C2-1 s=Sa:1;C3-2 s=Sa:a;Bmaster=3`, input: ` a=B:Ca-1;Bmaster=a| x=S:C2-1 s=Sa:1;C3-2 s;Bmaster=3`, + staged: { + ".gitmodules": FILESTATUS.REMOVED, + }, from: "HEAD^", }, "HEAD^ unmodified": { input: ` -x=S:C2-1 README.md=3;W README.md=hello world;Bmaster=2`, +x=S:C2-1 README.md=3;I README.md=hello world;W README.md=hello world; +Bmaster=2`, workdirToTree: true, from: "HEAD^", }, @@ -419,11 +425,12 @@ x=S:C2-1 README.md=3;W README.md=hello world;Bmaster=2`, tree = yield NodeGit.Tree.lookup(repo, treeId); } const result = yield DiffUtil.getRepoStatus( - repo, - tree, - c.paths || [], - c.workdirToTree || false, - c.all || false); + repo, + tree, + c.paths || [], + c.workdirToTree || false, + c.untrackedFilesOption || + DiffUtil.UNTRACKED_FILES_OPTIONS.NORMAL); const expected = { staged: c.staged || {}, workdir: c.workdir || {}, diff --git a/node/test/util/do_work_queue.js b/node/test/util/do_work_queue.js index 62c204482..5a97af1f8 100644 --- a/node/test/util/do_work_queue.js +++ b/node/test/util/do_work_queue.js @@ -81,8 +81,30 @@ describe("DoWorkQueue", function () { return i * 2; }); } - const result = yield DoWorkQueue.doInParallel(work, getWork, 1); + const result = yield DoWorkQueue.doInParallel(work, + getWork, + {limit: 1}); assert.equal(result.length, NUM_TO_DO); assert.deepEqual(result, expected); })); + + it("sub work failure", co.wrap(function *() { + let work = ["success", "fail"]; + function getWork(name, index) { + if ("fail" === name) { + throw new Error("deliberate error"); + } + return waitSomeTime(index); + } + try { + yield DoWorkQueue.doInParallel( + work, + getWork, + {limit: 1, failMsg: "getWork failed"} + ); + assert.fail("should have failed"); + } catch (error) { + assert.equal("deliberate error", error.message); + } + })); }); diff --git a/node/test/util/git_util.js b/node/test/util/git_util.js index e265eb860..e015e53e9 100644 --- a/node/test/util/git_util.js +++ b/node/test/util/git_util.js @@ -35,9 +35,9 @@ const co = require("co"); const fs = require("fs-promise"); const mkdirp = require("mkdirp"); const NodeGit = require("nodegit"); -const os = require("os"); const path = require("path"); +const ForcePushSpec = require("../../lib/util/force_push_spec"); const GitUtil = require("../../lib/util/git_util"); const RepoAST = require("../../lib/util/repo_ast"); const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); @@ -60,6 +60,7 @@ describe("GitUtil", function () { expected: { remoteName: null, branchName: "master", + pushRemoteName: null, }, }, "with remote": { @@ -67,6 +68,7 @@ describe("GitUtil", function () { branch: "bar", expected: { remoteName: "hoo", + pushRemoteName: "hoo", branchName: "gob", }, }, @@ -75,21 +77,97 @@ describe("GitUtil", function () { branch: "bar", expected: { remoteName: "hoo", + pushRemoteName: "hoo", branchName: "foo/bar", }, }, + "with pushRemote": { + state: "S:Rhoo=/a gob=1;Bbar=1 hoo/gob", + branch: "bar", + pushRemote: "bah", + expected: { + remoteName: "hoo", + pushRemoteName: "bah", + branchName: "gob", + }, + }, + "with pushDefault": { + state: "S:Rhoo=/a gob=1;Bbar=1 hoo/gob", + branch: "bar", + pushDefault: "bah", + expected: { + remoteName: "hoo", + pushRemoteName: "bah", + branchName: "gob", + }, + }, + "with pushRemote and pushDefault": { + state: "S:Rhoo=/a gob=1;Bbar=1 hoo/gob", + branch: "bar", + pushRemote: "hehe", + pushDefault: "bah", + expected: { + remoteName: "hoo", + pushRemoteName: "hehe", + branchName: "gob", + }, + } }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; it(caseName, co.wrap(function *() { const written = yield RepoASTTestUtil.createRepo(c.state); const repo = written.repo; + if (undefined !== c.pushRemote) { + const configPath = path.join(repo.path(), "config"); + yield fs.appendFile(configPath, `\ +[branch "${c.branch}"] + pushRemote = ${c.pushRemote} +`); + } + if (undefined !== c.pushDefault) { + const configPath = path.join(repo.path(), "config"); + yield fs.appendFile(configPath, `\ +[remote] + pushDefault = ${c.pushDefault} +`); + } const branch = yield repo.getBranch(c.branch); - const result = yield GitUtil.getTrackingInfo(branch); + const result = yield GitUtil.getTrackingInfo(repo, branch); assert.deepEqual(result, c.expected); })); }); }); + describe("getCurrentTrackingBranchName", function () { + const cases = { + "no tracking": { + state: "S", + expected: null, + }, + "local tracking": { + state: "S:Bfoo=1;Bblah=1 foo;*=blah", + expected: "foo", + }, + "no branch": { + state: "S:H=1", + expected: null, + }, + "with remote": { + state: "S:Rhoo=/a gob=1;Bmaster=1 hoo/gob", + expected: "hoo/gob", + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo(c.state); + const repo = written.repo; + const result = + yield GitUtil.getCurrentTrackingBranchName(repo); + assert.equal(result, c.expected); + })); + }); + }); describe("getRemoteForBranch", function () { it("no upstream", co.wrap(function *() { const written = yield RepoASTTestUtil.createRepo("S"); @@ -199,6 +277,18 @@ describe("GitUtil", function () { }); }); + describe("getUrlFromRemoteName", function () { + it("works", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const func = GitUtil.getUrlFromRemoteName; + yield NodeGit.Remote.create(repo, "upstream", + "https://example.com"); + assert.equal("https://example.com", yield func(repo, "upstream")); + assert.equal("https://example.org", + yield func(repo, "https://example.org")); + })); + }); + describe("getOriginUrl", function () { it("url from branch remote", co.wrap(function *() { // TODO: don't have it in my shorthand to associate a branch with @@ -298,42 +388,6 @@ describe("GitUtil", function () { }); }); - describe("getRootGitDirectory", function () { - let cwd; - before(function () { - cwd = process.cwd(); - }); - after(function () { - process.chdir(cwd); - }); - - // This method is recursive, so we will check just three cases: - // - failure case - // - simple case - // - one deep - - it("failure", function () { - const tempdir = os.tmpdir(); - process.chdir(tempdir); - const result = GitUtil.getRootGitDirectory(); - assert.isNull(result); - }); - - it("successes", co.wrap(function *() { - const repo = yield TestUtil.createSimpleRepository(); - const workdir = repo.workdir(); - process.chdir(workdir); - const repoRoot = GitUtil.getRootGitDirectory(workdir); - assert(yield TestUtil.isSameRealPath(workdir, repoRoot), - "trivial"); - const subdir = path.join(workdir, "sub"); - yield fs.mkdir(subdir); - process.chdir(subdir); - const subRoot = GitUtil.getRootGitDirectory(workdir); - assert(yield TestUtil.isSameRealPath(workdir, subRoot), "trivial"); - })); - }); - describe("getCurrentRepo", function () { let cwd; @@ -378,7 +432,7 @@ describe("GitUtil", function () { function pusher(repoName, origin, local, remote, force, quiet) { return co.wrap(function *(repos) { - force = force || false; + force = force || ForcePushSpec.NoForce; const result = yield GitUtil.push(repos[repoName], origin, @@ -406,9 +460,29 @@ describe("GitUtil", function () { }, "force success": { input: "a=B:C2-1;Bmaster=2|b=Ca:C3-1;Bmaster=3", - manipulator: pusher("b", "origin", "master", "master", true), + manipulator: pusher( + "b", "origin", "master", "master", ForcePushSpec.Force), expected: "a=B:C3-1;Bmaster=3|b=Ca:Bmaster=3", }, + "force with lease success": { + input: "a=B:C2-1;Bmaster=2|b=Ca:C3-1;Bmaster=3", + manipulator: pusher( + "b", + "origin", + "master", + "master", + ForcePushSpec.ForceWithLease), + expected: "a=B:C3-1;Bmaster=3|b=Ca:Bmaster=3", + }, + "force with lease failure": { + input: ` + a=B:C2-1;Bmaster=2| + b=Ca:Rorigin=a master=1;C3-1; + Bmaster=3 origin/master;Bold=2`, + manipulator: pusher( + "b", "origin", "master", "master", ForcePushSpec.Force), + fail: true, + }, "push new branch": { input: "a=S|b=Ca:Bfoo=1", expected: "a=S:Bfoo=1|b=Ca:Bfoo=1", @@ -417,7 +491,8 @@ describe("GitUtil", function () { "quiet push new branch": { input: "a=S|b=Ca:Bfoo=1", expected: "a=S:Bfoo=1|b=Ca:Bfoo=1", - manipulator: pusher("b", "origin", "foo", "foo", true), + manipulator: pusher( + "b", "origin", "foo", "foo", ForcePushSpec.Force), }, "update a branch": { input: "a=B|b=Ca:C2-1;Bmaster=2 origin/master", @@ -456,6 +531,9 @@ describe("GitUtil", function () { "detached head": { input: "S:*=", expected: null }, "not master": { input: "S:Bmaster=;Bfoo=1;*=foo", expected: "foo"}, "empty": { input: new RepoAST(), expected: null }, + "no current branch but not empty": { + input: "N:C2;Rtrunk=/a foo=2", + }, }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; @@ -648,7 +726,8 @@ describe("GitUtil", function () { const written = yield WriteRepoASTUtil.writeRAST(ast, path); const commit = written.oldCommitMap["1"]; const repo = written.repo; - yield GitUtil.fetchSha(repo, "not a url", commit); + const result = yield GitUtil.fetchSha(repo, "not a url", commit); + assert.equal(result, false); })); it("fetch one", co.wrap(function *() { @@ -661,7 +740,8 @@ describe("GitUtil", function () { const writtenY = yield WriteRepoASTUtil.writeRAST(astY, yPath); const commit = writtenX.oldCommitMap["2"]; const repo = writtenY.repo; - yield GitUtil.fetchSha(repo, xPath, commit); + const result = yield GitUtil.fetchSha(repo, xPath, commit); + assert.equal(result, true); yield repo.getCommit(commit); })); @@ -680,59 +760,13 @@ describe("GitUtil", function () { } catch (e) { assert.instanceOf(e, UserError); + assert.equal(e.code, UserError.CODES.FETCH_ERROR); return; } assert(false, "Bad sha, should have failed"); })); }); - describe("listUnpushedCommits", function () { - const cases = { - "no branches": { - input: "S:Rorigin=foo", - from: "1", - remote: "origin", - expected: ["1"], - }, - "up to date": { - input: "S:Rorigin=foo moo=1", - from: "1", - remote: "origin", - expected: [], - }, - "one not pushed": { - input: "S:C2-1;Bmaster=2;Rorigin=foo moo=1", - from: "2", - remote: "origin", - expected: ["2"], - }, - "two not pushed": { - input: "S:C3-2;C2-1;Bmaster=3;Rorigin=foo moo=1", - from: "3", - remote: "origin", - expected: ["2","3"], - }, - }; - Object.keys(cases).forEach(caseName => { - const c = cases[caseName]; - it(caseName, co.wrap(function *() { - const ast = ShorthandParserUtil.parseRepoShorthand(c.input); - const path = yield TestUtil.makeTempDir(); - const written = yield WriteRepoASTUtil.writeRAST(ast, path); - const fromSha = written.oldCommitMap[c.from]; - const unpushed = yield GitUtil.listUnpushedCommits( - written.repo, - c.remote, - fromSha); - const unpushedShas = unpushed.map(id => { - assert.instanceOf(id, NodeGit.Oid); - return written.commitMap[id.tostrS()]; - }); - assert.sameMembers(unpushedShas, c.expected); - })); - }); - }); - describe("isUpToDate", function () { const cases = { "trivial": { @@ -850,12 +884,19 @@ describe("GitUtil", function () { filename: "/", fails: true, }, - "invalid": { + "not there": { paths: ["a"], workdir: "a", cwd: "a", filename: "b", - fails: true, + expected: "b", + }, + "not there, relative": { + paths: ["a/c"], + workdir: "a", + cwd: "a/c", + filename: "../b", + expected: "b", }, "inside": { paths: ["a/b/c"], @@ -891,9 +932,9 @@ describe("GitUtil", function () { const cwd = path.join(tempDir, c.cwd); let result; try { - result = yield GitUtil.resolveRelativePath(workdir, - cwd, - c.filename); + result = GitUtil.resolveRelativePath(workdir, + cwd, + c.filename); } catch (e) { if (!c.fails) { @@ -927,7 +968,8 @@ describe("GitUtil", function () { const cmd = "echo bar >> "; process.env.GIT_EDITOR = cmd; const repo = yield TestUtil.createSimpleRepository(); - const result = yield GitUtil.editMessage(repo, "foo\n"); + const result = yield GitUtil.editMessage(repo, "foo\n", true, + true); assert.equal(result, "foo\nbar\n"); })); }); @@ -1101,7 +1143,7 @@ describe("GitUtil", function () { const repo = yield TestUtil.createSimpleRepository(); const head = yield repo.getHeadCommit(); yield fs.appendFile(path.join(repo.workdir(), "README.md"), "foo"); - const sig = repo.defaultSignature(); + const sig = yield repo.defaultSignature(); const newCommitId = yield repo.createCommitOnHead(["README.md"], sig, sig, @@ -1111,4 +1153,87 @@ describe("GitUtil", function () { assert.equal(result.id().tostrS(), head.id().tostrS()); })); }); + describe("getMergeBase", function () { + const cases = { + "base": { + input: "S:Cx-1;Cy-1;Bfoo=x;Bmaster=y", + expected: "1", + }, + "no base": { + input: "S:Cx-1;Cy;Bfoo=x;Bmaster=y", + expected: null, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo(c.input); + const repo = written.repo; + const oldMap = written.oldCommitMap; + const x = yield repo.getCommit(oldMap.x); + const y = yield repo.getCommit(oldMap.y); + const result = yield GitUtil.getMergeBase(repo, x, y); + if (null === c.expected) { + assert.isNull(result); + } else { + assert.isNotNull(result); + const sha = written.commitMap[result.id().tostrS()]; + assert.equal(c.expected, sha); + } + })); + }); + }); + describe("updateHead", function () { + const cases = { + "noop": { + input: "x=S", + to: "1", + }, + "another": { + input: "x=S:C2-1;Bfoo=2", + to: "2", + expected: "x=E:Bmaster=2;I 2", + }, + "from detached": { + input: "x=S:H=1;C2-1;Bmaster=2", + to: "2", + expected: "x=E:H=2;I 2", + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + const updateHead = co.wrap(function *(repos, maps) { + const repo = repos.x; + const rev = maps.reverseCommitMap; + const commit = yield repo.getCommit(rev[c.to]); + + // TODO: test reason propagation to reflog; we don't have good + // support for this in the test facility though, and it's + // pretty hard to mess up. + + yield GitUtil.updateHead(repo, commit, "a reason"); + }); + it(caseName, co.wrap(function *() { + yield RepoASTTestUtil.testMultiRepoManipulator(c.input, + c.expected, + updateHead); + })); + }); + }); + describe("getReference", function () { + it("good", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S"); + const repo = written.repo; + const result = yield GitUtil.getReference(repo, + "refs/heads/master"); + assert.instanceOf(result, NodeGit.Reference); + assert.equal(result.name(), "refs/heads/master"); + })); + it("bad", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S"); + const repo = written.repo; + const result = yield GitUtil.getReference(repo, "refs/foo"); + assert.isNull(result); + })); + }); }); diff --git a/node/test/util/git_util_fast.js b/node/test/util/git_util_fast.js new file mode 100644 index 000000000..0204340ac --- /dev/null +++ b/node/test/util/git_util_fast.js @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2018, 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 fs = require("fs-promise"); +const path = require("path"); + +const GitUtilFast = require("../../lib/util/git_util_fast"); +const TestUtil = require("../../lib/util/test_util"); + +describe("GitUtilFast", function () { + describe("getRootGitDirectory", function () { + let cwd; + before(function () { + cwd = process.cwd(); + }); + after(function () { + process.chdir(cwd); + }); + + // This method is recursive, so we will check just three cases: + // - failure case + // - simple case + // - one deep + + it("failure", co.wrap(function *() { + const tempdir = yield TestUtil.makeTempDir(); + process.chdir(tempdir); + const result = GitUtilFast.getRootGitDirectory(); + assert.isNull(result); + })); + + it("successes", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const workdir = repo.workdir(); + process.chdir(workdir); + const repoRoot = GitUtilFast.getRootGitDirectory(workdir); + assert(yield TestUtil.isSameRealPath(workdir, repoRoot), + "trivial"); + const subdir = path.join(workdir, "sub"); + yield fs.mkdir(subdir); + process.chdir(subdir); + const subRoot = GitUtilFast.getRootGitDirectory(workdir); + assert(yield TestUtil.isSameRealPath(workdir, subRoot), "trivial"); + })); + it("with a non-submodule link", co.wrap(function *() { + const tempdir = yield TestUtil.makeTempDir(); + process.chdir(tempdir); + const gitLink = path.join(tempdir, ".git"); + yield fs.writeFile(gitLink, "gitdir: /foo/bar"); + const result = GitUtilFast.getRootGitDirectory(); + assert.isNotNull(result); + assert(yield TestUtil.isSameRealPath(tempdir, result), result); + })); + }); +}); diff --git a/node/test/util/hook.js b/node/test/util/hook.js new file mode 100644 index 000000000..f14363fcd --- /dev/null +++ b/node/test/util/hook.js @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2018, 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 Hook = require("../../lib/util/hook"); +const path = require("path"); +const fs = require("fs-promise"); +const TestUtil = require("../../lib/util/test_util"); + +describe("Hook", function () { + describe("execHook", function () { + // 1. Hook does not exist, no error throws. + it("hook_does_not_exist", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const hookName = "fake_hook"; + assert.doesNotThrow( + function () { + //Nothing happened, no error throws. + }, + yield Hook.execHook(repo, hookName) + ); + })); + + // 2. Hook exists. + it("hook_exists", co.wrap(function *() { + const hookName = "real_hook"; + const repo = yield TestUtil.createSimpleRepository(); + const workDir = repo.workdir(); + process.chdir(workDir); + const hooksDir = path.join(workDir, ".git/hooks"); + const hookFile = path.join(hooksDir, hookName); + const hookOutputFile = path.join(hooksDir, "hook_test"); + yield fs.writeFile(hookFile, + "#!/bin/bash \necho 'it is a test hook' >" + hookOutputFile); + yield fs.chmod(hookFile, "755"); + yield Hook.execHook(repo, hookName); + assert.ok(fs.existsSync(hookOutputFile), "File does not exists"); + assert.equal(fs.readFileSync(hookOutputFile, "utf8"), + "it is a test hook\n"); + })); + }); +}); diff --git a/node/test/util/include.js b/node/test/util/include.js index d9ba2362b..1ff1b286e 100644 --- a/node/test/util/include.js +++ b/node/test/util/include.js @@ -115,10 +115,10 @@ describe("include", function () { })); it("should have signature of the current repo", co.wrap(function *() { - const repoSignature = repo.defaultSignature(); + const repoSignature = yield repo.defaultSignature(); const submoduleRepo = yield NodeGit.Repository.open(repo.workdir() + path); - const submoduleSignature = submoduleRepo.defaultSignature(); + const submoduleSignature = yield submoduleRepo.defaultSignature(); assert.equal(repoSignature.toString(), submoduleSignature.toString()); diff --git a/node/test/util/merge.js b/node/test/util/merge.js deleted file mode 100644 index 4dee1280b..000000000 --- a/node/test/util/merge.js +++ /dev/null @@ -1,228 +0,0 @@ -/* - * Copyright (c) 2016, 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 Merge = require("../../lib//util/merge"); -const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); -const StatusUtil = require("../../lib/util/status_util"); - -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 - - const doMerge = co.wrap(function *(upToDate, - fromCommit, - mode, - repos, - maps) { - const x = repos.x; - const status = yield StatusUtil.getRepoStatus(x); - const commitMap = maps.commitMap; - const reverseCommitMap = maps.reverseCommitMap; - assert.property(reverseCommitMap, fromCommit); - const physicalCommit = reverseCommitMap[fromCommit]; - const commit = yield x.getCommit(physicalCommit); - const result = yield Merge.merge(x, status, commit, mode, "message\n"); - if (upToDate) { - assert.isNull(result); - return; // RETURN - } - 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". - - if (!(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, - }; - }); - - // Test plan: - // - basic merging with meta-repo: normal/ffw/force commit - // - 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 = Merge.MODE; - const cases = { - "trivial": { - initial: "x=S", - fromCommit: "1", - expected: null, - }, - "ancestor": { - initial: "x=S:C2-1;Bmaster=2", - fromCommit: "1", - upToDate: true, - expected: null, - }, - "ff merge, not required": { - initial: "x=S:C2-1;Bfoo=2", - fromCommit: "2", - expected: "x=E:Bmaster=2", - }, - "ff merge, required": { - initial: "x=S:C2-1;Bfoo=2", - fromCommit: "2", - mode: MODE.FF_ONLY, - expected: "x=E:Bmaster=2", - }, - "ff merge, but disallowed": { - initial: "x=S:C2-1;Bfoo=2", - fromCommit: "2", - mode: MODE.FORCE_COMMIT, - expected: "x=E:Cx-1,2 2=2;Bmaster=x", - }, - "one merge": { - initial: "x=S:C2-1;C3-1;Bmaster=2;Bfoo=3", - fromCommit: "3", - expected: "x=E:Cx-2,3 3=3;Bmaster=x", - }, - "one merge, forced anyway": { - initial: "x=S:C2-1;C3-1;Bmaster=2;Bfoo=3", - fromCommit: "3", - expected: "x=E:Cx-2,3 3=3;Bmaster=x", - mode: MODE.FORCE_COMMIT, - }, - "one merge, ff requested": { - initial: "x=S:C2-1;C3-1;Bmaster=2;Bfoo=3", - fromCommit: "3", - mode: MODE.FF_ONLY, - fails: true, - }, - "ff merge adding submodule": { - initial: "a=S|x=U:Bfoo=1;*=foo", - fromCommit: "2", - expected: "x=E:Bfoo=2", - }, - "ff merge with submodule change": { - initial: "a=S:C4-1;Bfoo=4|x=U:C5-2 s=Sa:4;Bfoo=5", - fromCommit: "5", - expected: "x=E:Bmaster=5;Os H=4", - }, - "fforwardable but disallowed with submodule change": { - initial: "a=S:C4-1;Bfoo=4|x=U:C5-2 s=Sa:4;Bfoo=5", - fromCommit: "5", - mode: MODE.FORCE_COMMIT, - expected: - "x=E:Bmaster=x;Cx-2,5 s=Sa:s;Os Cs-1,4 4=4!H=s", - }, - "fforwardable merge with non-ffwd submodule change": { - initial: "\ -a=Aa:Cb-a;Cc-a;Bfoo=b;Bbar=c|\ -x=U:C3-2 s=Sa:b;C4-3 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", - }, - "fforwardable merge with non-ffwd submodule change, ff requested": { - initial: "\ -a=Aa:Cb-a;Cc-a;Bfoo=b;Bbar=c|\ -x=U:C3-2 s=Sa:b;C4-3 s=Sa:c;Bmaster=3;Bfoo=4", - fromCommit: "4", - mode: MODE.FF_ONLY, - expected: "x=E:Os H=b", - fails: true, - }, - "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 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,x=y;Bmaster=3;Bfoo=4", - fromCommit: "4", - expected: "x=E:Cx-3,4 x=y;Os H=c;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,x=y;Bmaster=3;Bfoo=4", - fromCommit: "4", - expected: "x=E:Cx-3,4 x=y;Bmaster=x", - }, - "otherwise ffwardable change to meta with two subs; one can't ffwd": { - initial: "\ -a=Aa:Cb-a;Cc-a;Cd-c;Bx=d;By=b|\ -x=U:C3-2 s=Sa:b,t=Sa:c;C4-3 s=Sa:c,t=Sa:d;Bmaster=3;Bfoo=4", - fromCommit: "4", - expected: "\ -x=E:Cx-3,4 s=Sa:s,t=Sa:d;Bmaster=x;\ -Os Cs-b,c c=c!H=s;\ -Ot H=d", - }, - }; - Object.keys(cases).forEach(caseName => { - const c = cases[caseName]; - it(caseName, co.wrap(function *() { - const expected = c.expected; - function manipulator(repos, maps) { - const upToDate = null === expected; - const mode = !("mode" in c) ? MODE.NORMAL : c.mode; - return doMerge(upToDate, c.fromCommit, mode, repos, maps); - } - yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, - c.expected || {}, - manipulator, - c.fails); - })); - }); -}); diff --git a/node/test/util/merge_bare.js b/node/test/util/merge_bare.js new file mode 100644 index 000000000..b46df8f7a --- /dev/null +++ b/node/test/util/merge_bare.js @@ -0,0 +1,387 @@ +/* + * 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 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 = MergeCommon.MODE; + const cases = { + "3 way merge in bare": { + initial: ` +a=B:Ca-1;Cb-1;Ba=a;Bb=b| +x=B:C2-1 s=Sa:1;C3-2 s=Sa:a;C4-2 s=Sa:b;Bmaster=3;Bfoo=4`, + theirCommit: "4", + ourCommit: "3", + }, + "fast forward in normal mode": { + initial: "a=B|x=S:C2-1 s=Sa:1;Bfoo=2", + theirCommit: "2", + ourCommit: "1", + mode: MODE.NORMAL, + parents: ["1"], + }, + "fast forward in no-ff mode": { + initial: "a=B|x=S:C2-1 s=Sa:1;Bfoo=2", + theirCommit: "2", + ourCommit: "1", + parents: ["1", "2"], + }, + "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`, + theirCommit: "4", + ourCommit: "3", + }, + "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`, + theirCommit: "5", + ourCommit: "3", + }, + "one merge with author and committer": { + 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`, + theirCommit: "4", + ourCommit: "3", + authorName: "alice", + authorEmail: "alice@example.com", + committerName: "bob", + committerEmail: "bob@example.com", + verify: co.wrap(function *(repo, result) { + const commit = yield repo.getCommit(result.metaCommit); + const author = commit.author(); + const committer = commit.committer(); + assert.equal(author.name(), "alice"); + assert.equal(author.email(), "alice@example.com"); + assert.equal(committer.name(), "bob"); + assert.equal(committer.email(), "bob@example.com"); + }), + }, + "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`, + theirCommit: "4", + ourCommit: "3", + }, + "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`, + theirCommit: "4", + ourCommit: "3", + }, + "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`, + theirCommit: "4", + ourCommit: "3", + }, + "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`, + theirCommit: "4", + ourCommit: "3", + }, + "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`, + theirCommit: "4", + ourCommit: "3", + }, + "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`, + theirCommit: "4", + ourCommit: "3", + }, + "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`, + theirCommit: "4", + ourCommit: "3", + }, + "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`, + theirCommit: "5", + ourCommit: "3", + }, + "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`, + theirCommit: "4", + ourCommit: "3", + }, + "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`, + theirCommit: "4", + ourCommit: "3", + }, + "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`, + theirCommit: "4", + ourCommit: "3", + }, + "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`, + theirCommit: "4", + ourCommit: "3", + }, + "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`, + theirCommit: "4", + ourCommit: "3", + }, + "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`, + theirCommit: "4", + ourCommit: "3", + }, + "submodule commit backwards": { + initial:` +a=Aa:Cb-a;Cc-b;Bfoo=b;Bbar=c| +x=U:C3-2 s=Sa:c;C4-2 s=Sa:b;Bmaster=3;Bfoo=4;Os`, + theirCommit: "4", + ourCommit: "3", + }, + "submodule commit forwards": { + initial:` +a=Aa:Cb-a;Cc-b;Bfoo=b;Bbar=c| +x=U:C3-2 s=Sa:b;C4-2 s=Sa:c;Bmaster=3;Bfoo=4;Os`, + theirCommit: "4", + ourCommit: "3", + }, + + "added in merge": { + initial: `a=B|x=S:C2-1;C3-1 t=Sa:1;Bmaster=2;Bfoo=3`, + theirCommit: "3", + ourCommit: "2", + }, + "added on both sides": { + initial: ` +a=B| +x=S:C2-1 s=Sa:1;C3-1 t=Sa:1;Bmaster=2;Bfoo=3`, + theirCommit: "2", + ourCommit: "3", + }, + "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`, + theirCommit: "3", + ourCommit: "2", + fails: true, + errorMessage: `\ +CONFLICT (content): +Conflicting entries for submodule: ${colors.red("s")} +Automatic merge failed +`, + }, + "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`, + theirCommit: "4", + ourCommit: "3", + fails: true, + errorMessage: `\ +CONFLICT (content): +Conflicting entries for submodule: ${colors.red("s")} +Automatic merge failed +`, + }, + "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`, + theirCommit: "5", + ourCommit: "4", + }, + "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`, + theirCommit: "5", + ourCommit: "4", + }, + "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`, + theirCommit: "4", + ourCommit: "3", + }, + "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`, + theirCommit: "3", + ourCommit: "4", + }, + "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`, + theirCommit: "t", + ourCommit: "l", + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + let authorName, authorEmail, committerName, committerEmail; + this.beforeEach(function () { + if (c.authorName && c.authorEmail) { + authorName = process.env.GIT_AUTHOR_NAME; + authorEmail = process.env.GIT_AUTHOR_EMAIL; + process.env.GIT_AUTHOR_NAME = c.authorName; + process.env.GIT_AUTHOR_EMAIL = c.authorEmail; + } + if (c.committerName && c.committerEmail) { + committerName = process.env.GIT_COMMITTER_NAME; + committerEmail = process.env.GIT_COMMITTER_EMAIL; + process.env.GIT_COMMITTER_NAME = c.committerName; + process.env.GIT_COMMITTER_EMAIL = c.committerEmail; + } + }); + this.afterEach(function () { + if (authorName && authorEmail) { + process.env.GIT_AUTHOR_NAME = authorName; + process.env.GIT_AUTHOR_EMAIL = authorEmail; + } + if (committerName && committerEmail) { + process.env.GIT_AUTHOR_NAME = committerName; + process.env.GIT_AUTHOR_EMAIL = committerEmail; + } + }); + it(caseName, co.wrap(function *() { + // expect no changes to the repo + const expected = "x=E"; + + const doMerge = co.wrap(function *(repos, maps) { + const upToDate = null === expected; + const x = repos.x; + const reverseCommitMap = maps.reverseCommitMap; + assert.property(reverseCommitMap, c.theirCommit); + const theirSha = reverseCommitMap[c.theirCommit]; + const theirCommit = yield x.getCommit(theirSha); + const ourSha = reverseCommitMap[c.ourCommit]; + const ourCommit = yield x.getCommit(ourSha); + + let message = c.message; + if (undefined === message) { + message = "message\n"; + } + const mode = !("mode" in c) ? MODE.FORCE_COMMIT : c.mode; + 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) { + assert.isNull(result.metaCommit); + return; // RETURN + } + if (c.verify) { + yield c.verify(x, result); + } + if (result.metaCommit) { + const parents = c.parents ? + c.parents.map(v => reverseCommitMap[v]) : + [theirSha, ourSha]; + const mergedCommit + = yield x.getCommit(result.metaCommit); + const mergeParents + = yield mergedCommit.getParents(null, null); + const mergeParentShas + = new Set(mergeParents.map(c => c.sha())); + const parentsMatch = parents + .map(c => mergeParentShas.has(c)) + .reduce( (acc, curr) => acc && curr, true); + assert.isTrue( + parentsMatch, + "parents (" + mergeParentShas + ") " + + "of created meta commit do not match expected: " + + parents); + } + return {commitMap: {}}; + }); + yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, + expected || {}, + doMerge, + c.fails); + })); + }); + }); +}); diff --git a/node/test/util/merge_full_open.js b/node/test/util/merge_full_open.js new file mode 100644 index 000000000..eadba1192 --- /dev/null +++ b/node/test/util/merge_full_open.js @@ -0,0 +1,434 @@ +/* + * 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", + expected: "a=B|x=E:Bmaster=3", + }, + "ancestor url changes": { + initial: "a=B|b=B|x=U:C4-3 q=Sa:1;C3-2 s=Sb:1;Bfoo=4", + fromCommit: "4", + expected: "a=B|x=E:Bmaster=4", + }, + "genuine url merge conflicts": { + initial: "a=B|b=B|c=B|" + + "x=U:C3-2 s=Sc:1;C4-2 s=Sb:1;Bmaster=3;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 new file mode 100644 index 000000000..4fa50657d --- /dev/null +++ b/node/test/util/merge_util.js @@ -0,0 +1,628 @@ +/* + * Copyright (c) 2017, 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("MergeUtil", function () { + describe("fastForwardMerge", function () { + const cases = { + "simple": { + initial: "a=B|x=S:C2-1 q=Sa:1;Bfoo=2", + commit: "2", + expected: "x=E:Bmaster=2", + }, + "simple detached": { + initial: "a=B|x=S:C2-1 u=Sa:1;Bfoo=2;*=", + commit: "2", + expected: "x=E:H=2", + }, + "with submodule": { + initial: "a=B:Ca-1;Ba=a|x=U:C3-2 s=Sa:a;Bfoo=3", + commit: "3", + expected: "x=E:Bmaster=3", + }, + "with open submodule": { + initial: "a=B:Ca-1;Ba=a|x=U:C3-2 s=Sa:a;Bfoo=3;Os", + commit: "3", + expected: "x=E:Bmaster=3;Os H=a", + }, + "with open submodule and change": { + initial: ` +a=B:Ca-1;Ba=a| +x=U:C3-2 s=Sa:a;Bfoo=3;Os W README.md=3`, + commit: "3", + expected: "x=E:Bmaster=3;Os H=a!W README.md=3", + }, + "with open submodule and conflict": { + initial: ` +a=B:Ca-1;Ba=a| +x=U:C3-2 s=Sa:a;Bfoo=3;Os W a=b`, + commit: "3", + fails: true, + }, + "ff merge adding submodule": { + initial: "a=S|x=U:Bfoo=1;*=foo", + commit: "2", + expected: "x=E:Bfoo=2", + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + const ffwd = co.wrap(function *(repos, maps) { + const x = repos.x; + const reverseCommitMap = maps.reverseCommitMap; + assert.property(reverseCommitMap, c.commit); + const physicalCommit = reverseCommitMap[c.commit]; + const commit = yield x.getCommit(physicalCommit); + const message = c.message || "message\n"; + yield MergeUtil.fastForwardMerge(x, + commit, + message); + return { + commitMap: {}, + }; + }); + it(caseName, co.wrap(function *() { + yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, + c.expected, + ffwd, + c.fails); + })); + }); + }); + + 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", + expected: "a=B|x=E:Bmaster=3" + }, + "ancestor url changes": { + initial: "a=B|b=B|x=U:C4-3 q=Sa:1;C3-2 s=Sb:1;Bfoo=4", + fromCommit: "4", + expected: "a=B|x=E:Bmaster=4" + }, + "genuine url merge conflicts": { + initial: "a=B|b=B|c=B|" + + "x=U:C3-2 s=Sc:1;C4-2 s=Sb:1;Bmaster=3;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": { + 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", + }, + "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`, + }, + "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`, + 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;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", + }, + "non-ffmerge with non-ffwd submodule change, unrelated dnr": { + 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", + doNotRecurse: ["fake"], + 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 is dnr": { + 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", + doNotRecurse: ["s"], + fails: true, + errorMessage: `\ +Submodule ${colors.red("s")} is conflicted. +`, + expected: `x=E:Qmessage\n#M 3: 4: 0 4;I *s=S:1*S:b*S:c` + }, + "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", + 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")} +`, + }, + "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.ALLOW_BARE; + + const doNotRecurse = c.doNotRecurse || []; + const result = yield MergeUtil.merge(x, + null, + commit, + mode, + openOption, + doNotRecurse, + 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, + expected || {}, + doMerge, + c.fails); + })); + }); + }); + describe("continue", function () { + const cases = { + "no merge": { + initial: "x=S", + fails: true, + }, + "continue in meta": { + initial: ` +x=S:C2-1;C3-1;Bmaster=2;I baz=bam;Qhi\n#M 2: 3: 0 3;Bfoo=3`, + expected: "x=E:Chi\n#x-2,3 baz=bam;Bmaster=x;Q;I baz=~", + }, + "cheap continue in meta": { + initial: "x=S:C2;Qhi\n#M 1: 2: 0 2;B2=2", + expected: "x=E:Chi\n#x-1,2 ;Bmaster=x;Q", + }, + "continue with extra in non-continue sub": { + initial: ` +a=B| +x=U:C3-1;Qhi\n#M 2: 3: 0 3;B3=3;Os I README.md=8`, + expected: ` +x=E:Chi\n#x-2,3 s=Sa:s;Bmaster=x;Q;Os Chi\n#s-1 README.md=8!H=s`, + }, + "continue in a sub": { + initial: ` +a=B:Ca;Ba=a| +x=U:C3-1;Qhi\n#M 2: 3: 0 3;B3=3;Os I README.md=8!Qyo\n#M 1: a: 0 a!Ba=a`, + expected: ` +x=E:Chi\n#x-2,3 s=Sa:s;Bmaster=x;Q;Os Cyo\n#s-1,a README.md=8!H=s!Ba=a`, + }, + "continue in one sub, done in another": { + initial: ` +a=B:Ca-1;Cac-1 a=2;Cb-1;Cmab-a,b b=b;Bmab=mab;Bb=b;Ba=a;Bac=ac| +x=S:C2-1 s=Sa:1,t=Sa:1; + C3-2 s=Sa:a,t=Sa:a; + C4-2 s=Sa:ac,t=Sa:b; + Bmaster=3;Bfoo=4; + Qhi\n#M 3: 4: 0 4; + Os I a=foo!Qyou\n#M a: ac: 0 ac!Bac=ac; + Ot H=mab`, + expected: ` +x=E:Chi\n#x-3,4 s=Sa:s,t=Sa:mab;Bmaster=x;Q; + Os Cyou\n#s-a,ac a=foo!H=s!Bac=ac; + Ot`, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + const doContinue = co.wrap(function *(repos, maps) { + const repo = repos.x; + const result = yield MergeUtil.continue(repo); + return mapReturnedCommits(result, maps); + }); + it(caseName, co.wrap(function *() { + yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, + c.expected, + doContinue, + c.fails); + })); + }); + }); + describe("abort", function() { + const cases = { + "no merge": { + initial: "x=S", + fails: true, + }, + "noop": { + initial: "x=S:Qfoo#M 1: 1: 0 1", + expected: "x=E:Q", + }, + "noop with sub": { + initial: "a=B|x=U:Qfoo#M 1: 1: 0 1;Os Qfoo#M 1: 1: 0 1", + expected: "x=E:Q;Os Q", + }, + "moved back a sub": { + initial: ` +a=B| +x=U:Qx#M 1: 1: 0 1;Os Cs-1!H=s!Bs=s`, + expected: `x=E:Q;Os H=1!Cs-1!Bs=s`, + }, + "from conflicts": { + initial: ` +a=B| +x=U:Qx#M 1: 1: 0 1;Os Cs-1!H=s!Bs=s!I *README.md=a*b*c`, + expected: `x=E:Q;Os H=1!Cs-1!Bs=s`, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + const doAbort = co.wrap(function *(repos) { + const repo = repos.x; + yield MergeUtil.abort(repo); + }); + it(caseName, co.wrap(function *() { + yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, + c.expected, + doAbort, + c.fails); + })); + }); + }); +}); diff --git a/node/test/util/open.js b/node/test/util/open.js index 3ee32770d..c40e78849 100644 --- a/node/test/util/open.js +++ b/node/test/util/open.js @@ -34,11 +34,13 @@ const assert = require("chai").assert; const co = require("co"); const NodeGit = require("nodegit"); +const ConfigUtil = require("../../lib/util/config_util"); const Open = require("../../lib/util/open"); 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 @@ -51,6 +53,12 @@ describe("openOnCommit", function () { commitSha: "1", expected: "x=E:Os", }, + "sparse": { + initial: "a=B|x=%U", + subName: "s", + commitSha: "1", + expected: "x=E:Os", + }, "not head": { initial: "a=B:C3-1;Bmaster=3|x=U", subName: "s", @@ -76,7 +84,8 @@ describe("openOnCommit", function () { const result = yield Open.openOnCommit(fetcher, c.subName, commit, - null); + null, + false); assert.instanceOf(result, NodeGit.Repository); }); yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, @@ -96,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"); - const s2 = yield opener.getSubrepo("s"); + 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"); @@ -106,11 +115,15 @@ 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"); - const s2 = yield opener.getSubrepo("s"); + 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"); + const config = yield s1.config(); + const gcConfig = yield ConfigUtil.getConfigString(config, + "gc.auto"); + assert.equal("0", gcConfig); })); it("different commit", co.wrap(function *() { const state = "a=B:Ca-1;Ba=a|x=U:C3-2 s=Sa:a;Bfoo=3"; @@ -121,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"); + const s = yield opener.getSubrepo("s", FORCE_OPEN); const head = yield s.getHeadCommit(); assert.equal(head.id().tostrS(), subSha); })); @@ -146,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"); + yield opener.getSubrepo("s", FORCE_OPEN); const open = yield opener.getOpenSubs(); assert.deepEqual(Array.from(open), []); })); @@ -171,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"); + yield opener.getSubrepo("s", FORCE_OPEN); const opened = yield opener.getOpenedSubs(); assert.deepEqual(opened, []); })); @@ -180,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"); + yield opener.getSubrepo("s", FORCE_OPEN); const opened = yield opener.getOpenedSubs(); assert.deepEqual(opened, ["s"]); })); @@ -189,7 +202,7 @@ describe("openOnCommit", function () { const w = yield RepoASTTestUtil.createMultiRepos(state); const repo = w.repos.x; const opener = new Open.Opener(repo, null); - const result = yield opener.isOpen("s"); + const result = opener.isOpen("s"); assert.equal(false, result); })); it("isOpen, true after open", co.wrap(function *() { @@ -197,16 +210,20 @@ 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"); - const result = yield opener.isOpen("s"); + const s = yield opener.getSubrepo("s", FORCE_OPEN); + const result = opener.isOpen("s"); assert.equal(true, result); + const config = yield s.config(); + const gcConfig = yield ConfigUtil.getConfigString(config, + "gc.auto"); + assert.equal("0", gcConfig); })); it("getOpenSubs, true immediately", co.wrap(function *() { const state = "a=B|x=U:Os"; const w = yield RepoASTTestUtil.createMultiRepos(state); const repo = w.repos.x; const opener = new Open.Opener(repo, null); - const result = yield opener.isOpen("s"); + const result = opener.isOpen("s"); assert.equal(true, result); })); }); diff --git a/node/test/util/print_status_util.js b/node/test/util/print_status_util.js index d2007cde7..3661fc8c4 100644 --- a/node/test/util/print_status_util.js +++ b/node/test/util/print_status_util.js @@ -32,12 +32,17 @@ const assert = require("chai").assert; const colors = require("colors"); +const NodeGit = require("nodegit"); -const Rebase = require("../../lib/util/rebase"); const RepoStatus = require("../../lib/util/repo_status"); const PrintStatusUtil = require("../../lib/util/print_status_util"); +const SequencerState = require("../../lib/util/sequencer_state"); describe("PrintStatusUtil", function () { + const CommitAndRef = SequencerState.CommitAndRef; + const TYPE = SequencerState.TYPE; + const FILEMODE = NodeGit.TreeEntry.FILEMODE; + const BLOB = FILEMODE.BLOB; const FILESTATUS = RepoStatus.FILESTATUS; const RELATION = RepoStatus.Submodule.COMMIT_RELATION; const StatusDescriptor = PrintStatusUtil.StatusDescriptor; @@ -76,7 +81,10 @@ describe("PrintStatusUtil", function () { check: /^deleted/, }, "conflicted": { - des: new StatusDescriptor(FILESTATUS.CONFLICTED, "x", "y"), + des: new StatusDescriptor( + new RepoStatus.Conflict(BLOB, BLOB, BLOB), + "x", + "y"), check: /^conflicted/, }, "renamed": { @@ -101,6 +109,11 @@ describe("PrintStatusUtil", function () { cwd: "q", check: /\.\.\/x/, }, + "same as cwd": { + des: new StatusDescriptor(FILESTATUS.ADDED, "x", "y"), + cwd: "x", + check: / \. /, + }, }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; @@ -624,30 +637,48 @@ describe("PrintStatusUtil", function () { }); }); - describe("printRebase", function () { + describe("getSequencerCommand", function () { const cases = { - "basic": { - input: new Rebase("master", "xxx", "ffffffffffffffff"), - check: /rebase in progress/ + "merge": { + input: TYPE.MERGE, + expected: "merge", }, - "branch": { - input: new Rebase("master", "xxx", "ffffffffffffffff"), - check: /master/ + "rebase": { + input: TYPE.REBASE, + expected: "rebase", }, - "sha": { - input: new Rebase("master", "xxx", "ffffffffffffffff"), - check: /ffff/, + "cherry-pick": { + input: TYPE.CHERRY_PICK, + expected: "cherry-pick", }, }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; it(caseName, function () { - const result = PrintStatusUtil.printRebase(c.input); - assert.match(result, c.check); + const result = PrintStatusUtil.getSequencerCommand(c.input); + assert.equal(result, c.expected); }); }); }); + it("printSequencer", function () { + const state = new SequencerState({ + type: TYPE.MERGE, + originalHead: new CommitAndRef("foo", null), + target: new CommitAndRef("bar", "baz"), + commits: ["2", "1"], + currentCommit: 1, + }); + const expected = `\ +A merge is in progress. + (after resolving conflicts mark the corrected paths + with 'git meta add', then run "git meta merge --continue") + (use "git meta merge --abort" to check out the original branch) +`; + const result = PrintStatusUtil.printSequencer(state); + assert.deepEqual(result.split("\n"), expected.split("\n")); + }); + describe("printCurrentBranch", function () { const cases = { "normal": { @@ -663,6 +694,11 @@ describe("PrintStatusUtil", function () { }), check: /aaaa/, }, + "no-commits": { + input: new RepoStatus({ + }), + check: /No commits yet/, + }, }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; @@ -687,20 +723,39 @@ describe("PrintStatusUtil", function () { input: new RepoStatus({ currentBranchName: "master", }), - regex: /On branch.*master.*\n.*nothing to commit.*/, + exact: `\ +On branch ${colors.green("master")}. +nothing to commit, working tree clean +`, + shortExact: "\n", }, - "rebase": { + "sequencer": { input: new RepoStatus({ currentBranchName: "master", - rebase: new Rebase("x", "y", "z"), + sequencerState: new SequencerState({ + type: TYPE.REBASE, + originalHead: new CommitAndRef("foo", null), + target: new CommitAndRef("bar", "baz"), + commits: ["2", "1"], + currentCommit: 1, + }), }), - regex: /rebas/, + exact: `\ +On branch ${colors.green("master")}. +A rebase is in progress. + (after resolving conflicts mark the corrected paths + with 'git meta add', then run "git meta rebase --continue") + (use "git meta rebase --abort" to check out the original branch) +nothing to commit, working tree clean +`, + shortExact: "\n", }, "detached": { input: new RepoStatus({ headCommit: "ffffaaaaffffaaaa", }), regex: /detached/, + shortExact: "\n", }, "dirty meta": { input: new RepoStatus({ @@ -710,6 +765,8 @@ describe("PrintStatusUtil", function () { }, }), regex: /.*qrst/, + shortExact: `${colors.green("A")} qrst +`, }, "dirty sub": { input: new RepoStatus({ @@ -726,6 +783,31 @@ describe("PrintStatusUtil", function () { }, }), regex: /qrst\/x\/y\/z/, + shortExact: `${colors.green("A")} qrst +${colors.green("M")} qrst/x/y/z +`, + }, + "dirty-and-staged sub": { + input: new RepoStatus({ + currentBranchName: "master", + submodules: { + qrst: new Submodule({ + index: new Index(null, "a", null), + workdir: new Workdir(new RepoStatus({ + staged: { + "x/y/z": FILESTATUS.MODIFIED, + }, + workdir: { + "x/y/z": FILESTATUS.MODIFIED, + }, + }), null), + }), + }, + }), + regex: /qrst\/x\/y\/z/, + shortExact: `${colors.green("A")} qrst +${colors.green("M")}${colors.red("M")} qrst/x/y/z +`, }, "cwd": { input: new RepoStatus({ @@ -736,6 +818,8 @@ describe("PrintStatusUtil", function () { }), cwd: "u/v", regex: /\.\.\/\.\.\/qrst/, + shortExact: `${colors.green("A")} qrst +`, }, "untracked": { input: new RepoStatus({ @@ -749,6 +833,8 @@ Untracked files: \t${colors.red("foo")} +`, + shortExact: `${colors.red("?") + colors.red("?")} foo `, }, "change in sub workdir": { @@ -768,6 +854,8 @@ Changes to be committed: \t${colors.green("modified: zap")} (submodule, new commits) +`, + shortExact: `${colors.green("M")} zap `, }, }; @@ -787,6 +875,11 @@ Changes to be committed: else { assert.match(result, c.regex); } + + + const shortResult = PrintStatusUtil.printRepoStatusShort( + c.input, cwd); + assert.equal(c.shortExact, shortResult); }); }); }); @@ -794,15 +887,17 @@ Changes to be committed: describe("printSubmoduleStatus", function () { const cases = { "empty show closed": { - status: new RepoStatus(), relCwd: "", + subsToPrint: {}, + openSubs: new Set(), showClosed: true, expected: `\ ${colors.grey("All submodules:")} `, }, "empty no show closed": { - status: new RepoStatus(), + subsToPrint: {}, + openSubs: new Set(), relCwd: "", showClosed: false, expected: `\ @@ -810,14 +905,8 @@ ${colors.grey("Open submodules:")} `, }, "a closed sub, not shown": { - status: new RepoStatus({ - submodules: { - foo: new Submodule({ - commit: new Commit("1", "/a"), - index: new Index("1", "/a", RELATION.SAME), - }), - }, - }), + subsToPrint: { foo: "1", }, + openSubs: new Set(), relCwd: "", showClosed: false, expected: `\ @@ -825,14 +914,8 @@ ${colors.grey("Open submodules:")} `, }, "a closed sub, shown": { - status: new RepoStatus({ - submodules: { - foo: new Submodule({ - commit: new Commit("1", "/a"), - index: new Index("1", "/a", RELATION.SAME), - }), - }, - }), + subsToPrint: { foo: "1", }, + openSubs: new Set(), relCwd: "", showClosed: true, expected: `\ @@ -841,17 +924,10 @@ ${colors.grey("All submodules:")} `, }, "an open sub": { - status: new RepoStatus({ - submodules: { - bar: new Submodule({ - commit: new Commit("1", "/a"), - index: new Index("1", "/a", RELATION.SAME), - workdir: new Workdir(new RepoStatus({ - headCommit: "1", - }), RELATION.SAME), - }), - }, - }), + subsToPrint: { + bar: "1", + }, + openSubs: new Set(["bar"]), relCwd: "", showClosed: true, expected: `\ @@ -860,21 +936,11 @@ ${colors.grey("All submodules:")} `, }, "an open sub and closed": { - status: new RepoStatus({ - submodules: { - foo: new Submodule({ - commit: new Commit("1", "/a"), - index: new Index("1", "/a", RELATION.SAME), - }), - bar: new Submodule({ - commit: new Commit("1", "/a"), - index: new Index("1", "/a", RELATION.SAME), - workdir: new Workdir(new RepoStatus({ - headCommit: "1", - }), RELATION.SAME), - }), - }, - }), + subsToPrint: { + foo: "1", + bar: "1", + }, + openSubs: new Set(["bar"]), relCwd: "", showClosed: true, expected: `\ @@ -884,17 +950,8 @@ ${colors.grey("All submodules:")} `, }, "with relative workdir": { - status: new RepoStatus({ - submodules: { - bar: new Submodule({ - commit: new Commit("1", "/a"), - index: new Index("1", "/a", RELATION.SAME), - workdir: new Workdir(new RepoStatus({ - headCommit: "1", - }), RELATION.SAME), - }), - }, - }), + subsToPrint: { bar: "1", }, + openSubs: new Set(["bar"]), relCwd: "q", showClosed: true, expected: `\ @@ -903,14 +960,8 @@ ${colors.grey("All submodules:")} `, }, "deleted": { - status: new RepoStatus({ - submodules: { - bar: new Submodule({ - commit: new Commit("1", "/a"), - index: null, - }), - }, - }), + subsToPrint: { bar: null }, + openSubs: new Set(), relCwd: "", showClosed: true, expected: `\ @@ -923,8 +974,9 @@ ${colors.grey("All submodules:")} const c = cases[caseName]; it(caseName, function () { const result = PrintStatusUtil.printSubmoduleStatus( - c.status, c.relCwd, + c.subsToPrint, + c.openSubs, c.showClosed); const resultLines = result.split("\n"); const expectedLines = c.expected.split("\n"); diff --git a/node/test/util/pull.js b/node/test/util/pull.js index e30548fff..93a7380b5 100644 --- a/node/test/util/pull.js +++ b/node/test/util/pull.js @@ -66,7 +66,7 @@ describe("pull", function () { fails: true, }, "changes": { - initial: "a=B:C2-1;Bfoo=2|x=Ca", + initial: "a=B:C2-1 s=Sa:1;Bfoo=2|x=Ca", remote: "origin", source: "foo", expected: "x=E:Bmaster=2 origin/master", @@ -77,6 +77,21 @@ describe("pull", function () { source: "foo", expected: "x=E:Rorigin=a foo=1", }, + "conflict": { + initial: ` +a=B:Ca-1;Cb-1 a=8;Ba=a;Bb=b|y=U:C3-2 s=Sa:a;C4-2 s=Sa:b;Bmaster=3;Bfoo=4|x=Cy`, + remote: "origin", + source: "foo", + expected: ` +x=E:H=4;QR 3:refs/heads/master 4: 0 3;Os I *a=~*8*a!Edetached HEAD,a,b! W a=\ +<<<<<<< HEAD +8 +======= +a +>>>>>>> message +;`, + fails: true, + }, }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; @@ -117,6 +132,10 @@ describe("userWantsRebase", function () { null, null)); + assert.equal(false, yield Pull.userWantsRebase({"rebase": null}, + repo, + master)); + assert.equal(false, yield Pull.userWantsRebase({}, repo, master)); diff --git a/node/test/util/push.js b/node/test/util/push.js index dfec266bb..62baab433 100644 --- a/node/test/util/push.js +++ b/node/test/util/push.js @@ -30,12 +30,19 @@ */ "use strict"; -const co = require("co"); +const assert = require("chai").assert; +const co = require("co"); +const NodeGit = require("nodegit"); +const rimraf = require("rimraf"); -const Push = require("../../lib/util/push"); -const RepoAST = require("../../lib/util/repo_ast"); -const RepoASTUtil = require("../../lib/util/repo_ast_util"); -const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); +const ForcePushSpec = require("../../lib/util/force_push_spec"); +const GitUtil = require("../../lib/util/git_util"); +const Push = require("../../lib/util/push"); +const RepoAST = require("../../lib/util/repo_ast"); +const RepoASTUtil = require("../../lib/util/repo_ast_util"); +const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); +const SubmoduleUtil = require("../../lib/util/submodule_util"); +const SubmoduleConfigUtil = require("../../lib/util/submodule_config_util"); /** * Return a map from name to RepoAST that is the same as the one in the @@ -190,11 +197,208 @@ describe("refMapper", function () { }); }); +describe("getPushMap", function () { + const cases = { + "empty, by sha" : { + initial: "x=S:C2-1;Bmaster=2", + source: "2", + expectedPushMap: {} + }, + "simple" : { + initial: `sub=S:C8-1;Bmaster=8|x=S:C2-1 d=Ssub:8;Bmaster=2;Od`, + source: "2", + expectedPushMap: { + d : "8" + }, + }, + "another sub in parent commit" : { + initial: `sub1=S:C8-1;Bmaster=8| + sub2=S:C7-1;Bmaster=7| + x=S:C2-1 d1=Ssub1:8;C3-2 d2=Ssub2:7;Bmaster=3;Od1;Od2`, + source: "3", + expectedPushMap: { + d1 : "8", + d2 : "7" + }, + }, + "another sub in parent commit, but origin already has it" : { + initial: `sub1=S:C8-1;Bmaster=8| + sub2=S:C7-1;Bmaster=7| + x=S:C2-1 d1=Ssub1:8;C3-2 d2=Ssub2:7;Bmaster=3; + Rorigin=foo target=2; + Od1;Od2`, + source: "3", + expectedPushMap: { + d2 : "7", + }, + }, + "origin has a child but we didn't fetch it, so we don't know that" : { + initial: `sub=S:C7-1;C8-7;Bmaster=8| + x=S:C2-1 d=Ssub:8;C3-1 d=Ssub:7;Bmaster=3; + Rorigin=foo target=2; + Od`, + source: "3", + expectedPushMap: { + d : "7", + }, + }, + "origin has different commit, but we didn't change anything": { + initial: ` +a=B:B3=3;C3-2 s=Sa:z;Cz-1;Bz=z|b=B| +x=S:B3=3;C2-1 s=Sa:1;Rorigin=a master=3;Rtarget=b;Bmaster=2 origin/master;Os`, + source: "refs/heads/master", + expectedPushMap: {}, + }, + "origin is equal" : { + initial: `sub=S:C7-1;Bmaster=7| + x=S:C2-1 d=Ssub:7;Bmaster=2; + Rorigin=foo target=2; + Od`, + extraFetch: { + "d" : { + sub: "sub", + commits: ["7"], + }, + }, + source: "2", + expectedPushMap: {}, + }, + }; + + const testGetPushMap = function(source, expectedPushMap, extraFetch) { + return co.wrap(function *(repos, commitMap) { + const repo = repos.x; + let sha; + if (parseInt(source) > 0) { + sha = commitMap.reverseCommitMap[source]; + } else { + sha = (yield repo.getReference(source)).target(); + } + + const commit = yield repo.getCommit(sha); + + // Do any necessary extra fetches in submodules + for (const sub of Object.keys(extraFetch)) { + const extra = extraFetch[sub]; + const subRepo = yield SubmoduleUtil.getBareRepo(repo, sub); + for (const toFetch of extra.commits) { + const mappedCommit = commitMap.reverseCommitMap[toFetch]; + yield GitUtil.fetchSha(subRepo, + commitMap.reverseUrlMap[extra.sub], + mappedCommit); + } + } + + // We want to test two modes: one with submodules open, + // and another with them closed. We need them to be initially + // open, because this will populate .git/modules, but + // we also want to test with them closed to ensure + // that we can handle that case. + for (const closeSubs of [false, true]) { + if (closeSubs) { + const subs = yield SubmoduleUtil.listOpenSubmodules(repo); + yield SubmoduleConfigUtil.deinit(repo, subs); + } + + const pushMap = yield Push.getPushMap(repo, source, commit); + const mappedPushMap = {}; + for (const sub of Object.keys(pushMap)) { + mappedPushMap[sub] = commitMap.commitMap[pushMap[sub]]; + } + assert.deepEqual(expectedPushMap, mappedPushMap); + } + }); + }; + + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const parts = c.initial.split(";"); + const expectedParts = parts.filter( + part => !part.trim().startsWith("O")); + const expected = expectedParts.join(";"); + const manipulator = testGetPushMap(c.source, + c.expectedPushMap, + c.extraFetch || {}); + yield RepoASTTestUtil.testMultiRepoManipulator( + c.initial, + expected, + manipulator, + false, + {includeRefsCommits : true}); + })); + }); + + it("handles a case with no tracking branches", co.wrap(function*() { + // We can't use the usual multi-repo test for this because it + // gets confused about which commits should exist in which + // submodules. Instead, we create a meta commit in one repo + // then do a fetch into a fresh repo (which has ever opened + // the repo in question but which doesn't have the + // newly-fetched commit). + + const them = "sub=S:C8-1;Bmaster=8|x=S:C2-1 d=Ssub:8;Bmaster=2"; + const us = "sub=S:C7-1;Bmaster=7|x=S:C3-1 d=Ssub:7;Bmaster=3;Od"; + + const theirWritten = yield RepoASTTestUtil.createMultiRepos(them); + const theirRepo = theirWritten.repos.x; + const theirCommitMap = theirWritten.reverseCommitMap; + + const ourWritten = yield RepoASTTestUtil.createMultiRepos(us); + + const ourRepo = ourWritten.repos.x; + const config = yield ourRepo.config(); + yield config.setString("remote.upstream.url", theirRepo.path()); + yield GitUtil.fetch(ourRepo, "upstream"); + + const sha = theirCommitMap["2"]; + const commit = yield ourRepo.getCommit(sha); + const pushMap = yield Push.getPushMap(ourRepo, "2", commit); + assert.deepEqual({}, pushMap); + })); + + it("local clone is missing directory", co.wrap(function*() { + const them = "sub=S:C8-1;Bmaster=8|x=S:C2-1 d=Ssub:8;Bmaster=2"; + const our = "sub=S:C7-1;Bmaster=7|x=S:C3-1 d=Ssub:7;Bmaster=3;Od"; + + const theirWritten = yield RepoASTTestUtil.createMultiRepos(them); + const theirRepo = theirWritten.repos.x; + + const ourWritten = yield RepoASTTestUtil.createMultiRepos(our); + const ourRepo = ourWritten.repos.x; + const ourCommitMap = ourWritten.reverseCommitMap; + + const config = yield ourRepo.config(); + yield config.setString("remote.upstream.url", theirRepo.path()); + + const sha = ourCommitMap["3"]; + const commit = yield ourRepo.getCommit(sha); + const pushMap = yield Push.getPushMap(ourRepo, "2", commit); + assert.deepEqual({"d": ourCommitMap["7"]}, pushMap); + + // "Delete" the codebase, but remain absorbed. + yield (new Promise(callback => { + return rimraf(ourWritten.repos.x.workdir() + "d", {}, + callback); + })); + const pushMapDel = yield Push.getPushMap(ourRepo, "2", commit); + assert.deepEqual({"d": ourCommitMap["7"]}, pushMapDel); + + // Remove the absorbed codebase. + yield (new Promise(callback => { + return rimraf(ourWritten.repos.x.path() + "modules/d", {}, + callback); + })); + const pushMapRm = yield Push.getPushMap(ourRepo, "2", commit); + assert.deepEqual({}, pushMapRm); + })); +}); + describe("push", function () { function pusher(repoName, remoteName, source, target, force) { return co.wrap(function *(repos) { - force = force || false; + force = force || ForcePushSpec.NoForce; const x = repos[repoName]; yield Push.push(x, remoteName, source, target, force); }); @@ -213,9 +417,56 @@ describe("push", function () { }, "no-ffwd success": { initial: "a=B:C2-1;Bmaster=2|b=Ca:C3-1;Bmaster=3 origin/master", - manipulator: pusher("b", "origin", "master", "master", true), + manipulator: pusher( + "b", "origin", "master", "master", ForcePushSpec.Force), + expected: "a=B:C3-1;Bmaster=3|b=Ca", + }, + "no-ffwd success with lease": { + initial: "a=B:C2-1;Bmaster=2|b=Ca:C3-1;Bmaster=3 origin/master", + manipulator: pusher( + "b", + "origin", + "master", + "master", + ForcePushSpec.ForceWithLease), expected: "a=B:C3-1;Bmaster=3|b=Ca", }, + "no-ffwd old remote failure": { + initial: ` + a=B:C2-1;Bmaster=2| + b=Ca:Rorigin=a master=1;C3-1;Bmaster=3 origin/master;Bold=2`, + manipulator: pusher( + "b", + "origin", + "master", + "master", + ForcePushSpec.NoForce), + fails: true, + }, + "no-ffwd old remote success": { + initial: ` + a=B:C2-1;Bmaster=2| + b=Ca:Rorigin=a master=1;C3-1;Bmaster=3 origin/master;Bold=2`, + manipulator: pusher( + "b", + "origin", + "master", + "master", + ForcePushSpec.Force), + expected: "a=B:C3-1;Bmaster=3|b=Ca:Bold=2", + }, + "no-ffwd old remote failure with lease": { + initial: ` + a=B:C2-1;Bmaster=2| + b=Ca:Rorigin=a master=1;C3-1;Bmaster=3 origin/master;Bold=2`, + manipulator: pusher( + "b", + "origin", + "master", + "master", + ForcePushSpec.ForceWithLease), + fails: true, + }, "simple (noop) success": { initial: "a=S|b=Ca", manipulator: pusher("b", "origin", "master", "master"), @@ -294,8 +545,121 @@ x=E:Rorigin=a foo=2`, c.expected, c.manipulator, c.fails, { + includeRefsCommits : true, expectedTransformer: refMapper, }); })); }); }); + +describe("getClosePushedCommit", function () { + // 5 -> 4 -> 3 -> 1 + // -> 2 -> + const baseRepo = "x=S:C2-1;C3-1;C4-3,2;C5-4;Bmaster=5"; + + const cases = { + "no ref, no close pushed commit": { + source: "5", + refs: { + }, + expectedCommit: null, + }, + "one ref, commit is pushed": { + source: "5", + refs: { + "refs/remotes/origin/1": "5", + }, + expectedCommit: "5", + }, + "one ref, far commit": { + source: "5", + refs: { + "refs/remotes/origin/1": "1", + }, + expectedCommit: "1", + }, + "one ref, close commit": { + source: "5", + refs: { + "refs/remotes/origin/1": "2", + }, + expectedCommit: "1", + }, + "lots of refs, none matching": { + source: "5", + remote: "origin", + refs: { + "refs/nonmatch/foo/1": "1", + "refs/nonmatch/foo/2": "2", + "refs/nonmatch/bar/3": "3", + "refs/nonmatch/bar/4": "4", + }, + expectedCommit: null, + }, + "lots of refs, something matching": { + source: "5", + refs: { + "refs/remotes/origin/1": "1", + "refs/nonmatch/foo/2": "2", + "refs/nonmatch/bar/3": "3", + "refs/nonmatch/bar/4": "4", + }, + expectedCommit: "1", + }, + "lots of refs, something matching 2": { + source: "5", + refs: { + "refs/nonmatch/foo/1": "1", + "refs/nonmatch/foo/2": "2", + "refs/nonmatch/bar/3": "3", + "refs/remotes/origin/4": "4", + }, + expectedCommit: "4", + }, + "lots of refs, all matching": { + source: "5", + refs: { + "refs/remotes/origin/1": "1", + "refs/remotes/origin/2": "2", + "refs/remotes/bar/3": "3", + "refs/remotes/bar/4": "4", + }, + expectedCommit: "4", + }, + "lots of refs, commit is pushed": { + source: "5", + remote: "*", + refs: { + "refs/remotes/origin/1": "1", + "refs/remotes/origin/2": "2", + "refs/remotes/bar/3": "3", + "refs/remotes/bar/4": "5", + }, + expectedCommit: "5", + }, + }; + + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const repos = yield RepoASTTestUtil.createMultiRepos(baseRepo); + const repo = repos.repos.x; + + for (const ref of Object.keys(c.refs)) { + yield NodeGit.Reference.create( + repo, ref, repos.reverseCommitMap[c.refs[ref]], 1, ""); + } + + const commit = yield repo.getCommit( + repos.reverseCommitMap[c.source]); + const actualCommit = yield Push.getClosePushedCommit( + repo, commit); + if (null !== c.expectedCommit) { + assert.deepEqual(repos.reverseCommitMap[c.expectedCommit], + actualCommit.id().tostrS()); + } else { + assert.deepEqual(null, actualCommit); + } + })); + }); +}); diff --git a/node/test/util/read_repo_ast_util.js b/node/test/util/read_repo_ast_util.js index af84dc922..b19c2fd69 100644 --- a/node/test/util/read_repo_ast_util.js +++ b/node/test/util/read_repo_ast_util.js @@ -36,14 +36,21 @@ const fs = require("fs-promise"); const NodeGit = require("nodegit"); const path = require("path"); -const DeinitUtil = require("../../lib/util/deinit_util"); +const ConflictUtil = require("../../lib/util/conflict_util"); +const GitUtil = require("../../lib/util/git_util"); const Rebase = require("../../lib/util/rebase"); const RepoAST = require("../../lib/util/repo_ast"); const ReadRepoASTUtil = require("../../lib/util/read_repo_ast_util"); const RepoASTUtil = require("../../lib/util/repo_ast_util"); +const SequencerState = require("../../lib/util/sequencer_state"); +const SequencerStateUtil = require("../../lib/util/sequencer_state_util"); +const SparseCheckoutUtil = require("../../lib/util/sparse_checkout_util"); const SubmoduleConfigUtil = require("../../lib/util/submodule_config_util"); const TestUtil = require("../../lib/util/test_util"); +const CommitAndRef = SequencerState.CommitAndRef; +const File = RepoAST.File; + // Test utilities /** @@ -60,7 +67,7 @@ const astFromSimpleRepo = co.wrap(function *(repo) { const commit = headId.id().tostrS(); let commits = {}; commits[commit] = new RepoAST.Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); return new RepoAST({ @@ -71,7 +78,6 @@ const astFromSimpleRepo = co.wrap(function *(repo) { }); }); - /** * Create a repository with a branch and two commits and a `RepoAST` object * representing its expected state. @@ -97,14 +103,14 @@ const repoWithCommit = co.wrap(function *() { const secondCommit = anotherCommit.id().tostrS(); let commits = {}; commits[firstCommit] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); commits[secondCommit] = new Commit({ parents: [firstCommit], changes: { - "README.md": "bleh", - "foobar": "meh", + "README.md": new File("bleh", false), + "foobar": new File("meh", false), }, message: "message\n", }); @@ -146,11 +152,11 @@ const repoWithDeeperCommits = co.wrap(function *() { let commits = {}; commits[firstCommit] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); commits[secondCommit] = new Commit({ - changes: { "README.md": "bleh" }, + changes: { "README.md": new File("bleh", false) }, parents: [firstCommit], message: "message\n", }); @@ -210,7 +216,7 @@ describe("readRAST", function () { const commit = headId.id().tostrS(); let commits = {}; commits[commit] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const expected = new RepoAST({ @@ -233,7 +239,7 @@ describe("readRAST", function () { let commits = {}; commits[commit] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const expected = new RepoAST({ @@ -262,12 +268,12 @@ describe("readRAST", function () { let commits = {}; commits[firstSha] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); commits[secondSha] = new Commit({ parents: [firstSha], - changes: { "foo/bar": "meh" }, + changes: { "foo/bar": new File("meh", false) }, message: "message\n", }); const expected = new RepoAST({ @@ -292,7 +298,7 @@ describe("readRAST", function () { const delCommit = yield TestUtil.makeCommit(r, []); const delSha = delCommit.id().tostrS(); commits[headSha] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); commits[delSha] = new Commit({ @@ -320,7 +326,7 @@ describe("readRAST", function () { const commit = headId.id().tostrS(); let commits = {}; commits[commit] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const expected = new RepoAST({ @@ -339,7 +345,7 @@ describe("readRAST", function () { const commit = headId.id().tostrS(); let commits = {}; commits[commit] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const expected = new RepoAST({ @@ -378,7 +384,7 @@ describe("readRAST", function () { const commit = headId.id().tostrS(); let commits = {}; commits[commit] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const expected = new RepoAST({ @@ -402,9 +408,9 @@ describe("readRAST", function () { it("headless with a commit", co.wrap(function *() { const path = yield TestUtil.makeTempDir(); const r = yield NodeGit.Repository.init(path, 1); - const sig = r.defaultSignature(); + const sig = yield r.defaultSignature(); const builder = yield NodeGit.Treebuilder.create(r, null); - const treeObj = builder.write(); + const treeObj = yield builder.write(); const tree = yield r.getTree(treeObj.tostrS()); const commitId = yield NodeGit.Commit.create(r, 0, @@ -437,7 +443,7 @@ describe("readRAST", function () { const commit = headId.id().tostrS(); let commits = {}; commits[commit] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const path = repos.bare.path(); @@ -461,8 +467,7 @@ describe("readRAST", function () { it("remote with path in tracking branch", co.wrap(function *() { const base = yield TestUtil.createSimpleRepository(); const headId = (yield base.getHeadCommit()).id(); - const sig = base.defaultSignature(); - yield base.createBranch("foo/bar", headId, 1, sig, "branch"); + yield base.createBranch("foo/bar", headId, 1); const clonePath = yield TestUtil.makeTempDir(); const clone = yield NodeGit.Clone.clone(base.workdir(), clonePath); const master = yield clone.getBranch("refs/heads/master"); @@ -471,7 +476,7 @@ describe("readRAST", function () { const commit = headId.tostrS(); let commits = {}; commits[commit] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const workdir = base.workdir(); @@ -504,7 +509,7 @@ describe("readRAST", function () { const commit = headId.id().tostrS(); let commits = {}; commits[commit] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const expected = new RepoAST({ @@ -534,10 +539,10 @@ describe("readRAST", function () { const commit = yield TestUtil.makeCommit(repo, ["x/y", ".gitmodules"]); - yield DeinitUtil.deinit(repo, "x/y"); + yield SubmoduleConfigUtil.deinit(repo, ["x/y"]); let commits = {}; commits[headCommit.id().tostrS()] = new Commit({ - changes: {"README.md":""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); commits[commit.id().tostrS()] = new Commit({ @@ -582,10 +587,10 @@ describe("readRAST", function () { const index = yield repo.index(); yield index.addByPath(".gitmodules"); yield index.write(); - yield DeinitUtil.deinit(repo, "x/y"); + yield SubmoduleConfigUtil.deinit(repo, ["x/y"]); let commits = {}; commits[headCommit.id().tostrS()] = new Commit({ - changes: {"README.md":""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); commits[commit.id().tostrS()] = new Commit({ @@ -633,11 +638,11 @@ describe("readRAST", function () { const subRepo = yield submodule.open(); const anotherSubCommit = yield TestUtil.generateCommit(subRepo); const lastCommit = yield TestUtil.makeCommit(repo, ["x/y"]); - yield DeinitUtil.deinit(repo, "x/y"); + yield SubmoduleConfigUtil.deinit(repo, ["x/y"]); let commits = {}; commits[headCommit.id().tostrS()] = new Commit({ - changes: {"README.md":""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); commits[commit.id().tostrS()] = new Commit({ @@ -683,7 +688,7 @@ describe("readRAST", function () { let commits = {}; commits[commit] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const expected = new RepoAST({ @@ -691,7 +696,7 @@ describe("readRAST", function () { branches: { "master": new RepoAST.Branch(commit, null), }, head: commit, currentBranchName: "master", - index: { "README.md": "foo" }, + index: { "README.md": new File("foo", false) }, }); const ast = yield ReadRepoASTUtil.readRAST(r); RepoASTUtil.assertEqualASTs(ast, expected); @@ -711,7 +716,7 @@ describe("readRAST", function () { let commits = {}; commits[commit] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const expected = new RepoAST({ @@ -719,7 +724,7 @@ describe("readRAST", function () { branches: { "master": new RepoAST.Branch(commit, null), }, head: commit, currentBranchName: "master", - index: { "foo": "foo" }, + index: { "foo": new File("foo", false), }, }); const ast = yield ReadRepoASTUtil.readRAST(r); RepoASTUtil.assertEqualASTs(ast, expected); @@ -741,7 +746,7 @@ describe("readRAST", function () { let commits = {}; commits[commit] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const expected = new RepoAST({ @@ -749,7 +754,7 @@ describe("readRAST", function () { branches: { "master": new RepoAST.Branch(commit, null), }, head: commit, currentBranchName: "master", - index: { "foo": "foo" }, + index: { "foo": new File("foo", false), }, workdir: { "foo": null }, }); const ast = yield ReadRepoASTUtil.readRAST(r); @@ -770,7 +775,7 @@ describe("readRAST", function () { let commits = {}; commits[commit] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const expected = new RepoAST({ @@ -794,11 +799,11 @@ describe("readRAST", function () { baseSubPath, "x/y", subHead.id().tostrS()); - yield DeinitUtil.deinit(repo, "x/y"); + yield SubmoduleConfigUtil.deinit(repo, ["x/y"]); let commits = {}; commits[headCommit.id().tostrS()] = new Commit({ - changes: {"README.md":""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const expected = new RepoAST({ @@ -839,11 +844,11 @@ describe("readRAST", function () { const nextSubCommit = yield TestUtil.generateCommit(subRepo); const index = yield repo.index(); yield index.addAll("x/y", -1); - yield DeinitUtil.deinit(repo, "x/y"); + yield SubmoduleConfigUtil.deinit(repo, ["x/y"]); let commits = {}; commits[headCommit.id().tostrS()] = new Commit({ - changes: {"README.md":""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); commits[commit.id().tostrS()] = new Commit({ @@ -876,7 +881,7 @@ describe("readRAST", function () { yield fs.unlink(path.join(r.workdir(), "README.md")); let commits = {}; commits[headCommit.id().tostrS()] = new Commit({ - changes: {"README.md":""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const expected = new RepoAST({ @@ -898,7 +903,7 @@ describe("readRAST", function () { yield fs.appendFile(path.join(r.workdir(), "foo"), "x"); let commits = {}; commits[headCommit.id().tostrS()] = new Commit({ - changes: {"README.md":""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const expected = new RepoAST({ @@ -908,7 +913,7 @@ describe("readRAST", function () { }, currentBranchName: "master", head: headCommit.id().tostrS(), - workdir: { foo: "x" }, + workdir: { foo: new File("x", false), }, }); const actual = yield ReadRepoASTUtil.readRAST(r); RepoASTUtil.assertEqualASTs(actual, expected); @@ -920,7 +925,7 @@ describe("readRAST", function () { yield fs.appendFile(path.join(r.workdir(), "README.md"), "x"); let commits = {}; commits[headCommit.id().tostrS()] = new Commit({ - changes: {"README.md":""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const expected = new RepoAST({ @@ -930,7 +935,7 @@ describe("readRAST", function () { }, currentBranchName: "master", head: headCommit.id().tostrS(), - workdir: { "README.md": "x" }, + workdir: { "README.md": new File("x", false) }, }); const actual = yield ReadRepoASTUtil.readRAST(r); RepoASTUtil.assertEqualASTs(actual, expected); @@ -946,7 +951,7 @@ describe("readRAST", function () { yield fs.appendFile(path.join(r.workdir(), "README.md"), "y"); let commits = {}; commits[headCommit.id().tostrS()] = new Commit({ - changes: {"README.md":""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const expected = new RepoAST({ @@ -956,8 +961,8 @@ describe("readRAST", function () { }, currentBranchName: "master", head: headCommit.id().tostrS(), - index: { "README.md": "x" }, - workdir: { "README.md": "xy" }, + index: { "README.md": new File("x", false), }, + workdir: { "README.md": new File("xy", false), }, }); const actual = yield ReadRepoASTUtil.readRAST(r); RepoASTUtil.assertEqualASTs(actual, expected); @@ -1001,8 +1006,8 @@ describe("readRAST", function () { const workdir = repo.workdir(); const firstCommit = yield repo.getHeadCommit(); const firstSha = firstCommit.id().tostrS(); - const sig = repo.defaultSignature(); - yield repo.createBranch("b", firstCommit, 0, sig); + const sig = yield repo.defaultSignature(); + yield repo.createBranch("b", firstCommit, 0); yield repo.checkoutBranch("b"); yield fs.writeFile(path.join(workdir, "foo"), "foo"); const commitB = yield TestUtil.makeCommit(repo, ["foo"]); @@ -1021,22 +1026,22 @@ describe("readRAST", function () { const commits = {}; const Commit = RepoAST.Commit; commits[firstSha] = new Commit({ - changes: { "README.md": "", }, + changes: { "README.md": new File("", false)}, message: "first commit", }); commits[bSha] = new Commit({ parents: [firstSha], - changes: { foo: "foo" }, + changes: { foo: new File("foo", false) }, message: "message\n", }); commits[cSha] = new Commit({ parents: [firstSha], - changes: { bar: "bar" }, + changes: { bar: new File("bar", false) }, message: "message\n", }); commits[mergeSha] = new Commit({ parents: [cSha, bSha], - changes: { foo: "foo" }, + changes: { foo: new File("foo", false) }, message: "Merge branch 'b'", }); const expected = new RepoAST({ @@ -1054,7 +1059,7 @@ describe("readRAST", function () { it("merge commit with submodule change", co.wrap(function *() { const repo = yield TestUtil.createSimpleRepository(); - const sig = repo.defaultSignature(); + const sig = yield repo.defaultSignature(); // Create the base repo for the submodule and add a couple of // commits. @@ -1063,12 +1068,12 @@ describe("readRAST", function () { const basePath = base.workdir(); const baseMaster = yield base.getHeadCommit(); const baseMasterSha = baseMaster.id().tostrS(); - yield base.createBranch("foo", baseMaster, 0, sig); + yield base.createBranch("foo", baseMaster, 0); yield base.checkoutBranch("foo"); yield fs.writeFile(path.join(basePath, "foo"), "foo"); const fooCommit = yield TestUtil.makeCommit(base, ["foo"]); const fooSha = fooCommit.id().tostrS(); - yield base.createBranch("bar", baseMaster, 0, sig); + yield base.createBranch("bar", baseMaster, 0); yield base.checkoutBranch("bar"); yield fs.writeFile(path.join(basePath, "bar"), "bar"); const barCommit = yield TestUtil.makeCommit(base, ["bar"]); @@ -1086,7 +1091,7 @@ describe("readRAST", function () { // Make the `wham` branch and put a change to the submodule on it. - yield repo.createBranch("wham", subCommit, 0, sig); + yield repo.createBranch("wham", subCommit, 0); yield repo.checkoutBranch("wham"); const subRepo = yield submodule.open(); const localBar = yield subRepo.getCommit(barSha); @@ -1141,23 +1146,23 @@ describe("readRAST", function () { const subCommits = {}; subCommits[baseMasterSha] = new Commit({ - changes: { "README.md": "", }, + changes: { "README.md": new File("", false)}, message: "first commit", }); subCommits[fooSha] = new Commit({ parents: [baseMasterSha], - changes: { foo: "foo" }, + changes: { foo: new File("foo", false), }, message: "message\n", }); subCommits[barSha] = new Commit({ parents: [baseMasterSha], - changes: { bar: "bar" }, + changes: { bar: new File("bar", false) }, message: "message\n", }); const commits = {}; commits[firstSha] = new Commit({ - changes: { "README.md": "", }, + changes: { "README.md": new File("", false)}, message: "first commit", }); commits[subSha] = new Commit({ @@ -1211,7 +1216,7 @@ describe("readRAST", function () { it("merge commit with ignored submodule change", co.wrap(function *() { const repo = yield TestUtil.createSimpleRepository(); - const sig = repo.defaultSignature(); + const sig = yield repo.defaultSignature(); // Create the base repo for the submodule and add a couple of // commits. @@ -1220,12 +1225,12 @@ describe("readRAST", function () { const basePath = base.workdir(); const baseMaster = yield base.getHeadCommit(); const baseMasterSha = baseMaster.id().tostrS(); - yield base.createBranch("foo", baseMaster, 0, sig); + yield base.createBranch("foo", baseMaster, 0); yield base.checkoutBranch("foo"); yield fs.writeFile(path.join(basePath, "foo"), "foo"); const fooCommit = yield TestUtil.makeCommit(base, ["foo"]); const fooSha = fooCommit.id().tostrS(); - yield base.createBranch("bar", baseMaster, 0, sig); + yield base.createBranch("bar", baseMaster, 0); yield base.checkoutBranch("bar"); yield fs.writeFile(path.join(basePath, "bar"), "bar"); const barCommit = yield TestUtil.makeCommit(base, ["bar"]); @@ -1243,7 +1248,7 @@ describe("readRAST", function () { // Make the `wham` branch and put a change to the submodule on it. - yield repo.createBranch("wham", subCommit, 0, sig); + yield repo.createBranch("wham", subCommit, 0); yield repo.checkoutBranch("wham"); const subRepo = yield submodule.open(); const localBar = yield subRepo.getCommit(barSha); @@ -1293,23 +1298,23 @@ describe("readRAST", function () { const subCommits = {}; subCommits[baseMasterSha] = new Commit({ - changes: { "README.md": "", }, + changes: { "README.md": new File("", false)}, message: "first commit", }); subCommits[fooSha] = new Commit({ parents: [baseMasterSha], - changes: { foo: "foo" }, + changes: { foo: new File("foo", false) }, message: "message\n", }); subCommits[barSha] = new Commit({ parents: [baseMasterSha], - changes: { bar: "bar" }, + changes: { bar: new File("bar", false) }, message: "message\n", }); const commits = {}; commits[firstSha] = new Commit({ - changes: { "README.md": "", }, + changes: { "README.md": new File("", false)}, message: "first commit", }); commits[subSha] = new Commit({ @@ -1366,7 +1371,7 @@ describe("readRAST", function () { const head = yield r.getHeadCommit(); const headId = head.id(); - const sig = r.defaultSignature(); + const sig = yield r.defaultSignature(); yield NodeGit.Note.create(r, "refs/notes/test", sig, sig, headId, "note", 0); @@ -1374,7 +1379,7 @@ describe("readRAST", function () { const commit = headId.tostrS(); let commits = {}; commits[commit] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const note = {}; @@ -1433,15 +1438,73 @@ describe("readRAST", function () { yield NodeGit.Rebase.init(r, current, onto, null, null); - // Remove the branches, making the commits reachable only from the - // rebase. - const ast = yield ReadRepoASTUtil.readRAST(r); const rebase = ast.rebase; assert.equal(rebase.originalHead, thirdCommit.id().tostrS()); assert.equal(rebase.onto, secondCommit.id().tostrS()); })); + it("sequencer", co.wrap(function *() { + // Start out with a base repo having two branches, "master", and "foo", + // foo having one commit on top of master. + + const start = yield repoWithCommit(); + const r = start.repo; + + // Switch to master + + yield r.checkoutBranch("master"); + + const head = yield r.getHeadCommit(); + const sha = head.id().tostrS(); + + const sequencer = new SequencerState({ + type: SequencerState.TYPE.REBASE, + originalHead: new CommitAndRef(sha, "foo"), + target: new CommitAndRef(sha, "bar"), + currentCommit: 0, + commits: [sha], + }); + + const original = yield ReadRepoASTUtil.readRAST(r); + const expected = original.copy({ + sequencerState: sequencer, + }); + + yield SequencerStateUtil.writeSequencerState(r.path(), sequencer); + + const actual = yield ReadRepoASTUtil.readRAST(r); + + RepoASTUtil.assertEqualASTs(actual, expected); + })); + + it("sequencer - unreachable", co.wrap(function *() { + const r = yield TestUtil.createSimpleRepository(); + r.detachHead(); + const second = yield TestUtil.generateCommit(r); + const third = yield TestUtil.generateCommit(r); + const fourth = yield TestUtil.generateCommit(r); + + // Then begin a cherry-pick. + + const sequencer = new SequencerState({ + type: SequencerState.TYPE.REBASE, + originalHead: new CommitAndRef(second.id().tostrS(), "foo"), + target: new CommitAndRef(third.id().tostrS(), "bar"), + currentCommit: 0, + commits: [fourth.id().tostrS()], + }); + + yield SequencerStateUtil.writeSequencerState(r.path(), sequencer); + + // Remove the branches, making the commits reachable only from the + // rebase. + + const ast = yield ReadRepoASTUtil.readRAST(r); + const actualSequencer = ast.sequencerState; + assert.deepEqual(actualSequencer, sequencer); + })); + it("add subs again", co.wrap(function *() { const repo = yield TestUtil.createSimpleRepository(); let expected = yield astFromSimpleRepo(repo); @@ -1459,7 +1522,7 @@ describe("readRAST", function () { const modules = ".gitmodules"; const nextCommit = yield TestUtil.makeCommit(repo, ["a", modules]); const nextSha = nextCommit.id().tostrS(); - yield DeinitUtil.deinit(repo, "a"); + yield SubmoduleConfigUtil.deinit(repo, ["a"]); yield fs.writeFile(path.join(repo.workdir(), modules), @@ -1516,14 +1579,15 @@ describe("readRAST", function () { r, "x", "foo", - null); + null, + false); const ast = yield ReadRepoASTUtil.readRAST(r); const headId = yield r.getHeadCommit(); const commit = headId.id().tostrS(); let commits = {}; commits[commit] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const expected = new RepoAST({ @@ -1562,7 +1626,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, @@ -1574,7 +1639,7 @@ describe("readRAST", function () { }; subCommits[subCommit.id().tostrS()] = new RepoAST.Commit({ changes: { - "README.md": "data", + "README.md": new File("data", false), }, message: "message\n", }); @@ -1583,7 +1648,7 @@ describe("readRAST", function () { const commit = headId.id().tostrS(); let commits = {}; commits[commit] = new Commit({ - changes: { "README.md": ""}, + changes: { "README.md": new File("", false)}, message: "first commit", }); const expected = new RepoAST({ @@ -1622,10 +1687,354 @@ describe("readRAST", function () { yield index.addByPath("foobar"); yield index.write(); yield NodeGit.Stash.save(repo, - repo.defaultSignature(), + yield repo.defaultSignature(), "stash", NodeGit.Stash.FLAGS.INCLUDE_UNTRACKED); yield ReadRepoASTUtil.readRAST(repo); })); + describe("conflicts", function () { + const FILEMODE = NodeGit.TreeEntry.FILEMODE; + const ConflictEntry = ConflictUtil.ConflictEntry; + it("three versions", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const makeEntry = co.wrap(function *(data) { + const id = yield GitUtil.hashObject(repo, data); + return new ConflictEntry(FILEMODE.BLOB, id.tostrS()); + }); + const ancestor = yield makeEntry("xxx"); + const our = yield makeEntry("yyy"); + const their = yield makeEntry("zzz"); + const index = yield repo.index(); + const filename = "README.md"; + const conflict = new ConflictUtil.Conflict(ancestor, our, their); + yield ConflictUtil.addConflict(index, filename, conflict); + yield index.write(); + yield fs.writeFile(path.join(repo.workdir(), filename), + "conflicted"); + const result = yield ReadRepoASTUtil.readRAST(repo); + const simple = yield astFromSimpleRepo(repo); + const expected = simple.copy({ + index: { + "README.md": new RepoAST.Conflict(new File("xxx", false), + new File("yyy", false), + new File("zzz", false)), + }, + workdir: { + "README.md": new File("conflicted", false), + }, + }); + RepoASTUtil.assertEqualASTs(result, expected); + })); + it("with a deletion", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const makeEntry = co.wrap(function *(data) { + const id = yield GitUtil.hashObject(repo, data); + return new ConflictEntry(FILEMODE.BLOB, id.tostrS()); + }); + const ancestor = yield makeEntry("xxx"); + const their = yield makeEntry("zzz"); + const conflict = new ConflictUtil.Conflict(ancestor, null, their); + const index = yield repo.index(); + const filename = "README.md"; + yield ConflictUtil.addConflict(index, filename, conflict); + yield index.write(); + yield fs.writeFile(path.join(repo.workdir(), filename), + "conflicted"); + const result = yield ReadRepoASTUtil.readRAST(repo); + const simple = yield astFromSimpleRepo(repo); + const expected = simple.copy({ + index: { + "README.md": new RepoAST.Conflict(new File("xxx", false), + null, + new File("zzz", false)), + }, + workdir: { + "README.md": new File("conflicted", false), + }, + }); + RepoASTUtil.assertEqualASTs(result, expected); + })); + it("with submodule", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const head = yield repo.getHeadCommit(); + const sha = head.id().tostrS(); + const entry = new ConflictEntry(FILEMODE.COMMIT, sha); + const index = yield repo.index(); + const conflict = new ConflictUtil.Conflict(null, entry, null); + yield ConflictUtil.addConflict(index, "s", conflict); + yield index.write(); + const result = yield ReadRepoASTUtil.readRAST(repo); + const simple = yield astFromSimpleRepo(repo); + const expected = simple.copy({ + index: { + "s": new RepoAST.Conflict(null, + new RepoAST.Submodule("", sha), + null), + }, + }); + RepoASTUtil.assertEqualASTs(result, expected); + })); + it("with submodule and open", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const head = yield repo.getHeadCommit(); + const sha = head.id().tostrS(); + const baseSub = yield TestUtil.createSimpleRepository(); + const subHead = yield baseSub.getHeadCommit(); + const baseHead = yield baseSub.getHeadCommit(); + const baseSha = baseHead.id().tostrS(); + const subAST = yield astFromSimpleRepo(baseSub); + yield addSubmodule(repo, baseSub.workdir(), "foo", baseSha); + const commit = yield TestUtil.makeCommit(repo, + ["foo", ".gitmodules"]); + + const entry = new ConflictEntry(FILEMODE.COMMIT, sha); + const index = yield repo.index(); + const conflict = new ConflictUtil.Conflict(null, entry, null); + yield ConflictUtil.addConflict(index, "foo", conflict); + yield index.write(); + let commits = {}; + commits[sha] = new Commit({ + changes: { "README.md": new File("", false)}, + message: "first commit", + }); + commits[commit.id().tostrS()] = new Commit({ + parents: [sha], + changes: { + "foo": new RepoAST.Submodule(baseSub.workdir(), + subHead.id().tostrS()), + }, + message: "message\n", + }); + const expected = new RepoAST({ + commits: commits, + branches: { + master: new RepoAST.Branch(commit.id().tostrS(), null), + }, + currentBranchName: "master", + head: commit.id().tostrS(), + index: { + "foo": new RepoAST.Conflict(null, + new RepoAST.Submodule("", sha), + null), + }, + openSubmodules: { + "foo": subAST.copy({ + branches: {}, + currentBranchName: null, + remotes: { + origin: new RepoAST.Remote(baseSub.workdir(), { + branches: { + master: baseSha, + } + }), + }, + }), + }, + }); + const actual = yield ReadRepoASTUtil.readRAST(repo); + RepoASTUtil.assertEqualASTs(actual, expected); + })); + }); + it("sparse", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + yield SparseCheckoutUtil.setSparseMode(repo); + const result = yield ReadRepoASTUtil.readRAST(repo); + assert.equal(result.sparse, true); + })); + it("sparse ignores worktree", co.wrap(function *() { + // Unfortunately, NodeGit will view files missing from the worktree as + // modifications. We need to verify that we deal with that. + + const repo = yield TestUtil.createSimpleRepository(); + yield SparseCheckoutUtil.setSparseMode(repo); + yield fs.unlink(path.join(repo.workdir(), "README.md")); + const result = yield ReadRepoASTUtil.readRAST(repo); + assert.equal(result.sparse, true); + assert.deepEqual(result.workdir, {}); + })); + it("workdir exec bit change", co.wrap(function *() { + const r = yield TestUtil.createSimpleRepository(); + + // Make readme executable + yield fs.chmod(path.join(r.workdir(), "README.md"), "755"); + + const ast = yield ReadRepoASTUtil.readRAST(r); + const headId = yield r.getHeadCommit(); + const commit = headId.id().tostrS(); + let commits = {}; + commits[commit] = new Commit({ + changes: { "README.md": new File("", false)}, + message: "first commit", + }); + + const expected = new RepoAST({ + commits: commits, + branches: { "master": new RepoAST.Branch(commit, null), }, + head: commit, + currentBranchName: "master", + workdir: { + "README.md": new File("", true), + }, + }); + RepoASTUtil.assertEqualASTs(ast, expected); + })); + it("new, executable file", co.wrap(function *() { + const r = yield TestUtil.createSimpleRepository(); + + // Make readme executable + const filePath = path.join(r.workdir(), "foo"); + yield fs.writeFile(filePath, "meh"); + yield fs.chmod(filePath, "755"); + + const ast = yield ReadRepoASTUtil.readRAST(r); + const headId = yield r.getHeadCommit(); + const commit = headId.id().tostrS(); + let commits = {}; + commits[commit] = new Commit({ + changes: { "README.md": new File("", false)}, + message: "first commit", + }); + + const expected = new RepoAST({ + commits: commits, + branches: { "master": new RepoAST.Branch(commit, null), }, + head: commit, + currentBranchName: "master", + workdir: { + foo: new File("meh", true), + }, + }); + RepoASTUtil.assertEqualASTs(ast, expected); + })); + it("executable change in index", co.wrap(function *() { + const r = yield TestUtil.createSimpleRepository(); + + // Make readme executable and stage it + yield fs.chmod(path.join(r.workdir(), "README.md"), "755"); + const index = yield r.index(); + yield index.addByPath("README.md"); + + const ast = yield ReadRepoASTUtil.readRAST(r); + const headId = yield r.getHeadCommit(); + const commit = headId.id().tostrS(); + let commits = {}; + commits[commit] = new Commit({ + changes: { "README.md": new File("", false)}, + message: "first commit", + }); + + const expected = new RepoAST({ + commits: commits, + branches: { "master": new RepoAST.Branch(commit, null), }, + head: commit, + currentBranchName: "master", + index: { + "README.md": new File("", true), + }, + }); + RepoASTUtil.assertEqualASTs(ast, expected); + })); + it("new, executable file in index", co.wrap(function *() { + const r = yield TestUtil.createSimpleRepository(); + + // Make readme executable + const filePath = path.join(r.workdir(), "foo"); + yield fs.writeFile(filePath, "meh"); + yield fs.chmod(filePath, "755"); + const index = yield r.index(); + yield index.addByPath("foo"); + + const ast = yield ReadRepoASTUtil.readRAST(r); + const headId = yield r.getHeadCommit(); + const commit = headId.id().tostrS(); + let commits = {}; + commits[commit] = new Commit({ + changes: { "README.md": new File("", false)}, + message: "first commit", + }); + + const expected = new RepoAST({ + commits: commits, + branches: { "master": new RepoAST.Branch(commit, null), }, + head: commit, + currentBranchName: "master", + index: { + foo: new File("meh", true), + }, + }); + RepoASTUtil.assertEqualASTs(ast, expected); + })); + it("executable change in commit", co.wrap(function *() { + const r = yield TestUtil.createSimpleRepository(); + + const headId = yield r.getHeadCommit(); + const commit = headId.id().tostrS(); + + // Make readme executable and stage it + yield fs.chmod(path.join(r.workdir(), "README.md"), "755"); + const index = yield r.index(); + yield index.addByPath("README.md"); + const execCommit = yield TestUtil.makeCommit(r, ["README.md"]); + const execSha = execCommit.id().tostrS(); + + const ast = yield ReadRepoASTUtil.readRAST(r); + + let commits = {}; + commits[commit] = new Commit({ + changes: { "README.md": new File("", false)}, + message: "first commit", + }); + commits[execSha] = new Commit({ + changes: { + "README.md": new File("", true), + }, + parents: [commit], + message: "message\n", + }); + const expected = new RepoAST({ + commits: commits, + branches: { "master": new RepoAST.Branch(execSha, null), }, + head: execSha, + currentBranchName: "master", + }); + RepoASTUtil.assertEqualASTs(ast, expected); + })); + it("executable new file in commit", co.wrap(function *() { + const r = yield TestUtil.createSimpleRepository(); + + const headId = yield r.getHeadCommit(); + const commit = headId.id().tostrS(); + + // Make readme executable and stage it + const filePath = path.join(r.workdir(), "foo"); + yield fs.writeFile(filePath, "meh"); + yield fs.chmod(filePath, "755"); + const index = yield r.index(); + yield index.addByPath("foo"); + const execCommit = yield TestUtil.makeCommit(r, ["foo"]); + const execSha = execCommit.id().tostrS(); + + const ast = yield ReadRepoASTUtil.readRAST(r); + + let commits = {}; + commits[commit] = new Commit({ + changes: { "README.md": new File("", false)}, + message: "first commit", + }); + commits[execSha] = new Commit({ + changes: { + "foo": new File("meh", true), + }, + parents: [commit], + message: "message\n", + }); + const expected = new RepoAST({ + commits: commits, + branches: { "master": new RepoAST.Branch(execSha, null), }, + head: execSha, + currentBranchName: "master", + }); + RepoASTUtil.assertEqualASTs(ast, expected); + })); }); diff --git a/node/test/util/rebase_util.js b/node/test/util/rebase_util.js index b0668030f..f1b7e5a6d 100644 --- a/node/test/util/rebase_util.js +++ b/node/test/util/rebase_util.js @@ -30,428 +30,746 @@ */ "use strict"; -const assert = require("chai").assert; -const co = require("co"); +const assert = require("chai").assert; +const co = require("co"); +const colors = require("colors"); -const RebaseUtil = require("../../lib/util/rebase_util"); -const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); -const SubmoduleUtil = require("../../lib/util/submodule_util"); +const RebaseUtil = require("../../lib/util/rebase_util"); +const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); +const SequencerState = require("../../lib/util/sequencer_state"); +const SequencerStateUtil = require("../../lib/util/sequencer_state_util"); + +const CommitAndRef = SequencerState.CommitAndRef; +const REBASE = SequencerState.TYPE.REBASE; function makeRebaser(operation) { return co.wrap(function *(repos, maps) { const result = yield operation(repos, maps); - // Now build a map from the newly generated commits to the // logical names that will be used in the expected case. - let commitMap = {}; - function addNewCommit(newCommit, oldCommit, suffix) { - const oldLogicalCommit = maps.commitMap[oldCommit]; - commitMap[newCommit] = oldLogicalCommit + suffix; + const commitMap = {}; + RepoASTTestUtil.mapCommits(commitMap, + result.metaCommits, + maps.commitMap, + "M"); + RepoASTTestUtil.mapSubCommits(commitMap, + result.submoduleCommits, + maps.commitMap); + const newCommits = result.newCommits || {}; + for (let path in newCommits) { + commitMap[newCommits[path]] = `N${path}`; } - Object.keys(result.metaCommits).forEach(newCommit => { - addNewCommit(newCommit, - result.metaCommits[newCommit], - "M"); - }); - Object.keys(result.submoduleCommits).forEach(subName => { - const subCommits = result.submoduleCommits[subName]; - Object.keys(subCommits).forEach(newCommit => { - addNewCommit(newCommit, - subCommits[newCommit], - subName); - }); - }); return { commitMap: commitMap, }; }); } -describe("rebase", function () { - describe("rebase", function () { - - // Will append the leter 'M' to any created meta-repo commits, and the - // submodule name to commits created in respective submodules. - - - function rebaser(repoName, commit) { - const rebaseOper = co.wrap(function *(repos, maps) { - assert.property(repos, repoName); - const repo = repos[repoName]; - const reverseCommitMap = maps.reverseCommitMap; - assert.property(reverseCommitMap, commit); - const originalActualCommit = reverseCommitMap[commit]; - const originalCommit = - yield repo.getCommit(originalActualCommit); - - return yield RebaseUtil.rebase(repo, originalCommit); - }); +describe("Rebase", function () { +describe("runRebase", function () { + const cases = { + "end": { + state: "x=S", + seq: new SequencerState({ + type: REBASE, + originalHead: new CommitAndRef("1", null), + target: new CommitAndRef("1", null), + currentCommit: 0, + commits: [], + }), + }, + "one, started detached": { + state: ` +a=B:Cf-1;Cg-1;Bf=f;Bg=g|x=U:C3-2 s=Sa:f;C4-2 s=Sa:g;H=4;Bfoo=3`, + seq: new SequencerState({ + type: REBASE, + originalHead: new CommitAndRef("3", null), + target: new CommitAndRef("4", null), + currentCommit: 0, + commits: ["3"], + }), + expected: "x=E:C3M-4 s=Sa:fs;H=3M;Os Cfs-g f=f!H=fs", + }, + "one, started on a branch": { + state: ` +a=B:Cf-1;Cg-1;Bf=f;Bg=g|x=U:C3-2 s=Sa:f;C4-2 s=Sa:g;H=4;Bfoo=3;Bold=3`, + seq: new SequencerState({ + type: REBASE, + originalHead: new CommitAndRef("3", "refs/heads/foo"), + target: new CommitAndRef("4", null), + currentCommit: 0, + commits: ["3"], + }), + expected: "x=E:C3M-4 s=Sa:fs;*=foo;Bfoo=3M;Os Cfs-g f=f!H=fs", + }, + "sub can be ffwded": { + state: ` +a=B:Cf-1;Bf=f|x=U:C3-2 s=Sa:f;C4-2 t=Sa:f;H=4;Bfoo=3`, + seq: new SequencerState({ + type: REBASE, + originalHead: new CommitAndRef("3", null), + target: new CommitAndRef("4", null), + currentCommit: 0, + commits: ["3"], + }), + expected: "x=E:C3M-4 s=Sa:f;H=3M", + }, + "two commits": { + state: ` +a=B|x=S:C2-1 q=Sa:1;Bmaster=2;Cf-1 s=Sa:1;Cg-1 t=Sa:1;Bf=f;Bg=g`, + seq: new SequencerState({ + type: REBASE, + originalHead: new CommitAndRef("2", "refs/heads/master"), + target: new CommitAndRef("g", null), + currentCommit: 0, + commits: ["f", "g"], + }), + expected: "x=E:CgM-fM t=Sa:1;CfM-2 s=Sa:1;Bmaster=gM", + }, - return makeRebaser(rebaseOper); - } - const cases = { - "trivially nothing to do": { - initial: "x=S", - rebaser: rebaser("x", "1"), - }, - "nothing to do, in past": { - initial: "x=S:C2-1;Bmaster=2", - rebaser: rebaser("x", "1"), - }, - "ffwd": { - initial: "x=S:C2-1;Bfoo=2", - rebaser: rebaser("x", "2"), - expected: "x=E:Bmaster=2", - }, - "simple rebase": { - initial: "x=S:C2-1;C3-1;Bmaster=2;Bfoo=3", - rebaser: rebaser("x", "3"), - expected: "x=S:C2M-3 2=2;C3-1;Bmaster=2M;Bfoo=3", - }, - "rebase two commits": { - initial: "x=S:C2-1;C3-2;C4-1;Bmaster=3;Bfoo=4;Bx=3", - rebaser: rebaser("x", "4"), - expected: "x=E:C3M-2M 3=3;C2M-4 2=2;Bmaster=3M", - }, - "rebase two commits on two": { - initial: "x=S:C2-1;C3-2;C4-1;C5-4;Bmaster=3;Bfoo=5;Bx=3", - rebaser: rebaser("x", "5"), - expected: "x=E:C3M-2M 3=3;C2M-5 2=2;Bmaster=3M", - }, - "up-to-date with sub": { - initial: "a=Aa:Cb-a;Bfoo=b|x=U:C3-2 s=Sa:b;Bmaster=3;Bfoo=2", - rebaser: rebaser("x", "2"), - }, - "ffwd with sub": { - initial: "a=Aa:Cb-a;Bfoo=b|x=U:C3-2 s=Sa:b;Bmaster=2;Bfoo=3", - rebaser: rebaser("x", "3"), - expected: "x=E:Bmaster=3", - }, - "rebase change in closed sub": { - initial: "\ -a=Aa:Cb-a;Cc-a;Bmaster=b;Bfoo=c|\ -x=U:C3-2 s=Sa:b;C4-2 s=Sa:c;Bmaster=3;Bfoo=4;Bother=3", - rebaser: rebaser("x", "4"), - expected: "x=E:C3M-4 s=Sa:bs;Bmaster=3M;Os Cbs-c b=b!H=bs", - }, - "rebase change in sub, sub already open": { - initial: "\ -a=Aa:Cb-a;Cc-a;Bmaster=b;Bfoo=c|\ -x=U:C3-2 s=Sa:b;C4-2 s=Sa:c;Bmaster=3;Bfoo=4;Bother=3;Os H=b", - rebaser: rebaser("x", "4"), - expected: "x=E:C3M-4 s=Sa:bs;Bmaster=3M;Os Cbs-c b=b!H=bs", - }, - "rebase change in sub with two commits": { - initial: "\ -a=Aa:Cb-a;Cc-a;Bmaster=b;Bfoo=c|\ -x=U:C4-2;C5-4 s=Sa:b;C3-2 s=Sa:c;Bmaster=5;Bfoo=5;Bother=3;Os H=b", - rebaser: rebaser("x", "3"), - expected: ` -x=E:C5M-4M s=Sa:bs;C4M-3 4=4;Bmaster=5M;Os Cbs-c b=b!H=bs`, - }, - "rebase change in sub with two intervening commits": { - initial: ` -a=Aa:Cb-a;Cc-a;Cd-c;Bmaster=b;Bfoo=d| -x=U:C4-2;C5-4 s=Sa:c;C6-5;C7-6 s=Sa:d;C3-2 s=Sa:b;Bmaster=7;Bfoo=7;Bother=3`, - rebaser: rebaser("x", "3"), - expected: ` -x=E:C7M-6M s=Sa:ds;C6M-5M 6=6;C5M-4M s=Sa:cs;C4M-3 4=4;Bmaster=7M; + "conflict": { + state: ` +a=B:Ca-1;Cb-1 a=8;Ba=a;Bb=b|x=U:Cf-2 s=Sa:a;Cg-2 s=Sa:b;H=f;Bg=g`, + seq: new SequencerState({ + type: REBASE, + originalHead: new CommitAndRef("f", null), + target: new CommitAndRef("g", null), + currentCommit: 0, + commits: ["g"], + }), + expected: ` +x=E:QR f: g: 0 g;Os Edetached HEAD,b,a!I *a=~*a*8!W a=\ +<<<<<<< HEAD +a +======= +8 +>>>>>>> message +;`, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + const runnerOp = co.wrap(function *(repos, maps) { + const repo = repos.x; + const seq = SequencerStateUtil.mapCommits(c.seq, + maps.reverseCommitMap); + return yield RebaseUtil.runRebase(repo, seq); + }); + const runner = makeRebaser(runnerOp); + it(caseName, co.wrap(function *() { + yield RepoASTTestUtil.testMultiRepoManipulator(c.state, + c.expected, + runner, + c.fails); + })); + }); +}); +describe("rebase", function () { + const cases = { + "already a rebase in progress": { + initial: "x=S:QM 1: 1: 0 1", + onto: "1", + fails: true, + }, + "dirty": { + initial: "a=B|x=U:Os W README.md=2", + onto: "1", + fails: true, + }, + "trivially nothing to do": { + initial: "x=S", + onto: "1", + }, + "nothing to do, in past": { + initial: "x=S:C2-1;Bmaster=2", + onto: "1", + }, + "nothing to do, detached": { + initial: "x=S:C2-1;H=2", + onto: "1", + }, + "ffwd": { + initial: "x=S:C2-1 s=S/a:1;Bfoo=2", + onto: "2", + expected: "x=E:Bmaster=2", + }, + "simple rebase": { + initial: ` +a=B:Cf-1;Cg-1;Bf=f;Bg=g|x=U:C3-2 s=Sa:f;C4-2 s=Sa:g;Bother=4;Bfoo=3;Bmaster=3`, + onto: "4", + expected: "x=E:C3M-4 s=Sa:fs;Bmaster=3M;Os Cfs-g f=f!H=fs", + }, + "two commits": { + initial: ` +a=B|x=S:C2-1 q=Sa:1;Bmaster=g;Cf-1 s=Sa:1;Cg-f t=Sa:1;Bonto=2;Bg=g`, + onto: "2", + expected: "x=E:CgM-fM t=Sa:1;CfM-2 s=Sa:1;Bmaster=gM", + }, + "up-to-date with sub": { + initial: "a=Aa:Cb-a;Bfoo=b|x=U:C3-2 s=Sa:b;Bmaster=3;Bfoo=2", + onto: "2", + }, + "ffwd with sub": { + initial: "a=Aa:Cb-a;Bfoo=b|x=U:C3-2 s=Sa:b;Bmaster=2;Bfoo=3", + onto: "3", + expected: "x=E:Bmaster=3", + }, + "rebase change in closed sub": { + initial: ` +a=B:Ca-1;Cb-a;Cc-a;Bmaster=b;Bfoo=c| +x=U:C3-2 s=Sa:b;C4-2 s=Sa:c;Bmaster=3;Bfoo=4;Bother=3`, + onto: "4", + expected: "x=E:C3M-4 s=Sa:bs;Bmaster=3M;Os Cbs-c b=b!H=bs", + }, + "rebase change in sub, sub already open": { + initial: ` +a=B:Ca-1;Cb-a;Cc-a;Bmaster=b;Bfoo=c| +x=U:C3-2 s=Sa:b;C4-2 s=Sa:c;Bmaster=3;Bfoo=4;Bother=3;Os H=b`, + onto: "4", + expected: "x=E:C3M-4 s=Sa:bs;Bmaster=3M;Os Cbs-c b=b!H=bs", + }, + "rebase change in sub with two commits": { + initial: ` +a=B:Ca-1;Cb-a;Cc-a;Bmaster=b;Bfoo=c| +x=U:C4-2 s=Sa:b;C3-2 s=Sa:c;Bmaster=4;Bfoo=4;Bother=3;Os H=b`, + onto: "3", + expected: ` +x=E:C4M-3 s=Sa:bs;Bmaster=4M;Os Cbs-c b=b!H=bs`, + }, + "rebase change in sub with two intervening commits": { + initial: ` +a=B:Ca-1;Cb-a;Cc-a;Cd-c;Bmaster=b;Bfoo=d| +x=U:C4-2 r=Sa:1;C5-4 s=Sa:c;C6-5 q=Sa:1;C7-6 s=Sa:d;C3-2 s=Sa:b;Bmaster=7; + Bfoo=7;Bother=3`, + onto: "3", + expected: ` +x=E:C7M-6M s=Sa:ds;C6M-5M q=Sa:1;C5M-4M s=Sa:cs;C4M-3 r=Sa:1;Bmaster=7M; Os Cds-cs d=d!Ccs-b c=c!H=ds`, - }, - "rebase change in sub with two intervening commits, open": { - initial: ` -a=Aa:Cb-a;Cc-a;Cd-c;Bmaster=b;Bfoo=d| -x=U:C4-2;C5-4 s=Sa:c;C6-5;C7-6 s=Sa:d;C3-2 s=Sa:b;Bmaster=7;Bfoo=7;Bother=3; + }, + "rebase change in sub with two intervening commits, open": { + initial: ` +a=B:Ca-1;Cb-a;Cc-a;Cd-c;Bmaster=b;Bfoo=d| +x=U:C4-2 t=Sa:a;C5-4 s=Sa:c;C6-5 u=Sa:1;C7-6 s=Sa:d;C3-2 s=Sa:b; + Bmaster=7;Bfoo=7;Bother=3; Os H=d`, - rebaser: rebaser("x", "3"), - expected: ` -x=E:C7M-6M s=Sa:ds;C6M-5M 6=6;C5M-4M s=Sa:cs;C4M-3 4=4;Bmaster=7M; + onto: "3", + expected: ` +x=E:C7M-6M s=Sa:ds;C6M-5M u=Sa:1;C5M-4M s=Sa:cs;C4M-3 t=Sa:a;Bmaster=7M; Os Cds-cs d=d!Ccs-b c=c!H=ds`, - }, - "ffwd, but not sub (should ffwd anyway)": { - initial: "\ + }, + "ffwd, but not sub (should ffwd anyway)": { + initial: "\ a=Aa:Cb-a;Cc-a;Bmaster=b;Bfoo=c|\ x=U:C3-2 s=Sa:b;C4-3 s=Sa:c;Bmaster=3;Bfoo=4;Bother=3", - rebaser: rebaser("x", "4"), - expected: "x=E:Bmaster=4", - }, - "no ffwd, but can ffwd sub": { - initial: "\ -a=Aa:Cb-a;Cc-b;Bmaster=b;Bfoo=c|\ -x=U:C3-2 3=3,s=Sa:b;C4-2 s=Sa:c;Bmaster=3;Bfoo=4;Bother=3;Os", - rebaser: rebaser("x", "4"), - expected: "x=E:C3M-4 3=3;Bmaster=3M;Os H=c", - }, - "ffwd sub 2X": { - initial: ` -a=Aa:Cb-a;Cc-b;Bmaster=b;Bfoo=c| -x=U:Cr-2;C3-2 s=Sa:b;C4-3 s=Sa:c;Bmaster=4;Bother=r;Os;Bfoo=4`, - rebaser: rebaser("x", "r"), - expected: "x=E:C3M-r s=Sa:b;C4M-3M s=Sa:c;Bmaster=4M;Os H=c", - }, - "ffwd-ed sub is closed after rebase": { - initial: "\ -a=Aa:Cb-a;Cc-b;Bmaster=b;Bfoo=c|\ -x=U:C3-2 3=3,s=Sa:b;C4-2 s=Sa:c;Bmaster=3;Bfoo=4;Bother=3", - rebaser: rebaser("x", "4"), - expected: "x=E:C3M-4 3=3;Bmaster=3M", - }, - "rebase two changes in sub": { - initial: "\ -a=Aa:Cb-a;Cc-b;Cd-a;Bmaster=c;Bfoo=d|\ -x=U:C3-2 s=Sa:c;C4-2 s=Sa:d;Bmaster=3;Bfoo=4;Bother=3", - rebaser: rebaser("x", "4"), - expected: "\ -x=E:C3M-4 s=Sa:cs;Bmaster=3M;Os Ccs-bs c=c!Cbs-d b=b!H=cs", - }, - "rebase with ffwd changes in sub and meta": { - initial: "\ -a=B:Bmaster=3;C2-1 s=Sb:q;C3-2 s=Sb:r,rar=wow|\ -b=B:Cq-1;Cr-q;Bmaster=r|\ -x=Ca:Bmaster=2;Os", - rebaser: rebaser("x", "3"), - expected: "x=E:Bmaster=3;Os H=r", - }, - "make sure unchanged repos stay closed": { - initial: "\ -a=B|\ -b=B:Cj-1;Ck-1;Bmaster=j;Bfoo=k|\ -x=S:C2-1 s=Sa:1,t=Sb:1;C3-2 t=Sb:j;C4-2 t=Sb:k;Bmaster=3;Bfoo=4;Bold=3", - rebaser: rebaser("x", "4"), - expected: "\ -x=E:C3M-4 t=Sb:jt;Bmaster=3M;Ot H=jt!Cjt-k j=j", - }, - "make sure unchanged repos stay closed -- onto-only change": { - initial: "\ -a=B|\ -b=B:Cj-1;Ck-1;Bmaster=j;Bfoo=k|\ -x=S:C2-1 s=Sa:1,t=Sb:1;C3-2;C4-2 t=Sb:k;Bmaster=3;Bfoo=4;Bold=3", - rebaser: rebaser("x", "4"), - expected: "\ -x=E:C3M-4 3=3;Bmaster=3M", - }, - "make sure unchanged repos stay closed -- local-only change": { - initial: "\ -a=B|\ -b=B:Cj-1;Ck-1;Bmaster=j;Bfoo=k|\ -x=S:C2-1 s=Sa:1,t=Sb:1;C3-2;C4-2 t=Sb:k;Bmaster=4;Bfoo=3;Bold=4", - rebaser: rebaser("x", "3"), - expected: "\ -x=E:C4M-3 t=Sb:k;Bmaster=4M", - }, - "unchanged repos stay closed -- different onto and local": { - initial: "\ -a=B:Cj-1;Bmaster=j|\ -b=B:Ck-1;Bmaster=k|\ -x=S:C2-1 s=Sa:1,t=Sb:1;C3-2 s=Sa:j;C4-2 t=Sb:k;Bmaster=3;Bfoo=4;Bold=3", - rebaser: rebaser("x", "4"), - expected: "\ -x=E:C3M-4 s=Sa:j;Bmaster=3M", - }, - "maintain submodule branch": { - initial: "\ -a=B:Ca-1;Cb-1;Bx=a;By=b|\ -x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;Bmaster=3;Bfoo=4;Bold=3;Os Bmaster=a!*=master", - rebaser: rebaser("x", "4"), - expected: "\ -x=E:C3M-4 s=Sa:as;Bmaster=3M;Os Bmaster=as!Cas-b a=a!*=master", - }, - "adding subs on both": { - initial: "\ -q=B|r=B|s=B|x=S:C2-1 s=Ss:1;C3-2 q=Sq:1;C4-2 r=Sr:1;Bmaster=3;Bfoo=4;Bold=3", - rebaser: rebaser("x", "4"), - expected: "\ -x=E:C3M-4 q=Sq:1;Bmaster=3M", - }, - "adding subs then changing": { - initial: "\ -q=B|\ -r=B|\ -s=B|\ -x=S:C2-1 s=Ss:1;C3-2 q=Sq:1;C31-3 q=Sr:1;C4-2 r=Sr:1;C41-4 r=Ss:1;\ -Bmaster=31;Bfoo=41;Bold=31", - rebaser: rebaser("x", "41"), - expected: "\ -x=E:C3M-41 q=Sq:1;C31M-3M q=Sr:1;Bmaster=31M", - }, - "open sub ffwd'd": { - initial: ` + onto: "4", + expected: "x=E:Bmaster=4", + }, + "no ffwd, but can ffwd sub": { + initial: ` +a=B:Ca-1;Cb-a;Cc-b;Bmaster=b;Bfoo=c| +x=U:C3-2 u=Sa:a,s=Sa:b;C4-2 s=Sa:c;Bmaster=3;Bfoo=4;Bother=3;Os`, + onto: "4", + expected: "x=E:C3M-4 u=Sa:a;Bmaster=3M;Os H=c", + }, + "ffwd sub 2X": { + initial: ` +a=B:Ca-1;Cb-a;Cc-b;Bmaster=b;Bfoo=c| +x=U:Cr-2 r=Sa:1;C3-2 s=Sa:b;C4-3 s=Sa:c;Bmaster=4;Bother=r;Os;Bfoo=4`, + onto: "r", + expected: "x=E:C3M-r s=Sa:b;C4M-3M s=Sa:c;Bmaster=4M;Os H=c", + }, + "up-to-date sub is closed after rebase": { + initial: ` +a=B:Ca-1;Cb-a;Cc-b;Bmaster=b;Bfoo=c| +x=U:C3-2 u=Sa:1,s=Sa:b;C4-2 s=Sa:c;Bmaster=3;Bfoo=4;Bother=3`, + onto: "4", + expected: "x=E:C3M-4 u=Sa:1;Bmaster=3M", + }, + "rebase two changes in sub": { + initial: ` +a=B:Ca-1;Cb-a;Cc-b;Cd-a;Bmaster=c;Bfoo=d| +x=U:C3-2 s=Sa:c;C4-2 s=Sa:d;Bmaster=3;Bfoo=4;Bother=3`, + onto: "4", + expected: ` +x=E:C3M-4 s=Sa:cs;Bmaster=3M;Os Ccs-bs c=c!Cbs-d b=b`, + }, + "rebase with ffwd changes in sub and meta": { + initial: ` +a=B:Bmaster=3;C2-1 s=Sb:q;C3-2 s=Sb:r| +b=B:Cq-1;Cr-q;Bmaster=r| +x=Ca:Bmaster=2;Os`, + onto: "3", + expected: "x=E:Bmaster=3;Os H=r", + }, + "make sure unchanged repos stay closed": { + initial: ` +a=B| +b=B:Cj-1;Ck-1;Bmaster=j;Bfoo=k| +x=S:C2-1 s=Sa:1,t=Sb:1;C3-2 t=Sb:j;C4-2 t=Sb:k;Bmaster=3;Bfoo=4;Bold=3`, + onto: "4", + expected: ` +x=E:C3M-4 t=Sb:jt;Bmaster=3M;Ot H=jt!Cjt-k j=j`, + }, + "make sure unchanged repos stay closed -- onto-only change": { + initial: ` +a=B| +b=B:Cj-1;Ck-1;Bmaster=j;Bfoo=k| +x=S:C2-1 s=Sa:1,t=Sb:1;C3-2 q=Sa:k;C4-2 t=Sb:k;Bmaster=3;Bfoo=4;Bold=3`, + onto: "4", + expected: ` +x=E:C3M-4 q=Sa:k;Bmaster=3M`, + }, + "make sure unchanged repos stay closed -- local-only change": { + initial: ` +a=B| +b=B:Cj-1;Ck-1;Bmaster=j;Bfoo=k| +x=S:C2-1 s=Sa:1,t=Sb:1;C3-2 z=Sa:1;C4-2 t=Sb:k;Bmaster=4;Bfoo=3;Bold=4`, + onto: "3", + expected: ` +x=E:C4M-3 t=Sb:k;Bmaster=4M`, + }, + "unchanged repos stay closed -- different onto and local": { + initial: ` +a=B:Cj-1;Bmaster=j| +b=B:Ck-1;Bmaster=k| +x=S:C2-1 s=Sa:1,t=Sb:1;C3-2 s=Sa:j;C4-2 t=Sb:k;Bmaster=3;Bfoo=4;Bold=3`, + onto: "4", + expected: ` +x=E:C3M-4 s=Sa:j;Bmaster=3M`, + }, + "adding subs on both": { + initial: ` +q=B|r=B|s=B|x=S:C2-1 s=Ss:1;C3-2 q=Sq:1;C4-2 r=Sr:1;Bmaster=3;Bfoo=4;Bold=3`, + onto: "4", + expected: ` +x=E:C3M-4 q=Sq:1;Bmaster=3M`, + }, + "open sub ffwd'd": { + initial: ` a=B:CX-1;Bmaster=X| -x=U:C3-2 a=b;C4-2 s=Sa:X;Bmaster=3;Bfoo=4;Bold=3;Os`, - rebaser: rebaser("x", "4"), - expected: ` -x=E:C3M-4 a=b;Bmaster=3M;Os H=X`, - }, - }; - Object.keys(cases).forEach(caseName => { - const c = cases[caseName]; - it(caseName, co.wrap(function *() { - yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, - c.expected, - c.rebaser, - c.fails); - })); - }); - it("conflict stays open", co.wrap(function *() { - const input = ` +x=U:C3-2 a=Sa:1;C4-2 s=Sa:X;Bmaster=3;Bfoo=4;Bold=3;Os`, + onto: "4", + expected: ` +x=E:C3M-4 a=Sa:1;Bmaster=3M;Os H=X`, + }, + "conflict": { + initial: ` a=B:Ca-1 t=t;Cb-1 t=u;Ba=a;Bb=b| -x=U:C31-2 s=Sa:a;C41-2 s=Sa:b;Bmaster=31;Bfoo=41;Bold=31`; - const w = yield RepoASTTestUtil.createMultiRepos(input); - const repo = w.repos.x; - const reverseCommitMap = w.reverseCommitMap; - const originalActualCommit = reverseCommitMap["41"]; - const originalCommit = yield repo.getCommit(originalActualCommit); - let threw = false; - try { - yield RebaseUtil.rebase(repo, originalCommit); - } - catch (e) { - threw = true; +x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;Bmaster=3;Bfoo=4;Bold=3`, + onto: "4", + expected: ` +x=E:H=4;QR 3:refs/heads/master 4: 0 3; + Os Edetached HEAD,a,b!I *t=~*u*t!W t=\ +<<<<<<< HEAD +u +======= +t +>>>>>>> message +;`, + errorMessage: `\ +Submodule ${colors.red("s")} is conflicted. + A rebase is in progress. + (after resolving conflicts mark the corrected paths + with 'git meta add', then run "git meta rebase --continue") + (use "git meta rebase --abort" to check out the original branch)`, + }, + "does not close open submodules when rewinding": { + initial: ` +a=B|x=S:C2-1 s=Sa:1;Bmaster=2;Os;C3-1 t=Sa:1;Bfoo=3;Bold=2`, + onto: "3", + expected: `x=E:C2M-3 s=Sa:1;Bmaster=2M` + }, +// TODO: I could not get libgit2 to remember or restore submodule branches when +// used in the three-way mode; it stores it in "onto_name", not in "head-name". +// "maintain submodule branch": { +// initial: ` +//a=B:Ca-1;Cb-1;Bx=a;By=b| +//x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;Bmaster=3;Bfoo=4;Bold=3;Os Bmaster=a!*=master`, +// onto: "4", +// expected: ` +//x=E:C3M-4 s=Sa:as;Bmaster=3M;Os Bmaster=as!Cas-b a=a!*=master`, +// }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + // Will append the leter 'M' to any created meta-repo commits, and the + // submodule name to commits created in respective submodules. + + const rebaseOp = co.wrap(function *(repos, maps) { + const repo = repos.x; + const reverseCommitMap = maps.reverseCommitMap; + const onto = yield repo.getCommit(reverseCommitMap[c.onto]); + const errorMessage = c.errorMessage || null; + const result = yield RebaseUtil.rebase(repo, onto); + if (null !== result.errorMessage) { + assert.isString(result.errorMessage); } - assert(threw, "should have thrown"); - const open = yield SubmoduleUtil.isVisible(repo, "s"); - assert(open, "should be open"); + assert.equal(result.errorMessage, errorMessage); + return result; + }); + const rebase = makeRebaser(rebaseOp); + it(caseName, co.wrap(function *() { + yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, + c.expected, + rebase, + c.fails); })); }); +}); - describe("abort", function () { - const cases = { - "simple, see if workdir is cleaned up": { - initial: ` -x=S:C2-1 x=y;C3-1 x=z;Bmaster=2;Bfoo=3;Erefs/heads/master,2,3;W x=q`, - expected: `x=E:E;W x=~`, - }, - "with rebase in submodule": { - initial: ` -a=B:Cq-1;Cr-1;Bmaster=q;Bfoo=r| -x=U:C3-2 s=Sa:q;C4-2 s=Sa:r; - Bmaster=3;Bfoo=4; - Erefs/heads/master,3,4; - Os Erefs/heads/foo,q,r!Bfoo=q!*=foo`, - expected: `x=E:E;Os Bfoo=q!*=foo`, - }, - }; - Object.keys(cases).forEach(caseName => { - const c = cases[caseName]; - it(caseName, co.wrap(function *() { - const aborter = co.wrap(function *(repos) { - yield RebaseUtil.abort(repos.x); - }); - yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, - c.expected, - aborter, - c.fails); - })); - }); +describe("abort", function () { + const cases = { + "no sequencer, fails": { + initial: "x=S", + fails: true, + }, + "sequencer not a rebase, fails": { + initial: "x=S:QM 1: 1: 0 1", + fails: true, + }, + "see if head is reset": { + initial: ` +a=B:Ca-1;Cb-1;Ba=a;Bb=b| +x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;H=4;Bmaster=3;Bfoo=4;QR 3: 4: 0 2`, + expected: `x=E:H=3;Q` + }, + "check that branch is restored": { + initial: ` +a=B:Ca-1;Cb-1;Ba=a;Bb=b| +x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;H=4;Bmaster=3;Bfoo=4; + QR 3:refs/heads/master 4: 0 2`, + expected: `x=E:*=master;Q` + }, + "submodule wd cleaned up": { + initial: ` +a=B:Ca-1;Cb-1;Ba=a;Bb=b| +x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;H=4;Bmaster=3;Bfoo=4; + QR 3: 4: 0 2; + Os W README.md=88`, + expected: `x=E:H=3;Q;Os H=a` + }, + "submodule rebase cleaned up": { + initial: ` +a=B:Ca-1;Cb-1;Ba=a;Bb=b| +x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;H=4;Bmaster=3;Bfoo=4; + QR 3: 4: 0 2; + Os Edetached HEAD,a,b`, + expected: `x=E:H=3;Q;Os H=a` + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const aborter = co.wrap(function *(repos) { + yield RebaseUtil.abort(repos.x); + }); + yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, + c.expected, + aborter, + c.fails); + })); }); - - describe("continue", function () { - const cases = { - "meta-only": { - initial: ` -x=S:C2-1 q=r;C3-1 q=s;Bmaster=2;Erefs/heads/master,2,3;I q=z`, - expected: ` -x=S:C2M-3 q=z;Bmaster=2M;E`, - }, - "two, meta-only": { - initial: ` -x=S:C2-1;C3-1;C4-3;Bmaster=4;Erefs/heads/master,4,2;I qq=hh,3=3`, - expected: ` -x=S:C4M-3M 4=4;C3M-2 3=3,qq=hh;Bmaster=4M;E`, - }, - "meta, has to open": { - initial: ` -a=B:Ca-1;Cb-1;Bmaster=a;Bfoo=b| -x=U:C3-2 s=Sa:a; - C4-2;C5-4 s=Sa:b; - Bmaster=5;Bfoo=5; - I 4=4; - Erefs/heads/master,5,3`, - expected: ` -x=E:C5M-4M s=Sa:bs;C4M-3 4=4;Bmaster=5M;E;Os Cbs-a b=b!H=bs;I 4=~`, - }, - "with rebase in submodule": { - initial: ` +}); +describe("continue", function () { + const cases = { + "no sequencer, fails": { + initial: "x=S", + fails: true, + }, + "sequencer not a rebase, fails": { + initial: "x=S:QM 1: 1: 0 1", + fails: true, + }, + "conflict fails": { + initial: ` +a=B:Ca-1;Cb-1;Ba=a;Bb=b| +x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;H=4;Bmaster=3;Bfoo=4; + QR 3: 4: 0 2; + Os I *a=1*2*3!W a=2`, + fails: true, + }, + "continue finishes with new commit": { + 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;H=4;Bold=3; + QR 3:refs/heads/master 4: 0 3; + Os I foo=bar`, + expected: ` +x=E:C3M-4 s=Sa:Ns;Bmaster=3M;*=master;Q;Os CNs-b foo=bar!H=Ns;`, + }, + "continue makes a new conflict with current op": { + initial: ` +a=B:Ca-1;Cb-a c=d;Cc-1;Bb=b;Bc=c| +x=U:C3-2 s=Sa:b;C4-2 s=Sa:c;Bmaster=3;Bfoo=4;H=4; + QR 3: 4: 0 3; + Os I foo=bar!Edetached HEAD,b,c!H=c!Bb=b!Bc=c`, + errorMessage: `\ +Submodule ${colors.red("s")} is conflicted. +`, + expected: ` +x=E:Os Cas-c foo=bar!H=as!Edetached HEAD,b,c!Bb=b!Bc=c!I *c=~*c*d! + W c=^<<<<<`, + }, + "with rebase in submodule": { + initial: ` a=B:Cq-1;Cr-1;Bmaster=q;Bfoo=r| x=U:C3-2 s=Sa:q;C4-2 s=Sa:r; - Bmaster=3;Bfoo=4;Bold=3; - Erefs/heads/master,3,4; + Bmaster=4;Bfoo=4;Bold=3; + QR 3:refs/heads/master 4: 0 3; Os EHEAD,q,r!I q=q!Bq=q!Br=r`, - expected: ` -x=E:E;C3M-4 s=Sa:qs;Bmaster=3M;Os Cqs-r q=q!H=qs!E!Bq=q!Br=r` - }, - "with rebase in submodule, other open subs": { - initial: ` + expected: ` +x=E:Q;C3M-4 s=Sa:qs;Bmaster=3M;Os Cqs-r q=q!H=qs!E!Bq=q!Br=r` + }, + "with rebase in submodule, other open subs": { + initial: ` a=B:Cq-1;Cr-1;Bmaster=q;Bfoo=r| x=S:C2-1 a=Sa:1,s=Sa:1,z=Sa:1;C3-2 s=Sa:q;C4-2 s=Sa:r; - Bmaster=3;Bfoo=4;Bold=3; - Erefs/heads/master,3,4; + Bmaster=4;Bfoo=4;Bold=3; + QR 3:refs/heads/master 4: 0 3; Oa;Oz; Os EHEAD,q,r!I q=q!Bq=q!Br=r`, - expected: ` -x=E:E;C3M-4 s=Sa:qs;Bmaster=3M;Os Cqs-r q=q!H=qs!E!Bq=q!Br=r;Oa;Oz` - }, - "with rebase in submodule, staged commit in another submodule": { - initial: ` + expected: ` +x=E:Q;C3M-4 s=Sa:qs;Bmaster=3M;Os Cqs-r q=q!H=qs!E!Bq=q!Br=r;Oa;Oz` + }, + "with rebase in submodule, staged commit in another submodule": { + initial: ` a=B:Cq-1;Cr-1;Cs-q;Bmaster=q;Bfoo=r;Bbar=s| x=S:C2-1 s=Sa:1,t=Sa:1;C3-2 s=Sa:q,t=Sa:s;C4-2 s=Sa:r,t=Sa:q; - Bmaster=3;Bfoo=4;Bold=3; - Erefs/heads/master,3,4; + Bmaster=4;Bfoo=4;Bold=3; + QR 3:refs/heads/master 4: 0 3; Os EHEAD,q,r!I q=q!Bq=q!Br=r; Ot H=s; I t=Sa:s`, - expected: ` -x=E:E;C3M-4 s=Sa:qs,t=Sa:s;Bmaster=3M; + expected: ` +x=E:Q;C3M-4 s=Sa:qs,t=Sa:s;Bmaster=3M; Os Cqs-r q=q!H=qs!E!Bq=q!Br=r;I t=~;Ot` - }, - "with rebase in submodule, workdir commit in another submodule": { - initial: ` + }, + "with rebase in submodule, workdir commit in another submodule": { + initial: ` a=B:Cq-1;Cr-1;Cs-q;Bmaster=q;Bfoo=r;Bbar=s| x=S:C2-1 s=Sa:1,t=Sa:1;C3-2 s=Sa:q,t=Sa:s;C4-2 s=Sa:r,t=Sa:q; - Bmaster=3;Bfoo=4;Bold=3; - Erefs/heads/master,3,4; + Bmaster=4;Bfoo=4;Bold=3; + QR 3:refs/heads/master 4: 0 3; Os EHEAD,q,r!I q=q!Bq=q!Br=r; Ot H=s`, - expected: ` -x=E:E;C3M-4 s=Sa:qs,t=Sa:s;Bmaster=3M; + expected: ` +x=E:Q;C3M-4 s=Sa:qs,t=Sa:s;Bmaster=3M; Os Cqs-r q=q!H=qs!E!Bq=q!Br=r; Ot H=s` - }, - "staged fix in submodule": { - initial: ` + }, + "staged fix in submodule": { + initial: ` a=B:Ca-1 q=r;Cb-1 q=s;Bmaster=a;Bfoo=b| -x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;Bmaster=3;Erefs/heads/master,3,4;Bold=3; +x=U:C3-2 s=Sa:a;C4-2 s=Sa:b;Bmaster=4;QR 3:refs/heads/master 4: 0 3;Bold=3; Os EHEAD,a,b!I q=z!Ba=a!Bb=b`, - expected: ` -x=E:C3M-4 s=Sa:as;E;Bmaster=3M;Os Cas-b q=z!H=as!Ba=a!Bb=b`, - }, - "multiple in subs": { - initial: ` + expected: ` +x=E:C3M-4 s=Sa:as;Q;Bmaster=3M;Os Cas-b q=z!H=as!Ba=a!Bb=b`, + }, + "multiple in subs": { + initial: ` a=B:Ca1-1 f=g;Ca2-1 f=h;Bmaster=a1;Bfoo=a2| b=B:Cb1-1 q=r;Cb2-1 q=s;Bmaster=b1;Bfoo=b2| x=S:C2-1 s=Sa:1,t=Sb:1;C3-2 s=Sa:a1,t=Sb:b1;C4-2 s=Sa:a2,t=Sb:b2; - Bmaster=3;Bfoo=4;Bold=3; - Erefs/heads/master,3,4; + Bmaster=4;Bfoo=4;Bold=3; + QR 3:refs/heads/master 4: 0 3; Os EHEAD,a1,a2!I f=z!Ba1=a1!Ba2=a2; Ot EHEAD,b1,b2!I q=t!Bb1=b1!Bb2=b2`, - expected: ` -x=E:C3M-4 s=Sa:a1s,t=Sb:b1t;E;Bmaster=3M; + expected: ` +x=E:C3M-4 s=Sa:a1s,t=Sb:b1t;Q;Bmaster=3M; Os Ca1s-a2 f=z!H=a1s!Ba1=a1!Ba2=a2; Ot Cb1t-b2 q=t!H=b1t!Bb1=b1!Bb2=b2` + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const continuer = makeRebaser(co.wrap(function *(repos) { + const errorMessage = c.errorMessage || null; + const result = yield RebaseUtil.continue(repos.x); + if (null !== result.errorMessage) { + assert.isString(result.errorMessage); + } + assert.equal(result.errorMessage, errorMessage); + return result; + })); + yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, + c.expected, + continuer, + c.fails); + })); + }); + + describe("listRebaseCommits", function () { + const cases = { + "same commit": { + input: "S", + from: "1", + onto: "1", + expected: [], + }, + "ancestor": { + input: "S:C2-1;Bfoo=2", + from: "1", + onto: "2", + expected: [], + }, + "descendant": { + input: "S:C2-1;Bfoo=2", + from: "2", + onto: "1", + expected: ["2"], + }, + "descendants": { + input: "S:C3-2;C2-1;Bfoo=3", + from: "3", + onto: "1", + expected: ["2", "3"], + }, + "merge of base": { + input: "S:C2-1;Bmaster=2;C4-3,1;C3-1;Bfoo=4", + from: "4", + onto: "2", + expected: ["3"], + }, + "non FFWD": { + input: "S:Cf-1;Co-1;Bf=f;Bo=o", + from: "f", + onto: "o", + expected: ["f"], + }, + "left-to-right": { + input: "S:Co-1;Cf-b,a;Ca-1;Cb-1;Bf=f;Bo=o", + from: "f", + onto: "o", + expected: ["b", "a"], + }, + "left-to-right and deep first": { + input: "S:Co-1;Cf-b,a;Ca-1;Cb-c;Cc-1;Bf=f;Bo=o", + from: "f", + onto: "o", + expected: ["c", "b", "a"], + }, + "double deep": { + input: "S:Co-1;Cf-b,a;Ca-1;Cb-c,d;Cc-1;Cd-1;Bf=f;Bo=o", + from: "f", + onto: "o", + expected: ["c", "d", "a"], + }, + "and deep on the right": { + input: "S:Co-1;Cf-b,a;Ca-q,r;Cq-1;Cr-1;Cb-1;Bf=f;Bo=o", + from: "f", + onto: "o", + expected: ["b", "q", "r"], + }, + "new commit in history more than once": { + input: "S:Co-1;Cf-r,a;Ca-q,r;Cq-1;Cr-1;Bf=f;Bo=o", + from: "f", + onto: "o", + expected: ["r", "q"], }, }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; it(caseName, co.wrap(function *() { - const continuer = makeRebaser(co.wrap(function *(repos) { - return yield RebaseUtil.continue(repos.x); - })); - yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, - c.expected, - continuer, - c.fails); + const written = yield RepoASTTestUtil.createRepo(c.input); + const repo = written.repo; + const old = written.oldCommitMap; + const from = yield repo.getCommit(old[c.from]); + const onto = yield repo.getCommit(old[c.onto]); + const result = yield RebaseUtil.listRebaseCommits(repo, + from, + onto); + const commits = result.map(sha => written.commitMap[sha]); + assert.deepEqual(commits, c.expected); })); }); }); }); + +describe("listRebaseCommits", function () { + const cases = { + "same commit": { + input: "S", + from: "1", + onto: "1", + expected: [], + }, + "ancestor": { + input: "S:C2-1;Bfoo=2", + from: "1", + onto: "2", + expected: [], + }, + "descendant": { + input: "S:C2-1;Bfoo=2", + from: "2", + onto: "1", + expected: ["2"], + }, + "descendants": { + input: "S:C3-2;C2-1;Bfoo=3", + from: "3", + onto: "1", + expected: ["2", "3"], + }, + "merge of base": { + input: "S:C2-1;Bmaster=2;C4-3,1;C3-1;Bfoo=4", + from: "4", + onto: "2", + expected: ["3"], + }, + "non FFWD": { + input: "S:Cf-1;Co-1;Bf=f;Bo=o", + from: "f", + onto: "o", + expected: ["f"], + }, + "left-to-right": { + input: "S:Co-1;Cf-b,a;Ca-1;Cb-1;Bf=f;Bo=o", + from: "f", + onto: "o", + expected: ["b", "a"], + }, + "left-to-right and deep first": { + input: "S:Co-1;Cf-b,a;Ca-1;Cb-c;Cc-1;Bf=f;Bo=o", + from: "f", + onto: "o", + expected: ["c", "b", "a"], + }, + "double deep": { + input: "S:Co-1;Cf-b,a;Ca-1;Cb-c,d;Cc-1;Cd-1;Bf=f;Bo=o", + from: "f", + onto: "o", + expected: ["c", "d", "a"], + }, + "and deep on the right": { + input: "S:Co-1;Cf-b,a;Ca-q,r;Cq-1;Cr-1;Cb-1;Bf=f;Bo=o", + from: "f", + onto: "o", + expected: ["b", "q", "r"], + }, + "new commit in history more than once": { + input: "S:Co-1;Cf-r,a;Ca-q,r;Cq-1;Cr-1;Bf=f;Bo=o", + from: "f", + onto: "o", + expected: ["r", "q"], + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo(c.input); + const repo = written.repo; + const old = written.oldCommitMap; + const from = yield repo.getCommit(old[c.from]); + const onto = yield repo.getCommit(old[c.onto]); + const result = yield RebaseUtil.listRebaseCommits(repo, + from, + onto); + const commits = result.map(sha => written.commitMap[sha]); + assert.deepEqual(commits, c.expected); + })); + }); +}); +}); diff --git a/node/test/util/repo_ast.js b/node/test/util/repo_ast.js index 1792e177b..5cd7d9c9a 100644 --- a/node/test/util/repo_ast.js +++ b/node/test/util/repo_ast.js @@ -35,6 +35,10 @@ const assert = require("chai").assert; const RepoAST = require("../../lib/util/repo_ast"); describe("RepoAST", function () { +const File = RepoAST.File; +const SequencerState = RepoAST.SequencerState; +const CommitAndRef = SequencerState.CommitAndRef; +const REBASE = SequencerState.TYPE.REBASE; describe("Branch", function () { it("breath", function () { @@ -49,6 +53,14 @@ describe("RepoAST", function () { }); }); + describe("File", function () { + it("breath", function () { + const f = new RepoAST.File("foo", true); + assert.equal(f.contents, "foo"); + assert.equal(f.isExecutable, true); + }); + }); + describe("Submodule", function () { it("breath", function () { const s = new RepoAST.Submodule("foo", "bar"); @@ -61,6 +73,93 @@ describe("RepoAST", function () { const s = new RepoAST.Submodule("foo", null); assert.isNull(s.sha); }); + it("equal", function () { + const Submodule = RepoAST.Submodule; + const cases = { + "same": { + lhs: new Submodule("foo", "bar"), + rhs: new Submodule("foo", "bar"), + expected: true, + }, + "diff url": { + lhs: new Submodule("boo", "bar"), + rhs: new Submodule("foo", "bar"), + expected: false, + }, + "diff sha": { + lhs: new Submodule("foo", "bar"), + rhs: new Submodule("foo", "baz"), + expected: false, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + assert.equal(c.lhs.equal(c.rhs), c.expected); + }); + }); + }); + + describe("Conflict", function () { + it("breath", function () { + const c = new RepoAST.Conflict(new File("foo", false), + new File("bar", true), + new File("baz", false)); + assert.equal(c.ancestor.contents, "foo"); + assert.equal(c.our.contents, "bar"); + assert.equal(c.their.contents, "baz"); + }); + it("nulls", function () { + const c = new RepoAST.Conflict(null, null, null); + assert.equal(c.ancestor, null); + assert.equal(c.our, null); + assert.equal(c.their, null); + }); + it("subs", function () { + const s0 = new RepoAST.Submodule("foo", null); + const s1 = new RepoAST.Submodule("bar", null); + const s2 = new RepoAST.Submodule("baz", null); + const c = new RepoAST.Conflict(s0, s1, s2); + assert.deepEqual(c.ancestor, s0); + assert.deepEqual(c.our, s1); + assert.deepEqual(c.their, s2); + }); + it("equal", function () { + const Conflict = RepoAST.Conflict; + const foo = new File("foo", false); + const bam = new File("bam", false); + const bar = new File("bar", false); + const baz = new File("baz", false); + const food = new File("food", true); + const bark = new File("bark", false); + const cases = { + "same": { + lhs: new Conflict(foo, bar, baz), + rhs: new Conflict(new File("foo", false), + new File("bar", false), + new File("baz", false)), + expected: true, + }, + "diff ancestor": { + lhs: new Conflict(foo, bar, baz), + rhs: new Conflict(food, bar, baz), + expected: false, + }, + "diff ours": { + lhs: new Conflict(foo, bar, baz), + rhs: new Conflict(foo, bark, baz), + expected: false, + }, + "diff theirs": { + lhs: new Conflict(foo, bar, baz), + rhs: new Conflict(foo, bar, bam), + expected: false, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + assert.equal(c.lhs.equal(c.rhs), c.expected); + }); + }); }); describe("Commit", function () { @@ -77,11 +176,11 @@ describe("RepoAST", function () { "simple": { input: { parents: ["foo"], - changes: { a: "b" }, + changes: { a: new File("b", true) }, message: "bam", }, eparents: ["foo"], - echanges: { a: "b"}, + echanges: { a: new File("b", true) }, emessage: "bam", }, "delete change": { @@ -192,12 +291,20 @@ describe("RepoAST", function () { eworkdir: ("workdir" in expected) ? expected.workdir : {}, eopenSubmodules: ("openSubmodules" in expected) ? expected.openSubmodules : {}, - erebase: ("rebase" in expected) ? - expected.rebase : null, + erebase: ("rebase" in expected) ? expected.rebase : null, + esequencerState: ("sequencerState" in expected) ? + expected.sequencerState : null, + esparse : ("sparse" in expected) ? + expected.sparse : false, fails : fails, }; } + const barFile = new File("bar", true); + const yFile = new File("y", true); + const aConflict = new RepoAST.Conflict(new File("foo", false), + new File("bar", true), + new File("baz", false)); const cases = { "trivial": m(undefined, undefined, false), "simple" : m( @@ -207,20 +314,22 @@ describe("RepoAST", function () { head: null, currentBranchName: null, rebase: null, + sequencerState: null, bare: false, + sparse: false, }, undefined, false), "with bare": m({ bare: true }, { bare: true} , false), "bad bare with index": m({ bare: true, - index: { foo: "bar" }, + index: { foo: barFile }, commits: {"1": c1 }, head: "1", }, undefined, true), "bad bare with workdir": m({ bare: true, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, commits: {"1": c1 }, head: "1", }, undefined, true), @@ -230,6 +339,18 @@ describe("RepoAST", function () { commits: {"1": c1 }, head: "1", }, undefined, true), + "bad bare with sequencer": m({ + bare: true, + sequencerState: new SequencerState({ + type: REBASE, + originalHead: new CommitAndRef("1", null), + target: new CommitAndRef("1", null), + commits: ["1"], + currentCommit: 0, + }), + commits: {"1": c1 }, + head: "1", + }, undefined, true), "branchCommit": m({ commits: {"1":c1, "2": cWithPar}, branches: {"master": new RepoAST.Branch("2", null) }, @@ -349,20 +470,20 @@ describe("RepoAST", function () { "index": m({ commits: { "1": c1}, head: "1", - index: { foo: "bar"}, + index: { foo: barFile }, }, { commits: { "1": c1}, head: "1", - index: { foo: "bar"}, + index: { foo: barFile }, }, false), "index without head": m({ commits: { "1": c1}, head: null, - index: { foo: "bar"}, + index: { foo: barFile }, }, { commits: { "1": c1}, head: "1", - index: { foo: "bar"}, + index: { foo: barFile}, }, true), "index with submodule": m({ commits: { "1": c1}, @@ -373,23 +494,34 @@ describe("RepoAST", function () { head: "1", index: { foo: new RepoAST.Submodule("z", "a") }, }, false), + "index with conflict": m({ + commits: { "1": c1}, + head: "1", + index: { foo: aConflict }, + workdir: { foo: barFile }, + }, { + commits: { "1": c1}, + head: "1", + index: { foo: aConflict }, + workdir: { foo: barFile }, + }, false), "workdir": m({ commits: { "1": c1}, head: "1", - workdir: { foo: "bar"}, + workdir: { foo: barFile }, }, { commits: { "1": c1}, head: "1", - workdir: { foo: "bar"}, + workdir: { foo: barFile }, }, false), "workdir without head": m({ commits: { "1": c1}, head: null, - workdir: { foo: "bar"}, + workdir: { foo: barFile }, }, { commits: { "1": c1}, head: "1", - workdir: { foo: "bar"}, + workdir: { foo: barFile }, }, true), "openSubmodules": m({ commits: { "1": cWithSubmodule }, @@ -408,45 +540,45 @@ describe("RepoAST", function () { }, true), "bad commit change": m({ commits: { - "1": new Commit({ changes: { x: "y"}}), + "1": new Commit({ changes: { x: yFile }}), "2": new Commit({ parents: ["1"], - changes: { x: "y"}, + changes: { x: yFile }, }), }, head: "2", }, {}, true), "bad commit change from ancestor": m({ commits: { - "1": new Commit({ changes: { x: "y"}}), + "1": new Commit({ changes: { x: yFile }}), "2": new Commit({ parents: ["1"], - changes: { y: "y"}, + changes: { y: yFile }, }), "3": new Commit({ parents: ["2"], - changes: { x: "y"}, + changes: { x: yFile }, }), }, head: "2", }, {}, true), "ok commit duplicting right-hand ancestory": m({ commits: { - "1": new Commit({ changes: { x: "y"}}), - "2": new Commit({ changes: { y: "y"}, }), + "1": new Commit({ changes: { x: yFile }}), + "2": new Commit({ changes: { y: yFile }, }), "3": new Commit({ parents: ["1","2"], - changes: { y: "y"}, + changes: { y: yFile }, }), }, head: "3", }, { commits: { - "1": new Commit({ changes: { x: "y"}}), - "2": new Commit({ changes: { y: "y"}, }), + "1": new Commit({ changes: { x: yFile }}), + "2": new Commit({ changes: { y: yFile }, }), "3": new Commit({ parents: ["1","2"], - changes: { y: "y"}, + changes: { y: yFile }, }), }, head: "3", @@ -488,6 +620,74 @@ describe("RepoAST", function () { "bad rebase": m({ rebase: new Rebase("fff", "1", "1"), }, undefined, true), + "with sequencer state": m({ + commits: { + "1": new Commit(), + "2": new Commit(), + "3": new Commit(), + }, + head: "1", + sequencerState: new SequencerState({ + type: REBASE, + originalHead: new CommitAndRef("3", null), + target: new CommitAndRef("3", null), + commits: ["2", "1"], + currentCommit: 1, + }), + }, { + commits: { + "1": new Commit(), + "2": new Commit(), + "3": new Commit(), + }, + head: "1", + sequencerState: new SequencerState({ + type: REBASE, + originalHead: new CommitAndRef("3", null), + target: new CommitAndRef("3", null), + commits: ["2", "1"], + currentCommit: 1, + }), + }), + "with sequencer specific commits": m({ + commits: { + "1": new Commit(), + "2": new Commit(), + "3": new Commit(), + }, + head: "1", + sequencerState: new SequencerState({ + type: REBASE, + originalHead: new CommitAndRef("3", null), + target: new CommitAndRef("3", null), + commits: ["2", "1"], + currentCommit: 1, + }), + }, { + commits: { + "1": new Commit(), + "2": new Commit(), + "3": new Commit(), + }, + head: "1", + sequencerState: new SequencerState({ + type: REBASE, + originalHead: new CommitAndRef("3", null), + target: new CommitAndRef("3", null), + commits: ["2", "1"], + currentCommit: 1, + }), + }), + "bad sequencer": m({ + sequencerState: new SequencerState({ + type: REBASE, + originalHead: new CommitAndRef("foo", null), + target: new CommitAndRef("bar", null), + commits: ["2", "1"], + currentCommit: 1, + }), + }, undefined, true), + "with sparse": m({ sparse: true }, { sparse: true} , false), }; Object.keys(cases).forEach(caseName => { it(caseName, function () { @@ -510,7 +710,9 @@ describe("RepoAST", function () { assert.deepEqual(obj.workdir, c.eworkdir); assert.deepEqual(obj.openSubmodules, c.eopenSubmodules); assert.deepEqual(obj.rebase, c.erebase); + assert.deepEqual(obj.sequencerState, c.esequencerState); assert.equal(obj.bare, c.ebare); + assert.equal(obj.sparse, c.esparse); if (c.input) { assert.notEqual(obj.commits, c.input.commits); @@ -557,18 +759,12 @@ describe("RepoAST", function () { }); }); + const barFile = new File("bar", false); + const bazFile = new File("baz", false); + describe("renderCommit", function () { const Commit = RepoAST.Commit; - const c1 = new Commit({ changes: { foo: "bar" }}); - const c2 = new Commit({ changes: { foo: "baz" }}); - const mergeChild = new Commit({ - parents: ["3"], - changes: { bam: "blast" }, - }); - const merge = new Commit({ - parents: ["1", "2"], - changes: {} - }); + const c1 = new Commit({ changes: { foo: barFile }}); const deleter = new Commit({ parents: ["1"], changes: { foo: null } @@ -582,28 +778,9 @@ describe("RepoAST", function () { "one": { commits: { "1": c1}, from: "1", - expected: { foo: "bar" }, + expected: { foo: barFile }, ecache: { - "1": { foo: "bar" }, - }, - }, - "merge": { - commits: { "1": c1, "2": c2, "3": merge}, - from: "3", - expected: { foo: "bar" }, - ecache: { - "1": c1.changes, - "3": { foo: "bar" }, - }, - }, - "merge child": { - commits: { "1": c1, "2": c2, "3": merge, "4": mergeChild}, - from: "4", - expected: { foo: "bar", bam: "blast" }, - ecache: { - "1": c1.changes, - "3": { foo: "bar" }, - "4": { foo: "bar", bam: "blast" }, + "1": { foo: barFile }, }, }, "deletion": { @@ -618,18 +795,18 @@ describe("RepoAST", function () { "with sub": { commits: { "1": c1, "2": subCommit }, from: "2", - expected: { foo: "bar", baz: submodule }, + expected: { foo: barFile, baz: submodule }, ecache: { "1": c1.changes, - "2": { foo: "bar", baz: submodule }, + "2": { foo: barFile, baz: submodule }, }, }, "use the cache": { commits: { "1": c1 }, from: "1", - cache: { "1": { foo: "baz" } }, - expected: { foo: "baz" }, - ecache: { "1": { foo: "baz"}, }, + cache: { "1": { foo: bazFile } }, + expected: { foo: bazFile }, + ecache: { "1": { foo: bazFile }, }, }, }; Object.keys(cases).forEach(caseName => { @@ -648,16 +825,25 @@ describe("RepoAST", function () { describe("AST.copy", function () { const Rebase = RepoAST.Rebase; + const barFile = new File("bar", false); const base = new RepoAST({ commits: { "1": new RepoAST.Commit()}, branches: { "master": new RepoAST.Branch("1", null) }, refs: { "a/b": "1"}, head: "1", currentBranchName: "master", - index: { foo: "bar" }, - workdir: { foo: "bar" }, + index: { foo: barFile }, + workdir: { foo: barFile }, rebase: new Rebase("hello", "1", "1"), + sequencerState: new SequencerState({ + type: REBASE, + originalHead: new CommitAndRef("1", null), + target: new CommitAndRef("1", null), + commits: ["1"], + currentCommit: 0, + }), bare: false, + sparse: false, }); const newArgs = { commits: { "2": new RepoAST.Commit()}, @@ -666,10 +852,18 @@ describe("RepoAST", function () { head: "2", currentBranchName: "foo", remotes: { "foo": new RepoAST.Remote("meeeee") }, - index: { foo: "bar" }, - workdir: { foo: "bar" }, + index: { foo: barFile }, + workdir: { foo: barFile }, rebase: new Rebase("hello world", "2", "2"), + sequencerState: new SequencerState({ + type: REBASE, + originalHead: new CommitAndRef("2", "refs/heads/master"), + target: new CommitAndRef("2", null), + commits: ["2"], + currentCommit: 0, + }), bare: false, + sparse: false, }; const cases = { "trivial": { @@ -686,6 +880,7 @@ describe("RepoAST", function () { index: {}, workdir: {}, rebase: null, + sequencerState: null, }, e: new RepoAST({ commits: { "1": new RepoAST.Commit()}, @@ -696,6 +891,23 @@ describe("RepoAST", function () { bare: true, }), }, + "sparse": { + i: { + sparse: true, + index: {}, + workdir: {}, + rebase: null, + sequencerState: null, + }, + e: new RepoAST({ + commits: { "1": new RepoAST.Commit()}, + branches: { "master": new RepoAST.Branch("1", null) }, + refs: { "a/b": "1"}, + head: "1", + currentBranchName: "master", + sparse: true, + }), + }, }; Object.keys(cases).forEach(caseName => { it(caseName, function () { @@ -711,7 +923,9 @@ describe("RepoAST", function () { assert.deepEqual(obj.workdir, c.e.workdir); assert.deepEqual(obj.openSubmodules, c.e.openSubmodules); assert.deepEqual(obj.rebase, c.e.rebase); + assert.deepEqual(obj.sequencerState, c.e.sequencerState); assert.equal(obj.bare, c.e.bare); + assert.equal(obj.sparse, c.e.sparse); }); }); }); @@ -722,19 +936,33 @@ describe("RepoAST", function () { // `accumulateChanges`. We just need to make sure they're put // together properly. + const bbFile = new File("bb", false); + const fooFile = new File("foo", false); + const barFile = new File("bar", true); + const zFile = new File("z", false); const Commit = RepoAST.Commit; - const c1 = new Commit({ changes: { foo: "bar" }}); + const Conflict = RepoAST.Conflict; + const c1 = new Commit({ changes: { foo: barFile }}); const cases = { "no index": { commits: { "1": c1}, from: "1", - expected: { foo: "bar" }, + expected: { foo: barFile }, }, "with index": { commits: { "1": c1}, from: "1", - index: { y: "z" }, - expected: { foo: "bar", y: "z" }, + index: { y: zFile }, + expected: { foo: barFile, y: zFile }, + }, + "ignore conflict": { + commits: { "1": c1}, + from: "1", + index: { + y: zFile, + foo: new Conflict(fooFile, barFile, bbFile), + }, + expected: { foo: barFile, y: zFile }, }, }; Object.keys(cases).forEach(caseName => { diff --git a/node/test/util/repo_ast_test_util.js b/node/test/util/repo_ast_test_util.js index 2ec98276d..de3656372 100644 --- a/node/test/util/repo_ast_test_util.js +++ b/node/test/util/repo_ast_test_util.js @@ -85,9 +85,8 @@ const makeClone = co.wrap(function *(repos, maps) { // Test framework expects a trailing '/' to support relative paths. const b = yield NodeGit.Clone.clone(aPath, bPath); - const sig = b.defaultSignature(); const head = yield b.getHeadCommit(); - yield b.createBranch("foo", head.id(), 1, sig, "branch commit"); + yield b.createBranch("foo", head.id(), 1); yield b.checkoutBranch("foo"); const commit = yield TestUtil.generateCommit(b); yield b.checkoutBranch("master"); @@ -265,8 +264,7 @@ Bmaster=1 origin/master`, m: co.wrap(function *(repos) { const x = repos.x; const head = yield x.getHeadCommit(); - const sig = x.defaultSignature(); - yield repos.x.createBranch("foo", head, 0, sig); + yield repos.x.createBranch("foo", head, 0); throw new UserError("bad bad"); }), e: "x=E:Bfoo=1", @@ -293,8 +291,7 @@ Bmaster=1 origin/master`, const x = repos.x; const head = yield x.getHeadCommit(); const headStr = head.id().tostrS(); - const sig = x.defaultSignature(); - yield repos.x.createBranch(`foo-${headStr}`, head, 0, sig); + yield repos.x.createBranch(`foo-${headStr}`, head, 0); }), e: "x=S", options: { @@ -316,8 +313,7 @@ Bmaster=1 origin/master`, const x = repos.x; const head = yield x.getHeadCommit(); const headStr = head.id().tostrS(); - const sig = x.defaultSignature(); - yield repos.x.createBranch(`foo-${headStr}`, head, 0, sig); + yield repos.x.createBranch(`foo-${headStr}`, head, 0); }), e: "x=E:Bfoo-1=1", options: { @@ -332,7 +328,17 @@ Bmaster=1 origin/master`, }; }, }, - } + }, + "bad remap": { + i: {}, + e: {}, + m: function () { + return Promise.resolve({ + commitMap: { "foo": "bar"}, + }); + }, + fails: true, + }, }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; @@ -353,4 +359,55 @@ Bmaster=1 origin/master`, })); }); }); + describe("mapCommits", function () { + const cases = { + "nothing to do": { + commits: {}, + commitMap: {}, + suffix: "foo", + expected: {}, + }, + "map one": { + commits: { "2": "1" }, + commitMap: { "1": "foo" }, + suffix: "bar", + expected: { "2": "foobar" }, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, function () { + const result = {}; + RepoASTTestUtil.mapCommits(result, + c.commits, + c.commitMap, + c.suffix); + assert.deepEqual(result, c.expected); + }); + }); + }); + describe("mapSubCommits", function () { + const cases = { + "nothing to do": { + subCommits: {}, + commitMap: {}, + expected: {}, + }, + "map one": { + subCommits: { "x": { "2": "1" } }, + commitMap: { "1": "fo" }, + expected: { "2": "fox" }, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, function () { + const result = {}; + RepoASTTestUtil.mapSubCommits(result, + c.subCommits, + c.commitMap); + assert.deepEqual(result, c.expected); + }); + }); + }); }); diff --git a/node/test/util/repo_ast_util.js b/node/test/util/repo_ast_util.js index ec244d9e5..1a7e38f04 100644 --- a/node/test/util/repo_ast_util.js +++ b/node/test/util/repo_ast_util.js @@ -35,8 +35,16 @@ const assert = require("chai").assert; const RepoAST = require("../../lib/util/repo_ast"); const RepoASTUtil = require("../../lib/util/repo_ast_util"); -describe("RepoAstUtil", function () { +const File = RepoAST.File; +const barFile = new File("bar", false); +const bamFile = new File("bam", true); +describe("RepoAstUtil", function () { + const Conflict = RepoAST.Conflict; + const Sequencer = RepoAST.SequencerState; + const MERGE = Sequencer.TYPE.MERGE; + const CommitAndRef = Sequencer.CommitAndRef; + const Submodule = RepoAST.Submodule; describe("assertEqualCommits", function () { const Commit = RepoAST.Commit; const cases = { @@ -47,12 +55,24 @@ describe("RepoAstUtil", function () { "with data": { actual: new Commit({ parents: ["1"], - changes: { foo: "bar" }, + changes: { foo: barFile }, + message: "foo", + }), + expected: new Commit({ + parents: ["1"], + changes: { foo: barFile }, + message: "foo", + }), + }, + "with null data": { + actual: new Commit({ + parents: ["1"], + changes: { foo: null }, message: "foo", }), expected: new Commit({ parents: ["1"], - changes: { foo: "bar" }, + changes: { foo: null }, message: "foo", }), }, @@ -63,44 +83,44 @@ describe("RepoAstUtil", function () { "bad parents": { actual: new Commit({ parents: ["1"], - changes: { foo: "bar" }, + changes: { foo: barFile }, }), expected: new Commit({ parents: ["2"], - changes: { foo: "bar" }, + changes: { foo: barFile }, }), fails: true, }, "wrong change": { actual: new Commit({ parents: ["1"], - changes: { foo: "bar" }, + changes: { foo: barFile }, }), expected: new Commit({ parents: ["2"], - changes: { foo: "z" }, + changes: { foo: new File("z", false) }, }), fails: true, }, "extra change": { actual: new Commit({ parents: ["1"], - changes: { foo: "bar", z: "q" }, + changes: { foo: barFile, z: new File("q", false) }, }), expected: new Commit({ parents: ["2"], - changes: { foo: "bar" }, + changes: { foo: barFile }, }), fails: true, }, "missing change": { actual: new Commit({ parents: ["1"], - changes: { foo: "bar" }, + changes: { foo: barFile }, }), expected: new Commit({ parents: ["1"], - changes: { foo: "bar", k: "z" }, + changes: { foo: barFile, k: new File("z", false) }, }), fails: true, }, @@ -131,12 +151,13 @@ describe("RepoAstUtil", function () { describe("assertEqualASTs", function () { const AST = RepoAST; + const Conflict = RepoAST.Conflict; const Commit = AST.Commit; const Rebase = AST.Rebase; const Remote = AST.Remote; const Submodule = AST.Submodule; - const aCommit = new Commit({ changes: { x: "y" } }); + const aCommit = new Commit({ changes: { x: new File("y", true) } }); const aRemote = new Remote("/z"); const aSubmodule = new Submodule("/y", "1"); const anAST = new RepoAST({ @@ -162,10 +183,18 @@ describe("RepoAstUtil", function () { currentBranchName: "master", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, rebase: new Rebase("foo", "1", "1"), + sequencerState: new Sequencer({ + type: MERGE, + originalHead: new CommitAndRef("1", null), + target: new CommitAndRef("1", "foo/bar"), + commits: ["1"], + currentCommit: 0, + }), bare: false, + sparse: false, }), expected: new AST({ commits: { "1": aCommit}, @@ -175,10 +204,18 @@ describe("RepoAstUtil", function () { currentBranchName: "master", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, rebase: new Rebase("foo", "1", "1"), + sequencerState: new Sequencer({ + type: MERGE, + originalHead: new CommitAndRef("1", null), + target: new CommitAndRef("1", "foo/bar"), + commits: ["1"], + currentCommit: 0, + }), bare: false, + sparse: false, }), }, "bad bare": { @@ -189,14 +226,16 @@ describe("RepoAstUtil", function () { "wrong commit": { actual: new AST({ commits: { - "1": new Commit({ changes: { x: "z" } }), + "1": new Commit({ + changes: { x: new File("z", false) } + }), }, branches: { master: new RepoAST.Branch("1", null) }, head: "1", currentBranchName: "master", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, }), expected: new AST({ @@ -206,7 +245,7 @@ describe("RepoAstUtil", function () { currentBranchName: "master", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, }), fails: true, @@ -219,7 +258,7 @@ describe("RepoAstUtil", function () { currentBranchName: "master", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, }), expected: new AST({ @@ -232,7 +271,7 @@ describe("RepoAstUtil", function () { currentBranchName: "master", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, }), fails: true, @@ -246,7 +285,7 @@ describe("RepoAstUtil", function () { currentBranchName: "master", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, }), expected: new AST({ @@ -257,7 +296,7 @@ describe("RepoAstUtil", function () { currentBranchName: "master", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, }), fails: true, @@ -272,7 +311,7 @@ describe("RepoAstUtil", function () { head: "2", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, }), expected: new AST({ @@ -284,7 +323,7 @@ describe("RepoAstUtil", function () { head: "1", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, }), fails: true, @@ -299,7 +338,7 @@ describe("RepoAstUtil", function () { currentBranchName: "master", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, }), expected: new AST({ @@ -308,7 +347,7 @@ describe("RepoAstUtil", function () { head: "1", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, }), fails: true, @@ -321,7 +360,7 @@ describe("RepoAstUtil", function () { currentBranchName: "master", remotes: { origin: aRemote, yyyy: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, }), expected: new AST({ @@ -331,7 +370,7 @@ describe("RepoAstUtil", function () { currentBranchName: "master", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, }), fails: true, @@ -343,8 +382,8 @@ describe("RepoAstUtil", function () { head: "1", currentBranchName: "master", remotes: { origin: aRemote }, - index: { y: aSubmodule, x: "xxxx" }, - workdir: { foo: "bar" }, + index: { y: aSubmodule, x: new File("xxxx", false) }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, }), expected: new AST({ @@ -354,11 +393,47 @@ describe("RepoAstUtil", function () { currentBranchName: "master", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, }), fails: true, }, + "wrong file data": { + actual: new AST({ + index: { foo: barFile, }, + }), + expected: new AST({ + index: { foo: new File("baz", false), }, + }), + fails: true, + }, + "regex match data miss": { + actual: new AST({ + index: { foo: barFile, }, + }), + expected: new AST({ + index: { foo: new File("^^ar", false) }, + }), + fails: true, + }, + "regex match data hit": { + actual: new AST({ + index: { foo: barFile, }, + }), + expected: new AST({ + index: { foo: new File("^^ba", false), }, + }), + fails: false, + }, + "regex match data hit but bad bit": { + actual: new AST({ + index: { foo: barFile, }, + }), + expected: new AST({ + index: { foo: new File("^^ba", true), }, + }), + fails: true, + }, "bad workdir": { actual: new AST({ commits: { "1": aCommit}, @@ -367,7 +442,7 @@ describe("RepoAstUtil", function () { currentBranchName: "master", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, }), expected: new AST({ @@ -377,7 +452,7 @@ describe("RepoAstUtil", function () { currentBranchName: "master", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { oo: "bar" }, + workdir: { oo: barFile }, openSubmodules: { y: anAST }, }), fails: true, @@ -390,7 +465,7 @@ describe("RepoAstUtil", function () { currentBranchName: "master", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, }), expected: new AST({ commits: { "1": aCommit}, @@ -399,7 +474,7 @@ describe("RepoAstUtil", function () { currentBranchName: "master", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, }), fails: true, @@ -412,7 +487,7 @@ describe("RepoAstUtil", function () { currentBranchName: "master", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: anAST }, }), expected: new AST({ @@ -422,7 +497,7 @@ describe("RepoAstUtil", function () { currentBranchName: "master", remotes: { origin: aRemote }, index: { y: aSubmodule }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, openSubmodules: { y: new AST({ commits: { "4": aCommit }, head: "4", @@ -497,7 +572,120 @@ describe("RepoAstUtil", function () { }), fails: true, }, - }; + "missing sequencer": { + actual: new AST({ + commits: { "1": aCommit}, + head: "1", + sequencerState: new Sequencer({ + type: MERGE, + originalHead: new CommitAndRef("1", null), + target: new CommitAndRef("1", "foo/bar"), + commits: ["1"], + currentCommit: 0, + }), + }), + expected: new AST({ + commits: { "1": aCommit}, + head: "1", + }), + fails: true, + }, + "unexpected sequencer": { + actual: new AST({ + commits: { "1": aCommit}, + head: "1", + }), + expected: new AST({ + commits: { "1": aCommit}, + head: "1", + sequencerState: new Sequencer({ + type: MERGE, + originalHead: new CommitAndRef("1", null), + target: new CommitAndRef("1", "foo/bar"), + commits: ["1"], + currentCommit: 0, + }), + }), + fails: true, + }, + "wrong sequencer": { + actual: new AST({ + commits: { "1": aCommit, "2": aCommit}, + head: "1", + branches: { master: new RepoAST.Branch("2", null), }, + sequencerState: new Sequencer({ + type: MERGE, + originalHead: new CommitAndRef("1", null), + target: new CommitAndRef("1", "foo/bar"), + commits: ["1"], + currentCommit: 0, + }), + }), + expected: new AST({ + commits: { "1": aCommit, "2": aCommit}, + head: "1", + branches: { master: new RepoAST.Branch("2", null), }, + sequencerState: new Sequencer({ + type: MERGE, + originalHead: new CommitAndRef("1", null), + target: new CommitAndRef("1", "foo/baz"), + commits: ["1"], + currentCommit: 0, + }), + }), + fails: true, + }, + "same Conflict": { + actual: new AST({ + index: { + "foo": new Conflict(new File("foo", false), + new File("bar", false), + new File("baz", true)), + }, + workdir: { + "foo": new File("boo", false), + }, + }), + expected: new AST({ + index: { + "foo": new Conflict(new File("foo", false), + new File("bar", false), + new File("baz", true)), + }, + workdir: { + "foo": new File("boo", false), + }, + }), + }, + "diff Conflict": { + actual: new AST({ + index: { + "foo": new Conflict(new File("foo", false), + new File("bar", false), + new File("baz", true)), + }, + workdir: { + "foo": new File("boo", false), + }, + }), + expected: new AST({ + index: { + "foo": new Conflict(new File("foo", true), + new File("bar", false), + new File("baz", true)), + }, + workdir: { + "foo": new File("boo", false), + }, + }), + fails: true, + }, + "bad sparse": { + actual: new AST({ sparse: true }), + expected: new AST({ sparse: false }), + fails: true, + }, + }; Object.keys(cases).forEach((caseName) => { const c = cases[caseName]; it(caseName, function () { @@ -545,6 +733,7 @@ describe("RepoAstUtil", function () { } }), m: {}, + fails: true, e: new RepoAST({ commits: { "1": c1 }, head: "1", @@ -659,6 +848,7 @@ describe("RepoAstUtil", function () { }, head: "2", }), + m: { "2": "2", "y": "y" }, u: { x: "z" }, e: new RepoAST({ commits: { @@ -678,7 +868,7 @@ describe("RepoAstUtil", function () { }, head: "2", }), - m: { "3": "4" }, + m: { "3": "4", "2": "2" }, e: new RepoAST({ commits: { "2": new RepoAST.Commit({ @@ -697,7 +887,7 @@ describe("RepoAstUtil", function () { }, head: "2", }), - m: { "3": "4" }, + m: { "3": "4", "2": "2" }, u: { x: "z" }, e: new RepoAST({ commits: { @@ -717,7 +907,7 @@ describe("RepoAstUtil", function () { }, head: "2", }), - m: { "8": "4" }, + m: { "2": "2", "3": "3" }, u: { r: "z" }, e: new RepoAST({ commits: { @@ -735,7 +925,7 @@ describe("RepoAstUtil", function () { remotes: { foo: new RepoAST.Remote("my-url"), }, - index: { foo: "bar" }, + index: { foo: barFile }, }), m: { "1": "2"}, e: new RepoAST({ @@ -744,7 +934,7 @@ describe("RepoAstUtil", function () { remotes: { foo: new RepoAST.Remote("my-url"), }, - index: { foo: "bar" }, + index: { foo: barFile }, }), }, "index unchanged submodule": { @@ -755,11 +945,11 @@ describe("RepoAstUtil", function () { foo: new RepoAST.Remote("my-url"), }, index: { - foo: "bar", + foo: barFile, baz: new RepoAST.Submodule("x", "y"), }, }), - m: { "1": "2"}, + m: { "1": "2", "y": "y" }, u: { "q": "z"}, e: new RepoAST({ commits: { "2": c1 }, @@ -768,7 +958,7 @@ describe("RepoAstUtil", function () { foo: new RepoAST.Remote("my-url"), }, index: { - foo: "bar", + foo: barFile, baz: new RepoAST.Submodule("x", "y"), }, }), @@ -781,7 +971,7 @@ describe("RepoAstUtil", function () { foo: new RepoAST.Remote("my-url"), }, index: { - foo: "bar", + foo: barFile, baz: new RepoAST.Submodule("q", "1"), }, }), @@ -794,11 +984,33 @@ describe("RepoAstUtil", function () { foo: new RepoAST.Remote("my-url"), }, index: { - foo: "bar", + foo: barFile, baz: new RepoAST.Submodule("z", "2"), }, }), }, + "index conflicted submodule": { + i: new RepoAST({ + commits: { "1": c1 }, + head: "1", + index: { + baz: new Conflict(new Submodule("q", "1"), + new Submodule("r", "1"), + new Submodule("s", "1")), + }, + }), + m: { "1": "2"}, + u: { "q": "z", "r": "a", "s": "b" }, + e: new RepoAST({ + commits: { "2": c1 }, + head: "2", + index: { + baz: new Conflict(new Submodule("z", "2"), + new Submodule("a", "2"), + new Submodule("b", "2")), + }, + }), + }, "workdir, unchanged": { i: new RepoAST({ commits: { "1": c1 }, @@ -806,7 +1018,7 @@ describe("RepoAstUtil", function () { remotes: { foo: new RepoAST.Remote("my-url"), }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, }), m: { "1": "2"}, e: new RepoAST({ @@ -815,7 +1027,7 @@ describe("RepoAstUtil", function () { remotes: { foo: new RepoAST.Remote("my-url"), }, - workdir: { foo: "bar" }, + workdir: { foo: barFile }, }), }, "submodule with changes": { @@ -831,7 +1043,7 @@ describe("RepoAstUtil", function () { } })}, }), - m: { "1": "2" }, + m: { "1": "2", "y": "y" }, u: { "x": "z" }, e: new RepoAST({ commits: { "2": c1 }, @@ -859,17 +1071,31 @@ describe("RepoAstUtil", function () { rebase: new Rebase("foo", "2", "2"), }), }, - "rebase unmapped": { + "sequencer": { i: new RepoAST({ commits: { "1": c1 }, head: "1", - rebase: new Rebase("foo", "1", "1"), + sequencerState: new Sequencer({ + type: MERGE, + originalHead: new CommitAndRef("1", null), + target: new CommitAndRef("1", "foo/bar"), + commits: ["1"], + currentCommit: 0, + message: "meh", + }), }), - m: {}, + m: { "1": "2"}, e: new RepoAST({ - commits: { "1": c1 }, - head: "1", - rebase: new Rebase("foo", "1", "1"), + commits: { "2": c1 }, + head: "2", + sequencerState: new Sequencer({ + type: MERGE, + originalHead: new CommitAndRef("2", null), + target: new CommitAndRef("2", "foo/bar"), + commits: ["2"], + currentCommit: 0, + message: "meh", + }), }), }, }; @@ -878,9 +1104,26 @@ describe("RepoAstUtil", function () { const c = cases[caseName]; const commitMap = c.m || {}; const urlMap = c.u || {}; - const result = RepoASTUtil.mapCommitsAndUrls(c.i, - commitMap, - urlMap); + let exception; + let result; + const shouldFail = undefined !== c.fails && c.fails; + try { + result = RepoASTUtil.mapCommitsAndUrls(c.i, + commitMap, + urlMap); + } catch (e) { + exception = e; + } + + if (undefined === exception) { + assert(!shouldFail, "should have failed"); + } else { + if (!shouldFail) { + throw exception; + } else { + return; + } + } RepoASTUtil.assertEqualASTs(result, c.e); }); }); @@ -890,8 +1133,8 @@ describe("RepoAstUtil", function () { const AST = RepoAST; const Commit = AST.Commit; const Remote = AST.Remote; - const c1 = new Commit({ changes: { foo: "bar" } }); - const c2 = new Commit({ changes: { baz: "bam" } }); + const c1 = new Commit({ changes: { foo: barFile } }); + const c2 = new Commit({ changes: { baz: bamFile } }); const child = new Commit({ parents: ["1"] }); const cases = { "sipmlest": { diff --git a/node/test/util/repo_status.js b/node/test/util/repo_status.js index 440e337c0..516a8d74a 100644 --- a/node/test/util/repo_status.js +++ b/node/test/util/repo_status.js @@ -32,10 +32,14 @@ const assert = require("chai").assert; -const Rebase = require("../../lib/util/rebase"); -const RepoStatus = require("../../lib/util/repo_status"); +const Rebase = require("../../lib/util/rebase"); +const RepoStatus = require("../../lib/util/repo_status"); +const SequencerState = require("../../lib/util/sequencer_state"); describe("RepoStatus", function () { +const CommitAndRef = SequencerState.CommitAndRef; +const MERGE = SequencerState.TYPE.MERGE; + const FILESTATUS = RepoStatus.FILESTATUS; const Submodule = RepoStatus.Submodule; const Commit = Submodule.Commit; @@ -475,6 +479,7 @@ describe("RepoStatus", function () { workdir: {}, submodules: {}, rebase: null, + sequencerState: null, }; return Object.assign(result, args); } @@ -500,6 +505,13 @@ describe("RepoStatus", function () { }), }, rebase: new Rebase("foo", "1", "2"), + sequencerState: new SequencerState({ + type: MERGE, + originalHead: new CommitAndRef("foo", null), + target: new CommitAndRef("bar", "baz"), + commits: ["2", "1"], + currentCommit: 1, + }), }, e: m({ currentBranchName: "foo", @@ -513,6 +525,13 @@ describe("RepoStatus", function () { }), }, rebase: new Rebase("foo", "1", "2"), + sequencerState: new SequencerState({ + type: MERGE, + originalHead: new CommitAndRef("foo", null), + target: new CommitAndRef("bar", "baz"), + commits: ["2", "1"], + currentCommit: 1, + }), }), } }; @@ -528,6 +547,7 @@ describe("RepoStatus", function () { assert.deepEqual(result.workdir, c.e.workdir); assert.deepEqual(result.submodules, c.e.submodules); assert.deepEqual(result.rebase, c.e.rebase); + assert.deepEqual(result.sequencerState, c.e.sequencerState); }); }); @@ -853,6 +873,79 @@ describe("RepoStatus", function () { }); }); + describe("isConflicted", function () { + const cases = { + "trivial": { + input: new RepoStatus(), + expected: false, + }, + "with files and submodules": { + input: new RepoStatus({ + currentBranchName: "foo", + headCommit: "1", + staged: { bar: FILESTATUS.MODIFIED }, + workdir: { foo: FILESTATUS.ADDED }, + submodules: { + "a": new Submodule({ + commit: new Commit("1", "a"), + staged: new Index("1", "a", RELATION.SAME), + workdir: new Workdir(new RepoStatus({ + headCommit: "1", + }), RELATION.SAME), + }), + }, + }), + expected: false, + }, + "conflict in meta": { + input: new RepoStatus({ + currentBranchName: "foo", + headCommit: "1", + staged: { bar: new RepoStatus.Conflict(null, null, null) }, + workdir: { foo: FILESTATUS.ADDED }, + submodules: { + "a": new Submodule({ + commit: new Commit("1", "a"), + staged: new Index("1", "a", RELATION.SAME), + workdir: new Workdir(new RepoStatus({ + headCommit: "1", + }), RELATION.SAME), + }), + }, + }), + expected: true, + }, + "conflict in sub": { + input: new RepoStatus({ + currentBranchName: "foo", + headCommit: "1", + staged: { bar: FILESTATUS.MODIFIED }, + workdir: { foo: FILESTATUS.ADDED }, + submodules: { + "a": new Submodule({ + commit: new Commit("1", "a"), + staged: new Index("1", "a", RELATION.SAME), + workdir: new Workdir(new RepoStatus({ + staged: { + meh: new RepoStatus.Conflict(1, 1, 1), + }, + headCommit: "1", + }), RELATION.SAME), + }), + }, + }), + expected: true, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, function () { + const result = c.input.isConflicted(); + assert.equal(result, c.expected); + }); + }); + }); + describe("isDeepClean", function () { const cases = { "trivial": { @@ -957,6 +1050,13 @@ describe("RepoStatus", function () { }, workdir: { x: FILESTATUS.MODIFIED }, rebase: new Rebase("2", "4", "b"), + sequencerState: new SequencerState({ + type: MERGE, + originalHead: new CommitAndRef("foo", null), + target: new CommitAndRef("bar", "baz"), + commits: ["2", "1"], + currentCommit: 1, + }), }); const anotherStat = new RepoStatus({ currentBranchName: "fo", @@ -967,6 +1067,13 @@ describe("RepoStatus", function () { }, workdir: { x: FILESTATUS.ADDED }, rebase: new Rebase("a", "4", "b"), + sequencerState: new SequencerState({ + type: MERGE, + originalHead: new CommitAndRef("foo", null), + target: new CommitAndRef("flim", "flam"), + commits: ["3", "4"], + currentCommit: 1, + }), }); it("simple, no args", function () { const newStat = stat.copy(); @@ -984,6 +1091,7 @@ describe("RepoStatus", function () { submodules: anotherStat.submodules, workdir: anotherStat.workdir, rebase: anotherStat.rebase, + sequencerState: anotherStat.sequencerState, }); assert.deepEqual(newStat, anotherStat); }); diff --git a/node/test/util/reset.js b/node/test/util/reset.js index 2b9d85707..f7df3e2b8 100644 --- a/node/test/util/reset.js +++ b/node/test/util/reset.js @@ -30,13 +30,163 @@ */ "use strict"; -const co = require("co"); -const path = require("path"); +const assert = require("chai").assert; +const co = require("co"); +const fs = require("fs-promise"); +const NodeGit = require("nodegit"); +const path = require("path"); -const Reset = require("../../lib/util/reset"); -const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); +const Reset = require("../../lib/util/reset"); +const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); +const SparseCheckoutUtil = require("../../lib/util/sparse_checkout_util"); +const SubmoduleChange = require("../../lib/util/submodule_change"); +const SubmoduleConfigUtil = require("../../lib/util/submodule_config_util"); +const SubmoduleUtil = require("../../lib/util/submodule_util"); describe("reset", function () { + describe("resetMetaRepo", function () { + const cases = { + "noop": { + input: "x=S", + to: "1", + }, + "another": { + input: "x=S:C2-1;Bfoo=2", + to: "2", + expected: "x=E:I 2=2;W 2", + }, + "a sub": { + input: "a=B:Ca-1;Ba=a|x=U:C3 s=Sa:a;Bfoo=3", + to: "3", + expected: "x=E:I s=Sa:a,README.md;W README.md=hello world", + }, + "a new sub": { + input: "a=B|x=S:C2-1 s=Sa:1;Bfoo=2", + to: "2", + expected: "x=E:I s=Sa:1", + }, + "removed a sub": { + input: "a=B|x=U:C3-2 s;Bfoo=3", + to: "3", + expected: "x=E:I s", + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + const updateHead = co.wrap(function *(repos, maps) { + const repo = repos.x; + const rev = maps.reverseCommitMap; + const index = yield repo.index(); + const commit = yield repo.getCommit(rev[c.to]); + const head = yield repo.getHeadCommit(); + const headTree = yield head.getTree(); + const commitTree = yield commit.getTree(); + const diff = yield NodeGit.Diff.treeToTree(repo, + headTree, + commitTree, + null); + const changes = + yield SubmoduleUtil.getSubmoduleChangesFromDiff(diff, true); + + yield Reset.resetMetaRepo(repo, index, commit, changes); + yield index.write(); + const fromWorkdir = + yield SubmoduleConfigUtil.getSubmodulesFromWorkdir(repo); + const fromIndex = + yield SubmoduleConfigUtil.getSubmodulesFromIndex(repo, index); + assert.deepEqual(fromIndex, fromWorkdir); + }); + it(caseName, co.wrap(function *() { + yield RepoASTTestUtil.testMultiRepoManipulator(c.input, + c.expected, + updateHead); + + })); + }); + it("does a mixed and non-mixed reset", co.wrap(function *() { + const input = "a=B|x=S:C2-1 s=Sa:1;Bmaster=2"; + const maps = yield RepoASTTestUtil.createMultiRepos(input); + const repo = maps.repos.x; + const index = yield repo.index(); + + let fromWorkdir = + yield SubmoduleConfigUtil.getSubmodulesFromWorkdir(repo); + assert.equal(1, Object.keys(fromWorkdir).length); + let fromIndex = + yield SubmoduleConfigUtil.getSubmodulesFromIndex(repo, index); + assert.equal(1, Object.keys(fromIndex).length); + + const rev = maps.reverseCommitMap; + const commit = yield repo.getCommit(rev["1"]); + const head = yield repo.getHeadCommit(); + const headTree = yield head.getTree(); + const commitTree = yield commit.getTree(); + const diff = yield NodeGit.Diff.treeToTree(repo, + headTree, + commitTree, + null); + const changes = + yield SubmoduleUtil.getSubmoduleChangesFromDiff(diff, true); + + yield Reset.resetMetaRepo(repo, index, commit, changes, true); + + fromWorkdir = + yield SubmoduleConfigUtil.getSubmodulesFromWorkdir(repo); + assert.equal(1, Object.keys(fromWorkdir).length); + fromIndex = + yield SubmoduleConfigUtil.getSubmodulesFromIndex(repo, index); + assert.equal(0, Object.keys(fromIndex).length); + + yield Reset.resetMetaRepo(repo, index, commit, changes, false); + + fromWorkdir = + yield SubmoduleConfigUtil.getSubmodulesFromWorkdir(repo); + assert.equal(0, Object.keys(fromWorkdir).length); + fromIndex = + yield SubmoduleConfigUtil.getSubmodulesFromIndex(repo, index); + assert.equal(0, Object.keys(fromIndex).length); + + })); + it("submodule directory is cleaned up", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo( + "U:C3-2 s;Bfoo=3"); + const repo = written.repo; + const rev = written.oldCommitMap; + const index = yield repo.index(); + const commit = yield repo.getCommit(rev["3"]); + const changes = { + s: new SubmoduleChange(rev["1"], null, null), + }; + yield Reset.resetMetaRepo(repo, index, commit, changes); + let exists = true; + try { + yield fs.stat(path.join(repo.workdir(), "s")); + } catch (e) { + exists = false; + } + assert.equal(false, exists); + })); + it("no directory creatd in sparse mode", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo( + "S:C2-1 s=S/a:1;Bfoo=2"); + const repo = written.repo; + yield SparseCheckoutUtil.setSparseMode(repo); + const rev = written.oldCommitMap; + const index = yield repo.index(); + const commit = yield repo.getCommit(rev["2"]); + const changes = { + s: new SubmoduleChange(null, rev["1"], null), + }; + yield Reset.resetMetaRepo(repo, index, commit, changes); + let exists = true; + try { + yield fs.stat(path.join(repo.workdir(), "s")); + } catch (e) { + exists = false; + } + assert.equal(false, exists); + })); + }); describe("reset", function () { // We are deferring the actual reset logic to NodeGit, so we are not @@ -61,31 +211,19 @@ describe("reset", function () { to: "1", type: TYPE.HARD, }, - "meta soft": { - initial: "x=S:C2-1 README.md=aaa;Bfoo=2", - to: "2", - type: TYPE.SOFT, - expected: "x=E:Bmaster=2;I README.md=hello world", - }, - "meta mixed": { - initial: "x=S:C2-1 README.md=aaa;Bfoo=2", - to: "2", - type: TYPE.MIXED, - expected: "x=E:Bmaster=2;W README.md=hello world", - }, - "meta hard": { - initial: "x=S:C2-1 README.md=aaa;Bfoo=2", + "hard with commit in sub": { + initial: "a=B:Ca-1;Ba=a|x=U:Os H=a", + expected: "x=U:Os", to: "2", type: TYPE.HARD, - expected: "x=E:Bmaster=2", }, "unchanged sub-repo not open": { - initial: "a=B|x=U:C4-2 x=y;Bfoo=4", + initial: "a=B|x=U:C4-2 t=Sa:1;Bfoo=4", to: "4", type: TYPE.HARD, expected: "x=E:Bmaster=4" }, - "changed sub-repo not open": { + "hard changed sub-repo not open": { initial: "a=B:Ca-1 y=x;Bfoo=a|x=U:C4-2 s=Sa:a;Bfoo=4", to: "4", type: TYPE.HARD, @@ -110,6 +248,37 @@ a=B:Ca-1 y=x;Bfoo=a|x=U:C3-2 t=Sa:1;C4-3 s=Sa:a,t=Sa:a;Bfoo=4;Bmaster=3;Os;Ot`, type: TYPE.HARD, expected: "x=E:Bmaster=4;Os;Ot" }, + "soft in sub": { + initial: "a=B:Ca-1;Bmaster=a|x=U:C3-2 s=Sa:a;Bmaster=3;Bf=3", + to: "2", + type: TYPE.SOFT, + expected: "x=E:Os H=1!I a=a;Bmaster=2", + }, + "soft in sub, already open": { + initial: ` +a=B:Ca-1;Bmaster=a|x=U:C3-2 s=Sa:a;Bmaster=3;Bf=3;Os`, + to: "2", + type: TYPE.SOFT, + expected: "x=E:Os H=1!I a=a;Bmaster=2", + }, + "merge should do as HARD but not refuse": { + initial: `a=B|x=U:Os W README.md=888`, + to: "1", + type: TYPE.MERGE, + expected: "x=S", + }, + "soft, submodule with changes should not refuse": { + initial: `a=B|x=U:Os W README.md=888;Bfoo=2`, + to: "1", + expected: "x=E:Bmaster=1;I s=Sa:1", + type: TYPE.SOFT, + }, + "submodule with unchanged head but changes": { + initial: "a=B|x=U:Os W README.md=888", + to: "2", + type: TYPE.HARD, + expected: "x=E:Os", + }, }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; @@ -139,31 +308,12 @@ a=B:Ca-1 y=x;Bfoo=a|x=U:C3-2 t=Sa:1;C4-3 s=Sa:a,t=Sa:a;Bfoo=4;Bmaster=3;Os;Ot`, commit: "1", paths: [ "README.md" ], }, - "reset one": { - initial: "x=S:I README.md=3", - commit: "1", - paths: [ "README.md" ], - expected: "x=S:W README.md=3", - }, - "reset multiple": { - initial: "x=S:C2-1;Bmaster=2;I README.md=3,2=3", - commit: "2", - paths: [ "README.md", "2" ], - expected: "x=S:C2-1;Bmaster=2;W README.md=3,2=3", - }, "reset from another commit": { initial: "x=S:C2-1 README.md=8;Bfoo=2;I README.md=3", commit: "2", paths: [ "README.md" ], fails: true, }, - "in subdir": { - initial: "x=S:C2-1 s/x=foo;Bmaster=2;I s/x=8", - commit: "2", - paths: [ "x" ], - cwd: "s", - expected: "x=E:I s/x=~;W s/x=8", - }, "in submodule": { initial: "a=B|x=U:Os I README.md=88", commit: "2", diff --git a/node/test/util/rm.js b/node/test/util/rm.js new file mode 100644 index 000000000..8ba30970b --- /dev/null +++ b/node/test/util/rm.js @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2018, 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 co = require("co"); + +const Rm = require("../../lib/util/rm"); +const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); + +describe("rm", function () { + describe("rmPaths", function () { + const cases = { + // Cases in meta repo + "everything from empty repo": { + initial: "x=S:I README.md", + paths: [""], + fails: true, + }, + "a clean file by name": { + initial: "x=S:C2-1 x/y/z=foo;W x/q/z=bar;Bmaster=2", + paths: ["x/y/z"], + expect: "x=E:I x/y/z", + }, + "a clean file by name --cached": { + initial: "x=S:C2-1 x/y/z=foo;W x/q/z=bar;Bmaster=2", + paths: ["x/y/z"], + cached: true, + expect: "x=E:I x/y/z;W x/y/z=foo", + }, + "a clean file by removing its containing dir, no --recursive": { + initial: "x=S:C2-1 x/y/z=foo;W x/q/z=bar;Bmaster=2", + paths: ["x/y"], + fails: true, + }, + "a clean file by removing its containing dir": { + initial: "x=S:C2-1 x/y/z=foo;W x/q/z=bar;Bmaster=2", + paths: ["x/y"], + recursive: true, + expect: "x=E:I x/y/z" + }, + "two clean files by removing their containing dir": { + initial: "x=S:C2-1 x/y/a=foo,x/y/z=foo;W x/q/z=bar;Bmaster=2", + paths: ["x/y"], + recursive: true, + expect: "x=E:I x/y/a,x/y/z" + }, + "two clean by removing their grandparent dir": { + initial: "x=S:C2-1 x/y/a=foo,x/y/z=foo;W x/q/z=bar;Bmaster=2", + paths: ["x"], + recursive: true, + expect: "x=E:I x/y/a,x/y/z" + }, + "a non-existent thing": { + initial: "x=S:C2-1 x/y/a=foo,x/y/z=foo;W x/q/z=bar;Bmaster=2", + paths: ["z"], + fails: true, + }, + "a file that only exists in the index": { + initial: "x=S:I x/y/a=foo;W x/y/a", + paths: ["x/y/a"], + expect: "x=S", + }, + "a file that only exists in the index, --cached": { + initial: "x=S:I x/y/a=foo;W x/y/a", + paths: ["x/y/a"], + cached: true, + expect: "x=S", + }, + "a file that only exists in the index, -f": { + initial: "x=S:I x/y/a=foo;W x/y/a=", + paths: ["x/y/a"], + force: true, + expect: "x=S" + }, + "a submodule": { + initial: `x=S:C2-1 x/y/a=foo,x/y/z=foo;C3-1 d=S/baz.git:2; + Bmaster=3;Bsub=2`, + paths: ["d"], + expect: "x=E:I d" + }, + "an open submodule": { + initial: `sub=S:C8-1 x/y/a=foo,x/y/z=foo;Bmaster=8| + x=S:C2-1 d=Ssub:8;Bmaster=2;Od`, + paths: ["d"], + expect: "x=S:C2-1 d=Ssub:8;Bmaster=2;I d" + }, + "a submodule that only exists in the index": { + // because this only exists in the index, need -f to remove it + initial: `sub=S:C8-1 x/y/a=foo;Bmaster=8| + x=S:I d=Ssub:8;Bmaster=1;Od`, + paths: ["d"], + cached: true, + force: true, + expect: "x=S:Bmaster=1" + }, + "a submodule that only exists in the wt and .gitmodules": { + initial: `sub=B|x=S:I d=Ssub:;Od`, + paths: ["d"], + expect: `x=S`, + }, + // cases inside submodules + "a non-existent thing from a submodule": { + initial: `sub=S:C4-1;Bsub=4|x=S:C2-1 x/y/a=foo,x/y/z=foo; + C3-2 d=Ssub:4; + Bmaster=3;Od`, + paths: ["d/f"], + fails: true, + }, + "a file from a closed submodule": { + initial: `x=S:C2-1 x/y/a=foo,x/y/z=foo;C3-1 d=S/baz.git:2; + Bmaster=3;Bsub=2`, + paths: ["d/x/y/z"], + fails: true, + }, + "a file from an open submodule": { + initial: `sub=S:C2-1 x/y/a=foo,x/y/z=foo;Bmaster=2| + x=S:C3-1 d=Ssub:2;Bmaster=3;Od`, + paths: ["d/x/y/z"], + expect: "x=E:Od I x/y/z", + }, + "a nonexistent dir from an open submodule": { + initial: `sub=S:C2-1 x/y/a=foo,x/y/z=foo;Bmaster=2| + x=S:C3-1 d=Ssub:2;Bmaster=3;Od`, + paths: ["d/q"], + fails: true, + }, + "a file an open submodule via dir, no --recursive": { + initial: `sub=S:C2-1 x/y/a=foo,x/y/z=foo;Bmaster=2| + x=S:C3-1 d=Ssub:2;Bmaster=3;Od`, + paths: ["d/x/y"], + fails: true, + }, + "a file from an open submodule via dir": { + initial: `sub=S:C2-1 x/y/a=foo,x/y/z=foo;Bmaster=2| + x=S:C3-1 d=Ssub:2,d2=Ssub:2;Bmaster=3;Od;Od2`, + paths: ["d/x/y"], + recursive: true, + expect: "x=E:Od I x/y/a,x/y/z;Od2", + }, + "a modified file from an open submodule via dir": { + initial: `sub=S:C2-1 x/y/a=foo,x/y/z=foo;Bmaster=2| + x=S:C3-1 d=Ssub:2;Bmaster=3;Od W x/y/z=mod`, + paths: ["d/x/y"], + recursive: true, + fails: true, + }, + "a cached file from an open submodule via dir": { + initial: `sub=S:C2-1 x/y/a=foo,x/y/z=orig;Bmaster=2| + x=S:C3-1 d=Ssub:2; + Bmaster=3;Od I x/y/z=mod!W x/y/z=orig`, + paths: ["d/x/y"], + recursive: true, + fails: true, + }, + "a cached file from an open submodule via dir --cached": { + initial: `sub=S:C2-1 x/y/a=foo,x/y/z=orig;Bmaster=2| + x=S:C3-1 d=Ssub:2; + Bmaster=3;Od I x/y/z=mod!W x/y/z=orig`, + paths: ["d/x/y"], + recursive: true, + cached: true, + fails: true, + }, + "a cached file from an open submodule, --cached, index = head": + { + initial: `sub=S:C2-1 x/y/a=foo,x/y/z=orig;Bmaster=2| + x=S:C3-1 d=Ssub:2; + Bmaster=3;Od I x/y/z=orig!W x/y/z=mod`, + paths: ["d/x/y/z"], + recursive: true, + cached: true, + expect: "x=E:Od I x/y/z!W x/y/z=mod", + }, + "a cached file from an open submodule, --cached, index = wt": { + initial: `sub=S:C2-1 x/y/a=foo,x/y/z=orig;Bmaster=2| + x=S:C3-1 d=Ssub:2; + Bmaster=3;Od I x/y/z=mod!W x/y/z=mod`, + paths: ["d/x/y/z"], + recursive: true, + cached: true, + expect: "x=E:Od I x/y/z!W x/y/z=mod", + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const rmPaths = co.wrap(function *(repos) { + const repo = repos.x; + yield Rm.rmPaths(repo, c.paths, { + recursive: c.recursive, + cached: c.cached, + force: c.force}); + }); + yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, + c.expect, + rmPaths, + c.fails); + })); + }); + }); +}); diff --git a/node/test/util/sequencer_state.js b/node/test/util/sequencer_state.js new file mode 100644 index 000000000..2e6e7e69a --- /dev/null +++ b/node/test/util/sequencer_state.js @@ -0,0 +1,290 @@ +/* + * Copyright (c) 2018, 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 SequencerState = require("../../lib/util/sequencer_state"); + +describe("SequencerState", function () { + +const TYPE = SequencerState.TYPE; +const CommitAndRef = SequencerState.CommitAndRef; + describe("CommitAndRef", function () { + it("breath", function () { + const withRef = new CommitAndRef("foo", "bar"); + assert.isFrozen(withRef); + assert.equal(withRef.sha, "foo"); + assert.equal(withRef.ref, "bar"); + + const noRef = new CommitAndRef("wee", null); + assert.equal(noRef.sha, "wee"); + assert.isNull(noRef.ref); + }); + describe("equal", function () { + const cases = { + "same": { + lhs: new CommitAndRef("a", "b"), + rhs: new CommitAndRef("a", "b"), + expected: true, + }, + "diff sha": { + lhs: new CommitAndRef("a", "b"), + rhs: new CommitAndRef("b", "b"), + expected: false, + }, + "diff ref": { + lhs: new CommitAndRef("a", null), + rhs: new CommitAndRef("a", "b"), + expected: false, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, function () { + const result = c.lhs.equal(c.rhs); + assert.equal(result, c.expected); + }); + }); + }); + }); + describe("toString", function () { + it("with ref", function () { + const input = new CommitAndRef("foo", "bar"); + const result = "" + input; + assert.equal(result, "CommitAndRef(sha=foo, ref=bar)"); + }); + it("no ref", function () { + const input = new CommitAndRef("foo", null); + const result = "" + input; + assert.equal(result, "CommitAndRef(sha=foo)"); + }); + }); + it("breathe", function () { + const original = new CommitAndRef("a", "foo"); + const target = new CommitAndRef("c", "bar"); + const seq = new SequencerState({ + type: TYPE.MERGE, + originalHead: original, + target: target, + commits: ["3"], + currentCommit: 0, + message: "meh", + }); + assert.isFrozen(seq); + assert.equal(seq.type, TYPE.MERGE); + assert.deepEqual(seq.originalHead, original); + assert.deepEqual(seq.target, target); + assert.deepEqual(seq.commits, ["3"]); + assert.equal(seq.currentCommit, 0); + assert.equal(seq.message, "meh"); + }); + describe("equal", function () { + const cnr0 = new CommitAndRef("a", "foo"); + const cnr1 = new CommitAndRef("b", "foo"); + const cases = { + "same": { + lhs: new SequencerState({ + type: TYPE.MERGE, + originalHead: cnr0, + target: cnr1, + commits: ["1", "2", "3"], + currentCommit: 1, + message: "moo", + }), + rhs: new SequencerState({ + type: TYPE.MERGE, + originalHead: cnr0, + target: cnr1, + commits: ["1", "2", "3"], + currentCommit: 1, + message: "moo", + }), + expected: true, + }, + "different type": { + lhs: new SequencerState({ + type: TYPE.MERGE, + originalHead: cnr0, + target: cnr1, + commits: ["1", "2", "3"], + currentCommit: 1, + }), + rhs: new SequencerState({ + type: TYPE.REBASE, + originalHead: cnr0, + target: cnr1, + commits: ["1", "2", "3"], + currentCommit: 1, + }), + expected: false, + }, + "different original head": { + lhs: new SequencerState({ + type: TYPE.MERGE, + originalHead: cnr1, + target: cnr1, + commits: ["1", "2", "3"], + currentCommit: 1, + }), + rhs: new SequencerState({ + type: TYPE.MERGE, + originalHead: cnr0, + target: cnr1, + commits: ["1", "2", "3"], + currentCommit: 1, + }), + expected: false, + }, + "different target": { + lhs: new SequencerState({ + type: TYPE.MERGE, + originalHead: cnr0, + target: cnr1, + commits: ["1", "2", "3"], + currentCommit: 1, + }), + rhs: new SequencerState({ + type: TYPE.MERGE, + originalHead: cnr0, + target: cnr0, + commits: ["1", "2", "3"], + currentCommit: 1, + }), + expected: false, + }, + "different commits": { + lhs: new SequencerState({ + type: TYPE.MERGE, + originalHead: cnr0, + target: cnr1, + commits: ["1", "2", "3"], + currentCommit: 1, + }), + rhs: new SequencerState({ + type: TYPE.MERGE, + originalHead: cnr0, + target: cnr1, + commits: ["3", "2", "1"], + currentCommit: 1, + }), + expected: false, + }, + "different current commit": { + lhs: new SequencerState({ + type: TYPE.MERGE, + originalHead: cnr0, + target: cnr1, + commits: ["1", "2", "3"], + currentCommit: 0, + }), + rhs: new SequencerState({ + type: TYPE.MERGE, + originalHead: cnr0, + target: cnr1, + commits: ["1", "2", "3"], + currentCommit: 1, + }), + expected: false, + }, + "different message": { + lhs: new SequencerState({ + type: TYPE.MERGE, + originalHead: cnr0, + target: cnr1, + commits: ["1", "2", "3"], + currentCommit: 1, + message: "ooo", + }), + rhs: new SequencerState({ + type: TYPE.MERGE, + originalHead: cnr0, + target: cnr1, + commits: ["1", "2", "3"], + currentCommit: 1, + message: "moo", + }), + expected: false, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, function () { + const result = c.lhs.equal(c.rhs); + assert.equal(result, c.expected); + }); + }); + }); + it("copy", function () { + const s0 = new SequencerState({ + type: TYPE.CHERRY_PICK, + originalHead: new CommitAndRef("1", "2"), + target: new CommitAndRef("a", "b"), + currentCommit: 0, + commits: ["a"], + message: "yo", + }); + const s1 = new SequencerState({ + type: TYPE.MERGE, + originalHead: new CommitAndRef("u", "v"), + target: new CommitAndRef("8", "8"), + currentCommit: 1, + commits: ["1", "3"], + message: "there", + }); + const defaults = s0.copy(); + assert.deepEqual(defaults, s0); + const overridden = s0.copy({ + type: s1.type, + originalHead: s1.originalHead, + target: s1.target, + commits: s1.commits, + currentCommit: s1.currentCommit, + message: s1.message, + }); + assert.deepEqual(overridden, s1); + }); + it("toString", function () { + const input = new SequencerState({ + type: TYPE.REBASE, + originalHead: new CommitAndRef("a", null), + target: new CommitAndRef("b", null), + commits: ["1"], + currentCommit: 0, + message: "meh", + }); + const result = "" + input; + assert.equal(result, + `\ +SequencerState(type=REBASE, originalHead=CommitAndRef(sha=a), \ +target=CommitAndRef(sha=b), commits=["1"], currentCommit=0, msg=meh)`); + }); +}); diff --git a/node/test/util/sequencer_state_util.js b/node/test/util/sequencer_state_util.js new file mode 100644 index 000000000..8571f6c3d --- /dev/null +++ b/node/test/util/sequencer_state_util.js @@ -0,0 +1,406 @@ +/* + * Copyright (c) 2018, 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 fs = require("fs-promise"); +const mkdirp = require("mkdirp"); +const path = require("path"); + +const SequencerState = require("../../lib//util/sequencer_state"); +const SequencerStateUtil = require("../../lib//util/sequencer_state_util"); +const TestUtil = require("../../lib/util/test_util"); + +const CommitAndRef = SequencerState.CommitAndRef; + +describe("SequencerStateUtil", function () { +describe("readFile", function () { + it("exists", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "foo"), "1234\n"); + const result = yield SequencerStateUtil.readFile(gitDir, "foo"); + assert.equal(result, "1234\n"); + })); + it("missing", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const result = yield SequencerStateUtil.readFile(gitDir, "foo"); + assert.isNull(result); + })); +}); +describe("readCommitAndRef", function () { + it("nothing", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const result = yield SequencerStateUtil.readCommitAndRef(gitDir, + "foo"); + assert.isNull(result); + })); + it("just a sha", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "foo"), "1234\n"); + const result = yield SequencerStateUtil.readCommitAndRef(gitDir, + "foo"); + assert.instanceOf(result, CommitAndRef); + assert.equal(result.sha, "1234"); + assert.isNull(result.ref); + })); + it("sha and a ref", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "foo"), "12\n34\n"); + const result = yield SequencerStateUtil.readCommitAndRef(gitDir, + "foo"); + assert.instanceOf(result, CommitAndRef); + assert.equal(result.sha, "12"); + assert.equal(result.ref, "34"); + })); + it("too few", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "foo"), "12"); + const result = yield SequencerStateUtil.readCommitAndRef(gitDir, + "foo"); + assert.isNull(result); + })); + it("too many", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "foo"), "1\n2\n3\n"); + const result = yield SequencerStateUtil.readCommitAndRef(gitDir, + "foo"); + assert.isNull(result); + })); +}); +describe("readCommits", function () { + it("got commits", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "COMMITS"), "1\n2\n3\n"); + const result = yield SequencerStateUtil.readCommits(gitDir); + assert.deepEqual(result, ["1", "2", "3"]); + })); + it("missing commits file", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const result = yield SequencerStateUtil.readCommits(gitDir); + assert.isNull(result); + })); + it("no commits in file", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "COMMITS"), ""); + const result = yield SequencerStateUtil.readCommits(gitDir); + assert.isNull(result); + })); +}); +describe("readCurrentCommit", function () { + it("good", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "CURRENT_COMMIT"), "1\n"); + const result = yield SequencerStateUtil.readCurrentCommit(gitDir, 2); + assert.equal(result, 1); + })); + it("missing", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const result = yield SequencerStateUtil.readCurrentCommit(gitDir, 2); + assert.isNull(result); + })); + it("no lines", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "CURRENT_COMMIT"), ""); + const result = yield SequencerStateUtil.readCurrentCommit(gitDir, 2); + assert.isNull(result); + })); + it("non", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "CURRENT_COMMIT"), "x\n"); + const result = yield SequencerStateUtil.readCurrentCommit(gitDir, 2); + assert.isNull(result); + })); + it("bad index", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "CURRENT_COMMIT"), "2\n"); + const result = yield SequencerStateUtil.readCurrentCommit(gitDir, 2); + assert.isNull(result); + })); +}); +describe("readSequencerState", function () { + it("good state", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "TYPE"), + SequencerState.TYPE.MERGE + "\n"); + yield fs.writeFile(path.join(fileDir, "ORIGINAL_HEAD"), "24\n"); + yield fs.writeFile(path.join(fileDir, "TARGET"), "12\n34\n"); + yield fs.writeFile(path.join(fileDir, "COMMITS"), "1\n2\n3\n"); + yield fs.writeFile(path.join(fileDir, "CURRENT_COMMIT"), "1\n"); + const result = yield SequencerStateUtil.readSequencerState(gitDir); + assert.instanceOf(result, SequencerState); + assert.equal(result.type, SequencerState.TYPE.MERGE); + assert.equal(result.originalHead.sha, "24"); + assert.isNull(result.originalHead.ref); + assert.equal(result.target.sha, "12"); + assert.equal(result.target.ref, "34"); + assert.deepEqual(result.commits, ["1", "2", "3"]); + assert.equal(result.currentCommit, 1); + assert.isNull(result.message); + })); + it("good state with message", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "TYPE"), + SequencerState.TYPE.MERGE + "\n"); + yield fs.writeFile(path.join(fileDir, "ORIGINAL_HEAD"), "24\n"); + yield fs.writeFile(path.join(fileDir, "TARGET"), "12\n34\n"); + yield fs.writeFile(path.join(fileDir, "COMMITS"), "1\n2\n3\n"); + yield fs.writeFile(path.join(fileDir, "CURRENT_COMMIT"), "1\n"); + yield fs.writeFile(path.join(fileDir, "MESSAGE"), "foo\n"); + const result = yield SequencerStateUtil.readSequencerState(gitDir); + assert.instanceOf(result, SequencerState); + assert.equal(result.type, SequencerState.TYPE.MERGE); + assert.equal(result.originalHead.sha, "24"); + assert.isNull(result.originalHead.ref); + assert.equal(result.target.sha, "12"); + assert.equal(result.target.ref, "34"); + assert.deepEqual(result.commits, ["1", "2", "3"]); + assert.equal(result.currentCommit, 1); + assert.equal(result.message, "foo\n"); + })); + it("bad type", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "ORIGINAL_HEAD"), "24\n"); + yield fs.writeFile(path.join(fileDir, "TARGET"), "12\n34\n"); + yield fs.writeFile(path.join(fileDir, "COMMITS"), "1\n2\n3\n"); + yield fs.writeFile(path.join(fileDir, "CURRENT_COMMIT"), "1\n"); + const result = yield SequencerStateUtil.readSequencerState(gitDir); + assert.isNull(result); + })); + it("wrong type", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "TYPE"), "foo\n"); + yield fs.writeFile(path.join(fileDir, "ORIGINAL_HEAD"), "24\n"); + yield fs.writeFile(path.join(fileDir, "TARGET"), "12\n34\n"); + yield fs.writeFile(path.join(fileDir, "COMMITS"), "1\n2\n3\n"); + yield fs.writeFile(path.join(fileDir, "CURRENT_COMMIT"), "1\n"); + const result = yield SequencerStateUtil.readSequencerState(gitDir); + assert.isNull(result); + })); + it("bad head", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "TYPE"), + SequencerState.TYPE.MERGE + "\n"); + yield fs.writeFile(path.join(fileDir, "TARGET"), "12\n34\n"); + yield fs.writeFile(path.join(fileDir, "COMMITS"), "1\n2\n3\n"); + yield fs.writeFile(path.join(fileDir, "CURRENT_COMMIT"), "1\n"); + const result = yield SequencerStateUtil.readSequencerState(gitDir); + assert.isNull(result); + })); + it("bad target", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "TYPE"), + SequencerState.TYPE.MERGE + "\n"); + yield fs.writeFile(path.join(fileDir, "ORIGINAL_HEAD"), "24\n"); + yield fs.writeFile(path.join(fileDir, "COMMITS"), "1\n2\n3\n"); + yield fs.writeFile(path.join(fileDir, "CURRENT_COMMIT"), "1\n"); + const result = yield SequencerStateUtil.readSequencerState(gitDir); + assert.isNull(result); + })); + it("bad commits", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "TYPE"), + SequencerState.TYPE.MERGE + "\n"); + yield fs.writeFile(path.join(fileDir, "ORIGINAL_HEAD"), "24\n"); + yield fs.writeFile(path.join(fileDir, "TARGET"), "12\n34\n"); + yield fs.writeFile(path.join(fileDir, "CURRENT_COMMIT"), "1\n"); + const result = yield SequencerStateUtil.readSequencerState(gitDir); + assert.isNull(result); + })); + it("bad commits length", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "TYPE"), + SequencerState.TYPE.MERGE + "\n"); + yield fs.writeFile(path.join(fileDir, "ORIGINAL_HEAD"), "24\n"); + yield fs.writeFile(path.join(fileDir, "TARGET"), "12\n34\n"); + yield fs.writeFile(path.join(fileDir, "COMMITS"), "1\n2\n3\n"); + yield fs.writeFile(path.join(fileDir, "CURRENT_COMMIT"), "4\n"); + const result = yield SequencerStateUtil.readSequencerState(gitDir); + assert.isNull(result); + })); + it("bad current commit", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + yield fs.writeFile(path.join(fileDir, "TYPE"), + SequencerState.TYPE.MERGE + "\n"); + yield fs.writeFile(path.join(fileDir, "ORIGINAL_HEAD"), "24\n"); + yield fs.writeFile(path.join(fileDir, "TARGET"), "12\n34\n"); + yield fs.writeFile(path.join(fileDir, "COMMITS"), "1\n2\n3\n"); + const result = yield SequencerStateUtil.readSequencerState(gitDir); + assert.isNull(result); + })); +}); +describe("cleanSequencerState", function () { + it("breathe", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const fileDir = path.join(gitDir, "meta_sequencer"); + mkdirp.sync(fileDir); + const filePath = path.join(fileDir, "TYPE"); + yield fs.writeFile(filePath, SequencerState.TYPE.MERGE); + yield fs.readFile(filePath, "utf8"); + yield SequencerStateUtil.cleanSequencerState(gitDir); + let gone = false; + try { + yield fs.readFile(filePath, "utf8"); + } catch (e) { + gone = true; + } + assert(gone); + })); +}); +describe("writeSequencerState", function () { + it("breathe", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const original = new CommitAndRef("a", null); + const target = new CommitAndRef("b", "c"); + const initial = new SequencerState({ + type: SequencerState.TYPE.REBASE, + originalHead: original, + target: target, + commits: ["1", "2"], + currentCommit: 0 + }); + yield SequencerStateUtil.writeSequencerState(gitDir, initial); + const read = yield SequencerStateUtil.readSequencerState(gitDir); + assert.deepEqual(read, initial); + })); + it("with message", co.wrap(function *() { + const gitDir = yield TestUtil.makeTempDir(); + const original = new CommitAndRef("a", null); + const target = new CommitAndRef("b", "c"); + const initial = new SequencerState({ + type: SequencerState.TYPE.REBASE, + originalHead: original, + target: target, + commits: ["1", "2"], + currentCommit: 0, + message: "mahaha", + }); + yield SequencerStateUtil.writeSequencerState(gitDir, initial); + const read = yield SequencerStateUtil.readSequencerState(gitDir); + assert.deepEqual(read, initial); + })); +}); +describe("mapCommits", function () { + const cases = { + "just one to map": { + sequencer: new SequencerState({ + type: SequencerState.TYPE.REBASE, + originalHead: new CommitAndRef("1", null), + target: new CommitAndRef("1", "foo"), + currentCommit: 0, + commits: ["1"], + message: "foo", + }), + commitMap: { + "1": "2", + }, + expected: new SequencerState({ + type: SequencerState.TYPE.REBASE, + originalHead: new CommitAndRef("2", null), + target: new CommitAndRef("2", "foo"), + currentCommit: 0, + commits: ["2"], + message: "foo", + }), + }, + "multiple": { + sequencer: new SequencerState({ + type: SequencerState.TYPE.REBASE, + originalHead: new CommitAndRef("1", null), + target: new CommitAndRef("2", "foo"), + currentCommit: 0, + commits: ["1", "3"], + }), + commitMap: { + "1": "2", + "2": "4", + "3": "8", + }, + expected: new SequencerState({ + type: SequencerState.TYPE.REBASE, + originalHead: new CommitAndRef("2", null), + target: new CommitAndRef("4", "foo"), + currentCommit: 0, + commits: ["2", "8"], + }), + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, function () { + const result = SequencerStateUtil.mapCommits(c.sequencer, + c.commitMap); + assert.instanceOf(result, SequencerState); + assert.deepEqual(result, c.expected); + }); + }); +}); +}); diff --git a/node/test/util/shorthand_parser_util.js b/node/test/util/shorthand_parser_util.js index 66d1d70f6..d3c75990e 100644 --- a/node/test/util/shorthand_parser_util.js +++ b/node/test/util/shorthand_parser_util.js @@ -36,7 +36,31 @@ const RepoAST = require("../../lib/util/repo_ast"); const RepoASTUtil = require("../../lib/util/repo_ast_util"); const ShorthandParserUtil = require("../../lib/util/shorthand_parser_util"); +const File = RepoAST.File; + describe("ShorthandParserUtil", function () { + const SequencerState = RepoAST.SequencerState; + const CommitAndRef = SequencerState.CommitAndRef; + describe("parseCommitAndRef", function () { + const cases = { + "without ref": { + input: "foo:", + expected: new CommitAndRef("foo", null), + }, + "with ref": { + input: "bar:baz", + expected: new CommitAndRef("bar", "baz"), + }, + }; + Object.keys(cases).forEach(caseName => { + it(caseName, function () { + const c = cases[caseName]; + const result = ShorthandParserUtil.parseCommitAndRef(c.input); + assert.instanceOf(result, CommitAndRef); + assert.deepEqual(result, c.expected); + }); + }); + }); describe("findSeparator", function () { const cases = { missing: { @@ -110,6 +134,7 @@ describe("ShorthandParserUtil", function () { }); describe("parseRepoShorthandRaw", function () { const Commit = RepoAST.Commit; + const Conflict = RepoAST.Conflict; const Submodule = RepoAST.Submodule; function m(args) { let result = { @@ -135,6 +160,7 @@ describe("ShorthandParserUtil", function () { } const cases = { "just type": { i: "S", e: m({ type: "S"})}, + "sparse": { i: "%S", e: m({ type: "S", sparse: true}) }, "just another type": { i: "B", e: m({ type: "B"})}, "branch": { i: "S:Bm=2", e: m({ branches: { m: new RepoAST.Branch("2", null), }, @@ -153,7 +179,7 @@ describe("ShorthandParserUtil", function () { commits: { "1": new Commit({ parents: ["2"], - changes: { "1": "1"}, + changes: { "1": new File("1", false)}, message: "message\n", }), } @@ -173,7 +199,7 @@ describe("ShorthandParserUtil", function () { commits: { "1": new Commit({ parents: [], - changes: { y: "2" }, + changes: { y: new File("2", false) }, message: "message\n", }), }, @@ -183,7 +209,7 @@ describe("ShorthandParserUtil", function () { commits: { "1": new Commit({ parents: ["2"], - changes: { "1": "1"}, + changes: { "1": new File("1", false) }, message: "hello world", }), } @@ -192,7 +218,7 @@ describe("ShorthandParserUtil", function () { commits: { "3": new Commit({ parents: ["1","2"], - changes: { "3": "3"}, + changes: { "3": new File("3", false) }, message: "message\n", }), }, @@ -201,7 +227,10 @@ describe("ShorthandParserUtil", function () { commits: { "3": new Commit({ parents: ["1","2"], - changes: { x: "y", q: "r" }, + changes: { + x: new File("y", false), + q: new File("r", false), + }, message: "hello", }), }, @@ -210,7 +239,7 @@ describe("ShorthandParserUtil", function () { commits: { "xxx2": new Commit({ parents: ["yy"], - changes: { "xxx2": "xxx2"}, + changes: { "xxx2": new File("xxx2", false) }, message: "message\n", }), } @@ -219,7 +248,7 @@ describe("ShorthandParserUtil", function () { commits: { "1": new Commit({ parents: ["2"], - changes: { "foo": "bar"}, + changes: { "foo": new File("bar", false) }, message: "message\n", }), } @@ -228,7 +257,7 @@ describe("ShorthandParserUtil", function () { commits: { "1": new Commit({ parents: ["2"], - changes: { "foo": ""}, + changes: { "foo": new File("", false) }, message: "message\n", }), } @@ -237,7 +266,10 @@ describe("ShorthandParserUtil", function () { commits: { "1": new Commit({ parents: ["2"], - changes: { "foo": "bar", "b": "z"}, + changes: { + "foo": new File("bar", false), + "b": new File("z", false), + }, message: "message\n", }), } @@ -246,7 +278,10 @@ describe("ShorthandParserUtil", function () { commits: { "1": new Commit({ parents: ["2"], - changes: { "foo": "bar", "b": "z"}, + changes: { + "foo": new File("bar", false), + "b": new File("z", false), + }, message: "message\n", }), } @@ -262,7 +297,7 @@ describe("ShorthandParserUtil", function () { commits: { "1": new Commit({ parents: ["2"], - changes: { "1": "1"}, + changes: { "1": new File("1", false) }, message: "message\n", })}, branches: { m: null }, @@ -275,7 +310,7 @@ describe("ShorthandParserUtil", function () { commits: { "1": new Commit({ parents: ["2"], - changes: { "1": "1"}, + changes: { "1": new File("1", false) }, message: "message\n", })}, branches: { m: null }, @@ -297,12 +332,12 @@ describe("ShorthandParserUtil", function () { commits: { "1": new Commit({ parents: ["2"], - changes: { "1": "1"}, + changes: { "1": new File("1", false) }, message: "message\n", }), "3": new Commit({ parents: ["4"], - changes: { "3": "3"}, + changes: { "3": new File("3", false) }, message: "message\n", }), }, @@ -401,21 +436,29 @@ describe("ShorthandParserUtil", function () { i: "S:I x=y", e: m({ type: "S", - index: { x: "y" }, + index: { x: new File("y", false) }, }), }, "index deletion and changes": { i: "S:I x=y,q,z=r", e: m({ type: "S", - index: { x: "y", q: null, z: "r" }, + index: { + x: new File("y", false), + q: null, + z: new File("r", false), + }, }), }, "index deletion and removal": { i: "S:I x=y,q=~,z=r", e: m({ type: "S", - index: { x: "y", q: undefined, z: "r" }, + index: { + x: new File("y", false), + q: undefined, + z: new File("r", false), + }, }), }, "index submodule change": { @@ -425,25 +468,64 @@ describe("ShorthandParserUtil", function () { index: { x: new Submodule("/x", "1") }, }), }, + "index with conflict": { + i: "S:I *a=x*y*S/x:2,b=q", + e: m({ + type: "S", + index: { + a: new Conflict(new File("x", false), + new File("y", false), + new Submodule("/x", "2")), + b: new File("q", false), + } + }), + }, + "index with conflict and nulls": { + i: "S:I *a=*~*,b=q", + e: m({ + type: "S", + index: { + a: new Conflict(new File("", false), + null, + new File("", false)), + b: new File("q", false), + } + }), + }, "workdir change": { i: "S:W x=y", e: m({ type: "S", - workdir: { x: "y" }, + workdir: { x: new File("y", false) }, + }), + }, + "workdir change, executable bit set": { + i: "S:W x=+y", + e: m({ + type: "S", + workdir: { x: new File("y", true) }, }), }, "workdir deletion and changes": { i: "S:W x=y,q,z=r", e: m({ type: "S", - workdir: { x: "y", q: null, z: "r" }, + workdir: { + x: new File("y", false), + q: null, + z: new File("r", false), + }, }), }, "workdir removal and changes": { i: "S:W x=y,q=~,z=r", e: m({ type: "S", - workdir: { x: "y", q: undefined, z: "r" }, + workdir: { + x: new File("y", false), + q: undefined, + z: new File("r", false), + }, }), }, "workdir submodule change": { @@ -482,7 +564,7 @@ describe("ShorthandParserUtil", function () { branches: { master: new RepoAST.Branch("foo", null), }, - workdir: { x: "z" }, + workdir: { x: new File("z", false) }, }), }, }), @@ -524,7 +606,7 @@ describe("ShorthandParserUtil", function () { commits: { "2": new Commit({ parents: ["1"], - changes: { "2": "2"}, + changes: { "2": new File("2", false) }, message: "message\n", }), }, @@ -542,7 +624,7 @@ describe("ShorthandParserUtil", function () { commits: { "2": new Commit({ parents: ["1"], - changes: { "2": "2"}, + changes: { "2": new File("2", false) }, message: "message\n", }), }, @@ -599,6 +681,66 @@ describe("ShorthandParserUtil", function () { rebase: null, }), }, + "sequencer null": { + i: "S:Q", + e: m({ + type: "S", + sequencerState: null, + }), + }, + "sequencer with cherry": { + i: "S:QC 1:foo 3: 2 a,b,c", + e: m({ + type: "S", + sequencerState: new SequencerState({ + type: SequencerState.TYPE.CHERRY_PICK, + originalHead: new CommitAndRef("1", "foo"), + target: new CommitAndRef("3", null), + currentCommit: 2, + commits: ["a", "b", "c"], + }), + }), + }, + "sequencer with merge": { + i: "S:QM 1:foo 3: 2 a,b,c", + e: m({ + type: "S", + sequencerState: new SequencerState({ + type: SequencerState.TYPE.MERGE, + originalHead: new CommitAndRef("1", "foo"), + target: new CommitAndRef("3", null), + currentCommit: 2, + commits: ["a", "b", "c"], + }), + }), + }, + "sequencer with rebase": { + i: "S:QR 1:foo 3: 2 a,b,c", + e: m({ + type: "S", + sequencerState: new SequencerState({ + type: SequencerState.TYPE.REBASE, + originalHead: new CommitAndRef("1", "foo"), + target: new CommitAndRef("3", null), + currentCommit: 2, + commits: ["a", "b", "c"], + }), + }), + }, + "sequencer with message": { + i: "S:Qhello world#R 1:foo 3: 2 a,b,c", + e: m({ + type: "S", + sequencerState: new SequencerState({ + type: SequencerState.TYPE.REBASE, + originalHead: new CommitAndRef("1", "foo"), + target: new CommitAndRef("3", null), + currentCommit: 2, + commits: ["a", "b", "c"], + message: "hello world", + }), + }), + }, "new submodule": { i: "S:I x=Sfoo:;Ox", e: m({ @@ -631,10 +773,13 @@ describe("ShorthandParserUtil", function () { assert.deepEqual(r.branches, e.branches); assert.deepEqual(r.remotes, e.remotes); assert.deepEqual(r.index, e.index); + assert.deepEqual(r.workdir, e.workdir); assert.equal(r.head, e.head); assert.equal(r.currentBranchName, e.currentBranchName); assert.deepEqual(r.openSubmodules, e.openSubmodules); assert.deepEqual(r.rebase, e.rebase); + assert.deepEqual(r.sequencerState, e.sequencerState); + assert.equal(r.sparse, e.sparse); }); }); }); @@ -653,6 +798,12 @@ describe("ShorthandParserUtil", function () { i: "S", e: S }, + "sparse": { + i: "%S", + e: S.copy({ + sparse: true, + }), + }, "null": { i: "N", e: new RepoAST(), @@ -663,7 +814,7 @@ describe("ShorthandParserUtil", function () { commits: { "1": new Commit({ changes: { - "1": "1", + "1": new File("1", false), }, message: "message\n", }), @@ -689,7 +840,7 @@ describe("ShorthandParserUtil", function () { commits: { "1": B.commits["1"], "2": new Commit({ - changes: { "2": "2" }, + changes: { "2": new File("2", false) }, message: "message\n", parents: ["1"], }), @@ -706,7 +857,7 @@ describe("ShorthandParserUtil", function () { commits: { xyz: new Commit({ changes: { - xyz: "xyz", + xyz: new File("xyz", false), }, message: "changed xyz", }), @@ -735,7 +886,7 @@ describe("ShorthandParserUtil", function () { let commits = S.commits; commits[2] = new Commit({ parents: ["1"], - changes: { "2": "2"}, + changes: { "2": new File("2", false) }, message: "message\n", }); return commits; @@ -755,7 +906,7 @@ describe("ShorthandParserUtil", function () { let commits = S.commits; commits[2] = new Commit({ parents: ["1"], - changes: { "2": "2"}, + changes: { "2": new File("2", false) }, message: "message\n", }); return commits; @@ -784,7 +935,7 @@ describe("ShorthandParserUtil", function () { i: "S:I a=b", e: S.copy({ index: { - a: "b", + a: new File("b", false), } }), }, @@ -806,6 +957,18 @@ describe("ShorthandParserUtil", function () { rebase: new RepoAST.Rebase("foo", "1", "1"), }), }, + "sequencer": { + i: "S:QM 1:foo 1: 0 1", + e: S.copy({ + sequencerState: new SequencerState({ + type: SequencerState.TYPE.MERGE, + originalHead: new CommitAndRef("1", "foo"), + target: new CommitAndRef("1", null), + currentCommit: 0, + commits: ["1"], + }), + }), + }, }; Object.keys(cases).forEach(caseName => { it(caseName, function () { @@ -841,7 +1004,7 @@ describe("ShorthandParserUtil", function () { commits: { "1": B.commits["1"], "2": new Commit({ - changes: { "2": "2" }, + changes: { "2": new File("2", false) }, message: "message\n", parents: ["1"], }), @@ -1006,13 +1169,15 @@ describe("ShorthandParserUtil", function () { commits: { "1": new Commit({ changes: { - "README.md": "hello world" + "README.md": new File( + "hello world", + false), }, message: "the first commit", }), "2": new Commit({ parents: ["1"], - changes: { "2": "2" }, + changes: { "2": new File("2", false) }, message: "message\n", }), }, @@ -1036,8 +1201,8 @@ describe("ShorthandParserUtil", function () { openSubmodules: { foo: RepoASTUtil.cloneRepo(S, "a").copy({ branches: {}, - index: { x: "y"}, - workdir: { u: "2" }, + index: { x: new File("y", false) }, + workdir: { u: new File("2", false) }, currentBranchName: null, remotes: { origin: new Remote("a") }, }) @@ -1058,7 +1223,7 @@ describe("ShorthandParserUtil", function () { commits: { "1": new Commit({ changes: { - "README.md": "hello world", + "README.md": new File("hello world", false) }, message: "the first commit", }), @@ -1086,7 +1251,7 @@ describe("ShorthandParserUtil", function () { commits: { "1": new Commit({ changes: { - "README.md": "hello world", + "README.md": new File("hello world", false) }, message: "the first commit", }), @@ -1119,13 +1284,15 @@ describe("ShorthandParserUtil", function () { commits: { "1": new Commit({ changes: { - "README.md": "hello world" + "README.md": new File( + "hello world", + false), }, message: "the first commit", }), "2": new Commit({ parents: ["1"], - changes: { "2": "2" }, + changes: { "2": new File("2", false) }, message: "message\n", }), }, @@ -1167,7 +1334,7 @@ describe("ShorthandParserUtil", function () { commits: { "1": new RepoAST.Commit({ changes: { - "README.md": "hello world" + "README.md": new File("hello world", false) }, message: "the first commit", }), @@ -1194,13 +1361,13 @@ describe("ShorthandParserUtil", function () { commits: { "1": new Commit({ changes: { - "README.md": "hello world" + "README.md": new File("hello world", false) }, message: "the first commit", }), "8": new Commit({ parents: ["1"], - changes: { "8": "8" }, + changes: { "8": new File("8", false) }, message: "message\n", }), }, @@ -1231,18 +1398,18 @@ x=S:Efoo,8,9`, commits: { "1": new Commit({ changes: { - "README.md": "hello world" + "README.md": new File("hello world", false) }, message: "the first commit", }), "8": new Commit({ parents: ["1"], - changes: { "8": "8" }, + changes: { "8": new File("8", false) }, message: "message\n", }), "9": new Commit({ parents: ["1"], - changes: { "9": "9" }, + changes: { "9": new File("9", false) }, message: "message\n", }), }, @@ -1256,18 +1423,18 @@ x=S:Efoo,8,9`, commits: { "1": new Commit({ changes: { - "README.md": "hello world" + "README.md": new File("hello world", false) }, message: "the first commit", }), "8": new Commit({ parents: ["1"], - changes: { "8": "8" }, + changes: { "8": new File("8", false) }, message: "message\n", }), "9": new Commit({ parents: ["1"], - changes: { "9": "9" }, + changes: { "9": new File("9", false) }, message: "message\n", }), }, diff --git a/node/test/util/sparse_checkout_util.js b/node/test/util/sparse_checkout_util.js new file mode 100644 index 000000000..0bad2aa02 --- /dev/null +++ b/node/test/util/sparse_checkout_util.js @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2018, 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 path = require("path"); + +const SparseCheckoutUtil = require("../../lib/util/sparse_checkout_util"); +const TestUtil = require("../../lib/util/test_util"); + +describe("SparseCheckoutUtil", function () { +describe("getSparseCheckoutPath", function () { + it("breathing", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const result = SparseCheckoutUtil.getSparseCheckoutPath(repo); + assert.equal(result, + path.join(repo.path(), "info", "sparse-checkout")); + })); +}); +describe("inSparseMode", function () { + it("nothing configured", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const result = yield SparseCheckoutUtil.inSparseMode(repo); + assert.equal(result, false); + })); + it("configured", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const config = yield repo.config(); + yield config.setString("core.sparsecheckout", "true"); + const result = yield SparseCheckoutUtil.inSparseMode(repo); + assert.equal(result, true); + })); +}); +describe("setSparseMode", function () { + it("breathing test", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + yield SparseCheckoutUtil.setSparseMode(repo); + const isSet = yield SparseCheckoutUtil.inSparseMode(repo); + assert.equal(isSet, true); + const content = SparseCheckoutUtil.readSparseCheckout(repo); + assert.equal(content, ".gitmodules\n"); + })); +}); +describe("readSparseCheckout", function () { + it("doesn't exist", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const result = SparseCheckoutUtil.readSparseCheckout(repo); + assert.equal(result, ""); + })); + it("exists", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + yield SparseCheckoutUtil.setSparseMode(repo); + const result = SparseCheckoutUtil.readSparseCheckout(repo); + assert.equal(result, ".gitmodules\n"); + })); +}); +describe("addToSparseCheckoutFile", function () { + it("breathing", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + yield SparseCheckoutUtil.setSparseMode(repo); + yield SparseCheckoutUtil.addToSparseCheckoutFile(repo, "foo"); + const result = SparseCheckoutUtil.readSparseCheckout(repo); + assert.equal(result, ".gitmodules\nfoo\n"); + })); +}); +describe("removeFromSparseCheckoutFile", function () { + it("nothing to remove", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + yield SparseCheckoutUtil.setSparseMode(repo); + SparseCheckoutUtil.removeFromSparseCheckoutFile(repo, ["foo"]); + const result = SparseCheckoutUtil.readSparseCheckout(repo); + assert.equal(result, ".gitmodules\n"); + })); + it("remove one", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + yield SparseCheckoutUtil.setSparseMode(repo); + yield SparseCheckoutUtil.addToSparseCheckoutFile(repo, "foo"); + SparseCheckoutUtil.removeFromSparseCheckoutFile(repo, ["foo"]); + const result = SparseCheckoutUtil.readSparseCheckout(repo); + assert.equal(result, ".gitmodules\n"); + })); +}); +}); diff --git a/node/test/util/stash_util.js b/node/test/util/stash_util.js index 8e61b8fb9..5f07b8d2b 100644 --- a/node/test/util/stash_util.js +++ b/node/test/util/stash_util.js @@ -42,13 +42,13 @@ const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); const writeLog = co.wrap(function *(repo, reverseMap, logs) { const log = yield NodeGit.Reflog.read(repo, "refs/meta-stash"); - const sig = repo.defaultSignature(); + const sig = yield repo.defaultSignature(); for(let i = 0; i < logs.length; ++i) { const logSha = logs[logs.length - (i + 1)]; const sha = reverseMap[logSha]; log.append(NodeGit.Oid.fromString(sha), sig, `log of ${logSha}`); } - log.write(); + yield log.write(); }); /** @@ -153,12 +153,14 @@ x=E:Ci#i foo=bar,1=1;Cw#w foo=bar,1=1;Bi=i;Bw=w`, it(caseName, co.wrap(function *() { const stasher = co.wrap(function *(repos) { const repo = repos.x; - const status = yield StatusUtil.getRepoStatus(repo); + const status = yield StatusUtil.getRepoStatus(repo, { + showMetaChanges: true, + }); const includeUntracked = c.includeUntracked || false; const result = yield StashUtil.stashRepo(repo, status, includeUntracked); - const sig = repo.defaultSignature(); + const sig = yield repo.defaultSignature(); const commitMap = {}; const commitAndBranch = co.wrap(function *(treeId, type) { const tree = yield NodeGit.Tree.lookup(repo, treeId); @@ -204,6 +206,12 @@ x=E:Ci#i foo=bar,1=1;Cw#w foo=bar,1=1;Bi=i;Bw=w`, state: "x=S:C2-1 README.md;Bmaster=2", expected: `x=E:Cstash#s-2, ;Fmeta-stash=s`, }, + "with message": { + state: "x=S:C2-1 README.md;Bmaster=2", + expected: `x=E:Cstash#s-2, ;Fmeta-stash=s`, + message: "hello world", + expectedMessage: "hello world", + }, "closed sub": { state: "a=B|x=S:C2-1 README.md,s=Sa:1;Bmaster=2", expected: `x=E:Cstash#s-2, ;Fmeta-stash=s`, @@ -212,6 +220,12 @@ x=E:Ci#i foo=bar,1=1;Cw#w foo=bar,1=1;Bi=i;Bw=w`, state: "a=B|x=S:C2-1 README.md,s=Sa:1;Bmaster=2;Os", expected: `x=E:Cstash#s-2 ;Fmeta-stash=s`, }, + "open sub on updated commit, unstaged": { + state: `a=B:Css-1;Bss=ss| + x=S:C2-1 README.md,s=Sa:1;Bmaster=2;Os H=ss`, + expected: `x=E:Cstash#s-2 s=Sa:ss;Fmeta-stash=s; + Os Fsub-stash/ss=ss!H=1`, + }, "open sub with an added file": { state: "a=B|x=S:C2-1 README.md,s=Sa:1;Bmaster=2;Os W foo=bar", expected: `x=E:Cstash#s-2 ;Fmeta-stash=s`, @@ -277,15 +291,20 @@ x=E:Fmeta-stash=s; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; const includeUntracked = c.includeUntracked || false; - const stasher = co.wrap(function *(repos) { + const stasher = co.wrap(function *(repos, mapping) { const repo = repos.x; - const expMessage = yield StashUtil.makeLogMessage(repo); + const stashMessage = c.message || null; + let expMessage = c.expectedMessage; + if (undefined === expMessage) { + expMessage = yield StashUtil.makeLogMessage(repo); + } const status = yield StatusUtil.getRepoStatus(repo, { showMetaChanges: false, }); const result = yield StashUtil.save(repo, status, - includeUntracked); + includeUntracked, + stashMessage); const commitMap = {}; const stashId = yield NodeGit.Reference.lookup( repo, @@ -297,21 +316,36 @@ x=E:Fmeta-stash=s; const message = entry.message(); assert.equal(message, expMessage); commitMap[stashId.target().tostrS()] = "s"; - // Look up the commits made for stashed submodules and create // the appropriate mappings. for (let subName in result) { const subSha = result[subName]; - commitMap[subSha] = `s${subName}`; + if (!(subSha in mapping.commitMap)) { + commitMap[subSha] = `s${subName}`; + } + const subRepo = yield SubmoduleUtil.getRepo(repo, subName); const subStash = yield subRepo.getCommit(subSha); - const indexCommit = yield subStash.parent(1); - commitMap[indexCommit.id().tostrS()] = `si${subName}`; - if (includeUntracked) { - const untrackedCommit = yield subStash.parent(2); - commitMap[untrackedCommit.id().tostrS()] = + if (subStash.parentcount() > 1) { + const indexCommit = yield subStash.parent(1); + commitMap[indexCommit.id().tostrS()] = `si${subName}`; + if (includeUntracked) { + const untrackedCommit = yield subStash.parent(2); + commitMap[untrackedCommit.id().tostrS()] = `su${subName}`; + } + } + } + + // In some cases, the first or second parent might be + // an existing commit, but the test framework only + // wants commitMap to contain commits it has never + // seen, so we have to remove the ones that already + // existed. + for (const c of Object.keys(commitMap)) { + if (c in mapping.commitMap) { + delete commitMap[c]; } } return { @@ -457,6 +491,7 @@ x=U:Fmeta-stash=s;Cstash#s-2 s=Sa:ss; result: { s: "ss", }, + reinstateIndex: true, expected: ` x=E:Os Bss=ss! C*#ss-1,sis README.md=bar! @@ -468,29 +503,34 @@ x=E:Os Bss=ss! }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; - const applier = co.wrap(function *(repos, mapping) { - const repo = repos.x; - assert.property(mapping.reverseCommitMap, c.sha); - const sha = mapping.reverseCommitMap[c.sha]; - const result = yield StashUtil.apply(repo, sha); - if (null === c.result) { - assert.isNull(result); - } - else { - const expected = {}; - Object.keys(c.result).forEach(name => { - const sha = c.result[name]; - expected[name] = mapping.reverseCommitMap[sha]; - }); - assert.deepEqual(result, expected); - } + const reinstateIndexValues = (undefined === c.reinstateIndex) ? + [false, true] : [c.reinstateIndex]; + reinstateIndexValues.forEach(function(reinstateIndex) { + const applier = co.wrap(function* (repos, mapping) { + const repo = repos.x; + assert.property(mapping.reverseCommitMap, c.sha); + const sha = mapping.reverseCommitMap[c.sha]; + const result = yield StashUtil.apply(repo, sha, + reinstateIndex); + if (null === c.result) { + assert.isNull(result); + } + else { + const expected = {}; + Object.keys(c.result).forEach(name => { + const sha = c.result[name]; + expected[name] = mapping.reverseCommitMap[sha]; + }); + assert.deepEqual(result, expected); + } + }); + it(caseName, co.wrap(function* () { + yield RepoASTTestUtil.testMultiRepoManipulator(c.state, + c.expected, + applier, + c.fails); + })); }); - it(caseName, co.wrap(function *() { - yield RepoASTTestUtil.testMultiRepoManipulator(c.state, - c.expected, - applier, - c.fails); - })); }); }); describe("removeStash", function () { @@ -558,6 +598,7 @@ x=E:Os Bss=ss! const cases = { "nothing to pop": { init: "x=S", + fails: true, }, "failed": { init: ` @@ -618,35 +659,40 @@ x=E:Fmeta-stash=2; }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; - const popper = co.wrap(function *(repos, mapping) { - const repo = repos.x; - const revMap = mapping.reverseCommitMap; - yield writeLog(repo, revMap, c.log || []); + const reinstateIndexValues = (undefined === c.reinstateIndex) ? + [false, true] : [c.reinstateIndex]; + reinstateIndexValues.forEach(function(reinstateIndex){ + const popper = co.wrap(function *(repos, mapping) { + const repo = repos.x; + const revMap = mapping.reverseCommitMap; + yield writeLog(repo, revMap, c.log || []); - // set up stash refs in submodules, if requested + // set up stash refs in submodules, if requested - const subStash = c.subStash || {}; - for (let subName in subStash) { - const sha = revMap[subStash[subName]]; - const subRepo = yield SubmoduleUtil.getRepo(repo, subName); - const refName = `refs/sub-stash/${sha}`; - NodeGit.Reference.create(subRepo, - refName, - NodeGit.Oid.fromString(sha), - 1, - "test stash"); - } - const index = (undefined === c.index) ? 0 : c.index; - yield StashUtil.pop(repo, index); - }); - it(caseName, co.wrap(function *() { - yield RepoASTTestUtil.testMultiRepoManipulator(c.init, - c.expected, - popper, - c.fails, { - expectedTransformer: refMapper, + const subStash = c.subStash || {}; + for (let subName in subStash) { + const sha = revMap[subStash[subName]]; + const subRepo = yield SubmoduleUtil.getRepo(repo, + subName); + const refName = `refs/sub-stash/${sha}`; + NodeGit.Reference.create(subRepo, + refName, + NodeGit.Oid.fromString(sha), + 1, + "test stash"); + } + const index = (undefined === c.index) ? 0 : c.index; + yield StashUtil.pop(repo, index, reinstateIndex, true); }); - })); + it(caseName, co.wrap(function *() { + yield RepoASTTestUtil.testMultiRepoManipulator(c.init, + c.expected, + popper, + c.fails, { + expectedTransformer: refMapper, + }); + })); + }); }); }); @@ -684,4 +730,164 @@ meta-stash@{1}: log of 1 })); }); }); + + describe("shadow", function () { + const cases = { + "clean": { + state: "x=S", + includeMeta: true, + }, + "a new file": { + state: "x=S:W foo/bar=2", + expected: "x=E:Cm-1 foo/bar=2;Bm=m", + includeMeta: true, + }, + "a new file, included in subrepo list": { + state: "x=S:W foo/bar=2", + includedSubrepos: ["foo/bar"], + expected: "x=E:Cm-1 foo/bar=2;Bm=m", + includeMeta: true, + }, + "a new file, untracked not included": { + state: "x=S:W foo/bar=2", + includeMeta: true, + includeUntracked: false, + }, + "a new file, tracked but not included in subrepo list": { + state: "x=S:W foo/bar=2", + includedSubrepos: ["bar/"], + includeMeta: true, + }, + "with a message": { + state: "x=S:W foo/bar=2", + expected: "x=E:Cfoo\n#m-1 foo/bar=2;Bm=m", + message: "foo", + includeMeta: true, + incrementTimestamp: true, + }, + "deleted file": { + state: "x=S:W README.md", + expected: "x=E:Cm-1 README.md;Bm=m", + includeMeta: true, + }, + "change in index": { + state: "x=S:I README.md=3", + expected: "x=E:Cm-1 README.md=3;Bm=m", + includeMeta: true, + }, + "new file in index": { + state: "x=S:I foo/bar=8", + expected: "x=E:Cm-1 foo/bar=8;Bm=m", + includeMeta: true, + }, + "unchanged submodule": { + state: "a=B|x=U:W README.md=2;Os", + expected: "x=E:Cm-2 README.md=2;Bm=m", + includeMeta: true, + }, + "new, staged commit in opened submodule": { + state: "a=B:Ca-1;Ba=a|x=U:I s=Sa:a;Os", + expected: "x=E:Cm-2 s=Sa:a;Bm=m", + includeMeta: true, + }, + "new, staged commit in unopened submodule": { + state: "a=B:Ca-1;Ba=a|x=U:I s=Sa:a", + expected: "x=E:Cm-2 s=Sa:a;Bm=m", + includeMeta: true, + }, + "new, unstaged commit in opened submodule": { + state: "a=B:Ca-1;Ba=a|x=U:C3-2;Bmaster=3;Os H=a", + expected: "x=E:Cm-3 s=Sa:a;Bm=m", + includeMeta: true, + }, + "new file in open submodule, untracked not included": { + state: "a=B|x=U:Os W x/y/z=3", + includeMeta: true, + expected: ` +x=E:Cm-2 s=Sa:s;Bm=m;Os W x/y/z=3!Cs-1 x/y/z=3!Bs=s`, + }, + "new file in open submodule, staged": { + state: "a=B|x=U:Os I x/y/z=3", + expected: ` +x=E:Cm-2 s=Sa:s;Bm=m;Os I x/y/z=3!Cs-1 x/y/z=3!Bs=s`, + includeMeta: true, + includeUntracked: false, + }, + "new submodule with a commit": { + state: "a=B|x=S:I s=Sa:;Os Cq-1!H=q", + includeMeta: false, + expected: ` +x=E:Cm-1 s=Sa:q;Bm=m` + }, + "new submodule with a new file": { + state: "a=B|x=S:I s=Sa:;Os W foo=bar", + includeMeta: false, + expected: ` +x=E:Cm-1 s=Sa:s;Bm=m;Os Cs foo=bar!Bs=s!W foo=bar` + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + const shadower = co.wrap(function *(repos, mapping) { + // If a meta commit was made, map it to "m" and create a branch + // named "m" pointing to it. For each submodule commit made, + // map the commit to one with that submodule's name, and make a + // branch in the submodule with that name. + + const repo = repos.x; + const message = c.message || "message\n"; + const meta = c.includeMeta; + const includeUntracked = + undefined === c.includeUntracked || c.includeUntracked; + const incrementTimestamp = + (undefined === c.incrementTimestamp) ? + false : c.incrementTimestamp; + const includedSubrepos = + (undefined === c.includedSubrepos) ? + [] : c.includedSubrepos; + const result = yield StashUtil.makeShadowCommit( + repo, + message, + incrementTimestamp, + meta, + includeUntracked, + includedSubrepos, + false); + + const commitMap = {}; + if (null !== result) { + const metaSha = result.metaCommit; + const commit = yield repo.getCommit(metaSha); + const head = yield repo.getHeadCommit(); + if (incrementTimestamp) { + assert.equal(commit.time(), head.time() + 1); + } + commitMap[metaSha] = "m"; + yield NodeGit.Branch.create(repo, "m", commit, 1); + for (let path in result.subCommits) { + const subSha = result.subCommits[path]; + const subRepo = yield SubmoduleUtil.getRepo(repo, + path); + const subCommit = yield subRepo.getCommit(subSha); + if (!(subSha in mapping.commitMap)) { + commitMap[subSha] = path; + yield NodeGit.Branch.create(subRepo, + path, + subCommit, + 1); + } + } + } + return { + commitMap: commitMap, + }; + }); + it(caseName, co.wrap(function *() { + yield RepoASTTestUtil.testMultiRepoManipulator(c.state, + c.expected, + shadower, + c.fails); + })); + }); + }); }); diff --git a/node/test/util/status_util.js b/node/test/util/status_util.js index e3c7fef73..50a926fba 100644 --- a/node/test/util/status_util.js +++ b/node/test/util/status_util.js @@ -32,25 +32,34 @@ const assert = require("chai").assert; const co = require("co"); +const path = require("path"); +const NodeGit = require("nodegit"); +const DiffUtil = require("../../lib/util/diff_util"); const Rebase = require("../../lib/util/rebase"); const RepoAST = require("../../lib/util/repo_ast"); const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); const RepoStatus = require("../../lib/util/repo_status"); +const SequencerState = require("../../lib/util/sequencer_state"); const StatusUtil = require("../../lib/util/status_util"); const SubmoduleUtil = require("../../lib/util/submodule_util"); const SubmoduleConfigUtil = require("../../lib/util/submodule_config_util"); +const UserError = require("../../lib/util/user_error"); // test utilities describe("StatusUtil", function () { + const CommitAndRef = SequencerState.CommitAndRef; + const TYPE = SequencerState.TYPE; + const FILEMODE = NodeGit.TreeEntry.FILEMODE; + const BLOB = FILEMODE.BLOB; const FILESTATUS = RepoStatus.FILESTATUS; const RELATION = RepoStatus.Submodule.COMMIT_RELATION; const Submodule = RepoStatus.Submodule; const Commit = Submodule.Commit; const Index = Submodule.Index; const Workdir = Submodule.Workdir; - + describe("remapSubmodule", function () { const cases = { "all": { @@ -104,6 +113,13 @@ describe("StatusUtil", function () { staged: { x: RepoStatus.FILESTATUS.ADDED }, workdir: { y: RepoStatus.FILESTATUS.ADDED }, rebase: new Rebase("foo", "1", "1"), + sequencerState: new SequencerState({ + type: TYPE.MERGE, + originalHead: new CommitAndRef("1", null), + target: new CommitAndRef("1", "baz"), + commits: ["1"], + currentCommit: 0, + }), }), commitMap: { "1": "3"}, urlMap: {}, @@ -113,6 +129,13 @@ describe("StatusUtil", function () { staged: { x: RepoStatus.FILESTATUS.ADDED }, workdir: { y: RepoStatus.FILESTATUS.ADDED }, rebase: new Rebase("foo", "3", "3"), + sequencerState: new SequencerState({ + type: TYPE.MERGE, + originalHead: new CommitAndRef("3", null), + target: new CommitAndRef("3", "baz"), + commits: ["3"], + currentCommit: 0, + }), }), }, "with a sub": { @@ -417,7 +440,6 @@ x=S:C2-1 s=Sa:a;I s=Sa:b;Bmaster=2;Os H=1`, }), }, }; - Object.keys(cases).forEach(caseName => { const c = cases[caseName]; it(caseName, co.wrap(function *() { @@ -466,6 +488,71 @@ x=S:C2-1 s=Sa:a;I s=Sa:b;Bmaster=2;Os H=1`, assert.deepEqual(mappedResult, c.expected); })); }); + it("misconfigured", co.wrap(function *() { + const result = yield StatusUtil.getSubmoduleStatus(null, + null, + null, + null, + null, + null); + assert.isUndefined(result); + })); + }); + + describe("readConflicts", function () { + const Conflict = RepoStatus.Conflict; + const FILEMODE = NodeGit.TreeEntry.FILEMODE; + const BLOB = FILEMODE.BLOB; + const cases = { + "trivial": { + state: "S", + expected: {}, + }, + "a conflict": { + state: "S:I *README.md=a*b*c,foo=bar", + expected: { + "README.md": new Conflict(BLOB, BLOB, BLOB), + }, + }, + "missing ancestor": { + state: "S:I *README.md=~*a*c", + expected: { + "README.md": new Conflict(null, BLOB, BLOB), + }, + }, + "missing our": { + state: "S:I *README.md=a*~*c", + expected: { + "README.md": new Conflict(BLOB, null, BLOB), + }, + }, + "missing their": { + state: "S:I *README.md=a*a*~", + expected: { + "README.md": new Conflict(BLOB, BLOB, null), + }, + }, + "submodule": { + state: "S:I *README.md=a*a*S:1", + expected: { + "README.md": new Conflict(BLOB, BLOB, FILEMODE.COMMIT), + }, + }, + "ignore submodule sha conflict": { + state: "S:I *README.md=a*S:1*S:1", + expected: {}, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo(c.state); + const repo = written.repo; + const index = yield repo.index(); + const result = StatusUtil.readConflicts(index, []); + assert.deepEqual(result, c.expected); + })); + }); }); describe("getRepoStatus", function () { @@ -482,13 +569,6 @@ x=S:C2-1 s=Sa:a;I s=Sa:b;Bmaster=2;Os H=1`, headCommit: "1", }), }, - "bare": { - state: "x=B", - expected: new RepoStatus({ - currentBranchName: "master", - headCommit: "1", - }), - }, "empty": { state: { x: new RepoAST()}, expected: new RepoStatus(), @@ -500,8 +580,25 @@ x=S:C2-1 s=Sa:a;I s=Sa:b;Bmaster=2;Os H=1`, rebase: new Rebase("master", "2", "3"), }), }, + "sequencer": { + state: "x=S:C2-1;C3-1;Bfoo=3;Bmaster=2;QM 1: 2:foo 1 2,3", + expected: new RepoStatus({ + headCommit: "2", + currentBranchName: "master", + sequencerState: new SequencerState({ + type: TYPE.MERGE, + originalHead: new CommitAndRef("1", null), + target: new CommitAndRef("2", "foo"), + commits: ["2", "3"], + currentCommit: 1, + }), + }), + }, "staged change": { state: "x=S:I README.md=whoohoo", + options: { + showMetaChanges: true, + }, expected: new RepoStatus({ currentBranchName: "master", headCommit: "1", @@ -518,7 +615,10 @@ x=S:C2-1 s=Sa:a;I s=Sa:b;Bmaster=2;Os H=1`, "x/y/q": FILESTATUS.ADDED, }, }), - options: { showAllUntracked: true, }, + options: { + untrackedFilesOption: DiffUtil.UNTRACKED_FILES_OPTIONS.ALL, + showMetaChanges: true + }, }, "ignore meta": { state: "x=S:I README.md=whoohoo", @@ -527,16 +627,16 @@ x=S:C2-1 s=Sa:a;I s=Sa:b;Bmaster=2;Os H=1`, headCommit: "1", staged: {}, }), - options: { showMetaChanges: false }, }, // The logic for filtering is tested earlier; here, we just need to // validate that the option is propagated properly. "path filtered out in meta": { - state: "x=S:I x/y=a,README.md=sss", + state: "x=S:I x/y=a,README.md=sss,y=foo", options: { paths: ["README.md"], + showMetaChanges: true, }, expected: new RepoStatus({ currentBranchName: "master", @@ -544,13 +644,26 @@ x=S:C2-1 s=Sa:a;I s=Sa:b;Bmaster=2;Os H=1`, staged: { "README.md": FILESTATUS.MODIFIED }, }), }, + "path resolved with cwd": { + state: "x=S:I x/y=a,README.md=sss,y=foo", + options: { + cwd: "x", + paths: ["y"], + showMetaChanges: true, + }, + expected: new RepoStatus({ + currentBranchName: "master", + headCommit: "1", + staged: { "x/y": FILESTATUS.ADDED }, + }), + }, // Submodules are tested earlier, but we need to test a few // concerns: // // - make sure that they're included, even if they have been // removed in the index or added in the index - // - `showAllUntracked` propagates + // - `untrackedFilesOption` propagates // - path filtering works "sub no show all added": { @@ -590,7 +703,9 @@ x=S:C2-1 s=Sa:a;I s=Sa:b;Bmaster=2;Os H=1`, }), }, }), - options: { showAllUntracked: true, }, + options: { + untrackedFilesOption: DiffUtil.UNTRACKED_FILES_OPTIONS.ALL, + }, }, "sub added to index": { state: "a=S|x=S:I s=Sa:1", @@ -602,6 +717,9 @@ x=S:C2-1 s=Sa:a;I s=Sa:b;Bmaster=2;Os H=1`, index: new Index("1", "a", null), }), }, + staged: { + ".gitmodules": FILESTATUS.ADDED, + } }), }, "sub removed from index": { @@ -614,6 +732,9 @@ x=S:C2-1 s=Sa:a;I s=Sa:b;Bmaster=2;Os H=1`, commit: new Commit("1", "a"), }), }, + staged: { + ".gitmodules": FILESTATUS.REMOVED, + } }), }, "sub changed in workdir": { @@ -632,6 +753,9 @@ x=S:C2-1 s=Sa:a;I s=Sa:b;Bmaster=2;Os H=1`, }), RELATION.SAME), }), }, + staged: { + ".gitmodules": FILESTATUS.ADDED, + } }), }, "show root untracked": { @@ -643,9 +767,17 @@ x=S:C2-1 s=Sa:a;I s=Sa:b;Bmaster=2;Os H=1`, "x/": FILESTATUS.ADDED, }, }), + options: { showMetaChanges: true, }, }, - "filtered out": { + "no changes, ingored": { state: "a=B|x=U", + expected: new RepoStatus({ + currentBranchName: "master", + headCommit: "2", + }), + }, + "filtered out": { + state: "a=B:Ca-1;Ba=a|x=U:I s=Sa:a", options: { paths: ["README.md"], }, @@ -655,19 +787,14 @@ x=S:C2-1 s=Sa:a;I s=Sa:b;Bmaster=2;Os H=1`, }), }, "filtered in": { - state: "a=B|x=U", + state: "a=B:Ca-1;Ba=a|x=U:I s=Sa:a", options: { - paths: ["s"], + paths: ["README.md"], }, expected: new RepoStatus({ currentBranchName: "master", headCommit: "2", - submodules: { - s: new Submodule({ - commit: new Commit("1", "a"), - index: new Index("1", "a", RELATION.SAME), - }), - }, + submodules: {}, }), }, "deep filter": { @@ -723,13 +850,50 @@ x=S:C2-1 s=Sa:a;I s=Sa:b;Bmaster=2;Os H=1`, }, }), }, + "new with staged": { + state: "a=B|x=S:I s=Sa:;Os I q=r", + expected: new RepoStatus({ + headCommit: "1", + currentBranchName: "master", + staged: { + ".gitmodules": FILESTATUS.ADDED, + }, + submodules: { + s: new Submodule({ + commit: null, + index: new Index(null, "a", null), + workdir: new Workdir(new RepoStatus({ + headCommit: null, + staged: { + q: FILESTATUS.ADDED, + }, + }), null), + }), + }, + }), + }, + "conflict": { + state: "x=S:I *foo=~*ff*~", + expected: new RepoStatus({ + currentBranchName: "master", + headCommit: "1", + staged: { + foo: new RepoStatus.Conflict(null, BLOB, null), + }, + }), + }, }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; it(caseName, co.wrap(function *() { const w = yield RepoASTTestUtil.createMultiRepos(c.state); + const options = c.options || {}; + if (undefined !== options.cwd) { + options.cwd = path.join(w.repos.x.workdir(), + options.cwd); + } const result = yield StatusUtil.getRepoStatus(w.repos.x, - c.options); + options); assert.instanceOf(result, RepoStatus); const mappedResult = StatusUtil.remapRepoStatus(result, w.commitMap, @@ -738,4 +902,50 @@ x=S:C2-1 s=Sa:a;I s=Sa:b;Bmaster=2;Os H=1`, })); }); }); + + describe("ensureReady", function () { + const cases = { + "ready": { + input: new RepoStatus(), + fails: false, + }, + "rebase": { + input: new RepoStatus({ + rebase: new Rebase("foo", "bart", "baz"), + }), + fails: true, + }, + "sequencer": { + input: new RepoStatus({ + sequencerState: new SequencerState({ + type: TYPE.MERGE, + originalHead: new CommitAndRef("1", null), + target: new CommitAndRef("1", "baz"), + commits: ["1"], + currentCommit: 0, + }), + }), + fails: true, + }, + }; + Object.keys(cases).forEach(caseName => { + it(caseName, function () { + const c = cases[caseName]; + let exception; + try { + StatusUtil.ensureReady(c.input); + } catch (e) { + exception = e; + } + if (undefined === exception) { + assert.equal(c.fails, false); + } else { + if (!(exception instanceof UserError)) { + throw exception; + } + assert.equal(c.fails, true); + } + }); + }); + }); }); diff --git a/node/test/util/stitch_util.js b/node/test/util/stitch_util.js new file mode 100644 index 000000000..263c41724 --- /dev/null +++ b/node/test/util/stitch_util.js @@ -0,0 +1,1526 @@ +/* + * Copyright (c) 2016, 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 NodeGit = require("nodegit"); + +const BulkNotesUtil = require("../../lib/util/bulk_notes_util"); +const RepoAST = require("../../lib/util/repo_ast"); +const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); +const RepoASTUtil = require("../../lib/util/repo_ast_util"); +const StitchUtil = require("../../lib/util/stitch_util"); +const SubmoduleChange = require("../../lib/util/submodule_change"); +const SubmoduleConfigUtil = require("../../lib/util/submodule_config_util"); +const SubmoduleUtil = require("../../lib/util/submodule_util"); +const TreeUtil = require("../../lib/util/tree_util"); + +const FILEMODE = NodeGit.TreeEntry.FILEMODE; + +/** + * Replace refs and notes with their equivalent logical mapping. + */ +function refMapper(actual, mapping) { + const fetchedSubRe = /(commits\/(?:[a-z/]*\/)?)(.*)/; + const commitMap = mapping.commitMap; + let result = {}; + + // Map refs + + Object.keys(actual).forEach(repoName => { + const ast = actual[repoName]; + const refs = ast.refs; + const newRefs = {}; + Object.keys(refs).forEach(refName => { + const ref = refs[refName]; + const fetchedSubMatch = fetchedSubRe.exec(refName); + if (null !== fetchedSubMatch) { + const sha = fetchedSubMatch[2]; + const logical = commitMap[sha]; + const newRefName = refName.replace(fetchedSubRe, + `$1${logical}`); + newRefs[newRefName] = ref; + return; // RETURN + } + newRefs[refName] = ref; + }); + + // map notes + + const notes = ast.notes; + const newNotes = {}; + Object.keys(notes).forEach(refName => { + const commits = notes[refName]; + if (StitchUtil.referenceNoteRef === refName || + StitchUtil.changeCacheRef === refName) { + // We can't check these in the normal way, so we have a + // special test case instead. + + return; // RETURN + } + if ("refs/notes/stitched/converted" !== refName) { + newNotes[refName] = commits; + return; // RETURN + } + const newCommits = {}; + Object.keys(commits).forEach(originalSha => { + let stitchedSha = commits[originalSha]; + if ("" !== stitchedSha) { + stitchedSha = commitMap[stitchedSha]; + } + newCommits[originalSha] = stitchedSha; + }); + newNotes[refName] = newCommits; + }); + result[repoName] = ast.copy({ + refs: newRefs, + notes: newNotes, + }); + }); + return result; +} + +/** + * Return the submodule changes in the specified `commit` in the specified + * `repo`. + * + * @param {NodeGit.Repository} repo + * @param {NodeGit.Commit} commit + */ +const getCommitChanges = co.wrap(function *(repo, commit) { + assert.instanceOf(repo, NodeGit.Repository); + assert.instanceOf(commit, NodeGit.Commit); + + const commitParents = yield commit.getParents(); + let parentCommit = null; + if (0 !== commitParents.length) { + parentCommit = commitParents[0]; + } + return yield SubmoduleUtil.getSubmoduleChanges(repo, + commit, + parentCommit, + true); +}); + +describe("StitchUtil", function () { +describe("readAllowedToFailList", function () { + it("breathing", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo(` +S:N refs/notes/stitched/allowed_to_fail 1=`); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const headSha = head.id().tostrS(); + const result = yield StitchUtil.readAllowedToFailList(repo); + assert(result.has(headSha)); + })); +}); +describe("makeConvertedNoteContent", function () { + it("with target sha", function () { + const result = StitchUtil.makeConvertedNoteContent("foo"); + assert.equal(result, "foo"); + }); + it("without target sha", function () { + const result = StitchUtil.makeConvertedNoteContent(null); + assert.equal(result, ""); + }); +}); +describe("makeStitchCommitMessage", function () { + it("just a meta", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const tree = yield head.getTree(); + const sig = NodeGit.Signature.create("me", "me@me", 3, 60); + const commitId = yield NodeGit.Commit.create(repo, + null, + sig, + sig, + null, + "hello world\n", + tree, + 0, + []); + const commit = yield repo.getCommit(commitId); + const result = StitchUtil.makeStitchCommitMessage(commit, {}); + const expected = "hello world\n"; + assert.equal(result, expected); + })); + it("same sub", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const tree = yield head.getTree(); + const sig = NodeGit.Signature.create("me", "me@me", 3, 60); + const commitId = yield NodeGit.Commit.create(repo, + null, + sig, + sig, + null, + "hello world\n", + tree, + 0, + []); + const commit = yield repo.getCommit(commitId); + const result = StitchUtil.makeStitchCommitMessage(commit, { + "foo/bar": commit, + }); + const expected = "hello world\n"; + assert.equal(result, expected); + })); + it("diff sub message", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const tree = yield head.getTree(); + const sig = NodeGit.Signature.create("me", "me@me", 3, 60); + const commitId = yield NodeGit.Commit.create(repo, + null, + sig, + sig, + null, + "hello world\n", + tree, + 0, + []); + const commit = yield repo.getCommit(commitId); + const fooBarId = yield NodeGit.Commit.create(repo, + null, + sig, + sig, + null, + "foo bar\n", + tree, + 0, + []); + const fooBar = yield repo.getCommit(fooBarId); + const result = StitchUtil.makeStitchCommitMessage(commit, { + "foo/bar": fooBar, + }); + const expected = `\ +hello world + +From 'foo/bar' + +foo bar +`; + assert.equal(result, expected); + })); + it("diff sub name", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const tree = yield head.getTree(); + const sig = NodeGit.Signature.create("me", "me@me", 3, 60); + const commitId = yield NodeGit.Commit.create(repo, + null, + sig, + sig, + null, + "hello world\n", + tree, + 0, + []); + const commit = yield repo.getCommit(commitId); + const fooBarSig = NodeGit.Signature.create("you", "me@me", 3, 60); + const fooBarId = yield NodeGit.Commit.create(repo, + null, + fooBarSig, + fooBarSig, + null, + "hello world\n", + tree, + 0, + []); + const fooBar = yield repo.getCommit(fooBarId); + const result = StitchUtil.makeStitchCommitMessage(commit, { + "foo/bar": fooBar, + }); + const expected = `\ +hello world + +From 'foo/bar' +Author: you +`; + assert.equal(result, expected); + })); + it("diff sub email", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const tree = yield head.getTree(); + const sig = NodeGit.Signature.create("me", "me@me", 3, 60); + const commitId = yield NodeGit.Commit.create(repo, + null, + sig, + sig, + null, + "hello world\n", + tree, + 0, + []); + const commit = yield repo.getCommit(commitId); + const fooBarSig = NodeGit.Signature.create("me", "you@you", 3, 60); + const fooBarId = yield NodeGit.Commit.create(repo, + null, + fooBarSig, + fooBarSig, + null, + "hello world\n", + tree, + 0, + []); + const fooBar = yield repo.getCommit(fooBarId); + const result = StitchUtil.makeStitchCommitMessage(commit, { + "foo/bar": fooBar, + }); + const expected = `\ +hello world + +From 'foo/bar' +Author: me +`; + assert.equal(result, expected); + })); + it("diff time", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const tree = yield head.getTree(); + const sig = NodeGit.Signature.create("me", "me@me", 3, 60); + const commitId = yield NodeGit.Commit.create(repo, + null, + sig, + sig, + null, + "hello world\n", + tree, + 0, + []); + const commit = yield repo.getCommit(commitId); + const fooBarSig = NodeGit.Signature.create("me", "me@me", 2, 60); + const fooBarId = yield NodeGit.Commit.create(repo, + null, + fooBarSig, + fooBarSig, + null, + "hello world\n", + tree, + 0, + []); + const fooBar = yield repo.getCommit(fooBarId); + const result = StitchUtil.makeStitchCommitMessage(commit, { + "foo/bar": fooBar, + }); + const expected = `\ +hello world + +From 'foo/bar' +Date: 1/1/1970, 01:00:02 100 +`; + assert.equal(result, expected); + })); + it("diff offset", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const tree = yield head.getTree(); + const sig = NodeGit.Signature.create("me", "me@me", 3, 60); + const commitId = yield NodeGit.Commit.create(repo, + null, + sig, + sig, + null, + "hello world\n", + tree, + 0, + []); + const commit = yield repo.getCommit(commitId); + const fooBarSig = NodeGit.Signature.create("me", "me@me", 3, 120); + const fooBarId = yield NodeGit.Commit.create(repo, + null, + fooBarSig, + fooBarSig, + null, + "hello world\n", + tree, + 0, + []); + const fooBar = yield repo.getCommit(fooBarId); + const result = StitchUtil.makeStitchCommitMessage(commit, { + "foo/bar": fooBar, + }); + const expected = `\ +hello world + +From 'foo/bar' +Date: 1/1/1970, 02:00:03 200 +`; + assert.equal(result, expected); + })); +}); +describe("makeReferenceNoteContent", function () { + it("breathing", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const headSha = head.id().tostrS(); + const subs = {}; + subs["foo/bar"] = head; + const result = + JSON.parse(StitchUtil.makeReferenceNoteContent("1", subs)); + const expected = { + metaRepoCommit: "1", + submoduleCommits: {}, + }; + expected.submoduleCommits["foo/bar"] = headSha; + assert.deepEqual(result, expected); + })); +}); +describe("listCommitsInOrder", function () { + const cases = { + "trival": { + input: { + a: [], + }, + entry: "a", + expected: ["a"], + }, + "skipped entry": { + input: {}, + entry: "a", + expected: [], + }, + "one parent": { + input: { + a: ["b"], + b: [], + }, + entry: "a", + expected: ["b", "a"], + }, + "one parent, skipped": { + input: { + b: [], + }, + entry: "b", + expected: ["b"], + }, + "two parents": { + input: { + b: ["a", "c"], + a: [], + c: [], + }, + entry: "b", + expected: ["a", "c", "b"], + }, + "chain": { + input: { + a: ["b"], + b: ["c"], + c: [], + }, + entry: "a", + expected: ["c", "b", "a"], + }, + "reference the same commit twice in history": { + input: { + c: ["b"], + a: ["b"], + d: ["a", "c"], + b: ["e", "f"], + e: ["f"], + f: [], + }, + entry: "d", + expected: ["f", "e", "b", "a", "c", "d"], + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, function () { + const result = StitchUtil.listCommitsInOrder(c.entry, c.input); + assert.deepEqual(c.expected, result); + }); + }); +}); +describe("refMapper", function () { + const Commit = RepoAST.Commit; + const cases = { + "trivial": { + input: { + }, + expected: { + }, + }, + "simple": { + input: { + x: new RepoAST(), + }, + expected: { + x: new RepoAST(), + }, + }, + "no transform": { + input: { + x: new RepoAST({ + commits: { "1": new Commit() }, + refs: { + "foo/bar": "1", + }, + }), + }, + expected: { + x: new RepoAST({ + commits: { "1": new Commit() }, + refs: { + "foo/bar": "1", + }, + }), + }, + }, + "note": { + input: { + x: new RepoAST({ + head: "fffd", + commits: { + "fffd": new Commit(), + }, + notes: { + "refs/notes/stitched/converted": { + "fffd": "ffff", + }, + }, + }), + }, + expected: { + x: new RepoAST({ + head: "fffd", + commits: { + "fffd": new Commit(), + }, + notes: { + "refs/notes/stitched/converted": { + "fffd": "1", + }, + }, + }), + }, + commitMap: { + "ffff": "1", + }, + }, + "note, empty": { + input: { + x: new RepoAST({ + head: "fffd", + commits: { + "fffd": new Commit(), + }, + notes: { + "refs/notes/stitched/converted": { + "fffd": "", + }, + }, + }), + }, + expected: { + x: new RepoAST({ + head: "fffd", + commits: { + "fffd": new Commit(), + }, + notes: { + "refs/notes/stitched/converted": { + "fffd": "", + }, + }, + }), + }, + commitMap: { + "ffff": "1", + }, + }, + "note, unrelated": { + input: { + x: new RepoAST({ + head: "fffd", + commits: { + "fffd": new Commit(), + }, + notes: { + "refs/notes/foo": { + "fffd": "ffff", + }, + }, + }), + }, + expected: { + x: new RepoAST({ + head: "fffd", + commits: { + "fffd": new Commit(), + }, + notes: { + "refs/notes/foo": { + "fffd": "ffff", + }, + }, + }), + }, + commitMap: { + "ffff": "1", + }, + }, + "fetched sub": { + input: { + x: new RepoAST({ + commits: { + "fffd": new Commit(), + }, + refs: { + "commits/ffff": "fffd", + }, + }), + }, + expected: { + x: new RepoAST({ + commits: { + "fffd": new Commit(), + }, + refs: { + "commits/1": "fffd", + }, + }), + }, + commitMap: { + "ffff": "1", + "aabb": "2", + }, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, () => { + const result = refMapper(c.input, { + commitMap: c.commitMap || {}, + }); + RepoASTUtil.assertEqualRepoMaps(result, c.expected); + }); + }); + +}); +describe("computeModulesFile", function () { + const cases = { + "one kept": { + newUrls: { foo: "bar/baz" }, + keepAsSubmodule: (name) => name === "foo", + expected: { foo: "bar/baz" }, + }, + "one not": { + newUrls: { foo: "bar/baz", bar: "zip/zap", }, + keepAsSubmodule: (name) => name === "foo", + expected: { foo: "bar/baz" }, + }, + "path omitted": { + newUrls: { foo: "bar/baz" }, + keepAsSubmodule: (name) => name === "foo", + expected: {}, + adjustPath: () => null, + }, + "path changed": { + newUrls: { foo: "bar/baz" }, + keepAsSubmodule: (name) => name === "foo", + adjustPath: () => "bam/bap", + expected: { "bam/bap": "bar/baz" }, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S"); + const repo = written.repo; + const text = SubmoduleConfigUtil.writeConfigText(c.expected); + const BLOB = 3; + const db = yield repo.odb(); + const id = yield db.write(text, text.length, BLOB); + const adjustPath = c.adjustPath || ((x) => x); + const result = yield StitchUtil.computeModulesFile( + repo, + c.newUrls, + c.keepAsSubmodule, + adjustPath); + assert.instanceOf(result, TreeUtil.Change); + assert.equal(id.tostrS(), result.id.tostrS(), "ids"); + assert.equal(FILEMODE.BLOB, result.mode, "mode"); + })); + }); +}); +describe("writeSubmoduleChangeCache", function () { + it("breathing", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S:C2-1;H=2"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const headSha = head.id().tostrS(); + const next = (yield head.getParents())[0]; + const nextSha = next.id().tostrS(); + const changes = {}; + changes[headSha] = { + "foo/bar": new SubmoduleChange("1", "2", null) + }; + changes[nextSha] = { + "baz/bam": new SubmoduleChange("3", "4", null) + }; + yield StitchUtil.writeSubmoduleChangeCache(repo, changes); + const refName = StitchUtil.changeCacheRef; + const headNote = yield NodeGit.Note.read(repo, refName, headSha); + const headObj = JSON.parse(headNote.message()); + assert.deepEqual(headObj, { + "foo/bar": { oldSha: "1", newSha: "2"}, + }); + const nextNote = yield NodeGit.Note.read(repo, refName, nextSha); + const nextObj = JSON.parse(nextNote.message()); + assert.deepEqual(nextObj, { + "baz/bam": { oldSha: "3", newSha: "4" }, + }); + })); +}); +describe("readSubmoduleChangeCache", function () { + it("breathing", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S:C2-1;H=2"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const headSha = head.id().tostrS(); + const next = (yield head.getParents())[0]; + const nextSha = next.id().tostrS(); + const changes = {}; + changes[headSha] = { + "foo/bar": new SubmoduleChange("1", "2", null) + }; + changes[nextSha] = { + "baz/bam": new SubmoduleChange("3", "4", null) + }; + yield StitchUtil.writeSubmoduleChangeCache(repo, changes); + const read = yield StitchUtil.readSubmoduleChangeCache(repo); + const expected = {}; + expected[headSha] = { + "foo/bar": { + oldSha: "1", + newSha: "2", + }, + }; + expected[nextSha] = { + "baz/bam": { + oldSha: "3", + newSha: "4", + }, + }; + assert.deepEqual(read, expected); + })); +}); +describe("sameInAnyOtherParent", function () { + const cases = { + "no other parents": { + state: "B:Ca;C2-1 s=S.:a;Ba=a;Bmaster=2", + expected: false, + }, + "missing in other parent": { + state: "B:Ca;C3-1,2 s=S.:a;C2-1 foo=bar;Ba=a;Bmaster=3", + expected: false, + }, + "different in other parent": { + state: ` +B:Ca;Cb;C3-1,2 s=S.:a;C2-1 s=S.:b;Ba=a;Bmaster=3;Bb=b`, + expected: false, + }, + "same in other parent": { + state: "B:Ca;C3-1,2 s=S.:a;C2-1 s=S.:a;Ba=a;Bmaster=3", + expected: true, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo(c.state); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const a = yield repo.getBranchCommit("a"); + const aSha = a.id().tostrS(); + const result = yield StitchUtil.sameInAnyOtherParent(repo, + head, + "s", + aSha); + assert.equal(result, c.expected); + })); + }); +}); +describe("writeStitchedCommit", function () { + const cases = { + "trivial, no subs": { + input: "x=S", + commit: "1", + parents: [], + keepAsSubmodule: () => false, + subCommits: {}, + expected: ` +x=E:Cthe first commit#s ;Bstitched=s`, + }, + "trivial, no subs, with a parent": { + input: "x=S:C2;Bp=2", + commit: "1", + parents: ["2"], + keepAsSubmodule: () => false, + subCommits: {}, + expected: `x=E:Cthe first commit#s-2 ;Bstitched=s`, + }, + "new stitched sub": { + input: ` +x=B:Ca;Cfoo#2-1 s=S.:a;Ba=a;Bmaster=2`, + commit: "2", + parents: [], + keepAsSubmodule: () => false, + subCommits: { s: "a" }, + expected: `x=E:C*#s s/a=a;Bstitched=s`, + }, + "new stitched sub, with parent": { + input: ` +x=B:Ca;C2-1 s=S.:a;Ba=a;Bmaster=2`, + commit: "2", + parents: ["1"], + keepAsSubmodule: () => false, + subCommits: { s: "a" }, + expected: `x=E:C*#s-1 s/a=a;Bstitched=s`, + }, + "2 new stitched subs": { + input: ` +x=B:Ca;Cb;C2-1 s=S.:a,t=S.:b;Ba=a;Bb=b;Bmaster=2`, + commit: "2", + parents: [], + keepAsSubmodule: () => false, + subCommits: { s: "a", t: "b" }, + expected: ` +x=E:C*#s s/a=a,t/b=b;Bstitched=s`, + }, + "modified stitched": { + input: ` +x=B:Ca;Cb;Cc;C2-1 s=S.:a,t=S.:b;C3-2 s=S.:c;Ba=a;Bb=b;Bc=c;Bmaster=3`, + commit: "3", + parents: [], + keepAsSubmodule: () => false, + subCommits: { s: "c" }, + expected: `x=E:C*#s s/c=c;Bstitched=s`, + }, + "deletion stitched": { + input: ` +x=B:Ca;Cb-a f=c,a; C2-1 s=S.:a;C3-2 s=S.:b;Bb=b;Bmaster=3; + Cr s/a=a;Br=r`, + commit: "3", + parents: ["r"], + keepAsSubmodule: () => false, + subCommits: { s: "b" }, + expected: `x=E:C*#s-r s/f=c,s/a;Bstitched=s`, + }, + "submodule deletion stitched": { + input: ` +x=B:Ca;Cb-a;Cc a/b=1,c/d=2;C2-1 s=S.:a,t/u=S.:c;C3-2 s=S.:b,t/u; + Bb=b;Bc=c;Bmaster=3; + Cr s/a=a,t/u/a/b=1,t/u/c/d=2;Br=r`, + commit: "3", + parents: ["r"], + keepAsSubmodule: () => false, + subCommits: { s: "b" }, + expected: `x=E:C*#s-r s/b=b,t/u/a/b,t/u/c/d;Bstitched=s`, + }, + "submodule deletion, but new subs added under it": { + input: ` +x=B:Ca;Cb-a;C2-1 s=S.:a,t=S.:b;C3-2 s=S.:b,t/u=Sa:a,t; + Bb=b;Bmaster=3; + Cr s/a=a,t/b=b;Br=r`, + commit: "3", + parents: ["r"], + keepAsSubmodule: () => false, + subCommits: { s: "b", "t/u": "a" }, + expected: `x=E:C*#s-r s/b=b,t/b,t/u/a=a;Bstitched=s`, + }, + "removed stitched": { + input: ` +x=B:Ca;Cb;Cc s/a=b;Cfoo#2-1 s=S.:a,t=S.:b;C3-2 s;Ba=a;Bb=b;Bc=c;Bmaster=3`, + commit: "3", + parents: ["c"], + keepAsSubmodule: () => false, + subCommits: {}, + expected: `x=E:Cs-c s/a;Bstitched=s`, + }, + "kept": { + input: ` +x=B:Ca;Cb;C2-1 s=S.:a,t=S.:b;Ba=a;Bb=b;Bmaster=2`, + commit: "2", + parents: [], + keepAsSubmodule: (name) => "t" === name, + subCommits: { s: "a" }, + expected: `x=E:C*#s s/a=a,t=S.:b;Bstitched=s`, + }, + "modified kept": { + input: ` +x=B:Ca;Cb;Ba=a;Bb=b;C2-1 s=S.:a;C3-2 s=S.:b;Cp foo=bar,s=S.:a;Bmaster=3;Bp=p`, + commit: "3", + parents: ["p"], + keepAsSubmodule: (name) => "s" === name, + subCommits: {}, + expected: `x=E:Cs-p s=S.:b;Bstitched=s`, + }, + "removed kept": { + input: ` +x=B:Ca;Ba=a;C2-1 s=S.:a;C3-2 s;Cp foo=bar,s=S.:a;Bmaster=3;Bp=p`, + commit: "3", + parents: ["p"], + keepAsSubmodule: (name) => "s" === name, + subCommits: {}, + expected: `x=E:Cs-p s;Bstitched=s`, + }, + "empty commit, but not skipped": { + input: ` +x=B:Ca;Cfoo#2 ;Ba=a;Bmaster=2;Bfoo=1`, + commit: "2", + parents: ["1"], + keepAsSubmodule: () => false, + subCommits: {}, + expected: `x=E:C*#s-1 ;Bstitched=s`, + }, + "empty commit, skipped": { + input: ` +x=B:Ca;Cfoo#2 ;Ba=a;Bmaster=2;Bfoo=1`, + commit: "2", + parents: [], + keepAsSubmodule: () => false, + skipEmpty: true, + isNull: true, + subCommits: {}, + }, + "skipped empty, with parent": { + input: ` +x=B:Ca;C2-1 ;Ba=a;Bmaster=2;Bstitched=1`, + commit: "2", + parents: ["1"], + keepAsSubmodule: () => false, + skipEmpty: true, + subCommits: {}, + }, + "adjusted to new path": { + input: ` +x=B:Ca;Cfoo#2-1 s=S.:a;Ba=a;Bmaster=2`, + commit: "2", + parents: [], + keepAsSubmodule: () => false, + adjustPath: () => "foo/bar", + subCommits: { "foo/bar": "a" }, + expected: `x=E:C*#s foo/bar/a=a;Bstitched=s`, + }, + "missing commit": { + input: ` +a=B|b=B:Cb-1;Bb=b|x=U:C3-2 s=Sa:b;H=3`, + commit: "3", + parents: [], + keepAsSubmodule: () => false, + fails: true, + }, + "missing commit, in allowed_to_fail list": { + input: ` +a=B|b=B:Cb-1;Bb=b|x=U:C3-2 s=Sa:b;H=3`, + commit: "3", + parents: [], + keepAsSubmodule: () => false, + allowed_to_fail: ["3"], + subCommits: {}, + expected: `x=E:Cs ;Bstitched=s`, + }, + "omit, from subCommits, non-new commits (existed in one parent)": { + input: ` +a=B:Ca a=a;Ba=a| +x=S:C2-1 s=Sa:a;C3-1 t=Sa:a;C4-2,3 t=Sa:a,u=Sa:a;H=4;Ba=a`, + commit: "4", + parents: [], + keepAsSubmodule: () => false, + subCommits: { "u": "a" }, + expected: `x=E:C*#s u/a=a,t/a=a;Bstitched=s`, + }, + "omit, from subCommits, with adjusted path": { + input: ` +a=B:Ca a=a;Ba=a| +x=S:C2-1 s=Sa:a;C3-1 t=Sa:a;C4-2,3 t=Sa:a,u=Sa:a;H=4;Ba=a`, + commit: "4", + parents: [], + keepAsSubmodule: () => false, + subCommits: { "foo/bar/u": "a" }, + adjustPath: (path) => `foo/bar/${path}`, + expected: `x=E:C*#s foo/bar/u/a=a,foo/bar/t/a=a;Bstitched=s`, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const stitcher = co.wrap(function *(repos, maps) { + const x = repos.x; + const revMap = maps.reverseCommitMap; + const commit = yield x.getCommit(revMap[c.commit]); + const parents = + yield c.parents.map(co.wrap(function *(sha) { + return yield x.getCommit(revMap[sha]); + })); + const adjustPath = c.adjustPath || ((x) => x); + const skipEmpty = c.skipEmpty || false; + + const changes = yield getCommitChanges(x, commit); + const allowed_list = c.allowed_to_fail || []; + const allowed_to_fail = new Set((allowed_list).map(c => { + return revMap[c]; + })); + const stitch = yield StitchUtil.writeStitchedCommit( + x, + commit, + changes, + parents, + c.keepAsSubmodule, + adjustPath, + skipEmpty, + allowed_to_fail); + const subCommits = {}; + for (let path in stitch.subCommits) { + const commit = stitch.subCommits[path]; + const sha = commit.id().tostrS(); + subCommits[path] = maps.commitMap[sha]; + } + assert.deepEqual(subCommits, c.subCommits); + if (true === c.isNull) { + assert(null === stitch.stitchedCommit, + "stitchedCommit should have been null"); + return; + } else { + // Need to root the commit we wrote + yield NodeGit.Reference.create(x, + "refs/heads/stitched", + stitch.stitchedCommit, + 1, + "stitched"); + } + const stitchSha = stitch.stitchedCommit.id().tostrS(); + const commitMap = {}; + if (!(stitchSha in maps.commitMap)) { + commitMap[stitch.stitchedCommit.id().tostrS()] = "s"; + } + return { + commitMap, + }; + }); + yield RepoASTTestUtil.testMultiRepoManipulator(c.input, + c.expected, + stitcher, + c.fails, { + actualTransformer: refMapper, + }); + })); + }); + it("messaging", co.wrap(function *() { + + const state = "B:Ca;C2-1 s=S.:a;Ba=a;Bmaster=2"; + const written = yield RepoASTTestUtil.createRepo(state); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const changes = yield getCommitChanges(repo, head); + const allowed_to_fail = new Set(); + const stitch = yield StitchUtil.writeStitchedCommit(repo, + head, + changes, + [], + () => false, + (x) => x, + false, + allowed_to_fail); + const expected = StitchUtil.makeStitchCommitMessage(head, + stitch.subCommits); + const stitchedCommit = stitch.stitchedCommit; + const actual = stitchedCommit.message(); + assert.deepEqual(expected.split("\n"), actual.split("\n")); + })); +}); +describe("listSubmoduleChanges", function () { + it("empty", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const result = yield StitchUtil.listSubmoduleChanges(repo, [head]); + const expected = {}; + expected[head.id().tostrS()] = {}; + assert.deepEqual(result, expected); + })); + it("with one", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S:C2-1 s=Sa:1;H=2"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const expected = {}; + const parents = yield head.getParents(); + const parent = parents[0]; + const firstSha = parent.id().tostrS(); + expected[head.id().tostrS()] = { + "s": new SubmoduleChange(null, firstSha, null), + }; + expected[firstSha] = {}; + const result = + yield StitchUtil.listSubmoduleChanges(repo, [head, parent]); + assert.deepEqual(result, expected); + })); +}); +describe("listFetches", function () { + const cases = { + "trivial": { + state: "S", + toFetch: [], + keepAsSubmodule: () => false, + expected: {}, + }, + "a sub, not picked": { + state: "S:C2-1 s=S/a:1;Bmaster=2", + toFetch: ["1"], + keepAsSubmodule: () => false, + expected: {}, + }, + "added sub": { + state: "S:C2-1 s=S/a:1;Bmaster=2", + toFetch: ["2"], + keepAsSubmodule: () => false, + expected: { + "s": [ + { metaSha: "2", url: "/a", sha: "1" }, + ], + }, + }, + "added sub kept": { + state: "S:C2-1 s=S/a:1;Bmaster=2", + toFetch: ["2"], + keepAsSubmodule: (name) => "s" === name, + expected: {}, + }, + "adjusted to null": { + state: "S:C2-1 s=S/a:1;Bmaster=2", + toFetch: ["2"], + keepAsSubmodule: () => false, + adjustPath: () => null, + expected: {}, + }, + "changed sub": { + state: "S:Cx-1;Bx=x;C2-1 s=S/a:1;C3-2 s=S/a:x;Bmaster=3", + toFetch: ["3"], + keepAsSubmodule: () => false, + expected: { + "s": [ + { metaSha: "3", url: "/a", sha: "x" }, + ], + }, + }, + "changed sub kept": { + state: "S:Cx-1;Bx=x;C2-1 s=S/a:1;C3-2 s=S/a:x;Bmaster=3", + toFetch: ["3"], + keepAsSubmodule: (name) => "s" === name, + expected: {}, + }, + "two changes in a sub": { + state: "S:Cx-1;Bx=x;C2-1 s=S/a:1;C3-2 s=S/a:x;Bmaster=3", + toFetch: ["2", "3"], + keepAsSubmodule: () => false, + expected: { + "s": [ + { metaSha: "3", url: "/a", sha: "x" }, + { metaSha: "2", url: "/a", sha: "1" }, + ], + }, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo(c.state); + const repo = written.repo; + const revMap = written.oldCommitMap; + const commitMap = written.commitMap; + const toFetch = yield c.toFetch.map(co.wrap(function *(e) { + const sha = revMap[e]; + return yield repo.getCommit(sha); + })); + const adjustPath = c.adjustPath || ((x) => x); + const changes = yield StitchUtil.listSubmoduleChanges(repo, + toFetch); + const result = yield StitchUtil.listFetches(repo, + toFetch, + changes, + c.keepAsSubmodule, + adjustPath); + function mapFetch(f) { + return { + url: f.url, + metaSha: commitMap[f.metaSha], + sha: commitMap[f.sha], + }; + } + for (let name in result) { + result[name] = result[name].map(mapFetch); + } + assert.deepEqual(result, c.expected); + })); + }); +}); +describe("fetchSubCommits", function () { + const cases = { + "trivial": { + input: "a=B|x=S", + fetches: [], + url: "a", + }, + "one, w sub": { + input: "a=B:Cz-1;Bz=z|x=U", + fetches: [ + { + url: "../a", + sha: "z", + metaSha: "2" + } + ], + url: "a", + expected: "x=E:Fcommits/x/z=z", + }, + "one, w sub no need to fetch": { + input: "a=B|x=U", + fetches: [ + { + url: "a", + sha: "1", + metaSha: "2" + } + ], + url: "a", + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const fetcher = co.wrap(function *(repos, maps) { + const x = repos.x; + const revMap = maps.reverseCommitMap; + const fetches = c.fetches.map(e => { + return { + url: e.url, + sha: revMap[e.sha], + metaSha: revMap[e.metaSha], + }; + }); + const url = maps.reverseUrlMap[c.url]; + yield StitchUtil.fetchSubCommits(x, "x", url, fetches); + }); + yield RepoASTTestUtil.testMultiRepoManipulator(c.input, + c.expected, + fetcher, + c.fails, { + includeRefsCommits: true, + actualTransformer: refMapper, + }); + + })); + }); +}); +describe("makeAdjustPathFunction", function () { + const cases = { + "null root": { + root: null, + filename: "foo", + expected: "foo", + }, + "match it": { + root: "foo/", + filename: "foo/bar", + expected: "bar", + }, + "miss it": { + root: "foo/", + filename: "meh", + expected: null, + }, + "match it with missing slash": { + root: "foo", + filename: "foo/bar", + expected: "bar", + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, function () { + const adjustPath = StitchUtil.makeAdjustPathFunction(c.root); + const result = adjustPath(c.filename); + assert.equal(result, c.expected); + }); + }); +}); +describe("readConvertedContent", function () { + it("empty", function () { + assert.equal(StitchUtil.readConvertedContent(""), null); + }); + it("not empty", function () { + assert.equal(StitchUtil.readConvertedContent("1"), "1"); + }); +}); +describe("readConvertedCommit", function () { + it("missing", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo(`S`); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const headSha = head.id().tostrS(); + const result = yield StitchUtil.readConvertedCommit(repo, headSha); + assert.isUndefined(result); + })); + it("there", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo(` +S:N refs/notes/stitched/converted 1=`); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const headSha = head.id().tostrS(); + const result = yield StitchUtil.readConvertedCommit(repo, headSha); + assert.isNull(result); + })); +}); +describe("readConvertedCommits", function () { + it("breathing", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo(` +S:C2-1;B2=2;N refs/notes/stitched/converted 1=; + N refs/notes/stitched/converted 2=1`); + const repo = written.repo; + const one = yield repo.getHeadCommit(); + const oldGetCommit = repo.getCommit.bind(repo); + repo.getCommit = co.wrap(function* (sha) { + if (sha === "1") { + return one; + } + return yield oldGetCommit(sha); + }); + + const two = yield repo.getBranchCommit("2"); + const twoSha = two.id().tostrS(); + const result = yield StitchUtil.readConvertedCommits(repo); + const expected = {}; + expected[twoSha] = "1"; + assert.deepEqual(expected, result); + })); +}); +describe("makeGetConvertedCommit", function () { + it("empty", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo(`S`); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const headSha = head.id().tostrS(); + const fun = StitchUtil.makeGetConvertedCommit(repo, {}); + const result = yield fun(headSha); + assert.isUndefined(result); + })); + it("got one", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo(` +S:N refs/notes/stitched/converted 1=`); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const headSha = head.id().tostrS(); + const fun = StitchUtil.makeGetConvertedCommit(repo, {}); + const result = yield fun(headSha); + assert.isUndefined(result); + + // Now delete and make sure we're remembering the result. + + NodeGit.Reference.remove(repo, StitchUtil.convertedNoteRef); + const nextResult = yield fun(headSha); + assert.isUndefined(nextResult); + })); + it("got one from cache", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo(`S`); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const headSha = head.id().tostrS(); + const cache = {}; + cache[headSha] = null; + const fun = StitchUtil.makeGetConvertedCommit(repo, cache); + const result = yield fun(headSha); + assert.isNull(result); + })); +}); +describe("listCommitsToStitch", function () { + // We don't need to validate the ordering part; that is check in the + // test driver for 'listCommitsInOrder'. We need to validate basic + // functionality, and that we stop at previously converted commits. + + it("trivial", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const getConv = StitchUtil.makeGetConvertedCommit(repo, {}); + const result = + yield StitchUtil.listCommitsToStitch(repo, head, getConv); + const headSha = head.id().tostrS(); + const resultShas = result.map(c => c.id().tostrS()); + assert.deepEqual([headSha], resultShas); + })); + + it("note present, stitched commit missing", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo("S"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const headSha = head.id().tostrS(); + const notes = {}; + notes[headSha] = StitchUtil.makeConvertedNoteContent(null); + yield BulkNotesUtil.writeNotes(repo, + StitchUtil.convertedNoteRef, + notes); + const getConv = StitchUtil.makeGetConvertedCommit(repo, {}); + const result = + yield StitchUtil.listCommitsToStitch(repo, head, getConv); + const resultShas = result.map(c => c.id().tostrS()); + assert.deepEqual([headSha], resultShas); + })); + + it("with parents", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo( + "S:C3-2,4;C4-2;C2-1;C5-3,4;Bmaster=5"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const getConv = StitchUtil.makeGetConvertedCommit(repo, {}); + const result = + yield StitchUtil.listCommitsToStitch(repo, head, getConv); + const expected = ["1", "2", "4", "3", "5"]; + const resultShas = result.map(c => { + const sha = c.id().tostrS(); + return written.commitMap[sha]; + }); + assert.deepEqual(expected, resultShas); + })); + + it("with parents and marker", co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo( + "S:C3-2,4;C4-2;C2-1;C5-3,4;Bmaster=5"); + const repo = written.repo; + const head = yield repo.getHeadCommit(); + const twoSha = written.oldCommitMap["2"]; + const notes = {}; + notes[twoSha] = StitchUtil.makeConvertedNoteContent(twoSha); + yield BulkNotesUtil.writeNotes(repo, + StitchUtil.convertedNoteRef, + notes); + const getConv = StitchUtil.makeGetConvertedCommit(repo, {}); + const result = + yield StitchUtil.listCommitsToStitch(repo, head, getConv); + const expected = ["4", "3", "5"]; + const resultShas = result.map(c => { + const sha = c.id().tostrS(); + return written.commitMap[sha]; + }); + assert.deepEqual(expected, resultShas); + })); +}); +describe("stitch", function () { + const cases = { + "breathing": { + input: ` +x=B:Ca;Cfoo#2-1 s=S.:a;Ba=a;Bmaster=2`, + commit: "2", + targetBranchName: "my-branch", + keepAsSubmodule: () => false, + numParallel: 8, + preloadCache: true, + expected: ` +x=E:C*#s2-s1 s/a=a;C*#s1 ; +Bmy-branch=s2; +N refs/notes/stitched/converted 2=s2; +N refs/notes/stitched/converted 1=s1`, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const stitcher = co.wrap(function *(repos, maps) { + const x = repos.x; + const options = { + numParallel: c.numParallel, + keepAsSubmodule: c.keepAsSubmodule, + preloadCache: c.preloadCache, + }; + if ("fetch" in c) { + options.fetch = c.fetch; + } + if ("url" in c) { + options.url = c.url; + } + if ("joinRoot" in c) { + options.joinRoot = c.joinRoot; + } + if ("skipEmpty" in c) { + options.skipEmpty = c.skipEmpty; + } + const revMap = maps.reverseCommitMap; + yield StitchUtil.stitch(x.path(), + revMap[c.commit], + c.targetBranchName, + options); + const noteRefs = []; + function listNoteRefs(_, objectId) { + noteRefs.push(objectId.tostrS()); + } + yield NodeGit.Note.foreach(x, + StitchUtil.convertedNoteRef, + listNoteRefs); + const commitMap = {}; + yield noteRefs.map(co.wrap(function *(noteRef) { + const note = yield NodeGit.Note.read( + x, + StitchUtil.convertedNoteRef, + noteRef); + const content = note.message(); + if ("" !== content) { + const name = "s" + maps.commitMap[noteRef]; + commitMap[content] = name; + } + })); + return { + commitMap, + }; + }); + yield RepoASTTestUtil.testMultiRepoManipulator(c.input, + c.expected, + stitcher, + c.fails, { + actualTransformer: refMapper, + }); + })); + }); +}); +}); diff --git a/node/test/util/submodule_change.js b/node/test/util/submodule_change.js new file mode 100644 index 000000000..bf4c9c8b3 --- /dev/null +++ b/node/test/util/submodule_change.js @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2017, 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 SubmoduleChange = require("../../lib/util/submodule_change"); + +describe("SubmoduleChange", function () { + it("breathing", function () { + const change = new SubmoduleChange("old", "new", null); + assert.equal(change.oldSha, "old"); + assert.equal(change.newSha, "new"); + }); +}); diff --git a/node/test/util/submodule_config_util.js b/node/test/util/submodule_config_util.js index e71d6a48e..5b4c99d26 100644 --- a/node/test/util/submodule_config_util.js +++ b/node/test/util/submodule_config_util.js @@ -37,13 +37,145 @@ const NodeGit = require("nodegit"); const path = require("path"); const rimraf = require("rimraf"); -const DeinitUtil = require("../../lib/util/deinit_util"); +const SparseCheckoutUtil = require("../../lib/util/sparse_checkout_util"); const SubmoduleConfigUtil = require("../../lib/util/submodule_config_util"); const TestUtil = require("../../lib/util/test_util"); const UserError = require("../../lib/util/user_error"); describe("SubmoduleConfigUtil", function () { + describe("clearSubmoduleConfigEntry", function () { + function configPath(repo) { + return path.join(repo.path(), "config"); + } + function getConfigContent(repo) { + return fs.readFileSync(configPath(repo), "utf8"); + } + it("noop", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const content = getConfigContent(repo); + yield SubmoduleConfigUtil.clearSubmoduleConfigEntry(repo.path(), + "foo"); + const result = getConfigContent(repo); + assert.equal(content, result); + })); + it("remove breathing", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const baseSubRepo = yield TestUtil.createSimpleRepository(); + const baseSubPath = baseSubRepo.workdir(); + const content = getConfigContent(repo); + yield NodeGit.Submodule.addSetup(repo, baseSubPath, "x/y", 1); + yield SubmoduleConfigUtil.clearSubmoduleConfigEntry(repo.path(), + "x/y"); + const result = getConfigContent(repo); + assert.equal(content, result); + })); + }); + + describe("deinit", function () { + + // Going to do a simple test here to verify that after closing a + // submodule: + // + // - the submodule dir contains only the `.git` line file. + // - the git repo is in a clean state + + it("breathing", co.wrap(function *() { + + // Create and set up repos. + + const repo = yield TestUtil.createSimpleRepository(); + const baseSubRepo = yield TestUtil.createSimpleRepository(); + const baseSubPath = baseSubRepo.workdir(); + const subHead = yield baseSubRepo.getHeadCommit(); + + // Set up the submodule. + + const sub = yield NodeGit.Submodule.addSetup(repo, + baseSubPath, + "x/y", + 1); + const subRepo = yield sub.open(); + const origin = yield subRepo.getRemote("origin"); + yield origin.connect(NodeGit.Enums.DIRECTION.FETCH, + new NodeGit.RemoteCallbacks(), + function () {}); + yield subRepo.fetch("origin", {}); + subRepo.setHeadDetached(subHead.id().tostrS()); + yield sub.addFinalize(); + + // Commit the submodule it. + + yield TestUtil.makeCommit(repo, ["x/y", ".gitmodules"]); + + // Verify that the status currently indicates a visible submodule. + + const addedStatus = yield NodeGit.Submodule.status(repo, "x/y", 0); + const WD_UNINITIALIZED = (1 << 7); // means "closed" + assert(!(addedStatus & WD_UNINITIALIZED)); + + // Then close it and recheck status. + + yield SubmoduleConfigUtil.deinit(repo, ["x/y"]); + const closedStatus = + yield NodeGit.Submodule.status(repo, "x/y", 0); + assert(closedStatus & WD_UNINITIALIZED); + })); + it("sparse mode", co.wrap(function *() { + + // Create and set up repos. + + const repo = yield TestUtil.createSimpleRepository(); + const baseSubRepo = yield TestUtil.createSimpleRepository(); + const baseSubPath = baseSubRepo.workdir(); + const subHead = yield baseSubRepo.getHeadCommit(); + + // Set up the submodule. + + const sub = yield NodeGit.Submodule.addSetup(repo, + baseSubPath, + "x/y", + 1); + yield NodeGit.Submodule.addSetup(repo, baseSubPath, "x/z", 1); + const subRepo = yield sub.open(); + const origin = yield subRepo.getRemote("origin"); + yield origin.connect(NodeGit.Enums.DIRECTION.FETCH, + new NodeGit.RemoteCallbacks(), + function () {}); + yield subRepo.fetch("origin", {}); + subRepo.setHeadDetached(subHead.id().tostrS()); + yield sub.addFinalize(); + + // Commit the submodule it. + + yield TestUtil.makeCommit(repo, ["x/y", ".gitmodules"]); + + yield SparseCheckoutUtil.setSparseMode(repo); + yield SubmoduleConfigUtil.deinit(repo, ["x/y"]); + + // Verify that directory for sub is gone + + let failed = false; + try { + yield fs.readdir(path.join(repo.workdir(), "x", "y")); + } catch (e) { + failed = true; + } + assert(failed); + + // verify we clean the root when all is gone + + failed = false; + yield SubmoduleConfigUtil.deinit(repo, ["x/z"]); + try { + yield fs.readdir(path.join(repo.workdir(), "x")); + } catch (e) { + failed = true; + } + assert(failed); + })); + }); + describe("computeRelativeGitDir", function () { const cases = { "simple": { @@ -406,6 +538,36 @@ describe("SubmoduleConfigUtil", function () { })); }); + describe("getSubmodulesFromWorkdir", function () { + // We know that the actual parsing is done by `parseSubmoduleConfig`; + // we just need to check that the parsing happens and that it works in + // the case where there is no `.gitmodules` file. + + it("no gitmodules", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const result = SubmoduleConfigUtil.getSubmodulesFromWorkdir(repo); + assert.deepEqual(result, {}); + })); + + it("with gitmodules", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const modulesPath = path.join(repo.workdir(), + SubmoduleConfigUtil.modulesFileName); + + yield fs.writeFile(modulesPath, `\ +[submodule "x/y"] + path = x/y +[submodule "x/y"] + url = /foo/bar/baz +` + ); + const result = SubmoduleConfigUtil.getSubmodulesFromWorkdir(repo); + assert.deepEqual(result, { + "x/y": "/foo/bar/baz", + }); + })); + }); + describe("getConfigPath", function () { it("breathing", co.wrap(function *() { const repo = yield TestUtil.createSimpleRepository(); @@ -429,8 +591,7 @@ describe("SubmoduleConfigUtil", function () { describe("initSubmodule", function () { it("breathing", co.wrap(function *() { const repoPath = yield TestUtil.makeTempDir(); - yield fs.mkdir(path.join(repoPath, ".git")); - const configPath = path.join(repoPath, ".git", "config"); + const configPath = path.join(repoPath, "config"); yield fs.writeFile(configPath, "foo\n"); yield SubmoduleConfigUtil.initSubmodule(repoPath, "xxx", "yyy"); const data = yield fs.readFile(configPath, { @@ -440,6 +601,22 @@ describe("SubmoduleConfigUtil", function () { foo [submodule "xxx"] \turl = yyy +`; + assert.equal(data, expected); + })); + it("already there", co.wrap(function *() { + const repoPath = yield TestUtil.makeTempDir(); + const configPath = path.join(repoPath, "config"); + yield fs.writeFile(configPath, "foo\n"); + yield SubmoduleConfigUtil.initSubmodule(repoPath, "xxx", "zzz"); + yield SubmoduleConfigUtil.initSubmodule(repoPath, "xxx", "yyy"); + const data = yield fs.readFile(configPath, { + encoding: "utf8" + }); + const expected =`\ +foo +[submodule "xxx"] +\turl = yyy `; assert.equal(data, expected); })); @@ -462,19 +639,11 @@ foo describe("initSubmoduleAndRepo", function () { - const runTest = co.wrap(function *(repo, - subRootRepo, - url, - subName, - originUrl) { - if (undefined === originUrl) { - originUrl = ""; - } + /** Setup a simple meta repo with one submodule (not opened). */ + const setupMeta = co.wrap(function *(subRootRepo, repo, url, subName) { const subHead = yield subRootRepo.getHeadCommit(); - const submodule = yield NodeGit.Submodule.addSetup(repo, - url, - subName, - 1); + const submodule = + yield NodeGit.Submodule.addSetup(repo,url, subName, 1); const subRepo = yield submodule.open(); yield subRepo.fetchAll(); subRepo.setHeadDetached(subHead.id()); @@ -483,19 +652,32 @@ foo newHead, NodeGit.Reset.TYPE.HARD); yield submodule.addFinalize(); - const sig = repo.defaultSignature(); + const sig = yield repo.defaultSignature(); yield repo.createCommitOnHead([".gitmodules", subName], sig, sig, "my message"); - yield DeinitUtil.deinit(repo, subName); + yield SubmoduleConfigUtil.deinit(repo, [subName]); + return subHead; + }); + + const runTest = co.wrap(function *(repo, + subRootRepo, + url, + subName, + originUrl) { + if (undefined === originUrl) { + originUrl = ""; + } + const subHead = yield setupMeta(subRootRepo, repo, url, subName); const repoPath = repo.workdir(); - const result = yield SubmoduleConfigUtil.initSubmoduleAndRepo( - originUrl, - repo, - subName, - url, - null); + const result = + yield SubmoduleConfigUtil.initSubmoduleAndRepo(originUrl, + repo, + subName, + url, + null, + false); assert.instanceOf(result, NodeGit.Repository); assert(TestUtil.isSameRealPath(result.workdir(), path.join(repoPath, subName))); @@ -531,18 +713,45 @@ foo const sub = yield NodeGit.Submodule.lookup(repo, "foo"); const subRepo = yield sub.open(); NodeGit.Remote.setUrl(subRepo, "origin", "/bar"); - yield DeinitUtil.deinit(repo, "foo"); + yield SubmoduleConfigUtil.deinit(repo, ["foo"]); const newSub = yield SubmoduleConfigUtil.initSubmoduleAndRepo("", repo, "foo", url, - null); + null, + false); const remote = yield newSub.getRemote("origin"); const newUrl = remote.url(); assert.equal(newUrl, url); })); + it("reset workdir if open in bare first", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const subRootRepo = yield TestUtil.createSimpleRepository(); + const url = subRootRepo.workdir(); + const originUrl = ""; + const subName = "foo"; + yield setupMeta(subRootRepo, repo, url, subName); + const newSub1 = + yield SubmoduleConfigUtil.initSubmoduleAndRepo(originUrl, + repo, + subName, + url, + null, + true); + assert.notExists(newSub1.workdir()); + const newSub2 = + yield SubmoduleConfigUtil.initSubmoduleAndRepo(originUrl, + repo, + subName, + url, + null, + false); + assert.equal(path.relative(repo.workdir(), newSub2.workdir()), + subName); + })); + it("deep name", co.wrap(function *() { const repo = yield TestUtil.createSimpleRepository(); const subRootRepo = yield TestUtil.createSimpleRepository(); @@ -594,12 +803,12 @@ foo newHead, NodeGit.Reset.TYPE.HARD); yield submodule.addFinalize(); - const sig = repo.defaultSignature(); + const sig = yield repo.defaultSignature(); yield repo.createCommitOnHead([".gitmodules", "foo"], sig, sig, "my message"); - yield DeinitUtil.deinit(repo, "foo"); + yield SubmoduleConfigUtil.deinit(repo, ["foo"]); // Remove `foo` dir, otherwise, we will not need to re-init the // repo and the template will not be executed. @@ -612,7 +821,8 @@ foo repo, "foo", url, - templateDir); + templateDir, + false); const copiedPath = path.join(repo.path(), "modules", @@ -623,136 +833,6 @@ foo assert.equal(read, data); })); }); - - describe("mergeSubmoduleConfigs", function () { - const cases = { - "trivial": { - lhs: {}, - rhs: {}, - mergeBase: {}, - expected: {}, - }, - "add from left": { - lhs: { foo: "bar"}, - rhs: {}, - mergeBase: {}, - expected: { foo: "bar",}, - }, - "add from right": { - lhs: {}, - rhs: { foo: "bar"}, - mergeBase: {}, - expected: { foo: "bar",}, - }, - "removed from both": { - lhs: {}, - rhs: {}, - mergeBase: { foo: "bar" }, - expected: {}, - }, - "removed from left": { - lhs: {}, - rhs: { foo: "bar" }, - mergeBase: { foo: "bar" }, - expected: {}, - }, - "removed from right": { - lhs: { foo: "bar" }, - rhs: {}, - mergeBase: { foo: "bar" }, - expected: {}, - }, - "change from left": { - lhs: { foo: "baz" }, - rhs: { foo: "bar" }, - mergeBase: { foo: "bar"}, - expected: { foo: "baz" }, - }, - "change from right": { - lhs: { foo: "bar" }, - rhs: { foo: "baz" }, - mergeBase: { foo: "bar"}, - expected: { foo: "baz" }, - }, - "same change from both": { - lhs: { foo: "baz" }, - rhs: { foo: "baz" }, - mergeBase: { foo: "bar"}, - expected: { foo: "baz" }, - }, - "both deleted": { - lhs: {}, - rhs: {}, - mergeBase: { foo: "zot" }, - expected: {}, - }, - "conflict -- different add": { - lhs: { foo: "bar" }, - rhs: { foo: "baz" }, - mergeBase: {}, - expected: null, - }, - "conflict -- different change": { - lhs: { foo: "bar" }, - rhs: { foo: "baz" }, - mergeBase: { foo: "bak" }, - expected: null, - }, - "conflict -- lhs delete, rhs change": { - lhs: {}, - rhs: { foo: "bar" }, - mergeBase: { foo: "boo" }, - expected: null, - }, - "conflict -- lhs change, rhs delete": { - lhs: { foo: "bar" }, - rhs: {}, - mergeBase: { foo: "boo" }, - expected: null, - }, - "conflict -- both add different": { - lhs: { foo: "bar" }, - rhs: { foo: "baz" }, - mergeBase: {}, - expected: null, - }, - "multiple, arbitrary, mixed changes": { - lhs: { - foo: "bar", - baz: "bam", - ack: "woo", - wee: "meh", - }, - rhs: { - foo: "bar", - ack: "waz", - wee: "meh", - }, - mergeBase: { - foo: "gaz", - ack: "woo", - wee: "fleh", - yah: "arg", - }, - expected: { - foo: "bar", - baz: "bam", - ack: "waz", - wee: "meh", - }, - }, - }; - Object.keys(cases).forEach(caseName => { - it(caseName, function () { - const c = cases[caseName]; - const result = SubmoduleConfigUtil.mergeSubmoduleConfigs( - c.lhs, - c.rhs, - c.mergeBase); - assert.deepEqual(result, c.expected); - }); - }); - }); describe("writeConfigText", function () { const cases = { "base": {}, @@ -768,5 +848,17 @@ foo }); }); }); + it("writeUrls", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const index = yield repo.index(); + yield SubmoduleConfigUtil.writeUrls(repo, index, { + foo: "/bar" + }); + const fromIndex = + yield SubmoduleConfigUtil.getSubmodulesFromIndex(repo, index); + assert.deepEqual(fromIndex, { + foo: "/bar" + }); + })); }); diff --git a/node/test/util/submodule_fetcher.js b/node/test/util/submodule_fetcher.js index c23d73103..7ba87e579 100644 --- a/node/test/util/submodule_fetcher.js +++ b/node/test/util/submodule_fetcher.js @@ -49,6 +49,11 @@ describe("SubmoduleFetcher", function () { metaSha: "2", expected: null, }, + "null commit": { + initial: "a=B:C4-1;Bfoo=4|b=B|x=U:Os Rorigin=c", + metaSha: null, + expected: null, + }, "pulled from origin": { initial: "a=B:C4-1;Bfoo=4|b=B|x=Ca", metaSha: "1", @@ -62,8 +67,11 @@ describe("SubmoduleFetcher", function () { const written = yield RepoASTTestUtil.createMultiRepos(c.initial); const repo = written.repos.x; - const newSha = written.reverseCommitMap[c.metaSha]; - const commit = yield repo.getCommit(newSha); + let commit = null; + if (null !== c.metaSha) { + const newSha = written.reverseCommitMap[c.metaSha]; + commit = yield repo.getCommit(newSha); + } let metaUrl = c.metaOriginUrl; if (null !== metaUrl) { metaUrl = written.reverseUrlMap[metaUrl]; diff --git a/node/test/util/submodule_rebase_util.js b/node/test/util/submodule_rebase_util.js new file mode 100644 index 000000000..178a306ab --- /dev/null +++ b/node/test/util/submodule_rebase_util.js @@ -0,0 +1,401 @@ +/* + * Copyright (c) 2016, 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 RepoAST = require("../../lib/util/repo_ast"); +const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); +const StatusUtil = require("../../lib/util/status_util"); +const SubmoduleRebaseUtil = require("../../lib/util/submodule_rebase_util"); + +describe("SubmoduleRebaseUtil", function () { +it("callNext", co.wrap(function *() { + const init = "S:C2-1;Bmaster=2;C3-1;Bfoo=3"; + const written = yield RepoASTTestUtil.createRepo(init); + const repo = written.repo; + const ontoSha = written.oldCommitMap["3"]; + const fromId = NodeGit.Oid.fromString(ontoSha); + const fromAnnotated = + yield NodeGit.AnnotatedCommit.lookup(repo, fromId); + const head = yield repo.head(); + const ontoAnnotated = yield NodeGit.AnnotatedCommit.fromRef(repo, + head); + const rebase = yield NodeGit.Rebase.init(repo, + fromAnnotated, + ontoAnnotated, + null, + null); + const first = yield SubmoduleRebaseUtil.callNext(rebase); + assert.equal(first.id().tostrS(), ontoSha); + const second = yield SubmoduleRebaseUtil.callNext(rebase); + assert.isNull(second); +})); + +describe("processRebase", function () { + const cases = { + "no conflicts": { + initial: "x=S:C2-1;Cr-1;Bmaster=2;Br=r", + expected: "x=E:Crr-2 r=r;H=rr", + conflictedCommit: null, + }, + "nothing to commit": { + initial: "x=S:C2-1;Cr-1 ;Bmaster=2;Br=r", + expected: "x=E:H=2", + conflictedCommit: null, + }, + "conflict": { + initial: "x=S:C2-1;Cr-1 2=3;Bmaster=2;Br=r", + expected: "x=E:I *2=~*2*3;W 2=u;H=2;Edetached HEAD,r,2", + conflictedCommit: "r", + expectedTransformer: function (expected, mapping) { + const content = `\ +<<<<<<< ${mapping.reverseCommitMap["2"]} +2 +======= +3 +>>>>>>> message +`; + expected.x = expected.x.copy({ + workdir: { + "2": new RepoAST.File(content, false), + }, + }); + return expected; + }, + }, + "fast forward": { + initial: "x=S:C2-r;Cr-1;Bmaster=2;Br=r", + expected: "x=E:H=2", + conflictedCommit: null, + }, + }; + Object.keys(cases).forEach(caseName => { + it(caseName, co.wrap(function *() { + const c = cases[caseName]; + const op = co.wrap(function *(repos, maps) { + const repo = repos.x; + const headCommit = yield repo.getHeadCommit(); + const AnnotatedCommit = NodeGit.AnnotatedCommit; + const headAnnotated = yield AnnotatedCommit.lookup( + repo, + headCommit.id()); + const targetCommitSha = maps.reverseCommitMap.r; + const targetCommit = yield repo.getCommit(targetCommitSha); + const targetAnnotated = yield AnnotatedCommit.lookup( + repo, + targetCommit.id()); + const rebase = yield NodeGit.Rebase.init(repo, + targetAnnotated, + headAnnotated, + null, + null); + const op = yield SubmoduleRebaseUtil.callNext(rebase); + const result = yield SubmoduleRebaseUtil.processRebase(repo, + rebase, + op); + if (null === c.conflictedCommit) { + assert.isNull(result.conflictedCommit); + } else { + assert.equal( + result.conflictedCommit, + maps.reverseCommitMap[c.conflictedCommit]); + } + const commitMap = {}; + Object.keys(result.commits).forEach(newSha => { + const oldSha = result.commits[newSha]; + const oldLogicalCommit = maps.commitMap[oldSha]; + commitMap[newSha] = oldLogicalCommit + "r"; + }); + return { + commitMap: commitMap, + }; + }); + const options = {}; + if (undefined !== c.expectedTransformer) { + options.expectedTransformer = c.expectedTransformer; + } + yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, + c.expected, + op, + c.fails, + options); + })); + }); +}); + +describe("rewriteCommits", function () { + const cases = { + "normal rebase": { + initial: "x=S:C2-1;Cr-1;Bmaster=2;Br=r", + expected: "x=E:Crr-2 r=r;H=rr", + upstream: null, + conflictedCommit: null, + }, + "skip if branch and head same": { + initial: "x=S:Cr-1;Bmaster=r", + upstream: null, + conflictedCommit: null, + expected: "x=E:H=r", + }, + "skip none": { + initial: "x=S:C2-1;Cr-1;Bmaster=2;Br=r", + expected: "x=E:Crr-2 r=r;H=rr", + upstream: "1", + conflictedCommit: null, + }, + "up-to-date": { + initial: "x=S:Cr-1; C3-2; C2-r;Bmaster=3;Br=r", + expected: "x=E:H=3", + upstream: null, + conflictedCommit: null, + }, + "up-to-date with upstream": { + initial: "x=S:Cr-1; C3-2; C2-r;Bmaster=3;Br=r", + expected: "x=E:H=3", + upstream: "1", + conflictedCommit: null, + }, + "ffwd when all included": { + initial: "x=S:Cr-3; C3-2; C2-1;Bmaster=2;Br=r", + expected: "x=E:H=r", + upstream: null, + conflictedCommit: null, + }, + "ffwd when enough included included": { + initial: "x=S:Cr-3; C3-2; C2-1;Bmaster=3;Br=r", + expected: "x=E:H=r", + upstream: "2", + conflictedCommit: null, + }, + "ffwd when enough included included, and equal": { + initial: "x=S:Cr-3; C3-2; C2-1;Bmaster=3;Br=r", + expected: "x=E:H=r", + upstream: "3", + conflictedCommit: null, + }, + "not ffwd when skipped commit": { + initial: "x=S:Cr-3; C3-2; C2-1;Bmaster=2;Br=r", + upstream: "3", + expected: "x=E:Crr-2 r=r;H=rr", + }, + "conflict": { + initial: "x=S:C2-1;Cr-1 2=3;Bmaster=2;Br=r", + expected: `x=E:I *2=~*2*3;H=2;Edetached HEAD,r,2;W 2=\ +<<<<<<< master +2 +======= +3 +>>>>>>> message +; +`, + upstream: null, + conflictedCommit: "r", + }, + "multiple commits": { + initial: "x=S:C2-1;C3-1;Cr-3;Bmaster=2;Br=r", + expected: "x=E:Crr-3r r=r;C3r-2 3=3;H=rr", + upstream: null, + conflictedCommit: null, + }, + "skip a commit": { + initial: "x=S:C2-1;C3-1;Cr-3;Bmaster=2;Br=r", + expected: "x=E:Crr-2 r=r;H=rr", + upstream: "3", + conflictedCommit: null, + }, + "excessive upstream": { + initial: "x=S:C2-1;C3-2;Cr-2;Bmaster=3;Br=r", + expected: "x=E:Crr-3 r=r;H=rr", + upstream: "1", + conflictedCommit: null, + }, + }; + Object.keys(cases).forEach(caseName => { + it(caseName, co.wrap(function *() { + const c = cases[caseName]; + const op = co.wrap(function *(repos, maps) { + const repo = repos.x; + const targetSha = maps.reverseCommitMap.r; + const targetCommit = yield repo.getCommit(targetSha); + let upstreamCommit = null; + if (null !== c.upstream) { + const upstreamSha = maps.reverseCommitMap[c.upstream]; + upstreamCommit = yield repo.getCommit(upstreamSha); + } + const result = yield SubmoduleRebaseUtil.rewriteCommits( + repo, + targetCommit, + upstreamCommit); + if (null === c.conflictedCommit) { + assert.isNull(result.conflictedCommit); + } else { + assert.equal( + result.conflictedCommit, + maps.reverseCommitMap[c.conflictedCommit]); + } + const commitMap = {}; + Object.keys(result.commits).forEach(newSha => { + const oldSha = result.commits[newSha]; + const oldLogicalCommit = maps.commitMap[oldSha]; + commitMap[newSha] = oldLogicalCommit + "r"; + }); + return { + commitMap: commitMap, + }; + }); + const options = {}; + if (undefined !== c.expectedTransformer) { + options.expectedTransformer = c.expectedTransformer; + } + yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, + c.expected, + op, + c.fails, + options); + })); + }); +}); +describe("continueSubmodules", function () { + const cases = { + "nothing to do": { + initial: `x=S`, + }, + "a closed sub": { + initial: "x=S:C2-1 s=Sa:1;Bmaster=2", + }, + "change in sub is staged": { + initial: "a=B:Ca-1;Ba=a|x=U:Os H=a", + expected: "x=E:Cadded 's'#M-2 s=Sa:a;Bmaster=M", + baseCommit: "2", + }, + "rebase in a sub": { + initial: ` +a=B:Cq-1;Cr-1;Bq=q;Br=r| +x=U:C3-2 s=Sa:q;Bmaster=3;Os EHEAD,q,r!I q=q`, + expected: ` +x=E:CM-3 s=Sa:qs;Bmaster=M;Os Cqs-r q=q!H=qs!E`, + baseCommit: "3", + }, + "rebase in a sub, was conflicted": { + initial: ` +a=B:Cq-1;Cr-1;Bq=q;Br=r| +x=U:C3-2 s=Sa:q;Bmaster=3;I *s=S:1*S:r*S:q;Os EHEAD,q,r!I q=q!Bq=q`, + expected: ` +x=E:CM-3 s=Sa:qs;Bmaster=M;I s=~;Os Cqs-r q=q!H=qs!E!Bq=q`, + baseCommit: "3", + }, + "rebase two in a sub": { + initial: ` +a=B:Cp-q;Cq-1;Cr-1;Bp=p;Br=r| +x=U:C3-2 s=Sa:q;Bmaster=3;Os EHEAD,p,r!I q=q!Bp=p`, + baseCommit: "3", + expected: ` +x=E:CM-3 s=Sa:ps;I s=~;Bmaster=M;Os Cps-qs p=p!Cqs-r q=q!H=ps!E!Bp=p` + }, + "rebase in two subs": { + initial: ` +a=B:Cp-q;Cq-1;Cr-1;Cz-1;Bp=p;Br=r;Bz=z| +x=S:C2-1 s=Sa:1,t=Sa:1;C3-2 s=Sa:q,t=Sa:q;Bmaster=3; + Os EHEAD,p,r!I q=q!Bp=p; + Ot EHEAD,z,r!I z=8!Bz=z; +`, + baseCommit: "3", + expected: ` +x=E:CM-3 s=Sa:ps,t=Sa:zt;Bmaster=M; + Os Cps-qs p=p!Cqs-r q=q!H=ps!E!Bp=p; + Ot Czt-r z=8!H=zt!E!Bz=z; +`, + }, + "rebase in two subs, conflict in one": { + initial: ` +a=B:Cp-q r=8;Cq-1;Cr-1;Cz-1;Bp=p;Br=r;Bz=z| +x=S:C2-1 s=Sa:1,t=Sa:1;C3-2 s=Sa:q,t=Sa:q;Bmaster=3; + Os EHEAD,p,r!I q=q!Bp=p; + Ot EHEAD,z,r!I z=8!Bz=z; +`, + expected: ` +x=E:I t=Sa:zt; + Os Cqs-r q=q!H=qs!EHEAD,p,r!Bp=p!I *r=~*r*8!W r=^<<<<; + Ot Czt-r z=8!H=zt!E!Bz=z; +`, + errorMessage: `\ +Submodule ${colors.red("s")} is conflicted. +`, + }, + "made a commit in a sub without a rebase": { + initial: `a=B|x=U:Cfoo#9-1;B9=9;Os I a=b`, + expected: `x=E:Cfoo#M-2 s=Sa:Ns;Bmaster=M;Os Cfoo#Ns-1 a=b!H=Ns`, + baseCommit: "9", + message: "foo", + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const continuer = co.wrap(function *(repos, maps) { + const repo = repos.x; + const index = yield repo.index(); + const status = yield StatusUtil.getRepoStatus(repo); + const baseSha = c.baseCommit || "1"; + const baseCommit = + yield repo.getCommit(maps.reverseCommitMap[baseSha]); + const result = yield SubmoduleRebaseUtil.continueSubmodules( + repo, + index, + status, + baseCommit); + assert.equal(result.errorMessage, c.errorMessage || null); + const commitMap = {}; + RepoASTTestUtil.mapSubCommits(commitMap, + result.commits, + maps.commitMap); + Object.keys(result.newCommits).forEach(name => { + commitMap[result.newCommits[name]] = "N" + name; + }); + if (null !== result.metaCommit) { + commitMap[result.metaCommit] = "M"; + } + return { + commitMap: commitMap, + }; + }); + yield RepoASTTestUtil.testMultiRepoManipulator(c.initial, + c.expected, + continuer, + c.fails); + })); + }); +}); +}); diff --git a/node/test/util/submodule_util.js b/node/test/util/submodule_util.js index 6db494d21..930a1b23f 100644 --- a/node/test/util/submodule_util.js +++ b/node/test/util/submodule_util.js @@ -32,11 +32,13 @@ const assert = require("chai").assert; const co = require("co"); +const fs = require("fs-promise"); const NodeGit = require("nodegit"); const path = require("path"); const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); const TestUtil = require("../../lib/util/test_util"); +const SubmoduleChange = require("../../lib/util/submodule_change"); const Submodule = require("../../lib/util/submodule"); const SubmoduleUtil = require("../../lib/util/submodule_util"); const SubmoduleConfigUtil = require("../../lib/util/submodule_config_util"); @@ -104,7 +106,7 @@ describe("SubmoduleUtil", function () { }); }); - describe("getSubmoduleNamesForBranch", function () { + describe("getSubmoduleNamesForCommittish", function () { // This method is implemented in terms of `getSubmoduleNamesForCommit`; // we just need to do basic verification. @@ -192,7 +194,7 @@ describe("SubmoduleUtil", function () { }); }); - describe("getSubmoduleShasForBranch", function () { + describe("getSubmoduleShasForCommitish", function () { // The implementation of this method is delegated to // `getSubmoduleShasForCommit`; just exercise basic functionality. @@ -201,7 +203,7 @@ describe("SubmoduleUtil", function () { yield RepoASTTestUtil.createRepo("S:C2-1 x=Sa:1;Bm=2"); const repo = written.repo; const result = - yield SubmoduleUtil.getSubmoduleShasForBranch(repo, "m"); + yield SubmoduleUtil.getSubmoduleShasForCommitish(repo, "m"); assert.equal(written.commitMap[result.x], "1"); })); }); @@ -296,6 +298,40 @@ describe("SubmoduleUtil", function () { })); }); + describe("listAbsorbedSubmodules", function () { + const cases = { + "simple": { + input: "x=S", + expected: [], + }, + "one open": { + input: "a=S|x=S:I q=Sa:1;Oq", + expected: ["q"], + }, + "two open": { + input: "a=S|x=S:I q=Sa:1,s/x=Sa:1;Oq;Os/x", + expected: ["q", "s/x"], + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const written = + yield RepoASTTestUtil.createMultiRepos(c.input); + const x = written.repos.x; + for (const closeSubs of [false, true]) { + if (closeSubs) { + const subs = yield SubmoduleUtil.listOpenSubmodules(x); + yield SubmoduleConfigUtil.deinit(x, subs); + } + const dut = SubmoduleUtil; + const result = yield dut.listAbsorbedSubmodules(x); + assert.deepEqual(result.sort(), c.expected.sort()); + } + })); + }); + }); + describe("listOpenSubmodules", function () { // We will always inspect the repo `x`. @@ -332,10 +368,21 @@ describe("SubmoduleUtil", function () { const repo = yield TestUtil.createSimpleRepository(); const url = "/Users/peabody/repos/git-meta-demo/scripts/demo/z-bare"; - SubmoduleConfigUtil.initSubmodule(repo.workdir(), "z", url); + SubmoduleConfigUtil.initSubmodule(repo.path(), "z", url); const openSubs = yield SubmoduleUtil.listOpenSubmodules(repo); assert.deepEqual(openSubs, []); })); + + it("missing from .gitmodules", co.wrap(function *() { + const input = "a=B|x=U:Os"; + const written = yield RepoASTTestUtil.createMultiRepos(input); + const x = written.repos.x; + const modulesPath = path.join(x.workdir(), + SubmoduleConfigUtil.modulesFileName); + yield fs.unlink(modulesPath); + const result = yield SubmoduleUtil.listOpenSubmodules(x); + assert.deepEqual(result, []); + })); }); describe("getSubmoduleRepos", function () { @@ -358,118 +405,109 @@ describe("SubmoduleUtil", function () { })); }); - describe("getSubmoduleChanges", function () { + describe("getSubmoduleChangesFromDiff", function () { const cases = { "trivial": { state: "S", from: "1", - added: {}, - changed: {}, - removed: {}, - modules: false, + result: {}, + allowMetaChanges: true, + }, + "trivial, no meta": { + state: "S", + from: "1", + result: {}, + allowMetaChanges: false, + fails: true, }, "changed something else": { state: "S:C2-1 README.md=foo;H=2", from: "2", - added: {}, - changed: {}, - removed: {}, - modules: false, + result: {}, + allowMetaChanges: true, + }, + "changed something in meta, not allowed": { + state: "S:C2-1 README.md=foo;H=2", + from: "2", + result: {}, + allowMetaChanges: false, + fails: true, }, "removed something else": { state: "S:C2-1 README.md;H=2", from: "2", - added: {}, - changed: {}, - removed: {}, - modules: false, + result: {}, + allowMetaChanges: true, + }, + "removed in meta, not allowed": { + state: "S:C2-1 README.md;H=2", + from: "2", + result: {}, + allowMetaChanges: false, + fails: true, }, "not on current commit": { state: "S:C2-1 x=Sa:1;H=2", from: "1", - added: {}, - changed: {}, - removed: {}, - modules: false, + result: {}, + allowMetaChanges: true, }, "added one": { state: "S:C2-1 x=Sa:1;H=2", from: "2", - added: { - "x": "1", + result: { + "x": new SubmoduleChange(null, "1", null), }, - changed: {}, - removed: {}, - modules: true, + allowMetaChanges: false, }, "added two": { state: "S:C2-1 a=Sa:1,x=Sa:1;H=2", from: "2", - added: { - a: "1", - x: "1", + result: { + a: new SubmoduleChange(null, "1", null), + x: new SubmoduleChange(null, "1", null), }, - changed: {}, - removed: {}, - modules: true, + allowMetaChanges: true, }, "changed one": { state: "S:C3-2 a=Sa:2;C2-1 a=Sa:1,x=Sa:1;H=3", from: "3", - added: {}, - changed: { - a: { - "new": "2", - old: "1", - }, + result: { + a: new SubmoduleChange("1", "2", null), }, - removed: {}, - modules: false, + allowMetaChanges: true, }, "changed url": { state: "S:C3-2 a=Sb:1;C2-1 a=Sa:1,x=Sa:1;H=3", from: "3", - added: {}, - changed: {}, - removed: {}, - modules: true, + result: {}, + allowMetaChanges: true, }, "changed and added": { state: "S:C3-2 a=Sa:2,c=Sa:2;C2-1 a=Sa:1,x=Sa:1;H=3", from: "3", - added: { - c: "2", + result: { + a: new SubmoduleChange("1", "2", null), + c: new SubmoduleChange(null, "2", null), }, - changed: { - a: { - "new": "2", - old: "1", - }, - }, - removed: {}, - modules: true, + allowMetaChanges: true, }, "removed one": { - state: "S:C3-2 a=;C2-1 a=Sa:1,x=Sa:1;H=3", + state: "S:C3-2 a;C2-1 a=Sa:1,x=Sa:1;H=3", from: "3", - added: {}, - changed: {}, - removed: { - "a": "1", + result: { + a: new SubmoduleChange("1", null, null), }, - modules: true, + allowMetaChanges: false, }, "added and removed": { state: "S:C3-2 a,c=Sa:2;C2-1 a=Sa:1,x=Sa:1;H=3", from: "3", - added: { - c: "2", - }, - changed: {}, - removed: { - a: "1", + result: { + c: new SubmoduleChange(null, "2", null), + a: new SubmoduleChange("1", null, null), }, - modules: true, + allowMetaChanges: true, }, }; Object.keys(cases).forEach(caseName => { @@ -480,33 +518,143 @@ describe("SubmoduleUtil", function () { const fromSha = written.oldCommitMap[c.from]; const fromId = NodeGit.Oid.fromString(fromSha); const commit = yield repo.getCommit(fromId); - const changes = - yield SubmoduleUtil.getSubmoduleChanges(repo, commit); + let parentTree = null; + const parents = yield commit.getParents(); + if (0 !== parents.length) { + parentTree = yield parents[0].getTree(); + } + const tree = yield commit.getTree(); + const diff = yield NodeGit.Diff.treeToTree(repo, + parentTree, + tree, + null); + let changes; + let exception; + try { + changes = yield SubmoduleUtil.getSubmoduleChangesFromDiff( + diff, + c.allowMetaChanges); + } + catch (e) { + exception = e; + } + const shouldFail = c.fails || false; + if (undefined === exception) { + assert.equal(false, shouldFail); + } + else { + if (!(exception instanceof UserError)) { + throw exception; + } + assert.equal(true, shouldFail); + return; // RETURN + } + + const commitMap = written.commitMap; // map the logical commits in the expected results to the // actual commit ids - const commitMap = written.oldCommitMap; - const expAdded = Object.assign({}, c.added); - for (let name in expAdded) { - expAdded[name] = commitMap[expAdded[name]]; + Object.keys(changes).forEach(name => { + const change = changes[name]; + assert.instanceOf(change, SubmoduleChange); + const oldSha = change.oldSha && commitMap[change.oldSha]; + const newSha = change.newSha && commitMap[change.newSha]; + changes[name] = new SubmoduleChange(oldSha, newSha, null); + }); + assert.deepEqual(changes, c.result); + })); + }); + }); + + describe("getSubmoduleChanges", function () { + // We know this is implemented in terms of + // `getSubmoduleChangesFromDiff`, so we just need to verify that it's + // hooked up correctly. + + const cases = { + "trivial": { + state: "S", + from: "1", + fails: true, + allowMetaChanges: false, + }, + "added one": { + state: "S:C2-1 x=Sa:1;H=2", + from: "2", + result: { + "x": new SubmoduleChange(null, "1", null), + }, + allowMetaChanges: false, + }, + "added one in ancestor": { + state: "S:C3-2 w=Sa:1;C2-1 x=Sa:1;H=3", + from: "3", + result: { + "w": new SubmoduleChange(null, "1", null), + }, + allowMetaChanges: false, + }, + "added one in ancestor, include base": { + state: "S:C3-2 w=Sa:1;C2-1 x=Sa:1;H=3", + from: "3", + base: "1", + result: { + "x": new SubmoduleChange(null, "1", null), + "w": new SubmoduleChange(null, "1", null), + }, + allowMetaChanges: false, + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + const written = yield RepoASTTestUtil.createRepo(c.state); + const repo = written.repo; + const old = written.oldCommitMap; + const fromSha = old[c.from]; + const fromId = NodeGit.Oid.fromString(fromSha); + const commit = yield repo.getCommit(fromId); + let changes; + let exception; + let baseCommit = null; + if ("base" in c) { + baseCommit = yield repo.getCommit(old[c.base]); } - const expChanged = Object.assign({}, c.changed); - for (let name in expChanged) { - expChanged[name] = { - "new": commitMap[expChanged[name]["new"]], - "old": commitMap[expChanged[name].old], - }; + try { + changes = yield SubmoduleUtil.getSubmoduleChanges( + repo, + commit, + baseCommit, + c.allowMetaChanges); + } + catch (e) { + exception = e; } - const expRemoved = Object.assign({}, c.removed); - for (let name in expRemoved) { - expRemoved[name] = commitMap[expRemoved[name]]; + const shouldFail = c.fails || false; + if (undefined === exception) { + assert.equal(false, shouldFail); } + else { + if (!shouldFail || !(exception instanceof UserError)) { + throw exception; + } + return; // RETURN + } + + const commitMap = written.commitMap; - assert.deepEqual(changes.added, expAdded, "added"); - assert.deepEqual(changes.changed, expChanged, "changed"); - assert.deepEqual(changes.removed, expRemoved, "removed"); - assert.equal(changes.modulesFileChanged, c.modules, "modules"); + // map the logical commits in the expected results to the + // actual commit ids + + Object.keys(changes).forEach(name => { + const change = changes[name]; + assert.instanceOf(change, SubmoduleChange); + const oldSha = change.oldSha && commitMap[change.oldSha]; + const newSha = change.newSha && commitMap[change.newSha]; + changes[name] = new SubmoduleChange(oldSha, newSha, null); + }); + assert.deepEqual(changes, c.result); })); }); }); @@ -515,17 +663,49 @@ describe("SubmoduleUtil", function () { "one": { state: "S:C2-1 foo=Sa:1;H=2", commit: "2", - expected: { foo: new Submodule("a", "1") }, + expected: { foo: new Submodule("a", "1", null) }, + names: null, + }, + "two": { + state: "S:C2-1 foo=Sa:1,bar=Sa:1;H=2", + commit: "2", + expected: { + foo: new Submodule("a", "1", null), + bar: new Submodule("a", "1", null), + }, + names: null, + }, + "no names": { + state: "S:C2-1 foo=Sa:1,bar=Sa:1;H=2", + commit: "2", + expected: {}, + names: [], + }, + "bad name": { + state: "S:C2-1 foo=Sa:1,bar=Sa:1;H=2", + commit: "2", + expected: {}, + names: ["whoo"], + }, + "good name": { + state: "S:C2-1 foo=Sa:1,bar=Sa:1;H=2", + commit: "2", + expected: { + bar: new Submodule("a", "1", null), + }, + names: ["bar"], }, "from later commit": { state: "S:C2-1 x=S/a:1;C3-2 x=S/a:2;H=3", commit: "3", - expected: { x: new Submodule("/a", "2") }, + expected: { x: new Submodule("/a", "2", null) }, + names: null, }, "none": { state: "S:Cu 1=1;Bu=u", commit: "u", expected: {}, + names: null, }, }; Object.keys(cases).forEach(caseName => { @@ -536,13 +716,16 @@ describe("SubmoduleUtil", function () { const mappedCommitSha = written.oldCommitMap[c.commit]; const commit = yield repo.getCommit(mappedCommitSha); const result = yield SubmoduleUtil.getSubmodulesForCommit( - repo, - commit); + repo, + commit, + c.names); let mappedResult = {}; Object.keys(result).forEach((name) => { const resultSub = result[name]; const commit = written.commitMap[resultSub.sha]; - mappedResult[name] = new Submodule(resultSub.url, commit); + mappedResult[name] = new Submodule(resultSub.url, + commit, + null); }); assert.deepEqual(mappedResult, c.expected); })); @@ -551,54 +734,81 @@ describe("SubmoduleUtil", function () { describe("getSubmodulesInPath", function () { const cases = { "trivial": { - state: "x=S", dir: "", indexSubNames: [], expected: [], }, "got a sub": { - state: "a=B|x=S:C2-1 q/r=Sa:1;Bmaster=2", dir: "", indexSubNames: ["q/r"], expected: ["q/r"], }, "got a sub, by path": { - state: "a=B|x=S:C2-1 q/r=Sa:1;Bmaster=2", dir: "q", indexSubNames: ["q/r"], expected: ["q/r"], }, "got a sub, by exact path": { - state: "a=B|x=S:C2-1 q/r=Sa:1;Bmaster=2", dir: "q/r", indexSubNames: ["q/r"], expected: ["q/r"], }, "missed": { - state: "a=B|x=S:C2-1 q/r=Sa:1;Bmaster=2", dir: "README.md", indexSubNames: ["q/r"], expected: [], }, "missed, with a dot": { - state: "a=B|x=S:C2-1 q/r=Sa:1;Bmaster=2;W .foo=bar", dir: ".foo", indexSubNames: ["q/r"], expected: [], }, + "direct hit, simple": { + dir: "q", + indexSubNames: ["q"], + expected: ["q"], + }, + "missed, with a slash": { + dir: "q/", + indexSubNames: ["q"], + expected: [], + }, + "from tree": { + dir: "q", + indexSubNames: ["q/r", "q/s", "q/t/v", "z"], + expected: ["q/r", "q/s", "q/t/v"], + }, + "from tree, with slash": { + dir: "q/", + indexSubNames: ["q/r", "q/s", "q/t/v", "z"], + expected: ["q/r", "q/s", "q/t/v"], + }, + "does not overmatch": { + dir: "q", + indexSubNames: ["q/r", "qr", "qr/"], + expected: ["q/r"], + }, + "from a subdir without includeSubdirs": { + dir: "q/r/s", + indexSubNames: ["q/r", "qr", "qr/"], + expected: [], + }, + "from a subdirq with includeSubdirs": { + dir: "q/r/s", + indexSubNames: ["q/r", "qr", "qr/"], + expected: ["q/r"], + includeSubdirs: true, + }, }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; - it(caseName, co.wrap(function *() { - const written = - yield RepoASTTestUtil.createMultiRepos(c.state); - const x = written.repos.x; - const result = yield SubmoduleUtil.getSubmodulesInPath( - x.workdir(), - c.dir, - c.indexSubNames); + it(caseName, function() { + const result = SubmoduleUtil.getSubmodulesInPath( + c.dir, + c.indexSubNames, + c.includeSubdirs); assert.deepEqual(result.sort(), c.expected.sort()); - })); + }); }); }); describe("resolveSubmoduleNames", function () { @@ -608,11 +818,10 @@ describe("SubmoduleUtil", function () { state: "x=S", expected: [], }, - "bad path": { + "missing path": { state: "x=S", - paths: ["a-bad-path"], + paths: ["a-missing-path"], expected: [], - fails: true, }, "no subs in path": { state: "x=S:C2-1 foo/bar=baz;Bmaster=2", @@ -653,7 +862,9 @@ describe("SubmoduleUtil", function () { x.workdir(), cwd, subs, - paths); + paths, + false, + true); } catch (e) { if (!(e instanceof UserError)) { @@ -673,43 +884,43 @@ describe("SubmoduleUtil", function () { const cases = { "trivial": { - state: "x=S", + subNames: [], paths: [], open: [], expected: {}, }, "got by path": { - state: "a=B|x=U", + subNames: ["s"], paths: ["s"], open: [], expected: { "s": [] }, }, "inside path": { - state: "a=B|x=U:Os", + subNames: ["s"], paths: ["s/README.md"], open: ["s"], expected: { "s": ["README.md"]}, }, "inside but not listed": { - state: "a=B|x=U:Os", + subNames: ["s"], paths: ["s/README.md"], open: [], expected: {}, }, "two inside": { - state: "a=B|x=U:Os I x/y=a,a/b=b", + subNames: ["s", "x/y", "a/b"], paths: ["s/x", "s/a"], open: ["s"], expected: { s: ["x","a"]}, }, "inside path, trumped by full path": { - state: "a=B|x=U:Os", + subNames: ["s"], paths: ["s/README.md", "s"], open: ["s"], expected: { "s": []}, }, "two contained": { - state: "a=B|x=S:I a/b=Sa:1,a/c=Sa:1", + subNames: ["a/b", "a/c"], paths: ["a"], open: [], expected: { @@ -718,7 +929,7 @@ describe("SubmoduleUtil", function () { }, }, "two specified": { - state: "a=B|x=S:I a/b=Sa:1,a/c=Sa:1", + subNames: ["a/b", "a/c"], paths: ["a/b", "a/c"], open: [], expected: { @@ -727,13 +938,42 @@ describe("SubmoduleUtil", function () { }, }, "path not in sub": { - state: "a=B|x=U:Os", + subNames: ["s"], paths: ["README.md"], open: ["s"], expected: {}, }, + "path not in sub (but has a slash)": { + subNames: ["s"], + paths: ["t/README.md"], + open: ["s"], + expected: {}, + }, + "path not in sub, fail": { + subNames: ["s"], + paths: ["README.md"], + open: ["s"], + failOnUnprefixed: true, + fails: true + }, + "path of sub": { + subNames: ["s"], + paths: ["s"], + open: ["s"], + failOnUnprefixed: true, + expected: { + "s": [] + } + }, + "path not in sub (but has a slash), fail": { + subNames: ["s"], + paths: ["t/README.md"], + open: ["s"], + failOnUnprefixed: true, + fails: true + }, "filename starts with subname but not in it": { - state: "a=B|x=U:I sam=3;Os", + subNames: ["s"], paths: ["sam"], open: ["s"], expected: {}, @@ -741,20 +981,25 @@ describe("SubmoduleUtil", function () { }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; - it(caseName, co.wrap(function *() { - const w = yield RepoASTTestUtil.createMultiRepos(c.state); - const repo = w.repos.x; - const workdir = repo.workdir(); - const subs = yield SubmoduleUtil.getSubmoduleNames(repo); - const result = yield SubmoduleUtil.resolvePaths(workdir, - c.paths, - subs, - c.open); - assert.deepEqual(result, c.expected); - })); + it(caseName, function () { + let result; + let failed; + try { + result = SubmoduleUtil.resolvePaths(c.paths, + c.subNames, + c.open, + c.failOnUnprefixed); + } catch (e) { + failed = true; + } + assert.equal(failed, c.fails); + if (!failed) { + assert.deepEqual(result, c.expected); + } + }); }); }); - describe("addRefs", function () { + describe("syncRefs", function () { const cases = { "trivial": { input: "x=S", @@ -787,41 +1032,15 @@ describe("SubmoduleUtil", function () { Object.keys(cases).forEach(caseName => { const c = cases[caseName]; it(caseName, co.wrap(function *() { - const addRefs = co.wrap(function *(repos) { + const syncRefs = co.wrap(function *(repos) { const repo = repos.x; - yield SubmoduleUtil.addRefs(repo, c.refs, c.subs); + yield SubmoduleUtil.syncRefs(repo, c.refs, c.subs); }); yield RepoASTTestUtil.testMultiRepoManipulator(c.input, c.expected, - addRefs, + syncRefs, c.fails); })); }); }); - describe("cacheSubmodules", function () { - it("breathing", co.wrap(function *() { - const repo = yield TestUtil.createSimpleRepository(); - function op(r) { - assert.equal(repo, r); - return Promise.resolve(3); - } - const result = yield SubmoduleUtil.cacheSubmodules(repo, op); - assert.equal(result, 3); - })); - it("exception", co.wrap(function *() { - class MyException {} - function op() { - throw new MyException(); - } - const repo = yield TestUtil.createSimpleRepository(); - try { - yield SubmoduleUtil.cacheSubmodules(repo, op); - } - catch (e) { - assert.instanceOf(e, MyException); - return; - } - assert(false, "should have thrown"); - })); - }); }); diff --git a/node/test/util/synthetic-branch.js b/node/test/util/synthetic-branch.js index c22ccda28..032741baa 100644 --- a/node/test/util/synthetic-branch.js +++ b/node/test/util/synthetic-branch.js @@ -41,10 +41,13 @@ const RepoASTTestUtil = require("../../lib/util/repo_ast_test_util"); const TestUtil = require("../../lib/util/test_util"); describe("synthetic-branch", function () { - const syntheticBranch = co.wrap(function *(repos, maps) { + const syntheticBranch = co.wrap(function *(repos) { const x = repos.x; + const notesRepo = repos.n || repos.x; const config = yield x.config(); yield config.setString("gitmeta.subreporootpath", "../../"); + yield config.setString("gitmeta.skipsyntheticrefpattern", + "skip"); const head = yield x.getHeadCommit(); @@ -59,24 +62,19 @@ describe("synthetic-branch", function () { value. */ } const headId = head.id().toString(); - SyntheticBranch.getSyntheticBranchForCommit = function(commit) { - /*jshint unused:false*/ - return "refs/heads/metaTEST"; - }; - const pass = yield SyntheticBranch.checkUpdate(x, old, headId, {}); + const pass = yield SyntheticBranch.checkUpdate(x, notesRepo, old, + headId, {}); if (!pass) { throw new UserError("fail"); } - return { - commitMap: maps, - }; }); const cases = { "simplest": { input: "x=S:C8-2;C2-1;Bmaster=8", expected: "x=S:C8-2;C2-1;Bmaster=8;" + - "N refs/notes/git-meta/subrepo-check 8=ok", + "N refs/notes/git-meta/subrepo-check 8=ok;" + + "N refs/notes/git-meta/subrepo-check 2=ok", }, "read a note, do nothing": { input: "x=S:C8-2;C2-1;Bmaster=8;" + @@ -100,67 +98,93 @@ describe("synthetic-branch", function () { "N refs/notes/git-meta/subrepo-check 4=ok|" + "z=S:C5-1;Bmaster=5", }, + //in these tests, we point meta's y to a commit which doesn't exist + //inside y's repo -- to do this, we use an otherwise unused submodule + //called 'u'. "with a submodule but no synthetic branch": { - input: "x=S:C2-1;C3-2 y=S/y:4;Bmaster=3|y=S:C4-1;Bmaster=4", - expected: "x=S:C2-1;C3-2 y=S/y:4;Bmaster=3|" + - "y=S:C4-1;Bmaster=4", + input: "x=S:C2-1;C3-2 y=S/y:7;Bmaster=3|y=S:C4-1;Bmaster=4" + + "|u=S:C7-1;Bmaster=7", + expected: "x=S:C2-1;C3-2 y=S/y:7;Bmaster=3|y=S:C4-1;Bmaster=4" + + "|u=S:C7-1;Bmaster=7", fails: true }, "with a submodule in a subdir but no synthetic branch": { - input: "x=S:C2-1;C3-2 y/z=S/z:4;Bmaster=3|z=S:C4-1;Bmaster=4", - expected: "x=S:C2-1;C3-2 y/z=S/z:4;Bmaster=3|" + - "z=S:C4-1;Bmaster=4", + input: "x=S:C2-1;C3-2 y/z=S/z:7;Bmaster=3|z=S:C4-1;Bmaster=4" + + "|u=S:C7-1;Bmaster=7", + expected: "x=S:C2-1;C3-2 y/z=S/z:7;Bmaster=3|z=S:C4-1;Bmaster=4" + + "|u=S:C7-1;Bmaster=7", fails: true }, "with a submodule in a subdir, bad parent commit": { - input: "x=S:C2-1;C3-2 y/z=S/z:5;C4-3;Bmaster=4|z=S:C5-1;Bmaster=5", - expected: "x=S:C2-1;C3-2 y/z=S/z:5;C4-3;Bmaster=4|" + - "z=S:C5-1;Bmaster=5", + input: "x=S:C2-1;C3-2 y/z=S/z:7;C4-3;Bmaster=4" + + "|z=S:C5-1;Bmaster=5" + + "|u=S:C7-1;Bmaster=7", + expected: "x=S:C2-1;C3-2 y/z=S/z:7;C4-3;Bmaster=4" + + "|z=S:C5-1;Bmaster=5" + + "|u=S:C7-1;Bmaster=7", fails: true }, "with a submodule in a subdir, bad merge commit": { - input: "x=S:C2-1;C3-2 y/z=S/z:5;C4-3,1;Bmaster=4|" + - "z=S:C5-1;Bmaster=5", - expected: "x=S:C2-1;C3-2 y/z=S/z:5;C4-3,1;Bmaster=4|" + - "z=S:C5-1;Bmaster=5", + input: "x=S:C2-1;C3-2 y/z=S/z:7;C4-3,1;Bmaster=4" + + "|z=S:C5-1;Bmaster=5" + + "|u=S:C7-1;Bmaster=7", + expected: "x=S:C2-1;C3-2 y/z=S/z:7;C4-3,1;Bmaster=4" + + "|z=S:C5-1;Bmaster=5" + + "|u=S:C7-1;Bmaster=7", fails: true }, "with a submodule, at meta commit": { input: "x=S:C2-1;C3-2 y=S/y:4;Bmaster=3|" + - "y=S:C4-1;Bmaster=4;BmetaTEST=4", + "y=S:C4-1;Bmaster=4", expected: "x=S:C2-1;C3-2 y=S/y:4;Bmaster=3;" + + "N refs/notes/git-meta/subrepo-check 2=ok;" + "N refs/notes/git-meta/subrepo-check 3=ok|" + - "y=S:C4-1;Bmaster=4;BmetaTEST=4", + "y=S:C4-1;Bmaster=4", }, "with a change to a submodule which was deleted on master": { - input: "x=S:C2-1 s=S/s:1;C3-2 s;Bmaster=3;C4-2 s=S/s:8" + - ";BmetaTEST=4" + + input: "x=S:C2-1 s=S/s:9;C3-2 s;Bmaster=3;C4-2 s=S/s:8" + + ";Btombstone=4" + + "|u=S:C9-1;Bmaster=9" + "|s=S:C8-7;C7-1;Bmaster=8", - expected: "x=S:C2-1 s=S/s:1;C3-2 s;Bmaster=3;C4-2 s=S/s:8" + - ";BmetaTEST=4" + + expected: "x=S:C2-1 s=S/s:9;C3-2 s;Bmaster=3;C4-2 s=S/s:8" + + ";Btombstone=4" + + "|u=S:C9-1;Bmaster=9" + "|s=S:C8-7;C7-1;Bmaster=8", fails: true }, + // This one should fail, but is in skipped URIs, so it passes. + "with a submodule but no synthetic branch but ignored": { + input: "x=S:C2-1;C3-2 y=S/skip:7;Bmaster=3" + + "|y=S:C4-1;Bmaster=4" + + "|u=S:C7-1;Bmaster=7", + expected: "x=S:C2-1;C3-2 y=S/skip:7;Bmaster=3;" + + "N refs/notes/git-meta/subrepo-check 2=ok;" + + "N refs/notes/git-meta/subrepo-check 3=ok" + + "|y=S:C4-1;Bmaster=4|u=S:C7-1;Bmaster=7" + }, "with a submodule in a subdir, at meta commit": { input: "x=S:C2-1;C3-2 y/z=S/z:4;Bmaster=3|" + - "z=S:C4-1;Bmaster=4;BmetaTEST=4", + "z=S:C4-1;Bmaster=4", expected: "x=S:C2-1;C3-2 y/z=S/z:4;Bmaster=3;" + + "N refs/notes/git-meta/subrepo-check 2=ok;" + "N refs/notes/git-meta/subrepo-check 3=ok|" + - "z=S:C4-1;Bmaster=4;BmetaTEST=4", + "z=S:C4-1;Bmaster=4", }, "with a submodule in a subdir, from earlier meta-commit": { input: "x=S:C2-1 y/z=S/z:4;C3-2;Bmaster=3|" + - "z=S:C4-1;Bmaster=4;BmetaTEST=4", + "z=S:C4-1;Bmaster=4", expected: "x=S:C2-1 y/z=S/z:4;C3-2;Bmaster=3;" + + "N refs/notes/git-meta/subrepo-check 2=ok;" + "N refs/notes/git-meta/subrepo-check 3=ok|" + - "z=S:C4-1;Bmaster=4;BmetaTEST=4", + "z=S:C4-1;Bmaster=4", }, "with a submodule in a subdir, irrelevant change": { input: "x=S:C2-1 y/z=S/z:4;C3-2 y/foo=bar;Bmaster=3|" + - "z=S:C4-1;Bmaster=4;BmetaTEST=4", + "z=S:C4-1;Bmaster=4", expected: "x=S:C2-1 y/z=S/z:4;C3-2 y/foo=bar;Bmaster=3;" + + "N refs/notes/git-meta/subrepo-check 2=ok;" + "N refs/notes/git-meta/subrepo-check 3=ok|" + - "z=S:C4-1;Bmaster=4;BmetaTEST=4", + "z=S:C4-1;Bmaster=4", }, }; @@ -171,6 +195,28 @@ describe("synthetic-branch", function () { c.expected, syntheticBranch, c.fails); + + // Test with notes in a separate repo -- we need to create + // that repo, and make sure it's the one that gets written to and + // read from. + + let input; + if (c.input.includes("N")) { + input = c.input.replace(/N[^|]* /, "|n=B:C5-1;Bmaster=5;$&"); + } else { + input = c.input + "|n=B:C5-1;Bmaster=5"; + } + + let expected = c.expected; + if (c.expected.includes("N")) { + expected = expected.replace(/N[^|]*/, + "|n=B:C5-1;Bmaster=5;$&"); + } // else it's a failure and won't change the notes repo + + yield RepoASTTestUtil.testMultiRepoManipulator(input, + expected, + syntheticBranch, + c.fails); })); }); }); @@ -198,11 +244,11 @@ describe("synthetic-branch-submodule-pre-receive", function () { const oid = yield createCommit(repo, addFile); // an empty push succeeds - let fail = yield SyntheticBranch.submoduleIsBad(repo, []); + let fail = yield SyntheticBranch.submoduleIsBad(repo, repo, []); assert(!fail); // a push with f to a correct branch succeeds - fail = yield SyntheticBranch.submoduleIsBad(repo, [{ + fail = yield SyntheticBranch.submoduleIsBad(repo, repo, [{ oldSha: "0000000000000000000000000000000000000000", newSha: oid.toString(), ref: "refs/commits/" + oid.toString(), @@ -210,7 +256,7 @@ describe("synthetic-branch-submodule-pre-receive", function () { assert(!fail); // a push with f to a bogus branch fails - fail = yield SyntheticBranch.submoduleIsBad(repo, [{ + fail = yield SyntheticBranch.submoduleIsBad(repo, repo, [{ oldSha: "0000000000000000000000000000000000000000", newSha: oid.toString(), ref: "refs/commits/0000000000000000000000000000000000000000", @@ -219,6 +265,46 @@ describe("synthetic-branch-submodule-pre-receive", function () { })); }); +describe("urlToLocalPath", function () { + const init = co.wrap(function*() { + const base = yield TestUtil.makeTempDir(); + const dir = yield fsp.realpath(base); + const repo = yield NodeGit.Repository.init(dir, 0); + const config = yield repo.config(); + yield config.setString("gitmeta.subrepourlbase", "http://example.com"); + yield config.setString("gitmeta.subreposuffix", ".GiT"); + yield config.setString("gitmeta.subreporootpath", "/a/b/c"); + return repo; + }); + const cases = { + "works without suffix" : { + input: "http://example.com/foo", + expected: "/a/b/c/foo.GiT" + }, + "works with existing suffix" : { + input: "http://example.com/foo.GiT", + expected: "/a/b/c/foo.GiT" + }, + "works with wrong prefix" : { + input: "http://wrong/foo.GiT", + expected: null + }, + }; + Object.keys(cases).forEach(caseName => { + const c = cases[caseName]; + it(caseName, co.wrap(function *() { + try { + const repo = yield init(); + const actual = + yield SyntheticBranch.urlToLocalPath(repo, c.input); + assert(c.expected === actual); + } catch (e) { + assert(c.expected === null); + } + })); + }); +}); + describe("synthetic-branch-meta-pre-receive", function () { it("works", co.wrap(function *() { const base = yield TestUtil.makeTempDir(); @@ -252,7 +338,7 @@ describe("synthetic-branch-meta-pre-receive", function () { process.env.GIT_ALTERNATE_OBJECT_DIRECTORIES = metaObjects; //fail: no synthetic ref - let fail = yield SyntheticBranch.metaUpdateIsBad(repo, [{ + let fail = yield SyntheticBranch.metaUpdateIsBad(repo, repo, [{ ref: "refs/heads/example", oldSha: "0000000000000000000000000000000000000000", newSha: metaOid.toString() @@ -264,7 +350,7 @@ describe("synthetic-branch-meta-pre-receive", function () { subOid, 0, "create synthetic ref"); //fail: no alt odb - fail = yield SyntheticBranch.metaUpdateIsBad(repo, [{ + fail = yield SyntheticBranch.metaUpdateIsBad(repo, repo, [{ ref: "refs/heads/example", oldSha: "0000000000000000000000000000000000000000", newSha: metaOid.toString() @@ -273,7 +359,7 @@ describe("synthetic-branch-meta-pre-receive", function () { //pass yield SyntheticBranch.initAltOdb(repo); - fail = yield SyntheticBranch.metaUpdateIsBad(repo, [{ + fail = yield SyntheticBranch.metaUpdateIsBad(repo, repo, [{ ref: "refs/heads/example", oldSha: "0000000000000000000000000000000000000000", newSha: metaOid.toString() diff --git a/node/test/util/text_util.js b/node/test/util/text_util.js new file mode 100644 index 000000000..f9b50d9e7 --- /dev/null +++ b/node/test/util/text_util.js @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2018, 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 TextUtil = require("../../lib/util/text_util"); + +describe("TextUtil", function () { + describe("indent", function () { + + // I don't know if we can verify that the returned directories are + // "temporary", but we can verify that they subsequent calls return + // different paths that are directories. + + it("breathing test", function () { + assert.equal(" morx", TextUtil.indent("morx")); + assert.equal(" three", TextUtil.indent("three", 3)); + }); + }); + + describe("strcmp", function () { + it("breathing test", function() { + const cases = [ + { + a : "fleem", + b : "morx", + expect : -1 + }, + { + a : "morx", + b : "fleem", + expect : 1 + }, + { + a : "foo", + b : "foo", + expect : 0 + } + ]; + for (const c of cases) { + assert.equal(TextUtil.strcmp(c.a, c.b), c.expect); + } + }); + }); + + describe("pluralize", function () { + it("breathing test", function() { + const cases = [ + { + noun : "fleem", + count : 1, + expect : "fleem" + }, + { + noun : "fleem", + count : 0, + expect : "fleems" + }, + { + noun : "fleem", + count : 2, + expect : "fleems" + }, + { + noun : "bass", + count : 1, + expect : "bass" + }, + { + noun : "bass", + count : 2, + expect : "basses" + }, + { + noun : "harpy", + count : 2, + expect : "harpies" + } + ]; + for (const c of cases) { + assert.equal(TextUtil.pluralize(c.noun, c.count), c.expect); + } + }); + }); +}); diff --git a/node/test/util/tree_util.js b/node/test/util/tree_util.js index 88ae230f6..d5efd0995 100644 --- a/node/test/util/tree_util.js +++ b/node/test/util/tree_util.js @@ -36,9 +36,10 @@ const fs = require("fs-promise"); const NodeGit = require("nodegit"); const path = require("path"); -const RepoStatus = require("../../lib/util/repo_status"); -const TestUtil = require("../../lib/util/test_util"); -const TreeUtil = require("../../lib/util/tree_util"); +const RepoStatus = require("../../lib/util/repo_status"); +const SubmoduleConfigUtil = require("../../lib/util/submodule_config_util"); +const TestUtil = require("../../lib/util/test_util"); +const TreeUtil = require("../../lib/util/tree_util"); describe("TreeUtil", function () { const Change = TreeUtil.Change; @@ -84,6 +85,46 @@ describe("TreeUtil", function () { }, }, }, + "deleted tree changed to parent": { + input: { + "a": null, + "a/b": "1", + }, + expected: { + "a": { + "b": "1", + }, + }, + }, + "deleted tree changed to parent, order reversed": { + input: { + "a/b": "1", + "a": null, + }, + expected: { + "a": { + "b": "1", + }, + } + }, + "creation and deletion": { + input: { + "a/b": "1", + "a": null, + }, + expected: { + a: { b: "1" }, + }, + }, + "deletion and creation": { + input: { + "a": null, + "a/b": "1", + }, + expected: { + a: { b: "1" }, + }, + }, }; Object.keys(cases).forEach((caseName) => { const c = cases[caseName]; @@ -235,6 +276,91 @@ describe("TreeUtil", function () { const entry = yield secondTree.entryByPath("foo"); assert.equal(entry.id().tostrS(), newId.tostrS()); })); + it("from blob to tree with blob", co.wrap(function *() { + const repo = yield makeRepo(); + const id = yield hashObject(repo, "xxxxxxxh"); + const firstTree = yield TreeUtil.writeTree(repo, null, { + "foo": new Change(id, FILEMODE.BLOB), + }); + const secondTree = yield TreeUtil.writeTree(repo, firstTree, { + "foo": null, + "foo/bar": new Change(id, FILEMODE.BLOB), + }); + const entry = yield secondTree.entryByPath("foo/bar"); + assert.equal(entry.id().tostrS(), id.tostrS()); + })); + it("from blob to tree with blob, reversed", co.wrap(function *() { + const repo = yield makeRepo(); + const id = yield hashObject(repo, "xxxxxxxh"); + const firstTree = yield TreeUtil.writeTree(repo, null, { + "foo": new Change(id, FILEMODE.BLOB), + }); + const secondTree = yield TreeUtil.writeTree(repo, firstTree, { + "foo/bar": new Change(id, FILEMODE.BLOB), + "foo": null, + }); + const entry = yield secondTree.entryByPath("foo/bar"); + assert.equal(entry.id().tostrS(), id.tostrS()); + })); + it("from tree with blob to blob", co.wrap(function *() { + const repo = yield makeRepo(); + const id = yield hashObject(repo, "xxxxxxxh"); + const firstTree = yield TreeUtil.writeTree(repo, null, { + "foo/bar": new Change(id, FILEMODE.BLOB), + }); + const secondTree = yield TreeUtil.writeTree(repo, firstTree, { + "foo": new Change(id, FILEMODE.BLOB), + "foo/bar": null, + }); + const entry = yield secondTree.entryByPath("foo"); + assert.equal(entry.id().tostrS(), id.tostrS()); + })); + it("from tree with blob to blob, reversed", co.wrap(function *() { + const repo = yield makeRepo(); + const id = yield hashObject(repo, "xxxxxxxh"); + const firstTree = yield TreeUtil.writeTree(repo, null, { + "foo/bar": new Change(id, FILEMODE.BLOB), + }); + const secondTree = yield TreeUtil.writeTree(repo, firstTree, { + "foo/bar": null, + "foo": new Change(id, FILEMODE.BLOB), + }); + const entry = yield secondTree.entryByPath("foo"); + assert.equal(entry.id().tostrS(), id.tostrS()); + })); + it("rm directory but add new content", co.wrap(function *() { + const repo = yield makeRepo(); + const id = yield hashObject(repo, "xxxxxxxh"); + const firstTree = yield TreeUtil.writeTree(repo, null, { + "foo/bam": new Change(id, FILEMODE.BLOB), + }); + const secondTree = yield TreeUtil.writeTree(repo, firstTree, { + "foo": null, + "foo/bar/baz": new Change(id, FILEMODE.BLOB), + }); + let failed = false; + try { + yield secondTree.entryByPath("foo/bam"); + } catch (e) { + failed = true; + } + assert(failed, "it's there"); + })); + it("implicitly overwrite blob with directory", co.wrap(function *() { + const repo = yield makeRepo(); + const blobAId = yield hashObject(repo, "xxxxxxxh"); + const parent = yield TreeUtil.writeTree(repo, null, { + foo: new Change(blobAId, FILEMODE.BLOB), + }); + const blobBId = yield hashObject(repo, "bazzzz"); + const result = yield TreeUtil.writeTree(repo, parent, { + "foo/bar": new Change(blobBId, FILEMODE.BLOB), + }); + const entry = yield result.entryByPath("foo/bar"); + assert.isNotNull(entry); + assert(entry.isBlob()); + assert.equal(entry.id().tostrS(), blobBId.tostrS()); + })); }); describe("hashFile", function () { it("breathing", co.wrap(function *() { @@ -243,7 +369,7 @@ describe("TreeUtil", function () { const filename = "foo"; const filepath = path.join(repo.workdir(), filename); yield fs.writeFile(filepath, content); - const result = TreeUtil.hashFile(repo, filename); + const result = yield TreeUtil.hashFile(repo, filename); const db = yield repo.odb(); const BLOB = 3; const expected = yield db.write(content, content.length, BLOB); @@ -261,7 +387,8 @@ describe("TreeUtil", function () { const status = new RepoStatus({ workdir: { foo: FILESTATUS.REMOVED }, }); - const result = TreeUtil.listWorkdirChanges(repo, status, false); + const result = yield TreeUtil.listWorkdirChanges(repo, status, + false); assert.deepEqual(result, { foo: null }); })); it("modified", co.wrap(function *() { @@ -273,7 +400,8 @@ describe("TreeUtil", function () { const status = new RepoStatus({ workdir: { foo: FILESTATUS.MODIFIED }, }); - const result = TreeUtil.listWorkdirChanges(repo, status, false); + const result = yield TreeUtil.listWorkdirChanges(repo, status, + false); const db = yield repo.odb(); const BLOB = 3; const id = yield db.write(content, content.length, BLOB); @@ -297,7 +425,8 @@ describe("TreeUtil", function () { }), }, }); - const result = TreeUtil.listWorkdirChanges(repo, status, false); + const result = yield TreeUtil.listWorkdirChanges(repo, status, + false); assert.deepEqual(result, {}); })); it("submodule", co.wrap(function *() { @@ -314,12 +443,91 @@ describe("TreeUtil", function () { }), }, }); - const result = TreeUtil.listWorkdirChanges(repo, status, false); + const result = yield TreeUtil.listWorkdirChanges(repo, status, + false); assert.deepEqual(result, { sub: new Change(NodeGit.Oid.fromString(commit), FILEMODE.COMMIT), }); })); + it("submodule with index change", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const commit = "1111111111111111111111111111111111111111"; + const status = new RepoStatus({ + submodules: { + "sub": new RepoStatus.Submodule({ + commit: new Submodule.Commit("1", "/a"), + index: new Submodule.Index(commit, + "/a", + RELATION.AHEAD), + workdir: null, + }), + }, + }); + const result = yield TreeUtil.listWorkdirChanges(repo, status, + true); + assert.deepEqual(result, { + sub: new Change(NodeGit.Oid.fromString(commit), + FILEMODE.COMMIT), + }); + })); + it("new submodule with commit", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const commit = "1111111111111111111111111111111111111111"; + const modPath = path.join(repo.workdir(), + SubmoduleConfigUtil.modulesFileName); + // It doesn't matter what's in the file, just that the function + // includes its contents. + yield fs.writeFile(modPath, "foo"); + const modId = yield TreeUtil.hashFile( + repo, + SubmoduleConfigUtil.modulesFileName); + const status = new RepoStatus({ + submodules: { + "sub": new RepoStatus.Submodule({ + commit: null, + index: new Submodule.Index(null, "/a", null), + workdir: new Submodule.Workdir(new RepoStatus({ + headCommit: commit, + }), RELATION.AHEAD), + }), + }, + }); + const result = yield TreeUtil.listWorkdirChanges(repo, status, + true); + assert.deepEqual(result, { + sub: new Change(NodeGit.Oid.fromString(commit), + FILEMODE.COMMIT), + ".gitmodules": new Change(modId, FILEMODE.BLOB), + }); + })); + it("deleted submodule", co.wrap(function *() { + const repo = yield TestUtil.createSimpleRepository(); + const modPath = path.join(repo.workdir(), + SubmoduleConfigUtil.modulesFileName); + // It doesn't matter what's in the file, just that the function + // includes its contents. + yield fs.writeFile(modPath, "foo"); + const modId = yield TreeUtil.hashFile( + repo, + SubmoduleConfigUtil.modulesFileName); + + const status = new RepoStatus({ + submodules: { + "sub": new RepoStatus.Submodule({ + commit: new Submodule.Commit("1", "/a"), + index: null, + workdir: null, + }), + }, + }); + const result = yield TreeUtil.listWorkdirChanges(repo, status, + true); + assert.deepEqual(result, { + sub: null, + ".gitmodules": new Change(modId, FILEMODE.BLOB), + }); + })); it("untracked and index", co.wrap(function *() { const repo = yield TestUtil.createSimpleRepository(); const status = new RepoStatus({ @@ -339,7 +547,8 @@ describe("TreeUtil", function () { }), }, }); - const result = TreeUtil.listWorkdirChanges(repo, status, false); + const result = yield TreeUtil.listWorkdirChanges(repo, status, + false); assert.deepEqual(result, {}); })); it("added, with includeUnstaged", co.wrap(function *() { @@ -351,7 +560,8 @@ describe("TreeUtil", function () { const status = new RepoStatus({ workdir: { foo: FILESTATUS.ADDED, }, }); - const result = TreeUtil.listWorkdirChanges(repo, status, true); + const result = yield TreeUtil.listWorkdirChanges(repo, status, + true); const db = yield repo.odb(); const BLOB = 3; const id = yield db.write(content, content.length, BLOB); @@ -359,5 +569,39 @@ describe("TreeUtil", function () { assert.equal(result.foo.id.tostrS(), id.tostrS()); assert.equal(result.foo.mode, FILEMODE.BLOB); })); + it("executable", co.wrap(function *() { + const content = "abcdefg"; + const repo = yield TestUtil.createSimpleRepository(); + + const filename1 = "foo"; + const filepath1 = path.join(repo.workdir(), filename1); + yield fs.writeFile(filepath1, content, { mode: 0o744 }); + + const filename2 = "bar"; + const filepath2 = path.join(repo.workdir(), filename2); + yield fs.writeFile(filepath2, content, { mode: 0o744 }); + + const status = new RepoStatus({ + workdir: { foo: FILESTATUS.MODIFIED, bar: FILESTATUS.ADDED }, + }); + + const db = yield repo.odb(); + const BLOB = 3; + const id = yield db.write(content, content.length, BLOB); + + // executable ignoring new files + let result = yield TreeUtil.listWorkdirChanges(repo, status, + false); + assert.deepEqual(Object.keys(result), ["foo"]); + assert.equal(result.foo.id.tostrS(), id.tostrS()); + assert.equal(result.foo.mode, FILEMODE.EXECUTABLE); + + // executable including added files + result = yield TreeUtil.listWorkdirChanges(repo, status, + true); + assert.deepEqual(Object.keys(result), ["foo", "bar"]); + assert.equal(result.bar.id.tostrS(), id.tostrS()); + assert.equal(result.bar.mode, FILEMODE.EXECUTABLE); + })); }); }); diff --git a/node/test/util/write_repo_ast_util.js b/node/test/util/write_repo_ast_util.js index 8203587e8..7a97cf80c 100644 --- a/node/test/util/write_repo_ast_util.js +++ b/node/test/util/write_repo_ast_util.js @@ -32,7 +32,9 @@ const assert = require("chai").assert; const co = require("co"); +const fs = require("fs-promise"); const NodeGit = require("nodegit"); +const path = require("path"); const ReadRepoASTUtil = require("../../lib/util/read_repo_ast_util"); const RepoAST = require("../../lib/util/repo_ast"); @@ -179,6 +181,10 @@ B:C2-1 x/y/qq=Sa:1,aa/bb/cc=Sb:1;C3-2 x/y/zz=Sq:1,aa/bb/dd=Sy:1; C7-6 aa/n/q=Spp:1,x/n/q=Sqq:1;Bm=7`, shas: [["1", "2"], ["3"], ["4"], ["5"], ["6"], ["7"]], }, + "file with exec bit": { + input: "N:C1 s=+2;Bmaster=1", + shas: [["1"]], + }, }; Object.keys(cases).forEach(caseName => { const c = cases[caseName]; @@ -290,6 +296,7 @@ ${sha} not in written list ${JSON.stringify(written)}`); const cases = { "simple": "S", + "sparse": "%S:H=1", "new head": "S:C2-1;H=2", "simple with branch": "S:Bfoo=1", "simple with ref": "S:Bfoo=1;Fa/b=1", @@ -342,10 +349,18 @@ S:C2-1 x=y;C3-1 x=z;Bmaster=2;Bfoo=3;Erefs/heads/master,2,3;I x=q", expected: "\ S:C2-1 x=y;C3-1 x=z;Bmaster=2;Bfoo=3;Erefs/heads/master,2,3;I x=q;H=3", }, + "with in-progress sequencer": "S:QR 1:foo 1:bar 0 1", + "sequencer with message": "S:Qfoo#R 1:foo 1:bar 0 1", "headless": { input: new RepoAST(), expected: new RepoAST(), }, + "conflict": "S:I *README.md=aa*bb*cc;W README.md=yyy", + "conflict with exec": "S:I *README.md=aa*+bb*cc;W README.md=yyy", + "submodule conflict": "S:I *README.md=aa*S:1*cc;W README.md=yyy", + "index exec change": "S:I README.md=+hello world", + "workdir exec change": "S:W README.md=+hello world", + "workdir new exec file": "S:W foo=+hello world", }; Object.keys(cases).forEach(caseName => { @@ -365,6 +380,7 @@ S:C2-1 x=y;C3-1 x=z;Bmaster=2;Bfoo=3;Erefs/heads/master,2,3;I x=q;H=3", describe("writeMultiRAST", function () { const cases = { "simple": "a=S", + "simple sparse": "a=%S", "bare": "a=B", "multiple": "a=B|b=Ca:C2-1;Bmaster=2", "external commit": "a=S:Bfoo=2|b=S:C2-1;Bmaster=2", @@ -395,6 +411,9 @@ S:C2-1 x=y;C3-1 x=z;Bmaster=2;Bfoo=3;Erefs/heads/master,2,3;I x=q;H=3", "cloned": "a=B|x=Ca", "pathed tracking branch": "a=B:Bfoo/bar=1|x=Ca:Bfoo/bar=1 origin/foo/bar", + "open submodule conflict": + "a=B|x=U:I *README.md=aa*S:1*cc;W README.md=yyy;Os", + "open sub with commit new to sub": "a=B|x=U:Os Cfoo-1!H=foo", }; Object.keys(cases).forEach(caseName => { const input = cases[caseName]; @@ -447,5 +466,31 @@ S:C2-1 x=y;C3-1 x=z;Bmaster=2;Bfoo=3;Erefs/heads/master,2,3;I x=q;H=3", } assert(false, "commit still exists"); })); + it("sparse subdir rm'd", co.wrap(function *() { + const root = yield TestUtil.makeTempDir(); + const input = "a=B|x=%U"; + const asts = ShorthandParserUtil.parseMultiRepoShorthand(input); + yield WriteRepoASTUtil.writeMultiRAST(asts, root); + let exists = true; + try { + yield fs.stat(path.join(root, "x", "s")); + } catch (e) { + exists = false; + } + assert.equal(exists, false); + })); + it("sparse subdir rm'd, detached head", co.wrap(function *() { + const root = yield TestUtil.makeTempDir(); + const input = "a=B|x=%U:H=2"; + const asts = ShorthandParserUtil.parseMultiRepoShorthand(input); + yield WriteRepoASTUtil.writeMultiRAST(asts, root); + let exists = true; + try { + yield fs.stat(path.join(root, "x", "s")); + } catch (e) { + exists = false; + } + assert.equal(exists, false); + })); }); }); diff --git a/node/yarn.lock b/node/yarn.lock new file mode 100644 index 000000000..27d1553ac --- /dev/null +++ b/node/yarn.lock @@ -0,0 +1,2371 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + +abbrev@1.0.x: + version "1.0.9" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.0.9.tgz#91b4792588a7738c25f35dd6f63752a2f8776135" + integrity sha1-kbR5JYinc4wl813W9jdSovh3YTU= + +ajv@^6.5.5: + version "6.10.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.0.tgz#90d0d54439da587cd7e843bfb7045f50bd22bdf1" + integrity sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg== + dependencies: + fast-deep-equal "^2.0.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +amdefine@>=0.0.4: + version "1.0.1" + resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" + integrity sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU= + +ansi-colors@3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.3.tgz#57d35b8686e851e2cc04c403f1c00203976a1813" + integrity sha512-LEHHyuhlPY3TmuUYMh2oz89lTShfvgbmzaBcxve9t/9Wuy7Dwf4yoAKcND7KFT1HAQfqZ12qtc+DUrBMeKF9nw== + +ansi-regex@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= + +ansi-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" + integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= + +ansi-regex@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" + integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +any-promise@^1.0.0, any-promise@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" + integrity sha1-q8av7tzqUugJzcA3au0845Y10X8= + +aproba@^1.0.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" + integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== + +are-we-there-yet@~1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" + integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== + dependencies: + delegates "^1.0.0" + readable-stream "^2.0.6" + +argparse@^1.0.0, argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +asap@~2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= + +asn1@~0.2.3: + version "0.2.4" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" + integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== + dependencies: + safer-buffer "~2.1.0" + +assert-plus@1.0.0, assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= + +assertion-error@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" + integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== + +async-mutex@^0.2.4: + version "0.2.6" + resolved "https://registry.yarnpkg.com/async-mutex/-/async-mutex-0.2.6.tgz#0d7a3deb978bc2b984d5908a2038e1ae2e54ff40" + integrity sha512-Hs4R+4SPgamu6rSGW8C7cV9gaWUKEHykfzCCvIRuaVv636Ju10ZdeUbvb4TBEW0INuq2DHZqXbK4Nd3yG4RaRw== + dependencies: + tslib "^2.0.0" + +async@1.x: + version "1.5.2" + resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" + integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo= + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + +aws-sign2@~0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" + integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= + +aws4@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f" + integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ== + +balanced-match@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + +bcrypt-pbkdf@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= + dependencies: + tweetnacl "^0.14.3" + +binary-search@: + version "1.3.6" + resolved "https://registry.yarnpkg.com/binary-search/-/binary-search-1.3.6.tgz#e32426016a0c5092f0f3598836a1c7da3560565c" + integrity sha512-nbE1WxOTTrUWIfsfZ4aHGYu5DOuNkbxGokjV6Z2kxfJK3uaAb8zNK1muzOeipoLHZjInT4Br88BHpzevc681xA== + +bl@^1.0.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.3.tgz#1e8dd80142eac80d7158c9dccc047fb620e035e7" + integrity sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww== + dependencies: + readable-stream "^2.3.5" + safe-buffer "^5.1.1" + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +browser-stdout@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + +buffer-alloc-unsafe@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0" + integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg== + +buffer-alloc@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec" + integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow== + dependencies: + buffer-alloc-unsafe "^1.1.0" + buffer-fill "^1.0.0" + +buffer-fill@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" + integrity sha1-+PeLdniYiO858gXNY39o5wISKyw= + +call-bind@^1.0.0, call-bind@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" + integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== + dependencies: + function-bind "^1.1.1" + get-intrinsic "^1.0.2" + +camelcase@^5.0.0: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= + +chai@: + version "4.2.0" + resolved "https://registry.yarnpkg.com/chai/-/chai-4.2.0.tgz#760aa72cf20e3795e84b12877ce0e83737aa29e5" + integrity sha512-XQU3bhBukrOsQCuwZndwGcCVQHyZi53fQ6Ys1Fym7E4olpIqqZZhhoFJoaKVvV17lWQoXYwgWN2nF5crA8J2jw== + dependencies: + assertion-error "^1.1.0" + check-error "^1.0.2" + deep-eql "^3.0.1" + get-func-name "^2.0.0" + pathval "^1.1.0" + type-detect "^4.0.5" + +chalk@^2.0.1: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +check-error@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" + integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII= + +child-process-promise@: + version "2.2.1" + resolved "https://registry.yarnpkg.com/child-process-promise/-/child-process-promise-2.2.1.tgz#4730a11ef610fad450b8f223c79d31d7bdad8074" + integrity sha1-RzChHvYQ+tRQuPIjx50x172tgHQ= + dependencies: + cross-spawn "^4.0.2" + node-version "^1.0.0" + promise-polyfill "^6.0.1" + +chownr@^1.0.1, chownr@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + +circular-json@^0.5.9: + version "0.5.9" + resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.5.9.tgz#932763ae88f4f7dead7a0d09c8a51a4743a53b1d" + integrity sha512-4ivwqHpIFJZBuhN3g/pEcdbnGUywkBblloGbkglyloVjjR3uT6tieI89MVOfbP2tHX5sgb01FuLgAOzebNlJNQ== + +cli@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cli/-/cli-1.0.1.tgz#22817534f24bfa4950c34d532d48ecbc621b8c14" + integrity sha1-IoF1NPJL+klQw01TLUjsvGIbjBQ= + dependencies: + exit "0.1.2" + glob "^7.1.1" + +cliui@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-4.1.0.tgz#348422dbe82d800b3022eef4f6ac10bf2e4d1b49" + integrity sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ== + dependencies: + string-width "^2.1.1" + strip-ansi "^4.0.0" + wrap-ansi "^2.0.0" + +co@: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= + +code-point-at@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" + integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + +colors@: + version "1.4.0" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" + integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== + +combined-stream@^1.0.6, combined-stream@~1.0.6: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +component-props@*: + version "1.1.1" + resolved "https://registry.yarnpkg.com/component-props/-/component-props-1.1.1.tgz#f9b7df9b9927b6e6d97c9bd272aa867670f34944" + integrity sha1-+bffm5kntubZfJvScqqGdnDzSUQ= + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + +console-browserify@1.1.x: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.1.0.tgz#f0241c45730a9fc6323b206dbf38edc741d0bb10" + integrity sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA= + dependencies: + date-now "^0.1.4" + +console-control-strings@^1.0.0, console-control-strings@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= + +core-util-is@1.0.2, core-util-is@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + +cross-spawn@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-4.0.2.tgz#7b9247621c23adfdd3856004a823cbe397424d41" + integrity sha1-e5JHYhwjrf3ThWAEqCPL45dCTUE= + dependencies: + lru-cache "^4.0.1" + which "^1.2.9" + +cross-spawn@^6.0.0: + version "6.0.5" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" + integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== + dependencies: + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" + shebang-command "^1.2.0" + which "^1.2.9" + +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= + dependencies: + assert-plus "^1.0.0" + +date-now@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" + integrity sha1-6vQ5/U1ISK105cx9vvIAZyueNFs= + +debug@3.2.6, debug@^3.2.6: + version "3.2.6" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" + integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== + dependencies: + ms "^2.1.1" + +debug@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" + integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== + dependencies: + ms "^2.1.1" + +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= + +deep-eql@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df" + integrity sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw== + dependencies: + type-detect "^4.0.0" + +deep-extend@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + +deep-is@~0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" + integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= + +deepcopy@: + version "2.0.0" + resolved "https://registry.yarnpkg.com/deepcopy/-/deepcopy-2.0.0.tgz#2acb9b7645f9f54d815eee991455e790e72e2252" + integrity sha512-d5ZK7pJw7F3k6M5vqDjGiiUS9xliIyWkdzBjnPhnSeRGjkYOGZMCFkdKVwV/WiHOe0NwzB8q+iDo7afvSf0arA== + dependencies: + type-detect "^4.0.8" + +deeper@: + version "2.1.0" + resolved "https://registry.yarnpkg.com/deeper/-/deeper-2.1.0.tgz#bc564e5f73174fdf201e08b00030e8a14da74368" + integrity sha1-vFZOX3MXT98gHgiwADDooU2nQ2g= + +define-properties@^1.1.2, define-properties@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" + integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== + dependencies: + object-keys "^1.0.12" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= + +detect-libc@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= + +diff@3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" + integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== + +dom-serializer@0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" + integrity sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g== + dependencies: + domelementtype "^2.0.1" + entities "^2.0.0" + +domelementtype@1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" + integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== + +domelementtype@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.0.1.tgz#1f8bdfe91f5a78063274e803b4bdcedf6e94f94d" + integrity sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ== + +domhandler@2.3: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.3.0.tgz#2de59a0822d5027fabff6f032c2b25a2a8abe738" + integrity sha1-LeWaCCLVAn+r/28DLCsloqir5zg= + dependencies: + domelementtype "1" + +domutils@1.5: + version "1.5.1" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" + integrity sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8= + dependencies: + dom-serializer "0" + domelementtype "1" + +ecc-jsbn@~0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" + integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= + dependencies: + jsbn "~0.1.0" + safer-buffer "^2.1.0" + +emoji-regex@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" + integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== + +end-of-stream@^1.0.0, end-of-stream@^1.1.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43" + integrity sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q== + dependencies: + once "^1.4.0" + +entities@1.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-1.0.0.tgz#b2987aa3821347fcde642b24fdfc9e4fb712bf26" + integrity sha1-sph6o4ITR/zeZCsk/fyeT7cSvyY= + +entities@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.3.tgz#5c487e5742ab93c15abb5da22759b8590ec03b7f" + integrity sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ== + +es-abstract@^1.18.0-next.2: + version "1.18.0" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.0.tgz#ab80b359eecb7ede4c298000390bc5ac3ec7b5a4" + integrity sha512-LJzK7MrQa8TS0ja2w3YNLzUgJCGPdPOV1yVvezjNnS89D+VR08+Szt2mz3YB2Dck/+w5tfIq/RoUAFqJJGM2yw== + dependencies: + call-bind "^1.0.2" + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + get-intrinsic "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.2" + is-callable "^1.2.3" + is-negative-zero "^2.0.1" + is-regex "^1.1.2" + is-string "^1.0.5" + object-inspect "^1.9.0" + object-keys "^1.1.1" + object.assign "^4.1.2" + string.prototype.trimend "^1.0.4" + string.prototype.trimstart "^1.0.4" + unbox-primitive "^1.0.0" + +es-abstract@^1.5.1: + version "1.13.0" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.13.0.tgz#ac86145fdd5099d8dd49558ccba2eaf9b88e24e9" + integrity sha512-vDZfg/ykNxQVwup/8E1BZhVzFfBxs9NqMzGcvIJrqg5k2/5Za2bWo40dK2J1pgLngZ7c+Shh8lwYtLGyrwPutg== + dependencies: + es-to-primitive "^1.2.0" + function-bind "^1.1.1" + has "^1.0.3" + is-callable "^1.1.4" + is-regex "^1.0.4" + object-keys "^1.0.12" + +es-to-primitive@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.0.tgz#edf72478033456e8dda8ef09e00ad9650707f377" + integrity sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" + integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +escape-string-regexp@1.0.5, escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + +escodegen@1.8.x: + version "1.8.1" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.8.1.tgz#5a5b53af4693110bebb0867aa3430dd3b70a1018" + integrity sha1-WltTr0aTEQvrsIZ6o0MN07cKEBg= + dependencies: + esprima "^2.7.1" + estraverse "^1.9.1" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.2.0" + +esprima@2.7.x, esprima@^2.7.1: + version "2.7.3" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581" + integrity sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE= + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +estraverse@^1.9.1: + version "1.9.3" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-1.9.3.tgz#af67f2dc922582415950926091a4005d29c9bb44" + integrity sha1-r2fy3JIlgkFZUJJgkaQAXSnJu0Q= + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +execa@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" + integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== + dependencies: + cross-spawn "^6.0.0" + get-stream "^4.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + +exit@0.1.2, exit@0.1.x: + version "0.1.2" + resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" + integrity sha1-BjJjj42HfMghB9MKD/8aF8uhzQw= + +extend@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +extsprintf@1.3.0, extsprintf@^1.2.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" + integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= + +fast-deep-equal@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" + integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk= + +fast-json-stable-stringify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" + integrity sha1-1RQsDK7msRifh9OnYREGT4bIu/I= + +fast-levenshtein@~2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= + +find-up@3.0.0, find-up@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" + integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== + dependencies: + locate-path "^3.0.0" + +flat@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/flat/-/flat-4.1.0.tgz#090bec8b05e39cba309747f1d588f04dbaf98db2" + integrity sha512-Px/TiLIznH7gEDlPXcUD4KnBusa6kR6ayRUVcnEAbreRIuhkqow/mun59BuRXwoYk7ZQOLW1ZM05ilIvK38hFw== + dependencies: + is-buffer "~2.0.3" + +for-each@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" + integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== + dependencies: + is-callable "^1.1.3" + +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= + +form-data@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" + integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + +fs-extra@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-2.1.2.tgz#046c70163cef9aad46b0e4a7fa467fb22d71de35" + integrity sha1-BGxwFjzvmq1GsOSn+kZ/si1x3jU= + dependencies: + graceful-fs "^4.1.2" + jsonfile "^2.1.0" + +fs-extra@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" + integrity sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw== + dependencies: + graceful-fs "^4.1.2" + jsonfile "^4.0.0" + universalify "^0.1.0" + +fs-minipass@^1.2.5: + version "1.2.7" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7" + integrity sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA== + dependencies: + minipass "^2.6.0" + +fs-promise@: + version "2.0.3" + resolved "https://registry.yarnpkg.com/fs-promise/-/fs-promise-2.0.3.tgz#f64e4f854bcf689aa8bddcba268916db3db46854" + integrity sha1-9k5PhUvPaJqovdy6JokW2z20aFQ= + dependencies: + any-promise "^1.3.0" + fs-extra "^2.0.0" + mz "^2.6.0" + thenify-all "^1.6.0" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + +function-bind@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== + +gauge@~2.7.3: + version "2.7.4" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" + integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= + dependencies: + aproba "^1.0.3" + console-control-strings "^1.0.0" + has-unicode "^2.0.0" + object-assign "^4.1.0" + signal-exit "^3.0.0" + string-width "^1.0.1" + strip-ansi "^3.0.1" + wide-align "^1.1.0" + +get-caller-file@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" + integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w== + +get-caller-file@^2.0.1: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-func-name@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41" + integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE= + +get-intrinsic@^1.0.2, get-intrinsic@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6" + integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + +get-stream@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" + integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== + dependencies: + pump "^3.0.0" + +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= + dependencies: + assert-plus "^1.0.0" + +git-range@: + version "0.2.0" + resolved "https://registry.yarnpkg.com/git-range/-/git-range-0.2.0.tgz#dc79f44a261f31aa8bc9e3c06eaedcc10fbf8349" + integrity sha1-3Hn0SiYfMaqLyePAbq7cwQ+/g0k= + dependencies: + lodash.flatten "^4.4.0" + +glob@7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1" + integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^5.0.15: + version "5.0.15" + resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1" + integrity sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E= + dependencies: + inflight "^1.0.4" + inherits "2" + minimatch "2 || 3" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^7.0.3, glob@^7.1.1, glob@^7.1.3: + version "7.1.4" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255" + integrity sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +graceful-fs@^4.1.2, graceful-fs@^4.1.6: + version "4.1.15" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00" + integrity sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA== + +group-by@: + version "0.0.1" + resolved "https://registry.yarnpkg.com/group-by/-/group-by-0.0.1.tgz#857620575f6714786f8d86bb19fd13e188dd68a4" + integrity sha1-hXYgV19nFHhvjYa7Gf0T4YjdaKQ= + dependencies: + to-function "*" + +growl@1.10.5: + version "1.10.5" + resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" + integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA== + +handlebars@^4.0.1: + version "4.7.7" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" + integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.0" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= + +har-validator@~5.1.0: + version "5.1.3" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080" + integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g== + dependencies: + ajv "^6.5.5" + har-schema "^2.0.0" + +has-bigints@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" + integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA== + +has-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa" + integrity sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo= + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + +has-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.0.tgz#ba1a8f1af2a0fc39650f5c850367704122063b44" + integrity sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q= + +has-symbols@^1.0.1, has-symbols@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423" + integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw== + +has-unicode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= + +has@^1.0.1, has@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== + dependencies: + function-bind "^1.1.1" + +he@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +htmlparser2@3.8.x: + version "3.8.3" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.8.3.tgz#996c28b191516a8be86501a7d79757e5c70c1068" + integrity sha1-mWwosZFRaovoZQGn15dX5ccMEGg= + dependencies: + domelementtype "1" + domhandler "2.3" + domutils "1.5" + entities "1.0" + readable-stream "1.1" + +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +iconv-lite@^0.4.4: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +ignore-walk@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8" + integrity sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ== + dependencies: + minimatch "^3.0.4" + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@~2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= + +inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ini@~1.3.0: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +invert-kv@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" + integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA== + +is-bigint@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.2.tgz#ffb381442503235ad245ea89e45b3dbff040ee5a" + integrity sha512-0JV5+SOCQkIdzjBK9buARcV804Ddu7A0Qet6sHi3FimE9ne6m4BGQZfRn+NZiXbBk4F4XmHfDZIipLj9pX8dSA== + +is-boolean-object@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.1.tgz#3c0878f035cb821228d350d2e1e36719716a3de8" + integrity sha512-bXdQWkECBUIAcCkeH1unwJLIpZYaa5VvuygSyS/c2lf719mTKZDU5UdDRlpd01UjADgmW8RfqaP+mRaVPdr/Ng== + dependencies: + call-bind "^1.0.2" + +is-buffer@~2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.3.tgz#4ecf3fcf749cbd1e472689e109ac66261a25e725" + integrity sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw== + +is-callable@^1.1.3, is-callable@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.3.tgz#8b1e0500b73a1d76c70487636f368e519de8db8e" + integrity sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ== + +is-callable@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.4.tgz#1e1adf219e1eeb684d691f9d6a05ff0d30a24d75" + integrity sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA== + +is-date-object@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16" + integrity sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY= + +is-fullwidth-code-point@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" + integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= + dependencies: + number-is-nan "^1.0.0" + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= + +is-negative-zero@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24" + integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w== + +is-number-object@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.5.tgz#6edfaeed7950cff19afedce9fbfca9ee6dd289eb" + integrity sha512-RU0lI/n95pMoUKu9v1BZP5MBcZuNSVJkMkAG2dJqC4z2GlkGUNeH68SuHuBKBD/XFe+LHZ+f9BKkLET60Niedw== + +is-regex@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.4.tgz#5517489b547091b0930e095654ced25ee97e9491" + integrity sha1-VRdIm1RwkbCTDglWVM7SXul+lJE= + dependencies: + has "^1.0.1" + +is-regex@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.3.tgz#d029f9aff6448b93ebbe3f33dac71511fdcbef9f" + integrity sha512-qSVXFz28HM7y+IWX6vLCsexdlvzT1PJNFSBuaQLQ5o0IEw8UDYW6/2+eCMVyIsbM8CNLX2a/QWmSpyxYEHY7CQ== + dependencies: + call-bind "^1.0.2" + has-symbols "^1.0.2" + +is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= + +is-string@^1.0.5: + version "1.0.6" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.6.tgz#3fe5d5992fb0d93404f32584d4b0179a71b54a5f" + integrity sha512-2gdzbKUuqtQ3lYNrUTQYoClPhm7oQu4UdpSZMp1/DGgkHBT8E2Z1l0yMdb6D4zNAxwDiMv8MdulKROJGNl0Q0w== + +is-symbol@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.2.tgz#a055f6ae57192caee329e7a860118b497a950f38" + integrity sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw== + dependencies: + has-symbols "^1.0.0" + +is-symbol@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" + integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg== + dependencies: + has-symbols "^1.0.2" + +is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= + +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= + +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + +isstream@~0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= + +istanbul@: + version "0.4.5" + resolved "https://registry.yarnpkg.com/istanbul/-/istanbul-0.4.5.tgz#65c7d73d4c4da84d4f3ac310b918fb0b8033733b" + integrity sha1-ZcfXPUxNqE1POsMQuRj7C4Azczs= + dependencies: + abbrev "1.0.x" + async "1.x" + escodegen "1.8.x" + esprima "2.7.x" + glob "^5.0.15" + handlebars "^4.0.1" + js-yaml "3.x" + mkdirp "0.5.x" + nopt "3.x" + once "1.x" + resolve "1.1.x" + supports-color "^3.1.0" + which "^1.1.1" + wordwrap "^1.0.0" + +js-yaml@3.13.1, js-yaml@3.x: + version "3.13.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" + integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= + +jshint@^2.8.0: + version "2.11.1" + resolved "https://registry.yarnpkg.com/jshint/-/jshint-2.11.1.tgz#28ec7d1cf7baaae5ce7bd37b9a70ed6cfd938570" + integrity sha512-WXWePB8ssAH3DlD05IoqolsY6arhbll/1+i2JkRPpihQAuiNaR/gSt8VKIcxpV5m6XChP0hCwESQUqpuQMA8Tg== + dependencies: + cli "~1.0.0" + console-browserify "1.1.x" + exit "0.1.x" + htmlparser2 "3.8.x" + lodash "~4.17.11" + minimatch "~3.0.2" + shelljs "0.3.x" + strip-json-comments "1.0.x" + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-schema@0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" + integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= + +json-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= + +json5@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.0.tgz#e7a0c62c48285c628d20a10b85c89bb807c32850" + integrity sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ== + dependencies: + minimist "^1.2.0" + +jsonfile@^2.1.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-2.4.0.tgz#3736a2b428b87bbda0cc83b53fa3d633a35c2ae8" + integrity sha1-NzaitCi4e72gzIO1P6PWM6NcKug= + optionalDependencies: + graceful-fs "^4.1.6" + +jsonfile@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" + integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= + optionalDependencies: + graceful-fs "^4.1.6" + +jsprim@^1.2.2: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" + integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI= + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.2.3" + verror "1.10.0" + +lcid@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/lcid/-/lcid-2.0.0.tgz#6ef5d2df60e52f82eb228a4c373e8d1f397253cf" + integrity sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA== + dependencies: + invert-kv "^2.0.0" + +levn@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" + integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= + dependencies: + prelude-ls "~1.1.2" + type-check "~0.3.2" + +locate-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" + integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== + dependencies: + p-locate "^3.0.0" + path-exists "^3.0.0" + +lodash.flatten@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" + integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8= + +lodash@^4.17.11, lodash@^4.17.14, lodash@~4.17.11: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +log-symbols@2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a" + integrity sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg== + dependencies: + chalk "^2.0.1" + +lru-cache@^4.0.1: + version "4.1.5" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" + integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== + dependencies: + pseudomap "^1.0.2" + yallist "^2.1.2" + +map-age-cleaner@^0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz#7d583a7306434c055fe474b0f45078e6e1b4b92a" + integrity sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w== + dependencies: + p-defer "^1.0.0" + +mem@^4.0.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/mem/-/mem-4.3.0.tgz#461af497bc4ae09608cdb2e60eefb69bff744178" + integrity sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w== + dependencies: + map-age-cleaner "^0.1.1" + mimic-fn "^2.0.0" + p-is-promise "^2.0.0" + +mime-db@1.40.0: + version "1.40.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32" + integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA== + +mime-types@^2.1.12, mime-types@~2.1.19: + version "2.1.24" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81" + integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ== + dependencies: + mime-db "1.40.0" + +mimic-fn@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +"minimatch@2 || 3", minimatch@3.0.4, minimatch@^3.0.0, minimatch@^3.0.4, minimatch@~3.0.2: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== + dependencies: + brace-expansion "^1.1.7" + +minimist@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" + integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= + +minimist@^1.2.0, minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + +minipass@^2.6.0, minipass@^2.8.6, minipass@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6" + integrity sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg== + dependencies: + safe-buffer "^5.1.2" + yallist "^3.0.0" + +minizlib@^1.2.1: + version "1.3.3" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.3.3.tgz#2290de96818a34c29551c8a8d301216bd65a861d" + integrity sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q== + dependencies: + minipass "^2.9.0" + +mkdirp@0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" + integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= + dependencies: + minimist "0.0.8" + +mkdirp@0.5.x, mkdirp@^0.5.0, mkdirp@^0.5.1: + version "0.5.5" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" + integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== + dependencies: + minimist "^1.2.5" + +mocha-jshint@: + version "2.3.1" + resolved "https://registry.yarnpkg.com/mocha-jshint/-/mocha-jshint-2.3.1.tgz#303f27e533938559d20f26a4123d5bcb5645a546" + integrity sha1-MD8n5TOThVnSDyakEj1by1ZFpUY= + dependencies: + jshint "^2.8.0" + minimatch "^3.0.0" + shelljs "^0.4.0" + uniq "^1.0.1" + +mocha-parallel-tests@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/mocha-parallel-tests/-/mocha-parallel-tests-2.1.2.tgz#2f5c24022257e5fc6c63a6bd22d49c1487496b26" + integrity sha512-FatFg3MHLio9ir1oP6J0HNEo6R5344JC1y+We90iALdiT9F9xNPN0KbGXxRNlGlSl0GodfSESKbRzBvT9ctgIw== + dependencies: + circular-json "^0.5.9" + debug "^4.1.1" + uuid "^3.3.2" + yargs "^13.2.2" + +mocha@^6.1.4: + version "6.1.4" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-6.1.4.tgz#e35fada242d5434a7e163d555c705f6875951640" + integrity sha512-PN8CIy4RXsIoxoFJzS4QNnCH4psUCPWc4/rPrst/ecSJJbLBkubMiyGCP2Kj/9YnWbotFqAoeXyXMucj7gwCFg== + dependencies: + ansi-colors "3.2.3" + browser-stdout "1.3.1" + debug "3.2.6" + diff "3.5.0" + escape-string-regexp "1.0.5" + find-up "3.0.0" + glob "7.1.3" + growl "1.10.5" + he "1.2.0" + js-yaml "3.13.1" + log-symbols "2.2.0" + minimatch "3.0.4" + mkdirp "0.5.1" + ms "2.1.1" + node-environment-flags "1.0.5" + object.assign "4.1.0" + strip-json-comments "2.0.1" + supports-color "6.0.0" + which "1.3.1" + wide-align "1.1.3" + yargs "13.2.2" + yargs-parser "13.0.0" + yargs-unparser "1.5.0" + +ms@2.1.1, ms@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" + integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== + +mz@^2.6.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" + integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== + dependencies: + any-promise "^1.0.0" + object-assign "^4.0.1" + thenify-all "^1.0.0" + +nan@^2.14.0: + version "2.14.1" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" + integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== + +needle@^2.2.1: + version "2.4.0" + resolved "https://registry.yarnpkg.com/needle/-/needle-2.4.0.tgz#6833e74975c444642590e15a750288c5f939b57c" + integrity sha512-4Hnwzr3mi5L97hMYeNl8wRW/Onhy4nUKR/lVemJ8gJedxxUyBLm9kkrDColJvoSfwi0jCNhD+xCdOtiGDQiRZg== + dependencies: + debug "^3.2.6" + iconv-lite "^0.4.4" + sax "^1.2.4" + +neo-async@^2.6.0: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + +nice-try@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" + integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== + +node-environment-flags@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.5.tgz#fa930275f5bf5dae188d6192b24b4c8bbac3d76a" + integrity sha512-VNYPRfGfmZLx0Ye20jWzHUjyTW/c+6Wq+iLhDzUI4XmhrDd9l/FozXV3F2xOaXjvp0co0+v1YSR3CMP6g+VvLQ== + dependencies: + object.getownpropertydescriptors "^2.0.3" + semver "^5.7.0" + +node-gyp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-4.0.0.tgz#972654af4e5dd0cd2a19081b4b46fe0442ba6f45" + integrity sha512-2XiryJ8sICNo6ej8d0idXDEMKfVfFK7kekGCtJAuelGsYHQxhj13KTf95swTCN2dZ/4lTfZ84Fu31jqJEEgjWA== + dependencies: + glob "^7.0.3" + graceful-fs "^4.1.2" + mkdirp "^0.5.0" + nopt "2 || 3" + npmlog "0 || 1 || 2 || 3 || 4" + osenv "0" + request "^2.87.0" + rimraf "2" + semver "~5.3.0" + tar "^4.4.8" + which "1" + +node-pre-gyp@^0.13.0: + version "0.13.0" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.13.0.tgz#df9ab7b68dd6498137717838e4f92a33fc9daa42" + integrity sha512-Md1D3xnEne8b/HGVQkZZwV27WUi1ZRuZBij24TNaZwUPU3ZAFtvT6xxJGaUVillfmMKnn5oD1HoGsp2Ftik7SQ== + dependencies: + detect-libc "^1.0.2" + mkdirp "^0.5.1" + needle "^2.2.1" + nopt "^4.0.1" + npm-packlist "^1.1.6" + npmlog "^4.0.2" + rc "^1.2.7" + rimraf "^2.6.1" + semver "^5.3.0" + tar "^4" + +node-version@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/node-version/-/node-version-1.2.0.tgz#34fde3ffa8e1149bd323983479dda620e1b5060d" + integrity sha512-ma6oU4Sk0qOoKEAymVoTvk8EdXEobdS7m/mAGhDJ8Rouugho48crHBORAmy5BoOcv8wraPM6xumapQp5hl4iIQ== + +nodegit-promise@~4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/nodegit-promise/-/nodegit-promise-4.0.0.tgz#5722b184f2df7327161064a791d2e842c9167b34" + integrity sha1-VyKxhPLfcycWEGSnkdLoQskWezQ= + dependencies: + asap "~2.0.3" + +nodegit@^0.25.0: + version "0.25.1" + resolved "https://registry.yarnpkg.com/nodegit/-/nodegit-0.25.1.tgz#70b9d89e62cbc1153237a10a196e3228a669895b" + integrity sha512-j2kEd4jTraimRPKDX31DsLcfY9fWpcYG8zT0tiLVtN5jMm9fDFgn3WOQ+Nk+3NcBqxr4p5N5pZqNrCS0nGdTbg== + dependencies: + fs-extra "^7.0.0" + json5 "^2.1.0" + lodash "^4.17.14" + nan "^2.14.0" + node-gyp "^4.0.0" + node-pre-gyp "^0.13.0" + promisify-node "~0.3.0" + ramda "^0.25.0" + request-promise-native "^1.0.5" + tar-fs "^1.16.3" + +"nopt@2 || 3", nopt@3.x: + version "3.0.6" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" + integrity sha1-xkZdvwirzU2zWTF/eaxopkayj/k= + dependencies: + abbrev "1" + +nopt@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" + integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00= + dependencies: + abbrev "1" + osenv "^0.1.4" + +npm-bundled@^1.0.1: + version "1.0.6" + resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.6.tgz#e7ba9aadcef962bb61248f91721cd932b3fe6bdd" + integrity sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g== + +npm-packlist@^1.1.6: + version "1.4.1" + resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.1.tgz#19064cdf988da80ea3cee45533879d90192bbfbc" + integrity sha512-+TcdO7HJJ8peiiYhvPxsEDhF3PJFGUGRcFsGve3vxvxdcpO2Z4Z7rkosRM0kWj6LfbK/P0gu3dzk5RU1ffvFcw== + dependencies: + ignore-walk "^3.0.1" + npm-bundled "^1.0.1" + +npm-run-path@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" + integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8= + dependencies: + path-key "^2.0.0" + +"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" + integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== + dependencies: + are-we-there-yet "~1.1.2" + console-control-strings "~1.1.0" + gauge "~2.7.3" + set-blocking "~2.0.0" + +number-is-nan@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" + integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= + +oauth-sign@~0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" + integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== + +object-assign@^4.0.1, object-assign@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + +object-inspect@^1.9.0: + version "1.10.3" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.10.3.tgz#c2aa7d2d09f50c99375704f7a0adf24c5782d369" + integrity sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw== + +object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da" + integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w== + dependencies: + define-properties "^1.1.2" + function-bind "^1.1.1" + has-symbols "^1.0.0" + object-keys "^1.0.11" + +object.assign@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940" + integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + has-symbols "^1.0.1" + object-keys "^1.1.1" + +object.getownpropertydescriptors@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz#8758c846f5b407adab0f236e0986f14b051caa16" + integrity sha1-h1jIRvW0B62rDyNuCYbxSwUcqhY= + dependencies: + define-properties "^1.1.2" + es-abstract "^1.5.1" + +object.getownpropertydescriptors@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.2.tgz#1bd63aeacf0d5d2d2f31b5e393b03a7c601a23f7" + integrity sha512-WtxeKSzfBjlzL+F9b7M7hewDzMwy+C8NRssHd1YrNlzHzIDrXcXiNOMrezdAEM4UXixgV+vvnyBeN7Rygl2ttQ== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.18.0-next.2" + +once@1.x, once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + dependencies: + wrappy "1" + +optionator@^0.8.1: + version "0.8.3" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" + integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== + dependencies: + deep-is "~0.1.3" + fast-levenshtein "~2.0.6" + levn "~0.3.0" + prelude-ls "~1.1.2" + type-check "~0.3.2" + word-wrap "~1.2.3" + +os-homedir@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" + integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= + +os-locale@^3.0.0, os-locale@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-3.1.0.tgz#a802a6ee17f24c10483ab9935719cef4ed16bf1a" + integrity sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q== + dependencies: + execa "^1.0.0" + lcid "^2.0.0" + mem "^4.0.0" + +os-tmpdir@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= + +osenv@0, osenv@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410" + integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g== + dependencies: + os-homedir "^1.0.0" + os-tmpdir "^1.0.0" + +p-defer@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" + integrity sha1-n26xgvbJqozXQwBKfU+WsZaw+ww= + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= + +p-is-promise@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-2.1.0.tgz#918cebaea248a62cf7ffab8e3bca8c5f882fc42e" + integrity sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg== + +p-limit@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.2.0.tgz#417c9941e6027a9abcba5092dd2904e255b5fbc2" + integrity sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ== + dependencies: + p-try "^2.0.0" + +p-locate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" + integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== + dependencies: + p-limit "^2.0.0" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +path-exists@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" + integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + +path-key@^2.0.0, path-key@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= + +pathval@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0" + integrity sha1-uULm1L3mUwBe9rcTYd74cn0GReA= + +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= + +prelude-ls@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" + integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +promise-polyfill@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-6.1.0.tgz#dfa96943ea9c121fca4de9b5868cb39d3472e057" + integrity sha1-36lpQ+qcEh/KTem1hoyznTRy4Fc= + +promisify-node@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/promisify-node/-/promisify-node-0.3.0.tgz#b4b55acf90faa7d2b8b90ca396899086c03060cf" + integrity sha1-tLVaz5D6p9K4uQyjlomQhsAwYM8= + dependencies: + nodegit-promise "~4.0.0" + +pseudomap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" + integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= + +psl@^1.1.24: + version "1.1.32" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.32.tgz#3f132717cf2f9c169724b2b6caf373cf694198db" + integrity sha512-MHACAkHpihU/REGGPLj4sEfc/XKW2bheigvHO1dUqjaKigMp1C8+WLQYRGgeKFMsw5PMfegZcaN8IDXK/cD0+g== + +pump@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/pump/-/pump-1.0.3.tgz#5dfe8311c33bbf6fc18261f9f34702c47c08a954" + integrity sha512-8k0JupWme55+9tCVE+FS5ULT3K6AbgqrGa58lTT49RpyfwwcGedHqaC5LlQNdEAumn/wFsu6aPwkuPMioy8kqw== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +punycode@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= + +punycode@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" + integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== + +qs@~6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== + +ramda@^0.25.0: + version "0.25.0" + resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.25.0.tgz#8fdf68231cffa90bc2f9460390a0cb74a29b29a9" + integrity sha512-GXpfrYVPwx3K7RQ6aYT8KPS8XViSXUVJT1ONhoKPE9VAleW42YE+U+8VEyGWt41EnEQW7gwecYJriTI0pKoecQ== + +rc@^1.2.7: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + +readable-stream@1.1: + version "1.1.14" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" + integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk= + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + +readable-stream@^2.0.6, readable-stream@^2.3.0, readable-stream@^2.3.5: + version "2.3.7" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" + integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readdir-withfiletypes@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/readdir-withfiletypes/-/readdir-withfiletypes-1.0.2.tgz#f54e6ed97e195c2823851c45477f35f500669f61" + integrity sha512-aERxJxl5lKupnqBdqatmaWOCj8YhHAB+/EdcBQU+z/vyII6dYysmNHxR9OVjdJOnhgq+5eX6wbo53ysFvu5UIw== + dependencies: + util.promisify "^1.0.0" + +request-promise-core@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.2.tgz#339f6aababcafdb31c799ff158700336301d3346" + integrity sha512-UHYyq1MO8GsefGEt7EprS8UrXsm1TxEvFUX1IMTuSLU2Rh7fTIdFtl8xD7JiEYiWU2dl+NYAjCTksTehQUxPag== + dependencies: + lodash "^4.17.11" + +request-promise-native@^1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.7.tgz#a49868a624bdea5069f1251d0a836e0d89aa2c59" + integrity sha512-rIMnbBdgNViL37nZ1b3L/VfPOpSi0TqVDQPAvO6U14lMzOLrt5nilxCQqtDKhZeDiW0/hkCXGoQjhgJd/tCh6w== + dependencies: + request-promise-core "1.1.2" + stealthy-require "^1.1.1" + tough-cookie "^2.3.3" + +request@^2.87.0: + version "2.88.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" + integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg== + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.0" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.4.3" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= + +require-main-filename@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" + integrity sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE= + +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + +resolve@1.1.x: + version "1.1.7" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" + integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= + +rimraf@: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +rimraf@2, rimraf@^2.6.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" + integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== + dependencies: + glob "^7.1.3" + +rimraf@~2.6.2: + version "2.6.3" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" + integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== + dependencies: + glob "^7.1.3" + +safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +sax@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" + integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== + +semver@^5.3.0, semver@~5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" + integrity sha1-myzl094C0XxgEq0yaqa00M9U+U8= + +semver@^5.5.0, semver@^5.7.0: + version "5.7.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b" + integrity sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA== + +set-blocking@^2.0.0, set-blocking@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= + +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= + dependencies: + shebang-regex "^1.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= + +shelljs@0.3.x: + version "0.3.0" + resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.3.0.tgz#3596e6307a781544f591f37da618360f31db57b1" + integrity sha1-NZbmMHp4FUT1kfN9phg2DzHbV7E= + +shelljs@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.4.0.tgz#199fe9e2de379efd03d345ff14062525e4b31ec2" + integrity sha1-GZ/p4t43nv0D00X/FAYlJeSzHsI= + +signal-exit@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" + integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= + +source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +source-map@~0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.2.0.tgz#dab73fbcfc2ba819b4de03bd6f6eaa48164b3f9d" + integrity sha1-2rc/vPwrqBm03gO9b26qSBZLP50= + dependencies: + amdefine ">=0.0.4" + +split@: + version "1.0.1" + resolved "https://registry.yarnpkg.com/split/-/split-1.0.1.tgz#605bd9be303aa59fb35f9229fbea0ddec9ea07d9" + integrity sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg== + dependencies: + through "2" + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= + +sshpk@^1.7.0: + version "1.16.1" + resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" + integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + bcrypt-pbkdf "^1.0.0" + dashdash "^1.12.0" + ecc-jsbn "~0.1.1" + getpass "^0.1.1" + jsbn "~0.1.0" + safer-buffer "^2.0.2" + tweetnacl "~0.14.0" + +stealthy-require@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" + integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= + +string-width@^1.0.1, "string-width@^1.0.2 || 2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" + integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= + dependencies: + code-point-at "^1.0.0" + is-fullwidth-code-point "^1.0.0" + strip-ansi "^3.0.0" + +string-width@^2.0.0, string-width@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + +string-width@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" + integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== + dependencies: + emoji-regex "^7.0.1" + is-fullwidth-code-point "^2.0.0" + strip-ansi "^5.1.0" + +string.prototype.trimend@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz#e75ae90c2942c63504686c18b287b4a0b1a45f80" + integrity sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + +string.prototype.trimstart@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz#b36399af4ab2999b4c9c648bd7a3fb2bb26feeed" + integrity sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + +string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ= + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +strip-ansi@^3.0.0, strip-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= + dependencies: + ansi-regex "^2.0.0" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= + dependencies: + ansi-regex "^3.0.0" + +strip-ansi@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" + integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== + dependencies: + ansi-regex "^4.1.0" + +strip-eof@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" + integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= + +strip-json-comments@1.0.x: + version "1.0.4" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-1.0.4.tgz#1e15fbcac97d3ee99bf2d73b4c656b082bbafb91" + integrity sha1-HhX7ysl9Pumb8tc7TGVrCCu6+5E= + +strip-json-comments@2.0.1, strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= + +supports-color@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.0.0.tgz#76cfe742cf1f41bb9b1c29ad03068c05b4c0e40a" + integrity sha512-on9Kwidc1IUQo+bQdhi8+Tijpo0e1SS6RoGo2guUwn5vdaxw8RXOF9Vb2ws+ihWOmh4JnCJOvaziZWP1VABaLg== + dependencies: + has-flag "^3.0.0" + +supports-color@^3.1.0: + version "3.2.3" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.2.3.tgz#65ac0504b3954171d8a64946b2ae3cbb8a5f54f6" + integrity sha1-ZawFBLOVQXHYpklGsq48u4pfVPY= + dependencies: + has-flag "^1.0.0" + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +tar-fs@^1.16.3: + version "1.16.3" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-1.16.3.tgz#966a628841da2c4010406a82167cbd5e0c72d509" + integrity sha512-NvCeXpYx7OsmOh8zIOP/ebG55zZmxLE0etfWRbWok+q2Qo8x/vOR/IJT1taADXPe+jsiu9axDb3X4B+iIgNlKw== + dependencies: + chownr "^1.0.1" + mkdirp "^0.5.1" + pump "^1.0.0" + tar-stream "^1.1.2" + +tar-stream@^1.1.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555" + integrity sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A== + dependencies: + bl "^1.0.0" + buffer-alloc "^1.2.0" + end-of-stream "^1.0.0" + fs-constants "^1.0.0" + readable-stream "^2.3.0" + to-buffer "^1.1.1" + xtend "^4.0.0" + +tar@^4, tar@^4.4.8: + version "4.4.15" + resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.15.tgz#3caced4f39ebd46ddda4d6203d48493a919697f8" + integrity sha512-ItbufpujXkry7bHH9NpQyTXPbJ72iTlXgkBAYsAjDXk3Ds8t/3NfO5P4xZGy7u+sYuQUbimgzswX4uQIEeNVOA== + dependencies: + chownr "^1.1.1" + fs-minipass "^1.2.5" + minipass "^2.8.6" + minizlib "^1.2.1" + mkdirp "^0.5.0" + safe-buffer "^5.1.2" + yallist "^3.0.3" + +temp@: + version "0.9.1" + resolved "https://registry.yarnpkg.com/temp/-/temp-0.9.1.tgz#2d666114fafa26966cd4065996d7ceedd4dd4697" + integrity sha512-WMuOgiua1xb5R56lE0eH6ivpVmg/lq2OHm4+LtT/xtEtPQ+sz6N3bBM6WZ5FvO1lO4IKIOb43qnhoc4qxP5OeA== + dependencies: + rimraf "~2.6.2" + +thenify-all@^1.0.0, thenify-all@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" + integrity sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY= + dependencies: + thenify ">= 3.1.0 < 4" + +"thenify@>= 3.1.0 < 4": + version "3.3.1" + resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f" + integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== + dependencies: + any-promise "^1.0.0" + +through@2: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= + +to-buffer@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80" + integrity sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg== + +to-function@*: + version "2.0.6" + resolved "https://registry.yarnpkg.com/to-function/-/to-function-2.0.6.tgz#7d56cd9c3b92fa8dbd7b22e83d51924de740ebc5" + integrity sha1-fVbNnDuS+o29eyLoPVGSTedA68U= + dependencies: + component-props "*" + +tough-cookie@^2.3.3, tough-cookie@~2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" + integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ== + dependencies: + psl "^1.1.24" + punycode "^1.4.1" + +tslib@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c" + integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ== + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= + dependencies: + safe-buffer "^5.0.1" + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= + +type-check@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" + integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= + dependencies: + prelude-ls "~1.1.2" + +type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + +uglify-js@^3.1.4: + version "3.13.5" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.13.5.tgz#5d71d6dbba64cf441f32929b1efce7365bb4f113" + integrity sha512-xtB8yEqIkn7zmOyS2zUNBsYCBRhDkvlNxMMY2smuJ/qA8NCHeQvKCF3i9Z4k8FJH4+PJvZRtMrPynfZ75+CSZw== + +unbox-primitive@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471" + integrity sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw== + dependencies: + function-bind "^1.1.1" + has-bigints "^1.0.1" + has-symbols "^1.0.2" + which-boxed-primitive "^1.0.2" + +uniq@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" + integrity sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8= + +universalify@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" + integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== + +uri-js@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" + integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ== + dependencies: + punycode "^2.1.0" + +util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + +util.promisify@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.1.1.tgz#77832f57ced2c9478174149cae9b96e9918cd54b" + integrity sha512-/s3UsZUrIfa6xDhr7zZhnE9SLQ5RIXyYfiVnMMyMDzOc8WhWN4Nbh36H842OyurKbCDAesZOJaVyvmSl6fhGQw== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + for-each "^0.3.3" + has-symbols "^1.0.1" + object.getownpropertydescriptors "^2.1.1" + +uuid@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" + integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== + +verror@1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" + integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + +which-boxed-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" + integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== + dependencies: + is-bigint "^1.0.1" + is-boolean-object "^1.1.0" + is-number-object "^1.0.4" + is-string "^1.0.5" + is-symbol "^1.0.3" + +which-module@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" + integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= + +which@1, which@1.3.1, which@^1.1.1, which@^1.2.9: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + +wide-align@1.1.3, wide-align@^1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" + integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== + dependencies: + string-width "^1.0.2 || 2" + +word-wrap@~1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" + integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= + +wrap-ansi@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" + integrity sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU= + dependencies: + string-width "^1.0.1" + strip-ansi "^3.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +xtend@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" + integrity sha1-pcbVMr5lbiPbgg77lDofBJmNY68= + +"y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" + integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== + +yallist@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" + integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= + +yallist@^3.0.0, yallist@^3.0.3: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + +yargs-parser@13.0.0, yargs-parser@^13.0.0: + version "13.0.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.0.0.tgz#3fc44f3e76a8bdb1cc3602e860108602e5ccde8b" + integrity sha512-w2LXjoL8oRdRQN+hOyppuXs+V/fVAYtpcrRxZuF7Kt/Oc+Jr2uAcVntaUTNT6w5ihoWfFDpNY8CPx1QskxZ/pw== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-parser@^11.1.1: + version "11.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-11.1.1.tgz#879a0865973bca9f6bab5cbdf3b1c67ec7d3bcf4" + integrity sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-unparser@1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-1.5.0.tgz#f2bb2a7e83cbc87bb95c8e572828a06c9add6e0d" + integrity sha512-HK25qidFTCVuj/D1VfNiEndpLIeJN78aqgR23nL3y4N0U/91cOAzqfHlF8n2BvoNDcZmJKin3ddNSvOxSr8flw== + dependencies: + flat "^4.1.0" + lodash "^4.17.11" + yargs "^12.0.5" + +yargs@13.2.2, yargs@^13.2.2: + version "13.2.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.2.2.tgz#0c101f580ae95cea7f39d927e7770e3fdc97f993" + integrity sha512-WyEoxgyTD3w5XRpAQNYUB9ycVH/PQrToaTXdYXRdOXvEy1l19br+VJsc0vcO8PTGg5ro/l/GY7F/JMEBmI0BxA== + dependencies: + cliui "^4.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + os-locale "^3.1.0" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.0.0" + +yargs@^12.0.5: + version "12.0.5" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.5.tgz#05f5997b609647b64f66b81e3b4b10a368e7ad13" + integrity sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw== + dependencies: + cliui "^4.0.0" + decamelize "^1.2.0" + find-up "^3.0.0" + get-caller-file "^1.0.1" + os-locale "^3.0.0" + require-directory "^2.1.1" + require-main-filename "^1.0.1" + set-blocking "^2.0.0" + string-width "^2.0.0" + which-module "^2.0.0" + y18n "^3.2.1 || ^4.0.0" + yargs-parser "^11.1.1"