Merge branch 'stable-6.10' into stable-7.0

* stable-6.10:
  Replace custom encoder Constants#encodeASCII by JDK implementation
  Replace custom encoder `Constants#encode` by JDK implementation
  Add `numberOfPackFilesAfterBitmap` to RepoStatistics
  Enhance CommitBuilder#parent to tolerate null parent

Change-Id: If05b0d474c728b54cab9af2b7416be30b2754d1b
diff --git a/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/TestRepository.java b/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/TestRepository.java
index a2e0a57..66cf739 100644
--- a/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/TestRepository.java
+++ b/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/TestRepository.java
@@ -30,6 +30,7 @@
 import java.util.Set;
 import java.util.TimeZone;
 
+import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheBuilder;
@@ -1144,15 +1145,18 @@ public class CommitBuilder {
 		}
 
 		/**
-		 * set parent commit
+		 * Set parent commit
 		 *
 		 * @param p
-		 *            parent commit
+		 *            parent commit, can be {@code null}
 		 * @return this commit builder
 		 * @throws Exception
 		 *             if an error occurred
 		 */
-		public CommitBuilder parent(RevCommit p) throws Exception {
+		public CommitBuilder parent(@Nullable RevCommit p) throws Exception {
+			if (p == null) {
+				return this;
+			}
 			if (parents.isEmpty()) {
 				DirCacheBuilder b = tree.builder();
 				parseBody(p);
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/internal/storage/file/GcNumberOfPackFilesAfterBitmapStatisticsTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/internal/storage/file/GcNumberOfPackFilesAfterBitmapStatisticsTest.java
new file mode 100644
index 0000000..e5a391f
--- /dev/null
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/internal/storage/file/GcNumberOfPackFilesAfterBitmapStatisticsTest.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright (c) 2024 Jacek Centkowski <geminica.programs@gmail.com> and others.
+ *
+ * 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.internal.storage.file;
+
+import static org.junit.Assert.assertEquals;
+
+import java.io.BufferedOutputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.stream.StreamSupport;
+
+import org.eclipse.jgit.internal.storage.file.PackIndex.MutableEntry;
+import org.eclipse.jgit.internal.storage.pack.PackExt;
+import org.eclipse.jgit.internal.storage.pack.PackWriter;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.util.FileUtils;
+import org.junit.Test;
+
+public class GcNumberOfPackFilesAfterBitmapStatisticsTest extends GcTestCase {
+	@Test
+	public void testShouldReportZeroObjectsForInitializedRepo()
+			throws IOException {
+		assertEquals(0L, gc.getStatistics().numberOfPackFilesAfterBitmap);
+	}
+
+	@Test
+	public void testShouldReportAllPackFilesWhenNoGcWasPerformed()
+			throws Exception {
+		packAndPrune();
+		long result = gc.getStatistics().numberOfPackFilesAfterBitmap;
+
+		assertEquals(repo.getObjectDatabase().getPacks().size(), result);
+	}
+
+	@Test
+	public void testShouldReportNoObjectsDirectlyAfterGc() throws Exception {
+		// given
+		addCommit(null);
+		gc.gc().get();
+		assertEquals(1L, repositoryBitmapFiles());
+		assertEquals(0L, gc.getStatistics().numberOfPackFilesAfterBitmap);
+	}
+
+	@Test
+	public void testShouldReportNewObjectsAfterGcWhenRepositoryProgresses()
+			throws Exception {
+		// commit & gc
+		RevCommit parent = addCommit(null);
+		gc.gc().get();
+		assertEquals(1L, repositoryBitmapFiles());
+
+		// progress & pack
+		addCommit(parent);
+		packAndPrune();
+
+		assertEquals(1L, gc.getStatistics().numberOfPackFilesAfterBitmap);
+	}
+
+	@Test
+	public void testShouldReportNewObjectsFromTheLatestBitmapWhenRepositoryProgresses()
+			throws Exception {
+		// commit & gc
+		RevCommit parent = addCommit(null);
+		gc.gc().get();
+		assertEquals(1L, repositoryBitmapFiles());
+
+		// progress & gc
+		parent = addCommit(parent);
+		gc.gc().get();
+		assertEquals(2L, repositoryBitmapFiles());
+
+		// progress & pack
+		addCommit(parent);
+		packAndPrune();
+
+		assertEquals(1L, gc.getStatistics().numberOfPackFilesAfterBitmap);
+	}
+
+	private void packAndPrune() throws Exception {
+		try (SkipNonExistingFilesTestRepository testRepo = new SkipNonExistingFilesTestRepository(
+				repo)) {
+			testRepo.packAndPrune();
+		}
+	}
+
+	private RevCommit addCommit(RevCommit parent) throws Exception {
+		return tr.branch("master").commit()
+				.author(new PersonIdent("repo-metrics", "repo@metrics.com"))
+				.parent(parent).create();
+	}
+
+	private long repositoryBitmapFiles() throws IOException {
+		return StreamSupport
+				.stream(Files
+						.newDirectoryStream(repo.getObjectDatabase()
+								.getPackDirectory().toPath(), "pack-*.bitmap")
+						.spliterator(), false)
+				.count();
+	}
+
+	/**
+	 * The TestRepository has a {@link TestRepository#packAndPrune()} function
+	 * but it fails in the last step after GC was performed as it doesn't
+	 * SKIP_MISSING files. In order to circumvent it was copied and improved
+	 * here.
+	 */
+	private static class SkipNonExistingFilesTestRepository
+			extends TestRepository<FileRepository> {
+		private final FileRepository repo;
+
+		private SkipNonExistingFilesTestRepository(FileRepository db) throws IOException {
+			super(db);
+			repo = db;
+		}
+
+		@Override
+		public void packAndPrune() throws Exception {
+			ObjectDirectory odb = repo.getObjectDatabase();
+			NullProgressMonitor m = NullProgressMonitor.INSTANCE;
+
+			final PackFile pack, idx;
+			try (PackWriter pw = new PackWriter(repo)) {
+				Set<ObjectId> all = new HashSet<>();
+				for (Ref r : repo.getRefDatabase().getRefs())
+					all.add(r.getObjectId());
+				pw.preparePack(m, all, PackWriter.NONE);
+
+				pack = new PackFile(odb.getPackDirectory(), pw.computeName(),
+						PackExt.PACK);
+				try (OutputStream out = new BufferedOutputStream(
+						new FileOutputStream(pack))) {
+					pw.writePack(m, m, out);
+				}
+				pack.setReadOnly();
+
+				idx = pack.create(PackExt.INDEX);
+				try (OutputStream out = new BufferedOutputStream(
+						new FileOutputStream(idx))) {
+					pw.writeIndex(out);
+				}
+				idx.setReadOnly();
+			}
+
+			odb.openPack(pack);
+			updateServerInfo();
+
+			// alternative packAndPrune implementation that skips missing files
+			// after GC.
+			for (Pack p : odb.getPacks()) {
+				for (MutableEntry e : p)
+					FileUtils.delete(odb.fileFor(e.toObjectId()),
+							FileUtils.SKIP_MISSING);
+			}
+		}
+	}
+}
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/internal/storage/file/GC.java b/org.eclipse.jgit/src/org/eclipse/jgit/internal/storage/file/GC.java
index 4fafc5a..8fde390 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/internal/storage/file/GC.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/internal/storage/file/GC.java
@@ -1509,6 +1509,12 @@ public static class RepoStatistics {
 		public long numberOfPackFiles;
 
 		/**
+		 * The number of pack files that were created after the last bitmap
+		 * generation.
+		 */
+		public long numberOfPackFilesAfterBitmap;
+
+		/**
 		 * The number of objects stored as loose objects.
 		 */
 		public long numberOfLooseObjects;
@@ -1543,6 +1549,8 @@ public String toString() {
 			final StringBuilder b = new StringBuilder();
 			b.append("numberOfPackedObjects=").append(numberOfPackedObjects); //$NON-NLS-1$
 			b.append(", numberOfPackFiles=").append(numberOfPackFiles); //$NON-NLS-1$
+			b.append(", numberOfPackFilesAfterBitmap=") //$NON-NLS-1$
+					.append(numberOfPackFilesAfterBitmap);
 			b.append(", numberOfLooseObjects=").append(numberOfLooseObjects); //$NON-NLS-1$
 			b.append(", numberOfLooseRefs=").append(numberOfLooseRefs); //$NON-NLS-1$
 			b.append(", numberOfPackedRefs=").append(numberOfPackedRefs); //$NON-NLS-1$
@@ -1567,8 +1575,11 @@ public RepoStatistics getStatistics() throws IOException {
 			ret.numberOfPackedObjects += p.getIndex().getObjectCount();
 			ret.numberOfPackFiles++;
 			ret.sizeOfPackedObjects += p.getPackFile().length();
-			if (p.getBitmapIndex() != null)
+			if (p.getBitmapIndex() != null) {
 				ret.numberOfBitmaps += p.getBitmapIndex().getBitmapCount();
+			} else {
+				ret.numberOfPackFilesAfterBitmap++;
+			}
 		}
 		File objDir = repo.getObjectsDirectory();
 		String[] fanout = objDir.list();
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/lib/Constants.java b/org.eclipse.jgit/src/org/eclipse/jgit/lib/Constants.java
index a503db9..997f4ed 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/lib/Constants.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/lib/Constants.java
@@ -12,9 +12,13 @@
 
 package org.eclipse.jgit.lib;
 
+import static java.nio.charset.StandardCharsets.US_ASCII;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CharsetEncoder;
+import java.nio.charset.CodingErrorAction;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.text.MessageFormat;
@@ -722,14 +726,15 @@ public static int decodeTypeString(final AnyObjectId id,
 	 *             the 7-bit ASCII character space.
 	 */
 	public static byte[] encodeASCII(String s) {
-		final byte[] r = new byte[s.length()];
-		for (int k = r.length - 1; k >= 0; k--) {
-			final char c = s.charAt(k);
-			if (c > 127)
-				throw new IllegalArgumentException(MessageFormat.format(JGitText.get().notASCIIString, s));
-			r[k] = (byte) c;
+		try {
+			CharsetEncoder encoder = US_ASCII.newEncoder()
+					.onUnmappableCharacter(CodingErrorAction.REPORT)
+					.onMalformedInput(CodingErrorAction.REPORT);
+			return encoder.encode(CharBuffer.wrap(s)).array();
+		} catch (CharacterCodingException e) {
+			throw new IllegalArgumentException(
+					MessageFormat.format(JGitText.get().notASCIIString, s), e);
 		}
-		return r;
 	}
 
 	/**
@@ -741,17 +746,7 @@ public static int decodeTypeString(final AnyObjectId id,
 	 *         default character encoding (UTF-8).
 	 */
 	public static byte[] encode(String str) {
-		final ByteBuffer bb = UTF_8.encode(str);
-		final int len = bb.limit();
-		if (bb.hasArray() && bb.arrayOffset() == 0) {
-			final byte[] arr = bb.array();
-			if (arr.length == len)
-				return arr;
-		}
-
-		final byte[] arr = new byte[len];
-		bb.get(arr);
-		return arr;
+		return str.getBytes(UTF_8);
 	}
 
 	static {