Merge "Add cooperative cancellation to RevWalk traversal"
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;