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}