Merge branch 'stable-6.10'

* stable-6.10:
  Prepare 6.10.1-SNAPSHOT builds
  JGit v6.10.0.202406032230-r
  JGit v6.10.0.202406032110-r
  Prepare 6.10.0-SNAPSHOT builds
  JGit v6.10.0.202405290101-rc1
  Revert "Update tycho to 4.0.8"
  JGit v6.10.0.202405282244-rc1
  Prepare 6.10.0-SNAPSHOT builds
  JGit v6.10.0.202405212237-m3

Change-Id: I777bfde90d43bece4278d42017feb835f76fbf5f
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/BareSuperprojectWriterTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/BareSuperprojectWriterTest.java
index c3b9387..5065b57 100644
--- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/BareSuperprojectWriterTest.java
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/BareSuperprojectWriterTest.java
@@ -12,6 +12,7 @@
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -22,6 +23,7 @@
 import org.eclipse.jgit.gitrepo.BareSuperprojectWriter.BareWriterConfig;
 import org.eclipse.jgit.gitrepo.RepoCommand.RemoteReader;
 import org.eclipse.jgit.junit.RepositoryTestCase;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
@@ -68,6 +70,49 @@ public void write_setGitModulesContents() throws Exception {
 	}
 
 	@Test
+	public void write_setGitModulesContents_pinned() throws Exception {
+		try (Repository bareRepo = createBareRepository()) {
+			RepoProject pinWithUpstream = new RepoProject("pinWithUpstream",
+					"path/x", "cbc0fae7e1911d27e1de37d364698dba4411c78b",
+					"remote", "");
+			pinWithUpstream.setUrl("http://example.com/a");
+			pinWithUpstream.setUpstream("branchX");
+
+			RepoProject pinWithoutUpstream = new RepoProject(
+					"pinWithoutUpstream", "path/y",
+					"cbc0fae7e1911d27e1de37d364698dba4411c78b", "remote", "");
+			pinWithoutUpstream.setUrl("http://example.com/b");
+
+			RemoteReader mockRemoteReader = mock(RemoteReader.class);
+
+			BareSuperprojectWriter w = new BareSuperprojectWriter(bareRepo,
+					null, "refs/heads/master", author, mockRemoteReader,
+					BareWriterConfig.getDefault(), List.of());
+
+			RevCommit commit = w
+					.write(Arrays.asList(pinWithUpstream, pinWithoutUpstream));
+
+			String contents = readContents(bareRepo, commit, ".gitmodules");
+			Config cfg = new Config();
+			cfg.fromText(contents);
+
+			assertThat(cfg.getString("submodule", "pinWithUpstream", "path"),
+					is("path/x"));
+			assertThat(cfg.getString("submodule", "pinWithUpstream", "url"),
+					is("http://example.com/a"));
+			assertThat(cfg.getString("submodule", "pinWithUpstream", "ref"),
+					is("branchX"));
+
+			assertThat(cfg.getString("submodule", "pinWithoutUpstream", "path"),
+					is("path/y"));
+			assertThat(cfg.getString("submodule", "pinWithoutUpstream", "url"),
+					is("http://example.com/b"));
+			assertThat(cfg.getString("submodule", "pinWithoutUpstream", "ref"),
+					nullValue());
+		}
+	}
+
+	@Test
 	public void write_setExtraContents() throws Exception {
 		try (Repository bareRepo = createBareRepository()) {
 			RepoProject repoProject = new RepoProject("subprojectX", "path/to",
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/ManifestParserTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/ManifestParserTest.java
index 20958a8..76176fe 100644
--- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/ManifestParserTest.java
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/ManifestParserTest.java
@@ -11,6 +11,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
@@ -18,7 +19,9 @@
 import java.io.IOException;
 import java.net.URI;
 import java.util.HashSet;
+import java.util.Map;
 import java.util.Set;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
@@ -138,6 +141,42 @@ public void testRemoveProject() throws Exception {
 						.collect(Collectors.toSet()));
 	}
 
+	@Test
+	public void testPinProjectWithUpstream() throws Exception {
+		StringBuilder xmlContent = new StringBuilder();
+		xmlContent.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
+				.append("<manifest>")
+				.append("<remote name=\"remote1\" fetch=\".\" />")
+				.append("<default revision=\"master\" remote=\"remote1\" />")
+				.append("<project path=\"foo\" name=\"pin-with-upstream\"")
+				.append("  revision=\"9b2fe85c0279f4d5ac69f07ddcd48566c3555405\"")
+				.append("  upstream=\"branchX\"/>")
+				.append("<project path=\"bar\" name=\"pin-without-upstream\"")
+				.append("  revision=\"76ce6d91a2e07fdfcbfc8df6970c9e98a98e36a0\" />")
+				.append("</manifest>");
+
+		ManifestParser parser = new ManifestParser(null, null, "master",
+				"https://git.google.com/", null, null);
+		parser.read(new ByteArrayInputStream(
+				xmlContent.toString().getBytes(UTF_8)));
+
+		Map<String, RepoProject> repos = parser.getProjects().stream().collect(
+				Collectors.toMap(RepoProject::getName, Function.identity()));
+		assertEquals(2, repos.size());
+
+		RepoProject foo = repos.get("pin-with-upstream");
+		assertEquals("pin-with-upstream", foo.getName());
+		assertEquals("9b2fe85c0279f4d5ac69f07ddcd48566c3555405",
+				foo.getRevision());
+		assertEquals("branchX", foo.getUpstream());
+
+		RepoProject bar = repos.get("pin-without-upstream");
+		assertEquals("pin-without-upstream", bar.getName());
+		assertEquals("76ce6d91a2e07fdfcbfc8df6970c9e98a98e36a0",
+				bar.getRevision());
+		assertNull(bar.getUpstream());
+	}
+
 	void testNormalize(String in, String want) {
 		URI got = ManifestParser.normalizeEmptyPath(URI.create(in));
 		if (!got.toString().equals(want)) {
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/RepoCommandTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/RepoCommandTest.java
index ca6f2e1..3162e79 100644
--- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/RepoCommandTest.java
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/RepoCommandTest.java
@@ -1171,6 +1171,94 @@ public void testRecordRemoteBranch() throws Exception {
 		}
 	}
 
+	@Test
+	public void testRecordRemoteBranch_pinned() throws Exception {
+		Repository remoteDb = createBareRepository();
+		Repository tempDb = createWorkRepository();
+
+		StringBuilder xmlContent = new StringBuilder();
+		xmlContent.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
+				.append("<manifest>")
+				.append("<remote name=\"remote1\" fetch=\".\" />")
+				.append("<default revision=\"master\" remote=\"remote1\" />")
+				.append("<project path=\"pin-noupstream\"")
+				.append("  name=\"pin-noupstream\"")
+				.append("  revision=\"76ce6d91a2e07fdfcbfc8df6970c9e98a98e36a0\" />")
+				.append("<project path=\"pin-upstream\"")
+				.append("  name=\"pin-upstream\"")
+				.append("  upstream=\"branchX\"")
+				.append("  revision=\"76ce6d91a2e07fdfcbfc8df6970c9e98a98e36a0\" />")
+				.append("</manifest>");
+		JGitTestUtil.writeTrashFile(tempDb, "manifest.xml",
+				xmlContent.toString());
+
+		RepoCommand command = new RepoCommand(remoteDb);
+		command.setPath(
+				tempDb.getWorkTree().getAbsolutePath() + "/manifest.xml")
+				.setURI(rootUri).setRecordRemoteBranch(true).call();
+		// Clone it
+		File directory = createTempDirectory("testBareRepo");
+		try (Repository localDb = Git.cloneRepository().setDirectory(directory)
+				.setURI(remoteDb.getDirectory().toURI().toString()).call()
+				.getRepository();) {
+			// The .gitmodules file should exist
+			File gitmodules = new File(localDb.getWorkTree(), ".gitmodules");
+			assertTrue("The .gitmodules file should exist",
+					gitmodules.exists());
+			FileBasedConfig c = new FileBasedConfig(gitmodules, FS.DETECTED);
+			c.load();
+			assertEquals("Pinned submodule with upstream records the ref",
+					"branchX", c.getString("submodule", "pin-upstream", "ref"));
+			assertNull("Pinned submodule without upstream don't have ref",
+					c.getString("submodule", "pin-noupstream", "ref"));
+		}
+	}
+
+	@Test
+	public void testRecordRemoteBranch_pinned_nameConflict() throws Exception {
+		Repository remoteDb = createBareRepository();
+		Repository tempDb = createWorkRepository();
+
+		StringBuilder xmlContent = new StringBuilder();
+		xmlContent.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
+				.append("<manifest>")
+				.append("<remote name=\"remote1\" fetch=\".\" />")
+				.append("<default revision=\"master\" remote=\"remote1\" />")
+				.append("<project path=\"pin-upstream\"")
+				.append("  name=\"pin-upstream\"")
+				.append("  upstream=\"branchX\"")
+				.append("  revision=\"76ce6d91a2e07fdfcbfc8df6970c9e98a98e36a0\" />")
+				.append("<project path=\"pin-upstream-name-conflict\"")
+				.append("  name=\"pin-upstream\"")
+				.append("  upstream=\"branchX\"")
+				.append("  revision=\"76ce6d91a2e07fdfcbfc8df6970c9e98a98e36a0\" />")
+				.append("</manifest>");
+		JGitTestUtil.writeTrashFile(tempDb, "manifest.xml",
+				xmlContent.toString());
+
+		RepoCommand command = new RepoCommand(remoteDb);
+		command.setPath(
+				tempDb.getWorkTree().getAbsolutePath() + "/manifest.xml")
+				.setURI(rootUri).setRecordRemoteBranch(true).call();
+		// Clone it
+		File directory = createTempDirectory("testBareRepo");
+		try (Repository localDb = Git.cloneRepository().setDirectory(directory)
+				.setURI(remoteDb.getDirectory().toURI().toString()).call()
+				.getRepository();) {
+			// The .gitmodules file should exist
+			File gitmodules = new File(localDb.getWorkTree(), ".gitmodules");
+			assertTrue("The .gitmodules file should exist",
+					gitmodules.exists());
+			FileBasedConfig c = new FileBasedConfig(gitmodules, FS.DETECTED);
+			c.load();
+			assertEquals("Upstream is preserved in name conflict", "branchX",
+					c.getString("submodule", "pin-upstream/pin-upstream",
+							"ref"));
+			assertEquals("Upstream is preserved in name conflict (other side)",
+					"branchX", c.getString("submodule",
+							"pin-upstream/pin-upstream-name-conflict", "ref"));
+		}
+	}
 
 	@Test
 	public void testRecordSubmoduleLabels() throws Exception {
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/internal/storage/commitgraph/CommitGraphWriterTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/internal/storage/commitgraph/CommitGraphWriterTest.java
index 9f65ee2..80a0f0c 100644
--- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/internal/storage/commitgraph/CommitGraphWriterTest.java
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/internal/storage/commitgraph/CommitGraphWriterTest.java
@@ -10,6 +10,7 @@
 
 package org.eclipse.jgit.internal.storage.commitgraph;
 
+import static java.util.stream.Collectors.toList;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.junit.Assert.assertArrayEquals;
@@ -19,8 +20,12 @@
 
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
 import java.util.Collections;
 import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 
 import org.eclipse.jgit.dircache.DirCacheEntry;
@@ -413,6 +418,27 @@ public void testReuseBloomFilters() throws Exception {
 				"119,69,63,-8,0,"));
 	}
 
+	@Test
+	public void testPathDiffCalculator_skipUnchangedTree() throws Exception {
+		RevCommit root = tr.commit(tr.tree(
+				tr.file("d/sd1/f1", tr.blob("f1")),
+				tr.file("d/sd2/f2", tr.blob("f2"))));
+		RevCommit tip = tr.commit(tr.tree(
+				tr.file("d/sd1/f1", tr.blob("f1")),
+				tr.file("d/sd2/f2", tr.blob("f2B"))), root);
+		CommitGraphWriter.PathDiffCalculator c = new CommitGraphWriter.PathDiffCalculator();
+
+		Optional<HashSet<ByteBuffer>> byteBuffers = c.changedPaths(walk.getObjectReader(), tip);
+
+		assertTrue(byteBuffers.isPresent());
+		List<String> asString = byteBuffers.get().stream()
+				.map(b -> StandardCharsets.UTF_8.decode(b).toString())
+				.collect(toList());
+		assertThat(asString, containsInAnyOrder("d", "d/sd2", "d/sd2/f2"));
+		// We don't walk into d/sd1/f1
+		assertEquals(1, c.stepCounter);
+	}
+
 	RevCommit commit(RevCommit... parents) throws Exception {
 		return tr.commit(parents);
 	}
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/BareSuperprojectWriter.java b/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/BareSuperprojectWriter.java
index 3ce97a4..d191e23 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/BareSuperprojectWriter.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/BareSuperprojectWriter.java
@@ -156,6 +156,9 @@ private void prepareIndex(List<RepoProject> projects, DirCache index,
 			ObjectId objectId;
 			if (ObjectId.isId(proj.getRevision())) {
 				objectId = ObjectId.fromString(proj.getRevision());
+				if (config.recordRemoteBranch && proj.getUpstream() != null) {
+					cfg.setString("submodule", name, "ref", proj.getUpstream());
+				}
 			} else {
 				objectId = callback.sha1(url, proj.getRevision());
 				if (objectId == null && !config.ignoreRemoteFailures) {
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/ManifestParser.java b/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/ManifestParser.java
index 957b386..7402c76 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/ManifestParser.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/ManifestParser.java
@@ -176,6 +176,8 @@ public void startElement(
 					attributes.getValue("groups"));
 			currentProject
 					.setRecommendShallow(attributes.getValue("clone-depth"));
+			currentProject
+					.setUpstream(attributes.getValue("upstream"));
 			break;
 		case "remote":
 			String alias = attributes.getValue("alias");
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/RepoCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/RepoCommand.java
index 95c1c8b..3aaef38 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/RepoCommand.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/RepoCommand.java
@@ -615,6 +615,7 @@ private List<RepoProject> renameProjects(List<RepoProject> projects) {
 				p.setUrl(proj.getUrl());
 				p.addCopyFiles(proj.getCopyFiles());
 				p.addLinkFiles(proj.getLinkFiles());
+				p.setUpstream(proj.getUpstream());
 				ret.add(p);
 			}
 		}
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/RepoProject.java b/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/RepoProject.java
index 8deb738..aa1af21 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/RepoProject.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/RepoProject.java
@@ -38,6 +38,7 @@ public class RepoProject implements Comparable<RepoProject> {
 	private final Set<String> groups;
 	private final List<CopyFile> copyfiles;
 	private final List<LinkFile> linkfiles;
+	private String upstream;
 	private String recommendShallow;
 	private String url;
 	private String defaultRevision;
@@ -389,6 +390,31 @@ public void clearLinkFiles() {
 		this.linkfiles.clear();
 	}
 
+	/**
+	 * Return the upstream attribute of the project
+	 *
+	 * @return the upstream value if present, null otherwise.
+	 *
+	 * @since 6.10
+	 */
+	public String getUpstream() {
+		return this.upstream;
+	}
+
+	/**
+	 * Set the upstream attribute of the project
+	 *
+	 * Name of the git ref in which a sha1 can be found, when the revision is a
+	 * sha1.
+	 *
+	 * @param upstream value of the attribute in the manifest
+	 *
+	 * @since 6.10
+	 */
+	void setUpstream(String upstream) {
+		this.upstream = upstream;
+	}
+
 	private String getPathWithSlash() {
 		if (path.endsWith("/")) { //$NON-NLS-1$
 			return path;
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/internal/storage/commitgraph/CommitGraphWriter.java b/org.eclipse.jgit/src/org/eclipse/jgit/internal/storage/commitgraph/CommitGraphWriter.java
index 0d9815e..55539e2 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/internal/storage/commitgraph/CommitGraphWriter.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/internal/storage/commitgraph/CommitGraphWriter.java
@@ -52,6 +52,7 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.treewalk.EmptyTreeIterator;
 import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.TreeFilter;
 import org.eclipse.jgit.util.NB;
 
 /**
@@ -71,6 +72,9 @@ public class CommitGraphWriter {
 
 	private static final int MAX_CHANGED_PATHS = 512;
 
+	private static final PathDiffCalculator PATH_DIFF_CALCULATOR
+			= new PathDiffCalculator();
+
 	private final int hashsz;
 
 	private final GraphCommits graphCommits;
@@ -374,37 +378,6 @@ private void writeCommitData(CancellableDigestOutputStream out)
 		return generations;
 	}
 
-	private static Optional<HashSet<ByteBuffer>> computeBloomFilterPaths(
-			ObjectReader or, RevCommit cmit) throws MissingObjectException,
-			IncorrectObjectTypeException, CorruptObjectException, IOException {
-		HashSet<ByteBuffer> paths = new HashSet<>();
-		try (TreeWalk walk = new TreeWalk(null, or)) {
-			walk.setRecursive(true);
-			if (cmit.getParentCount() == 0) {
-				walk.addTree(new EmptyTreeIterator());
-			} else {
-				walk.addTree(cmit.getParent(0).getTree());
-			}
-			walk.addTree(cmit.getTree());
-			while (walk.next()) {
-				if (walk.idEqual(0, 1)) {
-					continue;
-				}
-				byte[] rawPath = walk.getRawPath();
-				paths.add(ByteBuffer.wrap(rawPath));
-				for (int i = 0; i < rawPath.length; i++) {
-					if (rawPath[i] == '/') {
-						paths.add(ByteBuffer.wrap(rawPath, 0, i));
-					}
-					if (paths.size() > MAX_CHANGED_PATHS) {
-						return Optional.empty();
-					}
-				}
-			}
-		}
-		return Optional.of(paths);
-	}
-
 	private BloomFilterChunks computeBloomFilterChunks(ProgressMonitor monitor)
 			throws MissingObjectException, IncorrectObjectTypeException,
 			CorruptObjectException, IOException {
@@ -435,8 +408,8 @@ private BloomFilterChunks computeBloomFilterChunks(ProgressMonitor monitor)
 					filtersReused++;
 				} else {
 					filtersComputed++;
-					Optional<HashSet<ByteBuffer>> paths = computeBloomFilterPaths(
-							graphCommits.getObjectReader(), cmit);
+					Optional<HashSet<ByteBuffer>> paths = PATH_DIFF_CALCULATOR
+							.changedPaths(graphCommits.getObjectReader(), cmit);
 					if (paths.isEmpty()) {
 						cpf = ChangedPathFilter.FULL;
 					} else {
@@ -473,6 +446,44 @@ private void writeExtraEdges(CancellableDigestOutputStream out)
 		}
 	}
 
+	// Visible for testing
+	static class PathDiffCalculator {
+
+		// Walk steps in the last invocation of changedPaths
+		int stepCounter;
+
+		Optional<HashSet<ByteBuffer>> changedPaths(
+				ObjectReader or, RevCommit cmit) throws MissingObjectException,
+				IncorrectObjectTypeException, CorruptObjectException, IOException {
+			stepCounter = 0;
+			HashSet<ByteBuffer> paths = new HashSet<>();
+			try (TreeWalk walk = new TreeWalk(null, or)) {
+				walk.setRecursive(true);
+				walk.setFilter(TreeFilter.ANY_DIFF);
+				if (cmit.getParentCount() == 0) {
+					walk.addTree(new EmptyTreeIterator());
+				} else {
+					walk.addTree(cmit.getParent(0).getTree());
+				}
+				walk.addTree(cmit.getTree());
+				while (walk.next()) {
+					stepCounter += 1;
+					byte[] rawPath = walk.getRawPath();
+					paths.add(ByteBuffer.wrap(rawPath));
+					for (int i = 0; i < rawPath.length; i++) {
+						if (rawPath[i] == '/') {
+							paths.add(ByteBuffer.wrap(rawPath, 0, i));
+						}
+						if (paths.size() > MAX_CHANGED_PATHS) {
+							return Optional.empty();
+						}
+					}
+				}
+			}
+			return Optional.of(paths);
+		}
+	}
+
 	private static class ChunkHeader {
 		final int id;