From b212aab006dd98b4b522b6e635f63105e0333991 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 15 Feb 2017 15:59:43 -0800 Subject: [PATCH 01/34] Let's do some nodegits --- lib/git-shell-out-strategy.js | 98 ++++++++++++++++++++++++----------- package.json | 1 + 2 files changed, 70 insertions(+), 29 deletions(-) diff --git a/lib/git-shell-out-strategy.js b/lib/git-shell-out-strategy.js index f47a7d59ed..e09d811792 100644 --- a/lib/git-shell-out-strategy.js +++ b/lib/git-shell-out-strategy.js @@ -5,6 +5,7 @@ import {CompositeDisposable} from 'atom'; import {GitProcess} from 'git-kitchen-sink'; import {parse as parseDiff} from 'what-the-diff'; +import Git from 'nodegit'; import GitPromptServer from './git-prompt-server'; import AsyncQueue from './async-queue'; @@ -35,6 +36,7 @@ export default class GitShellOutStrategy { constructor(workingDir, options = {}) { this.workingDir = workingDir; + this.nodegit = Git.Repository.open(workingDir); const parallelism = options.parallelism || Math.max(3, os.cpus().length); this.commandQueue = new AsyncQueue({parallelism}); } @@ -122,6 +124,17 @@ export default class GitShellOutStrategy { /* eslint-enable no-console */ } + async execNodeGit(op, label = '') { + const timingMarker = GitTimingsView.generateMarker(label + ' (nodegit)'); + timingMarker.mark('queued'); + timingMarker.mark('prepare'); + timingMarker.mark('nexttick'); + timingMarker.mark('execute'); + const retVal = await op(); + timingMarker.finalize(); + return retVal; + } + /** * Execute a git command that may create a commit. If the command fails because the GPG binary was invoked and unable * to acquire a passphrase (because the pinentry program attempted to use a tty), retry with a `GitPromptServer`. @@ -304,9 +317,10 @@ export default class GitShellOutStrategy { * Miscellaneous getters */ async getCommit(ref) { - const output = await this.exec(['log', '--pretty=%H%x00%B%x00', '--no-abbrev-commit', '-1', ref]); - const [sha, message] = (output).split('\0'); - return {sha, message: message.trim()}; + return this.execNodeGit(async () => { + const commit = await this._getCommitFromRevspec(ref); + return {sha: commit.id().toString(), message: commit.message().trim()}; + }, 'getCommit'); } getHeadCommit() { @@ -340,16 +354,18 @@ export default class GitShellOutStrategy { /** * Remote interactions */ - clone(remoteUrl, options = {}) { + async clone(remoteUrl, options = {}) { const args = ['clone', '--no-local', remoteUrl, this.workingDir]; if (options.bare) { args.push('--bare'); } - return this.exec(args, {writeOperation: true}); + const result = await this.exec(args, {writeOperation: true}); + this.nodegit = Git.Repository.open(this.workingDir); + return result; } async getRemoteForBranch(branchName) { try { - const output = await this.exec(['config', `branch.${branchName}.remote`]); - return output.trim(); + // const output = await this.exec(['config', `branch.${branchName}.remote`]); + return await this.getConfig(`branch.${branchName}.remote`); } catch (e) { return null; } @@ -397,8 +413,11 @@ export default class GitShellOutStrategy { * Branches */ async getCurrentBranch() { - const output = await this.exec(['rev-parse', '--abbrev-ref', 'HEAD']); - return output.trim(); + return this.execNodeGit(async () => { + const repo = await this.nodegit; + const ref = await repo.head(); + return ref.shorthand(); + }, 'getCurrentBranch'); } checkout(branchName, options = {}) { @@ -430,38 +449,37 @@ export default class GitShellOutStrategy { } async getBranches() { - const output = await this.exec(['branch']); - return output.trim().split(LINE_ENDING_REGEX) - .map(branchName => branchName.trim().replace(/^\* /, '')); + return this.execNodeGit(async () => { + const repo = await this.nodegit; + const refs = await Git.Reference.list(repo); + return refs + .filter(ref => ref.startsWith('refs/heads/')) + .map(ref => ref.substr(11)); + }, 'getBranches'); } async getConfig(option, {local} = {}) { - let output; - try { - let args = ['config']; - if (local) { args.push('--local'); } - args = args.concat(option); - output = await this.exec(args); - } catch (err) { - if (err.code === 1 && err.stdErr === '') { - // No matching config found + // TODO: non-local??? + return this.execNodeGit(async () => { + const repo = await this.nodegit; + const config = await repo.configSnapshot(); + try { + return await config.getStringBuf(option); + } catch (_e) { return null; - } else { - throw err; } - } - - return output.trim(); + }, 'getConfig'); } setConfig(option, value, {replaceAll} = {}) { - let args = ['config']; + let args = ['config', '--local']; if (replaceAll) { args.push('--replace-all'); } args = args.concat(option, value); return this.exec(args, {writeOperation: true}); } async getRemotes() { + // TODO: ??? how to make this node-git compat? let output = await this.getConfig(['--get-regexp', '^remote..*.url$'], {local: true}); if (output) { output = output.trim(); @@ -491,13 +509,18 @@ export default class GitShellOutStrategy { } async expandBlobToFile(absFilePath, sha) { - const output = await this.exec(['cat-file', '-p', sha]); + const output = await this.getBlobContents(sha); await writeFile(absFilePath, output); return absFilePath; } async getBlobContents(sha) { - return await this.exec(['cat-file', '-p', sha]); + return this.execNodeGit(async () => { + const repo = await this.nodegit; + const oid = Git.Oid.fromString(sha); + const blob = await Git.Blob.lookup(repo, oid); + return blob.toString(); + }, 'getBlobContents'); } async mergeFile(currentPath, basePath, otherPath, resultPath) { @@ -520,6 +543,23 @@ export default class GitShellOutStrategy { await writeFile(resultPath, output); return {resultPath, conflict}; } + + async _getOidFromRevspec(revspec) { + const repo = await this.nodegit; + // `revspec` might be a refspec with suffixes, etc. + // so we use `fromRevspec` to get an annotated commit, + // and from that the actual commit. + const annotatedCommit = await Git.AnnotatedCommit.fromRevspec(repo, revspec); + const oid = annotatedCommit.id(); + annotatedCommit.free(); + return oid; + } + + async _getCommitFromRevspec(revspec) { + const repo = await this.nodegit; + const oid = await this._getOidFromRevspec(revspec); + return Git.Commit.lookup(repo, oid); + } } function buildAddedFilePatch(filePath, contents, stats) { diff --git a/package.json b/package.json index 3c6050cb31..ec5654cea0 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "multi-list-selection": "^0.1.1", "ncp": "^2.0.0", "node-fetch": "^1.6.3", + "nodegit": "^0.17.0", "nsfw": "1.0.14", "react": "^15.4.1", "react-dom": "^15.4.1", From f064145e3fc2aef4be7f5969d0e8b76c8d5ff531 Mon Sep 17 00:00:00 2001 From: Katrina Uychaco Date: Wed, 15 Feb 2017 19:35:33 -0800 Subject: [PATCH 02/34] Use nodegit for GSOS#getStatusesForChangedFiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This replaces the shelling out version (which was writing to the index) with a read-only implementation, allowing us to parallelize this operation and improve performance 🐎 --- lib/git-shell-out-strategy.js | 95 ++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 47 deletions(-) diff --git a/lib/git-shell-out-strategy.js b/lib/git-shell-out-strategy.js index e09d811792..a37f42997c 100644 --- a/lib/git-shell-out-strategy.js +++ b/lib/git-shell-out-strategy.js @@ -192,59 +192,60 @@ export default class GitShellOutStrategy { * File Status and Diffs */ async getStatusesForChangedFiles() { - const output = await this.exec(['status', '--untracked-files=all', '-z'], {writeOperation: true}); - - const statusMap = { - 'A': 'added', - 'C': 'added', // technically copied, but we'll treat as added - 'M': 'modified', - 'D': 'deleted', - 'U': 'modified', - '?': 'added', - }; - - const stagedFiles = {}; - const unstagedFiles = {}; - const mergeConflictFiles = {}; - let statusToHead; - - if (output) { - const lines = output.split('\0'); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (line === '') { continue; } - const [_, x, y, filePath] = line.match(/(.)(.)\s(.*)/); // eslint-disable-line no-unused-vars - if (x === 'U' || y === 'U' || (x === 'A' && y === 'A')) { - // Skipping this check here because we only run a single `await` - // and we only run it in the main, synchronous body of the for loop. - // eslint-disable-next-line babel/no-await-in-loop - if (!statusToHead) { statusToHead = await this.diffFileStatus({target: 'HEAD'}); } - mergeConflictFiles[filePath] = { - ours: statusMap[x], - theirs: statusMap[y], - file: statusToHead[filePath] || 'equivalent', - }; - continue; - } - if (x === 'R') { + return this.execNodeGit(async () => { + const stagedFiles = {}; + const unstagedFiles = {}; + const mergeConflictFiles = {}; + const repo = await this.nodegit; + const statuses = await repo.getStatus(); + const STATUS = Git.Status.STATUS; + const index = await repo.index(); + + for (let i = 0; i < statuses.length; i++) { + const status = statuses[i]; + const mask = status.statusBit(); + const filePath = status.path(); + if (mask & STATUS.INDEX_NEW) { stagedFiles[filePath] = 'added'; - stagedFiles[lines[++i]] = 'deleted'; - continue; } - if (y !== ' ') { - unstagedFiles[filePath] = statusMap[y]; + if (mask & STATUS.INDEX_MODIFIED || mask & STATUS.INDEX_TYPECHANGE) { + stagedFiles[filePath] = 'modified'; } - if (x !== ' ' && x !== '?') { - stagedFiles[filePath] = statusMap[x]; + if (mask & STATUS.INDEX_DELETED) { + stagedFiles[filePath] = 'deleted'; } - if (x === 'C') { - // skip the next line, which is the copied file - i++; + if (mask & STATUS.INDEX_RENAMED) { + console.warn('rename detected, check if handled correctly for staged filePath', filePath); + } + if (mask & STATUS.WT_NEW) { + unstagedFiles[filePath] = 'added'; + } + if (mask & STATUS.WT_MODIFIED || mask & STATUS.WT_TYPECHANGE) { + unstagedFiles[filePath] = 'modified'; + } + if (mask & STATUS.WT_DELETED) { + unstagedFiles[filePath] = 'deleted'; + } + if (mask & STATUS.WT_RENAMED) { + console.warn('rename detected, check if handled correctly for unstaged filePath', filePath); + } + if (mask & STATUS.CONFLICTED) { + const {ancestor_out: base, our_out: ours, their_out: theirs} = await index.conflictGet(filePath); + if (!base && ours && theirs) { + mergeConflictFiles[filePath] = {ours: 'added', theirs: 'added', file: 'modified'}; + } else if (base && ours && theirs) { + mergeConflictFiles[filePath] = {ours: 'modified', theirs: 'modified', file: 'modified'}; + } else if (base && ours && !theirs) { + mergeConflictFiles[filePath] = {ours: 'modified', theirs: 'deleted', file: 'equivalent'}; + } else if (base && !ours && theirs) { + mergeConflictFiles[filePath] = {ours: 'deleted', theirs: 'modified', file: 'added'}; + } else { + console.error('Unrecognized index state:', {base, ours, theirs}); + } } } - } - - return {stagedFiles, unstagedFiles, mergeConflictFiles}; + return {stagedFiles, unstagedFiles, mergeConflictFiles}; + }, 'getStatusesForChangedFiles'); } async diffFileStatus(options = {}) { From cfd9b493452fe0dd7bb95a4ed21745c1369fcdd6 Mon Sep 17 00:00:00 2001 From: Katrina Uychaco Date: Wed, 15 Feb 2017 20:24:45 -0800 Subject: [PATCH 03/34] Only select item in staging view when clicked or dragged over --- lib/views/staging-view.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/views/staging-view.js b/lib/views/staging-view.js index 88c7c9798f..c24afe87b4 100644 --- a/lib/views/staging-view.js +++ b/lib/views/staging-view.js @@ -273,6 +273,7 @@ export default class StagingView { async mousedownOnItem(event, item) { if (event.button === 0) { this.mouseSelectionInProgress = true; + this.selectionChanged = true; if (event.ctrlKey || event.metaKey) { this.selection.addOrSubtractSelection(item); } else { @@ -285,6 +286,7 @@ export default class StagingView { @autobind async mousemoveOnItem(event, item) { if (this.mouseSelectionInProgress) { + this.selectionChanged = true; this.selection.selectItem(item, true); await etch.update(this); } @@ -293,8 +295,9 @@ export default class StagingView { @autobind mouseup() { this.selection.coalesce(); - this.didChangeSelectedItems(); + if (this.selectionChanged) { this.didChangeSelectedItems(); } this.mouseSelectionInProgress = false; + this.selectionChanged = false; } render() { From 4049b12148a5645fecf918eed279bc51854da6a1 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Thu, 16 Feb 2017 11:33:11 -0800 Subject: [PATCH 04/34] :shirt: --- lib/git-shell-out-strategy.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/git-shell-out-strategy.js b/lib/git-shell-out-strategy.js index a37f42997c..56afbfaf4e 100644 --- a/lib/git-shell-out-strategy.js +++ b/lib/git-shell-out-strategy.js @@ -191,7 +191,7 @@ export default class GitShellOutStrategy { /** * File Status and Diffs */ - async getStatusesForChangedFiles() { + getStatusesForChangedFiles() { return this.execNodeGit(async () => { const stagedFiles = {}; const unstagedFiles = {}; @@ -201,6 +201,7 @@ export default class GitShellOutStrategy { const STATUS = Git.Status.STATUS; const index = await repo.index(); + /* eslint-disable no-bitwise */ for (let i = 0; i < statuses.length; i++) { const status = statuses[i]; const mask = status.statusBit(); @@ -215,6 +216,7 @@ export default class GitShellOutStrategy { stagedFiles[filePath] = 'deleted'; } if (mask & STATUS.INDEX_RENAMED) { + // eslint-disable-next-line no-console console.warn('rename detected, check if handled correctly for staged filePath', filePath); } if (mask & STATUS.WT_NEW) { @@ -227,9 +229,11 @@ export default class GitShellOutStrategy { unstagedFiles[filePath] = 'deleted'; } if (mask & STATUS.WT_RENAMED) { + // eslint-disable-next-line no-console console.warn('rename detected, check if handled correctly for unstaged filePath', filePath); } if (mask & STATUS.CONFLICTED) { + // eslint-disable-next-line babel/no-await-in-loop const {ancestor_out: base, our_out: ours, their_out: theirs} = await index.conflictGet(filePath); if (!base && ours && theirs) { mergeConflictFiles[filePath] = {ours: 'added', theirs: 'added', file: 'modified'}; @@ -240,10 +244,12 @@ export default class GitShellOutStrategy { } else if (base && !ours && theirs) { mergeConflictFiles[filePath] = {ours: 'deleted', theirs: 'modified', file: 'added'}; } else { + // eslint-disable-next-line no-console console.error('Unrecognized index state:', {base, ours, theirs}); } } } + /* eslint-enable no-bitwise */ return {stagedFiles, unstagedFiles, mergeConflictFiles}; }, 'getStatusesForChangedFiles'); } @@ -317,7 +323,7 @@ export default class GitShellOutStrategy { /** * Miscellaneous getters */ - async getCommit(ref) { + getCommit(ref) { return this.execNodeGit(async () => { const commit = await this._getCommitFromRevspec(ref); return {sha: commit.id().toString(), message: commit.message().trim()}; @@ -413,7 +419,7 @@ export default class GitShellOutStrategy { /** * Branches */ - async getCurrentBranch() { + getCurrentBranch() { return this.execNodeGit(async () => { const repo = await this.nodegit; const ref = await repo.head(); @@ -449,7 +455,7 @@ export default class GitShellOutStrategy { } } - async getBranches() { + getBranches() { return this.execNodeGit(async () => { const repo = await this.nodegit; const refs = await Git.Reference.list(repo); @@ -459,7 +465,7 @@ export default class GitShellOutStrategy { }, 'getBranches'); } - async getConfig(option, {local} = {}) { + getConfig(option, {local} = {}) { // TODO: non-local??? return this.execNodeGit(async () => { const repo = await this.nodegit; @@ -515,7 +521,7 @@ export default class GitShellOutStrategy { return absFilePath; } - async getBlobContents(sha) { + getBlobContents(sha) { return this.execNodeGit(async () => { const repo = await this.nodegit; const oid = Git.Oid.fromString(sha); From 49a2fcec23f22fecc3826077fa2fbf135bc6ae7f Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Thu, 16 Feb 2017 11:38:08 -0800 Subject: [PATCH 05/34] Convert GSOS#getRemotes to nodegit --- lib/git-shell-out-strategy.js | 24 +++++++++--------------- test/git-shell-out-strategy.test.js | 3 ++- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/lib/git-shell-out-strategy.js b/lib/git-shell-out-strategy.js index 56afbfaf4e..68763a6005 100644 --- a/lib/git-shell-out-strategy.js +++ b/lib/git-shell-out-strategy.js @@ -486,21 +486,15 @@ export default class GitShellOutStrategy { } async getRemotes() { - // TODO: ??? how to make this node-git compat? - let output = await this.getConfig(['--get-regexp', '^remote..*.url$'], {local: true}); - if (output) { - output = output.trim(); - if (!output.length) { return []; } - return output.split('\n').map(line => { - const match = line.match(/^remote\.(.*)\.url (.*)$/); - return { - name: match[1], - url: match[2], - }; - }); - } else { - return []; - } + const repo = await this.nodegit; + const remoteNames = await repo.getRemotes(); + const remotes = await Promise.all(remoteNames.map(remoteName => { + return Git.Remote.lookup(repo, remoteName); + })); + return remotes.map(remote => ({ + name: remote.name(), + url: remote.url(), + })); } async createBlob({filePath, stdin} = {}) { diff --git a/test/git-shell-out-strategy.test.js b/test/git-shell-out-strategy.test.js index 043c189bb9..7ce73da185 100644 --- a/test/git-shell-out-strategy.test.js +++ b/test/git-shell-out-strategy.test.js @@ -528,10 +528,11 @@ describe('Git commands', function() { await git.exec(['remote', 'add', 'upstream', 'git@github.com:my/upstream.git']); await git.exec(['remote', 'add', 'another.remote', 'git@github.com:another/upstream.git']); const remotes = await git.getRemotes(); + // Note: nodegit returns remote names in alphabetical order assert.deepEqual(remotes, [ + {name: 'another.remote', url: 'git@github.com:another/upstream.git'}, {name: 'origin', url: 'git@github.com:other/origin.git'}, {name: 'upstream', url: 'git@github.com:my/upstream.git'}, - {name: 'another.remote', url: 'git@github.com:another/upstream.git'}, ]); }); From 3c02de8d471f3a8a5aebb708371422a890601949 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Thu, 16 Feb 2017 13:52:53 -0800 Subject: [PATCH 06/34] Ensure tooltip is closed if showing when component unmounts --- lib/views/git-timings-view.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/views/git-timings-view.js b/lib/views/git-timings-view.js index b9faa0508b..feaff72d6a 100644 --- a/lib/views/git-timings-view.js +++ b/lib/views/git-timings-view.js @@ -138,11 +138,19 @@ class MarkerSpan extends React.Component { }); } - @autobind - handleMouseOut(e) { + closeTooltip() { this.tooltipDisposable && this.tooltipDisposable.dispose(); this.tooltipDisposable = null; } + + @autobind + handleMouseOut(e) { + this.closeTooltip(); + } + + componentWillUnmount() { + this.closeTooltip(); + } } From 79a90fe99a528a0b119e80a55a9c6839ad9de77c Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Sat, 18 Feb 2017 05:56:57 -0800 Subject: [PATCH 07/34] Add `firstImplementer` helper --- lib/helpers.js | 17 +++++++++++++++++ test/helpers.test.js | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 test/helpers.test.js diff --git a/lib/helpers.js b/lib/helpers.js index f8c4ea4f43..fae20c482b 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -1,6 +1,23 @@ import fs from 'fs-extra'; import {ncp} from 'ncp'; +/** + * Takes an array of targets and returns a proxy. The proxy intercepts property accessor calls and + * returns the value of that property on the first object in `targets` where the target implements that property. + */ +export function firstImplementer(...targets) { + return new Proxy(targets, { + get(target, name) { + const firstValidTarget = target.find(t => Reflect.has(t, name)); + if (firstValidTarget) { + return firstValidTarget[name]; + } else { + return undefined; + } + }, + }); +} + export function readFile(absoluteFilePath, encoding = 'utf8') { return new Promise((resolve, reject) => { fs.readFile(absoluteFilePath, encoding, (err, contents) => { diff --git a/test/helpers.test.js b/test/helpers.test.js new file mode 100644 index 0000000000..cc57b22998 --- /dev/null +++ b/test/helpers.test.js @@ -0,0 +1,20 @@ +import {firstImplementer} from '../lib/helpers'; + +class A { + one() { return 'a-one'; } + two() { return 'a-two'; } +} + +class B { + two() { return 'b-two'; } + three() { return 'b-three'; } +} + +describe('firstImplementer', function() { + it('calls methods from the first target that has the method', function() { + const target = firstImplementer(new A(), new B()); + assert.equal(target.one(), 'a-one'); + assert.equal(target.two(), 'a-two'); + assert.equal(target.three(), 'b-three'); + }); +}); From 82e2932545c06ba30ab5aebd088c2565616ca8db Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Sat, 18 Feb 2017 06:33:10 -0800 Subject: [PATCH 08/34] Revert GSOS to original (pre-nodegit) form --- lib/git-shell-out-strategy.js | 237 ++++++++++++++-------------------- 1 file changed, 98 insertions(+), 139 deletions(-) diff --git a/lib/git-shell-out-strategy.js b/lib/git-shell-out-strategy.js index 68763a6005..f47a7d59ed 100644 --- a/lib/git-shell-out-strategy.js +++ b/lib/git-shell-out-strategy.js @@ -5,7 +5,6 @@ import {CompositeDisposable} from 'atom'; import {GitProcess} from 'git-kitchen-sink'; import {parse as parseDiff} from 'what-the-diff'; -import Git from 'nodegit'; import GitPromptServer from './git-prompt-server'; import AsyncQueue from './async-queue'; @@ -36,7 +35,6 @@ export default class GitShellOutStrategy { constructor(workingDir, options = {}) { this.workingDir = workingDir; - this.nodegit = Git.Repository.open(workingDir); const parallelism = options.parallelism || Math.max(3, os.cpus().length); this.commandQueue = new AsyncQueue({parallelism}); } @@ -124,17 +122,6 @@ export default class GitShellOutStrategy { /* eslint-enable no-console */ } - async execNodeGit(op, label = '') { - const timingMarker = GitTimingsView.generateMarker(label + ' (nodegit)'); - timingMarker.mark('queued'); - timingMarker.mark('prepare'); - timingMarker.mark('nexttick'); - timingMarker.mark('execute'); - const retVal = await op(); - timingMarker.finalize(); - return retVal; - } - /** * Execute a git command that may create a commit. If the command fails because the GPG binary was invoked and unable * to acquire a passphrase (because the pinentry program attempted to use a tty), retry with a `GitPromptServer`. @@ -191,67 +178,60 @@ export default class GitShellOutStrategy { /** * File Status and Diffs */ - getStatusesForChangedFiles() { - return this.execNodeGit(async () => { - const stagedFiles = {}; - const unstagedFiles = {}; - const mergeConflictFiles = {}; - const repo = await this.nodegit; - const statuses = await repo.getStatus(); - const STATUS = Git.Status.STATUS; - const index = await repo.index(); - - /* eslint-disable no-bitwise */ - for (let i = 0; i < statuses.length; i++) { - const status = statuses[i]; - const mask = status.statusBit(); - const filePath = status.path(); - if (mask & STATUS.INDEX_NEW) { - stagedFiles[filePath] = 'added'; - } - if (mask & STATUS.INDEX_MODIFIED || mask & STATUS.INDEX_TYPECHANGE) { - stagedFiles[filePath] = 'modified'; - } - if (mask & STATUS.INDEX_DELETED) { - stagedFiles[filePath] = 'deleted'; - } - if (mask & STATUS.INDEX_RENAMED) { - // eslint-disable-next-line no-console - console.warn('rename detected, check if handled correctly for staged filePath', filePath); - } - if (mask & STATUS.WT_NEW) { - unstagedFiles[filePath] = 'added'; + async getStatusesForChangedFiles() { + const output = await this.exec(['status', '--untracked-files=all', '-z'], {writeOperation: true}); + + const statusMap = { + 'A': 'added', + 'C': 'added', // technically copied, but we'll treat as added + 'M': 'modified', + 'D': 'deleted', + 'U': 'modified', + '?': 'added', + }; + + const stagedFiles = {}; + const unstagedFiles = {}; + const mergeConflictFiles = {}; + let statusToHead; + + if (output) { + const lines = output.split('\0'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line === '') { continue; } + const [_, x, y, filePath] = line.match(/(.)(.)\s(.*)/); // eslint-disable-line no-unused-vars + if (x === 'U' || y === 'U' || (x === 'A' && y === 'A')) { + // Skipping this check here because we only run a single `await` + // and we only run it in the main, synchronous body of the for loop. + // eslint-disable-next-line babel/no-await-in-loop + if (!statusToHead) { statusToHead = await this.diffFileStatus({target: 'HEAD'}); } + mergeConflictFiles[filePath] = { + ours: statusMap[x], + theirs: statusMap[y], + file: statusToHead[filePath] || 'equivalent', + }; + continue; } - if (mask & STATUS.WT_MODIFIED || mask & STATUS.WT_TYPECHANGE) { - unstagedFiles[filePath] = 'modified'; + if (x === 'R') { + stagedFiles[filePath] = 'added'; + stagedFiles[lines[++i]] = 'deleted'; + continue; } - if (mask & STATUS.WT_DELETED) { - unstagedFiles[filePath] = 'deleted'; + if (y !== ' ') { + unstagedFiles[filePath] = statusMap[y]; } - if (mask & STATUS.WT_RENAMED) { - // eslint-disable-next-line no-console - console.warn('rename detected, check if handled correctly for unstaged filePath', filePath); + if (x !== ' ' && x !== '?') { + stagedFiles[filePath] = statusMap[x]; } - if (mask & STATUS.CONFLICTED) { - // eslint-disable-next-line babel/no-await-in-loop - const {ancestor_out: base, our_out: ours, their_out: theirs} = await index.conflictGet(filePath); - if (!base && ours && theirs) { - mergeConflictFiles[filePath] = {ours: 'added', theirs: 'added', file: 'modified'}; - } else if (base && ours && theirs) { - mergeConflictFiles[filePath] = {ours: 'modified', theirs: 'modified', file: 'modified'}; - } else if (base && ours && !theirs) { - mergeConflictFiles[filePath] = {ours: 'modified', theirs: 'deleted', file: 'equivalent'}; - } else if (base && !ours && theirs) { - mergeConflictFiles[filePath] = {ours: 'deleted', theirs: 'modified', file: 'added'}; - } else { - // eslint-disable-next-line no-console - console.error('Unrecognized index state:', {base, ours, theirs}); - } + if (x === 'C') { + // skip the next line, which is the copied file + i++; } } - /* eslint-enable no-bitwise */ - return {stagedFiles, unstagedFiles, mergeConflictFiles}; - }, 'getStatusesForChangedFiles'); + } + + return {stagedFiles, unstagedFiles, mergeConflictFiles}; } async diffFileStatus(options = {}) { @@ -323,11 +303,10 @@ export default class GitShellOutStrategy { /** * Miscellaneous getters */ - getCommit(ref) { - return this.execNodeGit(async () => { - const commit = await this._getCommitFromRevspec(ref); - return {sha: commit.id().toString(), message: commit.message().trim()}; - }, 'getCommit'); + async getCommit(ref) { + const output = await this.exec(['log', '--pretty=%H%x00%B%x00', '--no-abbrev-commit', '-1', ref]); + const [sha, message] = (output).split('\0'); + return {sha, message: message.trim()}; } getHeadCommit() { @@ -361,18 +340,16 @@ export default class GitShellOutStrategy { /** * Remote interactions */ - async clone(remoteUrl, options = {}) { + clone(remoteUrl, options = {}) { const args = ['clone', '--no-local', remoteUrl, this.workingDir]; if (options.bare) { args.push('--bare'); } - const result = await this.exec(args, {writeOperation: true}); - this.nodegit = Git.Repository.open(this.workingDir); - return result; + return this.exec(args, {writeOperation: true}); } async getRemoteForBranch(branchName) { try { - // const output = await this.exec(['config', `branch.${branchName}.remote`]); - return await this.getConfig(`branch.${branchName}.remote`); + const output = await this.exec(['config', `branch.${branchName}.remote`]); + return output.trim(); } catch (e) { return null; } @@ -419,12 +396,9 @@ export default class GitShellOutStrategy { /** * Branches */ - getCurrentBranch() { - return this.execNodeGit(async () => { - const repo = await this.nodegit; - const ref = await repo.head(); - return ref.shorthand(); - }, 'getCurrentBranch'); + async getCurrentBranch() { + const output = await this.exec(['rev-parse', '--abbrev-ref', 'HEAD']); + return output.trim(); } checkout(branchName, options = {}) { @@ -455,46 +429,53 @@ export default class GitShellOutStrategy { } } - getBranches() { - return this.execNodeGit(async () => { - const repo = await this.nodegit; - const refs = await Git.Reference.list(repo); - return refs - .filter(ref => ref.startsWith('refs/heads/')) - .map(ref => ref.substr(11)); - }, 'getBranches'); - } - - getConfig(option, {local} = {}) { - // TODO: non-local??? - return this.execNodeGit(async () => { - const repo = await this.nodegit; - const config = await repo.configSnapshot(); - try { - return await config.getStringBuf(option); - } catch (_e) { + async getBranches() { + const output = await this.exec(['branch']); + return output.trim().split(LINE_ENDING_REGEX) + .map(branchName => branchName.trim().replace(/^\* /, '')); + } + + async getConfig(option, {local} = {}) { + let output; + try { + let args = ['config']; + if (local) { args.push('--local'); } + args = args.concat(option); + output = await this.exec(args); + } catch (err) { + if (err.code === 1 && err.stdErr === '') { + // No matching config found return null; + } else { + throw err; } - }, 'getConfig'); + } + + return output.trim(); } setConfig(option, value, {replaceAll} = {}) { - let args = ['config', '--local']; + let args = ['config']; if (replaceAll) { args.push('--replace-all'); } args = args.concat(option, value); return this.exec(args, {writeOperation: true}); } async getRemotes() { - const repo = await this.nodegit; - const remoteNames = await repo.getRemotes(); - const remotes = await Promise.all(remoteNames.map(remoteName => { - return Git.Remote.lookup(repo, remoteName); - })); - return remotes.map(remote => ({ - name: remote.name(), - url: remote.url(), - })); + let output = await this.getConfig(['--get-regexp', '^remote..*.url$'], {local: true}); + if (output) { + output = output.trim(); + if (!output.length) { return []; } + return output.split('\n').map(line => { + const match = line.match(/^remote\.(.*)\.url (.*)$/); + return { + name: match[1], + url: match[2], + }; + }); + } else { + return []; + } } async createBlob({filePath, stdin} = {}) { @@ -510,18 +491,13 @@ export default class GitShellOutStrategy { } async expandBlobToFile(absFilePath, sha) { - const output = await this.getBlobContents(sha); + const output = await this.exec(['cat-file', '-p', sha]); await writeFile(absFilePath, output); return absFilePath; } - getBlobContents(sha) { - return this.execNodeGit(async () => { - const repo = await this.nodegit; - const oid = Git.Oid.fromString(sha); - const blob = await Git.Blob.lookup(repo, oid); - return blob.toString(); - }, 'getBlobContents'); + async getBlobContents(sha) { + return await this.exec(['cat-file', '-p', sha]); } async mergeFile(currentPath, basePath, otherPath, resultPath) { @@ -544,23 +520,6 @@ export default class GitShellOutStrategy { await writeFile(resultPath, output); return {resultPath, conflict}; } - - async _getOidFromRevspec(revspec) { - const repo = await this.nodegit; - // `revspec` might be a refspec with suffixes, etc. - // so we use `fromRevspec` to get an annotated commit, - // and from that the actual commit. - const annotatedCommit = await Git.AnnotatedCommit.fromRevspec(repo, revspec); - const oid = annotatedCommit.id(); - annotatedCommit.free(); - return oid; - } - - async _getCommitFromRevspec(revspec) { - const repo = await this.nodegit; - const oid = await this._getOidFromRevspec(revspec); - return Git.Commit.lookup(repo, oid); - } } function buildAddedFilePatch(filePath, contents, stats) { From 2a8d23acb638b022973c257b558a88876f4ef231 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Sat, 18 Feb 2017 06:40:51 -0800 Subject: [PATCH 09/34] Allow an AsyncQueue to be injected into GSOS --- lib/git-shell-out-strategy.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/git-shell-out-strategy.js b/lib/git-shell-out-strategy.js index f47a7d59ed..b57cda053c 100644 --- a/lib/git-shell-out-strategy.js +++ b/lib/git-shell-out-strategy.js @@ -35,8 +35,13 @@ export default class GitShellOutStrategy { constructor(workingDir, options = {}) { this.workingDir = workingDir; - const parallelism = options.parallelism || Math.max(3, os.cpus().length); - this.commandQueue = new AsyncQueue({parallelism}); + if (options.queue) { + this.commandQueue = options.queue; + } else { + const parallelism = options.parallelism || Math.max(3, os.cpus().length); + this.commandQueue = new AsyncQueue({parallelism}); + } + } // Execute a command and read the output using the embedded Git environment From 7085c7e84a833b97d88c5f2419653f6b9714f304 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Sat, 18 Feb 2017 08:11:09 -0800 Subject: [PATCH 10/34] Implement set/getOwnPropertyDescriptor/getPrototypeOf in firstImpementer --- lib/helpers.js | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/lib/helpers.js b/lib/helpers.js index fae20c482b..d74cb0e7fe 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -6,15 +6,45 @@ import {ncp} from 'ncp'; * returns the value of that property on the first object in `targets` where the target implements that property. */ export function firstImplementer(...targets) { - return new Proxy(targets, { + return new Proxy({strategies: targets}, { get(target, name) { - const firstValidTarget = target.find(t => Reflect.has(t, name)); + if (Reflect.has(target, name)) { + return target[name]; + } + + const firstValidTarget = targets.find(t => Reflect.has(t, name)); if (firstValidTarget) { return firstValidTarget[name]; } else { return undefined; } }, + + set(target, name, value) { + const firstValidTarget = targets.find(t => Reflect.has(t, name)); + if (firstValidTarget) { + firstValidTarget[name] = value; + } else { + target[name] = value; + } + }, + + // Used by sinon + getOwnPropertyDescriptor(target, name) { + const firstValidTarget = targets.find(t => Reflect.getOwnPropertyDescriptor(t, name)); + if (firstValidTarget) { + return Reflect.getOwnPropertyDescriptor(firstValidTarget, name); + } else { + return undefined; + } + }, + + // Used by sinon + getPrototypeOf(target) { + return targets.reduce((acc, t) => { + return Object.create(Reflect.getPrototypeOf(t), acc); + }, {}); + }, }); } From 99dbc5a3cada9cde38412f932a9a0d4162b99434 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Sat, 18 Feb 2017 08:11:46 -0800 Subject: [PATCH 11/34] Extract Nodegit strategy --- lib/nodegit-strategy.js | 200 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 lib/nodegit-strategy.js diff --git a/lib/nodegit-strategy.js b/lib/nodegit-strategy.js new file mode 100644 index 0000000000..589b7ff298 --- /dev/null +++ b/lib/nodegit-strategy.js @@ -0,0 +1,200 @@ +import os from 'os'; + +import Git from 'nodegit'; + +import AsyncQueue from './async-queue'; +import {writeFile} from './helpers'; +import GitTimingsView from './views/git-timings-view'; + +export default class NodeGitStrategy { + constructor(workingDir, options = {}) { + this.workingDir = workingDir; + this.nodegit = Git.Repository.open(workingDir); + if (options.queue) { + this.commandQueue = options.queue; + } else { + const parallelism = options.parallelism || Math.max(3, os.cpus().length); + this.commandQueue = new AsyncQueue({parallelism}); + } + } + + async execNodeGit(op, label = '') { + const timingMarker = GitTimingsView.generateMarker(label + ' (nodegit)'); + timingMarker.mark('queued'); + timingMarker.mark('prepare'); + timingMarker.mark('nexttick'); + timingMarker.mark('execute'); + const retVal = await op(); + timingMarker.finalize(); + return retVal; + } + + getStatusesForChangedFiles() { + return this.execNodeGit(async () => { + const stagedFiles = {}; + const unstagedFiles = {}; + const mergeConflictFiles = {}; + const repo = await this.nodegit; + const statuses = await repo.getStatus(); + const STATUS = Git.Status.STATUS; + const index = await repo.index(); + + /* eslint-disable no-bitwise */ + for (let i = 0; i < statuses.length; i++) { + const status = statuses[i]; + const mask = status.statusBit(); + const filePath = status.path(); + if (mask & STATUS.INDEX_NEW) { + stagedFiles[filePath] = 'added'; + } + if (mask & STATUS.INDEX_MODIFIED || mask & STATUS.INDEX_TYPECHANGE) { + stagedFiles[filePath] = 'modified'; + } + if (mask & STATUS.INDEX_DELETED) { + stagedFiles[filePath] = 'deleted'; + } + if (mask & STATUS.INDEX_RENAMED) { + // eslint-disable-next-line no-console + console.warn('rename detected, check if handled correctly for staged filePath', filePath); + } + if (mask & STATUS.WT_NEW) { + unstagedFiles[filePath] = 'added'; + } + if (mask & STATUS.WT_MODIFIED || mask & STATUS.WT_TYPECHANGE) { + unstagedFiles[filePath] = 'modified'; + } + if (mask & STATUS.WT_DELETED) { + unstagedFiles[filePath] = 'deleted'; + } + if (mask & STATUS.WT_RENAMED) { + // eslint-disable-next-line no-console + console.warn('rename detected, check if handled correctly for unstaged filePath', filePath); + } + if (mask & STATUS.CONFLICTED) { + // eslint-disable-next-line babel/no-await-in-loop + const {ancestor_out: base, our_out: ours, their_out: theirs} = await index.conflictGet(filePath); + if (!base && ours && theirs) { + mergeConflictFiles[filePath] = {ours: 'added', theirs: 'added', file: 'modified'}; + } else if (base && ours && theirs) { + mergeConflictFiles[filePath] = {ours: 'modified', theirs: 'modified', file: 'modified'}; + } else if (base && ours && !theirs) { + mergeConflictFiles[filePath] = {ours: 'modified', theirs: 'deleted', file: 'equivalent'}; + } else if (base && !ours && theirs) { + mergeConflictFiles[filePath] = {ours: 'deleted', theirs: 'modified', file: 'added'}; + } else { + // eslint-disable-next-line no-console + console.error('Unrecognized index state:', {base, ours, theirs}); + } + } + } + /* eslint-enable no-bitwise */ + return {stagedFiles, unstagedFiles, mergeConflictFiles}; + }, 'getStatusesForChangedFiles'); + } + + /** + * Miscellaneous getters + */ + getCommit(ref) { + return this.execNodeGit(async () => { + const commit = await this._getCommitFromRevspec(ref); + return {sha: commit.id().toString(), message: commit.message().trim()}; + }, 'getCommit'); + } + + getHeadCommit() { + return this.getCommit('HEAD'); + } + + async getRemoteForBranch(branchName) { + try { + // const output = await this.exec(['config', `branch.${branchName}.remote`]); + return await this.getConfig(`branch.${branchName}.remote`); + } catch (e) { + return null; + } + } + + async push(branchName, options = {}) { + const remote = await this.getRemoteForBranch(branchName); + const args = ['push', remote || 'origin', branchName]; + if (options.setUpstream) { args.push('--set-upstream'); } + if (options.force) { args.push('--force'); } + return this.exec(args, {useGitPromptServer: true, writeOperation: true}); + } + + getCurrentBranch() { + return this.execNodeGit(async () => { + const repo = await this.nodegit; + const ref = await repo.head(); + return ref.shorthand(); + }, 'getCurrentBranch'); + } + + getBranches() { + return this.execNodeGit(async () => { + const repo = await this.nodegit; + const refs = await Git.Reference.list(repo); + return refs + .filter(ref => ref.startsWith('refs/heads/')) + .map(ref => ref.substr(11)); + }, 'getBranches'); + } + + getConfig(option, {local} = {}) { + // TODO: non-local??? + return this.execNodeGit(async () => { + const repo = await this.nodegit; + const config = await repo.configSnapshot(); + try { + return await config.getStringBuf(option); + } catch (_e) { + return null; + } + }, 'getConfig'); + } + + async getRemotes() { + const repo = await this.nodegit; + const remoteNames = await repo.getRemotes(); + const remotes = await Promise.all(remoteNames.map(remoteName => { + return Git.Remote.lookup(repo, remoteName); + })); + return remotes.map(remote => ({ + name: remote.name(), + url: remote.url(), + })); + } + + async expandBlobToFile(absFilePath, sha) { + const output = await this.getBlobContents(sha); + await writeFile(absFilePath, output); + return absFilePath; + } + + getBlobContents(sha) { + return this.execNodeGit(async () => { + const repo = await this.nodegit; + const oid = Git.Oid.fromString(sha); + const blob = await Git.Blob.lookup(repo, oid); + return blob.toString(); + }, 'getBlobContents'); + } + + async _getOidFromRevspec(revspec) { + const repo = await this.nodegit; + // `revspec` might be a refspec with suffixes, etc. + // so we use `fromRevspec` to get an annotated commit, + // and from that the actual commit. + const annotatedCommit = await Git.AnnotatedCommit.fromRevspec(repo, revspec); + const oid = annotatedCommit.id(); + annotatedCommit.free(); + return oid; + } + + async _getCommitFromRevspec(revspec) { + const repo = await this.nodegit; + const oid = await this._getOidFromRevspec(revspec); + return Git.Commit.lookup(repo, oid); + } +} From a0be11ee84f169901e53c144d9aa209353598891 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Sat, 18 Feb 2017 08:12:18 -0800 Subject: [PATCH 12/34] Use CompositeGitStrategy as default strategy --- lib/composite-git-strategy.js | 19 +++++++++++++++++++ lib/models/repository.js | 4 ++-- 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 lib/composite-git-strategy.js diff --git a/lib/composite-git-strategy.js b/lib/composite-git-strategy.js new file mode 100644 index 0000000000..1e49f69cf7 --- /dev/null +++ b/lib/composite-git-strategy.js @@ -0,0 +1,19 @@ +import os from 'os'; + +import {firstImplementer} from './helpers'; +import AsyncQueue from './async-queue'; +import GitShellOutStrategy from './git-shell-out-strategy'; +import NodeGitStrategy from './nodegit-strategy'; + +export default { + create(workingDir, options = {}) { + const parallelism = options.parallelism || Math.max(3, os.cpus().length); + const commandQueue = new AsyncQueue({parallelism}); + const strategyOptions = {...options, queue: commandQueue}; + + const gitShellOutStrategy = new GitShellOutStrategy(workingDir, strategyOptions); + const nodegitStrategy = new NodeGitStrategy(workingDir, strategyOptions); + const strategies = [nodegitStrategy, gitShellOutStrategy]; + return firstImplementer(...strategies); + }, +}; diff --git a/lib/models/repository.js b/lib/models/repository.js index 4ed6f206ad..20dace9a87 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -3,7 +3,7 @@ import path from 'path'; import {Emitter, Disposable} from 'atom'; import {autobind} from 'core-decorators'; -import GitShellOutStrategy from '../git-shell-out-strategy'; +import CompositeGitStrategy from '../composite-git-strategy'; import FilePatch from './file-patch'; import Hunk from './hunk'; import HunkLine from './hunk-line'; @@ -33,7 +33,7 @@ export class CommitError extends Error { export default class Repository { static async open(workingDirectoryPath, gitStrategy) { - const strategy = gitStrategy || new GitShellOutStrategy(workingDirectoryPath); + const strategy = gitStrategy || CompositeGitStrategy.create(workingDirectoryPath); if (await strategy.isGitRepository()) { const historySha = await strategy.getConfig('atomGithub.historySha'); const history = historySha ? JSON.parse(await strategy.getBlobContents(historySha)) : {}; From 14c9cbec946778ddd63df3f78b5c1ae0d7517ece Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Sat, 18 Feb 2017 08:23:42 -0800 Subject: [PATCH 13/34] Convert GSOS tests to generic strategies test --- test/git-shell-out-strategy.test.js | 701 ------------------------- test/git-strategies.test.js | 779 ++++++++++++++++++++++++++++ 2 files changed, 779 insertions(+), 701 deletions(-) delete mode 100644 test/git-shell-out-strategy.test.js create mode 100644 test/git-strategies.test.js diff --git a/test/git-shell-out-strategy.test.js b/test/git-shell-out-strategy.test.js deleted file mode 100644 index 7ce73da185..0000000000 --- a/test/git-shell-out-strategy.test.js +++ /dev/null @@ -1,701 +0,0 @@ -import fs from 'fs-extra'; -import path from 'path'; - -import mkdirp from 'mkdirp'; - -import GitShellOutStrategy, {GitError} from '../lib/git-shell-out-strategy'; - -import {cloneRepository, assertDeepPropertyVals, setUpLocalAndRemoteRepositories} from './helpers'; - -/** - * KU Thoughts: The GitShellOutStrategy methods are tested in Repository tests for the most part - * For now, in order to minimize duplication, I'll limit test coverage here to methods that produce - * output that we rely on, to serve as documentation - */ - -describe('Git commands', function() { - describe('isGitRepository(directoryPath)', function() { - it('returns true if the path passed is a valid repository, and false if not', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - assert.isTrue(await git.isGitRepository(workingDirPath)); - - fs.removeSync(path.join(workingDirPath, '.git')); - assert.isFalse(await git.isGitRepository(workingDirPath)); - }); - }); - - describe('getStatusesForChangedFiles', function() { - it('returns objects for staged and unstaged files, including status information', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); - fs.unlinkSync(path.join(workingDirPath, 'b.txt')); - fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'd.txt')); - fs.writeFileSync(path.join(workingDirPath, 'e.txt'), 'qux', 'utf8'); - await git.exec(['add', 'a.txt', 'e.txt']); - fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'modify after staging', 'utf8'); - fs.writeFileSync(path.join(workingDirPath, 'e.txt'), 'modify after staging', 'utf8'); - const {stagedFiles, unstagedFiles, mergeConflictFiles} = await git.getStatusesForChangedFiles(); - assert.deepEqual(stagedFiles, { - 'a.txt': 'modified', - 'e.txt': 'added', - }); - assert.deepEqual(unstagedFiles, { - 'a.txt': 'modified', - 'b.txt': 'deleted', - 'c.txt': 'deleted', - 'd.txt': 'added', - 'e.txt': 'modified', - }); - assert.deepEqual(mergeConflictFiles, {}); - }); - - it('displays renamed files as one removed file and one added file', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'd.txt')); - await git.exec(['add', '.']); - const {stagedFiles, unstagedFiles, mergeConflictFiles} = await git.getStatusesForChangedFiles(); - assert.deepEqual(stagedFiles, { - 'c.txt': 'deleted', - 'd.txt': 'added', - }); - assert.deepEqual(unstagedFiles, {}); - assert.deepEqual(mergeConflictFiles, {}); - }); - - it('displays copied files as an added file', async function() { - // This repo is carefully constructed after much experimentation - // to cause git to detect `two.cc` as a copy of `one.cc`. - const workingDirPath = await cloneRepository('copied-file'); - const git = new GitShellOutStrategy(workingDirPath); - // Undo the last commit, which is where we copied one.cc to two.cc - // and then emptied one.cc. - await git.exec(['reset', '--soft', 'head^']); - const {stagedFiles, unstagedFiles, mergeConflictFiles} = await git.getStatusesForChangedFiles(); - assert.deepEqual(stagedFiles, { - 'one.cc': 'modified', - 'two.cc': 'added', - }); - assert.deepEqual(unstagedFiles, {}); - assert.deepEqual(mergeConflictFiles, {}); - }); - - it('returns an object for merge conflict files, including ours/theirs/file status information', async function() { - const workingDirPath = await cloneRepository('merge-conflict'); - const git = new GitShellOutStrategy(workingDirPath); - try { - await git.merge('origin/branch'); - } catch (e) { - // expected - if (!e.message.match(/CONFLICT/)) { - throw new Error(`merge failed for wrong reason: ${e.message}`); - } - } - - const {stagedFiles, unstagedFiles, mergeConflictFiles} = await git.getStatusesForChangedFiles(); - assert.deepEqual(stagedFiles, {}); - assert.deepEqual(unstagedFiles, {}); - - assert.deepEqual(mergeConflictFiles, { - 'added-to-both.txt': { - ours: 'added', - theirs: 'added', - file: 'modified', - }, - 'modified-on-both-ours.txt': { - ours: 'modified', - theirs: 'modified', - file: 'modified', - }, - 'modified-on-both-theirs.txt': { - ours: 'modified', - theirs: 'modified', - file: 'modified', - }, - 'removed-on-branch.txt': { - ours: 'modified', - theirs: 'deleted', - file: 'equivalent', - }, - 'removed-on-master.txt': { - ours: 'deleted', - theirs: 'modified', - file: 'added', - }, - }); - }); - }); - - describe('diffFileStatus', function() { - it('returns an object with working directory file diff status between relative to specified target commit', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); - fs.unlinkSync(path.join(workingDirPath, 'b.txt')); - fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'd.txt')); - fs.writeFileSync(path.join(workingDirPath, 'e.txt'), 'qux', 'utf8'); - const diffOutput = await git.diffFileStatus({target: 'HEAD'}); - assert.deepEqual(diffOutput, { - 'a.txt': 'modified', - 'b.txt': 'deleted', - 'c.txt': 'deleted', - 'd.txt': 'added', - 'e.txt': 'added', - }); - }); - - it('returns an empty object if there are no added, modified, or removed files', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - const diffOutput = await git.diffFileStatus({target: 'HEAD'}); - assert.deepEqual(diffOutput, {}); - }); - - it('only returns untracked files if the staged option is not passed', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - fs.writeFileSync(path.join(workingDirPath, 'new-file.txt'), 'qux', 'utf8'); - let diffOutput = await git.diffFileStatus({target: 'HEAD'}); - assert.deepEqual(diffOutput, {'new-file.txt': 'added'}); - diffOutput = await git.diffFileStatus({target: 'HEAD', staged: true}); - assert.deepEqual(diffOutput, {}); - }); - }); - - describe('getUntrackedFiles', function() { - it('returns an array of untracked file paths', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - fs.writeFileSync(path.join(workingDirPath, 'd.txt'), 'foo', 'utf8'); - fs.writeFileSync(path.join(workingDirPath, 'e.txt'), 'bar', 'utf8'); - fs.writeFileSync(path.join(workingDirPath, 'f.txt'), 'qux', 'utf8'); - assert.deepEqual(await git.getUntrackedFiles(), ['d.txt', 'e.txt', 'f.txt']); - }); - - it('handles untracked files in nested folders', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - fs.writeFileSync(path.join(workingDirPath, 'd.txt'), 'foo', 'utf8'); - const folderPath = path.join(workingDirPath, 'folder', 'subfolder'); - mkdirp.sync(folderPath); - fs.writeFileSync(path.join(folderPath, 'e.txt'), 'bar', 'utf8'); - fs.writeFileSync(path.join(folderPath, 'f.txt'), 'qux', 'utf8'); - assert.deepEqual(await git.getUntrackedFiles(), [ - 'd.txt', - 'folder/subfolder/e.txt', - 'folder/subfolder/f.txt', - ]); - }); - - it('returns an empty array if there are no untracked files', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - assert.deepEqual(await git.getUntrackedFiles(), []); - }); - }); - - describe('getDiffForFilePath', function() { - it('returns an empty array if there are no modified, added, or deleted files', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - - const diffOutput = await git.getDiffForFilePath('a.txt'); - assert.isUndefined(diffOutput); - }); - - it('ignores merge conflict files', async function() { - const workingDirPath = await cloneRepository('merge-conflict'); - const git = new GitShellOutStrategy(workingDirPath); - const diffOutput = await git.getDiffForFilePath('added-to-both.txt'); - assert.isUndefined(diffOutput); - }); - - describe('when the file is unstaged', function() { - it('returns a diff comparing the working directory copy of the file and the version on the index', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); - fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'd.txt')); - - assertDeepPropertyVals(await git.getDiffForFilePath('a.txt'), { - oldPath: 'a.txt', - newPath: 'a.txt', - oldMode: '100644', - newMode: '100644', - hunks: [ - { - oldStartLine: 1, - oldLineCount: 1, - newStartLine: 1, - newLineCount: 3, - heading: '', - lines: [ - '+qux', - ' foo', - '+bar', - ], - }, - ], - status: 'modified', - }); - - assertDeepPropertyVals(await git.getDiffForFilePath('c.txt'), { - oldPath: 'c.txt', - newPath: null, - oldMode: '100644', - newMode: null, - hunks: [ - { - oldStartLine: 1, - oldLineCount: 1, - newStartLine: 0, - newLineCount: 0, - heading: '', - lines: ['-baz'], - }, - ], - status: 'deleted', - }); - - assertDeepPropertyVals(await git.getDiffForFilePath('d.txt'), { - oldPath: null, - newPath: 'd.txt', - oldMode: null, - newMode: '100644', - hunks: [ - { - oldStartLine: 0, - oldLineCount: 0, - newStartLine: 1, - newLineCount: 1, - heading: '', - lines: ['+baz'], - }, - ], - status: 'added', - }); - }); - }); - - describe('when the file is staged', function() { - it('returns a diff comparing the index and head versions of the file', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); - fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'd.txt')); - await git.exec(['add', '.']); - - assertDeepPropertyVals(await git.getDiffForFilePath('a.txt', {staged: true}), { - oldPath: 'a.txt', - newPath: 'a.txt', - oldMode: '100644', - newMode: '100644', - hunks: [ - { - oldStartLine: 1, - oldLineCount: 1, - newStartLine: 1, - newLineCount: 3, - heading: '', - lines: [ - '+qux', - ' foo', - '+bar', - ], - }, - ], - status: 'modified', - }); - - assertDeepPropertyVals(await git.getDiffForFilePath('c.txt', {staged: true}), { - oldPath: 'c.txt', - newPath: null, - oldMode: '100644', - newMode: null, - hunks: [ - { - oldStartLine: 1, - oldLineCount: 1, - newStartLine: 0, - newLineCount: 0, - heading: '', - lines: ['-baz'], - }, - ], - status: 'deleted', - }); - - assertDeepPropertyVals(await git.getDiffForFilePath('d.txt', {staged: true}), { - oldPath: null, - newPath: 'd.txt', - oldMode: null, - newMode: '100644', - hunks: [ - { - oldStartLine: 0, - oldLineCount: 0, - newStartLine: 1, - newLineCount: 1, - heading: '', - lines: ['+baz'], - }, - ], - status: 'added', - }); - }); - }); - - describe('when the file is staged and a base commit is specified', function() { - it('returns a diff comparing the file on the index and in the specified commit', async function() { - const workingDirPath = await cloneRepository('multiple-commits'); - const git = new GitShellOutStrategy(workingDirPath); - - assertDeepPropertyVals(await git.getDiffForFilePath('file.txt', {staged: true, baseCommit: 'HEAD~'}), { - oldPath: 'file.txt', - newPath: 'file.txt', - oldMode: '100644', - newMode: '100644', - hunks: [ - { - oldStartLine: 1, - oldLineCount: 1, - newStartLine: 1, - newLineCount: 1, - heading: '', - lines: ['-two', '+three'], - }, - ], - status: 'modified', - }); - }); - }); - }); - - describe('isPartiallyStaged(filePath)', () => { - it('returns true if specified file path is partially staged', async () => { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'modified file', 'utf8'); - fs.writeFileSync(path.join(workingDirPath, 'new-file.txt'), 'foo\nbar\nbaz\n', 'utf8'); - fs.writeFileSync(path.join(workingDirPath, 'b.txt'), 'blah blah blah', 'utf8'); - fs.unlinkSync(path.join(workingDirPath, 'c.txt')); - - assert.isFalse(await git.isPartiallyStaged('a.txt')); - assert.isFalse(await git.isPartiallyStaged('b.txt')); - assert.isFalse(await git.isPartiallyStaged('c.txt')); - assert.isFalse(await git.isPartiallyStaged('new-file.txt')); - - await git.stageFiles(['a.txt', 'b.txt', 'c.txt', 'new-file.txt']); - assert.isFalse(await git.isPartiallyStaged('a.txt')); - assert.isFalse(await git.isPartiallyStaged('b.txt')); - assert.isFalse(await git.isPartiallyStaged('c.txt')); - assert.isFalse(await git.isPartiallyStaged('new-file.txt')); - - // modified on both - fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'more mods', 'utf8'); - // modified in working directory, added on index - fs.writeFileSync(path.join(workingDirPath, 'new-file.txt'), 'foo\nbar\nbaz\nqux\n', 'utf8'); - // deleted in working directory, modified on index - fs.unlinkSync(path.join(workingDirPath, 'b.txt')); - // untracked in working directory, deleted on index - fs.writeFileSync(path.join(workingDirPath, 'c.txt'), 'back baby', 'utf8'); - - assert.isTrue(await git.isPartiallyStaged('a.txt')); - assert.isTrue(await git.isPartiallyStaged('b.txt')); - assert.isTrue(await git.isPartiallyStaged('c.txt')); - assert.isTrue(await git.isPartiallyStaged('new-file.txt')); - }); - }); - - describe('isMerging', function() { - it('returns true if `.git/MERGE_HEAD` exists', async function() { - const workingDirPath = await cloneRepository('merge-conflict'); - const git = new GitShellOutStrategy(workingDirPath); - let isMerging = await git.isMerging(); - assert.isFalse(isMerging); - - try { - await git.merge('origin/branch'); - } catch (e) { - // expect merge to have conflicts - } - isMerging = await git.isMerging(); - assert.isTrue(isMerging); - - fs.unlinkSync(path.join(workingDirPath, '.git', 'MERGE_HEAD')); - isMerging = await git.isMerging(); - assert.isFalse(isMerging); - }); - }); - - describe('getAheadCount(branchName) and getBehindCount(branchName)', function() { - it('returns the number of different commits on the branch vs the remote', async function() { - const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); - const git = new GitShellOutStrategy(localRepoPath); - await git.exec(['config', 'push.default', 'upstream']); - assert.equal(await git.getBehindCount('master'), 0); - assert.equal(await git.getAheadCount('master'), 0); - await git.fetch('master'); - assert.equal(await git.getBehindCount('master'), 1); - assert.equal(await git.getAheadCount('master'), 0); - await git.commit('new commit', {allowEmpty: true}); - await git.commit('another commit', {allowEmpty: true}); - assert.equal(await git.getBehindCount('master'), 1); - assert.equal(await git.getAheadCount('master'), 2); - }); - - it('returns null if there is no remote tracking branch specified', async function() { - const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); - const git = new GitShellOutStrategy(localRepoPath); - await git.exec(['config', 'push.default', 'upstream']); - await git.exec(['config', '--remove-section', 'branch.master']); - assert.equal(await git.getBehindCount('master'), null); - assert.equal(await git.getAheadCount('master'), null); - }); - - it('returns null if the configured remote tracking branch does not exist locally', async function() { - const {localRepoPath} = await setUpLocalAndRemoteRepositories(); - const git = new GitShellOutStrategy(localRepoPath); - await git.exec(['config', 'push.default', 'upstream']); - await git.exec(['branch', '-d', '-r', 'origin/master']); - assert.equal(await git.getBehindCount('master'), null); - assert.equal(await git.getAheadCount('master'), null); - - await git.exec(['fetch']); - assert.equal(await git.getBehindCount('master'), 0); - assert.equal(await git.getAheadCount('master'), 0); - }); - - it('handles a remote tracking branch with a name that differs from the local branch', async function() { - const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); - const git = new GitShellOutStrategy(localRepoPath); - await git.exec(['config', 'push.default', 'upstream']); - await git.exec(['checkout', '-b', 'new-local-branch']); - await git.exec(['remote', 'rename', 'origin', 'upstream']); - await git.exec(['branch', '--set-upstream-to=upstream/master']); - assert.equal(await git.getBehindCount('new-local-branch'), 0); - assert.equal(await git.getAheadCount('new-local-branch'), 0); - }); - }); - - describe('getCurrentBranch() and checkout(branchName, {createNew})', function() { - it('returns the current branch name', async function() { - const workingDirPath = await cloneRepository('merge-conflict'); - const git = new GitShellOutStrategy(workingDirPath); - assert.equal(await git.getCurrentBranch(), 'master'); - await git.checkout('branch'); - assert.equal(await git.getCurrentBranch(), 'branch'); - - // newBranch does not yet exist - await assert.isRejected(git.checkout('newBranch')); - assert.equal(await git.getCurrentBranch(), 'branch'); - await git.checkout('newBranch', {createNew: true}); - assert.equal(await git.getCurrentBranch(), 'newBranch'); - }); - }); - - describe('getRemoteForBranch(branchName)', function() { - it('returns the name of the remote associated with the branch, and null if none exists', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - assert.equal(await git.getRemoteForBranch('master'), 'origin'); - await git.exec(['remote', 'rename', 'origin', 'foo']); - assert.equal(await git.getRemoteForBranch('master'), 'foo'); - await git.exec(['remote', 'rm', 'foo']); - assert.isNull(await git.getRemoteForBranch('master')); - }); - }); - - describe('getBranches()', function() { - it('returns an array of all branches', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - assert.deepEqual(await git.getBranches(), ['master']); - await git.checkout('new-branch', {createNew: true}); - assert.deepEqual(await git.getBranches(), ['master', 'new-branch']); - await git.checkout('another-branch', {createNew: true}); - assert.deepEqual(await git.getBranches(), ['another-branch', 'master', 'new-branch']); - }); - }); - - describe('getRemotes()', function() { - it('returns an array of remotes', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - await git.exec(['remote', 'set-url', 'origin', 'git@github.com:other/origin.git']); - await git.exec(['remote', 'add', 'upstream', 'git@github.com:my/upstream.git']); - await git.exec(['remote', 'add', 'another.remote', 'git@github.com:another/upstream.git']); - const remotes = await git.getRemotes(); - // Note: nodegit returns remote names in alphabetical order - assert.deepEqual(remotes, [ - {name: 'another.remote', url: 'git@github.com:another/upstream.git'}, - {name: 'origin', url: 'git@github.com:other/origin.git'}, - {name: 'upstream', url: 'git@github.com:my/upstream.git'}, - ]); - }); - - it('returns an empty array when no remotes are set up', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - await git.exec(['remote', 'rm', 'origin']); - const remotes = await git.getRemotes(); - assert.deepEqual(remotes, []); - }); - }); - - describe('getConfig() and setConfig()', function() { - it('gets and sets configs', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - assert.isNull(await git.getConfig('awesome.devs')); - await git.setConfig('awesome.devs', 'BinaryMuse,kuychaco,smashwilson'); - assert.equal('BinaryMuse,kuychaco,smashwilson', await git.getConfig('awesome.devs')); - }); - }); - - describe('commit(message, options) where amend option is true', function() { - it('amends the last commit', async function() { - const workingDirPath = await cloneRepository('multiple-commits'); - const git = new GitShellOutStrategy(workingDirPath); - const lastCommit = await git.getHeadCommit(); - const lastCommitParent = await git.getCommit('HEAD~'); - await git.commit('amend last commit', {amend: true, allowEmpty: true}); - const amendedCommit = await git.getHeadCommit(); - const amendedCommitParent = await git.getCommit('HEAD~'); - assert.notDeepEqual(lastCommit, amendedCommit); - assert.deepEqual(lastCommitParent, amendedCommitParent); - }); - }); - - describe('GPG signing', () => { - let git; - - beforeEach(async () => { - const workingDirPath = await cloneRepository('multiple-commits'); - git = new GitShellOutStrategy(workingDirPath); - }); - - const operations = [ - { - verbalNoun: 'commit', - progressiveTense: 'committing', - action: () => git.commit('message'), - }, - { - verbalNoun: 'merge', - progressiveTense: 'merging', - action: () => git.merge('some-branch'), - }, - { - verbalNoun: 'pull', - progressiveTense: 'pulling', - action: () => git.pull('some-branch'), - configureStub: stub => { - // Stub the `git config` call to resolve the branch's upstream. - stub.onCall(0).returns(Promise.resolve('origin')); - return 1; - }, - }, - ]; - - operations.forEach(op => { - it(`temporarily overrides gpg.program when ${op.progressiveTense}`, async () => { - const execStub = sinon.stub(git, 'exec'); - let callIndex = 0; - if (op.configureStub) { - callIndex = op.configureStub(execStub); - } - execStub.returns(Promise.resolve()); - - await op.action(); - - const callArgs = execStub.getCall(callIndex).args; - const execArgs = callArgs[0]; - assert.equal(execArgs[0], '-c'); - assert.match(execArgs[1], /^gpg\.program=.*gpg-no-tty\.sh$/); - assert.isNotOk(callArgs[1].stdin); - assert.isNotOk(callArgs[1].useGitPromptServer); - }); - - it(`retries a ${op.verbalNoun} with a GitPromptServer when GPG signing fails`, async () => { - const gpgErr = new GitError('Mock GPG failure'); - gpgErr.stdErr = 'stderr includes "gpg failed"'; - gpgErr.code = 128; - - let callIndex = 0; - const execStub = sinon.stub(git, 'exec'); - if (op.configureStub) { - callIndex = op.configureStub(execStub); - } - execStub.onCall(callIndex).returns(Promise.reject(gpgErr)); - execStub.returns(Promise.resolve()); - - try { - await op.action(); - } catch (err) { - assert.fail('expected op.action() not to throw the mock error'); - } - - const callArgs0 = execStub.getCall(callIndex).args; - const execArgs0 = callArgs0[0]; - assert.equal(execArgs0[0], '-c'); - assert.match(execArgs0[1], /^gpg\.program=.*gpg-no-tty\.sh$/); - assert.isNotOk(callArgs0[1].stdin); - assert.isNotOk(callArgs0[1].useGitPromptServer); - - const callArgs1 = execStub.getCall(callIndex + 1).args; - const execArgs1 = callArgs1[0]; - assert.equal(execArgs1[0], '-c'); - assert.match(execArgs1[1], /^gpg\.program=.*gpg-no-tty\.sh$/); - assert.isNotOk(callArgs1[1].stdin); - assert.isTrue(callArgs1[1].useGitPromptServer); - }); - }); - }); - - describe('createBlob({filePath})', () => { - it('creates a blob for the file path specified and returns its sha', async () => { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); - const sha = await git.createBlob({filePath: 'a.txt'}); - assert.equal(sha, 'c9f54222977c93ea17ba4a5a53c611fa7f1aaf56'); - const contents = await git.exec(['cat-file', '-p', sha]); - assert.equal(contents, 'qux\nfoo\nbar\n'); - }); - - it('creates a blob for the stdin specified and returns its sha', async () => { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - const sha = await git.createBlob({stdin: 'foo\n'}); - assert.equal(sha, '257cc5642cb1a054f08cc83f2d943e56fd3ebe99'); - const contents = await git.exec(['cat-file', '-p', sha]); - assert.equal(contents, 'foo\n'); - }); - }); - - describe('expandBlobToFile(absFilePath, sha)', () => { - it('restores blob contents for sha to specified file path', async () => { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - const absFilePath = path.join(workingDirPath, 'a.txt'); - fs.writeFileSync(absFilePath, 'qux\nfoo\nbar\n', 'utf8'); - const sha = await git.createBlob({filePath: 'a.txt'}); - fs.writeFileSync(absFilePath, 'modifications', 'utf8'); - await git.expandBlobToFile(absFilePath, sha); - assert.equal(fs.readFileSync(absFilePath), 'qux\nfoo\nbar\n'); - }); - }); - - describe('getBlobContents(sha)', () => { - it('returns blob contents for sha', async () => { - const workingDirPath = await cloneRepository('three-files'); - const git = new GitShellOutStrategy(workingDirPath); - const sha = await git.createBlob({stdin: 'foo\nbar\nbaz\n'}); - const contents = await git.getBlobContents(sha); - assert.equal(contents, 'foo\nbar\nbaz\n'); - }); - }); -}); diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js new file mode 100644 index 0000000000..6b9dc5465a --- /dev/null +++ b/test/git-strategies.test.js @@ -0,0 +1,779 @@ +import fs from 'fs-extra'; +import path from 'path'; + +import mkdirp from 'mkdirp'; + +import CompositeGitStrategy from '../lib/composite-git-strategy'; +import GitShellOutStrategy, {GitError} from '../lib/git-shell-out-strategy'; +import NodeGitStrategy from '../lib/nodegit-strategy'; + +import {cloneRepository, assertDeepPropertyVals, setUpLocalAndRemoteRepositories} from './helpers'; + +/** + * KU Thoughts: The GitShellOutStrategy methods are tested in Repository tests for the most part + * For now, in order to minimize duplication, I'll limit test coverage here to methods that produce + * output that we rely on, to serve as documentation + */ + +let createTestStrategy = null; +function forStrategy(Strategy, callback) { + createTestStrategy = function(...args) { + return new Strategy(...args); + }; + + function sdescribe(propertyUnderTest, description, suite, only = false) { + function createTestStrategyForPropertyUnderTest(...args) { + const compositeStrategy = CompositeGitStrategy.create(...args); + const originalStrategy = new Strategy(...args); + return new Proxy(compositeStrategy, { + get(target, name) { + if (name === propertyUnderTest) { + return originalStrategy[name]; + } else { + return compositeStrategy[name]; + } + }, + + set(target, name, value) { + if (name === propertyUnderTest) { + originalStrategy[name] = value; + } else { + compositeStrategy[name] = value; + } + }, + + getOwnPropertyDescriptor(target, name) { + if (name === propertyUnderTest) { + return Reflect.getOwnPropertyDescriptor(originalStrategy, name); + } else { + return Reflect.getOwnPropertyDescriptor(compositeStrategy, name); + } + }, + }); + } + + const describeMethod = only ? describe.only : describe; + + if (Strategy.prototype[propertyUnderTest]) { + return describeMethod(description, function() { + let oldCreateTestStrategy; + // eslint-disable-next-line jasmine/no-global-setup + beforeEach(function() { + oldCreateTestStrategy = createTestStrategy; + createTestStrategy = createTestStrategyForPropertyUnderTest; + }); + // eslint-disable-next-line jasmine/no-global-setup + afterEach(function() { + createTestStrategy = oldCreateTestStrategy; + }); + suite(); + }); + } else { + return describeMethod(description, function() { + it('is not implemented'); + }); + } + } + + sdescribe.only = function(propertyUnderTest, description, suite) { + return sdescribe(propertyUnderTest, description, suite, true); + }; + + callback(sdescribe); +} + +[GitShellOutStrategy, NodeGitStrategy].forEach(function(Strategy) { + forStrategy(Strategy, function(sdescribe) { + describe.only(`Git commands for ${Strategy.name}`, function() { + sdescribe('isGitRepository', 'isGitRepository(directoryPath)', function() { + it('returns true if the path passed is a valid repository, and false if not', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + assert.isTrue(await git.isGitRepository(workingDirPath)); + + fs.removeSync(path.join(workingDirPath, '.git')); + assert.isFalse(await git.isGitRepository(workingDirPath)); + }); + }); + + sdescribe('getStatusesForChangedFiles', 'getStatusesForChangedFiles', function() { + it('returns objects for staged and unstaged files, including status information', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); + fs.unlinkSync(path.join(workingDirPath, 'b.txt')); + fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'd.txt')); + fs.writeFileSync(path.join(workingDirPath, 'e.txt'), 'qux', 'utf8'); + await git.exec(['add', 'a.txt', 'e.txt']); + fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'modify after staging', 'utf8'); + fs.writeFileSync(path.join(workingDirPath, 'e.txt'), 'modify after staging', 'utf8'); + const {stagedFiles, unstagedFiles, mergeConflictFiles} = await git.getStatusesForChangedFiles(); + assert.deepEqual(stagedFiles, { + 'a.txt': 'modified', + 'e.txt': 'added', + }); + assert.deepEqual(unstagedFiles, { + 'a.txt': 'modified', + 'b.txt': 'deleted', + 'c.txt': 'deleted', + 'd.txt': 'added', + 'e.txt': 'modified', + }); + assert.deepEqual(mergeConflictFiles, {}); + }); + + it('displays renamed files as one removed file and one added file', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'd.txt')); + await git.exec(['add', '.']); + const {stagedFiles, unstagedFiles, mergeConflictFiles} = await git.getStatusesForChangedFiles(); + assert.deepEqual(stagedFiles, { + 'c.txt': 'deleted', + 'd.txt': 'added', + }); + assert.deepEqual(unstagedFiles, {}); + assert.deepEqual(mergeConflictFiles, {}); + }); + + it('displays copied files as an added file', async function() { + // This repo is carefully constructed after much experimentation + // to cause git to detect `two.cc` as a copy of `one.cc`. + const workingDirPath = await cloneRepository('copied-file'); + const git = createTestStrategy(workingDirPath); + // Undo the last commit, which is where we copied one.cc to two.cc + // and then emptied one.cc. + await git.exec(['reset', '--soft', 'head^']); + const {stagedFiles, unstagedFiles, mergeConflictFiles} = await git.getStatusesForChangedFiles(); + assert.deepEqual(stagedFiles, { + 'one.cc': 'modified', + 'two.cc': 'added', + }); + assert.deepEqual(unstagedFiles, {}); + assert.deepEqual(mergeConflictFiles, {}); + }); + + it('returns an object for merge conflict files, including ours/theirs/file status information', async function() { + const workingDirPath = await cloneRepository('merge-conflict'); + const git = createTestStrategy(workingDirPath); + try { + await git.merge('origin/branch'); + } catch (e) { + // expected + if (!e.message.match(/CONFLICT/)) { + throw new Error(`merge failed for wrong reason: ${e.message}`); + } + } + + const {stagedFiles, unstagedFiles, mergeConflictFiles} = await git.getStatusesForChangedFiles(); + assert.deepEqual(stagedFiles, {}); + assert.deepEqual(unstagedFiles, {}); + + assert.deepEqual(mergeConflictFiles, { + 'added-to-both.txt': { + ours: 'added', + theirs: 'added', + file: 'modified', + }, + 'modified-on-both-ours.txt': { + ours: 'modified', + theirs: 'modified', + file: 'modified', + }, + 'modified-on-both-theirs.txt': { + ours: 'modified', + theirs: 'modified', + file: 'modified', + }, + 'removed-on-branch.txt': { + ours: 'modified', + theirs: 'deleted', + file: 'equivalent', + }, + 'removed-on-master.txt': { + ours: 'deleted', + theirs: 'modified', + file: 'added', + }, + }); + }); + }); + + sdescribe('diffFileStatus', 'diffFileStatus', function() { + it('returns an object with working directory file diff status between relative to specified target commit', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); + fs.unlinkSync(path.join(workingDirPath, 'b.txt')); + fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'd.txt')); + fs.writeFileSync(path.join(workingDirPath, 'e.txt'), 'qux', 'utf8'); + const diffOutput = await git.diffFileStatus({target: 'HEAD'}); + assert.deepEqual(diffOutput, { + 'a.txt': 'modified', + 'b.txt': 'deleted', + 'c.txt': 'deleted', + 'd.txt': 'added', + 'e.txt': 'added', + }); + }); + + it('returns an empty object if there are no added, modified, or removed files', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + const diffOutput = await git.diffFileStatus({target: 'HEAD'}); + assert.deepEqual(diffOutput, {}); + }); + + it('only returns untracked files if the staged option is not passed', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + fs.writeFileSync(path.join(workingDirPath, 'new-file.txt'), 'qux', 'utf8'); + let diffOutput = await git.diffFileStatus({target: 'HEAD'}); + assert.deepEqual(diffOutput, {'new-file.txt': 'added'}); + diffOutput = await git.diffFileStatus({target: 'HEAD', staged: true}); + assert.deepEqual(diffOutput, {}); + }); + }); + + sdescribe('getUntrackedFiles', 'getUntrackedFiles', function() { + it('returns an array of untracked file paths', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + fs.writeFileSync(path.join(workingDirPath, 'd.txt'), 'foo', 'utf8'); + fs.writeFileSync(path.join(workingDirPath, 'e.txt'), 'bar', 'utf8'); + fs.writeFileSync(path.join(workingDirPath, 'f.txt'), 'qux', 'utf8'); + assert.deepEqual(await git.getUntrackedFiles(), ['d.txt', 'e.txt', 'f.txt']); + }); + + it('handles untracked files in nested folders', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + fs.writeFileSync(path.join(workingDirPath, 'd.txt'), 'foo', 'utf8'); + const folderPath = path.join(workingDirPath, 'folder', 'subfolder'); + mkdirp.sync(folderPath); + fs.writeFileSync(path.join(folderPath, 'e.txt'), 'bar', 'utf8'); + fs.writeFileSync(path.join(folderPath, 'f.txt'), 'qux', 'utf8'); + assert.deepEqual(await git.getUntrackedFiles(), [ + 'd.txt', + 'folder/subfolder/e.txt', + 'folder/subfolder/f.txt', + ]); + }); + + it('returns an empty array if there are no untracked files', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + assert.deepEqual(await git.getUntrackedFiles(), []); + }); + }); + + sdescribe('getDiffForFilePath', 'getDiffForFilePath', function() { + it('returns an empty array if there are no modified, added, or deleted files', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + + const diffOutput = await git.getDiffForFilePath('a.txt'); + assert.isUndefined(diffOutput); + }); + + it('ignores merge conflict files', async function() { + const workingDirPath = await cloneRepository('merge-conflict'); + const git = createTestStrategy(workingDirPath); + const diffOutput = await git.getDiffForFilePath('added-to-both.txt'); + assert.isUndefined(diffOutput); + }); + + describe('when the file is unstaged', function() { + it('returns a diff comparing the working directory copy of the file and the version on the index', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); + fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'd.txt')); + + assertDeepPropertyVals(await git.getDiffForFilePath('a.txt'), { + oldPath: 'a.txt', + newPath: 'a.txt', + oldMode: '100644', + newMode: '100644', + hunks: [ + { + oldStartLine: 1, + oldLineCount: 1, + newStartLine: 1, + newLineCount: 3, + heading: '', + lines: [ + '+qux', + ' foo', + '+bar', + ], + }, + ], + status: 'modified', + }); + + assertDeepPropertyVals(await git.getDiffForFilePath('c.txt'), { + oldPath: 'c.txt', + newPath: null, + oldMode: '100644', + newMode: null, + hunks: [ + { + oldStartLine: 1, + oldLineCount: 1, + newStartLine: 0, + newLineCount: 0, + heading: '', + lines: ['-baz'], + }, + ], + status: 'deleted', + }); + + assertDeepPropertyVals(await git.getDiffForFilePath('d.txt'), { + oldPath: null, + newPath: 'd.txt', + oldMode: null, + newMode: '100644', + hunks: [ + { + oldStartLine: 0, + oldLineCount: 0, + newStartLine: 1, + newLineCount: 1, + heading: '', + lines: ['+baz'], + }, + ], + status: 'added', + }); + }); + }); + + describe('when the file is staged', function() { + it('returns a diff comparing the index and head versions of the file', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); + fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'd.txt')); + await git.exec(['add', '.']); + + assertDeepPropertyVals(await git.getDiffForFilePath('a.txt', {staged: true}), { + oldPath: 'a.txt', + newPath: 'a.txt', + oldMode: '100644', + newMode: '100644', + hunks: [ + { + oldStartLine: 1, + oldLineCount: 1, + newStartLine: 1, + newLineCount: 3, + heading: '', + lines: [ + '+qux', + ' foo', + '+bar', + ], + }, + ], + status: 'modified', + }); + + assertDeepPropertyVals(await git.getDiffForFilePath('c.txt', {staged: true}), { + oldPath: 'c.txt', + newPath: null, + oldMode: '100644', + newMode: null, + hunks: [ + { + oldStartLine: 1, + oldLineCount: 1, + newStartLine: 0, + newLineCount: 0, + heading: '', + lines: ['-baz'], + }, + ], + status: 'deleted', + }); + + assertDeepPropertyVals(await git.getDiffForFilePath('d.txt', {staged: true}), { + oldPath: null, + newPath: 'd.txt', + oldMode: null, + newMode: '100644', + hunks: [ + { + oldStartLine: 0, + oldLineCount: 0, + newStartLine: 1, + newLineCount: 1, + heading: '', + lines: ['+baz'], + }, + ], + status: 'added', + }); + }); + }); + + describe('when the file is staged and a base commit is specified', function() { + it('returns a diff comparing the file on the index and in the specified commit', async function() { + const workingDirPath = await cloneRepository('multiple-commits'); + const git = createTestStrategy(workingDirPath); + + assertDeepPropertyVals(await git.getDiffForFilePath('file.txt', {staged: true, baseCommit: 'HEAD~'}), { + oldPath: 'file.txt', + newPath: 'file.txt', + oldMode: '100644', + newMode: '100644', + hunks: [ + { + oldStartLine: 1, + oldLineCount: 1, + newStartLine: 1, + newLineCount: 1, + heading: '', + lines: ['-two', '+three'], + }, + ], + status: 'modified', + }); + }); + }); + }); + + sdescribe('isPartiallyStaged', 'isPartiallyStaged(filePath)', () => { + it('returns true if specified file path is partially staged', async () => { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'modified file', 'utf8'); + fs.writeFileSync(path.join(workingDirPath, 'new-file.txt'), 'foo\nbar\nbaz\n', 'utf8'); + fs.writeFileSync(path.join(workingDirPath, 'b.txt'), 'blah blah blah', 'utf8'); + fs.unlinkSync(path.join(workingDirPath, 'c.txt')); + + assert.isFalse(await git.isPartiallyStaged('a.txt')); + assert.isFalse(await git.isPartiallyStaged('b.txt')); + assert.isFalse(await git.isPartiallyStaged('c.txt')); + assert.isFalse(await git.isPartiallyStaged('new-file.txt')); + + await git.stageFiles(['a.txt', 'b.txt', 'c.txt', 'new-file.txt']); + assert.isFalse(await git.isPartiallyStaged('a.txt')); + assert.isFalse(await git.isPartiallyStaged('b.txt')); + assert.isFalse(await git.isPartiallyStaged('c.txt')); + assert.isFalse(await git.isPartiallyStaged('new-file.txt')); + + // modified on both + fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'more mods', 'utf8'); + // modified in working directory, added on index + fs.writeFileSync(path.join(workingDirPath, 'new-file.txt'), 'foo\nbar\nbaz\nqux\n', 'utf8'); + // deleted in working directory, modified on index + fs.unlinkSync(path.join(workingDirPath, 'b.txt')); + // untracked in working directory, deleted on index + fs.writeFileSync(path.join(workingDirPath, 'c.txt'), 'back baby', 'utf8'); + + assert.isTrue(await git.isPartiallyStaged('a.txt')); + assert.isTrue(await git.isPartiallyStaged('b.txt')); + assert.isTrue(await git.isPartiallyStaged('c.txt')); + assert.isTrue(await git.isPartiallyStaged('new-file.txt')); + }); + }); + + sdescribe('isMerging', 'isMerging', function() { + it('returns true if `.git/MERGE_HEAD` exists', async function() { + const workingDirPath = await cloneRepository('merge-conflict'); + const git = createTestStrategy(workingDirPath); + let isMerging = await git.isMerging(); + assert.isFalse(isMerging); + + try { + await git.merge('origin/branch'); + } catch (e) { + // expect merge to have conflicts + } + isMerging = await git.isMerging(); + assert.isTrue(isMerging); + + fs.unlinkSync(path.join(workingDirPath, '.git', 'MERGE_HEAD')); + isMerging = await git.isMerging(); + assert.isFalse(isMerging); + }); + }); + + sdescribe('getAheadCount', 'getAheadCount(branchName) and getBehindCount(branchName)', function() { + it('returns the number of different commits on the branch vs the remote', async function() { + const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); + const git = createTestStrategy(localRepoPath); + await git.exec(['config', 'push.default', 'upstream']); + assert.equal(await git.getBehindCount('master'), 0); + assert.equal(await git.getAheadCount('master'), 0); + await git.fetch('master'); + assert.equal(await git.getBehindCount('master'), 1); + assert.equal(await git.getAheadCount('master'), 0); + await git.commit('new commit', {allowEmpty: true}); + await git.commit('another commit', {allowEmpty: true}); + assert.equal(await git.getBehindCount('master'), 1); + assert.equal(await git.getAheadCount('master'), 2); + }); + + it('returns null if there is no remote tracking branch specified', async function() { + const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); + const git = createTestStrategy(localRepoPath); + await git.exec(['config', 'push.default', 'upstream']); + await git.exec(['config', '--remove-section', 'branch.master']); + assert.equal(await git.getBehindCount('master'), null); + assert.equal(await git.getAheadCount('master'), null); + }); + + it('returns null if the configured remote tracking branch does not exist locally', async function() { + const {localRepoPath} = await setUpLocalAndRemoteRepositories(); + const git = createTestStrategy(localRepoPath); + await git.exec(['config', 'push.default', 'upstream']); + await git.exec(['branch', '-d', '-r', 'origin/master']); + assert.equal(await git.getBehindCount('master'), null); + assert.equal(await git.getAheadCount('master'), null); + + await git.exec(['fetch']); + assert.equal(await git.getBehindCount('master'), 0); + assert.equal(await git.getAheadCount('master'), 0); + }); + + it('handles a remote tracking branch with a name that differs from the local branch', async function() { + const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); + const git = createTestStrategy(localRepoPath); + await git.exec(['config', 'push.default', 'upstream']); + await git.exec(['checkout', '-b', 'new-local-branch']); + await git.exec(['remote', 'rename', 'origin', 'upstream']); + await git.exec(['branch', '--set-upstream-to=upstream/master']); + assert.equal(await git.getBehindCount('new-local-branch'), 0); + assert.equal(await git.getAheadCount('new-local-branch'), 0); + }); + }); + + sdescribe('getCurrentBranch', 'getCurrentBranch() and checkout(branchName, {createNew})', function() { + it('returns the current branch name', async function() { + const workingDirPath = await cloneRepository('merge-conflict'); + const git = createTestStrategy(workingDirPath); + assert.equal(await git.getCurrentBranch(), 'master'); + await git.checkout('branch'); + assert.equal(await git.getCurrentBranch(), 'branch'); + + // newBranch does not yet exist + await assert.isRejected(git.checkout('newBranch')); + assert.equal(await git.getCurrentBranch(), 'branch'); + await git.checkout('newBranch', {createNew: true}); + assert.equal(await git.getCurrentBranch(), 'newBranch'); + }); + }); + + sdescribe('getRemoteForBranch', 'getRemoteForBranch(branchName)', function() { + it('returns the name of the remote associated with the branch, and null if none exists', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + assert.equal(await git.getRemoteForBranch('master'), 'origin'); + await git.exec(['remote', 'rename', 'origin', 'foo']); + assert.equal(await git.getRemoteForBranch('master'), 'foo'); + await git.exec(['remote', 'rm', 'foo']); + assert.isNull(await git.getRemoteForBranch('master')); + }); + }); + + sdescribe('getBranches', 'getBranches()', function() { + it('returns an array of all branches', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + assert.deepEqual(await git.getBranches(), ['master']); + await git.checkout('new-branch', {createNew: true}); + assert.deepEqual(await git.getBranches(), ['master', 'new-branch']); + await git.checkout('another-branch', {createNew: true}); + assert.deepEqual(await git.getBranches(), ['another-branch', 'master', 'new-branch']); + }); + }); + + sdescribe('getRemotes', 'getRemotes()', function() { + it('returns an array of remotes', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + await git.exec(['remote', 'set-url', 'origin', 'git@github.com:other/origin.git']); + await git.exec(['remote', 'add', 'upstream', 'git@github.com:my/upstream.git']); + await git.exec(['remote', 'add', 'another.remote', 'git@github.com:another/upstream.git']); + const remotes = await git.getRemotes(); + // Note: nodegit returns remote names in alphabetical order + assert.deepEqual(remotes, [ + {name: 'another.remote', url: 'git@github.com:another/upstream.git'}, + {name: 'origin', url: 'git@github.com:other/origin.git'}, + {name: 'upstream', url: 'git@github.com:my/upstream.git'}, + ]); + }); + + it('returns an empty array when no remotes are set up', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + await git.exec(['remote', 'rm', 'origin']); + const remotes = await git.getRemotes(); + assert.deepEqual(remotes, []); + }); + }); + + sdescribe('getConfig', 'getConfig() and setConfig()', function() { + it('gets and sets configs', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + assert.isNull(await git.getConfig('awesome.devs')); + await git.setConfig('awesome.devs', 'BinaryMuse,kuychaco,smashwilson'); + assert.equal('BinaryMuse,kuychaco,smashwilson', await git.getConfig('awesome.devs')); + }); + }); + + sdescribe('commit', 'commit(message, options) where amend option is true', function() { + it('amends the last commit', async function() { + const workingDirPath = await cloneRepository('multiple-commits'); + const git = createTestStrategy(workingDirPath); + const lastCommit = await git.getHeadCommit(); + const lastCommitParent = await git.getCommit('HEAD~'); + await git.commit('amend last commit', {amend: true, allowEmpty: true}); + const amendedCommit = await git.getHeadCommit(); + const amendedCommitParent = await git.getCommit('HEAD~'); + assert.notDeepEqual(lastCommit, amendedCommit); + assert.deepEqual(lastCommitParent, amendedCommitParent); + }); + }); + + sdescribe('gpgExec', 'GPG signing', () => { + console.log('yes', Strategy); + let git; + + // eslint-disable-next-line jasmine/no-global-setup + beforeEach(async () => { + const workingDirPath = await cloneRepository('multiple-commits'); + git = createTestStrategy(workingDirPath); + }); + + const operations = [ + { + verbalNoun: 'commit', + progressiveTense: 'committing', + action: () => git.commit('message'), + }, + { + verbalNoun: 'merge', + progressiveTense: 'merging', + action: () => git.merge('some-branch'), + }, + { + verbalNoun: 'pull', + progressiveTense: 'pulling', + action: () => git.pull('some-branch'), + configureStub: stub => { + // Stub the `git config` call to resolve the branch's upstream. + stub.onCall(0).returns(Promise.resolve('origin')); + return 1; + }, + }, + ]; + + operations.forEach(op => { + it(`temporarily overrides gpg.program when ${op.progressiveTense}`, async () => { + // if (op !== 'pulling') { return; } + console.log('========================'); + const execStub = sinon.stub(git, 'exec'); + let callIndex = 0; + if (op.configureStub) { + callIndex = op.configureStub(execStub); + } + execStub.returns(Promise.resolve()); + + await op.action(); + + console.log(execStub, callIndex, execStub.getCall(callIndex)); + const callArgs = execStub.getCall(callIndex).args; + const execArgs = callArgs[0]; + assert.equal(execArgs[0], '-c'); + assert.match(execArgs[1], /^gpg\.program=.*gpg-no-tty\.sh$/); + assert.isNotOk(callArgs[1].stdin); + assert.isNotOk(callArgs[1].useGitPromptServer); + }); + + it(`retries a ${op.verbalNoun} with a GitPromptServer when GPG signing fails`, async () => { + const gpgErr = new GitError('Mock GPG failure'); + gpgErr.stdErr = 'stderr includes "gpg failed"'; + gpgErr.code = 128; + + let callIndex = 0; + const execStub = sinon.stub(git, 'exec'); + if (op.configureStub) { + callIndex = op.configureStub(execStub); + } + execStub.onCall(callIndex).returns(Promise.reject(gpgErr)); + execStub.returns(Promise.resolve()); + + try { + await op.action(); + } catch (err) { + assert.fail('expected op.action() not to throw the mock error'); + } + + const callArgs0 = execStub.getCall(callIndex).args; + const execArgs0 = callArgs0[0]; + assert.equal(execArgs0[0], '-c'); + assert.match(execArgs0[1], /^gpg\.program=.*gpg-no-tty\.sh$/); + assert.isNotOk(callArgs0[1].stdin); + assert.isNotOk(callArgs0[1].useGitPromptServer); + + const callArgs1 = execStub.getCall(callIndex + 1).args; + const execArgs1 = callArgs1[0]; + assert.equal(execArgs1[0], '-c'); + assert.match(execArgs1[1], /^gpg\.program=.*gpg-no-tty\.sh$/); + assert.isNotOk(callArgs1[1].stdin); + assert.isTrue(callArgs1[1].useGitPromptServer); + }); + }); + }); + + sdescribe('createBlob', 'createBlob({filePath})', () => { + it('creates a blob for the file path specified and returns its sha', async () => { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); + const sha = await git.createBlob({filePath: 'a.txt'}); + assert.equal(sha, 'c9f54222977c93ea17ba4a5a53c611fa7f1aaf56'); + const contents = await git.exec(['cat-file', '-p', sha]); + assert.equal(contents, 'qux\nfoo\nbar\n'); + }); + + it('creates a blob for the stdin specified and returns its sha', async () => { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + const sha = await git.createBlob({stdin: 'foo\n'}); + assert.equal(sha, '257cc5642cb1a054f08cc83f2d943e56fd3ebe99'); + const contents = await git.exec(['cat-file', '-p', sha]); + assert.equal(contents, 'foo\n'); + }); + }); + + sdescribe('expandBlobToFile', 'expandBlobToFile(absFilePath, sha)', () => { + it('restores blob contents for sha to specified file path', async () => { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + const absFilePath = path.join(workingDirPath, 'a.txt'); + fs.writeFileSync(absFilePath, 'qux\nfoo\nbar\n', 'utf8'); + const sha = await git.createBlob({filePath: 'a.txt'}); + fs.writeFileSync(absFilePath, 'modifications', 'utf8'); + await git.expandBlobToFile(absFilePath, sha); + assert.equal(fs.readFileSync(absFilePath), 'qux\nfoo\nbar\n'); + }); + }); + + sdescribe('getBlobContents', 'getBlobContents(sha)', () => { + it('returns blob contents for sha', async () => { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + const sha = await git.createBlob({stdin: 'foo\nbar\nbaz\n'}); + const contents = await git.getBlobContents(sha); + assert.equal(contents, 'foo\nbar\nbaz\n'); + }); + }); + }); + }); +}); From 57887e391ae2a656b3fb505bbbbd4fa0d35bf77f Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 11:32:22 -0800 Subject: [PATCH 14/34] sdescribe(cmd, desc) -> forCommand(cmd).describe(desc) --- test/git-strategies.test.js | 142 ++++++++++++++++++------------------ 1 file changed, 73 insertions(+), 69 deletions(-) diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js index 6b9dc5465a..5430412a0c 100644 --- a/test/git-strategies.test.js +++ b/test/git-strategies.test.js @@ -21,71 +21,75 @@ function forStrategy(Strategy, callback) { return new Strategy(...args); }; - function sdescribe(propertyUnderTest, description, suite, only = false) { - function createTestStrategyForPropertyUnderTest(...args) { - const compositeStrategy = CompositeGitStrategy.create(...args); - const originalStrategy = new Strategy(...args); - return new Proxy(compositeStrategy, { - get(target, name) { - if (name === propertyUnderTest) { - return originalStrategy[name]; - } else { - return compositeStrategy[name]; - } - }, + function forCommand(propertyUnderTest) { + function specificDescribe(description, suite, only = false) { + function createTestStrategyForPropertyUnderTest(...args) { + const compositeStrategy = CompositeGitStrategy.create(...args); + const originalStrategy = new Strategy(...args); + return new Proxy(compositeStrategy, { + get(target, name) { + if (name === propertyUnderTest) { + return originalStrategy[name]; + } else { + return compositeStrategy[name]; + } + }, - set(target, name, value) { - if (name === propertyUnderTest) { - originalStrategy[name] = value; - } else { - compositeStrategy[name] = value; - } - }, + set(target, name, value) { + if (name === propertyUnderTest) { + originalStrategy[name] = value; + } else { + compositeStrategy[name] = value; + } + }, - getOwnPropertyDescriptor(target, name) { - if (name === propertyUnderTest) { - return Reflect.getOwnPropertyDescriptor(originalStrategy, name); - } else { - return Reflect.getOwnPropertyDescriptor(compositeStrategy, name); - } - }, - }); - } + getOwnPropertyDescriptor(target, name) { + if (name === propertyUnderTest) { + return Reflect.getOwnPropertyDescriptor(originalStrategy, name); + } else { + return Reflect.getOwnPropertyDescriptor(compositeStrategy, name); + } + }, + }); + } - const describeMethod = only ? describe.only : describe; + const describeMethod = only ? describe.only : describe; - if (Strategy.prototype[propertyUnderTest]) { - return describeMethod(description, function() { - let oldCreateTestStrategy; - // eslint-disable-next-line jasmine/no-global-setup - beforeEach(function() { - oldCreateTestStrategy = createTestStrategy; - createTestStrategy = createTestStrategyForPropertyUnderTest; + if (Strategy.prototype[propertyUnderTest]) { + return describeMethod(description, function() { + let oldCreateTestStrategy; + // eslint-disable-next-line jasmine/no-global-setup + beforeEach(function() { + oldCreateTestStrategy = createTestStrategy; + createTestStrategy = createTestStrategyForPropertyUnderTest; + }); + // eslint-disable-next-line jasmine/no-global-setup + afterEach(function() { + createTestStrategy = oldCreateTestStrategy; + }); + suite(); }); - // eslint-disable-next-line jasmine/no-global-setup - afterEach(function() { - createTestStrategy = oldCreateTestStrategy; + } else { + return describeMethod(description, function() { + it('is not implemented', function() {}); }); - suite(); - }); - } else { - return describeMethod(description, function() { - it('is not implemented'); - }); + } } - } - sdescribe.only = function(propertyUnderTest, description, suite) { - return sdescribe(propertyUnderTest, description, suite, true); - }; + specificDescribe.only = function(description, suite) { + return specificDescribe(description, suite, true); + }; + + return {describe: specificDescribe}; + } - callback(sdescribe); + return callback(forCommand); } [GitShellOutStrategy, NodeGitStrategy].forEach(function(Strategy) { - forStrategy(Strategy, function(sdescribe) { + forStrategy(Strategy, function(forCommand) { describe.only(`Git commands for ${Strategy.name}`, function() { - sdescribe('isGitRepository', 'isGitRepository(directoryPath)', function() { + forCommand('isGitRepository').describe('isGitRepository(directoryPath)', function() { it('returns true if the path passed is a valid repository, and false if not', async function() { const workingDirPath = await cloneRepository('three-files'); const git = createTestStrategy(workingDirPath); @@ -96,7 +100,7 @@ function forStrategy(Strategy, callback) { }); }); - sdescribe('getStatusesForChangedFiles', 'getStatusesForChangedFiles', function() { + forCommand('getStatusesForChangedFiles').describe('getStatusesForChangedFiles', function() { it('returns objects for staged and unstaged files, including status information', async function() { const workingDirPath = await cloneRepository('three-files'); const git = createTestStrategy(workingDirPath); @@ -199,7 +203,7 @@ function forStrategy(Strategy, callback) { }); }); - sdescribe('diffFileStatus', 'diffFileStatus', function() { + forCommand('diffFileStatus').describe('diffFileStatus', function() { it('returns an object with working directory file diff status between relative to specified target commit', async function() { const workingDirPath = await cloneRepository('three-files'); const git = createTestStrategy(workingDirPath); @@ -235,7 +239,7 @@ function forStrategy(Strategy, callback) { }); }); - sdescribe('getUntrackedFiles', 'getUntrackedFiles', function() { + forCommand('getUntrackedFiles').describe('getUntrackedFiles', function() { it('returns an array of untracked file paths', async function() { const workingDirPath = await cloneRepository('three-files'); const git = createTestStrategy(workingDirPath); @@ -267,7 +271,7 @@ function forStrategy(Strategy, callback) { }); }); - sdescribe('getDiffForFilePath', 'getDiffForFilePath', function() { + forCommand('getDiffForFilePath').describe('getDiffForFilePath', function() { it('returns an empty array if there are no modified, added, or deleted files', async function() { const workingDirPath = await cloneRepository('three-files'); const git = createTestStrategy(workingDirPath); @@ -444,7 +448,7 @@ function forStrategy(Strategy, callback) { }); }); - sdescribe('isPartiallyStaged', 'isPartiallyStaged(filePath)', () => { + forCommand('isPartiallyStaged').describe('isPartiallyStaged(filePath)', () => { it('returns true if specified file path is partially staged', async () => { const workingDirPath = await cloneRepository('three-files'); const git = createTestStrategy(workingDirPath); @@ -480,7 +484,7 @@ function forStrategy(Strategy, callback) { }); }); - sdescribe('isMerging', 'isMerging', function() { + forCommand('isMerging').describe('isMerging', function() { it('returns true if `.git/MERGE_HEAD` exists', async function() { const workingDirPath = await cloneRepository('merge-conflict'); const git = createTestStrategy(workingDirPath); @@ -501,7 +505,7 @@ function forStrategy(Strategy, callback) { }); }); - sdescribe('getAheadCount', 'getAheadCount(branchName) and getBehindCount(branchName)', function() { + forCommand('getAheadCount').describe('getAheadCount(branchName) and getBehindCount(branchName)', function() { it('returns the number of different commits on the branch vs the remote', async function() { const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); const git = createTestStrategy(localRepoPath); @@ -551,7 +555,7 @@ function forStrategy(Strategy, callback) { }); }); - sdescribe('getCurrentBranch', 'getCurrentBranch() and checkout(branchName, {createNew})', function() { + forCommand('getCurrentBranch').describe('getCurrentBranch() and checkout(branchName, {createNew})', function() { it('returns the current branch name', async function() { const workingDirPath = await cloneRepository('merge-conflict'); const git = createTestStrategy(workingDirPath); @@ -567,7 +571,7 @@ function forStrategy(Strategy, callback) { }); }); - sdescribe('getRemoteForBranch', 'getRemoteForBranch(branchName)', function() { + forCommand('getRemoteForBranch').describe('getRemoteForBranch(branchName)', function() { it('returns the name of the remote associated with the branch, and null if none exists', async function() { const workingDirPath = await cloneRepository('three-files'); const git = createTestStrategy(workingDirPath); @@ -579,7 +583,7 @@ function forStrategy(Strategy, callback) { }); }); - sdescribe('getBranches', 'getBranches()', function() { + forCommand('getBranches').describe('getBranches()', function() { it('returns an array of all branches', async function() { const workingDirPath = await cloneRepository('three-files'); const git = createTestStrategy(workingDirPath); @@ -591,7 +595,7 @@ function forStrategy(Strategy, callback) { }); }); - sdescribe('getRemotes', 'getRemotes()', function() { + forCommand('getRemotes').describe('getRemotes()', function() { it('returns an array of remotes', async function() { const workingDirPath = await cloneRepository('three-files'); const git = createTestStrategy(workingDirPath); @@ -616,7 +620,7 @@ function forStrategy(Strategy, callback) { }); }); - sdescribe('getConfig', 'getConfig() and setConfig()', function() { + forCommand('getConfig').describe('getConfig() and setConfig()', function() { it('gets and sets configs', async function() { const workingDirPath = await cloneRepository('three-files'); const git = createTestStrategy(workingDirPath); @@ -626,7 +630,7 @@ function forStrategy(Strategy, callback) { }); }); - sdescribe('commit', 'commit(message, options) where amend option is true', function() { + forCommand('commit').describe('commit(message, options) where amend option is true', function() { it('amends the last commit', async function() { const workingDirPath = await cloneRepository('multiple-commits'); const git = createTestStrategy(workingDirPath); @@ -640,7 +644,7 @@ function forStrategy(Strategy, callback) { }); }); - sdescribe('gpgExec', 'GPG signing', () => { + forCommand('gpgExec').describe('GPG signing', () => { console.log('yes', Strategy); let git; @@ -731,7 +735,7 @@ function forStrategy(Strategy, callback) { }); }); - sdescribe('createBlob', 'createBlob({filePath})', () => { + forCommand('createBlob').describe('createBlob({filePath})', () => { it('creates a blob for the file path specified and returns its sha', async () => { const workingDirPath = await cloneRepository('three-files'); const git = createTestStrategy(workingDirPath); @@ -752,7 +756,7 @@ function forStrategy(Strategy, callback) { }); }); - sdescribe('expandBlobToFile', 'expandBlobToFile(absFilePath, sha)', () => { + forCommand('expandBlobToFile').describe('expandBlobToFile(absFilePath, sha)', () => { it('restores blob contents for sha to specified file path', async () => { const workingDirPath = await cloneRepository('three-files'); const git = createTestStrategy(workingDirPath); @@ -765,7 +769,7 @@ function forStrategy(Strategy, callback) { }); }); - sdescribe('getBlobContents', 'getBlobContents(sha)', () => { + forCommand('getBlobContents').describe('getBlobContents(sha)', () => { it('returns blob contents for sha', async () => { const workingDirPath = await cloneRepository('three-files'); const git = createTestStrategy(workingDirPath); From d1b156c8ba32268be7896d75371eb77ee7b245ce Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 12:21:37 -0800 Subject: [PATCH 15/34] Return assignment from Proxy `set` --- lib/helpers.js | 6 ++++-- test/git-strategies.test.js | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/helpers.js b/lib/helpers.js index d74cb0e7fe..3aa8d0fafc 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -23,9 +23,11 @@ export function firstImplementer(...targets) { set(target, name, value) { const firstValidTarget = targets.find(t => Reflect.has(t, name)); if (firstValidTarget) { - firstValidTarget[name] = value; + // eslint-disable-next-line no-return-assign + return firstValidTarget[name] = value; } else { - target[name] = value; + // eslint-disable-next-line no-return-assign + return target[name] = value; } }, diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js index 5430412a0c..368826e233 100644 --- a/test/git-strategies.test.js +++ b/test/git-strategies.test.js @@ -37,9 +37,11 @@ function forStrategy(Strategy, callback) { set(target, name, value) { if (name === propertyUnderTest) { - originalStrategy[name] = value; + // eslint-disable-next-line no-return-assign + return originalStrategy[name] = value; } else { - compositeStrategy[name] = value; + // eslint-disable-next-line no-return-assign + return compositeStrategy[name] = value; } }, From c0d2b1d5a144b41ece9dcb92848e61427ef32f14 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 12:23:04 -0800 Subject: [PATCH 16/34] Make getRemotes test work independent of order --- test/git-strategies.test.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js index 368826e233..9c0b7250e8 100644 --- a/test/git-strategies.test.js +++ b/test/git-strategies.test.js @@ -606,11 +606,14 @@ function forStrategy(Strategy, callback) { await git.exec(['remote', 'add', 'another.remote', 'git@github.com:another/upstream.git']); const remotes = await git.getRemotes(); // Note: nodegit returns remote names in alphabetical order - assert.deepEqual(remotes, [ - {name: 'another.remote', url: 'git@github.com:another/upstream.git'}, - {name: 'origin', url: 'git@github.com:other/origin.git'}, - {name: 'upstream', url: 'git@github.com:my/upstream.git'}, - ]); + assert.equal(remotes.length, 3); + [ + {name: 'another.remote', url: 'git@github.com:another/upstream.git'}, + {name: 'origin', url: 'git@github.com:other/origin.git'}, + {name: 'upstream', url: 'git@github.com:my/upstream.git'}, + ].forEach(remote => { + assert.include(remotes, remote); + }); }); it('returns an empty array when no remotes are set up', async function() { From 0443f6acecd5e57bbc250c307e099c37a31aca64 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 12:23:27 -0800 Subject: [PATCH 17/34] Redirect ALL methods that exist on the strategy under test to that strategy --- test/git-strategies.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js index 9c0b7250e8..8cb5ca2dc2 100644 --- a/test/git-strategies.test.js +++ b/test/git-strategies.test.js @@ -28,7 +28,7 @@ function forStrategy(Strategy, callback) { const originalStrategy = new Strategy(...args); return new Proxy(compositeStrategy, { get(target, name) { - if (name === propertyUnderTest) { + if (name in originalStrategy) { return originalStrategy[name]; } else { return compositeStrategy[name]; @@ -36,7 +36,7 @@ function forStrategy(Strategy, callback) { }, set(target, name, value) { - if (name === propertyUnderTest) { + if (name in originalStrategy) { // eslint-disable-next-line no-return-assign return originalStrategy[name] = value; } else { @@ -46,7 +46,7 @@ function forStrategy(Strategy, callback) { }, getOwnPropertyDescriptor(target, name) { - if (name === propertyUnderTest) { + if (name in originalStrategy) { return Reflect.getOwnPropertyDescriptor(originalStrategy, name); } else { return Reflect.getOwnPropertyDescriptor(compositeStrategy, name); From cac17410b9ab29eb59698f80236045b3a0b07df6 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 12:23:37 -0800 Subject: [PATCH 18/34] Show non-implemented strategy tests as pending --- test/git-strategies.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js index 8cb5ca2dc2..b4673cae32 100644 --- a/test/git-strategies.test.js +++ b/test/git-strategies.test.js @@ -73,7 +73,7 @@ function forStrategy(Strategy, callback) { }); } else { return describeMethod(description, function() { - it('is not implemented', function() {}); + it('is not implemented'); }); } } From 72b1d004eddc2f4195bf8561d9952781a453f9ed Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 12:23:43 -0800 Subject: [PATCH 19/34] :art: --- test/git-strategies.test.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js index b4673cae32..8a01e6c5d5 100644 --- a/test/git-strategies.test.js +++ b/test/git-strategies.test.js @@ -650,7 +650,6 @@ function forStrategy(Strategy, callback) { }); forCommand('gpgExec').describe('GPG signing', () => { - console.log('yes', Strategy); let git; // eslint-disable-next-line jasmine/no-global-setup @@ -675,7 +674,7 @@ function forStrategy(Strategy, callback) { progressiveTense: 'pulling', action: () => git.pull('some-branch'), configureStub: stub => { - // Stub the `git config` call to resolve the branch's upstream. + // Stub the `git config` call to resolve the branch's upstream. stub.onCall(0).returns(Promise.resolve('origin')); return 1; }, @@ -685,7 +684,6 @@ function forStrategy(Strategy, callback) { operations.forEach(op => { it(`temporarily overrides gpg.program when ${op.progressiveTense}`, async () => { // if (op !== 'pulling') { return; } - console.log('========================'); const execStub = sinon.stub(git, 'exec'); let callIndex = 0; if (op.configureStub) { @@ -695,7 +693,6 @@ function forStrategy(Strategy, callback) { await op.action(); - console.log(execStub, callIndex, execStub.getCall(callIndex)); const callArgs = execStub.getCall(callIndex).args; const execArgs = callArgs[0]; assert.equal(execArgs[0], '-c'); From 3d0ec932e6c018d805f63b8683354878f378de10 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 12:25:20 -0800 Subject: [PATCH 20/34] :fire: .only --- test/git-strategies.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js index 8a01e6c5d5..ebdbe0221f 100644 --- a/test/git-strategies.test.js +++ b/test/git-strategies.test.js @@ -90,7 +90,7 @@ function forStrategy(Strategy, callback) { [GitShellOutStrategy, NodeGitStrategy].forEach(function(Strategy) { forStrategy(Strategy, function(forCommand) { - describe.only(`Git commands for ${Strategy.name}`, function() { + describe(`Git commands for ${Strategy.name}`, function() { forCommand('isGitRepository').describe('isGitRepository(directoryPath)', function() { it('returns true if the path passed is a valid repository, and false if not', async function() { const workingDirPath = await cloneRepository('three-files'); From 2bc70619867bf01a81d491007128099736bb3c1f Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 12:27:08 -0800 Subject: [PATCH 21/34] :art: --- test/git-strategies.test.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js index ebdbe0221f..4d82d3df86 100644 --- a/test/git-strategies.test.js +++ b/test/git-strategies.test.js @@ -143,12 +143,12 @@ function forStrategy(Strategy, callback) { }); it('displays copied files as an added file', async function() { - // This repo is carefully constructed after much experimentation - // to cause git to detect `two.cc` as a copy of `one.cc`. + // This repo is carefully constructed after much experimentation + // to cause git to detect `two.cc` as a copy of `one.cc`. const workingDirPath = await cloneRepository('copied-file'); const git = createTestStrategy(workingDirPath); - // Undo the last commit, which is where we copied one.cc to two.cc - // and then emptied one.cc. + // Undo the last commit, which is where we copied one.cc to two.cc + // and then emptied one.cc. await git.exec(['reset', '--soft', 'head^']); const {stagedFiles, unstagedFiles, mergeConflictFiles} = await git.getStatusesForChangedFiles(); assert.deepEqual(stagedFiles, { @@ -165,7 +165,7 @@ function forStrategy(Strategy, callback) { try { await git.merge('origin/branch'); } catch (e) { - // expected + // expected if (!e.message.match(/CONFLICT/)) { throw new Error(`merge failed for wrong reason: ${e.message}`); } @@ -496,7 +496,7 @@ function forStrategy(Strategy, callback) { try { await git.merge('origin/branch'); } catch (e) { - // expect merge to have conflicts + // expect merge to have conflicts } isMerging = await git.isMerging(); assert.isTrue(isMerging); From 1495e5a621bb690b31edaac2321d5f4347a9c1ff Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 13:01:33 -0800 Subject: [PATCH 22/34] Ensure proper returns from firstImplementer --- lib/helpers.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/helpers.js b/lib/helpers.js index 3aa8d0fafc..8bfb2f94a8 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -6,7 +6,7 @@ import {ncp} from 'ncp'; * returns the value of that property on the first object in `targets` where the target implements that property. */ export function firstImplementer(...targets) { - return new Proxy({strategies: targets}, { + return new Proxy({__implementations: targets}, { get(target, name) { if (Reflect.has(target, name)) { return target[name]; @@ -15,6 +15,8 @@ export function firstImplementer(...targets) { const firstValidTarget = targets.find(t => Reflect.has(t, name)); if (firstValidTarget) { return firstValidTarget[name]; + } else if (Reflect.has(target, name)) { + return target[name]; } else { return undefined; } @@ -34,8 +36,11 @@ export function firstImplementer(...targets) { // Used by sinon getOwnPropertyDescriptor(target, name) { const firstValidTarget = targets.find(t => Reflect.getOwnPropertyDescriptor(t, name)); + const compositeOwnPropertyDescriptor = Reflect.getOwnPropertyDescriptor(target, name); if (firstValidTarget) { return Reflect.getOwnPropertyDescriptor(firstValidTarget, name); + } else if (compositeOwnPropertyDescriptor) { + return compositeOwnPropertyDescriptor; } else { return undefined; } From 75f1e814b3773f491363e4653287c92108458396 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 13:02:22 -0800 Subject: [PATCH 23/34] Always test every method on every strategy --- test/git-strategies.test.js | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js index 4d82d3df86..f9b9294446 100644 --- a/test/git-strategies.test.js +++ b/test/git-strategies.test.js @@ -57,25 +57,19 @@ function forStrategy(Strategy, callback) { const describeMethod = only ? describe.only : describe; - if (Strategy.prototype[propertyUnderTest]) { - return describeMethod(description, function() { - let oldCreateTestStrategy; - // eslint-disable-next-line jasmine/no-global-setup - beforeEach(function() { - oldCreateTestStrategy = createTestStrategy; - createTestStrategy = createTestStrategyForPropertyUnderTest; - }); - // eslint-disable-next-line jasmine/no-global-setup - afterEach(function() { - createTestStrategy = oldCreateTestStrategy; - }); - suite(); + return describeMethod(description, function() { + let oldCreateTestStrategy; + // eslint-disable-next-line jasmine/no-global-setup + beforeEach(function() { + oldCreateTestStrategy = createTestStrategy; + createTestStrategy = createTestStrategyForPropertyUnderTest; }); - } else { - return describeMethod(description, function() { - it('is not implemented'); + // eslint-disable-next-line jasmine/no-global-setup + afterEach(function() { + createTestStrategy = oldCreateTestStrategy; }); - } + suite(); + }); } specificDescribe.only = function(description, suite) { From 313300ccc495ed5676f33aec7e72a60416e6de1e Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 13:02:34 -0800 Subject: [PATCH 24/34] Only test gpgExec on strategies where it actually exists --- test/git-strategies.test.js | 163 +++++++++++++++++++----------------- 1 file changed, 84 insertions(+), 79 deletions(-) diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js index f9b9294446..0803e0c68c 100644 --- a/test/git-strategies.test.js +++ b/test/git-strategies.test.js @@ -643,93 +643,98 @@ function forStrategy(Strategy, callback) { }); }); - forCommand('gpgExec').describe('GPG signing', () => { - let git; + if (Strategy.prototype.gpgExec) { + // Only needs to be tested on strategies that actually implement gpgExec + forCommand('gpgExec').describe('GPG signing', () => { + let git; - // eslint-disable-next-line jasmine/no-global-setup - beforeEach(async () => { - const workingDirPath = await cloneRepository('multiple-commits'); - git = createTestStrategy(workingDirPath); - }); - - const operations = [ - { - verbalNoun: 'commit', - progressiveTense: 'committing', - action: () => git.commit('message'), - }, - { - verbalNoun: 'merge', - progressiveTense: 'merging', - action: () => git.merge('some-branch'), - }, - { - verbalNoun: 'pull', - progressiveTense: 'pulling', - action: () => git.pull('some-branch'), - configureStub: stub => { - // Stub the `git config` call to resolve the branch's upstream. - stub.onCall(0).returns(Promise.resolve('origin')); - return 1; - }, - }, - ]; - - operations.forEach(op => { - it(`temporarily overrides gpg.program when ${op.progressiveTense}`, async () => { - // if (op !== 'pulling') { return; } - const execStub = sinon.stub(git, 'exec'); - let callIndex = 0; - if (op.configureStub) { - callIndex = op.configureStub(execStub); - } - execStub.returns(Promise.resolve()); - - await op.action(); - - const callArgs = execStub.getCall(callIndex).args; - const execArgs = callArgs[0]; - assert.equal(execArgs[0], '-c'); - assert.match(execArgs[1], /^gpg\.program=.*gpg-no-tty\.sh$/); - assert.isNotOk(callArgs[1].stdin); - assert.isNotOk(callArgs[1].useGitPromptServer); + // eslint-disable-next-line jasmine/no-global-setup + beforeEach(async () => { + const workingDirPath = await cloneRepository('multiple-commits'); + git = createTestStrategy(workingDirPath); }); - it(`retries a ${op.verbalNoun} with a GitPromptServer when GPG signing fails`, async () => { - const gpgErr = new GitError('Mock GPG failure'); - gpgErr.stdErr = 'stderr includes "gpg failed"'; - gpgErr.code = 128; - - let callIndex = 0; - const execStub = sinon.stub(git, 'exec'); - if (op.configureStub) { - callIndex = op.configureStub(execStub); - } - execStub.onCall(callIndex).returns(Promise.reject(gpgErr)); - execStub.returns(Promise.resolve()); + const operations = [ + { + verbalNoun: 'commit', + progressiveTense: 'committing', + action: () => git.commit('message'), + }, + { + verbalNoun: 'merge', + progressiveTense: 'merging', + action: () => git.merge('some-branch'), + }, + { + verbalNoun: 'pull', + progressiveTense: 'pulling', + action: () => git.pull('some-branch'), + configureStub: stub => { + // Stub the `git config` call to resolve the branch's upstream. + stub.onCall(0).returns(Promise.resolve('origin')); + return 1; + }, + }, + ]; + + operations.forEach(op => { + it(`temporarily overrides gpg.program when ${op.progressiveTense}`, async () => { + // if (op !== 'pulling') { return; } + const execStub = sinon.stub(git, 'exec'); + let callIndex = 0; + if (op.configureStub) { + callIndex = op.configureStub(execStub); + } + execStub.returns(Promise.resolve()); - try { await op.action(); - } catch (err) { - assert.fail('expected op.action() not to throw the mock error'); - } - const callArgs0 = execStub.getCall(callIndex).args; - const execArgs0 = callArgs0[0]; - assert.equal(execArgs0[0], '-c'); - assert.match(execArgs0[1], /^gpg\.program=.*gpg-no-tty\.sh$/); - assert.isNotOk(callArgs0[1].stdin); - assert.isNotOk(callArgs0[1].useGitPromptServer); - - const callArgs1 = execStub.getCall(callIndex + 1).args; - const execArgs1 = callArgs1[0]; - assert.equal(execArgs1[0], '-c'); - assert.match(execArgs1[1], /^gpg\.program=.*gpg-no-tty\.sh$/); - assert.isNotOk(callArgs1[1].stdin); - assert.isTrue(callArgs1[1].useGitPromptServer); + console.dir(execStub); + console.log(execStub.getCalls(), callIndex); + const callArgs = execStub.getCall(callIndex).args; + const execArgs = callArgs[0]; + assert.equal(execArgs[0], '-c'); + assert.match(execArgs[1], /^gpg\.program=.*gpg-no-tty\.sh$/); + assert.isNotOk(callArgs[1].stdin); + assert.isNotOk(callArgs[1].useGitPromptServer); + }); + + it(`retries a ${op.verbalNoun} with a GitPromptServer when GPG signing fails`, async () => { + const gpgErr = new GitError('Mock GPG failure'); + gpgErr.stdErr = 'stderr includes "gpg failed"'; + gpgErr.code = 128; + + let callIndex = 0; + const execStub = sinon.stub(git, 'exec'); + if (op.configureStub) { + callIndex = op.configureStub(execStub); + } + execStub.onCall(callIndex).returns(Promise.reject(gpgErr)); + execStub.returns(Promise.resolve()); + + try { + await op.action(); + } catch (err) { + assert.fail('expected op.action() not to throw the mock error'); + } + + const callArgs0 = execStub.getCall(callIndex).args; + const execArgs0 = callArgs0[0]; + assert.equal(execArgs0[0], '-c'); + assert.match(execArgs0[1], /^gpg\.program=.*gpg-no-tty\.sh$/); + assert.isNotOk(callArgs0[1].stdin); + assert.isNotOk(callArgs0[1].useGitPromptServer); + + const callArgs1 = execStub.getCall(callIndex + 1).args; + const execArgs1 = callArgs1[0]; + assert.equal(execArgs1[0], '-c'); + assert.match(execArgs1[1], /^gpg\.program=.*gpg-no-tty\.sh$/); + assert.isNotOk(callArgs1[1].stdin); + assert.isTrue(callArgs1[1].useGitPromptServer); + }); }); }); - }); + } forCommand('createBlob').describe('createBlob({filePath})', () => { it('creates a blob for the file path specified and returns its sha', async () => { From 450b0ca4e1a34356713c721dd41fdd418ca90f7c Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 17:08:26 -0800 Subject: [PATCH 25/34] Implement getPrototypeOf in firstImplementer correctly --- lib/helpers.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/helpers.js b/lib/helpers.js index 8bfb2f94a8..921b90a5a7 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -1,6 +1,16 @@ import fs from 'fs-extra'; import {ncp} from 'ncp'; + +function descriptorsFromProto(proto) { + return Object.getOwnPropertyNames(proto).reduce((acc, name) => { + Object.assign(acc, { + [name]: Reflect.getOwnPropertyDescriptor(proto, name), + }); + return acc; + }, {}); +} + /** * Takes an array of targets and returns a proxy. The proxy intercepts property accessor calls and * returns the value of that property on the first object in `targets` where the target implements that property. @@ -15,8 +25,6 @@ export function firstImplementer(...targets) { const firstValidTarget = targets.find(t => Reflect.has(t, name)); if (firstValidTarget) { return firstValidTarget[name]; - } else if (Reflect.has(target, name)) { - return target[name]; } else { return undefined; } @@ -48,9 +56,10 @@ export function firstImplementer(...targets) { // Used by sinon getPrototypeOf(target) { - return targets.reduce((acc, t) => { - return Object.create(Reflect.getPrototypeOf(t), acc); - }, {}); + const v = targets.reduce((acc, t) => { + return Object.create(acc, descriptorsFromProto(Object.getPrototypeOf(t))); + }, Object.prototype); + return v; }, }); } From 2ab61058536cdfff8fe2f9d3947624cd73da184b Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 17:08:43 -0800 Subject: [PATCH 26/34] Change the way strategies get tested --- lib/composite-git-strategy.js | 20 +- test/git-strategies.test.js | 1297 ++++++++++++++++----------------- 2 files changed, 627 insertions(+), 690 deletions(-) diff --git a/lib/composite-git-strategy.js b/lib/composite-git-strategy.js index 1e49f69cf7..a3c2503ab9 100644 --- a/lib/composite-git-strategy.js +++ b/lib/composite-git-strategy.js @@ -7,13 +7,19 @@ import NodeGitStrategy from './nodegit-strategy'; export default { create(workingDir, options = {}) { - const parallelism = options.parallelism || Math.max(3, os.cpus().length); - const commandQueue = new AsyncQueue({parallelism}); - const strategyOptions = {...options, queue: commandQueue}; + return this.withStrategies([NodeGitStrategy, GitShellOutStrategy])(workingDir, options); + }, + + withStrategies(strategies) { + return function createForStrategies(workingDir, options = {}) { + const parallelism = options.parallelism || Math.max(3, os.cpus().length); + const commandQueue = new AsyncQueue({parallelism}); + const strategyOptions = {...options, queue: commandQueue}; - const gitShellOutStrategy = new GitShellOutStrategy(workingDir, strategyOptions); - const nodegitStrategy = new NodeGitStrategy(workingDir, strategyOptions); - const strategies = [nodegitStrategy, gitShellOutStrategy]; - return firstImplementer(...strategies); + // const gitShellOutStrategy = new GitShellOutStrategy(workingDir, strategyOptions); + // const nodegitStrategy = new NodeGitStrategy(workingDir, strategyOptions); + const strategyInstances = strategies.map(Strategy => new Strategy(workingDir, strategyOptions)); + return firstImplementer(...strategyInstances); + }; }, }; diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js index 0803e0c68c..a7e65d4d64 100644 --- a/test/git-strategies.test.js +++ b/test/git-strategies.test.js @@ -15,769 +15,700 @@ import {cloneRepository, assertDeepPropertyVals, setUpLocalAndRemoteRepositories * output that we rely on, to serve as documentation */ -let createTestStrategy = null; -function forStrategy(Strategy, callback) { - createTestStrategy = function(...args) { - return new Strategy(...args); +[ + [NodeGitStrategy, GitShellOutStrategy], + [GitShellOutStrategy, NodeGitStrategy], +].forEach(function(strategies) { + const createTestStrategy = (...args) => { + return CompositeGitStrategy.withStrategies(strategies)(...args); }; - function forCommand(propertyUnderTest) { - function specificDescribe(description, suite, only = false) { - function createTestStrategyForPropertyUnderTest(...args) { - const compositeStrategy = CompositeGitStrategy.create(...args); - const originalStrategy = new Strategy(...args); - return new Proxy(compositeStrategy, { - get(target, name) { - if (name in originalStrategy) { - return originalStrategy[name]; - } else { - return compositeStrategy[name]; - } - }, + describe(`Git commands for CompositeGitStrategy made of [${strategies.map(s => s.name).join(', ')}]`, function() { + describe('isGitRepository', function() { + it('returns true if the path passed is a valid repository, and false if not', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + assert.isTrue(await git.isGitRepository(workingDirPath)); - set(target, name, value) { - if (name in originalStrategy) { - // eslint-disable-next-line no-return-assign - return originalStrategy[name] = value; - } else { - // eslint-disable-next-line no-return-assign - return compositeStrategy[name] = value; - } - }, + fs.removeSync(path.join(workingDirPath, '.git')); + assert.isFalse(await git.isGitRepository(workingDirPath)); + }); + }); - getOwnPropertyDescriptor(target, name) { - if (name in originalStrategy) { - return Reflect.getOwnPropertyDescriptor(originalStrategy, name); - } else { - return Reflect.getOwnPropertyDescriptor(compositeStrategy, name); - } - }, - }); - } + describe('getStatusesForChangedFiles', function() { + it('returns objects for staged and unstaged files, including status information', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); + fs.unlinkSync(path.join(workingDirPath, 'b.txt')); + fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'd.txt')); + fs.writeFileSync(path.join(workingDirPath, 'e.txt'), 'qux', 'utf8'); + await git.exec(['add', 'a.txt', 'e.txt']); + fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'modify after staging', 'utf8'); + fs.writeFileSync(path.join(workingDirPath, 'e.txt'), 'modify after staging', 'utf8'); + const {stagedFiles, unstagedFiles, mergeConflictFiles} = await git.getStatusesForChangedFiles(); + assert.deepEqual(stagedFiles, { + 'a.txt': 'modified', + 'e.txt': 'added', + }); + assert.deepEqual(unstagedFiles, { + 'a.txt': 'modified', + 'b.txt': 'deleted', + 'c.txt': 'deleted', + 'd.txt': 'added', + 'e.txt': 'modified', + }); + assert.deepEqual(mergeConflictFiles, {}); + }); - const describeMethod = only ? describe.only : describe; + it('displays renamed files as one removed file and one added file', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'd.txt')); + await git.exec(['add', '.']); + const {stagedFiles, unstagedFiles, mergeConflictFiles} = await git.getStatusesForChangedFiles(); + assert.deepEqual(stagedFiles, { + 'c.txt': 'deleted', + 'd.txt': 'added', + }); + assert.deepEqual(unstagedFiles, {}); + assert.deepEqual(mergeConflictFiles, {}); + }); - return describeMethod(description, function() { - let oldCreateTestStrategy; - // eslint-disable-next-line jasmine/no-global-setup - beforeEach(function() { - oldCreateTestStrategy = createTestStrategy; - createTestStrategy = createTestStrategyForPropertyUnderTest; + it('displays copied files as an added file', async function() { + // This repo is carefully constructed after much experimentation + // to cause git to detect `two.cc` as a copy of `one.cc`. + const workingDirPath = await cloneRepository('copied-file'); + const git = createTestStrategy(workingDirPath); + // Undo the last commit, which is where we copied one.cc to two.cc + // and then emptied one.cc. + await git.exec(['reset', '--soft', 'head^']); + const {stagedFiles, unstagedFiles, mergeConflictFiles} = await git.getStatusesForChangedFiles(); + assert.deepEqual(stagedFiles, { + 'one.cc': 'modified', + 'two.cc': 'added', + }); + assert.deepEqual(unstagedFiles, {}); + assert.deepEqual(mergeConflictFiles, {}); + }); + + it('returns an object for merge conflict files, including ours/theirs/file status information', async function() { + const workingDirPath = await cloneRepository('merge-conflict'); + const git = createTestStrategy(workingDirPath); + try { + await git.merge('origin/branch'); + } catch (e) { + // expected + if (!e.message.match(/CONFLICT/)) { + throw new Error(`merge failed for wrong reason: ${e.message}`); + } + } + + const {stagedFiles, unstagedFiles, mergeConflictFiles} = await git.getStatusesForChangedFiles(); + assert.deepEqual(stagedFiles, {}); + assert.deepEqual(unstagedFiles, {}); + + assert.deepEqual(mergeConflictFiles, { + 'added-to-both.txt': { + ours: 'added', + theirs: 'added', + file: 'modified', + }, + 'modified-on-both-ours.txt': { + ours: 'modified', + theirs: 'modified', + file: 'modified', + }, + 'modified-on-both-theirs.txt': { + ours: 'modified', + theirs: 'modified', + file: 'modified', + }, + 'removed-on-branch.txt': { + ours: 'modified', + theirs: 'deleted', + file: 'equivalent', + }, + 'removed-on-master.txt': { + ours: 'deleted', + theirs: 'modified', + file: 'added', + }, }); - // eslint-disable-next-line jasmine/no-global-setup - afterEach(function() { - createTestStrategy = oldCreateTestStrategy; + }); + }); + + describe('diffFileStatus', function() { + it('returns an object with working directory file diff status between relative to specified target commit', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); + fs.unlinkSync(path.join(workingDirPath, 'b.txt')); + fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'd.txt')); + fs.writeFileSync(path.join(workingDirPath, 'e.txt'), 'qux', 'utf8'); + const diffOutput = await git.diffFileStatus({target: 'HEAD'}); + assert.deepEqual(diffOutput, { + 'a.txt': 'modified', + 'b.txt': 'deleted', + 'c.txt': 'deleted', + 'd.txt': 'added', + 'e.txt': 'added', }); - suite(); }); - } - specificDescribe.only = function(description, suite) { - return specificDescribe(description, suite, true); - }; + it('returns an empty object if there are no added, modified, or removed files', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + const diffOutput = await git.diffFileStatus({target: 'HEAD'}); + assert.deepEqual(diffOutput, {}); + }); - return {describe: specificDescribe}; - } + it('only returns untracked files if the staged option is not passed', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + fs.writeFileSync(path.join(workingDirPath, 'new-file.txt'), 'qux', 'utf8'); + let diffOutput = await git.diffFileStatus({target: 'HEAD'}); + assert.deepEqual(diffOutput, {'new-file.txt': 'added'}); + diffOutput = await git.diffFileStatus({target: 'HEAD', staged: true}); + assert.deepEqual(diffOutput, {}); + }); + }); - return callback(forCommand); -} + describe('getUntrackedFiles', function() { + it('returns an array of untracked file paths', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + fs.writeFileSync(path.join(workingDirPath, 'd.txt'), 'foo', 'utf8'); + fs.writeFileSync(path.join(workingDirPath, 'e.txt'), 'bar', 'utf8'); + fs.writeFileSync(path.join(workingDirPath, 'f.txt'), 'qux', 'utf8'); + assert.deepEqual(await git.getUntrackedFiles(), ['d.txt', 'e.txt', 'f.txt']); + }); -[GitShellOutStrategy, NodeGitStrategy].forEach(function(Strategy) { - forStrategy(Strategy, function(forCommand) { - describe(`Git commands for ${Strategy.name}`, function() { - forCommand('isGitRepository').describe('isGitRepository(directoryPath)', function() { - it('returns true if the path passed is a valid repository, and false if not', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - assert.isTrue(await git.isGitRepository(workingDirPath)); + it('handles untracked files in nested folders', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + fs.writeFileSync(path.join(workingDirPath, 'd.txt'), 'foo', 'utf8'); + const folderPath = path.join(workingDirPath, 'folder', 'subfolder'); + mkdirp.sync(folderPath); + fs.writeFileSync(path.join(folderPath, 'e.txt'), 'bar', 'utf8'); + fs.writeFileSync(path.join(folderPath, 'f.txt'), 'qux', 'utf8'); + assert.deepEqual(await git.getUntrackedFiles(), [ + 'd.txt', + 'folder/subfolder/e.txt', + 'folder/subfolder/f.txt', + ]); + }); - fs.removeSync(path.join(workingDirPath, '.git')); - assert.isFalse(await git.isGitRepository(workingDirPath)); - }); + it('returns an empty array if there are no untracked files', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + assert.deepEqual(await git.getUntrackedFiles(), []); }); + }); - forCommand('getStatusesForChangedFiles').describe('getStatusesForChangedFiles', function() { - it('returns objects for staged and unstaged files, including status information', async function() { + describe('getDiffForFilePath', function() { + it('returns an empty array if there are no modified, added, or deleted files', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + + const diffOutput = await git.getDiffForFilePath('a.txt'); + assert.isUndefined(diffOutput); + }); + + it('ignores merge conflict files', async function() { + const workingDirPath = await cloneRepository('merge-conflict'); + const git = createTestStrategy(workingDirPath); + const diffOutput = await git.getDiffForFilePath('added-to-both.txt'); + assert.isUndefined(diffOutput); + }); + + describe('when the file is unstaged', function() { + it('returns a diff comparing the working directory copy of the file and the version on the index', async function() { const workingDirPath = await cloneRepository('three-files'); const git = createTestStrategy(workingDirPath); fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); - fs.unlinkSync(path.join(workingDirPath, 'b.txt')); fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'd.txt')); - fs.writeFileSync(path.join(workingDirPath, 'e.txt'), 'qux', 'utf8'); - await git.exec(['add', 'a.txt', 'e.txt']); - fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'modify after staging', 'utf8'); - fs.writeFileSync(path.join(workingDirPath, 'e.txt'), 'modify after staging', 'utf8'); - const {stagedFiles, unstagedFiles, mergeConflictFiles} = await git.getStatusesForChangedFiles(); - assert.deepEqual(stagedFiles, { - 'a.txt': 'modified', - 'e.txt': 'added', - }); - assert.deepEqual(unstagedFiles, { - 'a.txt': 'modified', - 'b.txt': 'deleted', - 'c.txt': 'deleted', - 'd.txt': 'added', - 'e.txt': 'modified', - }); - assert.deepEqual(mergeConflictFiles, {}); - }); - it('displays renamed files as one removed file and one added file', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'd.txt')); - await git.exec(['add', '.']); - const {stagedFiles, unstagedFiles, mergeConflictFiles} = await git.getStatusesForChangedFiles(); - assert.deepEqual(stagedFiles, { - 'c.txt': 'deleted', - 'd.txt': 'added', + assertDeepPropertyVals(await git.getDiffForFilePath('a.txt'), { + oldPath: 'a.txt', + newPath: 'a.txt', + oldMode: '100644', + newMode: '100644', + hunks: [ + { + oldStartLine: 1, + oldLineCount: 1, + newStartLine: 1, + newLineCount: 3, + heading: '', + lines: [ + '+qux', + ' foo', + '+bar', + ], + }, + ], + status: 'modified', }); - assert.deepEqual(unstagedFiles, {}); - assert.deepEqual(mergeConflictFiles, {}); - }); - it('displays copied files as an added file', async function() { - // This repo is carefully constructed after much experimentation - // to cause git to detect `two.cc` as a copy of `one.cc`. - const workingDirPath = await cloneRepository('copied-file'); - const git = createTestStrategy(workingDirPath); - // Undo the last commit, which is where we copied one.cc to two.cc - // and then emptied one.cc. - await git.exec(['reset', '--soft', 'head^']); - const {stagedFiles, unstagedFiles, mergeConflictFiles} = await git.getStatusesForChangedFiles(); - assert.deepEqual(stagedFiles, { - 'one.cc': 'modified', - 'two.cc': 'added', + assertDeepPropertyVals(await git.getDiffForFilePath('c.txt'), { + oldPath: 'c.txt', + newPath: null, + oldMode: '100644', + newMode: null, + hunks: [ + { + oldStartLine: 1, + oldLineCount: 1, + newStartLine: 0, + newLineCount: 0, + heading: '', + lines: ['-baz'], + }, + ], + status: 'deleted', }); - assert.deepEqual(unstagedFiles, {}); - assert.deepEqual(mergeConflictFiles, {}); - }); - it('returns an object for merge conflict files, including ours/theirs/file status information', async function() { - const workingDirPath = await cloneRepository('merge-conflict'); - const git = createTestStrategy(workingDirPath); - try { - await git.merge('origin/branch'); - } catch (e) { - // expected - if (!e.message.match(/CONFLICT/)) { - throw new Error(`merge failed for wrong reason: ${e.message}`); - } - } - - const {stagedFiles, unstagedFiles, mergeConflictFiles} = await git.getStatusesForChangedFiles(); - assert.deepEqual(stagedFiles, {}); - assert.deepEqual(unstagedFiles, {}); - - assert.deepEqual(mergeConflictFiles, { - 'added-to-both.txt': { - ours: 'added', - theirs: 'added', - file: 'modified', - }, - 'modified-on-both-ours.txt': { - ours: 'modified', - theirs: 'modified', - file: 'modified', - }, - 'modified-on-both-theirs.txt': { - ours: 'modified', - theirs: 'modified', - file: 'modified', - }, - 'removed-on-branch.txt': { - ours: 'modified', - theirs: 'deleted', - file: 'equivalent', - }, - 'removed-on-master.txt': { - ours: 'deleted', - theirs: 'modified', - file: 'added', - }, + assertDeepPropertyVals(await git.getDiffForFilePath('d.txt'), { + oldPath: null, + newPath: 'd.txt', + oldMode: null, + newMode: '100644', + hunks: [ + { + oldStartLine: 0, + oldLineCount: 0, + newStartLine: 1, + newLineCount: 1, + heading: '', + lines: ['+baz'], + }, + ], + status: 'added', }); }); }); - forCommand('diffFileStatus').describe('diffFileStatus', function() { - it('returns an object with working directory file diff status between relative to specified target commit', async function() { + describe('when the file is staged', function() { + it('returns a diff comparing the index and head versions of the file', async function() { const workingDirPath = await cloneRepository('three-files'); const git = createTestStrategy(workingDirPath); fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); - fs.unlinkSync(path.join(workingDirPath, 'b.txt')); fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'd.txt')); - fs.writeFileSync(path.join(workingDirPath, 'e.txt'), 'qux', 'utf8'); - const diffOutput = await git.diffFileStatus({target: 'HEAD'}); - assert.deepEqual(diffOutput, { - 'a.txt': 'modified', - 'b.txt': 'deleted', - 'c.txt': 'deleted', - 'd.txt': 'added', - 'e.txt': 'added', - }); - }); - - it('returns an empty object if there are no added, modified, or removed files', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - const diffOutput = await git.diffFileStatus({target: 'HEAD'}); - assert.deepEqual(diffOutput, {}); - }); - - it('only returns untracked files if the staged option is not passed', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - fs.writeFileSync(path.join(workingDirPath, 'new-file.txt'), 'qux', 'utf8'); - let diffOutput = await git.diffFileStatus({target: 'HEAD'}); - assert.deepEqual(diffOutput, {'new-file.txt': 'added'}); - diffOutput = await git.diffFileStatus({target: 'HEAD', staged: true}); - assert.deepEqual(diffOutput, {}); - }); - }); + await git.exec(['add', '.']); - forCommand('getUntrackedFiles').describe('getUntrackedFiles', function() { - it('returns an array of untracked file paths', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - fs.writeFileSync(path.join(workingDirPath, 'd.txt'), 'foo', 'utf8'); - fs.writeFileSync(path.join(workingDirPath, 'e.txt'), 'bar', 'utf8'); - fs.writeFileSync(path.join(workingDirPath, 'f.txt'), 'qux', 'utf8'); - assert.deepEqual(await git.getUntrackedFiles(), ['d.txt', 'e.txt', 'f.txt']); - }); + assertDeepPropertyVals(await git.getDiffForFilePath('a.txt', {staged: true}), { + oldPath: 'a.txt', + newPath: 'a.txt', + oldMode: '100644', + newMode: '100644', + hunks: [ + { + oldStartLine: 1, + oldLineCount: 1, + newStartLine: 1, + newLineCount: 3, + heading: '', + lines: [ + '+qux', + ' foo', + '+bar', + ], + }, + ], + status: 'modified', + }); - it('handles untracked files in nested folders', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - fs.writeFileSync(path.join(workingDirPath, 'd.txt'), 'foo', 'utf8'); - const folderPath = path.join(workingDirPath, 'folder', 'subfolder'); - mkdirp.sync(folderPath); - fs.writeFileSync(path.join(folderPath, 'e.txt'), 'bar', 'utf8'); - fs.writeFileSync(path.join(folderPath, 'f.txt'), 'qux', 'utf8'); - assert.deepEqual(await git.getUntrackedFiles(), [ - 'd.txt', - 'folder/subfolder/e.txt', - 'folder/subfolder/f.txt', - ]); - }); + assertDeepPropertyVals(await git.getDiffForFilePath('c.txt', {staged: true}), { + oldPath: 'c.txt', + newPath: null, + oldMode: '100644', + newMode: null, + hunks: [ + { + oldStartLine: 1, + oldLineCount: 1, + newStartLine: 0, + newLineCount: 0, + heading: '', + lines: ['-baz'], + }, + ], + status: 'deleted', + }); - it('returns an empty array if there are no untracked files', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - assert.deepEqual(await git.getUntrackedFiles(), []); + assertDeepPropertyVals(await git.getDiffForFilePath('d.txt', {staged: true}), { + oldPath: null, + newPath: 'd.txt', + oldMode: null, + newMode: '100644', + hunks: [ + { + oldStartLine: 0, + oldLineCount: 0, + newStartLine: 1, + newLineCount: 1, + heading: '', + lines: ['+baz'], + }, + ], + status: 'added', + }); }); }); - forCommand('getDiffForFilePath').describe('getDiffForFilePath', function() { - it('returns an empty array if there are no modified, added, or deleted files', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - - const diffOutput = await git.getDiffForFilePath('a.txt'); - assert.isUndefined(diffOutput); - }); - - it('ignores merge conflict files', async function() { - const workingDirPath = await cloneRepository('merge-conflict'); + describe('when the file is staged and a base commit is specified', function() { + it('returns a diff comparing the file on the index and in the specified commit', async function() { + const workingDirPath = await cloneRepository('multiple-commits'); const git = createTestStrategy(workingDirPath); - const diffOutput = await git.getDiffForFilePath('added-to-both.txt'); - assert.isUndefined(diffOutput); - }); - describe('when the file is unstaged', function() { - it('returns a diff comparing the working directory copy of the file and the version on the index', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); - fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'd.txt')); - - assertDeepPropertyVals(await git.getDiffForFilePath('a.txt'), { - oldPath: 'a.txt', - newPath: 'a.txt', - oldMode: '100644', - newMode: '100644', - hunks: [ - { - oldStartLine: 1, - oldLineCount: 1, - newStartLine: 1, - newLineCount: 3, - heading: '', - lines: [ - '+qux', - ' foo', - '+bar', - ], - }, - ], - status: 'modified', - }); - - assertDeepPropertyVals(await git.getDiffForFilePath('c.txt'), { - oldPath: 'c.txt', - newPath: null, - oldMode: '100644', - newMode: null, - hunks: [ - { - oldStartLine: 1, - oldLineCount: 1, - newStartLine: 0, - newLineCount: 0, - heading: '', - lines: ['-baz'], - }, - ], - status: 'deleted', - }); - - assertDeepPropertyVals(await git.getDiffForFilePath('d.txt'), { - oldPath: null, - newPath: 'd.txt', - oldMode: null, - newMode: '100644', - hunks: [ - { - oldStartLine: 0, - oldLineCount: 0, - newStartLine: 1, - newLineCount: 1, - heading: '', - lines: ['+baz'], - }, - ], - status: 'added', - }); + assertDeepPropertyVals(await git.getDiffForFilePath('file.txt', {staged: true, baseCommit: 'HEAD~'}), { + oldPath: 'file.txt', + newPath: 'file.txt', + oldMode: '100644', + newMode: '100644', + hunks: [ + { + oldStartLine: 1, + oldLineCount: 1, + newStartLine: 1, + newLineCount: 1, + heading: '', + lines: ['-two', '+three'], + }, + ], + status: 'modified', }); }); + }); + }); - describe('when the file is staged', function() { - it('returns a diff comparing the index and head versions of the file', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); - fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'd.txt')); - await git.exec(['add', '.']); - - assertDeepPropertyVals(await git.getDiffForFilePath('a.txt', {staged: true}), { - oldPath: 'a.txt', - newPath: 'a.txt', - oldMode: '100644', - newMode: '100644', - hunks: [ - { - oldStartLine: 1, - oldLineCount: 1, - newStartLine: 1, - newLineCount: 3, - heading: '', - lines: [ - '+qux', - ' foo', - '+bar', - ], - }, - ], - status: 'modified', - }); - - assertDeepPropertyVals(await git.getDiffForFilePath('c.txt', {staged: true}), { - oldPath: 'c.txt', - newPath: null, - oldMode: '100644', - newMode: null, - hunks: [ - { - oldStartLine: 1, - oldLineCount: 1, - newStartLine: 0, - newLineCount: 0, - heading: '', - lines: ['-baz'], - }, - ], - status: 'deleted', - }); - - assertDeepPropertyVals(await git.getDiffForFilePath('d.txt', {staged: true}), { - oldPath: null, - newPath: 'd.txt', - oldMode: null, - newMode: '100644', - hunks: [ - { - oldStartLine: 0, - oldLineCount: 0, - newStartLine: 1, - newLineCount: 1, - heading: '', - lines: ['+baz'], - }, - ], - status: 'added', - }); - }); - }); + describe('isPartiallyStaged(filePath)', () => { + it('returns true if specified file path is partially staged', async () => { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'modified file', 'utf8'); + fs.writeFileSync(path.join(workingDirPath, 'new-file.txt'), 'foo\nbar\nbaz\n', 'utf8'); + fs.writeFileSync(path.join(workingDirPath, 'b.txt'), 'blah blah blah', 'utf8'); + fs.unlinkSync(path.join(workingDirPath, 'c.txt')); + + assert.isFalse(await git.isPartiallyStaged('a.txt')); + assert.isFalse(await git.isPartiallyStaged('b.txt')); + assert.isFalse(await git.isPartiallyStaged('c.txt')); + assert.isFalse(await git.isPartiallyStaged('new-file.txt')); + + await git.stageFiles(['a.txt', 'b.txt', 'c.txt', 'new-file.txt']); + assert.isFalse(await git.isPartiallyStaged('a.txt')); + assert.isFalse(await git.isPartiallyStaged('b.txt')); + assert.isFalse(await git.isPartiallyStaged('c.txt')); + assert.isFalse(await git.isPartiallyStaged('new-file.txt')); + + // modified on both + fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'more mods', 'utf8'); + // modified in working directory, added on index + fs.writeFileSync(path.join(workingDirPath, 'new-file.txt'), 'foo\nbar\nbaz\nqux\n', 'utf8'); + // deleted in working directory, modified on index + fs.unlinkSync(path.join(workingDirPath, 'b.txt')); + // untracked in working directory, deleted on index + fs.writeFileSync(path.join(workingDirPath, 'c.txt'), 'back baby', 'utf8'); + + assert.isTrue(await git.isPartiallyStaged('a.txt')); + assert.isTrue(await git.isPartiallyStaged('b.txt')); + assert.isTrue(await git.isPartiallyStaged('c.txt')); + assert.isTrue(await git.isPartiallyStaged('new-file.txt')); + }); + }); - describe('when the file is staged and a base commit is specified', function() { - it('returns a diff comparing the file on the index and in the specified commit', async function() { - const workingDirPath = await cloneRepository('multiple-commits'); - const git = createTestStrategy(workingDirPath); - - assertDeepPropertyVals(await git.getDiffForFilePath('file.txt', {staged: true, baseCommit: 'HEAD~'}), { - oldPath: 'file.txt', - newPath: 'file.txt', - oldMode: '100644', - newMode: '100644', - hunks: [ - { - oldStartLine: 1, - oldLineCount: 1, - newStartLine: 1, - newLineCount: 1, - heading: '', - lines: ['-two', '+three'], - }, - ], - status: 'modified', - }); - }); - }); + describe('isMerging', function() { + it('returns true if `.git/MERGE_HEAD` exists', async function() { + const workingDirPath = await cloneRepository('merge-conflict'); + const git = createTestStrategy(workingDirPath); + let isMerging = await git.isMerging(); + assert.isFalse(isMerging); + + try { + await git.merge('origin/branch'); + } catch (e) { + // expect merge to have conflicts + } + isMerging = await git.isMerging(); + assert.isTrue(isMerging); + + fs.unlinkSync(path.join(workingDirPath, '.git', 'MERGE_HEAD')); + isMerging = await git.isMerging(); + assert.isFalse(isMerging); }); + }); - forCommand('isPartiallyStaged').describe('isPartiallyStaged(filePath)', () => { - it('returns true if specified file path is partially staged', async () => { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'modified file', 'utf8'); - fs.writeFileSync(path.join(workingDirPath, 'new-file.txt'), 'foo\nbar\nbaz\n', 'utf8'); - fs.writeFileSync(path.join(workingDirPath, 'b.txt'), 'blah blah blah', 'utf8'); - fs.unlinkSync(path.join(workingDirPath, 'c.txt')); - - assert.isFalse(await git.isPartiallyStaged('a.txt')); - assert.isFalse(await git.isPartiallyStaged('b.txt')); - assert.isFalse(await git.isPartiallyStaged('c.txt')); - assert.isFalse(await git.isPartiallyStaged('new-file.txt')); - - await git.stageFiles(['a.txt', 'b.txt', 'c.txt', 'new-file.txt']); - assert.isFalse(await git.isPartiallyStaged('a.txt')); - assert.isFalse(await git.isPartiallyStaged('b.txt')); - assert.isFalse(await git.isPartiallyStaged('c.txt')); - assert.isFalse(await git.isPartiallyStaged('new-file.txt')); - - // modified on both - fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'more mods', 'utf8'); - // modified in working directory, added on index - fs.writeFileSync(path.join(workingDirPath, 'new-file.txt'), 'foo\nbar\nbaz\nqux\n', 'utf8'); - // deleted in working directory, modified on index - fs.unlinkSync(path.join(workingDirPath, 'b.txt')); - // untracked in working directory, deleted on index - fs.writeFileSync(path.join(workingDirPath, 'c.txt'), 'back baby', 'utf8'); - - assert.isTrue(await git.isPartiallyStaged('a.txt')); - assert.isTrue(await git.isPartiallyStaged('b.txt')); - assert.isTrue(await git.isPartiallyStaged('c.txt')); - assert.isTrue(await git.isPartiallyStaged('new-file.txt')); - }); + describe('getAheadCount(branchName) and getBehindCount(branchName)', function() { + it('returns the number of different commits on the branch vs the remote', async function() { + const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); + const git = createTestStrategy(localRepoPath); + await git.exec(['config', 'push.default', 'upstream']); + assert.equal(await git.getBehindCount('master'), 0); + assert.equal(await git.getAheadCount('master'), 0); + await git.fetch('master'); + assert.equal(await git.getBehindCount('master'), 1); + assert.equal(await git.getAheadCount('master'), 0); + await git.commit('new commit', {allowEmpty: true}); + await git.commit('another commit', {allowEmpty: true}); + assert.equal(await git.getBehindCount('master'), 1); + assert.equal(await git.getAheadCount('master'), 2); }); - forCommand('isMerging').describe('isMerging', function() { - it('returns true if `.git/MERGE_HEAD` exists', async function() { - const workingDirPath = await cloneRepository('merge-conflict'); - const git = createTestStrategy(workingDirPath); - let isMerging = await git.isMerging(); - assert.isFalse(isMerging); + it('returns null if there is no remote tracking branch specified', async function() { + const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); + const git = createTestStrategy(localRepoPath); + await git.exec(['config', 'push.default', 'upstream']); + await git.exec(['config', '--remove-section', 'branch.master']); + assert.equal(await git.getBehindCount('master'), null); + assert.equal(await git.getAheadCount('master'), null); + }); - try { - await git.merge('origin/branch'); - } catch (e) { - // expect merge to have conflicts - } - isMerging = await git.isMerging(); - assert.isTrue(isMerging); + it('returns null if the configured remote tracking branch does not exist locally', async function() { + const {localRepoPath} = await setUpLocalAndRemoteRepositories(); + const git = createTestStrategy(localRepoPath); + await git.exec(['config', 'push.default', 'upstream']); + await git.exec(['branch', '-d', '-r', 'origin/master']); + assert.equal(await git.getBehindCount('master'), null); + assert.equal(await git.getAheadCount('master'), null); + + await git.exec(['fetch']); + assert.equal(await git.getBehindCount('master'), 0); + assert.equal(await git.getAheadCount('master'), 0); + }); - fs.unlinkSync(path.join(workingDirPath, '.git', 'MERGE_HEAD')); - isMerging = await git.isMerging(); - assert.isFalse(isMerging); - }); + it('handles a remote tracking branch with a name that differs from the local branch', async function() { + const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); + const git = createTestStrategy(localRepoPath); + await git.exec(['config', 'push.default', 'upstream']); + await git.exec(['checkout', '-b', 'new-local-branch']); + await git.exec(['remote', 'rename', 'origin', 'upstream']); + await git.exec(['branch', '--set-upstream-to=upstream/master']); + assert.equal(await git.getBehindCount('new-local-branch'), 0); + assert.equal(await git.getAheadCount('new-local-branch'), 0); }); + }); - forCommand('getAheadCount').describe('getAheadCount(branchName) and getBehindCount(branchName)', function() { - it('returns the number of different commits on the branch vs the remote', async function() { - const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); - const git = createTestStrategy(localRepoPath); - await git.exec(['config', 'push.default', 'upstream']); - assert.equal(await git.getBehindCount('master'), 0); - assert.equal(await git.getAheadCount('master'), 0); - await git.fetch('master'); - assert.equal(await git.getBehindCount('master'), 1); - assert.equal(await git.getAheadCount('master'), 0); - await git.commit('new commit', {allowEmpty: true}); - await git.commit('another commit', {allowEmpty: true}); - assert.equal(await git.getBehindCount('master'), 1); - assert.equal(await git.getAheadCount('master'), 2); - }); + describe('getCurrentBranch() and checkout(branchName, {createNew})', function() { + it('returns the current branch name', async function() { + const workingDirPath = await cloneRepository('merge-conflict'); + const git = createTestStrategy(workingDirPath); + assert.equal(await git.getCurrentBranch(), 'master'); + await git.checkout('branch'); + assert.equal(await git.getCurrentBranch(), 'branch'); + + // newBranch does not yet exist + await assert.isRejected(git.checkout('newBranch')); + assert.equal(await git.getCurrentBranch(), 'branch'); + await git.checkout('newBranch', {createNew: true}); + assert.equal(await git.getCurrentBranch(), 'newBranch'); + }); + }); - it('returns null if there is no remote tracking branch specified', async function() { - const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); - const git = createTestStrategy(localRepoPath); - await git.exec(['config', 'push.default', 'upstream']); - await git.exec(['config', '--remove-section', 'branch.master']); - assert.equal(await git.getBehindCount('master'), null); - assert.equal(await git.getAheadCount('master'), null); - }); + describe('getRemoteForBranch(branchName)', function() { + it('returns the name of the remote associated with the branch, and null if none exists', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + assert.equal(await git.getRemoteForBranch('master'), 'origin'); + await git.exec(['remote', 'rename', 'origin', 'foo']); + assert.equal(await git.getRemoteForBranch('master'), 'foo'); + await git.exec(['remote', 'rm', 'foo']); + assert.isNull(await git.getRemoteForBranch('master')); + }); + }); - it('returns null if the configured remote tracking branch does not exist locally', async function() { - const {localRepoPath} = await setUpLocalAndRemoteRepositories(); - const git = createTestStrategy(localRepoPath); - await git.exec(['config', 'push.default', 'upstream']); - await git.exec(['branch', '-d', '-r', 'origin/master']); - assert.equal(await git.getBehindCount('master'), null); - assert.equal(await git.getAheadCount('master'), null); - - await git.exec(['fetch']); - assert.equal(await git.getBehindCount('master'), 0); - assert.equal(await git.getAheadCount('master'), 0); - }); + describe('getBranches()', function() { + it('returns an array of all branches', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + assert.deepEqual(await git.getBranches(), ['master']); + await git.checkout('new-branch', {createNew: true}); + assert.deepEqual(await git.getBranches(), ['master', 'new-branch']); + await git.checkout('another-branch', {createNew: true}); + assert.deepEqual(await git.getBranches(), ['another-branch', 'master', 'new-branch']); + }); + }); - it('handles a remote tracking branch with a name that differs from the local branch', async function() { - const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); - const git = createTestStrategy(localRepoPath); - await git.exec(['config', 'push.default', 'upstream']); - await git.exec(['checkout', '-b', 'new-local-branch']); - await git.exec(['remote', 'rename', 'origin', 'upstream']); - await git.exec(['branch', '--set-upstream-to=upstream/master']); - assert.equal(await git.getBehindCount('new-local-branch'), 0); - assert.equal(await git.getAheadCount('new-local-branch'), 0); + describe('getRemotes()', function() { + it('returns an array of remotes', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + await git.exec(['remote', 'set-url', 'origin', 'git@github.com:other/origin.git']); + await git.exec(['remote', 'add', 'upstream', 'git@github.com:my/upstream.git']); + await git.exec(['remote', 'add', 'another.remote', 'git@github.com:another/upstream.git']); + const remotes = await git.getRemotes(); + // Note: nodegit returns remote names in alphabetical order + assert.equal(remotes.length, 3); + [ + {name: 'another.remote', url: 'git@github.com:another/upstream.git'}, + {name: 'origin', url: 'git@github.com:other/origin.git'}, + {name: 'upstream', url: 'git@github.com:my/upstream.git'}, + ].forEach(remote => { + assert.include(remotes, remote); }); }); - forCommand('getCurrentBranch').describe('getCurrentBranch() and checkout(branchName, {createNew})', function() { - it('returns the current branch name', async function() { - const workingDirPath = await cloneRepository('merge-conflict'); - const git = createTestStrategy(workingDirPath); - assert.equal(await git.getCurrentBranch(), 'master'); - await git.checkout('branch'); - assert.equal(await git.getCurrentBranch(), 'branch'); - - // newBranch does not yet exist - await assert.isRejected(git.checkout('newBranch')); - assert.equal(await git.getCurrentBranch(), 'branch'); - await git.checkout('newBranch', {createNew: true}); - assert.equal(await git.getCurrentBranch(), 'newBranch'); - }); + it('returns an empty array when no remotes are set up', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + await git.exec(['remote', 'rm', 'origin']); + const remotes = await git.getRemotes(); + assert.deepEqual(remotes, []); }); + }); - forCommand('getRemoteForBranch').describe('getRemoteForBranch(branchName)', function() { - it('returns the name of the remote associated with the branch, and null if none exists', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - assert.equal(await git.getRemoteForBranch('master'), 'origin'); - await git.exec(['remote', 'rename', 'origin', 'foo']); - assert.equal(await git.getRemoteForBranch('master'), 'foo'); - await git.exec(['remote', 'rm', 'foo']); - assert.isNull(await git.getRemoteForBranch('master')); - }); + describe('getConfig() and setConfig()', function() { + it('gets and sets configs', async function() { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + assert.isNull(await git.getConfig('awesome.devs')); + await git.setConfig('awesome.devs', 'BinaryMuse,kuychaco,smashwilson'); + assert.equal('BinaryMuse,kuychaco,smashwilson', await git.getConfig('awesome.devs')); }); + }); - forCommand('getBranches').describe('getBranches()', function() { - it('returns an array of all branches', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - assert.deepEqual(await git.getBranches(), ['master']); - await git.checkout('new-branch', {createNew: true}); - assert.deepEqual(await git.getBranches(), ['master', 'new-branch']); - await git.checkout('another-branch', {createNew: true}); - assert.deepEqual(await git.getBranches(), ['another-branch', 'master', 'new-branch']); - }); + describe('commit(message, options) where amend option is true', function() { + it('amends the last commit', async function() { + const workingDirPath = await cloneRepository('multiple-commits'); + const git = createTestStrategy(workingDirPath); + const lastCommit = await git.getHeadCommit(); + const lastCommitParent = await git.getCommit('HEAD~'); + await git.commit('amend last commit', {amend: true, allowEmpty: true}); + const amendedCommit = await git.getHeadCommit(); + const amendedCommitParent = await git.getCommit('HEAD~'); + assert.notDeepEqual(lastCommit, amendedCommit); + assert.deepEqual(lastCommitParent, amendedCommitParent); }); + }); - forCommand('getRemotes').describe('getRemotes()', function() { - it('returns an array of remotes', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - await git.exec(['remote', 'set-url', 'origin', 'git@github.com:other/origin.git']); - await git.exec(['remote', 'add', 'upstream', 'git@github.com:my/upstream.git']); - await git.exec(['remote', 'add', 'another.remote', 'git@github.com:another/upstream.git']); - const remotes = await git.getRemotes(); - // Note: nodegit returns remote names in alphabetical order - assert.equal(remotes.length, 3); - [ - {name: 'another.remote', url: 'git@github.com:another/upstream.git'}, - {name: 'origin', url: 'git@github.com:other/origin.git'}, - {name: 'upstream', url: 'git@github.com:my/upstream.git'}, - ].forEach(remote => { - assert.include(remotes, remote); - }); - }); + // Only needs to be tested on strategies that actually implement gpgExec + describe('GPG signing', () => { + let git; - it('returns an empty array when no remotes are set up', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - await git.exec(['remote', 'rm', 'origin']); - const remotes = await git.getRemotes(); - assert.deepEqual(remotes, []); - }); + // eslint-disable-next-line jasmine/no-global-setup + beforeEach(async () => { + const workingDirPath = await cloneRepository('multiple-commits'); + git = createTestStrategy(workingDirPath); }); - forCommand('getConfig').describe('getConfig() and setConfig()', function() { - it('gets and sets configs', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - assert.isNull(await git.getConfig('awesome.devs')); - await git.setConfig('awesome.devs', 'BinaryMuse,kuychaco,smashwilson'); - assert.equal('BinaryMuse,kuychaco,smashwilson', await git.getConfig('awesome.devs')); - }); - }); + const operations = [ + { + verbalNoun: 'commit', + progressiveTense: 'committing', + action: () => git.commit('message'), + }, + { + verbalNoun: 'merge', + progressiveTense: 'merging', + action: () => git.merge('some-branch'), + }, + { + verbalNoun: 'pull', + progressiveTense: 'pulling', + action: () => git.pull('some-branch'), + configureStub: (stub, git) => { + // Stub the `git config` call to resolve the branch's upstream. + // stub.onCall(0).returns(Promise.resolve('origin')); + sinon.stub(git, 'getRemoteForBranch').returns(Promise.resolve('origin')); + }, + }, + ]; + + operations.forEach(op => { + it(`temporarily overrides gpg.program when ${op.progressiveTense}`, async () => { + const execStub = sinon.stub(git, 'exec'); + if (op.configureStub) { + op.configureStub(execStub, git); + } + execStub.returns(Promise.resolve()); - forCommand('commit').describe('commit(message, options) where amend option is true', function() { - it('amends the last commit', async function() { - const workingDirPath = await cloneRepository('multiple-commits'); - const git = createTestStrategy(workingDirPath); - const lastCommit = await git.getHeadCommit(); - const lastCommitParent = await git.getCommit('HEAD~'); - await git.commit('amend last commit', {amend: true, allowEmpty: true}); - const amendedCommit = await git.getHeadCommit(); - const amendedCommitParent = await git.getCommit('HEAD~'); - assert.notDeepEqual(lastCommit, amendedCommit); - assert.deepEqual(lastCommitParent, amendedCommitParent); + await op.action(); + + const callArgs = execStub.getCall(0).args; + const execArgs = callArgs[0]; + assert.equal(execArgs[0], '-c'); + assert.match(execArgs[1], /^gpg\.program=.*gpg-no-tty\.sh$/); + assert.isNotOk(callArgs[1].stdin); + assert.isNotOk(callArgs[1].useGitPromptServer); }); - }); - if (Strategy.prototype.gpgExec) { - // Only needs to be tested on strategies that actually implement gpgExec - forCommand('gpgExec').describe('GPG signing', () => { - let git; + it(`retries a ${op.verbalNoun} with a GitPromptServer when GPG signing fails`, async () => { + const gpgErr = new GitError('Mock GPG failure'); + gpgErr.stdErr = 'stderr includes "gpg failed"'; + gpgErr.code = 128; - // eslint-disable-next-line jasmine/no-global-setup - beforeEach(async () => { - const workingDirPath = await cloneRepository('multiple-commits'); - git = createTestStrategy(workingDirPath); - }); + const execStub = sinon.stub(git, 'exec'); + if (op.configureStub) { + op.configureStub(execStub, git); + } + execStub.onCall(0).returns(Promise.reject(gpgErr)); + execStub.returns(Promise.resolve()); - const operations = [ - { - verbalNoun: 'commit', - progressiveTense: 'committing', - action: () => git.commit('message'), - }, - { - verbalNoun: 'merge', - progressiveTense: 'merging', - action: () => git.merge('some-branch'), - }, - { - verbalNoun: 'pull', - progressiveTense: 'pulling', - action: () => git.pull('some-branch'), - configureStub: stub => { - // Stub the `git config` call to resolve the branch's upstream. - stub.onCall(0).returns(Promise.resolve('origin')); - return 1; - }, - }, - ]; - - operations.forEach(op => { - it(`temporarily overrides gpg.program when ${op.progressiveTense}`, async () => { - // if (op !== 'pulling') { return; } - const execStub = sinon.stub(git, 'exec'); - let callIndex = 0; - if (op.configureStub) { - callIndex = op.configureStub(execStub); - } - execStub.returns(Promise.resolve()); - - await op.action(); - - console.dir(execStub); - console.log(execStub.getCalls(), callIndex); - const callArgs = execStub.getCall(callIndex).args; - const execArgs = callArgs[0]; - assert.equal(execArgs[0], '-c'); - assert.match(execArgs[1], /^gpg\.program=.*gpg-no-tty\.sh$/); - assert.isNotOk(callArgs[1].stdin); - assert.isNotOk(callArgs[1].useGitPromptServer); - }); - - it(`retries a ${op.verbalNoun} with a GitPromptServer when GPG signing fails`, async () => { - const gpgErr = new GitError('Mock GPG failure'); - gpgErr.stdErr = 'stderr includes "gpg failed"'; - gpgErr.code = 128; - - let callIndex = 0; - const execStub = sinon.stub(git, 'exec'); - if (op.configureStub) { - callIndex = op.configureStub(execStub); - } - execStub.onCall(callIndex).returns(Promise.reject(gpgErr)); - execStub.returns(Promise.resolve()); - - try { - await op.action(); - } catch (err) { - assert.fail('expected op.action() not to throw the mock error'); - } - - const callArgs0 = execStub.getCall(callIndex).args; - const execArgs0 = callArgs0[0]; - assert.equal(execArgs0[0], '-c'); - assert.match(execArgs0[1], /^gpg\.program=.*gpg-no-tty\.sh$/); - assert.isNotOk(callArgs0[1].stdin); - assert.isNotOk(callArgs0[1].useGitPromptServer); - - const callArgs1 = execStub.getCall(callIndex + 1).args; - const execArgs1 = callArgs1[0]; - assert.equal(execArgs1[0], '-c'); - assert.match(execArgs1[1], /^gpg\.program=.*gpg-no-tty\.sh$/); - assert.isNotOk(callArgs1[1].stdin); - assert.isTrue(callArgs1[1].useGitPromptServer); - }); - }); - }); - } + try { + await op.action(); + } catch (err) { + assert.fail('expected op.action() not to throw the mock error'); + } - forCommand('createBlob').describe('createBlob({filePath})', () => { - it('creates a blob for the file path specified and returns its sha', async () => { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); - const sha = await git.createBlob({filePath: 'a.txt'}); - assert.equal(sha, 'c9f54222977c93ea17ba4a5a53c611fa7f1aaf56'); - const contents = await git.exec(['cat-file', '-p', sha]); - assert.equal(contents, 'qux\nfoo\nbar\n'); - }); + const callArgs0 = execStub.getCall(0).args; + const execArgs0 = callArgs0[0]; + assert.equal(execArgs0[0], '-c'); + assert.match(execArgs0[1], /^gpg\.program=.*gpg-no-tty\.sh$/); + assert.isNotOk(callArgs0[1].stdin); + assert.isNotOk(callArgs0[1].useGitPromptServer); - it('creates a blob for the stdin specified and returns its sha', async () => { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - const sha = await git.createBlob({stdin: 'foo\n'}); - assert.equal(sha, '257cc5642cb1a054f08cc83f2d943e56fd3ebe99'); - const contents = await git.exec(['cat-file', '-p', sha]); - assert.equal(contents, 'foo\n'); + const callArgs1 = execStub.getCall(0 + 1).args; + const execArgs1 = callArgs1[0]; + assert.equal(execArgs1[0], '-c'); + assert.match(execArgs1[1], /^gpg\.program=.*gpg-no-tty\.sh$/); + assert.isNotOk(callArgs1[1].stdin); + assert.isTrue(callArgs1[1].useGitPromptServer); }); }); + }); - forCommand('expandBlobToFile').describe('expandBlobToFile(absFilePath, sha)', () => { - it('restores blob contents for sha to specified file path', async () => { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - const absFilePath = path.join(workingDirPath, 'a.txt'); - fs.writeFileSync(absFilePath, 'qux\nfoo\nbar\n', 'utf8'); - const sha = await git.createBlob({filePath: 'a.txt'}); - fs.writeFileSync(absFilePath, 'modifications', 'utf8'); - await git.expandBlobToFile(absFilePath, sha); - assert.equal(fs.readFileSync(absFilePath), 'qux\nfoo\nbar\n'); - }); + describe('createBlob({filePath})', () => { + it('creates a blob for the file path specified and returns its sha', async () => { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); + const sha = await git.createBlob({filePath: 'a.txt'}); + assert.equal(sha, 'c9f54222977c93ea17ba4a5a53c611fa7f1aaf56'); + const contents = await git.exec(['cat-file', '-p', sha]); + assert.equal(contents, 'qux\nfoo\nbar\n'); }); - forCommand('getBlobContents').describe('getBlobContents(sha)', () => { - it('returns blob contents for sha', async () => { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - const sha = await git.createBlob({stdin: 'foo\nbar\nbaz\n'}); - const contents = await git.getBlobContents(sha); - assert.equal(contents, 'foo\nbar\nbaz\n'); - }); + it('creates a blob for the stdin specified and returns its sha', async () => { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + const sha = await git.createBlob({stdin: 'foo\n'}); + assert.equal(sha, '257cc5642cb1a054f08cc83f2d943e56fd3ebe99'); + const contents = await git.exec(['cat-file', '-p', sha]); + assert.equal(contents, 'foo\n'); + }); + }); + + describe('expandBlobToFile(absFilePath, sha)', () => { + it('restores blob contents for sha to specified file path', async () => { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + const absFilePath = path.join(workingDirPath, 'a.txt'); + fs.writeFileSync(absFilePath, 'qux\nfoo\nbar\n', 'utf8'); + const sha = await git.createBlob({filePath: 'a.txt'}); + fs.writeFileSync(absFilePath, 'modifications', 'utf8'); + await git.expandBlobToFile(absFilePath, sha); + assert.equal(fs.readFileSync(absFilePath), 'qux\nfoo\nbar\n'); + }); + }); + + describe('getBlobContents(sha)', () => { + it('returns blob contents for sha', async () => { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + const sha = await git.createBlob({stdin: 'foo\nbar\nbaz\n'}); + const contents = await git.getBlobContents(sha); + assert.equal(contents, 'foo\nbar\nbaz\n'); }); }); }); From 410525c8e19196d86d4b59faad32f9f80edbf815 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 17:11:45 -0800 Subject: [PATCH 27/34] :art: --- lib/composite-git-strategy.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/composite-git-strategy.js b/lib/composite-git-strategy.js index a3c2503ab9..e1824c7e5e 100644 --- a/lib/composite-git-strategy.js +++ b/lib/composite-git-strategy.js @@ -16,8 +16,6 @@ export default { const commandQueue = new AsyncQueue({parallelism}); const strategyOptions = {...options, queue: commandQueue}; - // const gitShellOutStrategy = new GitShellOutStrategy(workingDir, strategyOptions); - // const nodegitStrategy = new NodeGitStrategy(workingDir, strategyOptions); const strategyInstances = strategies.map(Strategy => new Strategy(workingDir, strategyOptions)); return firstImplementer(...strategyInstances); }; From 68d7995ebd1b5e446688bbb983e229723a66b199 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 17:14:59 -0800 Subject: [PATCH 28/34] =?UTF-8?q?Drop=20the=20nodegit=20strategy=20like=20?= =?UTF-8?q?a=20hot=20potato=20=F0=9F=8D=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/composite-git-strategy.js | 3 +- lib/nodegit-strategy.js | 200 ---------------------------------- test/git-strategies.test.js | 4 +- 3 files changed, 2 insertions(+), 205 deletions(-) delete mode 100644 lib/nodegit-strategy.js diff --git a/lib/composite-git-strategy.js b/lib/composite-git-strategy.js index e1824c7e5e..79711ef375 100644 --- a/lib/composite-git-strategy.js +++ b/lib/composite-git-strategy.js @@ -3,11 +3,10 @@ import os from 'os'; import {firstImplementer} from './helpers'; import AsyncQueue from './async-queue'; import GitShellOutStrategy from './git-shell-out-strategy'; -import NodeGitStrategy from './nodegit-strategy'; export default { create(workingDir, options = {}) { - return this.withStrategies([NodeGitStrategy, GitShellOutStrategy])(workingDir, options); + return this.withStrategies([GitShellOutStrategy])(workingDir, options); }, withStrategies(strategies) { diff --git a/lib/nodegit-strategy.js b/lib/nodegit-strategy.js deleted file mode 100644 index 589b7ff298..0000000000 --- a/lib/nodegit-strategy.js +++ /dev/null @@ -1,200 +0,0 @@ -import os from 'os'; - -import Git from 'nodegit'; - -import AsyncQueue from './async-queue'; -import {writeFile} from './helpers'; -import GitTimingsView from './views/git-timings-view'; - -export default class NodeGitStrategy { - constructor(workingDir, options = {}) { - this.workingDir = workingDir; - this.nodegit = Git.Repository.open(workingDir); - if (options.queue) { - this.commandQueue = options.queue; - } else { - const parallelism = options.parallelism || Math.max(3, os.cpus().length); - this.commandQueue = new AsyncQueue({parallelism}); - } - } - - async execNodeGit(op, label = '') { - const timingMarker = GitTimingsView.generateMarker(label + ' (nodegit)'); - timingMarker.mark('queued'); - timingMarker.mark('prepare'); - timingMarker.mark('nexttick'); - timingMarker.mark('execute'); - const retVal = await op(); - timingMarker.finalize(); - return retVal; - } - - getStatusesForChangedFiles() { - return this.execNodeGit(async () => { - const stagedFiles = {}; - const unstagedFiles = {}; - const mergeConflictFiles = {}; - const repo = await this.nodegit; - const statuses = await repo.getStatus(); - const STATUS = Git.Status.STATUS; - const index = await repo.index(); - - /* eslint-disable no-bitwise */ - for (let i = 0; i < statuses.length; i++) { - const status = statuses[i]; - const mask = status.statusBit(); - const filePath = status.path(); - if (mask & STATUS.INDEX_NEW) { - stagedFiles[filePath] = 'added'; - } - if (mask & STATUS.INDEX_MODIFIED || mask & STATUS.INDEX_TYPECHANGE) { - stagedFiles[filePath] = 'modified'; - } - if (mask & STATUS.INDEX_DELETED) { - stagedFiles[filePath] = 'deleted'; - } - if (mask & STATUS.INDEX_RENAMED) { - // eslint-disable-next-line no-console - console.warn('rename detected, check if handled correctly for staged filePath', filePath); - } - if (mask & STATUS.WT_NEW) { - unstagedFiles[filePath] = 'added'; - } - if (mask & STATUS.WT_MODIFIED || mask & STATUS.WT_TYPECHANGE) { - unstagedFiles[filePath] = 'modified'; - } - if (mask & STATUS.WT_DELETED) { - unstagedFiles[filePath] = 'deleted'; - } - if (mask & STATUS.WT_RENAMED) { - // eslint-disable-next-line no-console - console.warn('rename detected, check if handled correctly for unstaged filePath', filePath); - } - if (mask & STATUS.CONFLICTED) { - // eslint-disable-next-line babel/no-await-in-loop - const {ancestor_out: base, our_out: ours, their_out: theirs} = await index.conflictGet(filePath); - if (!base && ours && theirs) { - mergeConflictFiles[filePath] = {ours: 'added', theirs: 'added', file: 'modified'}; - } else if (base && ours && theirs) { - mergeConflictFiles[filePath] = {ours: 'modified', theirs: 'modified', file: 'modified'}; - } else if (base && ours && !theirs) { - mergeConflictFiles[filePath] = {ours: 'modified', theirs: 'deleted', file: 'equivalent'}; - } else if (base && !ours && theirs) { - mergeConflictFiles[filePath] = {ours: 'deleted', theirs: 'modified', file: 'added'}; - } else { - // eslint-disable-next-line no-console - console.error('Unrecognized index state:', {base, ours, theirs}); - } - } - } - /* eslint-enable no-bitwise */ - return {stagedFiles, unstagedFiles, mergeConflictFiles}; - }, 'getStatusesForChangedFiles'); - } - - /** - * Miscellaneous getters - */ - getCommit(ref) { - return this.execNodeGit(async () => { - const commit = await this._getCommitFromRevspec(ref); - return {sha: commit.id().toString(), message: commit.message().trim()}; - }, 'getCommit'); - } - - getHeadCommit() { - return this.getCommit('HEAD'); - } - - async getRemoteForBranch(branchName) { - try { - // const output = await this.exec(['config', `branch.${branchName}.remote`]); - return await this.getConfig(`branch.${branchName}.remote`); - } catch (e) { - return null; - } - } - - async push(branchName, options = {}) { - const remote = await this.getRemoteForBranch(branchName); - const args = ['push', remote || 'origin', branchName]; - if (options.setUpstream) { args.push('--set-upstream'); } - if (options.force) { args.push('--force'); } - return this.exec(args, {useGitPromptServer: true, writeOperation: true}); - } - - getCurrentBranch() { - return this.execNodeGit(async () => { - const repo = await this.nodegit; - const ref = await repo.head(); - return ref.shorthand(); - }, 'getCurrentBranch'); - } - - getBranches() { - return this.execNodeGit(async () => { - const repo = await this.nodegit; - const refs = await Git.Reference.list(repo); - return refs - .filter(ref => ref.startsWith('refs/heads/')) - .map(ref => ref.substr(11)); - }, 'getBranches'); - } - - getConfig(option, {local} = {}) { - // TODO: non-local??? - return this.execNodeGit(async () => { - const repo = await this.nodegit; - const config = await repo.configSnapshot(); - try { - return await config.getStringBuf(option); - } catch (_e) { - return null; - } - }, 'getConfig'); - } - - async getRemotes() { - const repo = await this.nodegit; - const remoteNames = await repo.getRemotes(); - const remotes = await Promise.all(remoteNames.map(remoteName => { - return Git.Remote.lookup(repo, remoteName); - })); - return remotes.map(remote => ({ - name: remote.name(), - url: remote.url(), - })); - } - - async expandBlobToFile(absFilePath, sha) { - const output = await this.getBlobContents(sha); - await writeFile(absFilePath, output); - return absFilePath; - } - - getBlobContents(sha) { - return this.execNodeGit(async () => { - const repo = await this.nodegit; - const oid = Git.Oid.fromString(sha); - const blob = await Git.Blob.lookup(repo, oid); - return blob.toString(); - }, 'getBlobContents'); - } - - async _getOidFromRevspec(revspec) { - const repo = await this.nodegit; - // `revspec` might be a refspec with suffixes, etc. - // so we use `fromRevspec` to get an annotated commit, - // and from that the actual commit. - const annotatedCommit = await Git.AnnotatedCommit.fromRevspec(repo, revspec); - const oid = annotatedCommit.id(); - annotatedCommit.free(); - return oid; - } - - async _getCommitFromRevspec(revspec) { - const repo = await this.nodegit; - const oid = await this._getOidFromRevspec(revspec); - return Git.Commit.lookup(repo, oid); - } -} diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js index a7e65d4d64..11c6ded756 100644 --- a/test/git-strategies.test.js +++ b/test/git-strategies.test.js @@ -5,7 +5,6 @@ import mkdirp from 'mkdirp'; import CompositeGitStrategy from '../lib/composite-git-strategy'; import GitShellOutStrategy, {GitError} from '../lib/git-shell-out-strategy'; -import NodeGitStrategy from '../lib/nodegit-strategy'; import {cloneRepository, assertDeepPropertyVals, setUpLocalAndRemoteRepositories} from './helpers'; @@ -16,8 +15,7 @@ import {cloneRepository, assertDeepPropertyVals, setUpLocalAndRemoteRepositories */ [ - [NodeGitStrategy, GitShellOutStrategy], - [GitShellOutStrategy, NodeGitStrategy], + [GitShellOutStrategy], ].forEach(function(strategies) { const createTestStrategy = (...args) => { return CompositeGitStrategy.withStrategies(strategies)(...args); From 6611be9773dd5eafd08b3a6d4bb13b1cb88c5f5e Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 17:50:18 -0800 Subject: [PATCH 29/34] :shirt: --- test/git-strategies.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js index 11c6ded756..0581a5e78f 100644 --- a/test/git-strategies.test.js +++ b/test/git-strategies.test.js @@ -605,10 +605,10 @@ import {cloneRepository, assertDeepPropertyVals, setUpLocalAndRemoteRepositories verbalNoun: 'pull', progressiveTense: 'pulling', action: () => git.pull('some-branch'), - configureStub: (stub, git) => { + configureStub: (stub, gitStrategy) => { // Stub the `git config` call to resolve the branch's upstream. // stub.onCall(0).returns(Promise.resolve('origin')); - sinon.stub(git, 'getRemoteForBranch').returns(Promise.resolve('origin')); + sinon.stub(gitStrategy, 'getRemoteForBranch').returns(Promise.resolve('origin')); }, }, ]; From c840e2697d8fec70af5582e218149b87ef0dee8d Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 17:53:14 -0800 Subject: [PATCH 30/34] :art: --- lib/helpers.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/helpers.js b/lib/helpers.js index 087c24bab1..4eb63b8ab3 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -56,10 +56,9 @@ export function firstImplementer(...targets) { // Used by sinon getPrototypeOf(target) { - const v = targets.reduce((acc, t) => { + return targets.reduce((acc, t) => { return Object.create(acc, descriptorsFromProto(Object.getPrototypeOf(t))); }, Object.prototype); - return v; }, }); } From 84b6575ed6a72d6b9a20e3c28c5114eac42ced4f Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 17:55:11 -0800 Subject: [PATCH 31/34] :fire: unused code --- test/git-strategies.test.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js index 0581a5e78f..eabd02d9b6 100644 --- a/test/git-strategies.test.js +++ b/test/git-strategies.test.js @@ -606,8 +606,6 @@ import {cloneRepository, assertDeepPropertyVals, setUpLocalAndRemoteRepositories progressiveTense: 'pulling', action: () => git.pull('some-branch'), configureStub: (stub, gitStrategy) => { - // Stub the `git config` call to resolve the branch's upstream. - // stub.onCall(0).returns(Promise.resolve('origin')); sinon.stub(gitStrategy, 'getRemoteForBranch').returns(Promise.resolve('origin')); }, }, From d50cce6786cf1776f6572fa332108285716871bf Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 21:44:37 -0800 Subject: [PATCH 32/34] Be more specific with firstImplementer tests --- test/helpers.test.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/helpers.test.js b/test/helpers.test.js index cc57b22998..4cdda1a9ef 100644 --- a/test/helpers.test.js +++ b/test/helpers.test.js @@ -11,10 +11,13 @@ class B { } describe('firstImplementer', function() { + const a = new A(); + const b = new B(); + it('calls methods from the first target that has the method', function() { - const target = firstImplementer(new A(), new B()); - assert.equal(target.one(), 'a-one'); - assert.equal(target.two(), 'a-two'); - assert.equal(target.three(), 'b-three'); + const target = firstImplementer(a, b); + assert.equal(target.one, a.one); + assert.equal(target.two, a.two); + assert.equal(target.three, b.three); }); }); From 17308d34a72eb2a4b5d6b56a729f86eac82a432d Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 21:44:59 -0800 Subject: [PATCH 33/34] Use reduceRight to produce correct prototype @kuychaco was right --- lib/helpers.js | 2 +- test/helpers.test.js | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/helpers.js b/lib/helpers.js index 4eb63b8ab3..fafcde1671 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -56,7 +56,7 @@ export function firstImplementer(...targets) { // Used by sinon getPrototypeOf(target) { - return targets.reduce((acc, t) => { + return targets.reduceRight((acc, t) => { return Object.create(acc, descriptorsFromProto(Object.getPrototypeOf(t))); }, Object.prototype); }, diff --git a/test/helpers.test.js b/test/helpers.test.js index 4cdda1a9ef..c5bfb2ebb2 100644 --- a/test/helpers.test.js +++ b/test/helpers.test.js @@ -20,4 +20,13 @@ describe('firstImplementer', function() { assert.equal(target.two, a.two); assert.equal(target.three, b.three); }); + + it('reports a combined prototype', function() { + const target = firstImplementer(a, b); + const proto = Object.getPrototypeOf(target); + const obj = Object.create(proto); + assert.equal(obj.one(), 'a-one'); + assert.equal(obj.two(), 'a-two'); + assert.equal(obj.three(), 'b-three'); + }); }); From 40d9a6fde75d4ab5f31c0f100800bf75c8922689 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 22 Feb 2017 21:51:33 -0800 Subject: [PATCH 34/34] Test firstImplementer set and getOwnPropertyDescriptor --- test/helpers.test.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/helpers.test.js b/test/helpers.test.js index c5bfb2ebb2..0053a9facf 100644 --- a/test/helpers.test.js +++ b/test/helpers.test.js @@ -29,4 +29,27 @@ describe('firstImplementer', function() { assert.equal(obj.two(), 'a-two'); assert.equal(obj.three(), 'b-three'); }); + + it('sets properties that exist on an implementer on that implementer, and ones that do not on the target', function() { + const target = firstImplementer(a, b); + target.one = () => 'new-one'; + assert.equal(a.one(), 'new-one'); + target.three = () => 'new-three'; + assert.notOk(a.three); + assert.equal(b.three(), 'new-three'); + target.four = () => 'four! ah ah ah'; + assert.notOk(a.four); + assert.notOk(b.four); + assert.equal(target.four(), 'four! ah ah ah'); + }); + + it('correctly reports getOwnPropertyDescriptor', function() { + const target = firstImplementer(a, b); + const descOne = Object.getOwnPropertyDescriptor(target, 'one'); + assert.equal(descOne.value, a.one); + const descThree = Object.getOwnPropertyDescriptor(target, 'three'); + assert.equal(descThree.value, b.three); + const descTarget = Object.getOwnPropertyDescriptor(target, '__implementations'); + assert.deepEqual(descTarget.value, [a, b]); + }); });