diff --git a/lib/controllers/repository-conflict-controller.js b/lib/controllers/repository-conflict-controller.js index 6e4a122133..89d71873c2 100644 --- a/lib/controllers/repository-conflict-controller.js +++ b/lib/controllers/repository-conflict-controller.js @@ -82,9 +82,9 @@ export default class RepositoryConflictController extends React.Component { return []; } - const basePath = this.props.workingDirectoryPath; + const commonBasePath = this.props.workingDirectoryPath; const fullMergeConflictPaths = new Set( - this.props.mergeConflictPaths.map(relativePath => path.join(basePath, relativePath)), + this.props.mergeConflictPaths.map(relativePath => path.join(commonBasePath, relativePath)), ); return this.state.openEditors.filter(editor => fullMergeConflictPaths.has(editor.getPath())); diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index 88910f8307..c8d9af8cee 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -21,7 +21,7 @@ import RepositoryConflictController from './repository-conflict-controller'; import ModelObserver from '../models/model-observer'; import ModelStateRegistry from '../models/model-state-registry'; import Conflict from '../models/conflicts/conflict'; -import {copyFile} from '../helpers'; +import {copyFile, deleteFileOrFolder} from '../helpers'; import {GitError} from '../git-shell-out-strategy'; const nullFilePatchState = { @@ -421,7 +421,7 @@ export default class RootController extends React.Component { }; return await this.props.repository.storeBeforeAndAfterBlobs( filePaths, - () => this.ensureNoUnsavedFiles(filePaths, 'Cannot discard lines.'), + () => this.ensureNoUnsavedFiles(filePaths, 'Cannot discard changes in selected files.'), destructiveAction, ); } @@ -509,9 +509,17 @@ export default class RootController extends React.Component { async proceedWithLastDiscardUndo(results, partialDiscardFilePath = null) { const promises = results.map(async result => { - const {filePath, resultPath} = result; + const {filePath, resultPath, deleted, conflict, theirsSha, commonBaseSha} = result; const absFilePath = path.join(this.props.repository.getWorkingDirectoryPath(), filePath); - await copyFile(resultPath, absFilePath); + if (deleted && resultPath === null) { + await deleteFileOrFolder(absFilePath); + } else { + await copyFile(resultPath, absFilePath); + } + if (conflict) { + const currentSha = await this.props.repository.createBlob({filePath}); + await this.props.repository.writeMergeConflictToIndex(filePath, commonBaseSha, currentSha, theirsSha); + } }); await Promise.all(promises); await this.props.repository.popDiscardHistory(partialDiscardFilePath); diff --git a/lib/git-shell-out-strategy.js b/lib/git-shell-out-strategy.js index 283896f63f..b18495ee4c 100644 --- a/lib/git-shell-out-strategy.js +++ b/lib/git-shell-out-strategy.js @@ -8,7 +8,7 @@ import {parse as parseDiff} from 'what-the-diff'; import GitPromptServer from './git-prompt-server'; import AsyncQueue from './async-queue'; -import {readFile, fileExists, writeFile, fsStat, deleteFileOrFolder} from './helpers'; +import {readFile, fileExists, writeFile, isFileExecutable} from './helpers'; import GitTimingsView from './views/git-timings-view'; const LINE_ENDING_REGEX = /\r?\n/; @@ -247,7 +247,6 @@ export default class GitShellOutStrategy { const args = ['diff', '--name-status', '--no-renames']; if (options.staged) { args.push('--staged'); } if (options.target) { args.push(options.target); } - if (options.diffFilter === 'unmerged') { args.push('--diff-filter=U'); } const output = await this.exec(args); const statusMap = { @@ -288,9 +287,9 @@ export default class GitShellOutStrategy { if (!staged && (await this.getUntrackedFiles()).includes(filePath)) { // add untracked file const absPath = path.join(this.workingDir, filePath); - const stats = await fsStat(absPath); + const executable = await isFileExecutable(absPath); const contents = await readFile(absPath); - rawDiffs.push(buildAddedFilePatch(filePath, contents, stats)); + rawDiffs.push(buildAddedFilePatch(filePath, contents, executable)); } if (rawDiffs.length > 1) { throw new Error(`Expected 0 or 1 diffs for ${filePath} but got ${rawDiffs.length}`); } return rawDiffs[0]; @@ -457,26 +456,11 @@ export default class GitShellOutStrategy { return this.exec(args.concat(branchName), {writeOperation: true}); } - async checkoutFiles(paths, revision) { + checkoutFiles(paths, revision) { if (paths.length === 0) { return null; } - try { - const args = ['checkout']; - if (revision) { args.push(revision); } - return await this.exec(args.concat('--', paths), {writeOperation: true}); - } catch (error) { - const matches = error.stdErr.match(/error: pathspec .* did not match any file\(s\) known to git\./g); - if (matches.length) { - const filePaths = matches.map(line => { - return line.match(/error: pathspec '(.*)' did not match any file\(s\) known to git\./)[1]; - }); - return Promise.all(filePaths.map(filePath => { - const absPath = path.join(this.workingDir, filePath); - return deleteFileOrFolder(absPath); - })); - } else { - return Promise.resolve(); - } - } + const args = ['checkout']; + if (revision) { args.push(revision); } + return this.exec(args.concat('--', paths), {writeOperation: true}); } async getBranches() { @@ -533,7 +517,11 @@ export default class GitShellOutStrategy { try { output = (await this.exec(['hash-object', '-w', filePath], {writeOperation: true})).trim(); } catch (e) { - output = null; + if (e.stdErr.match(/fatal: Cannot open .*: No such file or directory/)) { + output = null; + } else { + throw e; + } } } else if (stdin) { output = (await this.exec(['hash-object', '-w', '--stdin'], {stdin, writeOperation: true})).trim(); @@ -553,9 +541,9 @@ export default class GitShellOutStrategy { return await this.exec(['cat-file', '-p', sha]); } - async mergeFile(currentPath, basePath, otherPath, resultPath) { + async mergeFile(oursPath, commonBasePath, theirsPath, resultPath) { const args = [ - 'merge-file', '-p', currentPath, basePath, otherPath, + 'merge-file', '-p', oursPath, commonBasePath, theirsPath, '-L', 'current', '-L', 'after discard', '-L', 'before discard', ]; let output; @@ -571,11 +559,30 @@ export default class GitShellOutStrategy { } } await writeFile(resultPath, output); - return {filePath: currentPath, resultPath, conflict}; + return {filePath: oursPath, resultPath, conflict}; + } + + async writeMergeConflictToIndex(filePath, commonBaseSha, oursSha, theirsSha) { + const fileMode = await this.getFileMode(filePath); + let indexInfo = `0 0000000000000000000000000000000000000000\t${filePath}\n`; + if (commonBaseSha) { indexInfo += `${fileMode} ${commonBaseSha} 1\t${filePath}\n`; } + if (oursSha) { indexInfo += `${fileMode} ${oursSha} 2\t${filePath}\n`; } + if (theirsSha) { indexInfo += `${fileMode} ${theirsSha} 3\t${filePath}\n`; } + return this.exec(['update-index', '--index-info'], {stdin: indexInfo, writeOperation: true}); + } + + async getFileMode(filePath) { + const output = await this.exec(['ls-files', '--stage', '--', filePath]); + if (output) { + return output.slice(0, 6); + } else { + const executable = await isFileExecutable(path.join(this.workingDir, filePath)); + return executable ? '100755' : '100644'; + } } } -function buildAddedFilePatch(filePath, contents, stats) { +function buildAddedFilePatch(filePath, contents, executable) { const hunks = []; if (contents) { const noNewLine = contents[contents.length - 1] !== '\n'; @@ -590,7 +597,6 @@ function buildAddedFilePatch(filePath, contents, stats) { newLineCount: noNewLine ? lines.length - 1 : lines.length, }); } - const executable = Boolean((stats >> 6) && 1); // eslint-disable-line no-bitwise return { oldPath: null, newPath: filePath, diff --git a/lib/helpers.js b/lib/helpers.js index fafcde1671..1e8e8d9471 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -127,6 +127,11 @@ export function fsStat(absoluteFilePath) { }); } +export async function isFileExecutable(absoluteFilePath) { + const stat = await fsStat(absoluteFilePath); + return stat.mode & fs.constants.S_IXUSR; // eslint-disable-line no-bitwise +} + export function shortenSha(sha) { return sha.slice(0, 8); } diff --git a/lib/models/discard-history.js b/lib/models/discard-history.js index 546b326274..54f59bb771 100644 --- a/lib/models/discard-history.js +++ b/lib/models/discard-history.js @@ -1,15 +1,21 @@ import path from 'path'; import os from 'os'; +import mkdirp from 'mkdirp'; + import {PartialFileDiscardHistory, WholeFileDiscardHistory} from './discard-history-stores'; -import {getTempDir} from '../helpers'; +import {getTempDir, copyFile, fileExists, writeFile} from '../helpers'; + +const emptyFilePath = path.join(os.tmpdir(), 'empty-file.txt'); +const emptyFilePromise = writeFile(emptyFilePath, ''); export default class DiscardHistory { - constructor(createBlob, expandBlobToFile, mergeFile, {maxHistoryLength} = {}) { + constructor(createBlob, expandBlobToFile, mergeFile, workdirPath, {maxHistoryLength} = {}) { this.createBlob = createBlob; this.expandBlobToFile = expandBlobToFile; this.mergeFile = mergeFile; + this.workdirPath = workdirPath; this.partialFileHistory = new PartialFileDiscardHistory(maxHistoryLength); this.wholeFileHistory = new WholeFileDiscardHistory(maxHistoryLength); } @@ -114,25 +120,46 @@ export default class DiscardHistory { async expandBlobsToFilesInTempFolder(snapshots) { const tempFolderPath = await getTempDir(path.join(os.tmpdir(), 'github-discard-history-')); const pathPromises = snapshots.map(async ({filePath, beforeSha, afterSha}) => { - const otherPath = !beforeSha ? null : + const dir = path.dirname(path.join(tempFolderPath, filePath)); + await mkdirp(dir); + const theirsPath = !beforeSha ? null : await this.expandBlobToFile(path.join(tempFolderPath, `${filePath}-before-discard`), beforeSha); - const basePath = !afterSha ? null : + const commonBasePath = !afterSha ? null : await this.expandBlobToFile(path.join(tempFolderPath, `${filePath}-after-discard`), afterSha); - const resultPath = path.join(tempFolderPath, `${filePath}-merge-result`); - return {filePath, basePath, otherPath, resultPath}; + const resultPath = path.join(tempFolderPath, `~${path.basename(filePath)}-merge-result`); + return {filePath, commonBasePath, theirsPath, resultPath, theirsSha: beforeSha, commonBaseSha: afterSha}; }); return await Promise.all(pathPromises); } async mergeFiles(filePaths) { - const mergeFilePromises = filePaths.map(({filePath, basePath, otherPath, resultPath}) => { - if (otherPath && basePath) { - return this.mergeFile(filePath, basePath, otherPath, resultPath); - } else if (!otherPath && basePath) { // deleted file - // TODO: handle this - return null; + const mergeFilePromises = filePaths.map(async (filePathInfo, i) => { + const {filePath, commonBasePath, theirsPath, resultPath, theirsSha, commonBaseSha} = filePathInfo; + let mergeResult; + if (theirsPath && commonBasePath) { + mergeResult = await this.mergeFile(filePath, commonBasePath, theirsPath, resultPath); + } else if (!theirsPath && commonBasePath) { // deleted file + const oursSha = await this.createBlob({filePath}); + if (oursSha === commonBaseSha) { // no changes since discard, mark file to be deleted + mergeResult = {filePath, resultPath: null, deleted: true, conflict: false}; + } else { // changes since discard result in conflict + await copyFile(path.join(this.workdirPath, filePath), resultPath); + mergeResult = {filePath, resultPath, conflict: true}; + } + } else if (theirsPath && !commonBasePath) { // added file + const fileDoesExist = await fileExists(path.join(this.workdirPath, filePath)); + if (!fileDoesExist) { + await copyFile(theirsPath, resultPath); + mergeResult = {filePath, resultPath, conflict: false}; + } else { + await emptyFilePromise; + mergeResult = await this.mergeFile(filePath, emptyFilePath, theirsPath, resultPath); + } + } else { + throw new Error('One of the following must be defined - theirsPath:' + + `${theirsPath} or commonBasePath: ${commonBasePath}`); } - return null; + return {...mergeResult, theirsSha, commonBaseSha}; }); return await Promise.all(mergeFilePromises); } diff --git a/lib/models/repository.js b/lib/models/repository.js index ee84a3a51c..6763656189 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -8,10 +8,23 @@ import FilePatch from './file-patch'; import Hunk from './hunk'; import HunkLine from './hunk-line'; import DiscardHistory from './discard-history'; -import {readFile} from '../helpers'; +import {readFile, deleteFileOrFolder} from '../helpers'; const MERGE_MARKER_REGEX = /^(>|<){7} \S+$/m; +function partition(array, predicate) { + const matches = []; + const nonmatches = []; + array.forEach(item => { + if (predicate(item)) { + matches.push(item); + } else { + nonmatches.push(item); + } + }); + return [matches, nonmatches]; +} + export class AbortMergeError extends Error { constructor(code, filePath) { super(); @@ -80,6 +93,7 @@ export default class Repository { this.createBlob, this.expandBlobToFile, this.mergeFile, + this.workingDirectoryPath, {maxHistoryLength: 60}, ); if (history) { this.discardHistory.updateHistory(history); } @@ -438,8 +452,12 @@ export default class Repository { } @autobind - mergeFile(currentPath, basePath, otherPath, resultPath) { - return this.git.mergeFile(currentPath, basePath, otherPath, resultPath); + mergeFile(oursPath, commonBasePath, theirsPath, resultPath) { + return this.git.mergeFile(oursPath, commonBasePath, theirsPath, resultPath); + } + + writeMergeConflictToIndex(filePath, commonBaseSha, oursSha, theirsSha) { + return this.git.writeMergeConflictToIndex(filePath, commonBaseSha, oursSha, theirsSha); } async createDiscardHistoryBlob() { @@ -496,7 +514,14 @@ export default class Repository { return this.discardHistory.getLastSnapshots(partialDiscardFilePath); } + @autobind async discardWorkDirChangesForPaths(paths) { - await this.git.checkoutFiles(paths); + const untrackedFiles = await this.git.getUntrackedFiles(); + const [filesToRemove, filesToCheckout] = partition(paths, f => untrackedFiles.includes(f)); + await this.git.checkoutFiles(filesToCheckout); + await Promise.all(filesToRemove.map(filePath => { + const absPath = path.join(this.getWorkingDirectoryPath(), filePath); + return deleteFileOrFolder(absPath); + })); } } diff --git a/lib/views/push-pull-menu-view.js b/lib/views/push-pull-menu-view.js index b522756a57..73492506bd 100644 --- a/lib/views/push-pull-menu-view.js +++ b/lib/views/push-pull-menu-view.js @@ -11,6 +11,10 @@ export default class PushPullMenuView extends React.Component { remoteName: React.PropTypes.string, aheadCount: React.PropTypes.number, behindCount: React.PropTypes.number, + notificationManager: React.PropTypes.object, + fetch: React.PropTypes.func, + push: React.PropTypes.func, + pull: React.PropTypes.func, } static defaultProps = { diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index d7b18ffc72..2a92abd51e 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -19,7 +19,6 @@ describe('RootController', function() { tooltips = atomEnv.tooltips; confirm = sinon.stub(atomEnv, 'confirm'); - app = ( { + 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); + + 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')); + + app = React.cloneElement(app, {repository}); + const wrapper = shallow(app); + + 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/); + }); + }); + describe('undoLastDiscard(partialDiscardFilePath)', () => { describe('when partialDiscardFilePath is not null', () => { let unstagedFilePatch, repository, absFilePath, wrapper; @@ -588,65 +619,86 @@ describe('RootController', function() { }); }); - describe('when partial option is false', () => { - let repository, wrapper, pathA, pathB, pathC; + describe('when partialDiscardFilePath is falsey', () => { + let repository, workdirPath, wrapper, pathA, pathB, pathDeleted, pathAdded, getFileContents; beforeEach(async () => { - const workdirPath = await cloneRepository('three-files'); + 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, 'b.txt'); - pathC = path.join(workdirPath, 'c.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(pathC, ['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', async () => { + it('reverses last discard if there are no conflicts', async () => { const contents1 = { - pathA: fs.readFileSync(pathA, 'utf8'), - pathB: fs.readFileSync(pathB, 'utf8'), - pathC: fs.readFileSync(pathC, 'utf8'), + pathA: getFileContents(pathA), + pathB: getFileContents(pathB), + pathDeleted: getFileContents(pathDeleted), + pathAdded: getFileContents(pathAdded), }; - await wrapper.instance().discardWorkDirChangesForPaths(['a.txt', 'b.txt']); + await wrapper.instance().discardWorkDirChangesForPaths(['a.txt', 'subdir-1/b.txt']); const contents2 = { - pathA: fs.readFileSync(pathA, 'utf8'), - pathB: fs.readFileSync(pathB, 'utf8'), - pathC: fs.readFileSync(pathC, 'utf8'), + pathA: getFileContents(pathA), + pathB: getFileContents(pathB), + pathDeleted: getFileContents(pathDeleted), + pathAdded: getFileContents(pathAdded), }; assert.notDeepEqual(contents1, contents2); - await wrapper.instance().discardWorkDirChangesForPaths(['c.txt']); + await wrapper.instance().discardWorkDirChangesForPaths(['c.txt', 'added-file.txt']); const contents3 = { - pathA: fs.readFileSync(pathA, 'utf8'), - pathB: fs.readFileSync(pathB, 'utf8'), - pathC: fs.readFileSync(pathC, 'utf8'), + 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: fs.readFileSync(pathA, 'utf8'), - pathB: fs.readFileSync(pathB, 'utf8'), - pathC: fs.readFileSync(pathC, 'utf8'), + pathA: getFileContents(pathA), + pathB: getFileContents(pathB), + pathDeleted: getFileContents(pathDeleted), + pathAdded: getFileContents(pathAdded), }, contents2); await wrapper.instance().undoLastDiscard(); await assert.async.deepEqual({ - pathA: fs.readFileSync(pathA, 'utf8'), - pathB: fs.readFileSync(pathB, 'utf8'), - pathC: fs.readFileSync(pathC, 'utf8'), + pathA: getFileContents(pathA), + pathB: getFileContents(pathB), + pathDeleted: getFileContents(pathDeleted), + pathAdded: getFileContents(pathAdded), }, contents1); }); it('does not undo if buffer is modified', async () => { - await wrapper.instance().discardWorkDirChangesForPaths(['a.txt', 'b.txt', 'c.txt']); + await wrapper.instance().discardWorkDirChangesForPaths(['a.txt', 'subdir-1/b.txt', 'c.txt', 'added-file.txt']); // 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'); const expandBlobToFile = sinon.spy(repository, 'expandBlobToFile'); sinon.stub(notificationManager, 'addError'); @@ -656,65 +708,80 @@ describe('RootController', function() { 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, /b.txt/); - assert.notMatch(notificationArgs[1].description, /c.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); }); describe('when file content has changed since last discard', () => { it('successfully undoes discard if changes do not conflict', async () => { - await repository.git.exec(['commit', '-am', 'commit files lengthy enough that changes don\'t conflict']); + 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']); - const originalContents = { - pathA: fs.readFileSync(pathA, 'utf8'), - pathB: fs.readFileSync(pathB, 'utf8'), - pathC: fs.readFileSync(pathC, 'utf8'), - }; + pathAdded = path.join(workdirPath, 'another-added-file.txt'); - // add change to beginning of files + // 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.writeFileSync(pathC, 'change at beginning\n' + fs.readFileSync(pathC, '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', 'b.txt', 'c.txt']); + 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'); - fs.writeFileSync(pathC, fs.readFileSync(pathC, 'utf8') + 'change at end'); await wrapper.instance().undoLastDiscard(); await assert.async.deepEqual({ - pathA: fs.readFileSync(pathA, 'utf8'), - pathB: fs.readFileSync(pathB, 'utf8'), - pathC: fs.readFileSync(pathC, 'utf8'), + pathA: getFileContents(pathA), + pathB: getFileContents(pathB), + pathDeleted: getFileContents(pathDeleted), + pathAdded: getFileContents(pathAdded), }, { - pathA: 'change at beginning\n' + originalContents.pathA + 'change at end', - pathB: 'change at beginning\n' + originalContents.pathB + 'change at end', - pathC: 'change at beginning\n' + originalContents.pathC + 'change at end', + 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', async () => { - await repository.git.exec(['commit', '-am', 'commit files lengthy enough that changes don\'t conflict']); + 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']); - // add change to beginning of files + 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.writeFileSync(pathC, 'change at beginning\n' + fs.readFileSync(pathC, 'utf8')); + fs.unlinkSync(pathDeleted); + fs.writeFileSync(pathAdded, 'foo\nbar\baz\n'); - await wrapper.instance().discardWorkDirChangesForPaths(['a.txt', 'b.txt', 'c.txt']); + await wrapper.instance().discardWorkDirChangesForPaths(['a.txt', 'subdir-1/b.txt', 'deleted-file.txt', 'another-added-file.txt']); - // two files are changed in a conflicting way + // 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(pathC, fs.readFileSync(pathC, 'utf8') + 'change at end'); + fs.writeFileSync(pathDeleted, 'conflicting change\n'); + fs.writeFileSync(pathAdded, 'conflicting change\n'); const contentsAfterConflictingChange = { - pathA: fs.readFileSync(pathA, 'utf8'), - pathB: fs.readFileSync(pathB, 'utf8'), - pathC: fs.readFileSync(pathC, 'utf8'), + pathA: getFileContents(pathA), + pathB: getFileContents(pathB), + pathDeleted: getFileContents(pathDeleted), + pathAdded: getFileContents(pathAdded), }; // click 'Cancel' @@ -724,9 +791,10 @@ describe('RootController', function() { const confirmArg = confirm.args[0][0]; assert.match(confirmArg.message, /Undoing will result in conflicts/); await assert.async.deepEqual({ - pathA: fs.readFileSync(pathA, 'utf8'), - pathB: fs.readFileSync(pathB, 'utf8'), - pathC: fs.readFileSync(pathC, 'utf8'), + pathA: getFileContents(pathA), + pathB: getFileContents(pathB), + pathDeleted: getFileContents(pathDeleted), + pathAdded: getFileContents(pathAdded), }, contentsAfterConflictingChange); // click 'Open in new editors' @@ -738,37 +806,55 @@ describe('RootController', function() { const pB = b.getFileName(); if (pA < pB) { return -1; } else if (pA > pB) { return 1; } else { return 0; } }); - assert.equal(editors.length, 2); + 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(), /b.txt-/); + + 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: fs.readFileSync(pathA, 'utf8'), - pathB: fs.readFileSync(pathB, 'utf8'), - pathC: fs.readFileSync(pathC, 'utf8'), + 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.pathC.includes('<<<<<<<')); - await assert.async.isFalse(contentsAfterUndo.pathC.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']); }); }); 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', 'b.txt']); - const snapshots = await wrapper.instance().discardWorkDirChangesForPaths(['c.txt']); - const {beforeSha} = snapshots['c.txt']; + 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))); @@ -783,6 +869,5 @@ describe('RootController', function() { }); }); }); - }); }); diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js index b08a6b14fb..89a539a0d8 100644 --- a/test/git-strategies.test.js +++ b/test/git-strategies.test.js @@ -2,11 +2,13 @@ import fs from 'fs-extra'; import path from 'path'; import mkdirp from 'mkdirp'; +import dedent from 'dedent-js'; import CompositeGitStrategy from '../lib/composite-git-strategy'; import GitShellOutStrategy, {GitError} from '../lib/git-shell-out-strategy'; import {cloneRepository, initRepository, assertDeepPropertyVals, setUpLocalAndRemoteRepositories} from './helpers'; +import {fsStat} from '../lib/helpers'; /** * KU Thoughts: The GitShellOutStrategy methods are tested in Repository tests for the most part @@ -742,5 +744,120 @@ import {cloneRepository, initRepository, assertDeepPropertyVals, setUpLocalAndRe assert.equal(contents, 'foo\nbar\nbaz\n'); }); }); + + describe('getFileMode(filePath)', () => { + it('returns the file mode of the specified file', async () => { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + const absFilePath = path.join(workingDirPath, 'a.txt'); + fs.writeFileSync(absFilePath, 'qux\nfoo\nbar\n', 'utf8'); + + assert.equal(await git.getFileMode('a.txt'), '100644'); + + await git.exec(['update-index', '--chmod=+x', 'a.txt']); + assert.equal(await git.getFileMode('a.txt'), '100755'); + }); + + it('returns the file mode for untracked files', async () => { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + const absFilePath = path.join(workingDirPath, 'new-file.txt'); + fs.writeFileSync(absFilePath, 'qux\nfoo\nbar\n', 'utf8'); + const regularMode = await fsStat(absFilePath).mode; + const executableMode = regularMode | fs.constants.S_IXUSR; // eslint-disable-line no-bitwise + + assert.equal(await git.getFileMode('new-file.txt'), '100644'); + + fs.chmodSync(absFilePath, executableMode); + const expectedFileMode = process.platform === 'win32' ? '100644' : '100755'; + assert.equal(await git.getFileMode('new-file.txt'), expectedFileMode); + }); + }); + + describe('merging files', () => { + describe('mergeFile(oursPath, commonBasePath, theirsPath, resultPath)', () => { + it('merges ours/base/theirsPaths and writes to resultPath, returning {filePath, resultPath, conflicts}', async () => { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + + // current and other paths are the same, so no conflicts + const resultsWithoutConflict = await git.mergeFile('a.txt', 'b.txt', 'a.txt', 'results-without-conflict.txt'); + assert.deepEqual(resultsWithoutConflict, { + filePath: 'a.txt', + resultPath: 'results-without-conflict.txt', + conflict: false, + }); + assert.equal(fs.readFileSync('results-without-conflict.txt', 'utf8'), fs.readFileSync(path.join(workingDirPath, 'a.txt'), 'utf8')); + + // contents of current and other paths conflict + const resultsWithConflict = await git.mergeFile('a.txt', 'b.txt', 'c.txt', 'results-with-conflict.txt'); + assert.deepEqual(resultsWithConflict, { + filePath: 'a.txt', + resultPath: 'results-with-conflict.txt', + conflict: true, + }); + const contents = fs.readFileSync('results-with-conflict.txt', 'utf8'); + assert.isTrue(contents.includes('<<<<<<<')); + assert.isTrue(contents.includes('>>>>>>>')); + }); + }); + + describe('udpateIndex(filePath, commonBaseSha, oursSha, theirsSha)', () => { + it('updates the index to have the appropriate shas, retaining the original file mode', async () => { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + const absFilePath = path.join(workingDirPath, 'a.txt'); + fs.writeFileSync(absFilePath, 'qux\nfoo\nbar\n', 'utf8'); + await git.exec(['update-index', '--chmod=+x', 'a.txt']); + + const commonBaseSha = '7f95a814cbd9b366c5dedb6d812536dfef2fffb7'; + const oursSha = '95d4c5b7b96b3eb0853f586576dc8b5ac54837e0'; + const theirsSha = '5da808cc8998a762ec2761f8be2338617f8f12d9'; + await git.writeMergeConflictToIndex('a.txt', commonBaseSha, oursSha, theirsSha); + + const index = await git.exec(['ls-files', '--stage', '--', 'a.txt']); + assert.equal(index.trim(), dedent` + 100755 ${commonBaseSha} 1\ta.txt + 100755 ${oursSha} 2\ta.txt + 100755 ${theirsSha} 3\ta.txt + `); + }); + + it('handles the case when oursSha, commonBaseSha, or theirsSha is null', async () => { + const workingDirPath = await cloneRepository('three-files'); + const git = createTestStrategy(workingDirPath); + const absFilePath = path.join(workingDirPath, 'a.txt'); + fs.writeFileSync(absFilePath, 'qux\nfoo\nbar\n', 'utf8'); + await git.exec(['update-index', '--chmod=+x', 'a.txt']); + + const commonBaseSha = '7f95a814cbd9b366c5dedb6d812536dfef2fffb7'; + const oursSha = '95d4c5b7b96b3eb0853f586576dc8b5ac54837e0'; + const theirsSha = '5da808cc8998a762ec2761f8be2338617f8f12d9'; + await git.writeMergeConflictToIndex('a.txt', commonBaseSha, null, theirsSha); + + let index = await git.exec(['ls-files', '--stage', '--', 'a.txt']); + assert.equal(index.trim(), dedent` + 100755 ${commonBaseSha} 1\ta.txt + 100755 ${theirsSha} 3\ta.txt + `); + + await git.writeMergeConflictToIndex('a.txt', commonBaseSha, oursSha, null); + + index = await git.exec(['ls-files', '--stage', '--', 'a.txt']); + assert.equal(index.trim(), dedent` + 100755 ${commonBaseSha} 1\ta.txt + 100755 ${oursSha} 2\ta.txt + `); + + await git.writeMergeConflictToIndex('a.txt', null, oursSha, theirsSha); + + index = await git.exec(['ls-files', '--stage', '--', 'a.txt']); + assert.equal(index.trim(), dedent` + 100755 ${oursSha} 2\ta.txt + 100755 ${theirsSha} 3\ta.txt + `); + }); + }); + }); }); }); diff --git a/test/models/repository.test.js b/test/models/repository.test.js index c10988f27a..a6957e637c 100644 --- a/test/models/repository.test.js +++ b/test/models/repository.test.js @@ -672,9 +672,10 @@ describe('Repository', function() { const repo = await buildRepository(workingDirPath); fs.writeFileSync(path.join(workingDirPath, 'subdir-1', 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); fs.writeFileSync(path.join(workingDirPath, 'subdir-1', 'b.txt'), 'qux\nfoo\nbar\n', 'utf8'); + fs.writeFileSync(path.join(workingDirPath, 'new-file.txt'), 'hello there', 'utf8'); const unstagedChanges = await repo.getUnstagedChanges(); - assert.equal(unstagedChanges.length, 2); + assert.equal(unstagedChanges.length, 3); await repo.discardWorkDirChangesForPaths(unstagedChanges.map(c => c.filePath)); repo.refresh(); assert.deepEqual(await repo.getUnstagedChanges(), []);