View Javadoc
1   /*
2    * Copyright (c) 2020, 2022 Julian Ruppel <julian.ruppel@sap.com> 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  
11  package org.eclipse.jgit.lib;
12  
13  import java.io.File;
14  import java.io.FileNotFoundException;
15  import java.io.IOException;
16  import java.nio.charset.Charset;
17  import java.nio.charset.IllegalCharsetNameException;
18  import java.nio.charset.StandardCharsets;
19  import java.nio.charset.UnsupportedCharsetException;
20  import java.text.MessageFormat;
21  import java.util.Locale;
22  
23  import org.eclipse.jgit.annotations.NonNull;
24  import org.eclipse.jgit.annotations.Nullable;
25  import org.eclipse.jgit.errors.ConfigInvalidException;
26  import org.eclipse.jgit.internal.JGitText;
27  import org.eclipse.jgit.lib.Config.ConfigEnum;
28  import org.eclipse.jgit.lib.Config.SectionParser;
29  import org.eclipse.jgit.util.FS;
30  import org.eclipse.jgit.util.IO;
31  import org.eclipse.jgit.util.RawParseUtils;
32  import org.eclipse.jgit.util.StringUtils;
33  
34  /**
35   * The standard "commit" configuration parameters.
36   *
37   * @since 5.13
38   */
39  public class CommitConfig {
40  
41  	/**
42  	 * Key for {@link Config#get(SectionParser)}.
43  	 */
44  	public static final Config.SectionParser<CommitConfig> KEY = CommitConfig::new;
45  
46  	private static final String CUT = " ------------------------ >8 ------------------------\n"; //$NON-NLS-1$
47  
48  	private static final char[] COMMENT_CHARS = { '#', ';', '@', '!', '$', '%',
49  			'^', '&', '|', ':' };
50  
51  	/**
52  	 * How to clean up commit messages when committing.
53  	 *
54  	 * @since 6.1
55  	 */
56  	public enum CleanupMode implements ConfigEnum {
57  
58  		/**
59  		 * {@link #WHITESPACE}, additionally remove comment lines.
60  		 */
61  		STRIP,
62  
63  		/**
64  		 * Remove trailing whitespace and leading and trailing empty lines;
65  		 * collapse multiple empty lines to a single one.
66  		 */
67  		WHITESPACE,
68  
69  		/**
70  		 * Make no changes.
71  		 */
72  		VERBATIM,
73  
74  		/**
75  		 * Omit everything from the first "scissor" line on, then apply
76  		 * {@link #WHITESPACE}.
77  		 */
78  		SCISSORS,
79  
80  		/**
81  		 * Use {@link #STRIP} for user-edited messages, otherwise
82  		 * {@link #WHITESPACE}, unless overridden by a git config setting other
83  		 * than DEFAULT.
84  		 */
85  		DEFAULT;
86  
87  		@Override
88  		public String toConfigValue() {
89  			return name().toLowerCase(Locale.ROOT);
90  		}
91  
92  		@Override
93  		public boolean matchConfigValue(String in) {
94  			return toConfigValue().equals(in);
95  		}
96  	}
97  
98  	private final static Charset DEFAULT_COMMIT_MESSAGE_ENCODING = StandardCharsets.UTF_8;
99  
100 	private String i18nCommitEncoding;
101 
102 	private String commitTemplatePath;
103 
104 	private CleanupMode cleanupMode;
105 
106 	private char commentCharacter = '#';
107 
108 	private boolean autoCommentChar = false;
109 
110 	private CommitConfig(Config rc) {
111 		commitTemplatePath = rc.getString(ConfigConstants.CONFIG_COMMIT_SECTION,
112 				null, ConfigConstants.CONFIG_KEY_COMMIT_TEMPLATE);
113 		i18nCommitEncoding = rc.getString(ConfigConstants.CONFIG_SECTION_I18N,
114 				null, ConfigConstants.CONFIG_KEY_COMMIT_ENCODING);
115 		cleanupMode = rc.getEnum(ConfigConstants.CONFIG_COMMIT_SECTION, null,
116 				ConfigConstants.CONFIG_KEY_CLEANUP, CleanupMode.DEFAULT);
117 		String comment = rc.getString(ConfigConstants.CONFIG_CORE_SECTION, null,
118 				ConfigConstants.CONFIG_KEY_COMMENT_CHAR);
119 		if (!StringUtils.isEmptyOrNull(comment)) {
120 			if ("auto".equalsIgnoreCase(comment)) { //$NON-NLS-1$
121 				autoCommentChar = true;
122 			} else {
123 				char first = comment.charAt(0);
124 				if (first > ' ' && first < 127) {
125 					commentCharacter = first;
126 				}
127 			}
128 		}
129 	}
130 
131 	/**
132 	 * Get the path to the commit template as defined in the git
133 	 * {@code commit.template} property.
134 	 *
135 	 * @return the path to commit template or {@code null} if not present.
136 	 */
137 	@Nullable
138 	public String getCommitTemplatePath() {
139 		return commitTemplatePath;
140 	}
141 
142 	/**
143 	 * Get the encoding of the commit as defined in the git
144 	 * {@code i18n.commitEncoding} property.
145 	 *
146 	 * @return the encoding or {@code null} if not present.
147 	 */
148 	@Nullable
149 	public String getCommitEncoding() {
150 		return i18nCommitEncoding;
151 	}
152 
153 	/**
154 	 * Retrieves the comment character set by git config
155 	 * {@code core.commentChar}.
156 	 *
157 	 * @return the character to use for comments in commit messages
158 	 * @since 6.2
159 	 */
160 	public char getCommentChar() {
161 		return commentCharacter;
162 	}
163 
164 	/**
165 	 * Determines the comment character to use for a particular text. If
166 	 * {@code core.commentChar} is "auto", tries to determine an unused
167 	 * character; if none is found, falls back to '#'. Otherwise returns the
168 	 * character given by {@code core.commentChar}.
169 	 *
170 	 * @param text
171 	 *            existing text
172 	 *
173 	 * @return the character to use
174 	 * @since 6.2
175 	 */
176 	public char getCommentChar(String text) {
177 		if (isAutoCommentChar()) {
178 			char toUse = determineCommentChar(text);
179 			if (toUse > 0) {
180 				return toUse;
181 			}
182 			return '#';
183 		}
184 		return getCommentChar();
185 	}
186 
187 	/**
188 	 * Tells whether the comment character should be determined by choosing a
189 	 * character not occurring in a commit message.
190 	 *
191 	 * @return {@code true} if git config {@code core.commentChar} is "auto"
192 	 * @since 6.2
193 	 */
194 	public boolean isAutoCommentChar() {
195 		return autoCommentChar;
196 	}
197 
198 	/**
199 	 * Retrieves the {@link CleanupMode} as given by git config
200 	 * {@code commit.cleanup}.
201 	 *
202 	 * @return the {@link CleanupMode}; {@link CleanupMode#DEFAULT} if the git
203 	 *         config is not set
204 	 * @since 6.1
205 	 */
206 	@NonNull
207 	public CleanupMode getCleanupMode() {
208 		return cleanupMode;
209 	}
210 
211 	/**
212 	 * Computes a non-default {@link CleanupMode} from the given mode and the
213 	 * git config.
214 	 *
215 	 * @param mode
216 	 *            {@link CleanupMode} to resolve
217 	 * @param defaultStrip
218 	 *            if {@code true} return {@link CleanupMode#STRIP} if the git
219 	 *            config is also "default", otherwise return
220 	 *            {@link CleanupMode#WHITESPACE}
221 	 * @return the {@code mode}, if it is not {@link CleanupMode#DEFAULT},
222 	 *         otherwise the resolved mode, which is never
223 	 *         {@link CleanupMode#DEFAULT}
224 	 * @since 6.1
225 	 */
226 	@NonNull
227 	public CleanupMode resolve(@NonNull CleanupMode mode,
228 			boolean defaultStrip) {
229 		if (CleanupMode.DEFAULT == mode) {
230 			CleanupMode defaultMode = getCleanupMode();
231 			if (CleanupMode.DEFAULT == defaultMode) {
232 				return defaultStrip ? CleanupMode.STRIP
233 						: CleanupMode.WHITESPACE;
234 			}
235 			return defaultMode;
236 		}
237 		return mode;
238 	}
239 
240 	/**
241 	 * Get the content to the commit template as defined in
242 	 * {@code commit.template}. If no {@code i18n.commitEncoding} is specified,
243 	 * UTF-8 fallback is used.
244 	 *
245 	 * @param repository
246 	 *            to resolve relative path in local git repo config
247 	 *
248 	 * @return content of the commit template or {@code null} if not present.
249 	 * @throws IOException
250 	 *             if the template file can not be read
251 	 * @throws FileNotFoundException
252 	 *             if the template file does not exists
253 	 * @throws ConfigInvalidException
254 	 *             if a {@code commitEncoding} is specified and is invalid
255 	 * @since 6.0
256 	 */
257 	@Nullable
258 	public String getCommitTemplateContent(@NonNull Repository repository)
259 			throws FileNotFoundException, IOException, ConfigInvalidException {
260 
261 		if (commitTemplatePath == null) {
262 			return null;
263 		}
264 
265 		File commitTemplateFile;
266 		FS fileSystem = repository.getFS();
267 		if (commitTemplatePath.startsWith("~/")) { //$NON-NLS-1$
268 			commitTemplateFile = fileSystem.resolve(fileSystem.userHome(),
269 					commitTemplatePath.substring(2));
270 		} else {
271 			commitTemplateFile = fileSystem.resolve(null, commitTemplatePath);
272 		}
273 		if (!commitTemplateFile.isAbsolute()) {
274 			commitTemplateFile = fileSystem.resolve(
275 					repository.getWorkTree().getAbsoluteFile(),
276 					commitTemplatePath);
277 		}
278 
279 		Charset commitMessageEncoding = getEncoding();
280 		return RawParseUtils.decode(commitMessageEncoding,
281 				IO.readFully(commitTemplateFile));
282 
283 	}
284 
285 	private Charset getEncoding() throws ConfigInvalidException {
286 		Charset commitMessageEncoding = DEFAULT_COMMIT_MESSAGE_ENCODING;
287 
288 		if (i18nCommitEncoding == null) {
289 			return null;
290 		}
291 
292 		try {
293 			commitMessageEncoding = Charset.forName(i18nCommitEncoding);
294 		} catch (IllegalCharsetNameException | UnsupportedCharsetException e) {
295 			throw new ConfigInvalidException(MessageFormat.format(
296 					JGitText.get().invalidEncoding, i18nCommitEncoding), e);
297 		}
298 
299 		return commitMessageEncoding;
300 	}
301 
302 	/**
303 	 * Processes a text according to the given {@link CleanupMode}.
304 	 *
305 	 * @param text
306 	 *            text to process
307 	 * @param mode
308 	 *            {@link CleanupMode} to use
309 	 * @param commentChar
310 	 *            comment character (normally {@code #}) to use if {@code mode}
311 	 *            is {@link CleanupMode#STRIP} or {@link CleanupMode#SCISSORS}
312 	 * @return the processed text
313 	 * @throws IllegalArgumentException
314 	 *             if {@code mode} is {@link CleanupMode#DEFAULT} (use
315 	 *             {@link #resolve(CleanupMode, boolean)} first)
316 	 * @since 6.1
317 	 */
318 	public static String cleanText(@NonNull String text,
319 			@NonNull CleanupMode mode, char commentChar) {
320 		String toProcess = text;
321 		boolean strip = false;
322 		switch (mode) {
323 		case VERBATIM:
324 			return text;
325 		case SCISSORS:
326 			String cut = commentChar + CUT;
327 			if (text.startsWith(cut)) {
328 				return ""; //$NON-NLS-1$
329 			}
330 			int cutPos = text.indexOf('\n' + cut);
331 			if (cutPos >= 0) {
332 				toProcess = text.substring(0, cutPos + 1);
333 			}
334 			break;
335 		case STRIP:
336 			strip = true;
337 			break;
338 		case WHITESPACE:
339 			break;
340 		case DEFAULT:
341 		default:
342 			// Internal error; no translation
343 			throw new IllegalArgumentException("Invalid clean-up mode " + mode); //$NON-NLS-1$
344 		}
345 		// WHITESPACE
346 		StringBuilder result = new StringBuilder();
347 		boolean lastWasEmpty = true;
348 		for (String line : toProcess.split("\n")) { //$NON-NLS-1$
349 			line = line.stripTrailing();
350 			if (line.isEmpty()) {
351 				if (!lastWasEmpty) {
352 					result.append('\n');
353 					lastWasEmpty = true;
354 				}
355 			} else if (!strip || !isComment(line, commentChar)) {
356 				lastWasEmpty = false;
357 				result.append(line).append('\n');
358 			}
359 		}
360 		int bufferSize = result.length();
361 		if (lastWasEmpty && bufferSize > 0) {
362 			bufferSize--;
363 			result.setLength(bufferSize);
364 		}
365 		if (bufferSize > 0 && !toProcess.endsWith("\n")) { //$NON-NLS-1$
366 			if (result.charAt(bufferSize - 1) == '\n') {
367 				result.setLength(bufferSize - 1);
368 			}
369 		}
370 		return result.toString();
371 	}
372 
373 	private static boolean isComment(String text, char commentChar) {
374 		int len = text.length();
375 		for (int i = 0; i < len; i++) {
376 			char ch = text.charAt(i);
377 			if (!Character.isWhitespace(ch)) {
378 				return ch == commentChar;
379 			}
380 		}
381 		return false;
382 	}
383 
384 	/**
385 	 * Determines a comment character by choosing one from a limited set of
386 	 * 7-bit ASCII characters that do not occur in the given text at the
387 	 * beginning of any line. If none can be determined, {@code (char) 0} is
388 	 * returned.
389 	 *
390 	 * @param text
391 	 *            to get a comment character for
392 	 * @return the comment character, or {@code (char) 0} if none could be
393 	 *         determined
394 	 * @since 6.2
395 	 */
396 	public static char determineCommentChar(String text) {
397 		if (StringUtils.isEmptyOrNull(text)) {
398 			return '#';
399 		}
400 		final boolean[] inUse = new boolean[127];
401 		for (String line : text.split("\n")) { //$NON-NLS-1$
402 			int len = line.length();
403 			for (int i = 0; i < len; i++) {
404 				char ch = line.charAt(i);
405 				if (!Character.isWhitespace(ch)) {
406 					if (ch >= 0 && ch < inUse.length) {
407 						inUse[ch] = true;
408 					}
409 					break;
410 				}
411 			}
412 		}
413 		for (char candidate : COMMENT_CHARS) {
414 			if (!inUse[candidate]) {
415 				return candidate;
416 			}
417 		}
418 		return (char) 0;
419 	}
420 }