import fs from 'fs'; import path from 'path'; import React from 'react'; import {mount} from 'enzyme'; import dedent from 'dedent-js'; import temp from 'temp'; import GitTabController from '../../lib/controllers/git-tab-controller'; import {gitTabControllerProps} from '../fixtures/props/git-tab-props'; import {cloneRepository, buildRepository, buildRepositoryWithPipeline, initRepository} from '../helpers'; import Repository from '../../lib/models/repository'; import Author from '../../lib/models/author'; import ResolutionProgress from '../../lib/models/conflicts/resolution-progress'; import {GitError} from '../../lib/git-shell-out-strategy'; describe('GitTabController', function() { let atomEnvironment, workspace, workspaceElement, commands, notificationManager; let resolutionProgress, refreshResolutionProgress; beforeEach(function() { atomEnvironment = global.buildAtomEnvironment(); workspace = atomEnvironment.workspace; commands = atomEnvironment.commands; notificationManager = atomEnvironment.notifications; workspaceElement = atomEnvironment.views.getView(workspace); resolutionProgress = new ResolutionProgress(); refreshResolutionProgress = sinon.spy(); }); afterEach(function() { atomEnvironment.destroy(); }); async function buildApp(repository, overrides = {}) { const props = await gitTabControllerProps(atomEnvironment, repository, { resolutionProgress, refreshResolutionProgress, ...overrides, }); return <GitTabController {...props} />; } async function updateWrapper(repository, wrapper, overrides = {}) { repository.refresh(); const props = await gitTabControllerProps(atomEnvironment, repository, { resolutionProgress, refreshResolutionProgress, ...overrides, }); wrapper.setProps(props); } it('displays a loading message in GitTabView while data is being fetched', async function() { const workdirPath = await cloneRepository('three-files'); fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'a change\n'); fs.unlinkSync(path.join(workdirPath, 'b.txt')); const repository = new Repository(workdirPath); assert.isTrue(repository.isLoading()); const wrapper = mount(await buildApp(repository)); assert.isTrue(wrapper.find('.github-Git').hasClass('is-loading')); assert.lengthOf(wrapper.find('StagingView'), 1); assert.lengthOf(wrapper.find('CommitController'), 1); await repository.getLoadPromise(); await updateWrapper(repository, wrapper); await assert.async.isFalse(wrapper.update().find('.github-Git').hasClass('is-loading')); assert.lengthOf(wrapper.find('StagingView'), 1); assert.lengthOf(wrapper.find('CommitController'), 1); }); it('displays an initialization prompt for an absent repository', async function() { const repository = Repository.absent(); const wrapper = mount(await buildApp(repository)); assert.isTrue(wrapper.find('.is-empty').exists()); assert.isTrue(wrapper.find('.no-repository').exists()); }); it('fetches conflict marker counts for conflicting files', async function() { const workdirPath = await cloneRepository('merge-conflict'); const repository = await buildRepository(workdirPath); await assert.isRejected(repository.git.merge('origin/branch')); const rp = new ResolutionProgress(); rp.reportMarkerCount(path.join(workdirPath, 'added-to-both.txt'), 5); mount(await buildApp(repository, {resolutionProgress: rp})); assert.isTrue(refreshResolutionProgress.calledWith(path.join(workdirPath, 'modified-on-both-ours.txt'))); assert.isTrue(refreshResolutionProgress.calledWith(path.join(workdirPath, 'modified-on-both-theirs.txt'))); assert.isFalse(refreshResolutionProgress.calledWith(path.join(workdirPath, 'added-to-both.txt'))); }); describe('identity editor', function() { it('is not shown while loading data', async function() { const repository = await buildRepository(await cloneRepository('three-files')); const wrapper = mount(await buildApp(repository, { fetchInProgress: true, username: '', email: '', })); assert.isFalse(wrapper.find('GitTabView').prop('editingIdentity')); }); it('is not shown when the repository is out of sync', async function() { const repository = await buildRepository(await cloneRepository('three-files')); const wrapper = mount(await buildApp(repository, { fetchInProgress: false, username: '', email: '', repositoryDrift: true, })); assert.isFalse(wrapper.find('GitTabView').prop('editingIdentity')); }); it('is not shown for an absent repository', async function() { const wrapper = mount(await buildApp(Repository.absent(), { fetchInProgress: false, username: '', email: '', })); assert.isFalse(wrapper.find('GitTabView').prop('editingIdentity')); }); it('is not shown for an empty repository', async function() { const nongit = temp.mkdirSync(); const repository = await buildRepository(nongit); const wrapper = mount(await buildApp(repository, { fetchInProgress: false, username: '', email: '', })); assert.isFalse(wrapper.find('GitTabView').prop('editingIdentity')); }); it('is shown by default when username or email are empty', async function() { const repository = await buildRepository(await cloneRepository('three-files')); const wrapper = mount(await buildApp(repository, { username: '', email: 'not@empty.com', })); assert.isTrue(wrapper.find('GitTabView').prop('editingIdentity')); }); it('is toggled on and off with toggleIdentityEditor', async function() { const repository = await buildRepository(await cloneRepository('three-files')); const wrapper = mount(await buildApp(repository)); assert.isFalse(wrapper.find('GitTabView').prop('editingIdentity')); wrapper.find('GitTabView').prop('toggleIdentityEditor')(); wrapper.update(); assert.isTrue(wrapper.find('GitTabView').prop('editingIdentity')); wrapper.find('GitTabView').prop('toggleIdentityEditor')(); wrapper.update(); assert.isFalse(wrapper.find('GitTabView').prop('editingIdentity')); }); it('is toggled off with closeIdentityEditor', async function() { const repository = await buildRepository(await cloneRepository('three-files')); const wrapper = mount(await buildApp(repository)); assert.isFalse(wrapper.find('GitTabView').prop('editingIdentity')); wrapper.find('GitTabView').prop('toggleIdentityEditor')(); wrapper.update(); assert.isTrue(wrapper.find('GitTabView').prop('editingIdentity')); wrapper.find('GitTabView').prop('closeIdentityEditor')(); wrapper.update(); assert.isFalse(wrapper.find('GitTabView').prop('editingIdentity')); wrapper.find('GitTabView').prop('closeIdentityEditor')(); wrapper.update(); assert.isFalse(wrapper.find('GitTabView').prop('editingIdentity')); }); it('synchronizes buffer contents with fetched properties', async function() { const repository = await buildRepository(await cloneRepository('three-files')); const wrapper = mount(await buildApp(repository, { username: 'initial', email: 'initial@email.com', })); const usernameBuffer = wrapper.find('GitTabView').prop('usernameBuffer'); const emailBuffer = wrapper.find('GitTabView').prop('emailBuffer'); assert.strictEqual(usernameBuffer.getText(), 'initial'); assert.strictEqual(emailBuffer.getText(), 'initial@email.com'); usernameBuffer.setText('initial+'); emailBuffer.setText('initial+@email.com'); wrapper.setProps({ username: 'changed', email: 'changed@email.com', }); assert.strictEqual(wrapper.find('GitTabView').prop('usernameBuffer'), usernameBuffer); assert.strictEqual(usernameBuffer.getText(), 'changed'); assert.strictEqual(wrapper.find('GitTabView').prop('emailBuffer'), emailBuffer); assert.strictEqual(emailBuffer.getText(), 'changed@email.com'); usernameBuffer.setText('changed+'); emailBuffer.setText('changed+@email.com'); wrapper.setProps({ username: 'changed', email: 'changed@email.com', }); assert.strictEqual(wrapper.find('GitTabView').prop('usernameBuffer'), usernameBuffer); assert.strictEqual(usernameBuffer.getText(), 'changed+'); assert.strictEqual(wrapper.find('GitTabView').prop('emailBuffer'), emailBuffer); assert.strictEqual(emailBuffer.getText(), 'changed+@email.com'); }); it('sets repository-local identity', async function() { const repository = await buildRepository(await cloneRepository('three-files')); const setConfig = sinon.stub(repository, 'setConfig'); const wrapper = mount(await buildApp(repository)); wrapper.find('GitTabView').prop('usernameBuffer').setText('changed'); wrapper.find('GitTabView').prop('emailBuffer').setText('changed@email.com'); await wrapper.find('GitTabView').prop('setLocalIdentity')(); assert.isTrue(setConfig.calledWith('user.name', 'changed', {})); assert.isTrue(setConfig.calledWith('user.email', 'changed@email.com', {})); }); it('sets account-global identity', async function() { const repository = await buildRepository(await cloneRepository('three-files')); const setConfig = sinon.stub(repository, 'setConfig'); const wrapper = mount(await buildApp(repository)); wrapper.find('GitTabView').prop('usernameBuffer').setText('changed'); wrapper.find('GitTabView').prop('emailBuffer').setText('changed@email.com'); await wrapper.find('GitTabView').prop('setGlobalIdentity')(); assert.isTrue(setConfig.calledWith('user.name', 'changed', {global: true})); assert.isTrue(setConfig.calledWith('user.email', 'changed@email.com', {global: true})); }); it('unsets config values when empty', async function() { const repository = await buildRepository(await cloneRepository('three-files')); const unsetConfig = sinon.stub(repository, 'unsetConfig'); const wrapper = mount(await buildApp(repository)); wrapper.find('GitTabView').prop('usernameBuffer').setText(''); wrapper.find('GitTabView').prop('emailBuffer').setText(''); await wrapper.find('GitTabView').prop('setLocalIdentity')(); assert.isTrue(unsetConfig.calledWith('user.name')); assert.isTrue(unsetConfig.calledWith('user.email')); }); }); describe('abortMerge()', function() { it('resets merge related state', async function() { const workdirPath = await cloneRepository('merge-conflict'); const repository = await buildRepository(workdirPath); await assert.isRejected(repository.git.merge('origin/branch')); const confirm = sinon.stub(); const wrapper = mount(await buildApp(repository, {confirm})); await assert.async.isTrue(wrapper.update().find('GitTabView').prop('isMerging')); assert.notEqual(wrapper.find('GitTabView').prop('mergeConflicts').length, 0); assert.isOk(wrapper.find('GitTabView').prop('mergeMessage')); confirm.returns(0); await wrapper.instance().abortMerge(); await updateWrapper(repository, wrapper); await assert.async.lengthOf(wrapper.update().find('GitTabView').prop('mergeConflicts'), 0); assert.isFalse(wrapper.find('GitTabView').prop('isMerging')); assert.isNull(wrapper.find('GitTabView').prop('mergeMessage')); }); }); describe('prepareToCommit', function() { it('shows the git panel and returns false if it was hidden', async function() { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); const ensureGitTab = () => Promise.resolve(true); const wrapper = mount(await buildApp(repository, {ensureGitTab})); assert.isFalse(await wrapper.instance().prepareToCommit()); }); it('returns true if the git panel was already visible', async function() { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); const ensureGitTab = () => Promise.resolve(false); const wrapper = mount(await buildApp(repository, {ensureGitTab})); assert.isTrue(await wrapper.instance().prepareToCommit()); }); }); describe('commit(message)', function() { it('shows an error notification when committing throws an error', async function() { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepositoryWithPipeline(workdirPath, {confirm, notificationManager, workspace}); sinon.stub(repository.git, 'commit').callsFake(async () => { await Promise.resolve(); throw new GitError('message'); }); const wrapper = mount(await buildApp(repository)); notificationManager.clear(); // clear out any notifications try { await wrapper.instance().commit(); } catch (e) { assert(e, 'is error'); } assert.equal(notificationManager.getNotifications().length, 1); }); }); describe('when a new author is added', function() { it('user store is updated', async function() { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); const wrapper = mount(await buildApp(repository)); const coAuthors = [new Author('mona@lisa.com', 'Mona Lisa')]; const newAuthor = new Author('hubot@github.com', 'Mr. Hubot'); wrapper.instance().updateSelectedCoAuthors(coAuthors, newAuthor); assert.deepEqual(wrapper.state('selectedCoAuthors'), [...coAuthors, newAuthor]); }); }); it('selects an item by description', async function() { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); fs.writeFileSync(path.join(workdirPath, 'unstaged-1.txt'), 'This is an unstaged file.'); fs.writeFileSync(path.join(workdirPath, 'unstaged-2.txt'), 'This is an unstaged file.'); fs.writeFileSync(path.join(workdirPath, 'unstaged-3.txt'), 'This is an unstaged file.'); repository.refresh(); const wrapper = mount(await buildApp(repository)); await assert.async.lengthOf(wrapper.update().find('GitTabView').prop('unstagedChanges'), 3); const controller = wrapper.instance(); const stagingView = controller.refStagingView.get(); sinon.spy(stagingView, 'setFocus'); await controller.quietlySelectItem('unstaged-3.txt', 'unstaged'); const selections0 = Array.from(stagingView.state.selection.getSelectedItems()); assert.lengthOf(selections0, 1); assert.equal(selections0[0].filePath, 'unstaged-3.txt'); assert.isFalse(stagingView.setFocus.called); await controller.focusAndSelectStagingItem('unstaged-2.txt', 'unstaged'); const selections1 = Array.from(stagingView.state.selection.getSelectedItems()); assert.lengthOf(selections1, 1); assert.equal(selections1[0].filePath, 'unstaged-2.txt'); assert.equal(stagingView.setFocus.callCount, 1); }); it('imperatively selects the commit preview button', async function() { const repository = await buildRepository(await cloneRepository('three-files')); const wrapper = mount(await buildApp(repository)); const focusMethod = sinon.spy(wrapper.find('GitTabView').instance(), 'focusAndSelectCommitPreviewButton'); wrapper.instance().focusAndSelectCommitPreviewButton(); assert.isTrue(focusMethod.called); }); it('imperatively selects the recent commit', async function() { const repository = await buildRepository(await cloneRepository('three-files')); const wrapper = mount(await buildApp(repository)); const focusMethod = sinon.spy(wrapper.find('GitTabView').instance(), 'focusAndSelectRecentCommit'); wrapper.instance().focusAndSelectRecentCommit(); assert.isTrue(focusMethod.called); }); describe('focus management', function() { it('remembers the last focus reported by the view', async function() { const repository = await buildRepository(await cloneRepository()); const wrapper = mount(await buildApp(repository)); const view = wrapper.instance().refView.get(); const editorElement = wrapper.find('AtomTextEditor').getDOMNode().querySelector('atom-text-editor'); const commitElement = wrapper.find('.github-CommitView-commit').getDOMNode(); wrapper.instance().rememberLastFocus({target: editorElement}); sinon.spy(view, 'setFocus'); wrapper.instance().restoreFocus(); assert.isTrue(view.setFocus.calledWith(GitTabController.focus.EDITOR)); wrapper.instance().rememberLastFocus({target: commitElement}); view.setFocus.resetHistory(); wrapper.instance().restoreFocus(); assert.isTrue(view.setFocus.calledWith(GitTabController.focus.COMMIT_BUTTON)); wrapper.instance().rememberLastFocus({target: document.body}); view.setFocus.resetHistory(); wrapper.instance().restoreFocus(); assert.isTrue(view.setFocus.calledWith(GitTabController.focus.STAGING)); wrapper.instance().refView.setter(null); view.setFocus.resetHistory(); wrapper.instance().restoreFocus(); assert.isFalse(view.setFocus.called); }); it('detects focus', async function() { const repository = await buildRepository(await cloneRepository()); const wrapper = mount(await buildApp(repository)); const rootElement = wrapper.instance().refRoot.get(); sinon.stub(rootElement, 'contains'); rootElement.contains.returns(true); assert.isTrue(wrapper.instance().hasFocus()); rootElement.contains.returns(false); assert.isFalse(wrapper.instance().hasFocus()); rootElement.contains.returns(true); wrapper.instance().refRoot.setter(null); assert.isFalse(wrapper.instance().hasFocus()); }); it('does nothing on an absent repository', async function() { const repository = Repository.absent(); const wrapper = mount(await buildApp(repository)); const controller = wrapper.instance(); assert.isTrue(wrapper.find('.is-empty').exists()); assert.lengthOf(wrapper.find('.no-repository'), 1); controller.rememberLastFocus({target: null}); assert.strictEqual(controller.lastFocus, GitTabController.focus.STAGING); }); }); describe('integration tests', function() { it('can stage and unstage files and commit', async function() { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'a change\n'); fs.unlinkSync(path.join(workdirPath, 'b.txt')); const ensureGitTab = () => Promise.resolve(false); const wrapper = mount(await buildApp(repository, {ensureGitTab})); await assert.async.lengthOf(wrapper.update().find('GitTabView').prop('unstagedChanges'), 2); const stagingView = wrapper.instance().refStagingView.get(); const commitView = wrapper.find('CommitView'); assert.lengthOf(stagingView.props.unstagedChanges, 2); assert.lengthOf(stagingView.props.stagedChanges, 0); await stagingView.dblclickOnItem({}, stagingView.props.unstagedChanges[0]).stageOperationPromise; await updateWrapper(repository, wrapper, {ensureGitTab}); assert.lengthOf(stagingView.props.unstagedChanges, 1); assert.lengthOf(stagingView.props.stagedChanges, 1); await stagingView.dblclickOnItem({}, stagingView.props.unstagedChanges[0]).stageOperationPromise; await updateWrapper(repository, wrapper, {ensureGitTab}); assert.lengthOf(stagingView.props.unstagedChanges, 0); assert.lengthOf(stagingView.props.stagedChanges, 2); await stagingView.dblclickOnItem({}, stagingView.props.stagedChanges[1]).stageOperationPromise; await updateWrapper(repository, wrapper, {ensureGitTab}); assert.lengthOf(stagingView.props.unstagedChanges, 1); assert.lengthOf(stagingView.props.stagedChanges, 1); commitView.find('AtomTextEditor').instance().getModel().setText('Make it so'); commitView.find('.github-CommitView-commit').simulate('click'); await assert.async.strictEqual((await repository.getLastCommit()).getMessageSubject(), 'Make it so'); }); it('can stage merge conflict files', async function() { const workdirPath = await cloneRepository('merge-conflict'); const repository = await buildRepository(workdirPath); await assert.isRejected(repository.git.merge('origin/branch')); const confirm = sinon.stub(); const props = {confirm}; const wrapper = mount(await buildApp(repository, props)); assert.lengthOf(wrapper.find('GitTabView').prop('mergeConflicts'), 5); const stagingView = wrapper.instance().refStagingView.get(); assert.equal(stagingView.props.mergeConflicts.length, 5); assert.equal(stagingView.props.stagedChanges.length, 0); const conflict1 = stagingView.props.mergeConflicts.filter(c => c.filePath === 'modified-on-both-ours.txt')[0]; const contentsWithMarkers = fs.readFileSync(path.join(workdirPath, conflict1.filePath), {encoding: 'utf8'}); assert.include(contentsWithMarkers, '>>>>>>>'); assert.include(contentsWithMarkers, '<<<<<<<'); // click Cancel confirm.returns(1); await stagingView.dblclickOnItem({}, conflict1).stageOperationPromise; await updateWrapper(repository, wrapper, props); assert.isTrue(confirm.calledOnce); assert.lengthOf(stagingView.props.mergeConflicts, 5); assert.lengthOf(stagingView.props.stagedChanges, 0); // click Stage confirm.reset(); confirm.returns(0); await stagingView.dblclickOnItem({}, conflict1).stageOperationPromise; await updateWrapper(repository, wrapper, props); assert.isTrue(confirm.calledOnce); assert.lengthOf(stagingView.props.mergeConflicts, 4); assert.lengthOf(stagingView.props.stagedChanges, 1); // clear merge markers const conflict2 = stagingView.props.mergeConflicts.filter(c => c.filePath === 'modified-on-both-theirs.txt')[0]; confirm.reset(); fs.writeFileSync(path.join(workdirPath, conflict2.filePath), 'text with no merge markers'); await stagingView.dblclickOnItem({}, conflict2).stageOperationPromise; await updateWrapper(repository, wrapper, props); assert.lengthOf(stagingView.props.mergeConflicts, 3); assert.lengthOf(stagingView.props.stagedChanges, 2); assert.isFalse(confirm.called); }); it('avoids conflicts with pending file staging operations', async function() { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); fs.unlinkSync(path.join(workdirPath, 'a.txt')); fs.unlinkSync(path.join(workdirPath, 'b.txt')); const wrapper = mount(await buildApp(repository)); const stagingView = wrapper.instance().refStagingView.get(); assert.lengthOf(stagingView.props.unstagedChanges, 2); // ensure staging the same file twice does not cause issues // second stage action is a no-op since the first staging operation is in flight const file1StagingPromises = stagingView.confirmSelectedItems(); stagingView.confirmSelectedItems(); await file1StagingPromises.stageOperationPromise; await updateWrapper(repository, wrapper); assert.lengthOf(stagingView.props.unstagedChanges, 1); const file2StagingPromises = stagingView.confirmSelectedItems(); await file2StagingPromises.stageOperationPromise; await updateWrapper(repository, wrapper); assert.lengthOf(stagingView.props.unstagedChanges, 0); }); it('updates file status and paths when changed', async function() { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); fs.writeFileSync(path.join(workdirPath, 'new-file.txt'), 'foo\nbar\nbaz\n'); const wrapper = mount(await buildApp(repository)); const stagingView = wrapper.instance().refStagingView.get(); assert.include(stagingView.props.unstagedChanges.map(c => c.filePath), 'new-file.txt'); const [addedFilePatch] = stagingView.props.unstagedChanges; assert.equal(addedFilePatch.filePath, 'new-file.txt'); assert.equal(addedFilePatch.status, 'added'); const patchString = dedent` --- /dev/null +++ b/new-file.txt @@ -0,0 +1,1 @@ +foo `; // partially stage contents in the newly added file await repository.git.applyPatch(patchString, {index: true}); await updateWrapper(repository, wrapper); // since unstaged changes are calculated relative to the index, // which now has new-file.txt on it, the working directory version of // new-file.txt has a modified status const [modifiedFilePatch] = stagingView.props.unstagedChanges; assert.strictEqual(modifiedFilePatch.status, 'modified'); assert.strictEqual(modifiedFilePatch.filePath, 'new-file.txt'); }); describe('amend', function() { let repository, commitMessage, workdirPath, wrapper; function getLastCommit() { return wrapper.find('RecentCommitView').at(0).prop('commit'); } beforeEach(async function() { workdirPath = await cloneRepository('three-files'); repository = await buildRepository(workdirPath); wrapper = mount(await buildApp(repository)); commitMessage = 'most recent commit woohoo'; fs.writeFileSync(path.join(workdirPath, 'foo.txt'), 'oh\nem\ngee\n'); await repository.stageFiles(['foo.txt']); await repository.commit(commitMessage); await updateWrapper(repository, wrapper); assert.strictEqual(getLastCommit().getMessageSubject(), commitMessage); sinon.spy(repository, 'commit'); }); describe('when there are staged changes only', function() { it('uses the last commit\'s message since there is no new message', async function() { // stage some changes fs.writeFileSync(path.join(workdirPath, 'new-file.txt'), 'oh\nem\ngee\n'); await repository.stageFiles(['new-file.txt']); await updateWrapper(repository, wrapper); assert.lengthOf(wrapper.find('GitTabView').prop('stagedChanges'), 1); // ensure that the commit editor is empty assert.strictEqual( wrapper.find('CommitView').instance().refEditorModel.map(e => e.getText()).getOr(undefined), '', ); commands.dispatch(workspaceElement, 'github:amend-last-commit'); await assert.async.deepEqual( repository.commit.args[0][1], {amend: true, coAuthors: [], verbatim: true}, ); // amending should commit all unstaged changes await updateWrapper(repository, wrapper); assert.lengthOf(wrapper.find('GitTabView').prop('stagedChanges'), 0); // commit message from previous commit should be used assert.equal(getLastCommit().getMessageSubject(), commitMessage); }); }); describe('when there is a new commit message provided (and no staged changes)', function() { it('discards the last commit\'s message and uses the new one', async function() { // new commit message const newMessage = 'such new very message'; const commitView = wrapper.find('CommitView'); commitView.instance().refEditorModel.map(e => e.setText(newMessage)); // no staged changes assert.lengthOf(wrapper.find('GitTabView').prop('stagedChanges'), 0); commands.dispatch(workspaceElement, 'github:amend-last-commit'); await assert.async.deepEqual( repository.commit.args[0][1], {amend: true, coAuthors: [], verbatim: true}, ); await updateWrapper(repository, wrapper); // new commit message is used assert.strictEqual(getLastCommit().getMessageSubject(), newMessage); }); }); describe('when co-authors are changed', function() { it('amends the last commit re-using the commit message and adding the co-author', async function() { // verify that last commit has no co-author const commitBeforeAmend = getLastCommit(); assert.deepEqual(commitBeforeAmend.coAuthors, []); // add co author const author = new Author('foo@bar.com', 'foo bar'); const commitView = wrapper.find('CommitView').instance(); commitView.setState({showCoAuthorInput: true}); commitView.onSelectedCoAuthorsChanged([author]); await updateWrapper(repository, wrapper); commands.dispatch(workspaceElement, 'github:amend-last-commit'); // verify that coAuthor was passed await assert.async.deepEqual( repository.commit.args[0][1], {amend: true, coAuthors: [author], verbatim: true}, ); await repository.commit.returnValues[0]; await updateWrapper(repository, wrapper); assert.deepEqual(getLastCommit().coAuthors, [author]); assert.strictEqual(getLastCommit().getMessageSubject(), commitBeforeAmend.getMessageSubject()); }); it('uses a new commit message if provided', async function() { // verify that last commit has no co-author const commitBeforeAmend = getLastCommit(); assert.deepEqual(commitBeforeAmend.coAuthors, []); // add co author const author = new Author('foo@bar.com', 'foo bar'); const commitView = wrapper.find('CommitView').instance(); commitView.setState({showCoAuthorInput: true}); commitView.onSelectedCoAuthorsChanged([author]); const newMessage = 'Star Wars: A New Message'; commitView.refEditorModel.map(e => e.setText(newMessage)); commands.dispatch(workspaceElement, 'github:amend-last-commit'); // verify that coAuthor was passed await assert.async.deepEqual( repository.commit.args[0][1], {amend: true, coAuthors: [author], verbatim: true}, ); await repository.commit.returnValues[0]; await updateWrapper(repository, wrapper); // verify that commit message has coauthor assert.deepEqual(getLastCommit().coAuthors, [author]); assert.strictEqual(getLastCommit().getMessageSubject(), newMessage); }); it('successfully removes a co-author', async function() { const message = 'We did this together!'; const author = new Author('mona@lisa.com', 'Mona Lisa'); const commitMessageWithCoAuthors = dedent` ${message} Co-authored-by: ${author.getFullName()} <${author.getEmail()}> `; await repository.git.exec(['commit', '--amend', '-m', commitMessageWithCoAuthors]); await updateWrapper(repository, wrapper); // verify that commit message has coauthor assert.deepEqual(getLastCommit().coAuthors, [author]); assert.strictEqual(getLastCommit().getMessageSubject(), message); // buh bye co author const commitView = wrapper.find('CommitView').instance(); assert.strictEqual(commitView.refEditorModel.map(e => e.getText()).getOr(''), ''); commitView.onSelectedCoAuthorsChanged([]); // amend again commands.dispatch(workspaceElement, 'github:amend-last-commit'); // verify that NO coAuthor was passed await assert.async.deepEqual( repository.commit.args[0][1], {amend: true, coAuthors: [], verbatim: true}, ); await repository.commit.returnValues[0]; await updateWrapper(repository, wrapper); // assert that no co-authors are in last commit assert.deepEqual(getLastCommit().coAuthors, []); assert.strictEqual(getLastCommit().getMessageSubject(), message); }); }); }); describe('undoLastCommit()', function() { it('does nothing when there are no commits', async function() { const workdirPath = await initRepository(); const repository = await buildRepository(workdirPath); const wrapper = mount(await buildApp(repository)); await assert.isFulfilled(wrapper.instance().undoLastCommit()); }); it('restores to the state prior to committing', async function() { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); sinon.spy(repository, 'undoLastCommit'); fs.writeFileSync(path.join(workdirPath, 'new-file.txt'), 'foo\nbar\nbaz\n'); const coAuthorName = 'Janelle Monae'; const coAuthorEmail = 'janellemonae@github.com'; await repository.stageFiles(['new-file.txt']); const commitSubject = 'Commit some stuff'; const commitMessage = dedent` ${commitSubject} Co-authored-by: ${coAuthorName} <${coAuthorEmail}> `; await repository.commit(commitMessage); const wrapper = mount(await buildApp(repository)); assert.deepEqual(wrapper.find('CommitView').prop('selectedCoAuthors'), []); // ensure that the co author trailer is stripped from commit message let commitMessages = wrapper.find('.github-RecentCommit-message').map(node => node.text()); assert.deepEqual(commitMessages, [commitSubject, 'Initial commit']); assert.lengthOf(wrapper.find('.github-RecentCommit-undoButton'), 1); wrapper.find('.github-RecentCommit-undoButton').simulate('click'); await assert.async.isTrue(repository.undoLastCommit.called); await repository.undoLastCommit.returnValues[0]; await updateWrapper(repository, wrapper); assert.lengthOf(wrapper.find('GitTabView').prop('stagedChanges'), 1); assert.deepEqual(wrapper.find('GitTabView').prop('stagedChanges'), [{ filePath: 'new-file.txt', status: 'added', }]); commitMessages = wrapper.find('.github-RecentCommit-message').map(node => node.text()); assert.deepEqual(commitMessages, ['Initial commit']); const expectedCoAuthor = new Author(coAuthorEmail, coAuthorName); assert.strictEqual(wrapper.find('CommitView').prop('messageBuffer').getText(), commitSubject); assert.deepEqual(wrapper.find('CommitView').prop('selectedCoAuthors'), [expectedCoAuthor]); }); }); }); });