View Javadoc
1   /*
2    * Copyright (C) 2013, CloudBees, Inc. and others
3    *
4    * This program and the accompanying materials are made available under the
5    * terms of the Eclipse Distribution License v. 1.0 which is available at
6    * https://www.eclipse.org/org/documents/edl-v10.php.
7    *
8    * SPDX-License-Identifier: BSD-3-Clause
9    */
10  package org.eclipse.jgit.api;
11  
12  import static org.eclipse.jgit.lib.Constants.R_REFS;
13  import static org.eclipse.jgit.lib.Constants.R_TAGS;
14  import static org.eclipse.jgit.lib.TypedConfigGetter.UNSET_INT;
15  
16  import java.io.IOException;
17  import java.text.MessageFormat;
18  import java.util.ArrayList;
19  import java.util.Collection;
20  import java.util.Collections;
21  import java.util.Comparator;
22  import java.util.Date;
23  import java.util.List;
24  import java.util.Map;
25  import java.util.Optional;
26  import java.util.stream.Collectors;
27  import java.util.stream.Stream;
28  
29  import org.eclipse.jgit.api.errors.GitAPIException;
30  import org.eclipse.jgit.api.errors.JGitInternalException;
31  import org.eclipse.jgit.api.errors.RefNotFoundException;
32  import org.eclipse.jgit.errors.IncorrectObjectTypeException;
33  import org.eclipse.jgit.errors.InvalidPatternException;
34  import org.eclipse.jgit.errors.MissingObjectException;
35  import org.eclipse.jgit.fnmatch.FileNameMatcher;
36  import org.eclipse.jgit.internal.JGitText;
37  import org.eclipse.jgit.lib.AbbrevConfig;
38  import org.eclipse.jgit.lib.Constants;
39  import org.eclipse.jgit.lib.ObjectId;
40  import org.eclipse.jgit.lib.Ref;
41  import org.eclipse.jgit.lib.Repository;
42  import org.eclipse.jgit.revwalk.RevCommit;
43  import org.eclipse.jgit.revwalk.RevFlag;
44  import org.eclipse.jgit.revwalk.RevFlagSet;
45  import org.eclipse.jgit.revwalk.RevTag;
46  import org.eclipse.jgit.revwalk.RevWalk;
47  
48  /**
49   * Given a commit, show the most recent tag that is reachable from a commit.
50   *
51   * @since 3.2
52   */
53  public class DescribeCommand extends GitCommand<String> {
54  	private final RevWalk w;
55  
56  	/**
57  	 * Commit to describe.
58  	 */
59  	private RevCommit target;
60  
61  	/**
62  	 * How many tags we'll consider as candidates.
63  	 * This can only go up to the number of flags JGit can support in a walk,
64  	 * which is 24.
65  	 */
66  	private int maxCandidates = 10;
67  
68  	/**
69  	 * Whether to always use long output format or not.
70  	 */
71  	private boolean longDesc;
72  
73  	/**
74  	 * Pattern matchers to be applied to tags under consideration.
75  	 */
76  	private List<FileNameMatcher> matchers = new ArrayList<>();
77  
78  	/**
79  	 * Whether to use all refs in the refs/ namespace
80  	 */
81  	private boolean useAll;
82  
83  	/**
84  	 * Whether to use all tags (incl. lightweight) or not.
85  	 */
86  	private boolean useTags;
87  
88  	/**
89  	 * Whether to show a uniquely abbreviated commit hash as a fallback or not.
90  	 */
91  	private boolean always;
92  
93  	/**
94  	 * The prefix length to use when abbreviating a commit hash.
95  	 */
96  	private int abbrev = UNSET_INT;
97  
98  	/**
99  	 * Constructor for DescribeCommand.
100 	 *
101 	 * @param repo
102 	 *            the {@link org.eclipse.jgit.lib.Repository}
103 	 */
104 	protected DescribeCommand(Repository repo) {
105 		super(repo);
106 		w = new RevWalk(repo);
107 		w.setRetainBody(false);
108 	}
109 
110 	/**
111 	 * Sets the commit to be described.
112 	 *
113 	 * @param target
114 	 * 		A non-null object ID to be described.
115 	 * @return {@code this}
116 	 * @throws MissingObjectException
117 	 *             the supplied commit does not exist.
118 	 * @throws IncorrectObjectTypeException
119 	 *             the supplied id is not a commit or an annotated tag.
120 	 * @throws java.io.IOException
121 	 *             a pack file or loose object could not be read.
122 	 */
123 	public DescribeCommand setTarget(ObjectId target) throws IOException {
124 		this.target = w.parseCommit(target);
125 		return this;
126 	}
127 
128 	/**
129 	 * Sets the commit to be described.
130 	 *
131 	 * @param rev
132 	 *            Commit ID, tag, branch, ref, etc. See
133 	 *            {@link org.eclipse.jgit.lib.Repository#resolve(String)} for
134 	 *            allowed syntax.
135 	 * @return {@code this}
136 	 * @throws IncorrectObjectTypeException
137 	 *             the supplied id is not a commit or an annotated tag.
138 	 * @throws org.eclipse.jgit.api.errors.RefNotFoundException
139 	 *             the given rev didn't resolve to any object.
140 	 * @throws java.io.IOException
141 	 *             a pack file or loose object could not be read.
142 	 */
143 	public DescribeCommand setTarget(String rev) throws IOException,
144 			RefNotFoundException {
145 		ObjectId id = repo.resolve(rev);
146 		if (id == null)
147 			throw new RefNotFoundException(MessageFormat.format(JGitText.get().refNotResolved, rev));
148 		return setTarget(id);
149 	}
150 
151 	/**
152 	 * Determine whether always to use the long format or not. When set to
153 	 * <code>true</code> the long format is used even the commit matches a tag.
154 	 *
155 	 * @param longDesc
156 	 *            <code>true</code> if always the long format should be used.
157 	 * @return {@code this}
158 	 * @see <a
159 	 *      href="https://www.kernel.org/pub/software/scm/git/docs/git-describe.html"
160 	 *      >Git documentation about describe</a>
161 	 * @since 4.0
162 	 */
163 	public DescribeCommand setLong(boolean longDesc) {
164 		this.longDesc = longDesc;
165 		return this;
166 	}
167 
168 	/**
169 	 * Instead of using only the annotated tags, use any ref found in refs/
170 	 * namespace. This option enables matching any known branch,
171 	 * remote-tracking branch, or lightweight tag.
172 	 *
173 	 * @param all
174 	 *            <code>true</code> enables matching any ref found in refs/
175 	 *            like setting option --all in c git
176 	 * @return {@code this}
177 	 * @since 5.10
178 	 */
179 	public DescribeCommand setAll(boolean all) {
180 		this.useAll = all;
181 		return this;
182 	}
183 
184 	/**
185 	 * Instead of using only the annotated tags, use any tag found in refs/tags
186 	 * namespace. This option enables matching lightweight (non-annotated) tags
187 	 * or not.
188 	 *
189 	 * @param tags
190 	 *            <code>true</code> enables matching lightweight (non-annotated)
191 	 *            tags like setting option --tags in c git
192 	 * @return {@code this}
193 	 * @since 5.0
194 	 */
195 	public DescribeCommand setTags(boolean tags) {
196 		this.useTags = tags;
197 		return this;
198 	}
199 
200 	/**
201 	 * Always describe the commit by eventually falling back to a uniquely
202 	 * abbreviated commit hash if no other name matches.
203 	 *
204 	 * @param always
205 	 *            <code>true</code> enables falling back to a uniquely
206 	 *            abbreviated commit hash
207 	 * @return {@code this}
208 	 * @since 5.4
209 	 */
210 	public DescribeCommand setAlways(boolean always) {
211 		this.always = always;
212 		return this;
213 	}
214 
215 	/**
216 	 * Sets the prefix length to use when abbreviating an object SHA-1.
217 	 *
218 	 * @param abbrev
219 	 *            minimum length of the abbreviated string. Must be in the range
220 	 *            [{@value AbbrevConfig#MIN_ABBREV},
221 	 *            {@value Constants#OBJECT_ID_STRING_LENGTH}].
222 	 * @return {@code this}
223 	 * @since 6.1
224 	 */
225 	public DescribeCommand setAbbrev(int abbrev) {
226 		if (abbrev == 0) {
227 			this.abbrev = 0;
228 		} else {
229 			this.abbrev = AbbrevConfig.capAbbrev(abbrev);
230 		}
231 		return this;
232 	}
233 
234 	private String longDescription(Ref tag, int depth, ObjectId tip)
235 			throws IOException {
236 		if (abbrev == 0) {
237 			return formatRefName(tag.getName());
238 		}
239 		return String.format("%s-%d-g%s", formatRefName(tag.getName()), //$NON-NLS-1$
240 				Integer.valueOf(depth),
241 				w.getObjectReader().abbreviate(tip, abbrev).name());
242 	}
243 
244 	/**
245 	 * Sets one or more {@code glob(7)} patterns that tags must match to be
246 	 * considered. If multiple patterns are provided, tags only need match one
247 	 * of them.
248 	 *
249 	 * @param patterns
250 	 *            the {@code glob(7)} pattern or patterns
251 	 * @return {@code this}
252 	 * @throws org.eclipse.jgit.errors.InvalidPatternException
253 	 *             if the pattern passed in was invalid.
254 	 * @see <a href=
255 	 *      "https://www.kernel.org/pub/software/scm/git/docs/git-describe.html"
256 	 *      >Git documentation about describe</a>
257 	 * @since 4.9
258 	 */
259 	public DescribeCommand setMatch(String... patterns) throws InvalidPatternException {
260 		for (String p : patterns) {
261 			matchers.add(new FileNameMatcher(p, null));
262 		}
263 		return this;
264 	}
265 
266 	private final Comparator<Ref> TAG_TIE_BREAKER = new Comparator<>() {
267 
268 		@Override
269 		public int compare(Ref o1, Ref o2) {
270 			try {
271 				return tagDate(o2).compareTo(tagDate(o1));
272 			} catch (IOException e) {
273 				return 0;
274 			}
275 		}
276 
277 		private Date tagDate(Ref tag) throws IOException {
278 			RevTag t = w.parseTag(tag.getObjectId());
279 			w.parseBody(t);
280 			return t.getTaggerIdent().getWhen();
281 		}
282 	};
283 
284 	private Optional<Ref> getBestMatch(List<Ref> tags) {
285 		if (tags == null || tags.isEmpty()) {
286 			return Optional.empty();
287 		} else if (matchers.isEmpty()) {
288 			Collections.sort(tags, TAG_TIE_BREAKER);
289 			return Optional.of(tags.get(0));
290 		} else {
291 			// Find the first tag that matches in the stream of all tags
292 			// filtered by matchers ordered by tie break order
293 			Stream<Ref> matchingTags = Stream.empty();
294 			for (FileNameMatcher matcher : matchers) {
295 				Stream<Ref> m = tags.stream().filter(
296 						tag -> {
297 							matcher.append(formatRefName(tag.getName()));
298 							boolean result = matcher.isMatch();
299 							matcher.reset();
300 							return result;
301 						});
302 				matchingTags = Stream.of(matchingTags, m).flatMap(i -> i);
303 			}
304 			return matchingTags.sorted(TAG_TIE_BREAKER).findFirst();
305 		}
306 	}
307 
308 	private ObjectId getObjectIdFromRef(Ref r) throws JGitInternalException {
309 		try {
310 			ObjectId key = repo.getRefDatabase().peel(r).getPeeledObjectId();
311 			if (key == null) {
312 				key = r.getObjectId();
313 			}
314 			return key;
315 		} catch (IOException e) {
316 			throw new JGitInternalException(e.getMessage(), e);
317 		}
318 	}
319 
320 	/**
321 	 * {@inheritDoc}
322 	 * <p>
323 	 * Describes the specified commit. Target defaults to HEAD if no commit was
324 	 * set explicitly.
325 	 */
326 	@Override
327 	public String call() throws GitAPIException {
328 		try {
329 			checkCallable();
330 			if (target == null) {
331 				setTarget(Constants.HEAD);
332 			}
333 			if (abbrev == UNSET_INT) {
334 				abbrev = AbbrevConfig.parseFromConfig(repo).get();
335 			}
336 
337 			Collection<Ref> tagList = repo.getRefDatabase()
338 					.getRefsByPrefix(useAll ? R_REFS : R_TAGS);
339 			Map<ObjectId, List<Ref>> tags = tagList.stream()
340 					.filter(this::filterLightweightTags)
341 					.collect(Collectors.groupingBy(this::getObjectIdFromRef));
342 
343 			// combined flags of all the candidate instances
344 			final RevFlagSet allFlags = new RevFlagSet();
345 
346 			/**
347 			 * Tracks the depth of each tag as we find them.
348 			 */
349 			class Candidate {
350 				final Ref tag;
351 				final RevFlag flag;
352 
353 				/**
354 				 * This field counts number of commits that are reachable from
355 				 * the tip but not reachable from the tag.
356 				 */
357 				int depth;
358 
359 				Candidate(RevCommit commit, Ref tag) {
360 					this.tag = tag;
361 					this.flag = w.newFlag(tag.getName());
362 					// we'll mark all the nodes reachable from this tag accordingly
363 					allFlags.add(flag);
364 					w.carry(flag);
365 					commit.add(flag);
366 					// As of this writing, JGit carries a flag from a child to its parents
367 					// right before RevWalk.next() returns, so all the flags that are added
368 					// must be manually carried to its parents. If that gets fixed,
369 					// this will be unnecessary.
370 					commit.carry(flag);
371 				}
372 
373 				/**
374 				 * Does this tag contain the given commit?
375 				 */
376 				boolean reaches(RevCommit c) {
377 					return c.has(flag);
378 				}
379 
380 				String describe(ObjectId tip) throws IOException {
381 					return longDescription(tag, depth, tip);
382 				}
383 
384 			}
385 			List<Candidate> candidates = new ArrayList<>();    // all the candidates we find
386 
387 			// is the target already pointing to a suitable tag? if so, we are done!
388 			Optional<Ref> bestMatch = getBestMatch(tags.get(target));
389 			if (bestMatch.isPresent()) {
390 				return longDesc ? longDescription(bestMatch.get(), 0, target) :
391 						formatRefName(bestMatch.get().getName());
392 			}
393 
394 			w.markStart(target);
395 
396 			int seen = 0;   // commit seen thus far
397 			RevCommit c;
398 			while ((c = w.next()) != null) {
399 				if (!c.hasAny(allFlags)) {
400 					// if a tag already dominates this commit,
401 					// then there's no point in picking a tag on this commit
402 					// since the one that dominates it is always more preferable
403 					bestMatch = getBestMatch(tags.get(c));
404 					if (bestMatch.isPresent()) {
405 						Candidate cd = new Candidate(c, bestMatch.get());
406 						candidates.add(cd);
407 						cd.depth = seen;
408 					}
409 				}
410 
411 				// if the newly discovered commit isn't reachable from a tag that we've seen
412 				// it counts toward the total depth.
413 				for (Candidate cd : candidates) {
414 					if (!cd.reaches(c))
415 						cd.depth++;
416 				}
417 
418 				// if we have search going for enough tags, we will start
419 				// closing down. JGit can only give us a finite number of bits,
420 				// so we can't track all tags even if we wanted to.
421 				if (candidates.size() >= maxCandidates)
422 					break;
423 
424 				// TODO: if all the commits in the queue of RevWalk has allFlags
425 				// there's no point in continuing search as we'll not discover any more
426 				// tags. But RevWalk doesn't expose this.
427 				seen++;
428 			}
429 
430 			// at this point we aren't adding any more tags to our search,
431 			// but we still need to count all the depths correctly.
432 			while ((c = w.next()) != null) {
433 				if (c.hasAll(allFlags)) {
434 					// no point in visiting further from here, so cut the search here
435 					for (RevCommit p : c.getParents())
436 						p.add(RevFlag.SEEN);
437 				} else {
438 					for (Candidate cd : candidates) {
439 						if (!cd.reaches(c))
440 							cd.depth++;
441 					}
442 				}
443 			}
444 
445 			// if all the nodes are dominated by all the tags, the walk stops
446 			if (candidates.isEmpty()) {
447 				return always
448 						? w.getObjectReader()
449 								.abbreviate(target,
450 										AbbrevConfig.capAbbrev(abbrev))
451 								.name()
452 						: null;
453 			}
454 
455 			Candidate best = Collections.min(candidates,
456 					(Candidate o1, Candidate o2) -> o1.depth - o2.depth);
457 
458 			return best.describe(target);
459 		} catch (IOException e) {
460 			throw new JGitInternalException(e.getMessage(), e);
461 		} finally {
462 			setCallable(false);
463 			w.close();
464 		}
465 	}
466 
467 	/**
468 	 * Removes the refs/ or refs/tags prefix from tag names
469 	 * @param name the name of the tag
470 	 * @return the tag name with its prefix removed
471 	 */
472 	private String formatRefName(String name) {
473 		return name.startsWith(R_TAGS) ? name.substring(R_TAGS.length()) :
474 				name.substring(R_REFS.length());
475 	}
476 
477 	/**
478 	 * Whether we use lightweight tags or not for describe Candidates
479 	 *
480 	 * @param ref
481 	 *            reference under inspection
482 	 * @return true if it should be used for describe or not regarding
483 	 *         {@link org.eclipse.jgit.api.DescribeCommand#useTags}
484 	 */
485 	@SuppressWarnings("null")
486 	private boolean filterLightweightTags(Ref ref) {
487 		ObjectId id = ref.getObjectId();
488 		try {
489 			return this.useAll || this.useTags || (id != null && (w.parseTag(id) != null));
490 		} catch (IOException e) {
491 			return false;
492 		}
493 	}
494 }