From 307ef6b4b51f5f9266f6212015a9b5a1f1e75df4 Mon Sep 17 00:00:00 2001
From: Ivan Frade <ifrade@google.com>
Date: Mon, 11 Nov 2024 13:13:59 -0800
Subject: [PATCH] GitTimeParser: A date parser using the java.time API

Replacement of GitDateParser that uses java.time classes instead of
the obsolete Date. Updating GitDateParser would have been a mess of
deprecation and methods with confusing names, so I opted for writing a
parallel class with the new types.

Some differences:

* The new DateTimeFormatter is thread-safe, so we don't need the
LocalThread cache

* No code seems to use other locale than the default, we don't need to
cache per locale either

Change-Id: If24610a055a47702fb5b7be2fc35a7c722480ee3
---
 .../util/GitTimeParserBadlyFormattedTest.java |  62 +++++
 .../eclipse/jgit/util/GitTimeParserTest.java  | 247 ++++++++++++++++++
 .../org/eclipse/jgit/util/GitDateParser.java  |   3 +
 .../org/eclipse/jgit/util/GitTimeParser.java  | 202 ++++++++++++++
 4 files changed, 514 insertions(+)
 create mode 100644 org.eclipse.jgit.test/tst/org/eclipse/jgit/util/GitTimeParserBadlyFormattedTest.java
 create mode 100644 org.eclipse.jgit.test/tst/org/eclipse/jgit/util/GitTimeParserTest.java
 create mode 100644 org.eclipse.jgit/src/org/eclipse/jgit/util/GitTimeParser.java

diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/util/GitTimeParserBadlyFormattedTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/util/GitTimeParserBadlyFormattedTest.java
new file mode 100644
index 000000000..e5f162d11
--- /dev/null
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/util/GitTimeParserBadlyFormattedTest.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2012, Christian Halstrick 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.util;
+
+import static org.junit.Assert.assertThrows;
+
+import java.text.ParseException;
+
+import org.eclipse.jgit.junit.MockSystemReader;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.experimental.theories.DataPoints;
+import org.junit.experimental.theories.Theories;
+import org.junit.experimental.theories.Theory;
+import org.junit.runner.RunWith;
+
+/**
+ * Tests which assert that unparseable Strings lead to ParseExceptions
+ */
+@RunWith(Theories.class)
+public class GitTimeParserBadlyFormattedTest {
+	private String dateStr;
+
+	@Before
+	public void setUp() {
+		MockSystemReader mockSystemReader = new MockSystemReader();
+		SystemReader.setInstance(mockSystemReader);
+	}
+
+	@After
+	public void tearDown() {
+		SystemReader.setInstance(null);
+	}
+
+	public GitTimeParserBadlyFormattedTest(String dateStr) {
+		this.dateStr = dateStr;
+	}
+
+	@DataPoints
+	public static String[] getDataPoints() {
+		return new String[] { "", "1970", "3000.3000.3000", "3 yesterday ago",
+				"now yesterday ago", "yesterdays", "3.day. 2.week.ago",
+				"day ago", "Gra Feb 21 15:35:00 2007 +0100",
+				"Sun Feb 21 15:35:00 2007 +0100",
+				"Wed Feb 21 15:35:00 Grand +0100" };
+	}
+
+	@Theory
+	public void badlyFormattedWithoutRef() {
+		assertThrows(
+				"The expected ParseException while parsing '" + dateStr
+						+ "' did not occur.",
+				ParseException.class, () -> GitTimeParser.parse(dateStr));
+	}
+}
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/util/GitTimeParserTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/util/GitTimeParserTest.java
new file mode 100644
index 000000000..0e5eb283a
--- /dev/null
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/util/GitTimeParserTest.java
@@ -0,0 +1,247 @@
+/*
+ * Copyright (C) 2024, Christian Halstrick 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.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.text.ParseException;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.Period;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoField;
+import java.time.temporal.TemporalAccessor;
+
+import org.eclipse.jgit.junit.MockSystemReader;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class GitTimeParserTest {
+	MockSystemReader mockSystemReader;
+
+	@Before
+	public void setUp() {
+		mockSystemReader = new MockSystemReader();
+		SystemReader.setInstance(mockSystemReader);
+	}
+
+	@After
+	public void tearDown() {
+		SystemReader.setInstance(null);
+	}
+
+	@Test
+	public void yesterday() throws ParseException {
+		LocalDateTime parse = GitTimeParser.parse("yesterday");
+
+		LocalDateTime now = SystemReader.getInstance().civilNow();
+		assertEquals(Period.between(parse.toLocalDate(), now.toLocalDate()),
+				Period.ofDays(1));
+	}
+
+	@Test
+	public void never() throws ParseException {
+		LocalDateTime parse = GitTimeParser.parse("never");
+		assertEquals(LocalDateTime.MAX, parse);
+	}
+
+	@Test
+	public void now_pointInTime() throws ParseException {
+		LocalDateTime aTime = asLocalDateTime("2007-02-21 15:35:00 +0100");
+
+		LocalDateTime parsedNow = GitTimeParser.parse("now", aTime);
+
+		assertEquals(aTime, parsedNow);
+	}
+
+	@Test
+	public void now_systemTime() throws ParseException {
+		LocalDateTime firstNow = GitTimeParser.parse("now");
+		assertEquals(SystemReader.getInstance().civilNow(), firstNow);
+		mockSystemReader.tick(10);
+		LocalDateTime secondNow = GitTimeParser.parse("now");
+		assertTrue(secondNow.isAfter(firstNow));
+	}
+
+	@Test
+	public void weeksAgo() throws ParseException {
+		LocalDateTime aTime = asLocalDateTime("2007-02-21 15:35:00 +0100");
+
+		LocalDateTime parse = GitTimeParser.parse("2 weeks ago", aTime);
+		assertEquals(asLocalDateTime("2007-02-07 15:35:00 +0100"), parse);
+	}
+
+	@Test
+	public void daysAndWeeksAgo() throws ParseException {
+		LocalDateTime aTime = asLocalDateTime("2007-02-21 15:35:00 +0100");
+
+		LocalDateTime twoWeeksAgoActual = GitTimeParser.parse("2 weeks ago",
+				aTime);
+
+		LocalDateTime twoWeeksAgoExpected = asLocalDateTime(
+				"2007-02-07 15:35:00 +0100");
+		assertEquals(twoWeeksAgoExpected, twoWeeksAgoActual);
+
+		LocalDateTime combinedWhitespace = GitTimeParser
+				.parse("3 days 2 weeks ago", aTime);
+		LocalDateTime combinedWhitespaceExpected = asLocalDateTime(
+				"2007-02-04 15:35:00 +0100");
+		assertEquals(combinedWhitespaceExpected, combinedWhitespace);
+
+		LocalDateTime combinedDots = GitTimeParser.parse("3.day.2.week.ago",
+				aTime);
+		LocalDateTime combinedDotsExpected = asLocalDateTime(
+				"2007-02-04 15:35:00 +0100");
+		assertEquals(combinedDotsExpected, combinedDots);
+	}
+
+	@Test
+	public void hoursAgo() throws ParseException {
+		LocalDateTime aTime = asLocalDateTime("2007-02-21 17:35:00 +0100");
+
+		LocalDateTime twoHoursAgoActual = GitTimeParser.parse("2 hours ago",
+				aTime);
+
+		LocalDateTime twoHoursAgoExpected = asLocalDateTime(
+				"2007-02-21 15:35:00 +0100");
+		assertEquals(twoHoursAgoExpected, twoHoursAgoActual);
+	}
+
+	@Test
+	public void hoursAgo_acrossDay() throws ParseException {
+		LocalDateTime aTime = asLocalDateTime("2007-02-21 00:35:00 +0100");
+
+		LocalDateTime twoHoursAgoActual = GitTimeParser.parse("2 hours ago",
+				aTime);
+
+		LocalDateTime twoHoursAgoExpected = asLocalDateTime(
+				"2007-02-20 22:35:00 +0100");
+		assertEquals(twoHoursAgoExpected, twoHoursAgoActual);
+	}
+
+	@Test
+	public void minutesHoursAgoCombined() throws ParseException {
+		LocalDateTime aTime = asLocalDateTime("2007-02-04 15:35:00 +0100");
+
+		LocalDateTime combinedWhitespace = GitTimeParser
+				.parse("3 hours 2 minutes ago", aTime);
+		LocalDateTime combinedWhitespaceExpected = asLocalDateTime(
+				"2007-02-04 12:33:00 +0100");
+		assertEquals(combinedWhitespaceExpected, combinedWhitespace);
+
+		LocalDateTime combinedDots = GitTimeParser
+				.parse("3.hours.2.minutes.ago", aTime);
+		LocalDateTime combinedDotsExpected = asLocalDateTime(
+				"2007-02-04 12:33:00 +0100");
+		assertEquals(combinedDotsExpected, combinedDots);
+	}
+
+	@Test
+	public void minutesAgo() throws ParseException {
+		LocalDateTime aTime = asLocalDateTime("2007-02-21 17:35:10 +0100");
+
+		LocalDateTime twoMinutesAgo = GitTimeParser.parse("2 minutes ago",
+				aTime);
+
+		LocalDateTime twoMinutesAgoExpected = asLocalDateTime(
+				"2007-02-21 17:33:10 +0100");
+		assertEquals(twoMinutesAgoExpected, twoMinutesAgo);
+	}
+
+	@Test
+	public void minutesAgo_acrossDay() throws ParseException {
+		LocalDateTime aTime = asLocalDateTime("2007-02-21 00:35:10 +0100");
+
+		LocalDateTime minutesAgoActual = GitTimeParser.parse("40 minutes ago",
+				aTime);
+
+		LocalDateTime minutesAgoExpected = asLocalDateTime(
+				"2007-02-20 23:55:10 +0100");
+		assertEquals(minutesAgoExpected, minutesAgoActual);
+	}
+
+	@Test
+	public void iso() throws ParseException {
+		String dateStr = "2007-02-21 15:35:00 +0100";
+
+		LocalDateTime actual = GitTimeParser.parse(dateStr);
+
+		LocalDateTime expected = asLocalDateTime(dateStr);
+		assertEquals(expected, actual);
+	}
+
+	@Test
+	public void rfc() throws ParseException {
+		String dateStr = "Wed, 21 Feb 2007 15:35:00 +0100";
+
+		LocalDateTime actual = GitTimeParser.parse(dateStr);
+
+		LocalDateTime expected = asLocalDateTime(dateStr,
+				"EEE, dd MMM yyyy HH:mm:ss Z");
+		assertEquals(expected, actual);
+	}
+
+	@Test
+	public void shortFmt() throws ParseException {
+		assertParsing("2007-02-21", "yyyy-MM-dd");
+	}
+
+	@Test
+	public void shortWithDots() throws ParseException {
+		assertParsing("2007.02.21", "yyyy.MM.dd");
+	}
+
+	@Test
+	public void shortWithSlash() throws ParseException {
+		assertParsing("02/21/2007", "MM/dd/yyyy");
+	}
+
+	@Test
+	public void shortWithDotsReverse() throws ParseException {
+		assertParsing("21.02.2007", "dd.MM.yyyy");
+	}
+
+	@Test
+	public void defaultFmt() throws ParseException {
+		assertParsing("Wed Feb 21 15:35:00 2007 +0100",
+				"EEE MMM dd HH:mm:ss yyyy Z");
+	}
+
+	@Test
+	public void local() throws ParseException {
+		assertParsing("Wed Feb 21 15:35:00 2007", "EEE MMM dd HH:mm:ss yyyy");
+	}
+
+	private static void assertParsing(String dateStr, String format)
+			throws ParseException {
+		LocalDateTime actual = GitTimeParser.parse(dateStr);
+
+		LocalDateTime expected = asLocalDateTime(dateStr, format);
+		assertEquals(expected, actual);
+	}
+
+	private static LocalDateTime asLocalDateTime(String dateStr) {
+		return asLocalDateTime(dateStr, "yyyy-MM-dd HH:mm:ss Z");
+	}
+
+	private static LocalDateTime asLocalDateTime(String dateStr,
+			String pattern) {
+		DateTimeFormatter fmt = DateTimeFormatter.ofPattern(pattern);
+		TemporalAccessor ta = fmt
+				.withZone(SystemReader.getInstance().getTimeZoneId())
+				.withLocale(SystemReader.getInstance().getLocale())
+				.parse(dateStr);
+		return ta.isSupported(ChronoField.HOUR_OF_DAY) ? LocalDateTime.from(ta)
+				: LocalDate.from(ta).atStartOfDay();
+	}
+}
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/util/GitDateParser.java b/org.eclipse.jgit/src/org/eclipse/jgit/util/GitDateParser.java
index 6a4b39652..f08005654 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/util/GitDateParser.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/util/GitDateParser.java
@@ -28,7 +28,10 @@
  * used. One example is the parsing of the config parameter gc.pruneexpire. The
  * parser can handle only subset of what native gits approxidate parser
  * understands.
+ *
+ * @deprecated Use {@link GitTimeParser} instead.
  */
