diff --git a/lib/repository.js b/lib/repository.js index 371f4d0b2..e3d66a94e 100644 --- a/lib/repository.js +++ b/lib/repository.js @@ -1311,204 +1311,217 @@ Repository.prototype.stageFilemode = function(filePath, stageNew) { }); }; -/** - * Stages or unstages line selection of a specified file - * - * @async - * @param {String} filePath The relative path of this file in the repo - * @param {Array} newLines The array of DiffLine objects - * selected for staging or unstaging - * @param {Boolean} isStaged Are the selected lines currently staged - * @return {Number} 0 or an error code - */ -Repository.prototype.stageLines = - function(filePath, selectedLines, isSelectionStaged) { +function getPathHunks(repo, index, filePath, isStaged) { + return Promise.resolve() + .then(function() { + if (isStaged) { + return repo.getHeadCommit() + .then(function getTreeFromCommit(commit) { + return commit.getTree(); + }) + .then(function getDiffFromTree(tree) { + return NodeGit.Diff.treeToIndex(repo, tree, index); + }); + } - function applySelectedLinesToBlob - (pathHunks, isStaged, newLines, originalBlob) { - var lineTypes = { - ADDED: 43, // ascii code for '+' - DELETED: 45 // ascii code for '-' - }; - var newContent = ""; - var oldIndex = 0; - var linesPromises = []; + return NodeGit.Diff.indexToWorkdir(repo, index, { + flags: + NodeGit.Diff.OPTION.SHOW_UNTRACKED_CONTENT | + NodeGit.Diff.OPTION.RECURSE_UNTRACKED_DIRS + }); + }) + .then(function(diff) { + if (!(NodeGit.Status.file(repo, filePath) & + NodeGit.Status.STATUS.WT_MODIFIED) && + !(NodeGit.Status.file(repo, filePath) & + NodeGit.Status.STATUS.INDEX_MODIFIED)) { + return Promise.reject + ("Selected staging is only available on modified files."); + } - //split the original file into lines - var oldLines = originalBlob.toString().split("\n"); + return diff.patches(); + }) + .then(function(patches) { + var pathPatch = patches.filter(function(patch) { + return patch.newFile().path() === filePath; + }); - //if no selected lines were sent, return the original content - if (!newLines || newLines.length === 0) { - return originalBlob; - } + if (pathPatch.length !== 1) { + return Promise.reject("No differences found for this file."); + } - function lineEqualsFirstNewLine(hunkLine) { - return ((hunkLine.oldLineno() === newLines[0].oldLineno()) && - (hunkLine.newLineno() === newLines[0].newLineno())); - } + return pathPatch[0].hunks(); + }); +} - function processSelectedLine(hunkLine) { - //if this hunk line is a selected line find the selected line - var newLine = newLines.filter(function(nLine) { - return ((hunkLine.oldLineno() === nLine.oldLineno()) && - (hunkLine.newLineno() === nLine.newLineno())); - }); +function applySelectedLinesToTarget + (originalContent, newLines, pathHunks, isStaged, reverse) { + // 43: ascii code for '+' + // 45: ascii code for '-' + var lineTypes = { + ADDED: !reverse ? 43 : 45, + DELETED: !reverse ? 45 : 43 + }; + var newContent = ""; + var oldIndex = 0; + var linesPromises = []; - if (hunkLine.content() - .indexOf("\\ No newline at end of file") != -1) { - return false; - } + // split the original file into lines + var oldLines = originalContent.toString().split("\n"); - //determine what to add to the new content - if ((isStaged && newLine && newLine.length > 0) || - (!isStaged && (!newLine || newLine.length === 0))) { - if (hunkLine.origin() !== lineTypes.ADDED) { - newContent += hunkLine.content(); - } - if ((isStaged && hunkLine.origin() !== lineTypes.DELETED) || - (!isStaged && hunkLine.origin() !== lineTypes.ADDED)) { - oldIndex++; - } - } - else { - switch (hunkLine.origin()) { - case lineTypes.ADDED: - newContent += hunkLine.content(); - if (isStaged) { - oldIndex++; - } - break; - case lineTypes.DELETED: - if (!isStaged) { - oldIndex++; - } - break; - default: - newContent += oldLines[oldIndex++]; - if (oldIndex < oldLines.length) { - newContent += "\n"; - } - break; - } - } - } + // if no selected lines were sent, return the original content + if (!newLines || newLines.length === 0) { + return originalContent; + } - //find the affected hunk - pathHunks.forEach(function(pathHunk) { - linesPromises.push(pathHunk.lines()); + function lineEqualsFirstNewLine(hunkLine) { + return ((hunkLine.oldLineno() === newLines[0].oldLineno()) && + (hunkLine.newLineno() === newLines[0].newLineno())); + } + + function processSelectedLine(hunkLine) { + // if this hunk line is a selected line find the selected line + var newLine = newLines.filter(function(nLine) { + return ((hunkLine.oldLineno() === nLine.oldLineno()) && + (hunkLine.newLineno() === nLine.newLineno())); }); - return Promise.all(linesPromises).then(function(results) { - for (var index = 0; index < results.length && - newContent.length < 1; index++) { - var hunkStart = isStaged ? pathHunks[index].newStart() - : pathHunks[index].oldStart(); - var lines = results[index]; - if (lines.filter(lineEqualsFirstNewLine).length > 0) { - //add content that is before the hunk - while (hunkStart > (oldIndex + 1)) { - newContent += oldLines[oldIndex++] + "\n"; - } - //modify the lines of the hunk according to the selection - lines.forEach(processSelectedLine); + if (hunkLine.content().indexOf("\\ No newline at end of file") !== -1) { + return false; + } - //add the rest of the file - while (oldLines.length > oldIndex) { - newContent += oldLines[oldIndex++] + - (oldLines.length > oldIndex ? "\n" : ""); + // determine what to add to the new content + if ((isStaged && newLine && newLine.length > 0) || + (!isStaged && (!newLine || newLine.length === 0))) { + if (hunkLine.origin() !== lineTypes.ADDED) { + newContent += hunkLine.content(); + } + if ((isStaged && hunkLine.origin() !== lineTypes.DELETED) || + (!isStaged && hunkLine.origin() !== lineTypes.ADDED)) { + oldIndex++; + } + } + else { + switch (hunkLine.origin()) { + case lineTypes.ADDED: + newContent += hunkLine.content(); + if (isStaged) { + oldIndex++; } - } + break; + case lineTypes.DELETED: + if (!isStaged) { + oldIndex++; + } + break; + default: + newContent += oldLines[oldIndex++]; + if (oldIndex < oldLines.length) { + newContent += "\n"; + } + break; } - return newContent; - }); + } } - var repo = this; - var index; - var diffPromise = function diffPromise() { - return isSelectionStaged ? - repo.getHeadCommit() - .then(function getTreeFromCommit(commit) { - return commit.getTree(); - }) - .then(function getDiffFromTree(tree) { - return NodeGit.Diff.treeToIndex(repo, tree, index); - }) - : - NodeGit.Diff.indexToWorkdir(repo, index, { - flags: - NodeGit.Diff.OPTION.SHOW_UNTRACKED_CONTENT | - NodeGit.Diff.OPTION.RECURSE_UNTRACKED_DIRS - }); - }; + // find the affected hunk + pathHunks.forEach(function(pathHunk) { + linesPromises.push(pathHunk.lines()); + }); - //The following chain checks if there is a patch with no hunks left for the - //file, and no filemode changes were done on the file. It is then safe to - //stage the entire file so the file doesn't show as having unstaged changes - //in `git status`. Also, check if there are no type changes. - var lastHunkStagedPromise = function lastHunkStagedPromise(result) { - return NodeGit.Diff.indexToWorkdir(repo, index, { - flags: - NodeGit.Diff.OPTION.SHOW_UNTRACKED_CONTENT | - NodeGit.Diff.OPTION.RECURSE_UNTRACKED_DIRS - }) - .then(function (diff) { - return diff.patches(); - }) - .then(function(patches) { - var pathPatch = patches.filter(function(patch) { - return patch.newFile().path() === filePath; - }); - var emptyPatch = false; - if (pathPatch.length > 0) { - //No hunks, unchanged file mode, and no type changes. - emptyPatch = pathPatch[0].size() === 0 && - pathPatch[0].oldFile().mode() == pathPatch[0].newFile().mode() && - !pathPatch[0].isTypeChange(); + return Promise.all(linesPromises).then(function(results) { + for (var i = 0; i < results.length && newContent.length < 1; i++) { + var hunkStart = isStaged || reverse ? pathHunks[i].newStart() + : pathHunks[i].oldStart(); + var lines = results[i]; + if (lines.filter(lineEqualsFirstNewLine).length > 0) { + // add content that is before the hunk + while (hunkStart > (oldIndex + 1)) { + newContent += oldLines[oldIndex++] + "\n"; } - if (emptyPatch) { - return index.addByPath(filePath) - .then(function() { - return index.write(); - }); - } else { - return result; + + // modify the lines of the hunk according to the selection + lines.forEach(processSelectedLine); + + // add the rest of the file + while (oldLines.length > oldIndex) { + newContent += oldLines[oldIndex++] + + (oldLines.length > oldIndex ? "\n" : ""); } - }); - }; + } + } + return newContent; + }); +} - return repo.refreshIndex() - .then(function(indexResult) { - index = indexResult; - }) - .then(function() { - return diffPromise(); +/** + * Stages or unstages line selection of a specified file + * + * @async + * @param {String} filePath The relative path of this file in the repo + * @param {Array} selectedLines The array of DiffLine objects + * selected for staging or unstaging + * @param {Boolean} isStaged Are the selected lines currently staged + * @return {Number} 0 or an error code + */ +Repository.prototype.stageLines = + function(filePath, selectedLines, isSelectionStaged) { + + var repo = this; + var index; + var originalBlob; + + // The following chain checks if there is a patch with no hunks left for the + // file, and no filemode changes were done on the file. It is then safe to + // stage the entire file so the file doesn't show as having unstaged changes + // in `git status`. Also, check if there are no type changes. + var lastHunkStagedPromise = function lastHunkStagedPromise(result) { + return NodeGit.Diff.indexToWorkdir(repo, index, { + flags: + NodeGit.Diff.OPTION.SHOW_UNTRACKED_CONTENT | + NodeGit.Diff.OPTION.RECURSE_UNTRACKED_DIRS }) .then(function(diff) { - if (!(NodeGit.Status.file(repo, filePath) & - NodeGit.Status.STATUS.WT_MODIFIED) && - !(NodeGit.Status.file(repo, filePath) & - NodeGit.Status.STATUS.INDEX_MODIFIED)) { - return Promise.reject - ("Selected staging is only available on modified files."); - } return diff.patches(); }) .then(function(patches) { - var pathOid = index.getByPath(filePath).id; var pathPatch = patches.filter(function(patch) { return patch.newFile().path() === filePath; }); - if (pathPatch.length !== 1) { - return Promise.reject("No differences found for this file."); + var emptyPatch = false; + if (pathPatch.length > 0) { + // No hunks, unchanged file mode, and no type changes. + emptyPatch = pathPatch[0].size() === 0 && + pathPatch[0].oldFile().mode() === pathPatch[0].newFile().mode() && + !pathPatch[0].isTypeChange(); } - return Promise.all([repo.getBlob(pathOid), pathPatch[0].hunks()]); + if (emptyPatch) { + return index.addByPath(filePath) + .then(function() { + return index.write(); + }); + } + + return result; + }); + }; + + return repo.refreshIndex() + .then(function(indexResult) { + index = indexResult; + var pathOid = index.getByPath(filePath).id; + + return repo.getBlob(pathOid); + }) + .then(function(blob) { + originalBlob = blob; + + return getPathHunks(repo, index, filePath, isSelectionStaged); }) - .then(function(results) { - var originalBlob = results[0]; - var pathHunks = results[1]; - return applySelectedLinesToBlob( - pathHunks, isSelectionStaged, selectedLines, originalBlob); + .then(function(hunks) { + return applySelectedLinesToTarget( + originalBlob, selectedLines, hunks, isSelectionStaged + ); }) .then(function(newContent) { var newContentBuffer = new Buffer(newContent); @@ -1530,9 +1543,46 @@ Repository.prototype.stageLines = .then(function(result) { if (isSelectionStaged) { return result; - } else { - return lastHunkStagedPromise(result); } + + return lastHunkStagedPromise(result); + }); +}; + +/** + * Discard line selection of a specified file. + * Assumes selected lines are unstaged. + * + * @async + * @param {String} filePath The relative path of this file in the repo + * @param {Array} selectedLines The array of DiffLine objects + * selected for discarding + * @return {Number} 0 or an error code + */ +Repository.prototype.discardLines = function(filePath, selectedLines) { + var repo = this; + var fullFilePath = path.join(repo.workdir(), filePath); + var index; + var originalContent; + + return repo.refreshIndex() + .then(function(indexResult) { + index = indexResult; + + return fse.readFile(fullFilePath, "utf8"); + }) + .then(function(content) { + originalContent = content; + + return getPathHunks(repo, index, filePath, false); + }) + .then(function(hunks) { + return applySelectedLinesToTarget( + originalContent, selectedLines, hunks, false, true + ); + }) + .then(function(newContent) { + return fse.writeFile(fullFilePath, newContent); }); }; diff --git a/test/tests/stage.js b/test/tests/stage.js index ac0675492..248c3e0f1 100644 --- a/test/tests/stage.js +++ b/test/tests/stage.js @@ -25,7 +25,7 @@ describe("Stage", function() { return fse.remove(test.repository.workdir()); }); - function stagingTest(staging, newFileContent) { + function stagingTest(isUnstaged, newFileContent, discarding) { var fileContent = newFileContent || "One line of text\n" + "Two lines of text\n"+ @@ -48,13 +48,22 @@ describe("Stage", function() { "Nineteen lines of text\n"+ "Twenty lines of text\n"; var fileName = "stagedLinesTest.txt"; - var stagedFile; + var expectedContent; var workingDirFile; var getDiffFunction; - if (staging) { - stagedFile = fileContent.replace("Fifteen", "Changed fifteen"); - workingDirFile = stagedFile.replace("Three", "Changed three") + + if (!isUnstaged || discarding) { + expectedContent = fileContent.replace("Three", "Changed three") .replace("Seventeen", "Changed seventeen"); + workingDirFile = expectedContent.replace("Fifteen", "Changed fifteen"); + } + else { + expectedContent = fileContent.replace("Fifteen", "Changed fifteen"); + workingDirFile = expectedContent.replace("Three", "Changed three") + .replace("Seventeen", "Changed seventeen"); + } + + if (isUnstaged) { getDiffFunction = function() { return test.repository.refreshIndex() .then(function(index) { @@ -71,10 +80,6 @@ describe("Stage", function() { }; } else { - stagedFile = fileContent.replace("Three", "Changed three") - .replace("Seventeen", "Changed seventeen"); - workingDirFile = stagedFile.replace("Fifteen", "Changed fifteen"); - getDiffFunction = function() { return RepoUtils.addFileToIndex(test.repository, fileName) .then(function() { @@ -138,17 +143,28 @@ describe("Stage", function() { } }); }); - return test.repository.stageLines(fileName, linesToStage, !staging); + + if (discarding) { + return test.repository.discardLines(fileName, linesToStage); + } + + return test.repository.stageLines(fileName, linesToStage, !isUnstaged); }) .then(function() { - return test.repository.refreshIndex(); - }) - .then(function(reloadedIndex) { - var pathOid = reloadedIndex.getByPath(fileName).id; - return test.repository.getBlob(pathOid); + if (discarding) { + return fse.readFile( + path.join(test.repository.workdir(), fileName), "utf8" + ); + } + + return test.repository.refreshIndex() + .then(function(reloadedIndex) { + var pathOid = reloadedIndex.getByPath(fileName).id; + return test.repository.getBlob(pathOid); + }); }) .then(function(resultFileContents) { - assert.equal(resultFileContents.toString(), stagedFile); + assert.equal(resultFileContents.toString(), expectedContent); }); } @@ -501,4 +517,8 @@ describe("Stage", function() { return compareFilemodes(false, freshIndex, 0 /* expect nochange */); }); }); + + it("can discard selected lines", function() { + return stagingTest(true, null, true); + }); });