001/* 002 * JDrupes GitVersioning 003 * Copyright (C) 2025 Michael N. Lipp 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <https://www.gnu.org/licenses/>. 017 */ 018 019package org.jdrupes.gitversioning.core; 020 021import com.vdurmont.semver4j.Semver; 022import com.vdurmont.semver4j.SemverException; 023import java.io.IOException; 024import java.io.UncheckedIOException; 025import java.nio.file.Path; 026import java.util.ArrayList; 027import java.util.Collections; 028import java.util.Comparator; 029import java.util.HashSet; 030import java.util.Iterator; 031import java.util.List; 032import java.util.Map; 033import java.util.Objects; 034import java.util.Optional; 035import java.util.Set; 036import java.util.Spliterator; 037import java.util.Spliterators.AbstractSpliterator; 038import java.util.concurrent.ConcurrentHashMap; 039import java.util.function.Consumer; 040import java.util.logging.Logger; 041import java.util.stream.Stream; 042import java.util.stream.StreamSupport; 043import org.eclipse.jgit.api.Git; 044import org.eclipse.jgit.api.Status; 045import org.eclipse.jgit.api.errors.GitAPIException; 046import org.eclipse.jgit.diff.DiffEntry; 047import org.eclipse.jgit.lib.ObjectId; 048import org.eclipse.jgit.lib.ObjectReader; 049import org.eclipse.jgit.lib.Ref; 050import org.eclipse.jgit.lib.Repository; 051import org.eclipse.jgit.revwalk.RevCommit; 052import org.eclipse.jgit.revwalk.RevObject; 053import org.eclipse.jgit.revwalk.RevTag; 054import org.eclipse.jgit.revwalk.RevWalk; 055import org.eclipse.jgit.treewalk.CanonicalTreeParser; 056import org.jdrupes.gitversioning.api.TagFilter; 057import org.jdrupes.gitversioning.api.TagProcessor; 058import org.jdrupes.gitversioning.api.VersionEvaluator; 059 060/** 061 * Reference implementation of 062 * {@link org.jdrupes.gitversioning.api.VersionEvaluatorProvider}. 063 * 064 * <p>Finds the latest semantically versioned tag reachable from HEAD using the 065 * configured {@link TagFilter}, then delegates to the configured 066 * {@link TagProcessor} to produce the final version string. 067 * 068 * <p>Uses a {@link ConcurrentHashMap} to cache the set of 069 * commits reachable from HEAD, avoiding redundant graph walks. 070 */ 071@SuppressWarnings("PMD.CouplingBetweenObjects") 072public class VersionEvaluatorProvider 073 implements org.jdrupes.gitversioning.api.VersionEvaluatorProvider { 074 075 /** Logger for this instance. */ 076 protected final Logger log = Logger.getLogger(getClass().getName()); 077 @SuppressWarnings("PMD.FieldNamingConventions") 078 private static final Map<ObjectId, Set<ObjectId>> reachableByHead 079 = new ConcurrentHashMap<>(); 080 private Repository repository; 081 private final List<IncludeMatcher> matchers = new ArrayList<>(); 082 private TagFilter tagFilter = new DefaultTagFilter(); 083 private TagProcessor tagProcessor = new MavenStyleTagProcessor(); 084 085 /** 086 * Creates a new evaluator provider with default tag filter and processor. 087 */ 088 public VersionEvaluatorProvider() { 089 // Make javadoc happy. 090 } 091 092 @Override 093 public VersionEvaluatorProvider repository(Repository repository) { 094 this.repository = Objects.requireNonNull(repository); 095 return this; 096 } 097 098 @Override 099 public Repository repository() { 100 return repository; 101 } 102 103 @Override 104 public VersionEvaluator tagFilter(TagFilter tagFilter) { 105 this.tagFilter = tagFilter; 106 return this; 107 } 108 109 @Override 110 public VersionEvaluator tagProcessor(TagProcessor tagProcessor) { 111 this.tagProcessor = tagProcessor; 112 return this; 113 } 114 115 @Override 116 public VersionEvaluator matchingGlob(String glob) { 117 matchers.add(new GlobMatcher(glob)); 118 return this; 119 } 120 121 @Override 122 public VersionEvaluator matchingRegex(String regex) { 123 matchers.add(new RegexMatcher(regex)); 124 return this; 125 } 126 127 @Override 128 public VersionEvaluator matchingAntPattern(String pattern) { 129 matchers.add(new AntPatternMatcher(pattern)); 130 return this; 131 } 132 133 @Override 134 public VersionEvaluator subDirectory(Path subDirectory) { 135 var subDir = relativizeDirectory(repository, subDirectory).toString(); 136 if (subDir.isEmpty()) { 137 return this; 138 } 139 if (!subDir.endsWith("/")) { 140 subDir = subDir + "/"; 141 } 142 return matchingAntPattern(subDir + "**"); 143 } 144 145 /** 146 * If sub directory is absolute, return it as a path relative 147 * to the repository's work tree. 148 * 149 * @param repository the repository 150 * @param subDirectory the sub directory 151 * @return the path 152 */ 153 /* default */ static Path relativizeDirectory(Repository repository, 154 Path subDirectory) { 155 if (!subDirectory.isAbsolute()) { 156 return subDirectory; 157 } 158 if (!subDirectory.startsWith( 159 repository.getWorkTree().toPath().toAbsolutePath())) { 160 throw new IllegalArgumentException(subDirectory 161 + " is not a directory within the working tree"); 162 } 163 return repository.getWorkTree().toPath().relativize(subDirectory); 164 } 165 166 private boolean matches(Path path) { 167 return matchers.isEmpty() 168 || matchers.stream().filter(m -> m.matches(path)).findAny() 169 .isPresent(); 170 } 171 172 @Override 173 public Stream<Path> dirtyFiles() { 174 try (Git git = Git.wrap(repository)) { 175 Status status = git.status().call(); 176 177 // Uncommitted combines added, changed, removed, missing, 178 // modified and conflicting 179 return Stream.concat(status.getUncommittedChanges().stream(), 180 status.getUntracked().stream()).map(Path::of) 181 .filter(this::matches); 182 } catch (GitAPIException e) { 183 throw new IllegalStateException(e); 184 } 185 } 186 187 @Override 188 public Stream<Path> modifiedFiles() { 189 try { 190 var latest = getLatestVersionTagged(); 191 return modifiedFiles(latest.commit()); 192 } catch (IOException | GitAPIException e) { 193 throw new IllegalStateException(e); 194 } 195 } 196 197 @SuppressWarnings({ "PMD.AvoidCatchingGenericException", 198 "PMD.CognitiveComplexity", "PMD.NcssCount" }) 199 private Stream<Path> modifiedFiles(RevCommit taggedCommit) 200 throws IOException, GitAPIException { 201 var headId = repository.resolve("HEAD"); 202 if (headId == null || taggedCommit == null 203 || taggedCommit.getId().equals(headId)) { 204 return Stream.empty(); 205 } 206 207 @SuppressWarnings("PMD.CloseResource") 208 var revWalk = new RevWalk(repository); 209 @SuppressWarnings("PMD.CloseResource") 210 var git = new Git(repository); 211 @SuppressWarnings("PMD.CloseResource") 212 var reader = repository.newObjectReader(); 213 try { 214 revWalk.markStart(revWalk.parseCommit(headId)); 215 var commits = revWalk.iterator(); 216 var taggedId = taggedCommit.getId(); 217 var spliterator = new AbstractSpliterator<Path>( 218 Long.MAX_VALUE, Spliterator.ORDERED | Spliterator.NONNULL) { 219 private Iterator<DiffEntry> diffs = Collections.emptyIterator(); 220 private boolean finished; 221 222 @Override 223 public boolean tryAdvance(Consumer<? super Path> action) { 224 while (!finished) { 225 // Lazily consume the current commit's diffs. 226 while (diffs.hasNext()) { 227 var diff = diffs.next(); 228 var newPath = Path.of(diff.getNewPath()); 229 if (matches(newPath)) { 230 action.accept(newPath); 231 return true; 232 } 233 var oldPath = Path.of(diff.getOldPath()); 234 if (matches(oldPath)) { 235 action.accept(oldPath); 236 return true; 237 } 238 } 239 240 // Lazily advance to the next commit. 241 if (!commits.hasNext()) { 242 finished = true; 243 return false; 244 } 245 var commit = commits.next(); 246 if (commit.getId().equals(taggedId)) { 247 finished = true; 248 return false; 249 } 250 251 // Next commit, new diffs 252 diffs = nextDiffs(git, reader, commit); 253 } 254 return false; 255 } 256 257 private Iterator<DiffEntry> nextDiffs(Git git, 258 ObjectReader reader, 259 RevCommit commit) { 260 try { 261 var oldTreeParser = new CanonicalTreeParser(); 262 oldTreeParser.reset(reader, 263 commit.getParent(0).getTree().getId()); 264 var newTreeParser = new CanonicalTreeParser(); 265 newTreeParser.reset(reader, 266 commit.getTree().getId()); 267 return git.diff().setNewTree(newTreeParser) 268 .setOldTree(oldTreeParser).call().iterator(); 269 } catch (GitAPIException e) { 270 throw new UncheckedIOException(new IOException( 271 "Unable to calculate Git diff", e)); 272 } catch (IOException e) { 273 throw new UncheckedIOException( 274 "Unable to calculate Git diff", e); 275 } 276 } 277 }; 278 279 return StreamSupport.stream(spliterator, false) 280 .onClose(() -> { 281 reader.close(); 282 git.close(); 283 revWalk.close(); 284 }); 285 286 } catch (RuntimeException | Error e) { 287 reader.close(); 288 git.close(); 289 revWalk.close(); 290 throw e; 291 } 292 } 293 294 @Override 295 public String version() { 296 try { 297 var latest = getLatestVersionTagged(); 298 return tagProcessor.version(this, latest.tag(), 299 latest.version().toString()); 300 } catch (IOException | GitAPIException e) { 301 throw new IllegalStateException(e); 302 } 303 } 304 305 private record VersionedTag(Ref ref, String tag, Semver version) { 306 } 307 308 private record VersionedCommit(RevCommit commit, String tag, 309 Semver version) { 310 } 311 312 private VersionedCommit getLatestVersionTagged() 313 throws GitAPIException, IOException { 314 try (var git = Git.wrap(repository); 315 var revWalk = new RevWalk(repository)) { 316 var reachable = reachableCommits(); 317 return git.tagList().call().stream() 318 .mapMulti((Ref ref, Consumer< 319 VersionedTag> consumer) -> addVersionInfo(ref) 320 .ifPresent(consumer)) 321 .sorted(new Comparator<VersionedTag>() { 322 @Override 323 public int compare(VersionedTag obj1, VersionedTag obj2) { 324 return obj2.version().compareTo(obj1.version()); 325 } 326 }).mapMulti((VersionedTag vt, 327 Consumer<VersionedCommit> consumer) -> findCommit( 328 revWalk, vt.ref()).ifPresent( 329 c -> consumer.accept(new VersionedCommit( 330 c, vt.tag(), vt.version())))) 331 .filter(vc -> reachable.contains(vc.commit().getId())) 332 .findFirst().orElseGet( 333 () -> new VersionedCommit(null, null, new Semver("0.0.0"))); 334 } 335 } 336 337 private Optional<RevCommit> findCommit(RevWalk revWalk, Ref ref) { 338 try { 339 RevObject refd = revWalk.parseAny(ref.getObjectId()); 340 revWalk.reset(); 341 return switch (refd) { 342 case RevTag revtag -> Optional 343 .of(revWalk.parseCommit(revtag.getObject())); 344 case RevCommit revcommit -> Optional.of(revcommit); 345 default -> Optional.empty(); 346 }; 347 } catch (IOException e) { 348 return Optional.empty(); 349 } finally { 350 revWalk.reset(); 351 } 352 } 353 354 private Set<ObjectId> reachableCommits() throws IOException { 355 ObjectId headId = repository.resolve("HEAD"); 356 if (headId == null) { 357 // No commits yet 358 return Collections.emptySet(); 359 } 360 return reachableByHead.computeIfAbsent( 361 headId, k -> { 362 try (var revWalk = new RevWalk(repository)) { 363 var reachable = new HashSet<ObjectId>(); 364 revWalk.markStart(revWalk.parseCommit(headId)); 365 for (RevCommit commit : revWalk) { 366 reachable.add(commit.getId()); 367 } 368 return reachable; 369 } catch (IOException e) { 370 return Collections.emptySet(); 371 } 372 }); 373 } 374 375 private Optional<VersionedTag> addVersionInfo(Ref ref) { 376 var tag = ref.getName().substring("refs/tags/".length()); 377 return tagFilter.version(tag).map(v -> { 378 try { 379 var version = new Semver(v, Semver.SemverType.LOOSE); 380 return new VersionedTag(ref, tag, version); 381 } catch (SemverException e) { 382 throw new IllegalArgumentException( 383 "Failed to parse version: " + v, e); 384 } 385 }); 386 } 387 388}