View Javadoc
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.io.UnsupportedEncodingException;
23  import java.net.URI;
24  import java.net.URISyntaxException;
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       * @since 1.9
94       * @see GitStatusCommand#createRevparseShowPrefix(ScmFileSet)
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      * @since 1.11.0
111      * @see GitStatusCommand#createRevparseShowPrefix(ScmFileSet)
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      * @since 1.11.0
129      * @see GitStatusCommand#createRevparseShowPrefix(ScmFileSet)
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      *
246      * @param fileEntry the fileEntry, must not be {@code null}
247      * @param path the path, must not be {@code null}
248      * @return TODO
249      */
250     public static URI resolveURI(String fileEntry, URI path) {
251         // When using URI.create, spaces need to be escaped but not the slashes, so we can't use
252         // URLEncoder.encode( String, String )
253         // new File( String ).toURI() results in an absolute URI while path is relative, so that can't be used either.
254         return path.relativize(uriFromPath(stripQuotes(fileEntry)));
255     }
256 
257     /**
258      * Create an URI whose getPath() returns the given path and getScheme() returns null. The path may contain spaces,
259      * colons, and other special characters.
260      *
261      * @param path the path.
262      * @return the new URI
263      */
264     public static URI uriFromPath(String path) {
265         try {
266             if (path != null && path.indexOf(':') != -1) {
267                 // prefixing the path so the part preceding the colon does not become the scheme
268                 String tmp = new URI(null, null, "/x" + path, null).toString().substring(2);
269                 // the colon is not escaped by default
270                 return new URI(tmp.replace(":", "%3A"));
271             } else {
272                 return new URI(null, null, path, null);
273             }
274         } catch (URISyntaxException x) {
275             throw new IllegalArgumentException(x.getMessage(), x);
276         }
277     }
278 
279     public List<ScmFile> getChangedFiles() {
280         return changedFiles;
281     }
282 
283     /**
284      * @param str the (potentially quoted) string, must not be {@code null}
285      * @return the string with a pair of double quotes removed (if they existed)
286      */
287     private static String stripQuotes(String str) {
288         int strLen = str.length();
289         return (strLen > 0 && str.startsWith("\"") && str.endsWith("\""))
290                 ? unescape(str.substring(1, strLen - 1))
291                 : str;
292     }
293 
294     /**
295      * Dequote a quoted string generated by git status --porcelain.
296      * The leading and trailing quotes have already been removed.
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         try {
380             // explicit say UTF-8, otherwise it'll fail at least on Windows cmdline
381             return new String(outba, 0, outSub, "UTF-8");
382         } catch (UnsupportedEncodingException e) {
383             throw new RuntimeException(e);
384         }
385     }
386 }