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.util.ArrayList;
026import java.util.List;
027import java.util.regex.Matcher;
028import java.util.regex.Pattern;
029
030import org.apache.commons.lang.StringUtils;
031import org.apache.maven.scm.ScmFile;
032import org.apache.maven.scm.ScmFileStatus;
033import org.apache.maven.scm.log.ScmLogger;
034import org.codehaus.plexus.util.cli.StreamConsumer;
035
036/**
037 * @author <a href="mailto:struberg@yahoo.de">Mark Struberg</a>
038 */
039public class GitStatusConsumer
040    implements StreamConsumer
041{
042
043    /**
044     * The pattern used to match added file lines
045     */
046    private static final Pattern ADDED_PATTERN = Pattern.compile( "^A[ M]* (.*)$" );
047
048    /**
049     * The pattern used to match modified file lines
050     */
051    private static final Pattern MODIFIED_PATTERN = Pattern.compile( "^ *M[ M]* (.*)$" );
052
053    /**
054     * The pattern used to match deleted file lines
055     */
056    private static final Pattern DELETED_PATTERN = Pattern.compile( "^ *D * (.*)$" );
057
058    /**
059     * The pattern used to match renamed file lines
060     */
061    private static final Pattern RENAMED_PATTERN = Pattern.compile( "^R  (.*) -> (.*)$" );
062
063    private ScmLogger logger;
064
065    private File workingDirectory;
066
067    /**
068     * Entries are relative to working directory, not to the repositoryroot
069     */
070    private List<ScmFile> changedFiles = new ArrayList<ScmFile>();
071
072    private URI relativeRepositoryPath;
073    
074    // ----------------------------------------------------------------------
075    //
076    // ----------------------------------------------------------------------
077
078    /**
079     * Consumer when workingDirectory and repositoryRootDirectory are the same
080     * 
081     * @param logger the logger
082     * @param workingDirectory the working directory
083     */
084    public GitStatusConsumer( ScmLogger logger, File workingDirectory )
085    {
086        this.logger = logger;
087        this.workingDirectory = workingDirectory;
088    }
089
090    /**
091     * Assuming that you have to discover the repositoryRoot, this is how you can get the
092     * <code>relativeRepositoryPath</code>
093     * <pre>
094     * URI.create( repositoryRoot ).relativize( fileSet.getBasedir().toURI() )
095     * </pre>
096     * 
097     * @param logger the logger
098     * @param workingDirectory the working directory
099     * @param relativeRepositoryPath the working directory relative to the repository root
100     * @since 1.9
101     * @see GitStatusCommand#createRevparseShowToplevelCommand(org.apache.maven.scm.ScmFileSet)
102     */
103    public GitStatusConsumer( ScmLogger logger, File workingDirectory, URI relativeRepositoryPath )
104    {
105        this( logger, workingDirectory );
106        this.relativeRepositoryPath = relativeRepositoryPath;
107    }
108
109    // ----------------------------------------------------------------------
110    // StreamConsumer Implementation
111    // ----------------------------------------------------------------------
112
113    /**
114     * {@inheritDoc}
115     */
116    public void consumeLine( String line )
117    {
118        if ( logger.isDebugEnabled() )
119        {
120            logger.debug( line );
121        }
122        if ( StringUtils.isEmpty( line ) )
123        {
124            return;
125        }
126
127        ScmFileStatus status = null;
128
129        List<String> files = new ArrayList<String>();
130        
131        Matcher matcher;
132        if ( ( matcher = ADDED_PATTERN.matcher( line ) ).find() )
133        {
134            status = ScmFileStatus.ADDED;
135            files.add( resolvePath( matcher.group( 1 ), relativeRepositoryPath ) );
136        }
137        else if ( ( matcher = MODIFIED_PATTERN.matcher( line ) ).find() )
138        {
139            status = ScmFileStatus.MODIFIED;
140            files.add( resolvePath( matcher.group( 1 ), relativeRepositoryPath ) );
141        }
142        else if ( ( matcher = DELETED_PATTERN.matcher( line ) ).find() )
143        {
144            status = ScmFileStatus.DELETED;
145            files.add( resolvePath( matcher.group( 1 ), relativeRepositoryPath ) );
146        }
147        else if ( ( matcher = RENAMED_PATTERN.matcher( line ) ).find() )
148        {
149            status = ScmFileStatus.RENAMED;
150            files.add( resolvePath( matcher.group( 1 ), relativeRepositoryPath ) );
151            files.add( resolvePath( matcher.group( 2 ), relativeRepositoryPath ) );
152            logger.debug( "RENAMED status for line '" + line + "' files added '" + matcher.group( 1 ) + "' '"
153                              + matcher.group( 2 ) );
154        }
155        else
156        {
157            logger.warn( "Ignoring unrecognized line: " + line );
158            return;
159        }
160
161        // If the file isn't a file; don't add it.
162        if ( !files.isEmpty() && status != null )
163        {
164            if ( workingDirectory != null )
165            {
166                if ( status == ScmFileStatus.RENAMED )
167                {
168                    String oldFilePath = files.get( 0 );
169                    String newFilePath = files.get( 1 );
170                    if ( isFile( oldFilePath ) )
171                    {
172                        logger.debug( "file '" + oldFilePath + "' is a file" );
173                        return;
174                    }
175                    else
176                    {
177                        logger.debug( "file '" + oldFilePath + "' not a file" );
178                    }
179                    if ( !isFile( newFilePath ) )
180                    {
181                        logger.debug( "file '" + newFilePath + "' not a file" );
182                        return;
183                    }
184                    else
185                    {
186                        logger.debug( "file '" + newFilePath + "' is a file" );
187                    }
188                }
189                else if ( status == ScmFileStatus.DELETED )
190                {
191                    if ( isFile( files.get( 0 ) ) )
192                    {
193                        return;
194                    }
195                }
196                else
197                {
198                    if ( !isFile( files.get( 0 ) ) )
199                    {
200                        return;
201                    }
202                }
203            }
204
205            for ( String file : files )
206            {
207                changedFiles.add( new ScmFile( file, status ) );
208            }
209        }
210    }
211
212    private boolean isFile( String file )
213    {
214        File targetFile;
215        if ( relativeRepositoryPath == null )
216        {
217            targetFile = new File( workingDirectory, file );
218        }
219        else
220        {
221            targetFile = new File( relativeRepositoryPath.getPath(), file );
222        }
223        return targetFile.isFile();
224    }
225
226    protected static String resolvePath( String fileEntry, URI path )
227    {
228        /* Quotes may be included (from the git status line) when an fileEntry includes spaces */
229        String cleanedEntry = stripQuotes( fileEntry );
230        if ( path != null )
231        {
232            return resolveURI( cleanedEntry, path ).getPath();
233        }
234        else
235        {
236            return cleanedEntry;
237        }
238    }
239
240    /**
241     * 
242     * @param fileEntry the fileEntry, must not be {@code null}
243     * @param path the path, must not be {@code null}
244     * @return
245     */
246    public static URI resolveURI( String fileEntry, URI path )
247    {
248        // When using URI.create, spaces need to be escaped but not the slashes, so we can't use
249        // URLEncoder.encode( String, String )
250        // new File( String ).toURI() results in an absolute URI while path is relative, so that can't be used either.
251        return path.relativize( URI.create( stripQuotes( fileEntry ).replace( " ", "%20" ) ) );
252    }
253
254
255    public List<ScmFile> getChangedFiles()
256    {
257        return changedFiles;
258    }
259
260    /**
261     * @param str the (potentially quoted) string, must not be {@code null}
262     * @return the string with a pair of double quotes removed (if they existed)
263     */
264    private static String stripQuotes( String str )
265    {
266        int strLen = str.length();
267        return ( strLen > 0 && str.startsWith( "\"" ) && str.endsWith( "\"" ) ) ? unescape( str.substring( 1, strLen - 1 ) ) : str;
268    }
269    
270    /**
271     * Dequote a quoted string generated by git status --porcelain.
272     * The leading and trailing quotes have already been removed. 
273     * @param fileEntry
274     * @return
275     */
276    private static String unescape( String fileEntry )
277    {
278        // If there are no escaped characters, just return the input argument
279        int pos = fileEntry.indexOf( '\\' );
280        if ( pos == -1 )
281        {
282            return fileEntry;
283        }
284        
285        // We have escaped characters
286        byte[] inba = fileEntry.getBytes();
287        int inSub = 0;      // Input subscript into fileEntry
288        byte[] outba = new byte[fileEntry.length()];
289        int outSub = 0;     // Output subscript into outba
290        
291        while ( true )
292        {
293            System.arraycopy( inba,  inSub,  outba, outSub, pos - inSub );
294            outSub += pos - inSub;
295            inSub = pos + 1;
296            switch ( (char) inba[inSub++] )
297            {
298                case '"':
299                    outba[outSub++] = '"';
300                    break;
301                    
302                case 'a':
303                    outba[outSub++] = 7;        // Bell
304                    break;
305                    
306                case 'b':
307                    outba[outSub++] = '\b';
308                    break;
309                    
310                case 't':
311                    outba[outSub++] = '\t';
312                    break;
313                    
314                case 'n':
315                    outba[outSub++] = '\n';
316                    break;
317                    
318                case 'v':
319                    outba[outSub++] = 11;       // Vertical tab
320                    break;
321                    
322                case 'f':
323                    outba[outSub++] = '\f';
324                    break;
325                    
326                case 'r':
327                    outba[outSub++] = '\f';
328                    break;
329                    
330                case '\\':
331                    outba[outSub++] = '\\';
332                    break;
333                    
334                case '0':
335                case '1':
336                case '2':
337                case '3':
338                    // This assumes that the octal escape here is valid.
339                    byte b = (byte) ( ( inba[inSub - 1] - '0' ) << 6 );
340                    b |= (byte) ( ( inba[inSub++] - '0' ) << 3 );
341                    b |= (byte) ( inba[inSub++] - '0' );
342                    outba[outSub++] = b;
343                    break;
344                    
345                default:
346                    //This is an invalid escape in a string.  Just copy it.
347                    outba[outSub++] = '\\';
348                    inSub--;
349                    break;
350            }
351            pos = fileEntry.indexOf( '\\', inSub);
352            if ( pos == -1 )        // No more backslashes; we're done
353            {
354                System.arraycopy( inba, inSub, outba, outSub, inba.length - inSub );
355                outSub += inba.length - inSub;
356                break;
357            }
358        }
359        try
360        {
361            // explicit say UTF-8, otherwise it'll fail at least on Windows cmdline
362            return new String(outba, 0, outSub, "UTF-8");
363        }
364        catch ( UnsupportedEncodingException e )
365        {
366          throw new RuntimeException( e );    
367        }
368    }
369}