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