Add cooperative cancellation to RevWalk traversal

EGit cancels its GenerateHistoryJob when the history selection changes,
but for large resource-filtered histories the job can remain deep
inside JGit (e.g. in TreeRevFilter) well after RevWalk.next() is
entered, so polling IProgressMonitor from the caller alone is not enough
to stop promptly.

Add a ProgressMonitor to RevWalk that callers can set via
setProgressMonitor(ProgressMonitor). The hot path TreeRevFilter::include
now calls a checkCancelled() and throws a CancelledException if the
monitor has been cancelled i.e. if the selection in the history view has
changed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Change-Id: I9631e27bae55724ddc17e947f7331207f332e6c5
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/revwalk/RevWalkCancellationTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/revwalk/RevWalkCancellationTest.java
new file mode 100644
index 0000000..017d474
--- /dev/null
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/revwalk/RevWalkCancellationTest.java
@@ -0,0 +1,156 @@
+/*
+ * Copyright (c) 2026 Vector Informatik GmbH
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Distribution License v. 1.0 which is available at
+ * https://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+package org.eclipse.jgit.revwalk;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.util.Collections;
+
+import org.eclipse.jgit.diff.DiffConfig;
+import org.eclipse.jgit.errors.CancelledException;
+import org.eclipse.jgit.junit.TestRepository.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.treewalk.filter.AndTreeFilter;
+import org.eclipse.jgit.treewalk.filter.PathFilterGroup;
+import org.eclipse.jgit.treewalk.filter.TreeFilter;
+import org.junit.Test;
+
+/**
+ * Tests that {@link RevWalk} cooperatively cancels traversal when a
+ * {@link ProgressMonitor} reports cancellation, including during tree filtering
+ * and topological sorting.
+ */
+public class RevWalkCancellationTest extends RevWalkTestCase {
+
+	private static final class AlwaysCancelledMonitor
+			implements ProgressMonitor {
+
+		AlwaysCancelledMonitor() {
+		}
+
+		@Override
+		public void start(int totalTasks) {
+			// Not used.
+		}
+
+		@Override
+		public void beginTask(String title, int totalWork) {
+			// Not used.
+		}
+
+		@Override
+		public void update(int completed) {
+			// Not used.
+		}
+
+		@Override
+		public void endTask() {
+			// Not used.
+		}
+
+		@Override
+		public boolean isCancelled() {
+			return true;
+		}
+
+		@Override
+		public void showDuration(boolean enabled) {
+			// Not used.
+		}
+	}
+
+	private void filter(String path) {
+		rw.setTreeFilter(AndTreeFilter.create(
+				PathFilterGroup.createFromStrings(Collections.singleton(path)),
+				TreeFilter.ANY_DIFF));
+	}
+
+	@Test
+	public void testPathFilteredWalkThrowsWhenCancelled() throws Exception {
+		RevCommit a = commit(tree(file("a", blob("1"))));
+		RevCommit b = commit(tree(file("a", blob("2"))), a);
+		RevCommit c = commit(tree(file("a", blob("3"))), b);
+		RevCommit d = commit(tree(file("a", blob("4"))), c);
+		RevCommit e = commit(tree(file("a", blob("5"))), d);
+
+		filter("a");
+		markStart(e);
+		rw.setProgressMonitor(new AlwaysCancelledMonitor());
+
+		try {
+			while (rw.next() != null) {
+				// Keep pumping the walk; it must eventually observe
+				// cancellation and throw before running out of commits.
+			}
+			fail("Expected CancelledException while traversing a path-filtered walk");
+		} catch (CancelledException expected) {
+			assertTrue(expected.getMessage() != null);
+		}
+	}
+
+	@Test
+	public void testTopoSortedPathFilteredWalkThrowsWhenCancelled()
+			throws Exception {
+		RevCommit a = commit(tree(file("a", blob("1"))));
+		RevCommit b = commit(tree(file("a", blob("2"))), a);
+		RevCommit c1 = commit(-5, tree(file("a", blob("3"))), b);
+		RevCommit c2 = commit(10,
+				tree(file("a", blob("2")), file("b", blob("1"))), b);
+		RevCommit d = commit(tree(file("a", blob("3")), file("b", blob("1"))),
+				c1, c2);
+		RevCommit e = commit(tree(file("a", blob("4")), file("b", blob("2"))),
+				d);
+
+		rw.sort(RevSort.TOPO);
+		filter("a");
+		markStart(e);
+		rw.setProgressMonitor(new AlwaysCancelledMonitor());
+
+		try {
+			while (rw.next() != null) {
+				// Keep pumping the walk; the topo phases and TreeRevFilter
+				// must observe cancellation before the walk completes.
+			}
+			fail("Expected CancelledException while traversing a topo-sorted, "
+					+ "path-filtered walk");
+		} catch (CancelledException expected) {
+			assertTrue(expected.getMessage() != null);
+		}
+	}
+
+	@Test
+	public void testFollowRenameCancellationAroundRenameDetection()
+			throws Exception {
+		RevCommit a = commit(tree(file("a", blob("A"))));
+
+		// rename a to b
+		CommitBuilder commitBuilder = commitBuilder().parent(a)
+				.add("b", blob("A")).rm("a");
+		RevCommit renameCommit = commitBuilder.create();
+
+		FollowFilter followFilter = FollowFilter.create("b",
+				new Config().get(DiffConfig.KEY));
+		rw.setTreeFilter(followFilter);
+		markStart(renameCommit);
+		rw.setProgressMonitor(new AlwaysCancelledMonitor());
+
+		try {
+			while (rw.next() != null) {
+				// Keep pumping; cancellation should surface around rename
+				// detection triggered by updateFollowFilter().
+			}
+			fail("Expected CancelledException while following renames");
+		} catch (CancelledException expected) {
+			assertTrue(expected.getMessage() != null);
+		}
+	}
+}
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/revwalk/RevWalk.java b/org.eclipse.jgit/src/org/eclipse/jgit/revwalk/RevWalk.java
index 448e6e0..d31a89b 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/revwalk/RevWalk.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/revwalk/RevWalk.java
@@ -28,6 +28,7 @@
 
 import org.eclipse.jgit.annotations.NonNull;
 import org.eclipse.jgit.annotations.Nullable;