+@Deprecated(since = "7.1")
 public class GitDateParser {
 	/**
 	 * The Date representing never. Though this is a concrete value, most
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/util/GitTimeParser.java b/org.eclipse.jgit/src/org/eclipse/jgit/util/GitTimeParser.java
new file mode 100644
index 000000000..e238e3e92
--- /dev/null
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/util/GitTimeParser.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2024 Christian Halstrick 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.util;
+
+import java.text.MessageFormat;
+import java.text.ParseException;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.time.temporal.ChronoField;
+import java.time.temporal.TemporalAccessor;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.eclipse.jgit.internal.JGitText;
+
+/**
+ * Parses strings with time and date specifications into
+ * {@link java.time.Instant}.
+ *
+ * When git needs to parse strings specified by the user this parser can be
+ * used. One example is the parsing of the config parameter gc.pruneexpire. The
+ * parser can handle only subset of what native gits approxidate parser
+ * understands.
+ *
+ * @since 7.1
+ */
+public class GitTimeParser {
+
+	private static final Map<ParseableSimpleDateFormat, DateTimeFormatter> formatCache = new HashMap<>();
+
+	// An enum of all those formats which this parser can parse with the help of
+	// a DateTimeFormatter. There are other formats (e.g. the relative formats
+	// like "yesterday" or "1 week ago") which this parser can parse but which
+	// are not listed here because they are parsed without the help of a
+	// DateTimeFormatter.
+	enum ParseableSimpleDateFormat {
+		ISO("yyyy-MM-dd HH:mm:ss Z"), // //$NON-NLS-1$
+		RFC("EEE, dd MMM yyyy HH:mm:ss Z"), // //$NON-NLS-1$
+		SHORT("yyyy-MM-dd"), // //$NON-NLS-1$
+		SHORT_WITH_DOTS_REVERSE("dd.MM.yyyy"), // //$NON-NLS-1$
+		SHORT_WITH_DOTS("yyyy.MM.dd"), // //$NON-NLS-1$
+		SHORT_WITH_SLASH("MM/dd/yyyy"), // //$NON-NLS-1$
+		DEFAULT("EEE MMM dd HH:mm:ss yyyy Z"), // //$NON-NLS-1$
+		LOCAL("EEE MMM dd HH:mm:ss yyyy"); //$NON-NLS-1$
+
+		private final String formatStr;
+
+		ParseableSimpleDateFormat(String formatStr) {
+			this.formatStr = formatStr;
+		}
+	}
+
+	/**
+	 * Parses a string into a {@link java.time.LocalDateTime} using the default
+	 * locale. Since this parser also supports relative formats (e.g.
+	 * "yesterday") the caller can specify the reference date. These types of
+	 * strings can be parsed:
+	 * <ul>
+	 * <li>"never"</li>
+	 * <li>"now"</li>
+	 * <li>"yesterday"</li>
+	 * <li>"(x) years|months|weeks|days|hours|minutes|seconds ago"<br>
+	 * Multiple specs can be combined like in "2 weeks 3 days ago". Instead of '
+	 * ' one can use '.' to separate the words</li>
+	 * <li>"yyyy-MM-dd HH:mm:ss Z" (ISO)</li>
+	 * <li>"EEE, dd MMM yyyy HH:mm:ss Z" (RFC)</li>
+	 * <li>"yyyy-MM-dd"</li>
+	 * <li>"yyyy.MM.dd"</li>
+	 * <li>"MM/dd/yyyy",</li>
+	 * <li>"dd.MM.yyyy"</li>
+	 * <li>"EEE MMM dd HH:mm:ss yyyy Z" (DEFAULT)</li>
+	 * <li>"EEE MMM dd HH:mm:ss yyyy" (LOCAL)</li>
+	 * </ul>
+	 *
+	 * @param dateStr
+	 *            the string to be parsed
+	 * @return the parsed {@link java.time.LocalDateTime}
+	 * @throws java.text.ParseException
+	 *             if the given dateStr was not recognized
+	 */
+	public static LocalDateTime parse(String dateStr) throws ParseException {
+		return parse(dateStr, SystemReader.getInstance().civilNow());
+	}
+
+	// Only tests seem to use this method
+	static LocalDateTime parse(String dateStr, LocalDateTime now)
+			throws ParseException {
+		dateStr = dateStr.trim();
+		LocalDateTime ret;
+
+		if ("never".equalsIgnoreCase(dateStr)) //$NON-NLS-1$
+			return LocalDateTime.MAX;
+		ret = parse_relative(dateStr, now);
+		if (ret != null)
+			return ret;
+		for (ParseableSimpleDateFormat f : ParseableSimpleDateFormat.values()) {
+			try {
+				return parse_simple(dateStr, f);
+			} catch (DateTimeParseException e) {
+				// simply proceed with the next parser
+			}
+		}
+		ParseableSimpleDateFormat[] values = ParseableSimpleDateFormat.values();
+		StringBuilder allFormats = new StringBuilder("\"") //$NON-NLS-1$
+				.append(values[0].formatStr);
+		for (int i = 1; i < values.length; i++)
+			allFormats.append("\", \"").append(values[i].formatStr); //$NON-NLS-1$
+		allFormats.append("\""); //$NON-NLS-1$
+		throw new ParseException(
+				MessageFormat.format(JGitText.get().cannotParseDate, dateStr,
+						allFormats.toString()),
+				0);
+	}
+
+	// tries to parse a string with the formats supported by DateTimeFormatter
+	private static LocalDateTime parse_simple(String dateStr,
+			ParseableSimpleDateFormat f) throws DateTimeParseException {
+		DateTimeFormatter dateFormat = formatCache.computeIfAbsent(f,
+				format -> DateTimeFormatter.ofPattern(f.formatStr)
+						.withLocale(SystemReader.getInstance().getLocale()));
+		TemporalAccessor parsed = dateFormat.parse(dateStr);
+		return parsed.isSupported(ChronoField.HOUR_OF_DAY)
+				? LocalDateTime.from(parsed)
+				: LocalDate.from(parsed).atStartOfDay();
+	}
+
+	// tries to parse a string with a relative time specification
+	@SuppressWarnings("nls")
+	private static LocalDateTime parse_relative(String dateStr,
+			LocalDateTime now) {
+		// check for the static words "yesterday" or "now"
+		if ("now".equals(dateStr)) {
+			return now;
+		}
+
+		if ("yesterday".equals(dateStr)) {
+			return now.minusDays(1);
+		}
+
+		// parse constructs like "3 days ago", "5.week.2.day.ago"
+		String[] parts = dateStr.split("\\.| ");
+		int partsLength = parts.length;
+		// check we have an odd number of parts (at least 3) and that the last
+		// part is "ago"
+		if (partsLength < 3 || (partsLength & 1) == 0
+				|| !"ago".equals(parts[parts.length - 1]))
+			return null;
+		int number;
+		for (int i = 0; i < parts.length - 2; i += 2) {
+			try {
+				number = Integer.parseInt(parts[i]);
+			} catch (NumberFormatException e) {
+				return null;
+			}
+			if (parts[i + 1] == null) {
+				return null;
+			}
+			switch (parts[i + 1]) {
+			case "year":
+			case "years":
+				now = now.minusYears(number);
+				break;
+			case "month":
+			case "months":
+				now = now.minusMonths(number);
+				break;
+			case "week":
+			case "weeks":
+				now = now.minusWeeks(number);
+				break;
+			case "day":
+			case "days":
+				now = now.minusDays(number);
+				break;
+			case "hour":
+			case "hours":
+				now = now.minusHours(number);
+				break;
+			case "minute":
+			case "minutes":
+				now = now.minusMinutes(number);
+				break;
+			case "second":
+			case "seconds":
+				now = now.minusSeconds(number);
+				break;
+			default:
+				return null;
+			}
+		}
+		return now;
+	}
+}
-- 
GitLab