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