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