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