Merge branch 'stable-7.3' into stable-7.4

* stable-7.3:
  InterruptTimer: don't use Yoda-style condition
  InterruptTimer: avoid expensive notify when begin is soon after end
  InterruptTimer: avoid unneeded notify for end()
  Disable MergeToolTest#testEmptyToolName
  Add InterruptTimer Tests
  Allow to discover bitmap on disk created after the packfile
  Prepare 5.13.6-SNAPSHOT builds
  JGit v5.13.5.202508271544-r
  Remove resolver option from target-platform-configuration
  Add missing release property to maven build
  Suppress API errors for minor API changes in service releases
  Remove unnecessary casts

Change-Id: Ieed399f728cdb96534e5d6ee2bc65f9bebaaa211
diff --git a/org.eclipse.jgit.ssh.jsch.test/META-INF/MANIFEST.MF b/org.eclipse.jgit.ssh.jsch.test/META-INF/MANIFEST.MF
index ef3cbf2..4f1a737 100644
--- a/org.eclipse.jgit.ssh.jsch.test/META-INF/MANIFEST.MF
+++ b/org.eclipse.jgit.ssh.jsch.test/META-INF/MANIFEST.MF
@@ -18,6 +18,7 @@
  org.eclipse.jgit.transport;version="[7.4.1,7.5.0)",
  org.eclipse.jgit.transport.ssh.jsch;version="[7.4.1,7.5.0)",
  org.eclipse.jgit.util;version="[7.4.1,7.5.0)",
+ org.hamcrest;version="[1.1.0,3.0.0)",
  org.junit;version="[4.13,5.0.0)",
  org.junit.experimental.theories;version="[4.13,5.0.0)",
  org.junit.runner;version="[4.13,5.0.0)"
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/util/io/InterruptTimerTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/util/io/InterruptTimerTest.java
new file mode 100644
index 0000000..68de7d6
--- /dev/null
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/util/io/InterruptTimerTest.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright (C) 2025, NVIDIA CORPORATION
+ *
+ * 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.util.io;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class InterruptTimerTest {
+	private static final int MULTIPLIER = 1; // Increase if tests get flaky
+	private static final int BUFFER = 5; // Increase if tests get flaky
+	private static final int REPEATS = 100; // Increase to stress test more
+
+	private static final int TOO_LONG = 3 * MULTIPLIER + BUFFER;
+	private static final int SHORT_ENOUGH = 1 * MULTIPLIER;
+	private static final int TIMEOUT_LONG_ENOUGH = TOO_LONG;
+	private static final int TIMEOUT_TOO_SHORT = SHORT_ENOUGH;
+
+	private InterruptTimer timer;
+
+	@Before
+	public void setUp() {
+		timer = new InterruptTimer();
+	}
+
+	@After
+	public void tearDown() {
+		timer.terminate();
+		for (Thread t : active())
+			assertFalse(t instanceof InterruptTimer.AlarmThread);
+	}
+
+	@Test
+	public void testShortEnough() {
+		int interrupted = 0;
+		try {
+			timer.begin(TIMEOUT_LONG_ENOUGH);
+			Thread.sleep(SHORT_ENOUGH);
+			timer.end();
+		} catch (InterruptedException e) {
+			interrupted++;
+		}
+		assertEquals("Was Not Interrupted", interrupted, 0);
+	}
+
+	@Test
+	public void testTooLong() {
+		int interrupted = 0;
+		try {
+			timer.begin(TIMEOUT_TOO_SHORT);
+			Thread.sleep(TOO_LONG);
+			timer.end();
+		} catch (InterruptedException e) {
+			interrupted++;
+		}
+		assertEquals("Was Interrupted", interrupted, 1);
+	}
+
+	@Test
+	public void testNotInterruptedAfterEnd() {
+		int interrupted = 0;
+		try {
+			timer.begin(TIMEOUT_LONG_ENOUGH);
+			Thread.sleep(SHORT_ENOUGH);
+			timer.end();
+			Thread.sleep(TIMEOUT_LONG_ENOUGH * 3);
+		} catch (InterruptedException e) {
+			interrupted++;
+		}
+		assertEquals("Was Not Interrupted Even After End", interrupted, 0);
+	}
+
+	@Test
+	public void testRestartBeforeTimeout() {
+		int interrupted = 0;
+		try {
+			timer.begin(TIMEOUT_LONG_ENOUGH * 2);
+			Thread.sleep(SHORT_ENOUGH);
+			timer.end();
+			timer.begin(TIMEOUT_LONG_ENOUGH);
+			Thread.sleep(SHORT_ENOUGH);
+			timer.end();
+		} catch (InterruptedException e) {
+			interrupted++;
+		}
+		assertEquals("Was Not Interrupted Even When Restarted Before Timeout", interrupted, 0);
+	}
+
+	@Test
+	public void testSecondExpiresBeforeFirst() {
+		int interrupted = 0;
+		try {
+			timer.begin(TIMEOUT_LONG_ENOUGH * 3);
+			Thread.sleep(SHORT_ENOUGH);
+			timer.end();
+			timer.begin(TIMEOUT_TOO_SHORT);
+			Thread.sleep(TOO_LONG);
+			timer.end();
+		} catch (InterruptedException e) {
+			interrupted++;
+		}
+		assertEquals("Was Interrupted Even When Second Timeout Expired Before First", interrupted, 1);
+	}
+
+	@Test
+	public void testRepeatedShortEnough() {
+		int interrupted = 0;
+		for (int i = 0; i < REPEATS; i++) {
+			try {
+				timer.begin(TIMEOUT_LONG_ENOUGH);
+				Thread.sleep(SHORT_ENOUGH);
+				timer.end();
+			} catch (InterruptedException e) {
+				interrupted++;
+			}
+		}
+		assertEquals("Was Never Interrupted", interrupted, 0);
+	}
+
+	@Test
+	public void testRepeatedTooLong() {
+		int interrupted = 0;
+		for (int i = 0; i < REPEATS; i++) {
+			try {
+				timer.begin(TIMEOUT_TOO_SHORT);
+				Thread.sleep(TOO_LONG);
+				timer.end();
+			} catch (InterruptedException e) {
+				Thread.currentThread().interrupt();
+				interrupted++;
+			}
+		}
+		assertEquals("Was always Interrupted", interrupted, REPEATS);
+	}
+
+	@Test
+	public void testRepeatedShortThanTooLong() {
+		int interrupted = 0;
+		for (int i = 0; i < REPEATS; i++) {
+			try {
+				timer.begin(TIMEOUT_LONG_ENOUGH);
+				Thread.sleep(SHORT_ENOUGH);
+				timer.end();
+			} catch (InterruptedException e) {
+				interrupted++;
+			}
+		}
+		assertEquals("Was Not Interrupted Early", interrupted, 0);
+		try {
+			timer.begin(TIMEOUT_TOO_SHORT);
+			Thread.sleep(TOO_LONG);
+			timer.end();
+		} catch (InterruptedException e) {
+			interrupted++;
+		}
+		assertEquals("Was Interrupted On Long", interrupted, 1);
+	}
+
+	private static List<Thread> active() {
+		Thread[] all = new Thread[16];
+		int n = Thread.currentThread().getThreadGroup().enumerate(all);
+		while (n == all.length) {
+			all = new Thread[all.length * 2];
+			n = Thread.currentThread().getThreadGroup().enumerate(all);
+		}
+		return Arrays.asList(all).subList(0, n);
+	}
+}
diff --git a/org.eclipse.jgit/META-INF/MANIFEST.MF b/org.eclipse.jgit/META-INF/MANIFEST.MF
index de88670..839d46b 100644
--- a/org.eclipse.jgit/META-INF/MANIFEST.MF
+++ b/org.eclipse.jgit/META-INF/MANIFEST.MF
@@ -103,9 +103,12 @@
    org.eclipse.jgit.junit.http,
    org.eclipse.jgit.http.server,
    org.eclipse.jgit.lfs,
