From 8b9487691a706591142baaf57b9dc0176c07b9ce Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 19 Apr 2017 15:21:03 -0700 Subject: [PATCH 01/28] Allow DockItem to be activated via prop --- lib/views/dock-item.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/views/dock-item.js b/lib/views/dock-item.js index 5de0c57878..d6ac2a1c25 100644 --- a/lib/views/dock-item.js +++ b/lib/views/dock-item.js @@ -26,6 +26,7 @@ export default class DockItem extends React.Component { getItem: PropTypes.func, onDidCloseItem: PropTypes.func, stubItemSelector: PropTypes.string, + activate: PropTypes.bool, } static defaultProps = { @@ -65,8 +66,15 @@ export default class DockItem extends React.Component { if (stub) { stub.setRealItem(itemToAdd); this.dockItem = stub; + if (this.props.activate) { + this.activate(); + } } else { - Promise.resolve(this.props.workspace.open(itemToAdd)).then(item => { this.dockItem = item; }); + Promise.resolve(this.props.workspace.open(itemToAdd)) + .then(item => { this.dockItem = item; }) + .then(() => { + if (this.props.activate) { this.activate(); } + }); } this.subscriptions = this.props.workspace.onDidDestroyPaneItem(({item}) => { From f1fb49b710c501cee6e5ceff695abf65a7e0de02 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 19 Apr 2017 15:21:42 -0700 Subject: [PATCH 02/28] Show both panels when package is first activated Ensure that the Git panel is on the left but is also the one that is active --- lib/controllers/root-controller.js | 9 ++++++--- lib/github-package.js | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index 1de2800792..9e5bfbf870 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -54,12 +54,14 @@ export default class RootController extends React.Component { switchboard: PropTypes.instanceOf(Switchboard), savedState: PropTypes.object, useLegacyPanels: PropTypes.bool, + firstRun: React.PropTypes.bool, } static defaultProps = { switchboard: new Switchboard(), savedState: {}, useLegacyPanels: false, + firstRun: true, } serialize() { @@ -76,8 +78,8 @@ export default class RootController extends React.Component { this.state = { ...nullFilePatchState, amending: false, - gitPanelActive: !!props.savedState.gitPanelActive, - githubPanelActive: !!props.savedState.githubPanelActive, + gitPanelActive: props.firstRun || props.savedState.gitPanelActive, + githubPanelActive: props.firstRun || props.savedState.githubPanelActive, panelSize: props.savedState.panelSize || 400, activeTab: props.savedState.activeTab || 0, cloneDialogActive: false, @@ -160,7 +162,8 @@ export default class RootController extends React.Component { workspace={this.props.workspace} getItem={({subtree}) => subtree.getWrappedComponent()} onDidCloseItem={() => this.setState({gitPanelActive: false})} - stubItemSelector="git-tab-controller"> + stubItemSelector="git-tab-controller" + activate={this.props.firstRun}> { this.gitPanelController = c; }} className="github-PanelEtchWrapper" diff --git a/lib/github-package.js b/lib/github-package.js index 09ee5c186f..a1a4d1925a 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -22,6 +22,7 @@ import yardstick from './yardstick'; import GitTimingsView from './views/git-timings-view'; const defaultState = { + firstRun: true, resolutionProgressByPath: {}, }; @@ -145,6 +146,7 @@ export default class GithubPackage { return { activeRepositoryPath: activeRepository ? activeRepository.getWorkingDirectoryPath() : null, gitController: this.controller.serialize(), + firstRun: false, resolutionProgressByPath, }; } @@ -198,6 +200,7 @@ export default class GithubPackage { cloneRepositoryForProjectPath={this.cloneRepositoryForProjectPath} switchboard={this.switchboard} useLegacyPanels={this.useLegacyPanels} + firstRun={this.savedState.firstRun} />, this.element, ); } From 0d3896987452a2590f85906672377c8c08aaaed6 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 19 Apr 2017 15:45:01 -0700 Subject: [PATCH 03/28] :white_check_mark: implement new tests for first run functionality --- test/controllers/root-controller.test.js | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index 31ea490ada..c5c266aebb 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -35,6 +35,7 @@ describe('RootController', function() { config={config} confirm={confirm} useLegacyPanels={true} + firstRun={false} /> ); }); @@ -43,6 +44,38 @@ describe('RootController', function() { atomEnv.destroy(); }); + describe('initial panel visibility', function() { + it('is not visible when the saved state indicates they were not visible last run', async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); + + app = React.cloneElement(app, {repository}); + const wrapper = shallow(app); + + assert.isFalse(wrapper.find('Panel').prop('visible')); + }); + + it('is visible when the saved state indicates they were visible last run', async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); + + app = React.cloneElement(app, {repository, savedState: {gitPanelActive: true, githubPanelActive: true}}); + const wrapper = shallow(app); + + assert.isTrue(wrapper.find('Panel').prop('visible')); + }); + + it('is always visible when the lastRun prop is true', async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); + + app = React.cloneElement(app, {repository, firstRun: true}); + const wrapper = shallow(app); + + assert.isTrue(wrapper.find('Panel').prop('visible')); + }); + }); + describe('showMergeConflictFileForPath(relativeFilePath, {focus} = {})', function() { it('opens the file as a pending pane item if it exists', async function() { const workdirPath = await cloneRepository('merge-conflict'); From c845606a4ca287351ca40cf4c367858cbaf90114 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 19 Apr 2017 16:08:53 -0700 Subject: [PATCH 04/28] =?UTF-8?q?Why=20is=20this=20failing=20on=20CI=20?= =?UTF-8?q?=F0=9F=98=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/github-package.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/github-package.js b/lib/github-package.js index a1a4d1925a..d52ebe330f 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -177,7 +177,14 @@ export default class GithubPackage { if (!this.element) { this.element = document.createElement('div'); this.subscriptions.add(new Disposable(() => { - ReactDom.unmountComponentAtNode(this.element); + try { + ReactDom.unmountComponentAtNode(this.element); + } catch (err) { + /* eslint-disable no-console */ + console.error('Could not unmount RootController; DOM node did not contain a React app. Error was:'); + console.error(err); + /* eslint-enable no-console */ + } delete this.element; })); } From 5e96bc38bdce79117224cf50b83e7145bd3717c1 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 19 Apr 2017 22:46:41 -0700 Subject: [PATCH 05/28] Activate with {firstRun: false} in tests --- test/github-package.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/github-package.test.js b/test/github-package.test.js index 9d9ccfb67b..248d095086 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -187,7 +187,7 @@ describe('GithubPackage', function() { it('defaults to a single repository even without an active pane item', async function() { const workdirPath = await cloneRepository('three-files'); project.setPaths([workdirPath]); - await githubPackage.activate(); + await githubPackage.activate({firstRun: false}); await githubPackage.updateActiveModels(); From 5a878d69fede1a73663d1e258fe21322fd2edbdc Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Thu, 20 Apr 2017 14:46:26 -0700 Subject: [PATCH 06/28] Update rootControllerTest to use new prop name --- test/controllers/root-controller.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index e7aaf335df..05cdfa97cf 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -66,7 +66,7 @@ describe('RootController', function() { const workdirPath = await cloneRepository('multiple-commits'); const repository = await buildRepository(workdirPath); - app = React.cloneElement(app, {repository, savedState: {gitPanelActive: true, githubPanelActive: true}}); + app = React.cloneElement(app, {repository, savedState: {gitTabActive: true, githubPanelActive: true}}); const wrapper = shallow(app); assert.isTrue(wrapper.find('Panel').prop('visible')); From d219d474899a5a0425cfc17315242a398fe793d5 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Thu, 20 Apr 2017 14:48:00 -0700 Subject: [PATCH 07/28] Allow AsyncQueue to be disposed --- lib/async-queue.js | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/async-queue.js b/lib/async-queue.js index dfdfadf17e..e2579dc22c 100644 --- a/lib/async-queue.js +++ b/lib/async-queue.js @@ -39,6 +39,9 @@ export default class AsyncQueue { } push(fn, {parallel} = {parallel: true}) { + if (this.disposed) { + throw new Error('AsyncQueue is disposed'); + } const task = new Task(fn, parallel); this.queue.push(task); this.processQueue(); @@ -46,7 +49,7 @@ export default class AsyncQueue { } processQueue() { - if (!this.queue.length || this.nonParallelizableOperation) { return; } + if (!this.queue.length || this.nonParallelizableOperation || this.disposed) { return; } const task = this.queue[0]; const canRunParallelOp = task.runsInParallel() && this.tasksInProgress < this.parallelism; @@ -59,14 +62,24 @@ export default class AsyncQueue { } async processTask(task, runsInParallel) { + if (this.disposed) { return; } + this.tasksInProgress++; if (!runsInParallel) { this.nonParallelizableOperation = true; } - await task.execute(); - this.tasksInProgress--; - this.nonParallelizableOperation = false; - this.processQueue(); + try { + await task.execute(); + } finally { + this.tasksInProgress--; + this.nonParallelizableOperation = false; + this.processQueue(); + } + } + + dispose() { + this.queue = []; + this.disposed = true; } } From 166603b1f16ecc0fa04f4d40969209159a81f192 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Thu, 20 Apr 2017 14:51:56 -0700 Subject: [PATCH 08/28] Destroy GSOS and associated AsyncQueue when destroying Repository --- lib/git-shell-out-strategy.js | 4 ++++ lib/models/repository-states/destroyed.js | 1 + 2 files changed, 5 insertions(+) diff --git a/lib/git-shell-out-strategy.js b/lib/git-shell-out-strategy.js index fb847ea135..98fc6d402c 100644 --- a/lib/git-shell-out-strategy.js +++ b/lib/git-shell-out-strategy.js @@ -653,6 +653,10 @@ export default class GitShellOutStrategy { return executable ? '100755' : '100644'; } } + + destroy() { + this.commandQueue.dispose(); + } } function buildAddedFilePatch(filePath, contents, executable) { diff --git a/lib/models/repository-states/destroyed.js b/lib/models/repository-states/destroyed.js index 0273989a9d..e7f013e441 100644 --- a/lib/models/repository-states/destroyed.js +++ b/lib/models/repository-states/destroyed.js @@ -6,6 +6,7 @@ import State from './state'; export default class Destroyed extends State { start() { this.didDestroy(); + this.repository.git.destroy && this.repository.git.destroy(); this.repository.emitter.dispose(); } From 6d1b62b1b6606f965368f82ebaa17f7ce9f5f1bc Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 21 Apr 2017 10:43:50 -0400 Subject: [PATCH 09/28] Never render or update the active context after destruction --- lib/github-package.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/github-package.js b/lib/github-package.js index 5f727b7e8c..5b87b11331 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -188,6 +188,10 @@ export default class GithubPackage { @autobind rerender(callback) { + if (this.workspace.isDestroyed()) { + return; + } + if (!this.element) { this.element = document.createElement('div'); this.subscriptions.add(new Disposable(() => { @@ -391,6 +395,10 @@ export default class GithubPackage { } async updateActiveContext(savedState = {}) { + if (this.workspace.isDestroyed()) { + return; + } + this.switchboard.didBeginActiveContextUpdate(); const nextActiveContext = await this.getNextContext(savedState); From 9ca2a3cea3274f98f70d10401292b4b9fd16b12b Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 21 Apr 2017 10:44:33 -0400 Subject: [PATCH 10/28] Delegate git operations in async state methods to the current state --- lib/models/repository-states/state.js | 33 +++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index 95a8dd743c..53dfee839e 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -407,6 +407,12 @@ export default class State { return this.repository.getWorkingDirectoryPath(); } + // Call methods on the active Repository state, even if the state has transitioned beneath you. + // Use this to perform operations within `start()` methods to guard against interrupted state transitions. + current() { + return this.repository.state; + } + // Return a Promise that will resolve once the state transitions from Loading. getLoadPromise() { return this.repository.getLoadPromise(); @@ -439,16 +445,39 @@ export default class State { return this.repository.emitter.emit('did-update'); } + // Direct git access + // Non-delegated git operations for internal use within states. + + directIsGitRepository() { + return Promise.resolve(false); + } + + directGetConfig(key, options = {}) { + return Promise.resolve(null); + } + + directGetBlobContents() { + return Promise.reject(new Error('Not a valid object name')); + } + + // Deferred operations + // Direct raw git operations to the current state, even if the state has been changed. Use these methods within + // start() methods. + + isGitRepository() { + return this.current().directIsGitRepository(); + } + // Parse a DiscardHistory payload from the SHA recorded in config. async loadHistoryPayload() { - const historySha = await this.git().getConfig('atomGithub.historySha'); + const historySha = await this.current().directGetConfig('atomGithub.historySha'); if (!historySha) { return {}; } let blob; try { - blob = await this.git().getBlobContents(historySha); + blob = await this.current().directBlobContents(historySha); } catch (e) { if (/Not a valid object name/.test(e.stdErr)) { return {}; From ae017aae76a236be717416db2bca1089d70ed323 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 21 Apr 2017 10:44:54 -0400 Subject: [PATCH 11/28] Make Loading.start() cancellable --- lib/models/repository-states/loading.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/models/repository-states/loading.js b/lib/models/repository-states/loading.js index a70420f557..00fe19d758 100644 --- a/lib/models/repository-states/loading.js +++ b/lib/models/repository-states/loading.js @@ -6,7 +6,7 @@ import State from './state'; */ export default class Loading extends State { async start() { - if (await this.git().isGitRepository()) { + if (await this.isGitRepository()) { const history = await this.loadHistoryPayload(); return this.transitionTo('Present', history); } else { @@ -31,6 +31,18 @@ export default class Loading extends State { showGitTabLoading() { return true; } + + directIsGitRepository() { + return this.git().isGitRepository(); + } + + directGetConfig(key, options) { + return this.git().getConfig(key, options); + } + + directGetBlobContents(sha) { + return this.git().getBlobContents(sha); + } } State.register(Loading); From d2ce1031388b7f1bcba4ba3594e084d49defe842 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 21 Apr 2017 10:45:14 -0400 Subject: [PATCH 12/28] Only permit state transitions initiated by the current state --- lib/models/repository.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/models/repository.js b/lib/models/repository.js index e3f8d340a7..347d9af56e 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -67,11 +67,18 @@ export default class Repository { // State management ////////////////////////////////////////////////////////////////////////////////////////////////// transition(currentState, StateConstructor, ...payload) { + if (currentState !== this.state) { + // Attempted transition from a non-active state, most likely from an asynchronous start() method. + return Promise.resolve(); + } + const nextState = new StateConstructor(this, ...payload); this.state = nextState; this.emitter.emit('did-change-state', {from: currentState, to: this.state}); - this.emitter.emit('did-update'); + if (!this.isDestroyed()) { + this.emitter.emit('did-update'); + } return this.state.start(); } From fbbeb849d8e0b1eb39b3b6d7889499780b53aab6 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 21 Apr 2017 10:45:45 -0400 Subject: [PATCH 13/28] Consolidate .then callbacks --- lib/views/dock-item.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/views/dock-item.js b/lib/views/dock-item.js index d6ac2a1c25..fde8766bf1 100644 --- a/lib/views/dock-item.js +++ b/lib/views/dock-item.js @@ -71,8 +71,8 @@ export default class DockItem extends React.Component { } } else { Promise.resolve(this.props.workspace.open(itemToAdd)) - .then(item => { this.dockItem = item; }) - .then(() => { + .then(item => { + this.dockItem = item; if (this.props.activate) { this.activate(); } }); } From 4304f5b75f794f86087e326f2ef6f8746eb931cf Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 21 Apr 2017 10:45:58 -0400 Subject: [PATCH 14/28] Don't activate after close --- lib/views/dock-item.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/views/dock-item.js b/lib/views/dock-item.js index fde8766bf1..d4791ea9eb 100644 --- a/lib/views/dock-item.js +++ b/lib/views/dock-item.js @@ -102,7 +102,7 @@ export default class DockItem extends React.Component { activate() { setTimeout(() => { - if (!this.dockItem) { return; } + if (!this.dockItem || this.didCloseItem) { return; } const pane = this.props.workspace.paneForItem(this.dockItem); if (pane) { From a0160d6ffd6bceca714a44d5feb508ae485ea3df Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 21 Apr 2017 11:36:05 -0400 Subject: [PATCH 15/28] Allow DockItem to handle pane activation instead of Workspace.open() --- lib/views/dock-item.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/views/dock-item.js b/lib/views/dock-item.js index d4791ea9eb..6959c96a17 100644 --- a/lib/views/dock-item.js +++ b/lib/views/dock-item.js @@ -70,7 +70,7 @@ export default class DockItem extends React.Component { this.activate(); } } else { - Promise.resolve(this.props.workspace.open(itemToAdd)) + Promise.resolve(this.props.workspace.open(itemToAdd, {activatePane: false})) .then(item => { this.dockItem = item; if (this.props.activate) { this.activate(); } From 3120e652f43675ac2c693da0da894e8c332e303d Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 21 Apr 2017 11:36:24 -0400 Subject: [PATCH 16/28] Prevent Pane activation if the Workspace is destroyed --- lib/views/dock-item.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/views/dock-item.js b/lib/views/dock-item.js index 6959c96a17..960720a74c 100644 --- a/lib/views/dock-item.js +++ b/lib/views/dock-item.js @@ -102,7 +102,9 @@ export default class DockItem extends React.Component { activate() { setTimeout(() => { - if (!this.dockItem || this.didCloseItem) { return; } + if (!this.dockItem || this.didCloseItem || this.props.workspace.isDestroyed()) { + return; + } const pane = this.props.workspace.paneForItem(this.dockItem); if (pane) { From 511b9ac74fc50ce2551c154fa1d010bfdfbdba80 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 21 Apr 2017 14:04:17 -0400 Subject: [PATCH 17/28] Default useLegacyPanels correctly --- test/github-package.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/github-package.test.js b/test/github-package.test.js index 254aef9ff1..b8ccd66db2 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -410,6 +410,7 @@ describe('GithubPackage', function() { beforeEach(function() { // Necessary since we skip activate() githubPackage.savedState = {}; + githubPackage.useLegacyPanels = !workspace.getLeftDock; }); it('prefers the context of the active pane item', async function() { From 8e84ef5f423ddbb41b930e29674c1cb621634348 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 21 Apr 2017 14:13:37 -0400 Subject: [PATCH 18/28] Make init and clone cancellable --- lib/models/repository-states/cloning.js | 6 +++++- lib/models/repository-states/initializing.js | 6 +++++- lib/models/repository-states/state.js | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/models/repository-states/cloning.js b/lib/models/repository-states/cloning.js index 8afe1c081a..368af07f45 100644 --- a/lib/models/repository-states/cloning.js +++ b/lib/models/repository-states/cloning.js @@ -13,7 +13,7 @@ export default class Cloning extends State { async start() { await mkdirs(this.workdir()); - await this.git().clone(this.remoteUrl, {recursive: true}); + await this.doClone(this.remoteUrl, {recursive: true}); await this.transitionTo('Loading'); } @@ -21,6 +21,10 @@ export default class Cloning extends State { showGitTabLoading() { return true; } + + directClone(remoteUrl, options) { + return this.git().clone(remoteUrl, options); + } } State.register(Cloning); diff --git a/lib/models/repository-states/initializing.js b/lib/models/repository-states/initializing.js index 385203d6ca..e96af6a2da 100644 --- a/lib/models/repository-states/initializing.js +++ b/lib/models/repository-states/initializing.js @@ -5,7 +5,7 @@ import State from './state'; */ export default class Initializing extends State { async start() { - await this.git().init(this.workdir()); + await this.doInit(this.workdir()); await this.transitionTo('Loading'); } @@ -13,6 +13,10 @@ export default class Initializing extends State { showGitTabLoading() { return true; } + + directInit(workdir) { + return this.git().init(workdir); + } } State.register(Initializing); diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index 53dfee839e..f4356e69dc 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -460,6 +460,14 @@ export default class State { return Promise.reject(new Error('Not a valid object name')); } + directInit() { + return Promise.resolve(); + } + + directClone(remoteUrl, options) { + return Promise.resolve(); + } + // Deferred operations // Direct raw git operations to the current state, even if the state has been changed. Use these methods within // start() methods. @@ -468,6 +476,14 @@ export default class State { return this.current().directIsGitRepository(); } + doInit(workdir) { + return this.current().directInit(); + } + + doClone(remoteUrl, options) { + return this.current().directClone(remoteUrl, options); + } + // Parse a DiscardHistory payload from the SHA recorded in config. async loadHistoryPayload() { const historySha = await this.current().directGetConfig('atomGithub.historySha'); From cc99c804e0580d360c5b091f9f90910bfd9a9b7c Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 21 Apr 2017 14:27:01 -0400 Subject: [PATCH 19/28] Typo fix --- lib/models/repository-states/state.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index f4356e69dc..9fb78f5b36 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -493,7 +493,7 @@ export default class State { let blob; try { - blob = await this.current().directBlobContents(historySha); + blob = await this.current().directGetBlobContents(historySha); } catch (e) { if (/Not a valid object name/.test(e.stdErr)) { return {}; From 582bba96f13d357313d5ae1a4b49efa3bd6139cc Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Mon, 24 Apr 2017 11:39:55 -0700 Subject: [PATCH 20/28] Update sinon.stub(a, b, c) to sinon.stub(a, b).callsFake(c) --- test/controllers/git-tab-controller.test.js | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/test/controllers/git-tab-controller.test.js b/test/controllers/git-tab-controller.test.js index 93b578dbb3..5f1138f7a6 100644 --- a/test/controllers/git-tab-controller.test.js +++ b/test/controllers/git-tab-controller.test.js @@ -140,7 +140,7 @@ describe('GitTabController', function() { it('shows an error notification when abortMerge() throws an EDIRTYSTAGED exception', async function() { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); - sinon.stub(repository, 'abortMerge', async () => { + sinon.stub(repository, 'abortMerge').callsFake(async () => { await Promise.resolve(); throw new AbortMergeError('EDIRTYSTAGED', 'a.txt'); }); @@ -215,7 +215,7 @@ describe('GitTabController', function() { it('shows an error notification when committing throws an ECONFLICT exception', async function() { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); - sinon.stub(repository, 'commit', async () => { + sinon.stub(repository, 'commit').callsFake(async () => { await Promise.resolve(); throw new CommitError('ECONFLICT'); }); @@ -232,7 +232,7 @@ describe('GitTabController', function() { it('sets amending to false', async function() { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); - sinon.stub(repository, 'commit', () => Promise.resolve()); + sinon.stub(repository, 'commit').callsFake(() => Promise.resolve()); const didChangeAmending = sinon.stub(); const controller = new GitTabController({ workspace, commandRegistry, repository, didChangeAmending, @@ -284,9 +284,9 @@ describe('GitTabController', function() { commitView = commitViewController.refs.commitView; focusElement = stagingView; - sinon.stub(commitView, 'focus', () => { focusElement = commitView; }); - sinon.stub(commitView, 'isFocused', () => focusElement === commitView); - sinon.stub(stagingView, 'focus', () => { focusElement = stagingView; }); + sinon.stub(commitView, 'focus').callsFake(() => { focusElement = commitView; }); + sinon.stub(commitView, 'isFocused').callsFake(() => focusElement === commitView); + sinon.stub(stagingView, 'focus').callsFake(() => { focusElement = stagingView; }); }; const assertSelected = paths => { @@ -479,11 +479,9 @@ describe('GitTabController', function() { assert.include(contentsWithMarkers, '>>>>>>>'); assert.include(contentsWithMarkers, '<<<<<<<'); - let choice; - sinon.stub(atom, 'confirm', () => choice); - + sinon.stub(atom, 'confirm'); // click Cancel - choice = 1; + atom.confirm.returns(1); await stagingView.dblclickOnItem({}, conflict1).selectionUpdatePromise; await assert.async.lengthOf(stagingView.props.mergeConflicts, 5); @@ -491,8 +489,8 @@ describe('GitTabController', function() { assert.isTrue(atom.confirm.calledOnce); // click Stage - choice = 0; atom.confirm.reset(); + atom.confirm.returns(0); await stagingView.dblclickOnItem({}, conflict1).selectionUpdatePromise; await assert.async.lengthOf(stagingView.props.mergeConflicts, 4); From c7ec1d4d67231814bb51e5339e35eda510f084b8 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Mon, 24 Apr 2017 11:40:06 -0700 Subject: [PATCH 21/28] Don't render RootController in GithubPackage tests --- test/github-package.test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/github-package.test.js b/test/github-package.test.js index b8ccd66db2..42ebfffb75 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -28,6 +28,10 @@ describe('GithubPackage', function() { workspace, project, commandRegistry, notificationManager, tooltips, styles, config, confirm, getLoadSettings, ); + sinon.stub(githubPackage, 'rerender').callsFake(callback => { + callback && setTimeout(callback); + }); + contextPool = githubPackage.getContextPool(); }); @@ -627,6 +631,10 @@ describe('GithubPackage', function() { }); describe('serialized state', function() { + beforeEach(function() { + githubPackage.rerender.restore(); + }); + function resolutionProgressFrom(pkg, workdir) { return pkg.getContextPool().getContext(workdir).getResolutionProgress(); } From d78008326c93defaf71926c14ca0b9e8a0ff385d Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Mon, 24 Apr 2017 13:42:52 -0700 Subject: [PATCH 22/28] Convert error log in GSOS child exit event to warning --- lib/git-shell-out-strategy.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/git-shell-out-strategy.js b/lib/git-shell-out-strategy.js index 98fc6d402c..b45c025c63 100644 --- a/lib/git-shell-out-strategy.js +++ b/lib/git-shell-out-strategy.js @@ -132,9 +132,8 @@ export default class GitShellOutStrategy { env, processCallback: child => { child.on('error', err => { - console.error('Error executing: ' + formattedArgs); - - console.error(err.stack); + console.warn('Error executing: ' + formattedArgs + ':'); + console.warn(err.stack); }); child.stdin.on('error', err => { console.error('Error writing to process: ' + formattedArgs); From 57af78f4ada29f7f9afae43591a5f737715d3897 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Mon, 24 Apr 2017 13:59:30 -0700 Subject: [PATCH 23/28] :fire: unnecessary try/catch --- lib/github-package.js | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index dce2f22d06..b5f473f435 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -184,14 +184,7 @@ export default class GithubPackage { if (!this.element) { this.element = document.createElement('div'); this.subscriptions.add(new Disposable(() => { - try { - ReactDom.unmountComponentAtNode(this.element); - } catch (err) { - /* eslint-disable no-console */ - console.error('Could not unmount RootController; DOM node did not contain a React app. Error was:'); - console.error(err); - /* eslint-enable no-console */ - } + ReactDom.unmountComponentAtNode(this.element); delete this.element; })); } From 0a5bf852a37ffc56b9c57ce928f957370ae41a5d Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Mon, 24 Apr 2017 14:30:20 -0700 Subject: [PATCH 24/28] Fixup tests --- test/controllers/root-controller.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index 05cdfa97cf..3719a03b46 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -56,7 +56,7 @@ describe('RootController', function() { const workdirPath = await cloneRepository('multiple-commits'); const repository = await buildRepository(workdirPath); - app = React.cloneElement(app, {repository}); + app = React.cloneElement(app, {repository, savedState: {gitTabActive: false, githubPanelActive: false}}); const wrapper = shallow(app); assert.isFalse(wrapper.find('Panel').prop('visible')); @@ -72,7 +72,7 @@ describe('RootController', function() { assert.isTrue(wrapper.find('Panel').prop('visible')); }); - it('is always visible when the lastRun prop is true', async function() { + it('is always visible when the firstRun prop is true', async function() { const workdirPath = await cloneRepository('multiple-commits'); const repository = await buildRepository(workdirPath); From c9474d16fa3da612f250879875f691ed3aa08515 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Mon, 24 Apr 2017 14:40:40 -0700 Subject: [PATCH 25/28] Make useLegacyPanels configurable in RootController tests --- test/controllers/root-controller.test.js | 49 ++++++++++++++---------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index 3719a03b46..9bf5947257 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -13,10 +13,19 @@ import ResolutionProgress from '../../lib/models/conflicts/resolution-progress'; import RootController from '../../lib/controllers/root-controller'; -describe('RootController', function() { +const useLegacyPanels = true; +describe.only(`RootController with useLegacyPanels set to ${useLegacyPanels}`, function() { let atomEnv, workspace, commandRegistry, notificationManager, tooltips, config, confirm, app; let workspaceElement; + function isGitPaneDisplayed(wrapper) { + if (workspace.getLeftDock && !useLegacyPanels) { + return wrapper.find('DockItem').exists(); + } else { + return wrapper.find('Panel').prop('visible'); + } + } + beforeEach(function() { atomEnv = global.buildAtomEnvironment(); workspace = atomEnv.workspace; @@ -41,7 +50,7 @@ describe('RootController', function() { confirm={confirm} repository={absentRepository} resolutionProgress={emptyResolutionProgress} - useLegacyPanels={true} + useLegacyPanels={useLegacyPanels} firstRun={false} /> ); @@ -59,7 +68,7 @@ describe('RootController', function() { app = React.cloneElement(app, {repository, savedState: {gitTabActive: false, githubPanelActive: false}}); const wrapper = shallow(app); - assert.isFalse(wrapper.find('Panel').prop('visible')); + assert.isFalse(isGitPaneDisplayed(wrapper)); }); it('is visible when the saved state indicates they were visible last run', async function() { @@ -69,7 +78,7 @@ describe('RootController', function() { app = React.cloneElement(app, {repository, savedState: {gitTabActive: true, githubPanelActive: true}}); const wrapper = shallow(app); - assert.isTrue(wrapper.find('Panel').prop('visible')); + assert.isTrue(isGitPaneDisplayed(wrapper)); }); it('is always visible when the firstRun prop is true', async function() { @@ -79,7 +88,7 @@ describe('RootController', function() { app = React.cloneElement(app, {repository, firstRun: true}); const wrapper = shallow(app); - assert.isTrue(wrapper.find('Panel').prop('visible')); + assert.isTrue(isGitPaneDisplayed(wrapper)); }); }); @@ -317,11 +326,11 @@ describe('RootController', function() { app = React.cloneElement(app, {repository}); const wrapper = shallow(app); - assert.isFalse(wrapper.find('Panel').prop('visible')); + assert.isFalse(isGitPaneDisplayed(wrapper)); wrapper.find('ObserveModelDecorator(StatusBarTileController)').prop('toggleGitTab')(); - assert.isTrue(wrapper.find('Panel').prop('visible')); + assert.isTrue(isGitPaneDisplayed(wrapper)); wrapper.find('ObserveModelDecorator(StatusBarTileController)').prop('toggleGitTab')(); - assert.isFalse(wrapper.find('Panel').prop('visible')); + assert.isFalse(isGitPaneDisplayed(wrapper)); }); }); @@ -333,11 +342,11 @@ describe('RootController', function() { app = React.cloneElement(app, {repository}); const wrapper = shallow(app); - assert.isFalse(wrapper.find('Panel').prop('visible')); + assert.isFalse(isGitPaneDisplayed(wrapper)); wrapper.instance().toggleGitTab(); - assert.isTrue(wrapper.find('Panel').prop('visible')); + assert.isTrue(isGitPaneDisplayed(wrapper)); wrapper.instance().toggleGitTab(); - assert.isFalse(wrapper.find('Panel').prop('visible')); + assert.isFalse(isGitPaneDisplayed(wrapper)); }); }); @@ -356,12 +365,12 @@ describe('RootController', function() { }); it('opens and focuses the Git panel when it is initially closed', function() { - assert.isFalse(wrapper.find('Panel').prop('visible')); + assert.isFalse(isGitPaneDisplayed(wrapper)); sinon.stub(wrapper.instance(), 'gitTabHasFocus').returns(false); wrapper.instance().toggleGitTabFocus(); - assert.isTrue(wrapper.find('Panel').prop('visible')); + assert.isTrue(isGitPaneDisplayed(wrapper)); assert.equal(wrapper.instance().focusGitTab.callCount, 1); assert.isFalse(workspace.getActivePane().activate.called); }); @@ -370,11 +379,11 @@ describe('RootController', function() { wrapper.instance().toggleGitTab(); sinon.stub(wrapper.instance(), 'gitTabHasFocus').returns(false); - assert.isTrue(wrapper.find('Panel').prop('visible')); + assert.isTrue(isGitPaneDisplayed(wrapper)); wrapper.instance().toggleGitTabFocus(); - assert.isTrue(wrapper.find('Panel').prop('visible')); + assert.isTrue(isGitPaneDisplayed(wrapper)); assert.equal(wrapper.instance().focusGitTab.callCount, 1); assert.isFalse(workspace.getActivePane().activate.called); }); @@ -383,11 +392,11 @@ describe('RootController', function() { wrapper.instance().toggleGitTab(); sinon.stub(wrapper.instance(), 'gitTabHasFocus').returns(true); - assert.isTrue(wrapper.find('Panel').prop('visible')); + assert.isTrue(isGitPaneDisplayed(wrapper)); wrapper.instance().toggleGitTabFocus(); - assert.isTrue(wrapper.find('Panel').prop('visible')); + assert.isTrue(isGitPaneDisplayed(wrapper)); assert.equal(wrapper.instance().focusGitTab.callCount, 0); assert.isTrue(workspace.getActivePane().activate.called); }); @@ -601,15 +610,15 @@ describe('RootController', function() { }); it('opens the Git panel when it is initially closed', async function() { - assert.isFalse(wrapper.find('Panel').prop('visible')); + assert.isFalse(isGitPaneDisplayed(wrapper)); assert.isTrue(await wrapper.instance().ensureGitTab()); }); it('does nothing when the Git panel is already open', async function() { wrapper.instance().toggleGitTab(); - assert.isTrue(wrapper.find('Panel').prop('visible')); + assert.isTrue(isGitPaneDisplayed(wrapper)); assert.isFalse(await wrapper.instance().ensureGitTab()); - assert.isTrue(wrapper.find('Panel').prop('visible')); + assert.isTrue(isGitPaneDisplayed(wrapper)); }); }); From 4b59e44af0a5bf5d7b97031283aec6679a495853 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Mon, 24 Apr 2017 14:42:27 -0700 Subject: [PATCH 26/28] Test RootController with non-legacy panels if dock API exists --- test/controllers/root-controller.test.js | 1797 +++++++++++----------- 1 file changed, 899 insertions(+), 898 deletions(-) diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index 9bf5947257..45d56f7920 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -13,1162 +13,1163 @@ import ResolutionProgress from '../../lib/models/conflicts/resolution-progress'; import RootController from '../../lib/controllers/root-controller'; -const useLegacyPanels = true; -describe.only(`RootController with useLegacyPanels set to ${useLegacyPanels}`, function() { - let atomEnv, workspace, commandRegistry, notificationManager, tooltips, config, confirm, app; - let workspaceElement; - - function isGitPaneDisplayed(wrapper) { - if (workspace.getLeftDock && !useLegacyPanels) { - return wrapper.find('DockItem').exists(); - } else { - return wrapper.find('Panel').prop('visible'); +[true, false].forEach(function(useLegacyPanels) { + describe(`RootController with useLegacyPanels set to ${useLegacyPanels}`, function() { + let atomEnv, workspace, commandRegistry, notificationManager, tooltips, config, confirm, app; + let workspaceElement; + + function isGitPaneDisplayed(wrapper) { + if (workspace.getLeftDock && !useLegacyPanels) { + return wrapper.find('DockItem').exists(); + } else { + return wrapper.find('Panel').prop('visible'); + } } - } - - beforeEach(function() { - atomEnv = global.buildAtomEnvironment(); - workspace = atomEnv.workspace; - commandRegistry = atomEnv.commands; - notificationManager = atomEnv.notifications; - tooltips = atomEnv.tooltips; - config = atomEnv.config; - - workspaceElement = atomEnv.views.getView(workspace); - - const absentRepository = Repository.absent(); - const emptyResolutionProgress = new ResolutionProgress(); - - confirm = sinon.stub(atomEnv, 'confirm'); - app = ( - - ); - }); - - afterEach(function() { - atomEnv.destroy(); - }); - describe('initial panel visibility', function() { - it('is not visible when the saved state indicates they were not visible last run', async function() { - const workdirPath = await cloneRepository('multiple-commits'); - const repository = await buildRepository(workdirPath); - - app = React.cloneElement(app, {repository, savedState: {gitTabActive: false, githubPanelActive: false}}); - const wrapper = shallow(app); + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + workspace = atomEnv.workspace; + commandRegistry = atomEnv.commands; + notificationManager = atomEnv.notifications; + tooltips = atomEnv.tooltips; + config = atomEnv.config; + + workspaceElement = atomEnv.views.getView(workspace); + + const absentRepository = Repository.absent(); + const emptyResolutionProgress = new ResolutionProgress(); + + confirm = sinon.stub(atomEnv, 'confirm'); + app = ( + + ); + }); - assert.isFalse(isGitPaneDisplayed(wrapper)); + afterEach(function() { + atomEnv.destroy(); }); - it('is visible when the saved state indicates they were visible last run', async function() { - const workdirPath = await cloneRepository('multiple-commits'); - const repository = await buildRepository(workdirPath); + describe('initial panel visibility', function() { + it('is not visible when the saved state indicates they were not visible last run', async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); - app = React.cloneElement(app, {repository, savedState: {gitTabActive: true, githubPanelActive: true}}); - const wrapper = shallow(app); + app = React.cloneElement(app, {repository, savedState: {gitTabActive: false, githubPanelActive: false}}); + const wrapper = shallow(app); - assert.isTrue(isGitPaneDisplayed(wrapper)); - }); + assert.isFalse(isGitPaneDisplayed(wrapper)); + }); - it('is always visible when the firstRun prop is true', async function() { - const workdirPath = await cloneRepository('multiple-commits'); - const repository = await buildRepository(workdirPath); + it('is visible when the saved state indicates they were visible last run', async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); - app = React.cloneElement(app, {repository, firstRun: true}); - const wrapper = shallow(app); + app = React.cloneElement(app, {repository, savedState: {gitTabActive: true, githubPanelActive: true}}); + const wrapper = shallow(app); - assert.isTrue(isGitPaneDisplayed(wrapper)); - }); - }); + assert.isTrue(isGitPaneDisplayed(wrapper)); + }); - describe('showMergeConflictFileForPath(relativeFilePath, {focus} = {})', function() { - it('opens the file as a pending pane item if it exists', async function() { - const workdirPath = await cloneRepository('merge-conflict'); - const repository = await buildRepository(workdirPath); - sinon.spy(workspace, 'open'); - app = React.cloneElement(app, {repository}); - const wrapper = shallow(app); - await wrapper.instance().showMergeConflictFileForPath('added-to-both.txt'); + it('is always visible when the firstRun prop is true', async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); + + app = React.cloneElement(app, {repository, firstRun: true}); + const wrapper = shallow(app); - assert.equal(workspace.open.callCount, 1); - assert.deepEqual(workspace.open.args[0], [path.join(workdirPath, 'added-to-both.txt'), {activatePane: false, pending: true}]); + assert.isTrue(isGitPaneDisplayed(wrapper)); + }); }); - describe('when the file doesn\'t exist', function() { - it('shows an info notification and does not open the file', async function() { + describe('showMergeConflictFileForPath(relativeFilePath, {focus} = {})', function() { + it('opens the file as a pending pane item if it exists', async function() { const workdirPath = await cloneRepository('merge-conflict'); const repository = await buildRepository(workdirPath); - fs.unlinkSync(path.join(workdirPath, 'added-to-both.txt')); - - sinon.spy(notificationManager, 'addInfo'); sinon.spy(workspace, 'open'); app = React.cloneElement(app, {repository}); const wrapper = shallow(app); - - assert.equal(notificationManager.getNotifications().length, 0); await wrapper.instance().showMergeConflictFileForPath('added-to-both.txt'); - assert.equal(workspace.open.callCount, 0); - assert.equal(notificationManager.addInfo.callCount, 1); - assert.deepEqual(notificationManager.addInfo.args[0], ['File has been deleted.']); - }); - }); - }); - describe('diveIntoMergeConflictFileForPath(relativeFilePath)', function() { - it('opens the file and focuses the pane', async function() { - const workdirPath = await cloneRepository('merge-conflict'); - const repository = await buildRepository(workdirPath); - sinon.spy(workspace, 'open'); - app = React.cloneElement(app, {repository}); - const wrapper = shallow(app); + assert.equal(workspace.open.callCount, 1); + assert.deepEqual(workspace.open.args[0], [path.join(workdirPath, 'added-to-both.txt'), {activatePane: false, pending: true}]); + }); - await wrapper.instance().diveIntoMergeConflictFileForPath('added-to-both.txt'); + describe('when the file doesn\'t exist', function() { + it('shows an info notification and does not open the file', async function() { + const workdirPath = await cloneRepository('merge-conflict'); + const repository = await buildRepository(workdirPath); + fs.unlinkSync(path.join(workdirPath, 'added-to-both.txt')); - assert.equal(workspace.open.callCount, 1); - assert.deepEqual(workspace.open.args[0], [path.join(workdirPath, 'added-to-both.txt'), {activatePane: true, pending: true}]); - }); - }); - - describe('rendering a FilePatch', function() { - it('renders the FilePatchController based on state', async function() { - const workdirPath = await cloneRepository('three-files'); - const repository = await buildRepository(workdirPath); - app = React.cloneElement(app, {repository}); - const wrapper = shallow(app); + sinon.spy(notificationManager, 'addInfo'); + sinon.spy(workspace, 'open'); + app = React.cloneElement(app, {repository}); + const wrapper = shallow(app); - wrapper.setState({ - filePath: null, - filePatch: null, - stagingStatus: null, + assert.equal(notificationManager.getNotifications().length, 0); + await wrapper.instance().showMergeConflictFileForPath('added-to-both.txt'); + assert.equal(workspace.open.callCount, 0); + assert.equal(notificationManager.addInfo.callCount, 1); + assert.deepEqual(notificationManager.addInfo.args[0], ['File has been deleted.']); + }); }); - assert.equal(wrapper.find('FilePatchController').length, 0); - - const state = { - filePath: 'path', - filePatch: {getPath: () => 'path.txt'}, - stagingStatus: 'unstaged', - }; - wrapper.setState(state); - assert.equal(wrapper.find('FilePatchController').length, 1); - assert.equal(wrapper.find('PaneItem').length, 1); - assert.equal(wrapper.find('PaneItem FilePatchController').length, 1); - assert.equal(wrapper.find('FilePatchController').prop('filePatch'), state.filePatch); - assert.equal(wrapper.find('FilePatchController').prop('stagingStatus'), state.stagingStatus); - assert.equal(wrapper.find('FilePatchController').prop('repository'), app.props.repository); }); - }); - describe('showFilePatchForPath(filePath, staged, {amending, activate})', function() { - describe('when a file is selected in the staging panel', function() { - it('sets appropriate state', async function() { - const workdirPath = await cloneRepository('three-files'); + describe('diveIntoMergeConflictFileForPath(relativeFilePath)', function() { + it('opens the file and focuses the pane', async function() { + const workdirPath = await cloneRepository('merge-conflict'); const repository = await buildRepository(workdirPath); - - fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'change', 'utf8'); - fs.writeFileSync(path.join(workdirPath, 'd.txt'), 'new-file', 'utf8'); - await repository.stageFiles(['d.txt']); - + sinon.spy(workspace, 'open'); app = React.cloneElement(app, {repository}); const wrapper = shallow(app); - await wrapper.instance().showFilePatchForPath('a.txt', 'unstaged'); - - assert.equal(wrapper.state('filePath'), 'a.txt'); - assert.equal(wrapper.state('filePatch').getPath(), 'a.txt'); - assert.equal(wrapper.state('stagingStatus'), 'unstaged'); - - await wrapper.instance().showFilePatchForPath('d.txt', 'staged'); - - assert.equal(wrapper.state('filePath'), 'd.txt'); - assert.equal(wrapper.state('filePatch').getPath(), 'd.txt'); - assert.equal(wrapper.state('stagingStatus'), 'staged'); + await wrapper.instance().diveIntoMergeConflictFileForPath('added-to-both.txt'); - wrapper.find('PaneItem').prop('onDidCloseItem')(); - assert.isNull(wrapper.state('filePath')); - assert.isNull(wrapper.state('filePatch')); - - const activate = sinon.stub(); - wrapper.instance().filePatchControllerPane = {activate}; - await wrapper.instance().showFilePatchForPath('d.txt', 'staged', {activate: true}); - assert.equal(activate.callCount, 1); + assert.equal(workspace.open.callCount, 1); + assert.deepEqual(workspace.open.args[0], [path.join(workdirPath, 'added-to-both.txt'), {activatePane: true, pending: true}]); }); }); - describe('when there is a change to the repo', function() { - it('calls onRepoRefresh', async function() { - const workdirPath = await cloneRepository('multiple-commits'); + describe('rendering a FilePatch', function() { + it('renders the FilePatchController based on state', async function() { + const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); - - fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change', 'utf8'); - app = React.cloneElement(app, {repository}); const wrapper = shallow(app); - sinon.spy(wrapper.instance(), 'onRepoRefresh'); - repository.refresh(); - await wrapper.instance().repositoryObserver.getLastModelDataRefreshPromise(); - assert.isTrue(wrapper.instance().onRepoRefresh.called); + wrapper.setState({ + filePath: null, + filePatch: null, + stagingStatus: null, + }); + assert.equal(wrapper.find('FilePatchController').length, 0); + + const state = { + filePath: 'path', + filePatch: {getPath: () => 'path.txt'}, + stagingStatus: 'unstaged', + }; + wrapper.setState(state); + assert.equal(wrapper.find('FilePatchController').length, 1); + assert.equal(wrapper.find('PaneItem').length, 1); + assert.equal(wrapper.find('PaneItem FilePatchController').length, 1); + assert.equal(wrapper.find('FilePatchController').prop('filePatch'), state.filePatch); + assert.equal(wrapper.find('FilePatchController').prop('stagingStatus'), state.stagingStatus); + assert.equal(wrapper.find('FilePatchController').prop('repository'), app.props.repository); }); }); - describe('#onRepoRefresh', function() { - it('sets the correct FilePatch as state', async function() { - const workdirPath = await cloneRepository('multiple-commits'); - const repository = await buildRepository(workdirPath); + describe('showFilePatchForPath(filePath, staged, {amending, activate})', function() { + describe('when a file is selected in the staging panel', function() { + it('sets appropriate state', async function() { + const workdirPath = await cloneRepository('three-files'); + const repository = await buildRepository(workdirPath); - fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change', 'utf8'); + fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'change', 'utf8'); + fs.writeFileSync(path.join(workdirPath, 'd.txt'), 'new-file', 'utf8'); + await repository.stageFiles(['d.txt']); - app = React.cloneElement(app, {repository}); - const wrapper = shallow(app); + app = React.cloneElement(app, {repository}); + const wrapper = shallow(app); - await wrapper.instance().showFilePatchForPath('file.txt', 'unstaged', {activate: true}); + await wrapper.instance().showFilePatchForPath('a.txt', 'unstaged'); - const originalFilePatch = wrapper.state('filePatch'); - assert.equal(wrapper.state('filePath'), 'file.txt'); - assert.equal(wrapper.state('filePatch').getPath(), 'file.txt'); - assert.equal(wrapper.state('stagingStatus'), 'unstaged'); + assert.equal(wrapper.state('filePath'), 'a.txt'); + assert.equal(wrapper.state('filePatch').getPath(), 'a.txt'); + assert.equal(wrapper.state('stagingStatus'), 'unstaged'); - fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change\nand again!', 'utf8'); - repository.refresh(); - await wrapper.instance().onRepoRefresh(); + await wrapper.instance().showFilePatchForPath('d.txt', 'staged'); - assert.equal(wrapper.state('filePath'), 'file.txt'); - assert.equal(wrapper.state('filePatch').getPath(), 'file.txt'); - assert.equal(wrapper.state('stagingStatus'), 'unstaged'); - assert.notEqual(originalFilePatch, wrapper.state('filePatch')); - }); - }); + assert.equal(wrapper.state('filePath'), 'd.txt'); + assert.equal(wrapper.state('filePatch').getPath(), 'd.txt'); + assert.equal(wrapper.state('stagingStatus'), 'staged'); - // https://github.com/atom/github/issues/505 - it('calls repository.getFilePatchForPath with amending: true only if staging status is staged', async () => { - const workdirPath = await cloneRepository('three-files'); - const repository = await buildRepository(workdirPath); + wrapper.find('PaneItem').prop('onDidCloseItem')(); + assert.isNull(wrapper.state('filePath')); + assert.isNull(wrapper.state('filePatch')); - app = React.cloneElement(app, {repository}); - const wrapper = shallow(app); + const activate = sinon.stub(); + wrapper.instance().filePatchControllerPane = {activate}; + await wrapper.instance().showFilePatchForPath('d.txt', 'staged', {activate: true}); + assert.equal(activate.callCount, 1); + }); + }); - sinon.stub(repository, 'getFilePatchForPath'); - await wrapper.instance().showFilePatchForPath('a.txt', 'unstaged', {amending: true}); - assert.equal(repository.getFilePatchForPath.callCount, 1); - assert.deepEqual(repository.getFilePatchForPath.args[0], ['a.txt', {staged: false, amending: false}]); - }); - }); + describe('when there is a change to the repo', function() { + it('calls onRepoRefresh', async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); - describe('diveIntoFilePatchForPath(filePath, staged, {amending, activate})', function() { - it('reveals and focuses the file patch', async function() { - const workdirPath = await cloneRepository('three-files'); - const repository = await buildRepository(workdirPath); + fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change', 'utf8'); - fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'change', 'utf8'); - repository.refresh(); + app = React.cloneElement(app, {repository}); + const wrapper = shallow(app); - app = React.cloneElement(app, {repository}); - const wrapper = shallow(app); + sinon.spy(wrapper.instance(), 'onRepoRefresh'); + repository.refresh(); + await wrapper.instance().repositoryObserver.getLastModelDataRefreshPromise(); + assert.isTrue(wrapper.instance().onRepoRefresh.called); + }); + }); - const focusFilePatch = sinon.spy(); - const activate = sinon.spy(); + describe('#onRepoRefresh', function() { + it('sets the correct FilePatch as state', async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); - const mockPane = { - getPaneItem: () => mockPane, - getElement: () => mockPane, - querySelector: () => mockPane, - focus: focusFilePatch, - activate, - }; - wrapper.instance().filePatchControllerPane = mockPane; + fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change', 'utf8'); - await wrapper.instance().diveIntoFilePatchForPath('a.txt', 'unstaged'); + app = React.cloneElement(app, {repository}); + const wrapper = shallow(app); - assert.equal(wrapper.state('filePath'), 'a.txt'); - assert.equal(wrapper.state('filePatch').getPath(), 'a.txt'); - assert.equal(wrapper.state('stagingStatus'), 'unstaged'); + await wrapper.instance().showFilePatchForPath('file.txt', 'unstaged', {activate: true}); - assert.isTrue(focusFilePatch.called); - assert.isTrue(activate.called); - }); - }); + const originalFilePatch = wrapper.state('filePatch'); + assert.equal(wrapper.state('filePath'), 'file.txt'); + assert.equal(wrapper.state('filePatch').getPath(), 'file.txt'); + assert.equal(wrapper.state('stagingStatus'), 'unstaged'); - describe('when amend mode is toggled in the staging panel while viewing a staged change', function() { - it('refetches the FilePatch with the amending flag toggled', async function() { - const workdirPath = await cloneRepository('multiple-commits'); - const repository = await buildRepository(workdirPath); + fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change\nand again!', 'utf8'); + repository.refresh(); + await wrapper.instance().onRepoRefresh(); - app = React.cloneElement(app, {repository}); - const wrapper = shallow(app); + assert.equal(wrapper.state('filePath'), 'file.txt'); + assert.equal(wrapper.state('filePatch').getPath(), 'file.txt'); + assert.equal(wrapper.state('stagingStatus'), 'unstaged'); + assert.notEqual(originalFilePatch, wrapper.state('filePatch')); + }); + }); - fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change', 'utf8'); - await wrapper.instance().showFilePatchForPath('file.txt', 'unstaged', {amending: false}); - const originalFilePatch = wrapper.state('filePatch'); - assert.isOk(originalFilePatch); + // https://github.com/atom/github/issues/505 + it('calls repository.getFilePatchForPath with amending: true only if staging status is staged', async () => { + const workdirPath = await cloneRepository('three-files'); + const repository = await buildRepository(workdirPath); - sinon.spy(wrapper.instance(), 'showFilePatchForPath'); - await wrapper.instance().didChangeAmending(true); - assert.isTrue(wrapper.instance().showFilePatchForPath.args[0][2].amending); + app = React.cloneElement(app, {repository}); + const wrapper = shallow(app); + + sinon.stub(repository, 'getFilePatchForPath'); + await wrapper.instance().showFilePatchForPath('a.txt', 'unstaged', {amending: true}); + assert.equal(repository.getFilePatchForPath.callCount, 1); + assert.deepEqual(repository.getFilePatchForPath.args[0], ['a.txt', {staged: false, amending: false}]); + }); }); - }); - describe('when the StatusBarTileController calls toggleGitTab', function() { - it('toggles the git panel', async function() { - const workdirPath = await cloneRepository('multiple-commits'); - const repository = await buildRepository(workdirPath); + describe('diveIntoFilePatchForPath(filePath, staged, {amending, activate})', function() { + it('reveals and focuses the file patch', async function() { + const workdirPath = await cloneRepository('three-files'); + const repository = await buildRepository(workdirPath); - app = React.cloneElement(app, {repository}); - const wrapper = shallow(app); + fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'change', 'utf8'); + repository.refresh(); - assert.isFalse(isGitPaneDisplayed(wrapper)); - wrapper.find('ObserveModelDecorator(StatusBarTileController)').prop('toggleGitTab')(); - assert.isTrue(isGitPaneDisplayed(wrapper)); - wrapper.find('ObserveModelDecorator(StatusBarTileController)').prop('toggleGitTab')(); - assert.isFalse(isGitPaneDisplayed(wrapper)); - }); - }); + app = React.cloneElement(app, {repository}); + const wrapper = shallow(app); - describe('toggleGitTab()', function() { - it('toggles the visibility of the Git panel', async function() { - const workdirPath = await cloneRepository('multiple-commits'); - const repository = await buildRepository(workdirPath); + const focusFilePatch = sinon.spy(); + const activate = sinon.spy(); - app = React.cloneElement(app, {repository}); - const wrapper = shallow(app); + const mockPane = { + getPaneItem: () => mockPane, + getElement: () => mockPane, + querySelector: () => mockPane, + focus: focusFilePatch, + activate, + }; + wrapper.instance().filePatchControllerPane = mockPane; + + await wrapper.instance().diveIntoFilePatchForPath('a.txt', 'unstaged'); - assert.isFalse(isGitPaneDisplayed(wrapper)); - wrapper.instance().toggleGitTab(); - assert.isTrue(isGitPaneDisplayed(wrapper)); - wrapper.instance().toggleGitTab(); - assert.isFalse(isGitPaneDisplayed(wrapper)); + assert.equal(wrapper.state('filePath'), 'a.txt'); + assert.equal(wrapper.state('filePatch').getPath(), 'a.txt'); + assert.equal(wrapper.state('stagingStatus'), 'unstaged'); + + assert.isTrue(focusFilePatch.called); + assert.isTrue(activate.called); + }); }); - }); - describe('toggleGitTabFocus()', function() { - let wrapper; + describe('when amend mode is toggled in the staging panel while viewing a staged change', function() { + it('refetches the FilePatch with the amending flag toggled', async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); - beforeEach(async function() { - const workdirPath = await cloneRepository('multiple-commits'); - const repository = await buildRepository(workdirPath); + app = React.cloneElement(app, {repository}); + const wrapper = shallow(app); - app = React.cloneElement(app, {repository}); - wrapper = shallow(app); + fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change', 'utf8'); + await wrapper.instance().showFilePatchForPath('file.txt', 'unstaged', {amending: false}); + const originalFilePatch = wrapper.state('filePatch'); + assert.isOk(originalFilePatch); - sinon.stub(wrapper.instance(), 'focusGitTab'); - sinon.spy(workspace.getActivePane(), 'activate'); + sinon.spy(wrapper.instance(), 'showFilePatchForPath'); + await wrapper.instance().didChangeAmending(true); + assert.isTrue(wrapper.instance().showFilePatchForPath.args[0][2].amending); + }); }); - it('opens and focuses the Git panel when it is initially closed', function() { - assert.isFalse(isGitPaneDisplayed(wrapper)); - sinon.stub(wrapper.instance(), 'gitTabHasFocus').returns(false); + describe('when the StatusBarTileController calls toggleGitTab', function() { + it('toggles the git panel', async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); - wrapper.instance().toggleGitTabFocus(); + app = React.cloneElement(app, {repository}); + const wrapper = shallow(app); - assert.isTrue(isGitPaneDisplayed(wrapper)); - assert.equal(wrapper.instance().focusGitTab.callCount, 1); - assert.isFalse(workspace.getActivePane().activate.called); + assert.isFalse(isGitPaneDisplayed(wrapper)); + wrapper.find('ObserveModelDecorator(StatusBarTileController)').prop('toggleGitTab')(); + assert.isTrue(isGitPaneDisplayed(wrapper)); + wrapper.find('ObserveModelDecorator(StatusBarTileController)').prop('toggleGitTab')(); + assert.isFalse(isGitPaneDisplayed(wrapper)); + }); }); - it('focuses the Git panel when it is already open, but blurred', function() { - wrapper.instance().toggleGitTab(); - sinon.stub(wrapper.instance(), 'gitTabHasFocus').returns(false); - - assert.isTrue(isGitPaneDisplayed(wrapper)); + describe('toggleGitTab()', function() { + it('toggles the visibility of the Git panel', async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); - wrapper.instance().toggleGitTabFocus(); + app = React.cloneElement(app, {repository}); + const wrapper = shallow(app); - assert.isTrue(isGitPaneDisplayed(wrapper)); - assert.equal(wrapper.instance().focusGitTab.callCount, 1); - assert.isFalse(workspace.getActivePane().activate.called); + assert.isFalse(isGitPaneDisplayed(wrapper)); + wrapper.instance().toggleGitTab(); + assert.isTrue(isGitPaneDisplayed(wrapper)); + wrapper.instance().toggleGitTab(); + assert.isFalse(isGitPaneDisplayed(wrapper)); + }); }); - it('blurs the Git panel when it is already open and focused', function() { - wrapper.instance().toggleGitTab(); - sinon.stub(wrapper.instance(), 'gitTabHasFocus').returns(true); + describe('toggleGitTabFocus()', function() { + let wrapper; - assert.isTrue(isGitPaneDisplayed(wrapper)); + beforeEach(async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); - wrapper.instance().toggleGitTabFocus(); + app = React.cloneElement(app, {repository}); + wrapper = shallow(app); - assert.isTrue(isGitPaneDisplayed(wrapper)); - assert.equal(wrapper.instance().focusGitTab.callCount, 0); - assert.isTrue(workspace.getActivePane().activate.called); - }); - }); + sinon.stub(wrapper.instance(), 'focusGitTab'); + sinon.spy(workspace.getActivePane(), 'activate'); + }); - describe('initializeRepo', function() { - let createRepositoryForProjectPath, resolveInit, rejectInit; + it('opens and focuses the Git panel when it is initially closed', function() { + assert.isFalse(isGitPaneDisplayed(wrapper)); + sinon.stub(wrapper.instance(), 'gitTabHasFocus').returns(false); - beforeEach(function() { - createRepositoryForProjectPath = sinon.stub().returns(new Promise((resolve, reject) => { - resolveInit = resolve; - rejectInit = reject; - })); - }); + wrapper.instance().toggleGitTabFocus(); - it('initializes the current working directory if there is one', function() { - app = React.cloneElement(app, { - createRepositoryForProjectPath, - activeWorkingDirectory: '/some/workdir', + assert.isTrue(isGitPaneDisplayed(wrapper)); + assert.equal(wrapper.instance().focusGitTab.callCount, 1); + assert.isFalse(workspace.getActivePane().activate.called); }); - const wrapper = shallow(app); - wrapper.instance().initializeRepo(); - resolveInit(); + it('focuses the Git panel when it is already open, but blurred', function() { + wrapper.instance().toggleGitTab(); + sinon.stub(wrapper.instance(), 'gitTabHasFocus').returns(false); - assert.isTrue(createRepositoryForProjectPath.calledWith('/some/workdir')); - }); + assert.isTrue(isGitPaneDisplayed(wrapper)); - it('renders the modal init panel', function() { - app = React.cloneElement(app, {createRepositoryForProjectPath}); - const wrapper = shallow(app); + wrapper.instance().toggleGitTabFocus(); - wrapper.instance().initializeRepo(); + assert.isTrue(isGitPaneDisplayed(wrapper)); + assert.equal(wrapper.instance().focusGitTab.callCount, 1); + assert.isFalse(workspace.getActivePane().activate.called); + }); - assert.lengthOf(wrapper.find('Panel').find({location: 'modal'}).find('InitDialog'), 1); - }); + it('blurs the Git panel when it is already open and focused', function() { + wrapper.instance().toggleGitTab(); + sinon.stub(wrapper.instance(), 'gitTabHasFocus').returns(true); - it('triggers the init callback on accept', function() { - app = React.cloneElement(app, {createRepositoryForProjectPath}); - const wrapper = shallow(app); + assert.isTrue(isGitPaneDisplayed(wrapper)); - wrapper.instance().initializeRepo(); - const dialog = wrapper.find('InitDialog'); - dialog.prop('didAccept')('/a/path'); - resolveInit(); + wrapper.instance().toggleGitTabFocus(); - assert.isTrue(createRepositoryForProjectPath.calledWith('/a/path')); + assert.isTrue(isGitPaneDisplayed(wrapper)); + assert.equal(wrapper.instance().focusGitTab.callCount, 0); + assert.isTrue(workspace.getActivePane().activate.called); + }); }); - it('dismisses the init callback on cancel', function() { - app = React.cloneElement(app, {createRepositoryForProjectPath}); - const wrapper = shallow(app); + describe('initializeRepo', function() { + let createRepositoryForProjectPath, resolveInit, rejectInit; - wrapper.instance().initializeRepo(); - const dialog = wrapper.find('InitDialog'); - dialog.prop('didCancel')(); + beforeEach(function() { + createRepositoryForProjectPath = sinon.stub().returns(new Promise((resolve, reject) => { + resolveInit = resolve; + rejectInit = reject; + })); + }); - assert.isFalse(wrapper.find('InitDialog').exists()); - }); + it('initializes the current working directory if there is one', function() { + app = React.cloneElement(app, { + createRepositoryForProjectPath, + activeWorkingDirectory: '/some/workdir', + }); + const wrapper = shallow(app); - it('creates a notification if the init fails', async function() { - sinon.stub(notificationManager, 'addError'); + wrapper.instance().initializeRepo(); + resolveInit(); - app = React.cloneElement(app, {createRepositoryForProjectPath}); - const wrapper = shallow(app); + assert.isTrue(createRepositoryForProjectPath.calledWith('/some/workdir')); + }); - wrapper.instance().initializeRepo(); - const dialog = wrapper.find('InitDialog'); - const acceptPromise = dialog.prop('didAccept')('/a/path'); - const err = new GitError('git init exited with status 1'); - err.stdErr = 'this is stderr'; - rejectInit(err); - await acceptPromise; - - assert.isFalse(wrapper.find('InitDialog').exists()); - assert.isTrue(notificationManager.addError.calledWith( - 'Unable to initialize git repository in /a/path', - sinon.match({detail: sinon.match(/this is stderr/)}), - )); - }); - }); + it('renders the modal init panel', function() { + app = React.cloneElement(app, {createRepositoryForProjectPath}); + const wrapper = shallow(app); - describe('github:clone', function() { - let wrapper, cloneRepositoryForProjectPath, resolveClone, rejectClone; + wrapper.instance().initializeRepo(); - beforeEach(function() { - cloneRepositoryForProjectPath = sinon.stub().returns(new Promise((resolve, reject) => { - resolveClone = resolve; - rejectClone = reject; - })); + assert.lengthOf(wrapper.find('Panel').find({location: 'modal'}).find('InitDialog'), 1); + }); - app = React.cloneElement(app, {cloneRepositoryForProjectPath}); - wrapper = shallow(app); - }); + it('triggers the init callback on accept', function() { + app = React.cloneElement(app, {createRepositoryForProjectPath}); + const wrapper = shallow(app); - it('renders the modal clone panel', function() { - commandRegistry.dispatch(workspaceElement, 'github:clone'); + wrapper.instance().initializeRepo(); + const dialog = wrapper.find('InitDialog'); + dialog.prop('didAccept')('/a/path'); + resolveInit(); - assert.lengthOf(wrapper.find('Panel').find({location: 'modal'}).find('CloneDialog'), 1); - }); + assert.isTrue(createRepositoryForProjectPath.calledWith('/a/path')); + }); - it('triggers the clone callback on accept', function() { - commandRegistry.dispatch(workspaceElement, 'github:clone'); + it('dismisses the init callback on cancel', function() { + app = React.cloneElement(app, {createRepositoryForProjectPath}); + const wrapper = shallow(app); - const dialog = wrapper.find('CloneDialog'); - dialog.prop('didAccept')('git@github.com:atom/github.git', '/home/me/github'); - resolveClone(); + wrapper.instance().initializeRepo(); + const dialog = wrapper.find('InitDialog'); + dialog.prop('didCancel')(); - assert.isTrue(cloneRepositoryForProjectPath.calledWith('git@github.com:atom/github.git', '/home/me/github')); - }); + assert.isFalse(wrapper.find('InitDialog').exists()); + }); - it('marks the clone dialog as in progress during clone', async function() { - commandRegistry.dispatch(workspaceElement, 'github:clone'); + it('creates a notification if the init fails', async function() { + sinon.stub(notificationManager, 'addError'); - const dialog = wrapper.find('CloneDialog'); - assert.isFalse(dialog.prop('inProgress')); + app = React.cloneElement(app, {createRepositoryForProjectPath}); + const wrapper = shallow(app); - const acceptPromise = dialog.prop('didAccept')('git@github.com:atom/github.git', '/home/me/github'); + wrapper.instance().initializeRepo(); + const dialog = wrapper.find('InitDialog'); + const acceptPromise = dialog.prop('didAccept')('/a/path'); + const err = new GitError('git init exited with status 1'); + err.stdErr = 'this is stderr'; + rejectInit(err); + await acceptPromise; + + assert.isFalse(wrapper.find('InitDialog').exists()); + assert.isTrue(notificationManager.addError.calledWith( + 'Unable to initialize git repository in /a/path', + sinon.match({detail: sinon.match(/this is stderr/)}), + )); + }); + }); - assert.isTrue(wrapper.find('CloneDialog').prop('inProgress')); + describe('github:clone', function() { + let wrapper, cloneRepositoryForProjectPath, resolveClone, rejectClone; - resolveClone(); - await acceptPromise; + beforeEach(function() { + cloneRepositoryForProjectPath = sinon.stub().returns(new Promise((resolve, reject) => { + resolveClone = resolve; + rejectClone = reject; + })); - assert.isFalse(wrapper.find('CloneDialog').exists()); - }); + app = React.cloneElement(app, {cloneRepositoryForProjectPath}); + wrapper = shallow(app); + }); - it('creates a notification if the clone fails', async function() { - sinon.stub(notificationManager, 'addError'); + it('renders the modal clone panel', function() { + commandRegistry.dispatch(workspaceElement, 'github:clone'); - commandRegistry.dispatch(workspaceElement, 'github:clone'); + assert.lengthOf(wrapper.find('Panel').find({location: 'modal'}).find('CloneDialog'), 1); + }); - const dialog = wrapper.find('CloneDialog'); - assert.isFalse(dialog.prop('inProgress')); + it('triggers the clone callback on accept', function() { + commandRegistry.dispatch(workspaceElement, 'github:clone'); - const acceptPromise = dialog.prop('didAccept')('git@github.com:nope/nope.git', '/home/me/github'); - const err = new GitError('git clone exited with status 1'); - err.stdErr = 'this is stderr'; - rejectClone(err); - await acceptPromise; + const dialog = wrapper.find('CloneDialog'); + dialog.prop('didAccept')('git@github.com:atom/github.git', '/home/me/github'); + resolveClone(); - assert.isFalse(wrapper.find('CloneDialog').exists()); - assert.isTrue(notificationManager.addError.calledWith( - 'Unable to clone git@github.com:nope/nope.git', - sinon.match({detail: sinon.match(/this is stderr/)}), - )); - }); + assert.isTrue(cloneRepositoryForProjectPath.calledWith('git@github.com:atom/github.git', '/home/me/github')); + }); - it('dismisses the clone panel on cancel', function() { - commandRegistry.dispatch(workspaceElement, 'github:clone'); + it('marks the clone dialog as in progress during clone', async function() { + commandRegistry.dispatch(workspaceElement, 'github:clone'); - const dialog = wrapper.find('CloneDialog'); - dialog.prop('didCancel')(); + const dialog = wrapper.find('CloneDialog'); + assert.isFalse(dialog.prop('inProgress')); - assert.lengthOf(wrapper.find('CloneDialog'), 0); - assert.isFalse(cloneRepositoryForProjectPath.called); - }); - }); + const acceptPromise = dialog.prop('didAccept')('git@github.com:atom/github.git', '/home/me/github'); - describe('promptForCredentials()', function() { - let wrapper; + assert.isTrue(wrapper.find('CloneDialog').prop('inProgress')); - beforeEach(function() { - wrapper = shallow(app); - }); + resolveClone(); + await acceptPromise; - it('renders the modal credentials dialog', function() { - wrapper.instance().promptForCredentials({ - prompt: 'Password plz', - includeUsername: true, + assert.isFalse(wrapper.find('CloneDialog').exists()); }); - const dialog = wrapper.find('Panel').find({location: 'modal'}).find('CredentialDialog'); - assert.isTrue(dialog.exists()); - assert.equal(dialog.prop('prompt'), 'Password plz'); - assert.isTrue(dialog.prop('includeUsername')); - }); + it('creates a notification if the clone fails', async function() { + sinon.stub(notificationManager, 'addError'); - it('resolves the promise with credentials on accept', async function() { - const credentialPromise = wrapper.instance().promptForCredentials({ - prompt: 'Speak "friend" and enter', - includeUsername: false, - }); + commandRegistry.dispatch(workspaceElement, 'github:clone'); - wrapper.find('CredentialDialog').prop('onSubmit')({password: 'friend'}); - assert.deepEqual(await credentialPromise, {password: 'friend'}); - assert.isFalse(wrapper.find('CredentialDialog').exists()); - }); + const dialog = wrapper.find('CloneDialog'); + assert.isFalse(dialog.prop('inProgress')); - it('rejects the promise on cancel', async function() { - const credentialPromise = wrapper.instance().promptForCredentials({ - prompt: 'Enter the square root of 1244313452349528345', - includeUsername: false, - }); + const acceptPromise = dialog.prop('didAccept')('git@github.com:nope/nope.git', '/home/me/github'); + const err = new GitError('git clone exited with status 1'); + err.stdErr = 'this is stderr'; + rejectClone(err); + await acceptPromise; - wrapper.find('CredentialDialog').prop('onCancel')(); - await assert.isRejected(credentialPromise); - assert.isFalse(wrapper.find('CredentialDialog').exists()); - }); - }); + assert.isFalse(wrapper.find('CloneDialog').exists()); + assert.isTrue(notificationManager.addError.calledWith( + 'Unable to clone git@github.com:nope/nope.git', + sinon.match({detail: sinon.match(/this is stderr/)}), + )); + }); - describe('ensureGitTab()', function() { - let wrapper; + it('dismisses the clone panel on cancel', function() { + commandRegistry.dispatch(workspaceElement, 'github:clone'); - beforeEach(async function() { - const workdirPath = await cloneRepository('multiple-commits'); - const repository = await buildRepository(workdirPath); + const dialog = wrapper.find('CloneDialog'); + dialog.prop('didCancel')(); - app = React.cloneElement(app, {repository}); - wrapper = shallow(app); + assert.lengthOf(wrapper.find('CloneDialog'), 0); + assert.isFalse(cloneRepositoryForProjectPath.called); + }); }); - it('opens the Git panel when it is initially closed', async function() { - assert.isFalse(isGitPaneDisplayed(wrapper)); - assert.isTrue(await wrapper.instance().ensureGitTab()); - }); + describe('promptForCredentials()', function() { + let wrapper; - it('does nothing when the Git panel is already open', async function() { - wrapper.instance().toggleGitTab(); - assert.isTrue(isGitPaneDisplayed(wrapper)); - assert.isFalse(await wrapper.instance().ensureGitTab()); - assert.isTrue(isGitPaneDisplayed(wrapper)); - }); - }); + beforeEach(function() { + wrapper = shallow(app); + }); - it('correctly updates state when switching repos', async function() { - const workdirPath1 = await cloneRepository('three-files'); - const repository1 = await buildRepository(workdirPath1); - const workdirPath2 = await cloneRepository('three-files'); - const repository2 = await buildRepository(workdirPath2); + it('renders the modal credentials dialog', function() { + wrapper.instance().promptForCredentials({ + prompt: 'Password plz', + includeUsername: true, + }); - app = React.cloneElement(app, {repository: repository1}); - const wrapper = shallow(app); + const dialog = wrapper.find('Panel').find({location: 'modal'}).find('CredentialDialog'); + assert.isTrue(dialog.exists()); + assert.equal(dialog.prop('prompt'), 'Password plz'); + assert.isTrue(dialog.prop('includeUsername')); + }); - assert.equal(wrapper.state('amending'), false); + it('resolves the promise with credentials on accept', async function() { + const credentialPromise = wrapper.instance().promptForCredentials({ + prompt: 'Speak "friend" and enter', + includeUsername: false, + }); - wrapper.setState({amending: true}); - wrapper.setProps({repository: repository2}); - assert.equal(wrapper.state('amending'), false); + wrapper.find('CredentialDialog').prop('onSubmit')({password: 'friend'}); + assert.deepEqual(await credentialPromise, {password: 'friend'}); + assert.isFalse(wrapper.find('CredentialDialog').exists()); + }); - wrapper.setProps({repository: repository1}); - assert.equal(wrapper.state('amending'), true); - }); + it('rejects the promise on cancel', async function() { + const credentialPromise = wrapper.instance().promptForCredentials({ + prompt: 'Enter the square root of 1244313452349528345', + includeUsername: false, + }); - describe('openFiles(filePaths)', () => { - it('calls workspace.open, passing pending:true if only one file path is passed', async () => { - const workdirPath = await cloneRepository('three-files'); - const repository = await buildRepository(workdirPath); + wrapper.find('CredentialDialog').prop('onCancel')(); + await assert.isRejected(credentialPromise); + assert.isFalse(wrapper.find('CredentialDialog').exists()); + }); + }); - fs.writeFileSync(path.join(workdirPath, 'file1.txt'), 'foo'); - fs.writeFileSync(path.join(workdirPath, 'file2.txt'), 'bar'); - fs.writeFileSync(path.join(workdirPath, 'file3.txt'), 'baz'); + describe('ensureGitTab()', function() { + let wrapper; - sinon.stub(workspace, 'open'); - app = React.cloneElement(app, {repository}); - const wrapper = shallow(app); - await wrapper.instance().openFiles(['file1.txt']); + beforeEach(async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); - assert.equal(workspace.open.callCount, 1); - assert.deepEqual(workspace.open.args[0], [path.join(repository.getWorkingDirectoryPath(), 'file1.txt'), {pending: true}]); + app = React.cloneElement(app, {repository}); + wrapper = shallow(app); + }); + + it('opens the Git panel when it is initially closed', async function() { + assert.isFalse(isGitPaneDisplayed(wrapper)); + assert.isTrue(await wrapper.instance().ensureGitTab()); + }); - workspace.open.reset(); - await wrapper.instance().openFiles(['file2.txt', 'file3.txt']); - assert.equal(workspace.open.callCount, 2); - assert.deepEqual(workspace.open.args[0], [path.join(repository.getWorkingDirectoryPath(), 'file2.txt'), {pending: false}]); - assert.deepEqual(workspace.open.args[1], [path.join(repository.getWorkingDirectoryPath(), 'file3.txt'), {pending: false}]); + it('does nothing when the Git panel is already open', async function() { + wrapper.instance().toggleGitTab(); + assert.isTrue(isGitPaneDisplayed(wrapper)); + assert.isFalse(await wrapper.instance().ensureGitTab()); + assert.isTrue(isGitPaneDisplayed(wrapper)); + }); }); - }); - describe('discarding and restoring changed lines', () => { - describe('discardLines(lines)', () => { - it('only discards lines if buffer is unmodified, otherwise notifies user', async () => { - const workdirPath = await cloneRepository('three-files'); - const repository = await buildRepository(workdirPath); + it('correctly updates state when switching repos', async function() { + const workdirPath1 = await cloneRepository('three-files'); + const repository1 = await buildRepository(workdirPath1); + const workdirPath2 = await cloneRepository('three-files'); + const repository2 = await buildRepository(workdirPath2); - fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'modification\n'); - const unstagedFilePatch = await repository.getFilePatchForPath('a.txt'); + app = React.cloneElement(app, {repository: repository1}); + const wrapper = shallow(app); - const editor = await workspace.open(path.join(workdirPath, 'a.txt')); + assert.equal(wrapper.state('amending'), false); - app = React.cloneElement(app, {repository}); - const wrapper = shallow(app); - const state = { - filePath: 'a.txt', - filePatch: unstagedFilePatch, - stagingStatus: 'unstaged', - }; - wrapper.setState(state); + wrapper.setState({amending: true}); + wrapper.setProps({repository: repository2}); + assert.equal(wrapper.state('amending'), false); - sinon.stub(repository, 'applyPatchToWorkdir'); - sinon.stub(notificationManager, 'addError'); - // unmodified buffer - const hunkLines = unstagedFilePatch.getHunks()[0].getLines(); - await wrapper.instance().discardLines(new Set([hunkLines[0]])); - assert.isTrue(repository.applyPatchToWorkdir.calledOnce); - assert.isFalse(notificationManager.addError.called); - - // modified buffer - repository.applyPatchToWorkdir.reset(); - editor.setText('modify contents'); - await wrapper.instance().discardLines(new Set(unstagedFilePatch.getHunks()[0].getLines())); - assert.isFalse(repository.applyPatchToWorkdir.called); - const notificationArgs = notificationManager.addError.args[0]; - assert.equal(notificationArgs[0], 'Cannot discard lines.'); - assert.match(notificationArgs[1].description, /You have unsaved changes in/); - }); + wrapper.setProps({repository: repository1}); + assert.equal(wrapper.state('amending'), true); }); - describe('discardWorkDirChangesForPaths(filePaths)', () => { - it('only discards changes in files if all buffers are unmodified, otherwise notifies user', async () => { + describe('openFiles(filePaths)', () => { + it('calls workspace.open, passing pending:true if only one file path is passed', async () => { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); - fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'do\n'); - fs.writeFileSync(path.join(workdirPath, 'b.txt'), 'ray\n'); - fs.writeFileSync(path.join(workdirPath, 'c.txt'), 'me\n'); - - const editor = await workspace.open(path.join(workdirPath, 'a.txt')); + fs.writeFileSync(path.join(workdirPath, 'file1.txt'), 'foo'); + fs.writeFileSync(path.join(workdirPath, 'file2.txt'), 'bar'); + fs.writeFileSync(path.join(workdirPath, 'file3.txt'), 'baz'); + sinon.stub(workspace, 'open'); app = React.cloneElement(app, {repository}); const wrapper = shallow(app); + await wrapper.instance().openFiles(['file1.txt']); - sinon.stub(repository, 'discardWorkDirChangesForPaths'); - sinon.stub(notificationManager, 'addError'); - // unmodified buffer - await wrapper.instance().discardWorkDirChangesForPaths(['a.txt', 'b.txt', 'c.txt']); - assert.isTrue(repository.discardWorkDirChangesForPaths.calledOnce); - assert.isFalse(notificationManager.addError.called); - - // modified buffer - repository.discardWorkDirChangesForPaths.reset(); - editor.setText('modify contents'); - await wrapper.instance().discardWorkDirChangesForPaths(['a.txt', 'b.txt', 'c.txt']); - assert.isFalse(repository.discardWorkDirChangesForPaths.called); - const notificationArgs = notificationManager.addError.args[0]; - assert.equal(notificationArgs[0], 'Cannot discard changes in selected files.'); - assert.match(notificationArgs[1].description, /You have unsaved changes in.*a\.txt/); + assert.equal(workspace.open.callCount, 1); + assert.deepEqual(workspace.open.args[0], [path.join(repository.getWorkingDirectoryPath(), 'file1.txt'), {pending: true}]); + + workspace.open.reset(); + await wrapper.instance().openFiles(['file2.txt', 'file3.txt']); + assert.equal(workspace.open.callCount, 2); + assert.deepEqual(workspace.open.args[0], [path.join(repository.getWorkingDirectoryPath(), 'file2.txt'), {pending: false}]); + assert.deepEqual(workspace.open.args[1], [path.join(repository.getWorkingDirectoryPath(), 'file3.txt'), {pending: false}]); }); }); - describe('undoLastDiscard(partialDiscardFilePath)', () => { - describe('when partialDiscardFilePath is not null', () => { - let unstagedFilePatch, repository, absFilePath, wrapper; - beforeEach(async () => { - const workdirPath = await cloneRepository('multi-line-file'); - repository = await buildRepository(workdirPath); + describe('discarding and restoring changed lines', () => { + describe('discardLines(lines)', () => { + it('only discards lines if buffer is unmodified, otherwise notifies user', async () => { + const workdirPath = await cloneRepository('three-files'); + const repository = await buildRepository(workdirPath); - absFilePath = path.join(workdirPath, 'sample.js'); - fs.writeFileSync(absFilePath, 'foo\nbar\nbaz\n'); - unstagedFilePatch = await repository.getFilePatchForPath('sample.js'); + fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'modification\n'); + const unstagedFilePatch = await repository.getFilePatchForPath('a.txt'); + + const editor = await workspace.open(path.join(workdirPath, 'a.txt')); app = React.cloneElement(app, {repository}); - wrapper = shallow(app); - wrapper.setState({ - filePath: 'sample.js', + const wrapper = shallow(app); + const state = { + filePath: 'a.txt', filePatch: unstagedFilePatch, stagingStatus: 'unstaged', - }); - }); + }; + wrapper.setState(state); - it('reverses last discard for file path', async () => { - const contents1 = fs.readFileSync(absFilePath, 'utf8'); - await wrapper.instance().discardLines(new Set(unstagedFilePatch.getHunks()[0].getLines().slice(0, 2))); - const contents2 = fs.readFileSync(absFilePath, 'utf8'); - assert.notEqual(contents1, contents2); - await repository.refresh(); - - unstagedFilePatch = await repository.getFilePatchForPath('sample.js'); - wrapper.setState({filePatch: unstagedFilePatch}); - await wrapper.instance().discardLines(new Set(unstagedFilePatch.getHunks()[0].getLines().slice(2, 4))); - const contents3 = fs.readFileSync(absFilePath, 'utf8'); - assert.notEqual(contents2, contents3); - - await wrapper.instance().undoLastDiscard('sample.js'); - await assert.async.equal(fs.readFileSync(absFilePath, 'utf8'), contents2); - await wrapper.instance().undoLastDiscard('sample.js'); - await assert.async.equal(fs.readFileSync(absFilePath, 'utf8'), contents1); + sinon.stub(repository, 'applyPatchToWorkdir'); + sinon.stub(notificationManager, 'addError'); + // unmodified buffer + const hunkLines = unstagedFilePatch.getHunks()[0].getLines(); + await wrapper.instance().discardLines(new Set([hunkLines[0]])); + assert.isTrue(repository.applyPatchToWorkdir.calledOnce); + assert.isFalse(notificationManager.addError.called); + + // modified buffer + repository.applyPatchToWorkdir.reset(); + editor.setText('modify contents'); + await wrapper.instance().discardLines(new Set(unstagedFilePatch.getHunks()[0].getLines())); + assert.isFalse(repository.applyPatchToWorkdir.called); + const notificationArgs = notificationManager.addError.args[0]; + assert.equal(notificationArgs[0], 'Cannot discard lines.'); + assert.match(notificationArgs[1].description, /You have unsaved changes in/); }); + }); - it('does not undo if buffer is modified', async () => { - const contents1 = fs.readFileSync(absFilePath, 'utf8'); - await wrapper.instance().discardLines(new Set(unstagedFilePatch.getHunks()[0].getLines().slice(0, 2))); - const contents2 = fs.readFileSync(absFilePath, 'utf8'); - assert.notEqual(contents1, contents2); + describe('discardWorkDirChangesForPaths(filePaths)', () => { + it('only discards changes in files if all buffers are unmodified, otherwise notifies user', async () => { + const workdirPath = await cloneRepository('three-files'); + const repository = await buildRepository(workdirPath); - // modify buffer - const editor = await workspace.open(absFilePath); - editor.getBuffer().append('new line'); + fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'do\n'); + fs.writeFileSync(path.join(workdirPath, 'b.txt'), 'ray\n'); + fs.writeFileSync(path.join(workdirPath, 'c.txt'), 'me\n'); - const expandBlobToFile = sinon.spy(repository, 'expandBlobToFile'); - sinon.stub(notificationManager, 'addError'); + const editor = await workspace.open(path.join(workdirPath, 'a.txt')); + + app = React.cloneElement(app, {repository}); + const wrapper = shallow(app); - await repository.refresh(); - unstagedFilePatch = await repository.getFilePatchForPath('sample.js'); - wrapper.setState({filePatch: unstagedFilePatch}); - await wrapper.instance().undoLastDiscard('sample.js'); + sinon.stub(repository, 'discardWorkDirChangesForPaths'); + sinon.stub(notificationManager, 'addError'); + // unmodified buffer + await wrapper.instance().discardWorkDirChangesForPaths(['a.txt', 'b.txt', 'c.txt']); + assert.isTrue(repository.discardWorkDirChangesForPaths.calledOnce); + assert.isFalse(notificationManager.addError.called); + + // modified buffer + repository.discardWorkDirChangesForPaths.reset(); + editor.setText('modify contents'); + await wrapper.instance().discardWorkDirChangesForPaths(['a.txt', 'b.txt', 'c.txt']); + assert.isFalse(repository.discardWorkDirChangesForPaths.called); const notificationArgs = notificationManager.addError.args[0]; - assert.equal(notificationArgs[0], 'Cannot undo last discard.'); - assert.match(notificationArgs[1].description, /You have unsaved changes./); - assert.isFalse(expandBlobToFile.called); + assert.equal(notificationArgs[0], 'Cannot discard changes in selected files.'); + assert.match(notificationArgs[1].description, /You have unsaved changes in.*a\.txt/); }); + }); - describe('when file content has changed since last discard', () => { - it('successfully undoes discard if changes do not conflict', async () => { + describe('undoLastDiscard(partialDiscardFilePath)', () => { + describe('when partialDiscardFilePath is not null', () => { + let unstagedFilePatch, repository, absFilePath, wrapper; + beforeEach(async () => { + const workdirPath = await cloneRepository('multi-line-file'); + repository = await buildRepository(workdirPath); + + absFilePath = path.join(workdirPath, 'sample.js'); + fs.writeFileSync(absFilePath, 'foo\nbar\nbaz\n'); + unstagedFilePatch = await repository.getFilePatchForPath('sample.js'); + + app = React.cloneElement(app, {repository}); + wrapper = shallow(app); + wrapper.setState({ + filePath: 'sample.js', + filePatch: unstagedFilePatch, + stagingStatus: 'unstaged', + }); + }); + + it('reverses last discard for file path', async () => { const contents1 = fs.readFileSync(absFilePath, 'utf8'); await wrapper.instance().discardLines(new Set(unstagedFilePatch.getHunks()[0].getLines().slice(0, 2))); const contents2 = fs.readFileSync(absFilePath, 'utf8'); assert.notEqual(contents1, contents2); - - // change file contents on disk in non-conflicting way - const change = '\nchange file contents'; - fs.writeFileSync(absFilePath, contents2 + change); - await repository.refresh(); + unstagedFilePatch = await repository.getFilePatchForPath('sample.js'); wrapper.setState({filePatch: unstagedFilePatch}); - await wrapper.instance().undoLastDiscard('sample.js'); + await wrapper.instance().discardLines(new Set(unstagedFilePatch.getHunks()[0].getLines().slice(2, 4))); + const contents3 = fs.readFileSync(absFilePath, 'utf8'); + assert.notEqual(contents2, contents3); - await assert.async.equal(fs.readFileSync(absFilePath, 'utf8'), contents1 + change); + await wrapper.instance().undoLastDiscard('sample.js'); + await assert.async.equal(fs.readFileSync(absFilePath, 'utf8'), contents2); + await wrapper.instance().undoLastDiscard('sample.js'); + await assert.async.equal(fs.readFileSync(absFilePath, 'utf8'), contents1); }); - it('prompts user to continue if conflicts arise and proceeds based on user input', async () => { - await repository.git.exec(['config', 'merge.conflictstyle', 'diff3']); - + it('does not undo if buffer is modified', async () => { const contents1 = fs.readFileSync(absFilePath, 'utf8'); await wrapper.instance().discardLines(new Set(unstagedFilePatch.getHunks()[0].getLines().slice(0, 2))); const contents2 = fs.readFileSync(absFilePath, 'utf8'); assert.notEqual(contents1, contents2); - // change file contents on disk in a conflicting way - const change = '\nchange file contents'; - fs.writeFileSync(absFilePath, change + contents2); + // modify buffer + const editor = await workspace.open(absFilePath); + editor.getBuffer().append('new line'); + + const expandBlobToFile = sinon.spy(repository, 'expandBlobToFile'); + sinon.stub(notificationManager, 'addError'); await repository.refresh(); unstagedFilePatch = await repository.getFilePatchForPath('sample.js'); wrapper.setState({filePatch: unstagedFilePatch}); - - // click 'Cancel' - confirm.returns(2); await wrapper.instance().undoLastDiscard('sample.js'); - assert.equal(confirm.callCount, 1); - const confirmArg = confirm.args[0][0]; - assert.match(confirmArg.message, /Undoing will result in conflicts/); - await assert.async.equal(fs.readFileSync(absFilePath, 'utf8'), change + contents2); - - // click 'Open in new buffer' - confirm.returns(1); - await wrapper.instance().undoLastDiscard('sample.js'); - assert.equal(confirm.callCount, 2); - const activeEditor = workspace.getActiveTextEditor(); - assert.match(activeEditor.getFileName(), /sample.js-/); - assert.isTrue(activeEditor.getText().includes('<<<<<<<')); - assert.isTrue(activeEditor.getText().includes('>>>>>>>')); - - // click 'Proceed and resolve conflicts' - confirm.returns(0); - await wrapper.instance().undoLastDiscard('sample.js'); - assert.equal(confirm.callCount, 3); - await assert.async.isTrue(fs.readFileSync(absFilePath, 'utf8').includes('<<<<<<<')); - await assert.async.isTrue(fs.readFileSync(absFilePath, 'utf8').includes('>>>>>>>')); - - // index is updated accordingly - const diff = await repository.git.exec(['diff', '--', 'sample.js']); - assert.equal(diff, dedent` - diff --cc sample.js - index 5c084c0,86e041d..0000000 - --- a/sample.js - +++ b/sample.js - @@@ -1,6 -1,3 +1,12 @@@ - ++<<<<<<< current - + - +change file contentsvar quicksort = function () { - + var sort = function(items) { - ++||||||| after discard - ++var quicksort = function () { - ++ var sort = function(items) { - ++======= - ++>>>>>>> before discard - foo - bar - baz - - `); + const notificationArgs = notificationManager.addError.args[0]; + assert.equal(notificationArgs[0], 'Cannot undo last discard.'); + assert.match(notificationArgs[1].description, /You have unsaved changes./); + assert.isFalse(expandBlobToFile.called); }); - }); - it('clears the discard history if the last blob is no longer valid', async () => { - // this would occur in the case of garbage collection cleaning out the blob - await wrapper.instance().discardLines(new Set(unstagedFilePatch.getHunks()[0].getLines().slice(0, 2))); - await repository.refresh(); - unstagedFilePatch = await repository.getFilePatchForPath('sample.js'); - wrapper.setState({filePatch: unstagedFilePatch}); - const {beforeSha} = await wrapper.instance().discardLines(new Set(unstagedFilePatch.getHunks()[0].getLines().slice(2, 4))); + describe('when file content has changed since last discard', () => { + it('successfully undoes discard if changes do not conflict', async () => { + const contents1 = fs.readFileSync(absFilePath, 'utf8'); + await wrapper.instance().discardLines(new Set(unstagedFilePatch.getHunks()[0].getLines().slice(0, 2))); + const contents2 = fs.readFileSync(absFilePath, 'utf8'); + assert.notEqual(contents1, contents2); - // remove blob from git object store - fs.unlinkSync(path.join(repository.getGitDirectoryPath(), 'objects', beforeSha.slice(0, 2), beforeSha.slice(2))); + // change file contents on disk in non-conflicting way + const change = '\nchange file contents'; + fs.writeFileSync(absFilePath, contents2 + change); - sinon.stub(notificationManager, 'addError'); - assert.equal(repository.getDiscardHistory('sample.js').length, 2); - await wrapper.instance().undoLastDiscard('sample.js'); - const notificationArgs = notificationManager.addError.args[0]; - assert.equal(notificationArgs[0], 'Discard history has expired.'); - assert.match(notificationArgs[1].description, /Stale discard history has been deleted./); - assert.equal(repository.getDiscardHistory('sample.js').length, 0); - }); - }); - - describe('when partialDiscardFilePath is falsey', () => { - let repository, workdirPath, wrapper, pathA, pathB, pathDeleted, pathAdded, getFileContents; - beforeEach(async () => { - workdirPath = await cloneRepository('three-files'); - repository = await buildRepository(workdirPath); - - getFileContents = filePath => { - try { - return fs.readFileSync(filePath, 'utf8'); - } catch (e) { - if (e.code === 'ENOENT') { - return null; - } else { - throw e; - } - } - }; + await repository.refresh(); + unstagedFilePatch = await repository.getFilePatchForPath('sample.js'); + wrapper.setState({filePatch: unstagedFilePatch}); + await wrapper.instance().undoLastDiscard('sample.js'); - pathA = path.join(workdirPath, 'a.txt'); - pathB = path.join(workdirPath, 'subdir-1', 'b.txt'); - pathDeleted = path.join(workdirPath, 'c.txt'); - pathAdded = path.join(workdirPath, 'added-file.txt'); - fs.writeFileSync(pathA, [1, 2, 3, 4, 5, 6, 7, 8, 9].join('\n')); - fs.writeFileSync(pathB, ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'].join('\n')); - fs.writeFileSync(pathAdded, ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')'].join('\n')); - fs.unlinkSync(pathDeleted); - - app = React.cloneElement(app, {repository}); - wrapper = shallow(app); - }); - - it('reverses last discard if there are no conflicts', async () => { - const contents1 = { - pathA: getFileContents(pathA), - pathB: getFileContents(pathB), - pathDeleted: getFileContents(pathDeleted), - pathAdded: getFileContents(pathAdded), - }; - await wrapper.instance().discardWorkDirChangesForPaths(['a.txt', 'subdir-1/b.txt']); - const contents2 = { - pathA: getFileContents(pathA), - pathB: getFileContents(pathB), - pathDeleted: getFileContents(pathDeleted), - pathAdded: getFileContents(pathAdded), - }; - assert.notDeepEqual(contents1, contents2); - - await wrapper.instance().discardWorkDirChangesForPaths(['c.txt', 'added-file.txt']); - const contents3 = { - pathA: getFileContents(pathA), - pathB: getFileContents(pathB), - pathDeleted: getFileContents(pathDeleted), - pathAdded: getFileContents(pathAdded), - }; - assert.notDeepEqual(contents2, contents3); - - await wrapper.instance().undoLastDiscard(); - await assert.async.deepEqual({ - pathA: getFileContents(pathA), - pathB: getFileContents(pathB), - pathDeleted: getFileContents(pathDeleted), - pathAdded: getFileContents(pathAdded), - }, contents2); - await wrapper.instance().undoLastDiscard(); - await assert.async.deepEqual({ - pathA: getFileContents(pathA), - pathB: getFileContents(pathB), - pathDeleted: getFileContents(pathDeleted), - pathAdded: getFileContents(pathAdded), - }, contents1); - }); + await assert.async.equal(fs.readFileSync(absFilePath, 'utf8'), contents1 + change); + }); - it('does not undo if buffer is modified', async () => { - await wrapper.instance().discardWorkDirChangesForPaths(['a.txt', 'subdir-1/b.txt', 'c.txt', 'added-file.txt']); + it('prompts user to continue if conflicts arise and proceeds based on user input', async () => { + await repository.git.exec(['config', 'merge.conflictstyle', 'diff3']); + + const contents1 = fs.readFileSync(absFilePath, 'utf8'); + await wrapper.instance().discardLines(new Set(unstagedFilePatch.getHunks()[0].getLines().slice(0, 2))); + const contents2 = fs.readFileSync(absFilePath, 'utf8'); + assert.notEqual(contents1, contents2); + + // change file contents on disk in a conflicting way + const change = '\nchange file contents'; + fs.writeFileSync(absFilePath, change + contents2); + + await repository.refresh(); + unstagedFilePatch = await repository.getFilePatchForPath('sample.js'); + wrapper.setState({filePatch: unstagedFilePatch}); + + // click 'Cancel' + confirm.returns(2); + await wrapper.instance().undoLastDiscard('sample.js'); + assert.equal(confirm.callCount, 1); + const confirmArg = confirm.args[0][0]; + assert.match(confirmArg.message, /Undoing will result in conflicts/); + await assert.async.equal(fs.readFileSync(absFilePath, 'utf8'), change + contents2); + + // click 'Open in new buffer' + confirm.returns(1); + await wrapper.instance().undoLastDiscard('sample.js'); + assert.equal(confirm.callCount, 2); + const activeEditor = workspace.getActiveTextEditor(); + assert.match(activeEditor.getFileName(), /sample.js-/); + assert.isTrue(activeEditor.getText().includes('<<<<<<<')); + assert.isTrue(activeEditor.getText().includes('>>>>>>>')); + + // click 'Proceed and resolve conflicts' + confirm.returns(0); + await wrapper.instance().undoLastDiscard('sample.js'); + assert.equal(confirm.callCount, 3); + await assert.async.isTrue(fs.readFileSync(absFilePath, 'utf8').includes('<<<<<<<')); + await assert.async.isTrue(fs.readFileSync(absFilePath, 'utf8').includes('>>>>>>>')); + + // index is updated accordingly + const diff = await repository.git.exec(['diff', '--', 'sample.js']); + assert.equal(diff, dedent` + diff --cc sample.js + index 5c084c0,86e041d..0000000 + --- a/sample.js + +++ b/sample.js + @@@ -1,6 -1,3 +1,12 @@@ + ++<<<<<<< current + + + +change file contentsvar quicksort = function () { + + var sort = function(items) { + ++||||||| after discard + ++var quicksort = function () { + ++ var sort = function(items) { + ++======= + ++>>>>>>> before discard + foo + bar + baz + + `); + }); + }); - // modify buffers - (await workspace.open(pathA)).getBuffer().append('stuff'); - (await workspace.open(pathB)).getBuffer().append('other stuff'); - (await workspace.open(pathDeleted)).getBuffer().append('this stuff'); - (await workspace.open(pathAdded)).getBuffer().append('that stuff'); + it('clears the discard history if the last blob is no longer valid', async () => { + // this would occur in the case of garbage collection cleaning out the blob + await wrapper.instance().discardLines(new Set(unstagedFilePatch.getHunks()[0].getLines().slice(0, 2))); + await repository.refresh(); + unstagedFilePatch = await repository.getFilePatchForPath('sample.js'); + wrapper.setState({filePatch: unstagedFilePatch}); + const {beforeSha} = await wrapper.instance().discardLines(new Set(unstagedFilePatch.getHunks()[0].getLines().slice(2, 4))); - const expandBlobToFile = sinon.spy(repository, 'expandBlobToFile'); - sinon.stub(notificationManager, 'addError'); + // remove blob from git object store + fs.unlinkSync(path.join(repository.getGitDirectoryPath(), 'objects', beforeSha.slice(0, 2), beforeSha.slice(2))); - await wrapper.instance().undoLastDiscard(); - const notificationArgs = notificationManager.addError.args[0]; - assert.equal(notificationArgs[0], 'Cannot undo last discard.'); - assert.match(notificationArgs[1].description, /You have unsaved changes./); - assert.match(notificationArgs[1].description, /a.txt/); - assert.match(notificationArgs[1].description, /subdir-1\/b.txt/); - assert.match(notificationArgs[1].description, /c.txt/); - assert.match(notificationArgs[1].description, /added-file.txt/); - assert.isFalse(expandBlobToFile.called); + sinon.stub(notificationManager, 'addError'); + assert.equal(repository.getDiscardHistory('sample.js').length, 2); + await wrapper.instance().undoLastDiscard('sample.js'); + const notificationArgs = notificationManager.addError.args[0]; + assert.equal(notificationArgs[0], 'Discard history has expired.'); + assert.match(notificationArgs[1].description, /Stale discard history has been deleted./); + assert.equal(repository.getDiscardHistory('sample.js').length, 0); + }); }); - describe('when file content has changed since last discard', () => { - it('successfully undoes discard if changes do not conflict', async () => { - pathDeleted = path.join(workdirPath, 'deleted-file.txt'); - fs.writeFileSync(pathDeleted, 'this file will be deleted\n'); - await repository.git.exec(['add', '.']); - await repository.git.exec(['commit', '-m', 'commit files lengthy enough that changes don\'t conflict']); - - pathAdded = path.join(workdirPath, 'another-added-file.txt'); + describe('when partialDiscardFilePath is falsey', () => { + let repository, workdirPath, wrapper, pathA, pathB, pathDeleted, pathAdded, getFileContents; + beforeEach(async () => { + workdirPath = await cloneRepository('three-files'); + repository = await buildRepository(workdirPath); + + getFileContents = filePath => { + try { + return fs.readFileSync(filePath, 'utf8'); + } catch (e) { + if (e.code === 'ENOENT') { + return null; + } else { + throw e; + } + } + }; - // change files - fs.writeFileSync(pathA, 'change at beginning\n' + fs.readFileSync(pathA, 'utf8')); - fs.writeFileSync(pathB, 'change at beginning\n' + fs.readFileSync(pathB, 'utf8')); + pathA = path.join(workdirPath, 'a.txt'); + pathB = path.join(workdirPath, 'subdir-1', 'b.txt'); + pathDeleted = path.join(workdirPath, 'c.txt'); + pathAdded = path.join(workdirPath, 'added-file.txt'); + fs.writeFileSync(pathA, [1, 2, 3, 4, 5, 6, 7, 8, 9].join('\n')); + fs.writeFileSync(pathB, ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'].join('\n')); + fs.writeFileSync(pathAdded, ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')'].join('\n')); fs.unlinkSync(pathDeleted); - fs.writeFileSync(pathAdded, 'foo\nbar\baz\n'); - const contentsBeforeDiscard = { + app = React.cloneElement(app, {repository}); + wrapper = shallow(app); + }); + + it('reverses last discard if there are no conflicts', async () => { + const contents1 = { pathA: getFileContents(pathA), pathB: getFileContents(pathB), pathDeleted: getFileContents(pathDeleted), pathAdded: getFileContents(pathAdded), }; - - await wrapper.instance().discardWorkDirChangesForPaths(['a.txt', 'subdir-1/b.txt', 'deleted-file.txt', 'another-added-file.txt']); - - // change file contents on disk in non-conflicting way - fs.writeFileSync(pathA, fs.readFileSync(pathA, 'utf8') + 'change at end'); - fs.writeFileSync(pathB, fs.readFileSync(pathB, 'utf8') + 'change at end'); - - await wrapper.instance().undoLastDiscard(); - - await assert.async.deepEqual({ + await wrapper.instance().discardWorkDirChangesForPaths(['a.txt', 'subdir-1/b.txt']); + const contents2 = { pathA: getFileContents(pathA), pathB: getFileContents(pathB), pathDeleted: getFileContents(pathDeleted), pathAdded: getFileContents(pathAdded), - }, { - pathA: contentsBeforeDiscard.pathA + 'change at end', - pathB: contentsBeforeDiscard.pathB + 'change at end', - pathDeleted: contentsBeforeDiscard.pathDeleted, - pathAdded: contentsBeforeDiscard.pathAdded, - }); - }); - - it('prompts user to continue if conflicts arise and proceeds based on user input, updating index to reflect files under conflict', async () => { - pathDeleted = path.join(workdirPath, 'deleted-file.txt'); - fs.writeFileSync(pathDeleted, 'this file will be deleted\n'); - await repository.git.exec(['add', '.']); - await repository.git.exec(['commit', '-m', 'commit files lengthy enough that changes don\'t conflict']); - - pathAdded = path.join(workdirPath, 'another-added-file.txt'); - fs.writeFileSync(pathA, 'change at beginning\n' + fs.readFileSync(pathA, 'utf8')); - fs.writeFileSync(pathB, 'change at beginning\n' + fs.readFileSync(pathB, 'utf8')); - fs.unlinkSync(pathDeleted); - fs.writeFileSync(pathAdded, 'foo\nbar\baz\n'); - - await wrapper.instance().discardWorkDirChangesForPaths(['a.txt', 'subdir-1/b.txt', 'deleted-file.txt', 'another-added-file.txt']); - - // change files in a conflicting way - fs.writeFileSync(pathA, 'conflicting change\n' + fs.readFileSync(pathA, 'utf8')); - fs.writeFileSync(pathB, 'conflicting change\n' + fs.readFileSync(pathB, 'utf8')); - fs.writeFileSync(pathDeleted, 'conflicting change\n'); - fs.writeFileSync(pathAdded, 'conflicting change\n'); + }; + assert.notDeepEqual(contents1, contents2); - const contentsAfterConflictingChange = { + await wrapper.instance().discardWorkDirChangesForPaths(['c.txt', 'added-file.txt']); + const contents3 = { pathA: getFileContents(pathA), pathB: getFileContents(pathB), pathDeleted: getFileContents(pathDeleted), pathAdded: getFileContents(pathAdded), }; + assert.notDeepEqual(contents2, contents3); - // click 'Cancel' - confirm.returns(2); await wrapper.instance().undoLastDiscard(); - await assert.async.equal(confirm.callCount, 1); - const confirmArg = confirm.args[0][0]; - assert.match(confirmArg.message, /Undoing will result in conflicts/); await assert.async.deepEqual({ pathA: getFileContents(pathA), pathB: getFileContents(pathB), pathDeleted: getFileContents(pathDeleted), pathAdded: getFileContents(pathAdded), - }, contentsAfterConflictingChange); - - // click 'Open in new editors' - confirm.returns(1); + }, contents2); await wrapper.instance().undoLastDiscard(); - assert.equal(confirm.callCount, 2); - const editors = workspace.getTextEditors().sort((a, b) => { - const pA = a.getFileName(); - const pB = b.getFileName(); - if (pA < pB) { return -1; } else if (pA > pB) { return 1; } else { return 0; } - }); - assert.equal(editors.length, 4); - - assert.match(editors[0].getFileName(), /a.txt-/); - assert.isTrue(editors[0].getText().includes('<<<<<<<')); - assert.isTrue(editors[0].getText().includes('>>>>>>>')); + await assert.async.deepEqual({ + pathA: getFileContents(pathA), + pathB: getFileContents(pathB), + pathDeleted: getFileContents(pathDeleted), + pathAdded: getFileContents(pathAdded), + }, contents1); + }); - assert.match(editors[1].getFileName(), /another-added-file.txt-/); - // no merge markers since 'ours' version is a deleted file - assert.isTrue(editors[1].getText().includes('<<<<<<<')); - assert.isTrue(editors[1].getText().includes('>>>>>>>')); + it('does not undo if buffer is modified', async () => { + await wrapper.instance().discardWorkDirChangesForPaths(['a.txt', 'subdir-1/b.txt', 'c.txt', 'added-file.txt']); - assert.match(editors[2].getFileName(), /b.txt-/); - assert.isTrue(editors[2].getText().includes('<<<<<<<')); - assert.isTrue(editors[2].getText().includes('>>>>>>>')); + // modify buffers + (await workspace.open(pathA)).getBuffer().append('stuff'); + (await workspace.open(pathB)).getBuffer().append('other stuff'); + (await workspace.open(pathDeleted)).getBuffer().append('this stuff'); + (await workspace.open(pathAdded)).getBuffer().append('that stuff'); - assert.match(editors[3].getFileName(), /deleted-file.txt-/); - // no merge markers since 'theirs' version is a deleted file - assert.isFalse(editors[3].getText().includes('<<<<<<<')); - assert.isFalse(editors[3].getText().includes('>>>>>>>')); + const expandBlobToFile = sinon.spy(repository, 'expandBlobToFile'); + sinon.stub(notificationManager, 'addError'); - // click 'Proceed and resolve conflicts' - confirm.returns(0); await wrapper.instance().undoLastDiscard(); - assert.equal(confirm.callCount, 3); - const contentsAfterUndo = { - pathA: getFileContents(pathA), - pathB: getFileContents(pathB), - pathDeleted: getFileContents(pathDeleted), - pathAdded: getFileContents(pathAdded), - }; - await assert.async.isTrue(contentsAfterUndo.pathA.includes('<<<<<<<')); - await assert.async.isTrue(contentsAfterUndo.pathA.includes('>>>>>>>')); - await assert.async.isTrue(contentsAfterUndo.pathB.includes('<<<<<<<')); - await assert.async.isTrue(contentsAfterUndo.pathB.includes('>>>>>>>')); - await assert.async.isFalse(contentsAfterUndo.pathDeleted.includes('<<<<<<<')); - await assert.async.isFalse(contentsAfterUndo.pathDeleted.includes('>>>>>>>')); - await assert.async.isTrue(contentsAfterUndo.pathAdded.includes('<<<<<<<')); - await assert.async.isTrue(contentsAfterUndo.pathAdded.includes('>>>>>>>')); - let unmergedFiles = await repository.git.exec(['diff', '--name-status', '--diff-filter=U']); - unmergedFiles = unmergedFiles.trim().split('\n').map(line => line.split('\t')[1]).sort(); - assert.deepEqual(unmergedFiles, ['a.txt', 'another-added-file.txt', 'deleted-file.txt', 'subdir-1/b.txt']); + const notificationArgs = notificationManager.addError.args[0]; + assert.equal(notificationArgs[0], 'Cannot undo last discard.'); + assert.match(notificationArgs[1].description, /You have unsaved changes./); + assert.match(notificationArgs[1].description, /a.txt/); + assert.match(notificationArgs[1].description, /subdir-1\/b.txt/); + assert.match(notificationArgs[1].description, /c.txt/); + assert.match(notificationArgs[1].description, /added-file.txt/); + assert.isFalse(expandBlobToFile.called); }); - }); - it('clears the discard history if the last blob is no longer valid', async () => { - // this would occur in the case of garbage collection cleaning out the blob - await wrapper.instance().discardWorkDirChangesForPaths(['a.txt']); - const snapshots = await wrapper.instance().discardWorkDirChangesForPaths(['subdir-1/b.txt']); - const {beforeSha} = snapshots['subdir-1/b.txt']; + describe('when file content has changed since last discard', () => { + it('successfully undoes discard if changes do not conflict', async () => { + pathDeleted = path.join(workdirPath, 'deleted-file.txt'); + fs.writeFileSync(pathDeleted, 'this file will be deleted\n'); + await repository.git.exec(['add', '.']); + await repository.git.exec(['commit', '-m', 'commit files lengthy enough that changes don\'t conflict']); + + pathAdded = path.join(workdirPath, 'another-added-file.txt'); + + // change files + fs.writeFileSync(pathA, 'change at beginning\n' + fs.readFileSync(pathA, 'utf8')); + fs.writeFileSync(pathB, 'change at beginning\n' + fs.readFileSync(pathB, 'utf8')); + fs.unlinkSync(pathDeleted); + fs.writeFileSync(pathAdded, 'foo\nbar\baz\n'); + + const contentsBeforeDiscard = { + pathA: getFileContents(pathA), + pathB: getFileContents(pathB), + pathDeleted: getFileContents(pathDeleted), + pathAdded: getFileContents(pathAdded), + }; + + await wrapper.instance().discardWorkDirChangesForPaths(['a.txt', 'subdir-1/b.txt', 'deleted-file.txt', 'another-added-file.txt']); + + // change file contents on disk in non-conflicting way + fs.writeFileSync(pathA, fs.readFileSync(pathA, 'utf8') + 'change at end'); + fs.writeFileSync(pathB, fs.readFileSync(pathB, 'utf8') + 'change at end'); + + await wrapper.instance().undoLastDiscard(); + + await assert.async.deepEqual({ + pathA: getFileContents(pathA), + pathB: getFileContents(pathB), + pathDeleted: getFileContents(pathDeleted), + pathAdded: getFileContents(pathAdded), + }, { + pathA: contentsBeforeDiscard.pathA + 'change at end', + pathB: contentsBeforeDiscard.pathB + 'change at end', + pathDeleted: contentsBeforeDiscard.pathDeleted, + pathAdded: contentsBeforeDiscard.pathAdded, + }); + }); - // remove blob from git object store - fs.unlinkSync(path.join(repository.getGitDirectoryPath(), 'objects', beforeSha.slice(0, 2), beforeSha.slice(2))); + it('prompts user to continue if conflicts arise and proceeds based on user input, updating index to reflect files under conflict', async () => { + pathDeleted = path.join(workdirPath, 'deleted-file.txt'); + fs.writeFileSync(pathDeleted, 'this file will be deleted\n'); + await repository.git.exec(['add', '.']); + await repository.git.exec(['commit', '-m', 'commit files lengthy enough that changes don\'t conflict']); + + pathAdded = path.join(workdirPath, 'another-added-file.txt'); + fs.writeFileSync(pathA, 'change at beginning\n' + fs.readFileSync(pathA, 'utf8')); + fs.writeFileSync(pathB, 'change at beginning\n' + fs.readFileSync(pathB, 'utf8')); + fs.unlinkSync(pathDeleted); + fs.writeFileSync(pathAdded, 'foo\nbar\baz\n'); + + await wrapper.instance().discardWorkDirChangesForPaths(['a.txt', 'subdir-1/b.txt', 'deleted-file.txt', 'another-added-file.txt']); + + // change files in a conflicting way + fs.writeFileSync(pathA, 'conflicting change\n' + fs.readFileSync(pathA, 'utf8')); + fs.writeFileSync(pathB, 'conflicting change\n' + fs.readFileSync(pathB, 'utf8')); + fs.writeFileSync(pathDeleted, 'conflicting change\n'); + fs.writeFileSync(pathAdded, 'conflicting change\n'); + + const contentsAfterConflictingChange = { + pathA: getFileContents(pathA), + pathB: getFileContents(pathB), + pathDeleted: getFileContents(pathDeleted), + pathAdded: getFileContents(pathAdded), + }; + + // click 'Cancel' + confirm.returns(2); + await wrapper.instance().undoLastDiscard(); + await assert.async.equal(confirm.callCount, 1); + const confirmArg = confirm.args[0][0]; + assert.match(confirmArg.message, /Undoing will result in conflicts/); + await assert.async.deepEqual({ + pathA: getFileContents(pathA), + pathB: getFileContents(pathB), + pathDeleted: getFileContents(pathDeleted), + pathAdded: getFileContents(pathAdded), + }, contentsAfterConflictingChange); + + // click 'Open in new editors' + confirm.returns(1); + await wrapper.instance().undoLastDiscard(); + assert.equal(confirm.callCount, 2); + const editors = workspace.getTextEditors().sort((a, b) => { + const pA = a.getFileName(); + const pB = b.getFileName(); + if (pA < pB) { return -1; } else if (pA > pB) { return 1; } else { return 0; } + }); + assert.equal(editors.length, 4); + + assert.match(editors[0].getFileName(), /a.txt-/); + assert.isTrue(editors[0].getText().includes('<<<<<<<')); + assert.isTrue(editors[0].getText().includes('>>>>>>>')); + + assert.match(editors[1].getFileName(), /another-added-file.txt-/); + // no merge markers since 'ours' version is a deleted file + assert.isTrue(editors[1].getText().includes('<<<<<<<')); + assert.isTrue(editors[1].getText().includes('>>>>>>>')); + + assert.match(editors[2].getFileName(), /b.txt-/); + assert.isTrue(editors[2].getText().includes('<<<<<<<')); + assert.isTrue(editors[2].getText().includes('>>>>>>>')); + + assert.match(editors[3].getFileName(), /deleted-file.txt-/); + // no merge markers since 'theirs' version is a deleted file + assert.isFalse(editors[3].getText().includes('<<<<<<<')); + assert.isFalse(editors[3].getText().includes('>>>>>>>')); + + // click 'Proceed and resolve conflicts' + confirm.returns(0); + await wrapper.instance().undoLastDiscard(); + assert.equal(confirm.callCount, 3); + const contentsAfterUndo = { + pathA: getFileContents(pathA), + pathB: getFileContents(pathB), + pathDeleted: getFileContents(pathDeleted), + pathAdded: getFileContents(pathAdded), + }; + await assert.async.isTrue(contentsAfterUndo.pathA.includes('<<<<<<<')); + await assert.async.isTrue(contentsAfterUndo.pathA.includes('>>>>>>>')); + await assert.async.isTrue(contentsAfterUndo.pathB.includes('<<<<<<<')); + await assert.async.isTrue(contentsAfterUndo.pathB.includes('>>>>>>>')); + await assert.async.isFalse(contentsAfterUndo.pathDeleted.includes('<<<<<<<')); + await assert.async.isFalse(contentsAfterUndo.pathDeleted.includes('>>>>>>>')); + await assert.async.isTrue(contentsAfterUndo.pathAdded.includes('<<<<<<<')); + await assert.async.isTrue(contentsAfterUndo.pathAdded.includes('>>>>>>>')); + let unmergedFiles = await repository.git.exec(['diff', '--name-status', '--diff-filter=U']); + unmergedFiles = unmergedFiles.trim().split('\n').map(line => line.split('\t')[1]).sort(); + assert.deepEqual(unmergedFiles, ['a.txt', 'another-added-file.txt', 'deleted-file.txt', 'subdir-1/b.txt']); + }); + }); - sinon.stub(notificationManager, 'addError'); - assert.equal(repository.getDiscardHistory().length, 2); - await wrapper.instance().undoLastDiscard(); - const notificationArgs = notificationManager.addError.args[0]; - assert.equal(notificationArgs[0], 'Discard history has expired.'); - assert.match(notificationArgs[1].description, /Stale discard history has been deleted./); - assert.equal(repository.getDiscardHistory().length, 0); + it('clears the discard history if the last blob is no longer valid', async () => { + // this would occur in the case of garbage collection cleaning out the blob + await wrapper.instance().discardWorkDirChangesForPaths(['a.txt']); + const snapshots = await wrapper.instance().discardWorkDirChangesForPaths(['subdir-1/b.txt']); + const {beforeSha} = snapshots['subdir-1/b.txt']; + + // remove blob from git object store + fs.unlinkSync(path.join(repository.getGitDirectoryPath(), 'objects', beforeSha.slice(0, 2), beforeSha.slice(2))); + + sinon.stub(notificationManager, 'addError'); + assert.equal(repository.getDiscardHistory().length, 2); + await wrapper.instance().undoLastDiscard(); + const notificationArgs = notificationManager.addError.args[0]; + assert.equal(notificationArgs[0], 'Discard history has expired.'); + assert.match(notificationArgs[1].description, /Stale discard history has been deleted./); + assert.equal(repository.getDiscardHistory().length, 0); + }); }); }); }); - }); - describe('integration tests', function() { - it('mounts the FilePatchController as a PaneItem', async function() { - const workdirPath = await cloneRepository('three-files'); - const repository = await buildRepository(workdirPath); - const wrapper = mount(React.cloneElement(app, {repository})); + describe('integration tests', function() { + it('mounts the FilePatchController as a PaneItem', async function() { + const workdirPath = await cloneRepository('three-files'); + const repository = await buildRepository(workdirPath); + const wrapper = mount(React.cloneElement(app, {repository})); - const filePath = path.join(workdirPath, 'a.txt'); - await writeFile(filePath, 'wut\n'); - await wrapper.instance().showFilePatchForPath('a.txt', 'unstaged'); + const filePath = path.join(workdirPath, 'a.txt'); + await writeFile(filePath, 'wut\n'); + await wrapper.instance().showFilePatchForPath('a.txt', 'unstaged'); - const paneItem = workspace.getActivePaneItem(); - assert.isDefined(paneItem); - assert.equal(paneItem.getTitle(), 'Unstaged Changes: a.txt'); + const paneItem = workspace.getActivePaneItem(); + assert.isDefined(paneItem); + assert.equal(paneItem.getTitle(), 'Unstaged Changes: a.txt'); + }); }); }); }); From 69cd52306169f1a2cb7760acca8ee5ac92e4fbba Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Mon, 24 Apr 2017 15:39:12 -0700 Subject: [PATCH 27/28] Fix up conditional --- test/controllers/root-controller.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index 45d56f7920..b5011a9461 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -19,10 +19,10 @@ import RootController from '../../lib/controllers/root-controller'; let workspaceElement; function isGitPaneDisplayed(wrapper) { - if (workspace.getLeftDock && !useLegacyPanels) { - return wrapper.find('DockItem').exists(); - } else { + if (useLegacyPanels || !workspace.getLeftDock) { return wrapper.find('Panel').prop('visible'); + } else { + return wrapper.find('DockItem').exists(); } } From f5c4df20f4fbc3cdaa87c9d8d4aa52ed2e54c073 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Mon, 24 Apr 2017 15:51:35 -0700 Subject: [PATCH 28/28] Aha --- test/controllers/root-controller.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index b5011a9461..f13567bda9 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -50,7 +50,7 @@ import RootController from '../../lib/controllers/root-controller'; confirm={confirm} repository={absentRepository} resolutionProgress={emptyResolutionProgress} - useLegacyPanels={useLegacyPanels} + useLegacyPanels={useLegacyPanels || !workspace.getLeftDock} firstRun={false} /> );