001package org.apache.maven.scm.provider.git.gitexe.command.status;
002
003/*
004 * Licensed to the Apache Software Foundation (ASF) under one
005 * or more contributor license agreements.  See the NOTICE file
006 * distributed with this work for additional information
007 * regarding copyright ownership.  The ASF licenses this file
008 * to you under the Apache License, Version 2.0 (the
009 * "License"); you may not use this file except in compliance
010 * with the License.  You may obtain a copy of the License at
011 *
012 * http://www.apache.org/licenses/LICENSE-2.0
013 *
014 * Unless required by applicable law or agreed to in writing,
015 * software distributed under the License is distributed on an
016 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
017 * KIND, either express or implied.  See the License for the
018 * specific language governing permissions and limitations
019 * under the License.
020 */
021
022import java.io.File;
023import java.io.UnsupportedEncodingException;
024import java.net.URI;
025import java.net.URISyntaxException;
026import java.util.ArrayList;
027import java.util.List;
028import java.util.regex.Matcher;
029import java.util.regex.Pattern;
030
031import org.apache.commons.lang.StringUtils;
032import org.apache.maven.scm.ScmFile;
033import org.apache.maven.scm.ScmFileStatus;
034import org.apache.maven.scm.log.ScmLogger;
035import org.codehaus.plexus.util.cli.StreamConsumer;
036
037/**
038 * @author <a href="mailto:struberg@yahoo.de">Mark Struberg</a>
039 */
040public class GitStatusConsumer
041    implements StreamConsumer
042{
043
044    /**
045     * The pattern used to match added file lines
046     */
047    private static final Pattern ADDED_PATTERN = Pattern.compile( "^A[ M]* (.*)$" );
048
049    /**
050     * The pattern used to match modified file lines
051     */
052    private static final Pattern MODIFIED_PATTERN = Pattern.compile( "^ *M[ M]* (.*)$" );
053
054    /**
055     * The pattern used to match deleted file lines
056     */
057    private static final Pattern DELETED_PATTERN = Pattern.compile( "^ *D * (.*)$" );
058
059    /**
060     * The pattern used to match renamed file lines
061     */
062    private static final Pattern RENAMED_PATTERN = Pattern.compile( "^R  (.*) -> (.*)$" );
063
064    private ScmLogger logger;
065
066    private File workingDirectory;
067
068    /**
069     * Entries are relative to working directory, not to the repositoryroot
070     */
071    private List<ScmFile> changedFiles = new ArrayList<ScmFile>();
072
073    private URI relativeRepositoryPath;
074    
075    // ----------------------------------------------------------------------
076    //
077    // ----------------------------------------------------------------------
078
079    /**
080     * Consumer when workingDirectory and repositoryRootDirectory are the same
081     * 
082     * @param logger the logger
083     * @param workingDirectory the working directory
084     */
085    public GitStatusConsumer( ScmLogger logger, File workingDirectory )
086    {
087        this.logger = logger;
088        this.workingDirectory = workingDirectory;
089    }
090
091    /**
092     * Assuming that you have to discover the repositoryRoot, this is how you can get the
093     * <code>relativeRepositoryPath</code>
094     * <pre>
095     * URI.create( repositoryRoot ).relativize( fileSet.getBasedir().toURI() )
096     * </pre>
097     * 
098     * @param logger the logger
099     * @param workingDirectory the working directory
100     * @param relativeRepositoryPath the working directory relative to the repository root
101     * @since 1.9
102     * @see GitStatusCommand#createRevparseShowPrefix(org.apache.maven.scm.ScmFileSet)
103     */
104    public GitStatusConsumer( ScmLogger logger, File workingDirectory, URI relativeRepositoryPath )
105    {
106        this( logger, workingDirectory );
107        this.relativeRepositoryPath = relativeRepositoryPath;
108    }
109
110    // ----------------------------------------------------------------------
111    // StreamConsumer Implementation
112    // ----------------------------------------------------------------------
113
114    /**
115     * {@inheritDoc}
116     */
117    public void consumeLine( String line )
118    {
119        if ( logger.isDebugEnabled() )
120        {
121            logger.debug( line );
122        }
123        if ( StringUtils.isEmpty( line ) )
124        {
125            return;
126        }
127
128        ScmFileStatus status = null;
129
130        List<String> files = new ArrayList<String>();
131        
132        Matcher matcher;
133        if ( ( matcher = ADDED_PATTERN.matcher( line ) ).find() )
134        {
135            status = ScmFileStatus.ADDED;
136            files.add( resolvePath( matcher.group( 1 ), relativeRepositoryPath ) );
137        }
138        else if ( ( matcher = MODIFIED_PATTERN.matcher( line ) ).find() )
139        {
140            status = ScmFileStatus.MODIFIED;
141            files.add( resolvePath( matcher.group( 1 ), relativeRepositoryPath ) );
142        }
143        else if ( ( matcher = DELETED_PATTERN.matcher( line ) ).find() )
144        {
145            status = ScmFileStatus.DELETED;
146            files.add( resolvePath( matcher.group( 1 ), relativeRepositoryPath ) );
147        }
148        else if ( ( matcher = RENAMED_PATTERN.matcher( line ) ).find() )
149        {
150            status = ScmFileStatus.RENAMED;
151            files.add( resolvePath( matcher.group( 1 ), relativeRepositoryPath ) );
152            files.add( resolvePath( matcher.group( 2 ), relativeRepositoryPath ) );
153            logger.debug( "RENAMED status for line '" + line + "' files added '" + matcher.group( 1 ) + "' '"
154                              + matcher.group( 2 ) );
155        }
156        else
157        {
158            logger.warn( "Ignoring unrecognized line: " + line );
159            return;
160        }
161
162        // If the file isn't a file; don't add it.
163        if ( !files.isEmpty() && status != null )
164        {
165            if ( workingDirectory != null )
166            {
167                if ( status == ScmFileStatus.RENAMED )
168                {
169                    String oldFilePath = files.get( 0 );
170                    String newFilePath = files.get( 1 );
171                    if ( isFile( oldFilePath ) )
172                    {
173                        logger.debug( "file '" + oldFilePath + "' is a file" );
174                        return;
175                    }
176                    else
177                    {
178                        logger.debug( "file '" + oldFilePath + "' not a file" );
179                    }
180                    if ( !isFile( newFilePath ) )
181                    {
182                        logger.debug( "file '" + newFilePath + "' not a file" );
183                        return;
184                    }
185                    else
186                    {
187                        logger.debug( "file '" + newFilePath + "' is a file" );
188                    }
189                }
190                else if ( status == ScmFileStatus.DELETED )
191                {
192                    if ( isFile( files.get( 0 ) ) )
193                    {
194                        return;
195                    }
196                }
197                else
198                {
199                    if ( !isFile( files.get( 0 ) ) )
200                    {
201                        return;
202                    }
203                }
204            }
205
206            for ( String file : files )
207            {
208                changedFiles.add( new ScmFile( file, status ) );
209            }
210        }
211    }
212
213    private boolean isFile( String file )
214    {
215        File targetFile = new File( workingDirectory, file );
216        return targetFile.isFile();
217    }
218
219    protected static String resolvePath( String fileEntry, URI path )
220    {
221        /* Quotes may be included (from the git status line) when an fileEntry includes spaces */
222        String cleanedEntry = stripQuotes( fileEntry );
223        if ( path != null )
224        {
225            return resolveURI( cleanedEntry, path ).getPath();
226        }
227        else
228        {
229            return cleanedEntry;
230        }
231    }
232
233    /**
234     * 
235     * @param fileEntry the fileEntry, must not be {@code null}
236     * @param path the path, must not be {@code null}
237     * @return
238     */
239    public static URI resolveURI( String fileEntry, URI path )
240    {
241        // When using URI.create, spaces need to be escaped but not the slashes, so we can't use
242        // URLEncoder.encode( String, String )
243        // new File( String ).toURI() results in an absolute URI while path is relative, so that can't be used either.
244        return path.relativize( uriFromPath( stripQuotes ( fileEntry ) ) );
245    }
246
247    /**
248     * Create an URI whose getPath() returns the given path and getScheme() returns null. The path may contain spaces,
249     * colons, and other special characters.
250     * 
251     * @param path the path.
252     * @return the new URI
253     */
254    public static URI uriFromPath( String path )
255    {
256        try
257        {
258            if ( path != null && path.indexOf( ':' ) != -1 )
259            {
260                // prefixing the path so the part preceding the colon does not become the scheme
261                String tmp = new URI( null, null, "/x" + path, null ).toString().substring( 2 );
262                // the colon is not escaped by default
263                return new URI( tmp.replace( ":", "%3A" ) );
264            }
265            else
266            {
267                return new URI( null, null, path, null );
268            }
269        }
270        catch ( URISyntaxException x )
271        {
272            throw new IllegalArgumentException( x.getMessage(), x );
273        }
274    }
275
276    public List<ScmFile> getChangedFiles()
277    {
278        return changedFiles;
279    }
280
281    /**
282     * @param str the (potentially quoted) string, must not be {@code null}
283     * @return the string with a pair of double quotes removed (if they existed)
284     */
285    private static String stripQuotes( String str )
286    {
287        int strLen = str.length();
288        return ( strLen > 0 && str.startsWith( "\"" ) && str.endsWith( "\"" ) ) ? unescape( str.substring( 1, strLen - 1 ) ) : str;
289    }
290    
291    /**
292     * Dequote a quoted string generated by git status --porcelain.
293     * The leading and trailing quotes have already been removed. 
294     * @param fileEntry
295     * @return
296     */
297    private static String unescape( String fileEntry )
298    {
299        // If there are no escaped characters, just return the input argument
300        int pos = fileEntry.indexOf( '\\' );
301        if ( pos == -1 )
302        {
303            return fileEntry;
304        }
305        
306        // We have escaped characters
307        byte[] inba = fileEntry.getBytes();
308        int inSub = 0;      // Input subscript into fileEntry
309        byte[] outba = new byte[fileEntry.length()];
310        int outSub = 0;     // Output subscript into outba
311        
312        while ( true )
313        {
314            System.arraycopy( inba,  inSub,  outba, outSub, pos - inSub );
315            outSub += pos - inSub;
316            inSub = pos + 1;
317            switch ( (char) inba[inSub++] )
318            {
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        {
382            // explicit say UTF-8, otherwise it'll fail at least on Windows cmdline
383            return new String(outba, 0, outSub, "UTF-8");
384        }
385        catch ( UnsupportedEncodingException e )
386        {
387          throw new RuntimeException( e );    
388        }
389    }
390}