001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *   http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing,
013 * software distributed under the License is distributed on an
014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015 * KIND, either express or implied.  See the License for the
016 * specific language governing permissions and limitations
017 * under the License.
018 */
019package org.apache.maven.scm.provider.git.jgit.command;
020
021import java.io.File;
022import java.io.IOException;
023import java.io.UnsupportedEncodingException;
024import java.net.URLEncoder;
025import java.util.ArrayList;
026import java.util.Collection;
027import java.util.Date;
028import java.util.HashSet;
029import java.util.List;
030import java.util.Set;
031
032import org.apache.commons.lang3.StringUtils;
033import org.apache.maven.scm.ScmFile;
034import org.apache.maven.scm.ScmFileSet;
035import org.apache.maven.scm.ScmFileStatus;
036import org.apache.maven.scm.provider.git.repository.GitScmProviderRepository;
037import org.apache.maven.scm.util.FilenameUtils;
038import org.eclipse.jgit.api.AddCommand;
039import org.eclipse.jgit.api.Git;
040import org.eclipse.jgit.api.PushCommand;
041import org.eclipse.jgit.api.RmCommand;
042import org.eclipse.jgit.api.Status;
043import org.eclipse.jgit.api.errors.GitAPIException;
044import org.eclipse.jgit.api.errors.InvalidRemoteException;
045import org.eclipse.jgit.api.errors.TransportException;
046import org.eclipse.jgit.diff.DiffEntry;
047import org.eclipse.jgit.diff.DiffEntry.ChangeType;
048import org.eclipse.jgit.diff.DiffFormatter;
049import org.eclipse.jgit.diff.RawTextComparator;
050import org.eclipse.jgit.errors.CorruptObjectException;
051import org.eclipse.jgit.errors.IncorrectObjectTypeException;
052import org.eclipse.jgit.errors.MissingObjectException;
053import org.eclipse.jgit.errors.StopWalkException;
054import org.eclipse.jgit.lib.Constants;
055import org.eclipse.jgit.lib.ObjectId;
056import org.eclipse.jgit.lib.ProgressMonitor;
057import org.eclipse.jgit.lib.Ref;
058import org.eclipse.jgit.lib.Repository;
059import org.eclipse.jgit.lib.RepositoryBuilder;
060import org.eclipse.jgit.lib.StoredConfig;
061import org.eclipse.jgit.lib.TextProgressMonitor;
062import org.eclipse.jgit.revwalk.RevCommit;
063import org.eclipse.jgit.revwalk.RevFlag;
064import org.eclipse.jgit.revwalk.RevSort;
065import org.eclipse.jgit.revwalk.RevWalk;
066import org.eclipse.jgit.revwalk.filter.CommitTimeRevFilter;
067import org.eclipse.jgit.revwalk.filter.RevFilter;
068import org.eclipse.jgit.transport.CredentialsProvider;
069import org.eclipse.jgit.transport.PushResult;
070import org.eclipse.jgit.transport.RefSpec;
071import org.eclipse.jgit.transport.RemoteRefUpdate;
072import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
073import org.eclipse.jgit.util.io.DisabledOutputStream;
074import org.slf4j.Logger;
075import org.slf4j.LoggerFactory;
076
077import static org.eclipse.jgit.lib.Constants.R_TAGS;
078
079/**
080 * JGit utility functions.
081 *
082 * @author <a href="mailto:struberg@yahoo.de">Mark Struberg</a>
083 * @author Dominik Bartholdi (imod)
084 * @since 1.9
085 */
086public class JGitUtils {
087    private static final Logger LOGGER = LoggerFactory.getLogger(JGitUtils.class);
088
089    private JGitUtils() {
090        // no op
091    }
092
093    /**
094     * Opens a JGit repository in the current directory or a parent directory.
095     * @param basedir The directory to start with
096     * @throws IOException If the repository cannot be opened
097     */
098    public static Git openRepo(File basedir) throws IOException {
099        return new Git(new RepositoryBuilder()
100                .readEnvironment()
101                .findGitDir(basedir)
102                .setMustExist(true)
103                .build());
104    }
105
106    /**
107     * Closes the repository wrapped by the passed git object
108     * @param git
109     */
110    public static void closeRepo(Git git) {
111        if (git != null && git.getRepository() != null) {
112            git.getRepository().close();
113        }
114    }
115
116    /**
117     * Construct a logging ProgressMonitor for all JGit operations.
118     *
119     * @return a ProgressMonitor for use
120     */
121    public static ProgressMonitor getMonitor() {
122        // X TODO write an own ProgressMonitor which logs to ScmLogger!
123        return new TextProgressMonitor();
124    }
125
126    /**
127     * Prepares the in memory configuration of git to connect to the configured
128     * repository. It configures the following settings in memory: <br>
129     * <ul><li>push url</li> <li>fetch url</li></ul>
130     * <p>
131     *
132     * @param git        the instance to configure (only in memory, not saved)
133     * @param repository the repo config to be used
134     * @return {@link CredentialsProvider} in case there are credentials
135     *         informations configured in the repository.
136     */
137    public static CredentialsProvider prepareSession(Git git, GitScmProviderRepository repository) {
138        StoredConfig config = git.getRepository().getConfig();
139        config.setString("remote", "origin", "url", repository.getFetchUrl());
140        config.setString("remote", "origin", "pushURL", repository.getPushUrl());
141
142        // make sure we do not log any passwords to the output
143        String password = StringUtils.isNotBlank(repository.getPassword())
144                ? repository.getPassword().trim()
145                : "no-pwd-defined";
146        // if password contains special characters it won't match below.
147        // Try encoding before match. (Passwords without will be unaffected)
148        try {
149            password = URLEncoder.encode(password, "UTF-8");
150        } catch (UnsupportedEncodingException e) {
151            // UTF-8 should be valid
152            // TODO use a logger
153            System.out.println("Ignore UnsupportedEncodingException when trying to encode password");
154        }
155        LOGGER.info("fetch url: " + repository.getFetchUrl().replace(password, "******"));
156        LOGGER.info("push url: " + repository.getPushUrl().replace(password, "******"));
157        return getCredentials(repository);
158    }
159
160    /**
161     * Creates a credentials provider from the information passed in the
162     * repository. Current implementation supports: <br>
163     * <ul><li>UserName/Password</li></ul>
164     * <p>
165     *
166     * @param repository the config to get the details from
167     * @return <code>null</code> if there is not enough info to create a
168     *         provider with
169     */
170    public static CredentialsProvider getCredentials(GitScmProviderRepository repository) {
171        if (StringUtils.isNotBlank(repository.getUser()) && StringUtils.isNotBlank(repository.getPassword())) {
172            return new UsernamePasswordCredentialsProvider(
173                    repository.getUser().trim(), repository.getPassword().trim());
174        }
175
176        return null;
177    }
178
179    public static Iterable<PushResult> push(Git git, GitScmProviderRepository repo, RefSpec refSpec)
180            throws GitAPIException, InvalidRemoteException, TransportException {
181        CredentialsProvider credentials = prepareSession(git, repo);
182        PushCommand command = git.push()
183                .setRefSpecs(refSpec)
184                .setCredentialsProvider(credentials)
185                .setTransportConfigCallback(
186                        new JGitTransportConfigCallback(new ScmProviderAwareSshdSessionFactory(repo, LOGGER)));
187
188        Iterable<PushResult> pushResultList = command.call();
189        for (PushResult pushResult : pushResultList) {
190            Collection<RemoteRefUpdate> ru = pushResult.getRemoteUpdates();
191            for (RemoteRefUpdate remoteRefUpdate : ru) {
192                LOGGER.info(remoteRefUpdate.getStatus() + " - " + remoteRefUpdate);
193            }
194        }
195        return pushResultList;
196    }
197
198    /**
199     * Does the Repository have any commits?
200     *
201     * @param repo
202     * @return false if there are no commits
203     */
204    public static boolean hasCommits(Repository repo) {
205        if (repo != null && repo.getDirectory().exists()) {
206            return (new File(repo.getDirectory(), "objects").list().length > 2)
207                    || (new File(repo.getDirectory(), "objects/pack").list().length > 0);
208        }
209        return false;
210    }
211
212    /**
213     * get a list of all files in the given commit
214     *
215     * @param repository the repo
216     * @param commit     the commit to get the files from
217     * @return a list of files included in the commit
218     * @throws MissingObjectException
219     * @throws IncorrectObjectTypeException
220     * @throws CorruptObjectException
221     * @throws IOException
222     */
223    public static List<ScmFile> getFilesInCommit(Repository repository, RevCommit commit)
224            throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException, IOException {
225        return getFilesInCommit(repository, commit, null);
226    }
227
228    /**
229     * get a list of all files in the given commit
230     *
231     * @param repository the repo
232     * @param commit     the commit to get the files from
233     * @param baseDir    the directory to which the returned files should be relative.
234     *                   May be {@code null} in case they should be relative to the working directory root.
235     * @return a list of files included in the commit
236     *
237     * @throws MissingObjectException
238     * @throws IncorrectObjectTypeException
239     * @throws CorruptObjectException
240     * @throws IOException
241     */
242    public static List<ScmFile> getFilesInCommit(Repository repository, RevCommit commit, File baseDir)
243            throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException, IOException {
244        List<ScmFile> list = new ArrayList<>();
245        if (JGitUtils.hasCommits(repository)) {
246
247            try (RevWalk rw = new RevWalk(repository);
248                    DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
249                RevCommit realParent = commit.getParentCount() > 0 ? commit.getParent(0) : commit;
250                RevCommit parent = rw.parseCommit(realParent.getId());
251                df.setRepository(repository);
252                df.setDiffComparator(RawTextComparator.DEFAULT);
253                df.setDetectRenames(true);
254                List<DiffEntry> diffs = df.scan(parent.getTree(), commit.getTree());
255                for (DiffEntry diff : diffs) {
256                    final String path;
257                    if (baseDir != null) {
258                        path = relativize(baseDir, new File(repository.getWorkTree(), diff.getNewPath()))
259                                .getPath();
260                    } else {
261                        path = diff.getNewPath();
262                    }
263                    list.add(new ScmFile(path, ScmFileStatus.CHECKED_IN));
264                }
265            }
266        }
267        return list;
268    }
269
270    /**
271     * Translate a {@code FileStatus} in the matching {@code ScmFileStatus}.
272     *
273     * @param changeType
274     * @return the matching ScmFileStatus
275     */
276    public static ScmFileStatus getScmFileStatus(ChangeType changeType) {
277        switch (changeType) {
278            case ADD:
279                return ScmFileStatus.ADDED;
280            case MODIFY:
281                return ScmFileStatus.MODIFIED;
282            case DELETE:
283                return ScmFileStatus.DELETED;
284            case RENAME:
285                return ScmFileStatus.RENAMED;
286            case COPY:
287                return ScmFileStatus.COPIED;
288            default:
289                return ScmFileStatus.UNKNOWN;
290        }
291    }
292
293    /**
294     * Adds all files in the given fileSet to the repository.
295     *
296     * @param git     the repo to add the files to
297     * @param fileSet the set of files within the workspace, the files are added
298     *                relative to the basedir of this fileset
299     * @return a list of added files
300     * @throws GitAPIException
301     */
302    public static List<ScmFile> addAllFiles(Git git, ScmFileSet fileSet) throws GitAPIException {
303        File workingCopyRootDirectory = git.getRepository().getWorkTree();
304        AddCommand add = git.add();
305        getWorkingCopyRelativePaths(workingCopyRootDirectory, fileSet).stream()
306                .forEach(f -> add.addFilepattern(toNormalizedFilePath(f)));
307        add.call();
308
309        Status status = git.status().call();
310
311        Set<String> allInIndex = new HashSet<>();
312        allInIndex.addAll(status.getAdded());
313        allInIndex.addAll(status.getChanged());
314        return getScmFilesForAllFileSetFilesContainedInRepoPath(
315                workingCopyRootDirectory, fileSet, allInIndex, ScmFileStatus.ADDED);
316    }
317
318    /**
319     * Remove all files in the given fileSet from the repository.
320     *
321     * @param git     the repo to remove the files from
322     * @param fileSet the set of files within the workspace, the files are removed
323     *                relative to the basedir of this fileset
324     * @return a list of removed files
325     * @throws GitAPIException
326     */
327    public static List<ScmFile> removeAllFiles(Git git, ScmFileSet fileSet) throws GitAPIException {
328        File workingCopyRootDirectory = git.getRepository().getWorkTree();
329        RmCommand remove = git.rm();
330        getWorkingCopyRelativePaths(workingCopyRootDirectory, fileSet).stream()
331                .forEach(f -> remove.addFilepattern(toNormalizedFilePath(f)));
332        remove.call();
333
334        Status status = git.status().call();
335
336        Set<String> allInIndex = new HashSet<>(status.getRemoved());
337        return getScmFilesForAllFileSetFilesContainedInRepoPath(
338                workingCopyRootDirectory, fileSet, allInIndex, ScmFileStatus.DELETED);
339    }
340
341    /**
342     * Convert each file in the {@code fileSet} to their relative file path to workingCopyDirectory
343     * and return them in a list.
344     * @param workingCopyDirectory the working copy root directory
345     * @param fileSet the file set to convert
346     */
347    public static List<File> getWorkingCopyRelativePaths(File workingCopyDirectory, ScmFileSet fileSet) {
348        List<File> repositoryRelativePaths = new ArrayList<>();
349        for (File path : fileSet.getFileList()) {
350            if (!path.isAbsolute()) {
351                path = new File(fileSet.getBasedir().getPath(), path.getPath());
352            }
353            File repositoryRelativePath = relativize(workingCopyDirectory, path);
354            repositoryRelativePaths.add(repositoryRelativePath);
355        }
356        return repositoryRelativePaths;
357    }
358
359    /**
360     * Converts the given file to a string only containing forward slashes
361     * @param file
362     * @return the normalized file path
363     */
364    public static String toNormalizedFilePath(File file) {
365        return FilenameUtils.normalizeFilename(file);
366    }
367
368    private static List<ScmFile> getScmFilesForAllFileSetFilesContainedInRepoPath(
369            File workingCopyDirectory, ScmFileSet fileSet, Set<String> repoFilePaths, ScmFileStatus fileStatus) {
370        List<ScmFile> files = new ArrayList<>(repoFilePaths.size());
371        getWorkingCopyRelativePaths(workingCopyDirectory, fileSet).stream().forEach((relativeFile) -> {
372            // check if repo relative path is contained
373            if (repoFilePaths.contains(toNormalizedFilePath(relativeFile))) {
374                // returned ScmFiles should be relative to given fileset's basedir
375                ScmFile scmfile = new ScmFile(
376                        relativize(fileSet.getBasedir(), new File(workingCopyDirectory, relativeFile.getPath()))
377                                .getPath(),
378                        fileStatus);
379                files.add(scmfile);
380            }
381        });
382        return files;
383    }
384
385    private static File relativize(File baseDir, File file) {
386        if (file.isAbsolute()) {
387            return baseDir.toPath().relativize(file.toPath()).toFile();
388        }
389        return file;
390    }
391
392    /**
393     * Get a list of commits between two revisions.
394     *
395     * @param repo     the repository to work on
396     * @param sortings sorting
397     * @param fromRev  start revision
398     * @param toRev    if null, falls back to head
399     * @param fromDate from which date on
400     * @param toDate   until which date
401     * @param maxLines max number of lines
402     * @return a list of commits, might be empty, but never <code>null</code>
403     * @throws IOException
404     * @throws MissingObjectException
405     * @throws IncorrectObjectTypeException
406     */
407    public static List<RevCommit> getRevCommits(
408            Repository repo,
409            RevSort[] sortings,
410            String fromRev,
411            String toRev,
412            final Date fromDate,
413            final Date toDate,
414            int maxLines)
415            throws IOException, MissingObjectException, IncorrectObjectTypeException {
416
417        List<RevCommit> revs = new ArrayList<>();
418
419        ObjectId fromRevId = fromRev != null ? repo.resolve(fromRev) : null;
420        ObjectId toRevId = toRev != null ? repo.resolve(toRev) : null;
421
422        if (sortings == null || sortings.length == 0) {
423            sortings = new RevSort[] {RevSort.TOPO, RevSort.COMMIT_TIME_DESC};
424        }
425
426        try (RevWalk walk = new RevWalk(repo)) {
427            for (final RevSort s : sortings) {
428                walk.sort(s, true);
429            }
430
431            if (fromDate != null && toDate != null) {
432                // walk.setRevFilter( CommitTimeRevFilter.between( fromDate, toDate ) );
433                walk.setRevFilter(new RevFilter() {
434                    @Override
435                    public boolean include(RevWalk walker, RevCommit cmit)
436                            throws StopWalkException, MissingObjectException, IncorrectObjectTypeException,
437                                    IOException {
438                        int cmtTime = cmit.getCommitTime();
439
440                        return (cmtTime >= (fromDate.getTime() / 1000)) && (cmtTime <= (toDate.getTime() / 1000));
441                    }
442
443                    @Override
444                    public RevFilter clone() {
445                        return this;
446                    }
447                });
448            } else {
449                if (fromDate != null) {
450                    walk.setRevFilter(CommitTimeRevFilter.after(fromDate));
451                }
452                if (toDate != null) {
453                    walk.setRevFilter(CommitTimeRevFilter.before(toDate));
454                }
455            }
456
457            if (fromRevId != null) {
458                RevCommit c = walk.parseCommit(fromRevId);
459                c.add(RevFlag.UNINTERESTING);
460                RevCommit real = walk.parseCommit(c);
461                walk.markUninteresting(real);
462            }
463
464            if (toRevId != null) {
465                RevCommit c = walk.parseCommit(toRevId);
466                c.remove(RevFlag.UNINTERESTING);
467                RevCommit real = walk.parseCommit(c);
468                walk.markStart(real);
469            } else {
470                final ObjectId head = repo.resolve(Constants.HEAD);
471                if (head == null) {
472                    throw new RuntimeException("Cannot resolve " + Constants.HEAD);
473                }
474                RevCommit real = walk.parseCommit(head);
475                walk.markStart(real);
476            }
477
478            int n = 0;
479            for (final RevCommit c : walk) {
480                n++;
481                if (maxLines != -1 && n > maxLines) {
482                    break;
483                }
484
485                revs.add(c);
486            }
487            return revs;
488        }
489    }
490
491    /**
492     * Get a list of tags that has been set in the specified commit.
493     *
494     * @param repo the repository to work on
495     * @param commit the commit for which we want the tags
496     * @return a list of tags, might be empty, and never <code>null</code>
497     */
498    public static List<String> getTags(Repository repo, RevCommit commit) throws IOException {
499        List<Ref> refList = repo.getRefDatabase().getRefsByPrefix(R_TAGS);
500
501        try (RevWalk revWalk = new RevWalk(repo)) {
502            ObjectId commitId = commit.getId();
503            List<String> result = new ArrayList<>();
504
505            for (Ref ref : refList) {
506                ObjectId tagId = ref.getObjectId();
507                RevCommit tagCommit = revWalk.parseCommit(tagId);
508                if (commitId.equals(tagCommit.getId())) {
509                    result.add(ref.getName().substring(R_TAGS.length()));
510                }
511            }
512            return result;
513        }
514    }
515}