1 /*
2 * Licensed to the Apache Software Foundation (ASF) under one
3 * or more contributor license agreements. See the NOTICE file
4 * distributed with this work for additional information
5 * regarding copyright ownership. The ASF licenses this file
6 * to you under the Apache License, Version 2.0 (the
7 * "License"); you may not use this file except in compliance
8 * with the License. You may obtain a copy of the License at
9 *
10 * http://www.apache.org/licenses/LICENSE-2.0
11 *
12 * Unless required by applicable law or agreed to in writing,
13 * software distributed under the License is distributed on an
14 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15 * KIND, either express or implied. See the License for the
16 * specific language governing permissions and limitations
17 * under the License.
18 */
19 package org.apache.maven.scm.provider.git.gitexe.command.status;
20
21 import java.io.File;
22 import java.net.URI;
23 import java.net.URISyntaxException;
24 import java.nio.charset.StandardCharsets;
25 import java.util.ArrayList;
26 import java.util.List;
27 import java.util.regex.Matcher;
28 import java.util.regex.Pattern;
29
30 import org.apache.maven.scm.ScmFile;
31 import org.apache.maven.scm.ScmFileSet;
32 import org.apache.maven.scm.ScmFileStatus;
33 import org.apache.maven.scm.util.AbstractConsumer;
34
35 /**
36 * @author <a href="mailto:struberg@yahoo.de">Mark Struberg</a>
37 */
38 public class GitStatusConsumer extends AbstractConsumer {
39
40 /**
41 * The pattern used to match added file lines.
42 */
43 private static final Pattern ADDED_PATTERN = Pattern.compile("^A[ M]* (.*)$");
44
45 /**
46 * The pattern used to match modified file lines.
47 */
48 private static final Pattern MODIFIED_PATTERN = Pattern.compile("^ *M[ M]* (.*)$");
49
50 /**
51 * The pattern used to match deleted file lines.
52 */
53 private static final Pattern DELETED_PATTERN = Pattern.compile("^ *D * (.*)$");
54
55 /**
56 * The pattern used to match renamed file lines.
57 */
58 private static final Pattern RENAMED_PATTERN = Pattern.compile("^R (.*) -> (.*)$");
59
60 private final File workingDirectory;
61
62 private ScmFileSet scmFileSet;
63
64 /**
65 * Entries are relative to working directory, not to the repositoryroot.
66 */
67 private final List<ScmFile> changedFiles = new ArrayList<>();
68
69 private URI relativeRepositoryPath;
70
71 // ----------------------------------------------------------------------
72 //
73 // ----------------------------------------------------------------------
74
75 /**
76 * Consumer when workingDirectory and repositoryRootDirectory are the same.
77 *
78 * @param workingDirectory the working directory
79 */
80 public GitStatusConsumer(File workingDirectory) {
81 this.workingDirectory = workingDirectory;
82 }
83
84 /**
85 * Assuming that you have to discover the repositoryRoot, this is how you can get the
86 * <code>relativeRepositoryPath</code>
87 * <pre>
88 * URI.create( repositoryRoot ).relativize( fileSet.getBasedir().toURI() )
89 * </pre>
90 *
91 * @param workingDirectory the working directory
92 * @param relativeRepositoryPath the working directory relative to the repository root
93 * @see GitStatusCommand#createRevparseShowPrefix(ScmFileSet)
94 * @since 1.9
95 */
96 public GitStatusConsumer(File workingDirectory, URI relativeRepositoryPath) {
97 this(workingDirectory);
98 this.relativeRepositoryPath = relativeRepositoryPath;
99 }
100
101 /**
102 * Assuming that you have to discover the repositoryRoot, this is how you can get the
103 * <code>relativeRepositoryPath</code>
104 * <pre>
105 * URI.create( repositoryRoot ).relativize( fileSet.getBasedir().toURI() )
106 * </pre>
107 *
108 * @param workingDirectory the working directory
109 * @param scmFileSet fileset with includes and excludes
110 * @see GitStatusCommand#createRevparseShowPrefix(ScmFileSet)
111 * @since 1.11.0
112 */
113 public GitStatusConsumer(File workingDirectory, ScmFileSet scmFileSet) {
114 this(workingDirectory);
115 this.scmFileSet = scmFileSet;
116 }
117
118 /**
119 * Assuming that you have to discover the repositoryRoot, this is how you can get the
120 * <code>relativeRepositoryPath</code>
121 * <pre>
122 * URI.create( repositoryRoot ).relativize( fileSet.getBasedir().toURI() )
123 * </pre>
124 *
125 * @param workingDirectory the working directory
126 * @param relativeRepositoryPath the working directory relative to the repository root
127 * @param scmFileSet fileset with includes and excludes
128 * @see GitStatusCommand#createRevparseShowPrefix(ScmFileSet)
129 * @since 1.11.0
130 */
131 public GitStatusConsumer(File workingDirectory, URI relativeRepositoryPath, ScmFileSet scmFileSet) {
132 this(workingDirectory, scmFileSet);
133 this.relativeRepositoryPath = relativeRepositoryPath;
134 }
135
136 // ----------------------------------------------------------------------
137 // StreamConsumer Implementation
138 // ----------------------------------------------------------------------
139
140 /**
141 * {@inheritDoc}
142 */
143 public void consumeLine(String line) {
144 if (logger.isDebugEnabled()) {
145 logger.debug(line);
146 }
147 if (line == null || line.isEmpty()) {
148 return;
149 }
150
151 ScmFileStatus status = null;
152
153 List<String> files = new ArrayList<>();
154
155 Matcher matcher;
156 if ((matcher = ADDED_PATTERN.matcher(line)).find()) {
157 status = ScmFileStatus.ADDED;
158 files.add(resolvePath(matcher.group(1), relativeRepositoryPath));
159 } else if ((matcher = MODIFIED_PATTERN.matcher(line)).find()) {
160 status = ScmFileStatus.MODIFIED;
161 files.add(resolvePath(matcher.group(1), relativeRepositoryPath));
162 } else if ((matcher = DELETED_PATTERN.matcher(line)).find()) {
163 status = ScmFileStatus.DELETED;
164 files.add(resolvePath(matcher.group(1), relativeRepositoryPath));
165 } else if ((matcher = RENAMED_PATTERN.matcher(line)).find()) {
166 status = ScmFileStatus.RENAMED;
167 files.add(resolvePath(matcher.group(1), relativeRepositoryPath));
168 files.add(resolvePath(matcher.group(2), relativeRepositoryPath));
169 logger.debug("RENAMED status for line '" + line + "' files added '" + matcher.group(1) + "' '"
170 + matcher.group(2));
171 } else {
172 logger.warn("Ignoring unrecognized line: " + line);
173 return;
174 }
175
176 // If the file isn't a file; don't add it.
177 if (!files.isEmpty()) {
178 if (workingDirectory != null) {
179 if (status == ScmFileStatus.RENAMED) {
180 String oldFilePath = files.get(0);
181 String newFilePath = files.get(1);
182 if (isFile(oldFilePath)) {
183 logger.debug("file '" + oldFilePath + "' is a file");
184 return;
185 } else {
186 logger.debug("file '" + oldFilePath + "' not a file");
187 }
188 if (!isFile(newFilePath)) {
189 logger.debug("file '" + newFilePath + "' not a file");
190 return;
191 } else {
192 logger.debug("file '" + newFilePath + "' is a file");
193 }
194 } else if (status == ScmFileStatus.DELETED) {
195 if (isFile(files.get(0))) {
196 return;
197 }
198 } else {
199 if (!isFile(files.get(0))) {
200 return;
201 }
202 }
203 }
204
205 for (String file : files) {
206 if (this.scmFileSet != null && !isFileNameInFileList(this.scmFileSet.getFileList(), file)) {
207 // skip adding this file
208 } else {
209 changedFiles.add(new ScmFile(file, status));
210 }
211 }
212 }
213 }
214
215 private boolean isFileNameInFileList(List<File> fileList, String fileName) {
216 if (relativeRepositoryPath == null) {
217 return fileList.contains(new File(fileName));
218 } else {
219 for (File f : fileList) {
220 File file = new File(relativeRepositoryPath.getPath(), fileName);
221 if (file.getPath().endsWith(f.getName())) {
222 return true;
223 }
224 }
225 return fileList.isEmpty();
226 }
227 }
228
229 private boolean isFile(String file) {
230 File targetFile = new File(workingDirectory, file);
231 return targetFile.isFile();
232 }
233
234 public static String resolvePath(String fileEntry, URI path) {
235 /* Quotes may be included (from the git status line) when an fileEntry includes spaces */
236 String cleanedEntry = stripQuotes(fileEntry);
237 if (path != null) {
238 return resolveURI(cleanedEntry, path).getPath();
239 } else {
240 return cleanedEntry;
241 }
242 }
243
244 /**
245 * @param fileEntry the fileEntry, must not be {@code null}
246 * @param path the path, must not be {@code null}
247 * @return TODO
248 */
249 public static URI resolveURI(String fileEntry, URI path) {
250 // When using URI.create, spaces need to be escaped but not the slashes, so we can't use
251 // URLEncoder.encode( String, String )
252 // new File( String ).toURI() results in an absolute URI while path is relative, so that can't be used either.
253 return path.relativize(uriFromPath(stripQuotes(fileEntry)));
254 }
255
256 /**
257 * Create an URI whose getPath() returns the given path and getScheme() returns null. The path may contain spaces,
258 * colons, and other special characters.
259 *
260 * @param path the path
261 * @return the new URI
262 */
263 public static URI uriFromPath(String path) {
264 try {
265 if (path != null && path.indexOf(':') != -1) {
266 // prefixing the path so the part preceding the colon does not become the scheme
267 String tmp = new URI(null, null, "/x" + path, null).toString().substring(2);
268 // the colon is not escaped by default
269 return new URI(tmp.replace(":", "%3A"));
270 } else {
271 return new URI(null, null, path, null);
272 }
273 } catch (URISyntaxException x) {
274 throw new IllegalArgumentException(x.getMessage(), x);
275 }
276 }
277
278 public List<ScmFile> getChangedFiles() {
279 return changedFiles;
280 }
281
282 /**
283 * @param str the (potentially quoted) string, must not be {@code null}
284 * @return the string with a pair of double quotes removed (if they existed)
285 */
286 private static String stripQuotes(String str) {
287 int strLen = str.length();
288 return (strLen > 0 && str.startsWith("\"") && str.endsWith("\""))
289 ? unescape(str.substring(1, strLen - 1))
290 : str;
291 }
292
293 /**
294 * Dequote a quoted string generated by git status --porcelain.
295 * The leading and trailing quotes have already been removed.
296 *
297 * @param fileEntry
298 * @return TODO
299 */
300 private static String unescape(String fileEntry) {
301 // If there are no escaped characters, just return the input argument
302 int pos = fileEntry.indexOf('\\');
303 if (pos == -1) {
304 return fileEntry;
305 }
306
307 // We have escaped characters
308 byte[] inba = fileEntry.getBytes();
309 int inSub = 0; // Input subscript into fileEntry
310 byte[] outba = new byte[fileEntry.length()];
311 int outSub = 0; // Output subscript into outba
312
313 while (true) {
314 System.arraycopy(inba, inSub, outba, outSub, pos - inSub);
315 outSub += pos - inSub;
316 inSub = pos + 1;
317 switch ((char) inba[inSub++]) {
318 case '"':
319 outba[outSub++] = '"';
320 break;
321
322 case 'a':
323 outba[outSub++] = 7; // Bell
324 break;
325
326 case 'b':
327 outba[outSub++] = '\b';
328 break;
329
330 case 't':
331 outba[outSub++] = '\t';
332 break;
333
334 case 'n':
335 outba[outSub++] = '\n';
336 break;
337
338 case 'v':
339 outba[outSub++] = 11; // Vertical tab
340 break;
341
342 case 'f':
343 outba[outSub++] = '\f';
344 break;
345
346 case 'r':
347 outba[outSub++] = '\f';
348 break;
349
350 case '\\':
351 outba[outSub++] = '\\';
352 break;
353
354 case '0':
355 case '1':
356 case '2':
357 case '3':
358 // This assumes that the octal escape here is valid.
359 byte b = (byte) ((inba[inSub - 1] - '0') << 6);
360 b |= (byte) ((inba[inSub++] - '0') << 3);
361 b |= (byte) (inba[inSub++] - '0');
362 outba[outSub++] = b;
363 break;
364
365 default:
366 // This is an invalid escape in a string. Just copy it.
367 outba[outSub++] = '\\';
368 inSub--;
369 break;
370 }
371 pos = fileEntry.indexOf('\\', inSub);
372 if (pos == -1) // No more backslashes; we're done
373 {
374 System.arraycopy(inba, inSub, outba, outSub, inba.length - inSub);
375 outSub += inba.length - inSub;
376 break;
377 }
378 }
379 // explicitly say UTF-8, otherwise it'll fail at least on Windows cmdline
380 return new String(outba, 0, outSub, StandardCharsets.UTF_8);
381 }
382 }