Blame: Add option to ignore revisions Git blame provides a '--ignore-revs-file' option to skip over specified revisions, allowing users to bypass large-scale formatting or refactoring commits and assign blame to the real modifications instead of mass moves. JGit's BlameCommand and BlameGenerator were missing this capability. The new method setIgnoreRevs(Collection<? extends ObjectId>) on BlameGenerator and BlameCommand accepts commits to ignore. When an ignored commit is encountered during forward blame traversal, modified regions are transferred to the parent candidate during diff processing (Candidate.takeBlame), preserving accurate line coordinate transformations and supporting renames across ignored revisions. Specifying ignored revisions disables using cached blame results. Bug: jgit-181 Change-Id: I66ca7365bee1dd8c46f78feb5a6ab06eb5491bcc Signed-off-by: Ravi Mistry <rmistry@google.com>
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/blame/BlameGeneratorTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/blame/BlameGeneratorTest.java index c2c06b2..963a7c8 100644 --- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/blame/BlameGeneratorTest.java +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/blame/BlameGeneratorTest.java
@@ -13,11 +13,16 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + import org.eclipse.jgit.api.Git; import org.eclipse.jgit.blame.BlameGenerator; import org.eclipse.jgit.blame.BlameResult; import org.eclipse.jgit.junit.RepositoryTestCase; import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.revwalk.RevCommit; import org.junit.Test; @@ -168,10 +173,566 @@ public void testLinesAllDeletedShortenedWalk() throws Exception { } } + @Test + public void testBlameIgnoreSingleRevision() throws Exception { + try (Git git = new Git(db)) { + String[] content1 = new String[] { "a" }; + writeTrashFile(FILE, join(content1)); + git.add().addFilepattern(FILE).call(); + RevCommit c1 = git.commit().setMessage("c1").call(); + + String[] content2 = new String[] { "a", "b" }; + writeTrashFile(FILE, join(content2)); + git.add().addFilepattern(FILE).call(); + RevCommit c2 = git.commit().setMessage("c2").call(); + + String[] content3 = new String[] { "a", "b", "c" }; + writeTrashFile(FILE, join(content3)); + git.add().addFilepattern(FILE).call(); + RevCommit c3 = git.commit().setMessage("c3").call(); + + try (BlameGenerator generator = new BlameGenerator(db, FILE)) { + generator.setIgnoreRevs(Collections.singleton(c2)); + generator.push(null, c3); + BlameResult result = generator.computeBlameResult(); + + assertEquals(3, result.getResultContents().size()); + assertEquals(c1, result.getSourceCommit(0)); + assertEquals(c1, result.getSourceCommit(1)); + assertEquals(c3, result.getSourceCommit(2)); + } + } + } + + @Test + public void testBlameIgnoreMultipleRevisions() throws Exception { + try (Git git = new Git(db)) { + String[] content1 = new String[] { "1" }; + writeTrashFile(FILE, join(content1)); + git.add().addFilepattern(FILE).call(); + RevCommit c1 = git.commit().setMessage("c1").call(); + + String[] content2 = new String[] { "1", "2" }; + writeTrashFile(FILE, join(content2)); + git.add().addFilepattern(FILE).call(); + RevCommit c2 = git.commit().setMessage("c2").call(); + + String[] content3 = new String[] { "1", "2", "3" }; + writeTrashFile(FILE, join(content3)); + git.add().addFilepattern(FILE).call(); + RevCommit c3 = git.commit().setMessage("c3").call(); + + String[] content4 = new String[] { "1", "2", "3", "4" }; + writeTrashFile(FILE, join(content4)); + git.add().addFilepattern(FILE).call(); + RevCommit c4 = git.commit().setMessage("c4").call(); + + try (BlameGenerator generator = new BlameGenerator(db, FILE)) { + Set<ObjectId> ignores = new HashSet<>(); + ignores.add(c2); + ignores.add(c3); + generator.setIgnoreRevs(ignores); + generator.push(null, c4); + + BlameResult result = generator.computeBlameResult(); + + assertEquals(4, result.getResultContents().size()); + assertEquals(c1, result.getSourceCommit(0)); + assertEquals(c1, result.getSourceCommit(1)); + assertEquals(c1, result.getSourceCommit(2)); + assertEquals(c4, result.getSourceCommit(3)); + } + } + } + + @Test + public void testBlameIgnoreNonModifyingRevision() throws Exception { + try (Git git = new Git(db)) { + String[] content1 = new String[] { "A" }; + writeTrashFile(FILE, join(content1)); + git.add().addFilepattern(FILE).call(); + RevCommit c1 = git.commit().setMessage("c1").call(); + + String[] content2 = new String[] { "A", "B" }; + writeTrashFile(FILE, join(content2)); + git.add().addFilepattern(FILE).call(); + RevCommit c2 = git.commit().setMessage("c2").call(); + + String[] content3 = new String[] { "A prime", "B" }; + writeTrashFile(FILE, join(content3)); + git.add().addFilepattern(FILE).call(); + RevCommit c3 = git.commit().setMessage("c3").call(); + + try (BlameGenerator generator = new BlameGenerator(db, FILE)) { + generator.setIgnoreRevs(Collections.singleton(c2)); + generator.push(null, c3); + BlameResult result = generator.computeBlameResult(); + + assertEquals(c3, result.getSourceCommit(0)); + assertEquals(c1, result.getSourceCommit(1)); + } + + try (BlameGenerator generator = new BlameGenerator(db, FILE)) { + generator.setIgnoreRevs(Collections.singleton(c3)); + generator.push(null, c3); + BlameResult result = generator.computeBlameResult(); + + assertEquals(c1, result.getSourceCommit(0)); + } + } + } + + @Test + public void testBlameIgnoreMergeCommit() throws Exception { + try (Git git = new Git(db)) { + String[] content1 = new String[] { "base" }; + writeTrashFile(FILE, join(content1)); + git.add().addFilepattern(FILE).call(); + RevCommit c1 = git.commit().setMessage("c1").call(); + + git.checkout().setCreateBranch(true).setName("a").call(); + String[] content2 = new String[] { "base", "a" }; + writeTrashFile(FILE, join(content2)); + git.add().addFilepattern(FILE).call(); + RevCommit c2 = git.commit().setMessage("c2").call(); + + git.checkout().setName("master").call(); + String[] content3 = new String[] { "base", "b" }; + writeTrashFile(FILE, join(content3)); + git.add().addFilepattern(FILE).call(); + RevCommit c3 = git.commit().setMessage("c3").call(); + + git.merge().include(c2).call(); + writeTrashFile(FILE, join(content2)); + git.add().addFilepattern(FILE).call(); + RevCommit c4 = git.commit().setMessage("c4").call(); + + try (BlameGenerator generator = new BlameGenerator(db, FILE)) { + generator.setIgnoreRevs(Collections.singleton(c2)); + generator.push(null, c4); + BlameResult result = generator.computeBlameResult(); + + assertEquals(c1, result.getSourceCommit(1)); + } + + try (BlameGenerator generator = new BlameGenerator(db, FILE)) { + generator.setIgnoreRevs(Collections.singleton(c3)); + generator.push(null, c4); + BlameResult result = generator.computeBlameResult(); + + assertEquals(c2, result.getSourceCommit(1)); + } + + try (BlameGenerator generator = new BlameGenerator(db, FILE)) { + generator.setIgnoreRevs(Collections.singleton(c4)); + generator.push(null, c4); + BlameResult result = generator.computeBlameResult(); + + assertEquals(c2, result.getSourceCommit(1)); + } + } + } + + @Test + public void testBlameIgnoreWithEmptySet() throws Exception { + try (Git git = new Git(db)) { + String[] content1 = new String[] { "a" }; + writeTrashFile(FILE, join(content1)); + git.add().addFilepattern(FILE).call(); + RevCommit c1 = git.commit().setMessage("c1").call(); + + try (BlameGenerator generator = new BlameGenerator(db, FILE)) { + generator.setIgnoreRevs(Collections.emptySet()); + generator.push(null, c1); + BlameResult result = generator.computeBlameResult(); + assertEquals(c1, result.getSourceCommit(0)); + } + } + } + + @Test + public void testBlameIgnoreWithNull() throws Exception { + try (Git git = new Git(db)) { + String[] content1 = new String[] { "a" }; + writeTrashFile(FILE, join(content1)); + git.add().addFilepattern(FILE).call(); + RevCommit c1 = git.commit().setMessage("c1").call(); + + try (BlameGenerator generator = new BlameGenerator(db, FILE)) { + generator.setIgnoreRevs(null); + generator.push(null, c1); + BlameResult result = generator.computeBlameResult(); + assertEquals(c1, result.getSourceCommit(0)); + } + } + } + private static String join(String... lines) { StringBuilder joined = new StringBuilder(); for (String line : lines) joined.append(line).append('\n'); return joined.toString(); } + @Test + public void testBlameIgnoreMergeCommitMultipleRegions() throws Exception { + try (Git git = new Git(db)) { + String[] content1 = new String[] { "1", "2", "3", "4", "5" }; + writeTrashFile(FILE, join(content1)); + git.add().addFilepattern(FILE).call(); + RevCommit c1 = git.commit().setMessage("c1").call(); + + git.checkout().setCreateBranch(true).setName("a").call(); + String[] content2 = new String[] { "1_mod", "2", "3", "4", "5" }; + writeTrashFile(FILE, join(content2)); + git.add().addFilepattern(FILE).call(); + RevCommit c2 = git.commit().setMessage("c2").call(); + + git.checkout().setName("master").call(); + String[] content3 = new String[] { "1", "2", "3", "4", "5_mod" }; + writeTrashFile(FILE, join(content3)); + git.add().addFilepattern(FILE).call(); + RevCommit c3 = git.commit().setMessage("c3").call(); + + git.merge().include(c2).call(); + String[] content4 = new String[] { "1_mod", "2", "3", "4", "5_mod" }; + writeTrashFile(FILE, join(content4)); + git.add().addFilepattern(FILE).call(); + RevCommit c4 = git.commit().setMessage("c4").call(); + + try (BlameGenerator generator = new BlameGenerator(db, FILE)) { + generator.setIgnoreRevs(Collections.singleton(c4)); + generator.push(null, c4); + BlameResult result = generator.computeBlameResult(); + + assertEquals(c2, result.getSourceCommit(0)); + assertEquals(c1, result.getSourceCommit(1)); + assertEquals(c3, result.getSourceCommit(4)); + } + } + } + + @Test + public void testBlameIgnoreWithRename() throws Exception { + try (Git git = new Git(db)) { + String oldFile = "OldFile.txt"; + String newFile = "NewFile.txt"; + + String[] content1 = new String[] { "line1", "line2", "line3", "line4", "line5" }; + writeTrashFile(oldFile, join(content1)); + git.add().addFilepattern(oldFile).call(); + RevCommit c1 = git.commit().setMessage("c1: create OldFile").call(); + + // Rename OldFile.txt to NewFile.txt and modify line1 in c2 (80% similarity) + git.rm().addFilepattern(oldFile).call(); + String[] content2 = new String[] { "line1 formatted", "line2", "line3", "line4", "line5" }; + writeTrashFile(newFile, join(content2)); + git.add().addFilepattern(newFile).call(); + RevCommit c2 = git.commit().setMessage("c2: rename and format").call(); + + // Modify line2 in c3 + String[] content3 = new String[] { "line1 formatted", "line2 modified", "line3", "line4", "line5" }; + writeTrashFile(newFile, join(content3)); + git.add().addFilepattern(newFile).call(); + RevCommit c3 = git.commit().setMessage("c3: edit line2").call(); + + try (BlameGenerator generator = new BlameGenerator(db, newFile)) { + generator.setFollowFileRenames(true); + generator.setIgnoreRevs(Collections.singleton(c2)); + generator.push(null, c3); + BlameResult result = generator.computeBlameResult(); + + assertEquals(5, result.getResultContents().size()); + assertEquals(c1, result.getSourceCommit(0)); + assertEquals(oldFile, result.getSourcePath(0)); + assertEquals(c3, result.getSourceCommit(1)); + assertEquals(newFile, result.getSourcePath(1)); + assertEquals(c1, result.getSourceCommit(2)); + assertEquals(oldFile, result.getSourcePath(2)); + } + } + } + + @Test + public void testBlameIgnoreWithLineCountChange() throws Exception { + try (Git git = new Git(db)) { + String[] content1 = new String[] { "body1", "body2" }; + writeTrashFile(FILE, join(content1)); + git.add().addFilepattern(FILE).call(); + RevCommit c1 = git.commit().setMessage("c1: initial").call(); + + // c2 adds 3 header lines and modifies body1 + String[] content2 = new String[] { "// Header 1", "// Header 2", "// Header 3", "body1 formatted", "body2" }; + writeTrashFile(FILE, join(content2)); + git.add().addFilepattern(FILE).call(); + RevCommit c2 = git.commit().setMessage("c2: add header and format").call(); + + // c3 adds a footer line + String[] content3 = new String[] { "// Header 1", "// Header 2", "// Header 3", "body1 formatted", "body2", "footer" }; + writeTrashFile(FILE, join(content3)); + git.add().addFilepattern(FILE).call(); + RevCommit c3 = git.commit().setMessage("c3: add footer").call(); + + try (BlameGenerator generator = new BlameGenerator(db, FILE)) { + generator.setIgnoreRevs(Collections.singleton(c2)); + generator.push(null, c3); + BlameResult result = generator.computeBlameResult(); + + assertEquals(6, result.getResultContents().size()); + assertEquals(c1, result.getSourceCommit(3)); + assertEquals(c1, result.getSourceCommit(4)); + assertEquals(c3, result.getSourceCommit(5)); + } + } + } + + @Test + public void testBlameCommandWithIgnoreRevs() throws Exception { + try (Git git = new Git(db)) { + String[] content1 = new String[] { "a" }; + writeTrashFile(FILE, join(content1)); + git.add().addFilepattern(FILE).call(); + RevCommit c1 = git.commit().setMessage("c1").call(); + + String[] content2 = new String[] { "a", "b" }; + writeTrashFile(FILE, join(content2)); + git.add().addFilepattern(FILE).call(); + RevCommit c2 = git.commit().setMessage("c2").call(); + + String[] content3 = new String[] { "a", "b", "c" }; + writeTrashFile(FILE, join(content3)); + git.add().addFilepattern(FILE).call(); + RevCommit c3 = git.commit().setMessage("c3").call(); + + BlameResult result = git.blame() + .setFilePath(FILE) + .setStartCommit(c3) + .setIgnoreRevs(Collections.singleton(c2)) + .call(); + + assertEquals(3, result.getResultContents().size()); + assertEquals(c1, result.getSourceCommit(0)); + assertEquals(c1, result.getSourceCommit(1)); + assertEquals(c3, result.getSourceCommit(2)); + } + } + + @Test + public void testBlameIgnoreMergeCommitBothBranches() throws Exception { + try (Git git = new Git(db)) { + String[] content1 = new String[] { "header", "body", "footer" }; + writeTrashFile(FILE, join(content1)); + git.add().addFilepattern(FILE).call(); + RevCommit c1 = git.commit().setMessage("c1: base").call(); + + // Branch 1: modify header + git.checkout().setName("branch1").setCreateBranch(true).setStartPoint(c1).call(); + String[] content2 = new String[] { "header branch1", "body", "footer" }; + writeTrashFile(FILE, join(content2)); + git.add().addFilepattern(FILE).call(); + RevCommit c2 = git.commit().setMessage("c2: header branch1").call(); + + // Branch 2: modify footer + git.checkout().setName("branch2").setCreateBranch(true).setStartPoint(c1).call(); + String[] content3 = new String[] { "header", "body", "footer branch2" }; + writeTrashFile(FILE, join(content3)); + git.add().addFilepattern(FILE).call(); + RevCommit c3 = git.commit().setMessage("c3: footer branch2").call(); + + // Merge branch1 and branch2 into master + git.checkout().setName("master").call(); + git.merge().include(c2).call(); + git.merge().include(c3).call(); + String[] content4 = new String[] { "header branch1", "body", "footer branch2" }; + writeTrashFile(FILE, join(content4)); + git.add().addFilepattern(FILE).call(); + RevCommit c4 = git.commit().setMessage("c4: merge branch1 and branch2").call(); + + try (BlameGenerator generator = new BlameGenerator(db, FILE)) { + generator.setIgnoreRevs(Collections.singleton(c4)); + generator.push(null, c4); + BlameResult result = generator.computeBlameResult(); + + assertEquals(3, result.getResultContents().size()); + assertEquals(c2, result.getSourceCommit(0)); + assertEquals(c1, result.getSourceCommit(1)); + assertEquals(c3, result.getSourceCommit(2)); + } + } + } + + @Test + public void testBlameIgnoreLargeInsertion() throws Exception { + try (Git git = new Git(db)) { + String[] content1 = new String[] { "line1", "line2" }; + writeTrashFile(FILE, join(content1)); + git.add().addFilepattern(FILE).call(); + RevCommit c1 = git.commit().setMessage("c1: base").call(); + + String[] content2 = new String[] { + "line1", + "ins1", "ins2", "ins3", "ins4", "ins5", + "ins6", "ins7", "ins8", "ins9", "ins10", + "line2" + }; + writeTrashFile(FILE, join(content2)); + git.add().addFilepattern(FILE).call(); + RevCommit c2 = git.commit().setMessage("c2: insert 10 lines").call(); + + String[] content3 = new String[] { + "line1", + "ins1", "ins2", "ins3", "ins4", "ins5", + "ins6", "ins7", "ins8", "ins9", "ins10", + "line2 modified" + }; + writeTrashFile(FILE, join(content3)); + git.add().addFilepattern(FILE).call(); + RevCommit c3 = git.commit().setMessage("c3: edit line2").call(); + + try (BlameGenerator generator = new BlameGenerator(db, FILE)) { + generator.setIgnoreRevs(Collections.singleton(c2)); + generator.push(null, c3); + BlameResult result = generator.computeBlameResult(); + + assertEquals(12, result.getResultContents().size()); + assertEquals(c1, result.getSourceCommit(0)); + for (int i = 1; i <= 10; i++) { + assertEquals(c1, result.getSourceCommit(i)); + assertTrue(result.getSourceLine(i) < 2); + } + assertEquals(c3, result.getSourceCommit(11)); + } + } + } + + @Test + public void testBlameIgnoreRootCommit() throws Exception { + try (Git git = new Git(db)) { + String[] content1 = new String[] { "root line" }; + writeTrashFile(FILE, join(content1)); + git.add().addFilepattern(FILE).call(); + RevCommit c1 = git.commit().setMessage("c1: root").call(); + + try (BlameGenerator generator = new BlameGenerator(db, FILE)) { + generator.setIgnoreRevs(Collections.singleton(c1)); + generator.push(null, c1); + BlameResult result = generator.computeBlameResult(); + + assertEquals(1, result.getResultContents().size()); + assertEquals(c1, result.getSourceCommit(0)); + } + } + } + + @Test + public void testBlameIgnoreMergeCommitWithConflictResolution() throws Exception { + try (Git git = new Git(db)) { + String[] content1 = new String[] { "line1", "line2" }; + writeTrashFile(FILE, join(content1)); + git.add().addFilepattern(FILE).call(); + RevCommit c1 = git.commit().setMessage("c1: base").call(); + + // Branch 1: modify line1 + git.checkout().setName("branch1").setCreateBranch(true).setStartPoint(c1).call(); + String[] content2 = new String[] { "line1 branch1", "line2" }; + writeTrashFile(FILE, join(content2)); + git.add().addFilepattern(FILE).call(); + RevCommit c2 = git.commit().setMessage("c2: branch1").call(); + + // Branch 2: modify line1 differently + git.checkout().setName("branch2").setCreateBranch(true).setStartPoint(c1).call(); + String[] content3 = new String[] { "line1 branch2", "line2" }; + writeTrashFile(FILE, join(content3)); + git.add().addFilepattern(FILE).call(); + RevCommit c3 = git.commit().setMessage("c3: branch2").call(); + + // Merge branch1 and branch2, resolving conflict with new text + git.checkout().setName("master").call(); + git.merge().include(c2).call(); + git.merge().include(c3).call(); + String[] content4 = new String[] { "line1 conflict resolved", "line2" }; + writeTrashFile(FILE, join(content4)); + git.add().addFilepattern(FILE).call(); + RevCommit c4 = git.commit().setMessage("c4: merge with resolution").call(); + + try (BlameGenerator generator = new BlameGenerator(db, FILE)) { + generator.setIgnoreRevs(Collections.singleton(c4)); + generator.push(null, c4); + BlameResult result = generator.computeBlameResult(); + + assertEquals(2, result.getResultContents().size()); + assertEquals(c2, result.getSourceCommit(0)); + assertEquals(c1, result.getSourceCommit(1)); + } + } + } + + @Test + public void testBlameIgnoreContiguousBlockReformat() throws Exception { + try (Git git = new Git(db)) { + String[] content1 = new String[] { + "line 1", + "line 2", + "line 3", + "line 4", + "line 5", + "line 6", + "line 7", + "line 8", + "line 9", + "line 10" + }; + writeTrashFile(FILE, join(content1)); + git.add().addFilepattern(FILE).call(); + RevCommit c1 = git.commit().setMessage("c1: initial 10 lines").call(); + + // c2 reformats lines 3 to 7 (a contiguous block of 5 lines) + String[] content2 = new String[] { + "line 1", + "line 2", + " line 3 formatted", + " line 4 formatted", + " line 5 formatted", + " line 6 formatted", + " line 7 formatted", + "line 8", + "line 9", + "line 10" + }; + writeTrashFile(FILE, join(content2)); + git.add().addFilepattern(FILE).call(); + RevCommit c2 = git.commit().setMessage("c2: reformat 5 lines").call(); + + // c3 modifies line 10 + String[] content3 = new String[] { + "line 1", + "line 2", + " line 3 formatted", + " line 4 formatted", + " line 5 formatted", + " line 6 formatted", + " line 7 formatted", + "line 8", + "line 9", + "line 10 modified" + }; + writeTrashFile(FILE, join(content3)); + git.add().addFilepattern(FILE).call(); + RevCommit c3 = git.commit().setMessage("c3: edit line 10").call(); + + try (BlameGenerator generator = new BlameGenerator(db, FILE)) { + generator.setIgnoreRevs(Collections.singleton(c2)); + generator.push(null, c3); + BlameResult result = generator.computeBlameResult(); + + assertEquals(10, result.getResultContents().size()); + // Lines 0..8 should all be attributed to c1 (ignoring c2's formatting block) + for (int i = 0; i < 9; i++) { + assertEquals("Line " + i + " should be attributed to c1", + c1, result.getSourceCommit(i)); + } + // Line 9 should be attributed to c3 + assertEquals(c3, result.getSourceCommit(9)); + } + } + } }
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/BlameCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/BlameCommand.java index 513853e..0b94654 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/api/BlameCommand.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/BlameCommand.java
@@ -155,6 +155,24 @@ public BlameCommand reverse(AnyObjectId start, Collection<ObjectId> end) return this; } + private Collection<? extends ObjectId> ignoreRevs; + + /** + * Set revisions to ignore during blame. + * <p> + * Specifying revisions to ignore disables using cached blame results. + * + * @param revs + * collection of commit {@link org.eclipse.jgit.lib.ObjectId}s to + * ignore. + * @return this command + * @since 7.8 + */ + public BlameCommand setIgnoreRevs(Collection<? extends ObjectId> revs) { + this.ignoreRevs = revs; + return this; + } + /** * {@inheritDoc} * <p> @@ -171,6 +189,8 @@ public BlameResult call() throws GitAPIException { gen.setTextComparator(textComparator); if (followFileRenames != null) gen.setFollowFileRenames(followFileRenames.booleanValue()); + if (ignoreRevs != null) + gen.setIgnoreRevs(ignoreRevs); if (reverseEndCommits != null) gen.reverse(startCommit, reverseEndCommits);
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/blame/BlameGenerator.java b/org.eclipse.jgit/src/org/eclipse/jgit/blame/BlameGenerator.java index c3653dc..45f96f3 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/blame/BlameGenerator.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/blame/BlameGenerator.java
@@ -20,7 +20,9 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; import org.eclipse.jgit.annotations.Nullable; import org.eclipse.jgit.api.errors.NoHeadException; @@ -151,6 +153,8 @@ public class BlameGenerator implements AutoCloseable { private final TreeFilter.MutableBoolean changedPathFilterUsed = new TreeFilter.MutableBoolean(); + private Set<ObjectId> ignoreIds = Collections.emptySet(); + /** * Create a blame generator for the repository and path (relative to * repository) @@ -333,6 +337,36 @@ public void setUseCache(boolean useCache) { } /** + * Set revisions to ignore during blame. + * <p> + * Revisions to ignore are applied during forward blame traversal. When an + * ignored commit is encountered, modified lines are transferred to the + * parent commit without attributing blame to the ignored revision. + * Specifying revisions to ignore disables using cached blame results. + * + * @param ids + * a {@link java.util.Collection} of + * {@link org.eclipse.jgit.lib.ObjectId}. + * @return {@code this} + * @since 7.8 + */ + public BlameGenerator setIgnoreRevs(Collection<? extends ObjectId> ids) { + if (ids != null && !ids.isEmpty()) { + this.ignoreIds = Collections.unmodifiableSet(new HashSet<>(ids)); + this.useCache = false; + } else { + this.ignoreIds = Collections.emptySet(); + } + return this; + } + + private boolean isIgnored(Candidate n) { + return !ignoreIds.isEmpty() && n.sourceCommit != null + && !(n instanceof ReverseCandidate) + && ignoreIds.contains(n.sourceCommit.getId()); + } + + /** * Push a candidate blob onto the generator's traversal stack. * <p> * Candidates should be pushed in history order from oldest-to-newest. @@ -788,7 +822,7 @@ private void push(Candidate toInsert) { @Nullable private Candidate blameFromCache(Candidate n) throws IOException { - if (blameCache == null || !useCache) { + if (blameCache == null || !useCache || !ignoreIds.isEmpty()) { return null; } @@ -918,7 +952,8 @@ private boolean split(Candidate parent, Candidate source) return result(cached); } - parent.takeBlame(editList, source); + boolean isIgnored = isIgnored(source); + parent.takeBlame(editList, source, isIgnored); if (parent.regionList != null) push(parent); if (source.regionList != null) { @@ -1077,6 +1112,47 @@ private boolean processMerge(Candidate n) throws IOException { return false; } + if (n.regionList != null && isIgnored(n)) { + // Any remaining regions represent conflict resolutions or new + // edits authored directly in the merge commit itself (which did + // not match any parent). Since this merge commit is ignored, we + // must not blame it. Instead, transfer the leftover regions to + // an active parent candidate so traversal continues upstream. + Candidate target = null; + for (int pIdx = 0; pIdx < pCnt; pIdx++) { + if (parents[pIdx] != null) { + target = parents[pIdx]; + break; + } + } + if (target != null) { + // Clamp residual coordinates within target's line bounds + int maxTarget = (target.sourceText != null + && target.sourceText.size() > 0) + ? target.sourceText.size() - 1 + : 0; + for (Region r = n.regionList; r != null; r = r.next) { + if (r.sourceStart > maxTarget) { + r.sourceStart = maxTarget; + } + } + target.mergeRegions(n); + } else if (pCnt > 0) { + RevCommit p0 = n.getParent(0); + PathFilter p0Path = (renames != null && renames[0] != null) + ? PathFilter.create(renames[0].getOldPath()) + : n.sourcePath; + if (find(p0, p0Path)) { + Candidate p = n.create(getRepository(), p0, p0Path); + p.sourceBlob = idBuf.toObjectId(); + p.loadText(reader); + p.regionList = n.regionList; + parents[0] = p; + } + } + n.regionList = null; + } + // Push any parents that are still candidates. for (int pIdx = 0; pIdx < pCnt; pIdx++) { if (parents[pIdx] != null) @@ -1085,6 +1161,7 @@ private boolean processMerge(Candidate n) throws IOException { if (n.regionList != null) return result(n); + return false; }
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/blame/Candidate.java b/org.eclipse.jgit/src/org/eclipse/jgit/blame/Candidate.java index 8e2aaec..f04ec7c 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/blame/Candidate.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/blame/Candidate.java
@@ -149,10 +149,15 @@ void loadText(ObjectReader reader) throws IOException { } void takeBlame(EditList editList, Candidate child) { - blame(editList, this, child); + blame(editList, this, child, false); } - private static void blame(EditList editList, Candidate a, Candidate b) { + void takeBlame(EditList editList, Candidate child, boolean isChildIgnored) { + blame(editList, this, child, isChildIgnored); + } + + private static void blame(EditList editList, Candidate a, Candidate b, + boolean ignoreB) { Region r = b.clearRegionList(); Region aTail = null; Region bTail = null; @@ -199,11 +204,15 @@ private static void blame(EditList editList, Candidate a, Candidate b) { continue; } - // If the region ends before the edit, blame on B. + // If the region ends before the edit, blame on B (or pass to A if B is ignored). int rEnd = r.sourceStart + r.length; if (rEnd <= e.getEndB()) { Region next = r.next; - bTail = add(bTail, b, r); + if (ignoreB) { + aTail = addIgnoredEdit(aTail, a, r, e); + } else { + bTail = add(bTail, b, r); + } r = next; if (rEnd == e.getEndB()) eIdx++; @@ -211,9 +220,14 @@ private static void blame(EditList editList, Candidate a, Candidate b) { } // This region extends beyond the edit. Blame the first - // half of the region on B, and process the rest after. + // half of the region on B (or pass to A if B is ignored), and process the rest after. int len = e.getEndB() - r.sourceStart; - bTail = add(bTail, b, r.splitFirst(r.sourceStart, len)); + Region firstHalf = r.splitFirst(r.sourceStart, len); + if (ignoreB) { + aTail = addIgnoredEdit(aTail, a, firstHalf, e); + } else { + bTail = add(bTail, b, firstHalf); + } r.slideAndShrink(len); eIdx++; } @@ -237,6 +251,75 @@ private static void blame(EditList editList, Candidate a, Candidate b) { } while (r != null); } + /** + * Map a candidate region from an ignored child candidate onto its parent. + * <p> + * The edit blames some lines in Candidate B, but B is ignored, so we move + * the blame to Candidate A adapting the line numbers. + * <p> + * For 1:1 edits (such as formatting or re-indentation), candidate regions + * are mapped directly into parent coordinates in O(1). For insertions or + * expansions, lines are mapped individually and clamped within parent + * bounds to prevent overrunning into unrelated lines in parent Candidate A. + * + * @param aTail + * current tail of region list for Candidate A. + * @param a + * parent candidate receiving the transferred blame regions. + * @param r + * source region from child candidate being evaluated. + * @param e + * diff edit between parent Candidate A and child Candidate B. + * @return new tail of the region list for Candidate A. + */ + private static Region addIgnoredEdit(Region aTail, Candidate a, Region r, + Edit e) { + int maxA = (a.sourceText != null && a.sourceText.size() > 0) + ? a.sourceText.size() - 1 + : 0; + int offset = r.sourceStart - e.getBeginB(); + + // Fast path: contiguous 1:1 mapping (e.g. reformatting/re-indentation) + // where all lines in region r fit within parent edit length A. + if (e.getLengthA() > 0 && offset >= 0 + && offset + r.length <= e.getLengthA()) { + int aStart = Math.min(Math.max(0, e.getBeginA() + offset), maxA); + if (aStart + r.length - 1 <= maxA) { + return add(aTail, a, + new Region(r.resultStart, aStart, r.length)); + } + } + + // Hunk expansion or pure insertion: map lines individually and clamp + // within parent boundaries so trailing lines do not overrun into + // unrelated lines in parent A. + int rEnd = r.sourceStart + r.length; + int curRs = r.resultStart; + for (int bLine = r.sourceStart; bLine < rEnd; bLine++, curRs++) { + int aLine = translateBIntoA(bLine, e, maxA); + aTail = add(aTail, a, new Region(curRs, aLine, 1)); + } + return aTail; + } + + private static int translateBIntoA(int bLine, Edit e, int maxA) { + int curOffset = bLine - e.getBeginB(); + if (e.getLengthA() == 0) { + // Pure insertion: anchor to the insertion point in parent + return clamp(e.getBeginA(), 0, maxA); + } + if (curOffset < e.getLengthA()) { + // 1:1 line mapping (e.g. reformatting/re-indentation) + return clamp(e.getBeginA() + curOffset, 0, maxA); + } + // Hunk expansion: clamp trailing lines to last parent line in edit + return clamp(e.getBeginA() + e.getLengthA() - 1, 0, maxA); + } + + private static int clamp(int val, int min, int max) { + return Math.min(Math.max(val, min), max); + } + private static Region add(Region aTail, Candidate a, Region n) { // If there is no region on the list, use only this one. if (aTail == null) {