+   org.eclipse.jgit.lfs.test,
    org.eclipse.jgit.pgm,
    org.eclipse.jgit.pgm.test,
-   org.eclipse.jgit.ssh.apache",
+   org.eclipse.jgit.ssh.apache,
+   org.eclipse.jgit.ssh.apache.test,
+   org.eclipse.jgit.ssh.jsch.test",
  org.eclipse.jgit.internal.storage.io;version="7.4.1";
   x-friends:="org.eclipse.jgit.junit,
    org.eclipse.jgit.test,
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/util/io/InterruptTimer.java b/org.eclipse.jgit/src/org/eclipse/jgit/util/io/InterruptTimer.java
index 888b8fb..e717412 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/util/io/InterruptTimer.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/util/io/InterruptTimer.java
@@ -151,6 +151,7 @@ protected void finalize() throws Throwable {
 	static final class AlarmState implements Runnable {
 		private Thread callingThread;
 
+		private int lastTimeout;
 		private long deadline;
 
 		private boolean terminated;
@@ -163,7 +164,7 @@ static final class AlarmState implements Runnable {
 		public synchronized void run() {
 			while (!terminated && callingThread.isAlive()) {
 				try {
-					if (0 < deadline) {
+					if (deadline > 0) {
 						final long delay = deadline - now();
 						if (delay <= 0) {
 							deadline = 0;
@@ -172,7 +173,9 @@ public synchronized void run() {
 							wait(delay);
 						}
 					} else {
-						wait(1000);
+						// When the timer is not running, avoid waking up more than once a second
+						wait(lastTimeout == 0 ? 1000 : lastTimeout);
+						lastTimeout = 0;
 					}
 				} catch (InterruptedException e) {
 					// Treat an interrupt as notice to examine state.
@@ -185,15 +188,21 @@ synchronized void begin(int timeout) {
 				throw new IllegalStateException(JGitText.get().timerAlreadyTerminated);
 			callingThread = Thread.currentThread();
 			deadline = now() + timeout;
-			notifyAll();
+			if (lastTimeout != timeout) {
+				boolean isNotify = lastTimeout == 0 || lastTimeout > timeout;
+				lastTimeout = timeout;
+				if (isNotify) {
+					notifyAll();
+				} // else avoid the expensive notify when the runloop will already timeout in time
+			}
 		}
 
 		synchronized void end() {
-			if (0 == deadline)
+			if (0 == deadline) {
 				Thread.interrupted();
-			else
+			} else {
 				deadline = 0;
-			notifyAll();
+			}
 		}
 
 		synchronized void terminate() {