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; } } diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index 6a2c88c527..c5d8c8b602 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -55,12 +55,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() { @@ -77,8 +79,8 @@ export default class RootController extends React.Component { this.state = { ...nullFilePatchState, amending: false, - gitTabActive: !!props.savedState.gitTabActive, - githubPanelActive: !!props.savedState.githubPanelActive, + gitTabActive: props.firstRun || props.savedState.gitTabActive, + githubPanelActive: props.firstRun || props.savedState.githubPanelActive, panelSize: props.savedState.panelSize || 400, activeTab: props.savedState.activeTab || 0, cloneDialogActive: false, @@ -163,7 +165,8 @@ export default class RootController extends React.Component { workspace={this.props.workspace} getItem={({subtree}) => subtree.getWrappedComponent()} onDidCloseItem={() => this.setState({gitTabActive: false})} - stubItemSelector="git-tab-controller"> + stubItemSelector="git-tab-controller" + activate={this.props.firstRun}> { this.gitTabController = c; }} className="github-PanelEtchWrapper" diff --git a/lib/git-shell-out-strategy.js b/lib/git-shell-out-strategy.js index fb847ea135..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); @@ -653,6 +652,10 @@ export default class GitShellOutStrategy { return executable ? '100755' : '100644'; } } + + destroy() { + this.commandQueue.dispose(); + } } function buildAddedFilePatch(filePath, contents, executable) { diff --git a/lib/github-package.js b/lib/github-package.js index 5de49fc7aa..b5f473f435 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -21,6 +21,7 @@ import GitTimingsView from './views/git-timings-view'; import AsyncQueue from './async-queue'; const defaultState = { + firstRun: true, resolutionProgressByPath: {}, }; @@ -170,11 +171,16 @@ export default class GithubPackage { return { activeRepositoryPath, gitController: this.controller.serialize(), + firstRun: false, }; } @autobind rerender(callback) { + if (this.workspace.isDestroyed()) { + return; + } + if (!this.element) { this.element = document.createElement('div'); this.subscriptions.add(new Disposable(() => { @@ -201,6 +207,7 @@ export default class GithubPackage { cloneRepositoryForProjectPath={this.cloneRepositoryForProjectPath} switchboard={this.switchboard} useLegacyPanels={this.useLegacyPanels} + firstRun={this.savedState.firstRun} />, this.element, callback, ); } @@ -370,6 +377,10 @@ export default class GithubPackage { } async updateActiveContext(savedState = {}) { + if (this.workspace.isDestroyed()) { + return; + } + this.switchboard.didBeginActiveContextUpdate(); const nextActiveContext = await this.getNextContext(savedState); 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/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(); } 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/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); diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index 95a8dd743c..9fb78f5b36 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,55 @@ 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')); + } + + 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. + + isGitRepository() { + 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.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().directGetBlobContents(historySha); } catch (e) { if (/Not a valid object name/.test(e.stdErr)) { return {}; 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(); } diff --git a/lib/views/dock-item.js b/lib/views/dock-item.js index 5de0c57878..960720a74c 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, {activatePane: false})) + .then(item => { + this.dockItem = item; + if (this.props.activate) { this.activate(); } + }); } this.subscriptions = this.props.workspace.onDidDestroyPaneItem(({item}) => { @@ -94,7 +102,9 @@ export default class DockItem extends React.Component { activate() { setTimeout(() => { - if (!this.dockItem) { return; } + if (!this.dockItem || this.didCloseItem || this.props.workspace.isDestroyed()) { + return; + } const pane = this.props.workspace.paneForItem(this.dockItem); if (pane) { 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); diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index bc9170c60a..f13567bda9 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -13,1120 +13,1163 @@ import ResolutionProgress from '../../lib/models/conflicts/resolution-progress'; import RootController from '../../lib/controllers/root-controller'; -describe('RootController', function() { - let atomEnv, workspace, commandRegistry, notificationManager, tooltips, config, confirm, app; - let workspaceElement; - - 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(); - }); +[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 (useLegacyPanels || !workspace.getLeftDock) { + return wrapper.find('Panel').prop('visible'); + } else { + return wrapper.find('DockItem').exists(); + } + } - 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'); + 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.equal(workspace.open.callCount, 1); - assert.deepEqual(workspace.open.args[0], [path.join(workdirPath, 'added-to-both.txt'), {activatePane: false, pending: true}]); + afterEach(function() { + atomEnv.destroy(); }); - 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'); + 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); - fs.unlinkSync(path.join(workdirPath, 'added-to-both.txt')); - sinon.spy(notificationManager, 'addInfo'); - sinon.spy(workspace, 'open'); - app = React.cloneElement(app, {repository}); + app = React.cloneElement(app, {repository, savedState: {gitTabActive: false, githubPanelActive: false}}); 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.']); + assert.isFalse(isGitPaneDisplayed(wrapper)); }); - }); - }); - 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); + 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); - await wrapper.instance().diveIntoMergeConflictFileForPath('added-to-both.txt'); + app = React.cloneElement(app, {repository, savedState: {gitTabActive: true, githubPanelActive: 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: true, pending: true}]); - }); - }); + assert.isTrue(isGitPaneDisplayed(wrapper)); + }); - 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); + it('is always visible when the firstRun prop is true', async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); - wrapper.setState({ - filePath: null, - filePatch: null, - stagingStatus: null, + app = React.cloneElement(app, {repository, firstRun: true}); + const wrapper = shallow(app); + + assert.isTrue(isGitPaneDisplayed(wrapper)); }); - 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('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.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().showMergeConflictFileForPath('added-to-both.txt'); - 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(workspace.open.callCount, 1); + assert.deepEqual(workspace.open.args[0], [path.join(workdirPath, 'added-to-both.txt'), {activatePane: false, pending: true}]); + }); - assert.equal(wrapper.state('filePath'), 'd.txt'); - assert.equal(wrapper.state('filePatch').getPath(), 'd.txt'); - assert.equal(wrapper.state('stagingStatus'), 'staged'); + 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')); - wrapper.find('PaneItem').prop('onDidCloseItem')(); - assert.isNull(wrapper.state('filePath')); - assert.isNull(wrapper.state('filePatch')); + sinon.spy(notificationManager, 'addInfo'); + sinon.spy(workspace, 'open'); + 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); + 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('when there is a change to the repo', function() { - it('calls onRepoRefresh', async function() { - const workdirPath = await cloneRepository('multiple-commits'); + 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, 'file.txt'), 'change', 'utf8'); - + sinon.spy(workspace, 'open'); 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); + await wrapper.instance().diveIntoMergeConflictFileForPath('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('#onRepoRefresh', function() { - it('sets the correct FilePatch as state', 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); - await wrapper.instance().showFilePatchForPath('file.txt', 'unstaged', {activate: true}); - - 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'); - - fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change\nand again!', 'utf8'); - repository.refresh(); - await wrapper.instance().onRepoRefresh(); + wrapper.setState({ + filePath: null, + filePatch: null, + stagingStatus: null, + }); + assert.equal(wrapper.find('FilePatchController').length, 0); - 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')); + 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); }); }); - // 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); + 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); - app = React.cloneElement(app, {repository}); - const wrapper = shallow(app); + 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.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}]); - }); - }); + app = React.cloneElement(app, {repository}); + const wrapper = shallow(app); - 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); + await wrapper.instance().showFilePatchForPath('a.txt', 'unstaged'); - fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'change', 'utf8'); - repository.refresh(); + assert.equal(wrapper.state('filePath'), 'a.txt'); + assert.equal(wrapper.state('filePatch').getPath(), 'a.txt'); + assert.equal(wrapper.state('stagingStatus'), 'unstaged'); - app = React.cloneElement(app, {repository}); - const wrapper = shallow(app); + await wrapper.instance().showFilePatchForPath('d.txt', 'staged'); - const focusFilePatch = sinon.spy(); - const activate = sinon.spy(); + assert.equal(wrapper.state('filePath'), 'd.txt'); + assert.equal(wrapper.state('filePatch').getPath(), 'd.txt'); + assert.equal(wrapper.state('stagingStatus'), 'staged'); - const mockPane = { - getPaneItem: () => mockPane, - getElement: () => mockPane, - querySelector: () => mockPane, - focus: focusFilePatch, - activate, - }; - wrapper.instance().filePatchControllerPane = mockPane; + wrapper.find('PaneItem').prop('onDidCloseItem')(); + assert.isNull(wrapper.state('filePath')); + assert.isNull(wrapper.state('filePatch')); - await wrapper.instance().diveIntoFilePatchForPath('a.txt', 'unstaged'); + const activate = sinon.stub(); + wrapper.instance().filePatchControllerPane = {activate}; + await wrapper.instance().showFilePatchForPath('d.txt', 'staged', {activate: true}); + assert.equal(activate.callCount, 1); + }); + }); - assert.equal(wrapper.state('filePath'), 'a.txt'); - assert.equal(wrapper.state('filePatch').getPath(), 'a.txt'); - assert.equal(wrapper.state('stagingStatus'), 'unstaged'); + 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); - assert.isTrue(focusFilePatch.called); - assert.isTrue(activate.called); - }); - }); + fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change', 'utf8'); - 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); + 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); + }); + }); - 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); + describe('#onRepoRefresh', function() { + it('sets the correct FilePatch as state', async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); - sinon.spy(wrapper.instance(), 'showFilePatchForPath'); - await wrapper.instance().didChangeAmending(true); - assert.isTrue(wrapper.instance().showFilePatchForPath.args[0][2].amending); - }); - }); + fs.writeFileSync(path.join(workdirPath, 'file.txt'), 'change', 'utf8'); - 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); + 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}); - assert.isFalse(wrapper.find('Panel').prop('visible')); - wrapper.find('ObserveModelDecorator(StatusBarTileController)').prop('toggleGitTab')(); - assert.isTrue(wrapper.find('Panel').prop('visible')); - wrapper.find('ObserveModelDecorator(StatusBarTileController)').prop('toggleGitTab')(); - assert.isFalse(wrapper.find('Panel').prop('visible')); - }); - }); + 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('toggleGitTab()', function() { - it('toggles the visibility of the Git panel', 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')); + }); + }); - assert.isFalse(wrapper.find('Panel').prop('visible')); - wrapper.instance().toggleGitTab(); - assert.isTrue(wrapper.find('Panel').prop('visible')); - wrapper.instance().toggleGitTab(); - assert.isFalse(wrapper.find('Panel').prop('visible')); + // 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); + + 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('toggleGitTabFocus()', function() { - let wrapper; + 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); - beforeEach(async function() { - const workdirPath = await cloneRepository('multiple-commits'); - const repository = await buildRepository(workdirPath); + fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'change', 'utf8'); + repository.refresh(); - app = React.cloneElement(app, {repository}); - wrapper = shallow(app); + app = React.cloneElement(app, {repository}); + const wrapper = shallow(app); - sinon.stub(wrapper.instance(), 'focusGitTab'); - sinon.spy(workspace.getActivePane(), 'activate'); - }); + const focusFilePatch = sinon.spy(); + const activate = sinon.spy(); - it('opens and focuses the Git panel when it is initially closed', function() { - assert.isFalse(wrapper.find('Panel').prop('visible')); - sinon.stub(wrapper.instance(), 'gitTabHasFocus').returns(false); + const mockPane = { + getPaneItem: () => mockPane, + getElement: () => mockPane, + querySelector: () => mockPane, + focus: focusFilePatch, + activate, + }; + wrapper.instance().filePatchControllerPane = mockPane; + + await wrapper.instance().diveIntoFilePatchForPath('a.txt', 'unstaged'); - wrapper.instance().toggleGitTabFocus(); + assert.equal(wrapper.state('filePath'), 'a.txt'); + assert.equal(wrapper.state('filePatch').getPath(), 'a.txt'); + assert.equal(wrapper.state('stagingStatus'), 'unstaged'); - assert.isTrue(wrapper.find('Panel').prop('visible')); - assert.equal(wrapper.instance().focusGitTab.callCount, 1); - assert.isFalse(workspace.getActivePane().activate.called); + assert.isTrue(focusFilePatch.called); + assert.isTrue(activate.called); + }); }); - it('focuses the Git panel when it is already open, but blurred', function() { - wrapper.instance().toggleGitTab(); - sinon.stub(wrapper.instance(), 'gitTabHasFocus').returns(false); + 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); - assert.isTrue(wrapper.find('Panel').prop('visible')); + app = React.cloneElement(app, {repository}); + const wrapper = shallow(app); - wrapper.instance().toggleGitTabFocus(); + 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); - assert.isTrue(wrapper.find('Panel').prop('visible')); - assert.equal(wrapper.instance().focusGitTab.callCount, 1); - assert.isFalse(workspace.getActivePane().activate.called); + sinon.spy(wrapper.instance(), 'showFilePatchForPath'); + await wrapper.instance().didChangeAmending(true); + assert.isTrue(wrapper.instance().showFilePatchForPath.args[0][2].amending); + }); }); - it('blurs the Git panel when it is already open and focused', function() { - wrapper.instance().toggleGitTab(); - sinon.stub(wrapper.instance(), 'gitTabHasFocus').returns(true); - - assert.isTrue(wrapper.find('Panel').prop('visible')); + 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(wrapper.find('Panel').prop('visible')); - assert.equal(wrapper.instance().focusGitTab.callCount, 0); - assert.isTrue(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)); + }); }); - }); - describe('initializeRepo', function() { - let createRepositoryForProjectPath, resolveInit, rejectInit; + describe('toggleGitTab()', function() { + it('toggles the visibility of the Git panel', async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); - beforeEach(function() { - createRepositoryForProjectPath = sinon.stub().returns(new Promise((resolve, reject) => { - resolveInit = resolve; - rejectInit = reject; - })); + app = React.cloneElement(app, {repository}); + const wrapper = shallow(app); + + assert.isFalse(isGitPaneDisplayed(wrapper)); + wrapper.instance().toggleGitTab(); + assert.isTrue(isGitPaneDisplayed(wrapper)); + wrapper.instance().toggleGitTab(); + assert.isFalse(isGitPaneDisplayed(wrapper)); + }); }); - it('initializes the current working directory if there is one', function() { - app = React.cloneElement(app, { - createRepositoryForProjectPath, - activeWorkingDirectory: '/some/workdir', + describe('toggleGitTabFocus()', function() { + let wrapper; + + beforeEach(async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); + + app = React.cloneElement(app, {repository}); + wrapper = shallow(app); + + sinon.stub(wrapper.instance(), 'focusGitTab'); + sinon.spy(workspace.getActivePane(), 'activate'); }); - const wrapper = shallow(app); - wrapper.instance().initializeRepo(); - resolveInit(); + it('opens and focuses the Git panel when it is initially closed', function() { + assert.isFalse(isGitPaneDisplayed(wrapper)); + sinon.stub(wrapper.instance(), 'gitTabHasFocus').returns(false); - assert.isTrue(createRepositoryForProjectPath.calledWith('/some/workdir')); - }); + wrapper.instance().toggleGitTabFocus(); - it('renders the modal init panel', function() { - app = React.cloneElement(app, {createRepositoryForProjectPath}); - const wrapper = shallow(app); + assert.isTrue(isGitPaneDisplayed(wrapper)); + assert.equal(wrapper.instance().focusGitTab.callCount, 1); + assert.isFalse(workspace.getActivePane().activate.called); + }); - wrapper.instance().initializeRepo(); + it('focuses the Git panel when it is already open, but blurred', function() { + wrapper.instance().toggleGitTab(); + sinon.stub(wrapper.instance(), 'gitTabHasFocus').returns(false); - assert.lengthOf(wrapper.find('Panel').find({location: 'modal'}).find('InitDialog'), 1); - }); + assert.isTrue(isGitPaneDisplayed(wrapper)); - it('triggers the init callback on accept', function() { - app = React.cloneElement(app, {createRepositoryForProjectPath}); - const wrapper = shallow(app); + wrapper.instance().toggleGitTabFocus(); - wrapper.instance().initializeRepo(); - const dialog = wrapper.find('InitDialog'); - dialog.prop('didAccept')('/a/path'); - resolveInit(); + assert.isTrue(isGitPaneDisplayed(wrapper)); + assert.equal(wrapper.instance().focusGitTab.callCount, 1); + assert.isFalse(workspace.getActivePane().activate.called); + }); - assert.isTrue(createRepositoryForProjectPath.calledWith('/a/path')); - }); + it('blurs the Git panel when it is already open and focused', function() { + wrapper.instance().toggleGitTab(); + sinon.stub(wrapper.instance(), 'gitTabHasFocus').returns(true); - it('dismisses the init callback on cancel', function() { - app = React.cloneElement(app, {createRepositoryForProjectPath}); - const wrapper = shallow(app); + assert.isTrue(isGitPaneDisplayed(wrapper)); - wrapper.instance().initializeRepo(); - const dialog = wrapper.find('InitDialog'); - dialog.prop('didCancel')(); + wrapper.instance().toggleGitTabFocus(); - assert.isFalse(wrapper.find('InitDialog').exists()); + assert.isTrue(isGitPaneDisplayed(wrapper)); + assert.equal(wrapper.instance().focusGitTab.callCount, 0); + assert.isTrue(workspace.getActivePane().activate.called); + }); }); - it('creates a notification if the init fails', async function() { - sinon.stub(notificationManager, 'addError'); + describe('initializeRepo', function() { + let createRepositoryForProjectPath, resolveInit, rejectInit; - app = React.cloneElement(app, {createRepositoryForProjectPath}); - const wrapper = shallow(app); + beforeEach(function() { + createRepositoryForProjectPath = sinon.stub().returns(new Promise((resolve, reject) => { + resolveInit = resolve; + rejectInit = reject; + })); + }); - 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('initializes the current working directory if there is one', function() { + app = React.cloneElement(app, { + createRepositoryForProjectPath, + activeWorkingDirectory: '/some/workdir', + }); + const wrapper = shallow(app); - describe('github:clone', function() { - let wrapper, cloneRepositoryForProjectPath, resolveClone, rejectClone; + wrapper.instance().initializeRepo(); + resolveInit(); - beforeEach(function() { - cloneRepositoryForProjectPath = sinon.stub().returns(new Promise((resolve, reject) => { - resolveClone = resolve; - rejectClone = reject; - })); + assert.isTrue(createRepositoryForProjectPath.calledWith('/some/workdir')); + }); - app = React.cloneElement(app, {cloneRepositoryForProjectPath}); - wrapper = shallow(app); - }); + it('renders the modal init panel', 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(); - assert.lengthOf(wrapper.find('Panel').find({location: 'modal'}).find('CloneDialog'), 1); - }); + assert.lengthOf(wrapper.find('Panel').find({location: 'modal'}).find('InitDialog'), 1); + }); - it('triggers the clone callback on accept', function() { - commandRegistry.dispatch(workspaceElement, 'github:clone'); + it('triggers the init callback on accept', 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('didAccept')('/a/path'); + resolveInit(); - assert.isTrue(cloneRepositoryForProjectPath.calledWith('git@github.com:atom/github.git', '/home/me/github')); - }); + assert.isTrue(createRepositoryForProjectPath.calledWith('/a/path')); + }); - it('marks the clone dialog as in progress during clone', async 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'); - assert.isFalse(dialog.prop('inProgress')); + wrapper.instance().initializeRepo(); + const dialog = wrapper.find('InitDialog'); + dialog.prop('didCancel')(); - const acceptPromise = dialog.prop('didAccept')('git@github.com:atom/github.git', '/home/me/github'); + assert.isFalse(wrapper.find('InitDialog').exists()); + }); - assert.isTrue(wrapper.find('CloneDialog').prop('inProgress')); + it('creates a notification if the init fails', async function() { + sinon.stub(notificationManager, 'addError'); - resolveClone(); - await acceptPromise; + app = React.cloneElement(app, {createRepositoryForProjectPath}); + const wrapper = shallow(app); - assert.isFalse(wrapper.find('CloneDialog').exists()); + 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('creates a notification if the clone fails', async function() { - sinon.stub(notificationManager, 'addError'); + describe('github:clone', function() { + let wrapper, cloneRepositoryForProjectPath, resolveClone, rejectClone; - commandRegistry.dispatch(workspaceElement, 'github:clone'); + beforeEach(function() { + cloneRepositoryForProjectPath = sinon.stub().returns(new Promise((resolve, reject) => { + resolveClone = resolve; + rejectClone = reject; + })); - const dialog = wrapper.find('CloneDialog'); - assert.isFalse(dialog.prop('inProgress')); + app = React.cloneElement(app, {cloneRepositoryForProjectPath}); + wrapper = shallow(app); + }); - 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; + it('renders the modal clone panel', function() { + commandRegistry.dispatch(workspaceElement, 'github:clone'); - 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.lengthOf(wrapper.find('Panel').find({location: 'modal'}).find('CloneDialog'), 1); + }); - it('dismisses the clone panel on cancel', function() { - commandRegistry.dispatch(workspaceElement, 'github:clone'); + it('triggers the clone callback on accept', function() { + commandRegistry.dispatch(workspaceElement, 'github:clone'); - const dialog = wrapper.find('CloneDialog'); - dialog.prop('didCancel')(); + const dialog = wrapper.find('CloneDialog'); + dialog.prop('didAccept')('git@github.com:atom/github.git', '/home/me/github'); + resolveClone(); - assert.lengthOf(wrapper.find('CloneDialog'), 0); - assert.isFalse(cloneRepositoryForProjectPath.called); - }); - }); + assert.isTrue(cloneRepositoryForProjectPath.calledWith('git@github.com:atom/github.git', '/home/me/github')); + }); - describe('promptForCredentials()', function() { - let wrapper; + it('marks the clone dialog as in progress during clone', async function() { + commandRegistry.dispatch(workspaceElement, 'github:clone'); - beforeEach(function() { - wrapper = shallow(app); - }); + const dialog = wrapper.find('CloneDialog'); + assert.isFalse(dialog.prop('inProgress')); - it('renders the modal credentials dialog', function() { - wrapper.instance().promptForCredentials({ - prompt: 'Password plz', - includeUsername: true, - }); + const acceptPromise = dialog.prop('didAccept')('git@github.com:atom/github.git', '/home/me/github'); - 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.isTrue(wrapper.find('CloneDialog').prop('inProgress')); + + resolveClone(); + await acceptPromise; - it('resolves the promise with credentials on accept', async function() { - const credentialPromise = wrapper.instance().promptForCredentials({ - prompt: 'Speak "friend" and enter', - includeUsername: false, + assert.isFalse(wrapper.find('CloneDialog').exists()); }); - wrapper.find('CredentialDialog').prop('onSubmit')({password: 'friend'}); - assert.deepEqual(await credentialPromise, {password: 'friend'}); - assert.isFalse(wrapper.find('CredentialDialog').exists()); - }); + it('creates a notification if the clone fails', async function() { + sinon.stub(notificationManager, 'addError'); - it('rejects the promise on cancel', async function() { - const credentialPromise = wrapper.instance().promptForCredentials({ - prompt: 'Enter the square root of 1244313452349528345', - includeUsername: false, - }); + commandRegistry.dispatch(workspaceElement, 'github:clone'); - wrapper.find('CredentialDialog').prop('onCancel')(); - await assert.isRejected(credentialPromise); - assert.isFalse(wrapper.find('CredentialDialog').exists()); - }); - }); + const dialog = wrapper.find('CloneDialog'); + assert.isFalse(dialog.prop('inProgress')); - describe('ensureGitTab()', function() { - let wrapper; + 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; - beforeEach(async function() { - const workdirPath = await cloneRepository('multiple-commits'); - const repository = await buildRepository(workdirPath); + 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/)}), + )); + }); - app = React.cloneElement(app, {repository}); - wrapper = shallow(app); - }); + it('dismisses the clone panel on cancel', function() { + commandRegistry.dispatch(workspaceElement, 'github:clone'); - it('opens the Git panel when it is initially closed', async function() { - assert.isFalse(wrapper.find('Panel').prop('visible')); - assert.isTrue(await wrapper.instance().ensureGitTab()); - }); + const dialog = wrapper.find('CloneDialog'); + dialog.prop('didCancel')(); - it('does nothing when the Git panel is already open', async function() { - wrapper.instance().toggleGitTab(); - assert.isTrue(wrapper.find('Panel').prop('visible')); - assert.isFalse(await wrapper.instance().ensureGitTab()); - assert.isTrue(wrapper.find('Panel').prop('visible')); + assert.lengthOf(wrapper.find('CloneDialog'), 0); + assert.isFalse(cloneRepositoryForProjectPath.called); + }); }); - }); - 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); + describe('promptForCredentials()', function() { + let wrapper; + + beforeEach(function() { + wrapper = shallow(app); + }); - app = React.cloneElement(app, {repository: repository1}); - const wrapper = shallow(app); + it('renders the modal credentials dialog', function() { + wrapper.instance().promptForCredentials({ + prompt: 'Password plz', + includeUsername: true, + }); - assert.equal(wrapper.state('amending'), false); + 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')); + }); - wrapper.setState({amending: true}); - wrapper.setProps({repository: repository2}); - 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.setProps({repository: repository1}); - assert.equal(wrapper.state('amending'), true); - }); + wrapper.find('CredentialDialog').prop('onSubmit')({password: 'friend'}); + assert.deepEqual(await credentialPromise, {password: 'friend'}); + assert.isFalse(wrapper.find('CredentialDialog').exists()); + }); - 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); + it('rejects the promise on cancel', async function() { + const credentialPromise = wrapper.instance().promptForCredentials({ + prompt: 'Enter the square root of 1244313452349528345', + includeUsername: false, + }); - fs.writeFileSync(path.join(workdirPath, 'file1.txt'), 'foo'); - fs.writeFileSync(path.join(workdirPath, 'file2.txt'), 'bar'); - fs.writeFileSync(path.join(workdirPath, 'file3.txt'), 'baz'); + wrapper.find('CredentialDialog').prop('onCancel')(); + await assert.isRejected(credentialPromise); + assert.isFalse(wrapper.find('CredentialDialog').exists()); + }); + }); - sinon.stub(workspace, 'open'); - app = React.cloneElement(app, {repository}); - const wrapper = shallow(app); - await wrapper.instance().openFiles(['file1.txt']); + describe('ensureGitTab()', function() { + let wrapper; + + beforeEach(async function() { + const workdirPath = await cloneRepository('multiple-commits'); + const repository = await buildRepository(workdirPath); + + app = React.cloneElement(app, {repository}); + wrapper = shallow(app); + }); - assert.equal(workspace.open.callCount, 1); - assert.deepEqual(workspace.open.args[0], [path.join(repository.getWorkingDirectoryPath(), 'file1.txt'), {pending: true}]); + 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); + + fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'modification\n'); + const unstagedFilePatch = await repository.getFilePatchForPath('a.txt'); - absFilePath = path.join(workdirPath, 'sample.js'); - fs.writeFileSync(absFilePath, 'foo\nbar\nbaz\n'); - unstagedFilePatch = await repository.getFilePatchForPath('sample.js'); + 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('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'); - describe('when file content has changed since last discard', () => { - it('successfully undoes discard if changes do not conflict', async () => { + 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); - }); - }); + await repository.refresh(); + unstagedFilePatch = await repository.getFilePatchForPath('sample.js'); + wrapper.setState({filePatch: unstagedFilePatch}); + await wrapper.instance().undoLastDiscard('sample.js'); - 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; - } - } - }; - - 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'); + }); }); }); }); diff --git a/test/github-package.test.js b/test/github-package.test.js index 4bcf197157..8c70ba1a28 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(); }); @@ -179,6 +183,7 @@ describe('GithubPackage', function() { await contextUpdateAfter(() => githubPackage.activate({ activeRepositoryPath: workdirPath2, + firstRun: false, })); const context = contextPool.getContext(workdirPath2); @@ -394,6 +399,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() {