From b212aab006dd98b4b522b6e635f63105e0333991 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 15 Feb 2017 15:59:43 -0800 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] :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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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