+import org.eclipse.jgit.errors.CancelledException;
 import org.eclipse.jgit.errors.CorruptObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.LargeObjectException;
@@ -219,6 +220,8 @@ public class RevWalk implements Iterable<RevCommit>, AutoCloseable {
 
 	boolean shallowCommitsInitialized;
 
+	private ProgressMonitor monitor = NullProgressMonitor.INSTANCE;
+
 	private enum GetMergedIntoStrategy {
 		RETURN_ON_FIRST_FOUND, RETURN_ON_FIRST_NOT_FOUND, EVALUATE_ALL
 	}
@@ -675,10 +678,42 @@ private Set<RevCommit> getCommitsMergedInto(RevCommit needle, Collection<RevComm
 	 */
 	public RevCommit next() throws MissingObjectException,
 			IncorrectObjectTypeException, IOException {
+		checkCancelled();
 		return pending.next();
 	}
 
 	/**
+	 * Set a progress monitor to cooperatively cancel this walk.
+	 * <p>
+	 * The walk periodically checks {@link ProgressMonitor#isCancelled()}
+	 * during traversal and aborts with a
+	 * {@link org.eclipse.jgit.errors.CancelledException} once the monitor
+	 * reports cancellation.
+	 *
+	 * @param monitor
+	 *            monitor to poll for cancellation, or {@code null} to stop
+	 *            checking for cancellation.
+	 * @since 7.8
+	 */
+	public void setProgressMonitor(ProgressMonitor monitor) {
+		this.monitor = monitor == null ? NullProgressMonitor.INSTANCE
+				: monitor;
+	}
+
+	/**
+	 * Check whether this walk has been cooperatively cancelled.
+	 *
+	 * @throws CancelledException
+	 *             if the configured {@link ProgressMonitor} reports
+	 *             cancellation, or if the current thread was interrupted.
+	 */
+	void checkCancelled() throws CancelledException {
+		if (monitor.isCancelled() || Thread.currentThread().isInterrupted()) {
+			throw new CancelledException(JGitText.get().operationCanceled);
+		}
+	}
+
+	/**
 	 * Obtain the sort types applied to the commits returned.
 	 *
 	 * @return the sorting strategies employed. At least one strategy is always
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/revwalk/TreeRevFilter.java b/org.eclipse.jgit/src/org/eclipse/jgit/revwalk/TreeRevFilter.java
index e9a3e72..ed8640a 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/revwalk/TreeRevFilter.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/revwalk/TreeRevFilter.java
@@ -136,6 +136,7 @@ public boolean include(RevWalk walker, RevCommit c)
 					changedPathFilterUsed);
 			if (mustCalculateChgs) {
 				while (tw.next()) {
+					walker.checkCancelled();
 					chgs++;
 					if (tw.getRawMode(0) == 0 && tw.getRawMode(1) != 0) {
 						adds++;
@@ -197,6 +198,7 @@ public boolean include(RevWalk walker, RevCommit c)
 		while (tw.next()) {
 			int myMode = tw.getRawMode(nParents);
 			for (int i = 0; i < nParents; i++) {
+				walker.checkCancelled();
 				int pMode = tw.getRawMode(i);
 				if (myMode == pMode && tw.idEqual(i, nParents)) {
 					continue;