blob: 0e5c7de6774d91acb9590ae912209400a3b8b7c2 [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2010, 2022 SAP AG and others.
*
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Mathias Kinzler (SAP AG) - initial implementation
* Laurent Goubet <laurent.goubet@obeo.fr - 404121
* Alexander Nittka <alex@nittka.de> - 545123
*******************************************************************************/
package org.eclipse.egit.ui.internal.repository;
import java.io.File;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.DirectoryIteratorException;
import java.nio.file.DirectoryStream;
import java.nio.file.FileVisitOption;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import org.eclipse.core.commands.State;
import org.eclipse.core.resources.IWorkspaceRoot;
import org.eclipse.core.resources.WorkspaceJob;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Path;
import org.eclipse.core.runtime.Status;
import org.eclipse.egit.core.RepositoryCache;
import org.eclipse.egit.core.RepositoryUtil;
import org.eclipse.egit.ui.Activator;
import org.eclipse.egit.ui.internal.UIText;
import org.eclipse.egit.ui.internal.commands.ToggleCommand;
import org.eclipse.egit.ui.internal.groups.RepositoryGroup;
import org.eclipse.egit.ui.internal.groups.RepositoryGroups;
import org.eclipse.egit.ui.internal.repository.tree.AdditionalRefNode;
import org.eclipse.egit.ui.internal.repository.tree.AdditionalRefsNode;
import org.eclipse.egit.ui.internal.repository.tree.BranchHierarchyNode;
import org.eclipse.egit.ui.internal.repository.tree.BranchesNode;
import org.eclipse.egit.ui.internal.repository.tree.ErrorNode;
import org.eclipse.egit.ui.internal.repository.tree.FetchNode;
import org.eclipse.egit.ui.internal.repository.tree.FileNode;
import org.eclipse.egit.ui.internal.repository.tree.FolderNode;
import org.eclipse.egit.ui.internal.repository.tree.LocalNode;
import org.eclipse.egit.ui.internal.repository.tree.PushNode;
import org.eclipse.egit.ui.internal.repository.tree.RefNode;
import org.eclipse.egit.ui.internal.repository.tree.RemoteNode;
import org.eclipse.egit.ui.internal.repository.tree.RemoteTrackingNode;
import org.eclipse.egit.ui.internal.repository.tree.RemotesNode;
import org.eclipse.egit.ui.internal.repository.tree.RepositoryGroupNode;
import org.eclipse.egit.ui.internal.repository.tree.RepositoryNode;
import org.eclipse.egit.ui.internal.repository.tree.RepositoryTreeNode;
import org.eclipse.egit.ui.internal.repository.tree.StashNode;
import org.eclipse.egit.ui.internal.repository.tree.StashedCommitNode;
import org.eclipse.egit.ui.internal.repository.tree.SubmodulesNode;
import org.eclipse.egit.ui.internal.repository.tree.TagNode;
import org.eclipse.egit.ui.internal.repository.tree.TagsNode;
import org.eclipse.egit.ui.internal.repository.tree.WorkingDirNode;
import org.eclipse.egit.ui.internal.repository.tree.filter.NodesByCommitTimeFilter;
import org.eclipse.jface.viewers.ITreeContentProvider;
import org.eclipse.jface.viewers.Viewer;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.lib.ConfigConstants;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.RefDatabase;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevObject;
import org.eclipse.jgit.revwalk.RevTag;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.submodule.SubmoduleWalk;
import org.eclipse.jgit.transport.RemoteConfig;
import org.eclipse.jgit.transport.URIish;
import org.eclipse.jgit.util.StringUtils;
import org.eclipse.ui.PlatformUI;
import org.eclipse.ui.commands.ICommandService;
import org.eclipse.ui.dialogs.SearchPattern;
import org.eclipse.ui.handlers.RegistryToggleState;
/**
* Content Provider for the Git Repositories View
*/
public class RepositoriesViewContentProvider implements ITreeContentProvider {
private static final Object[] NO_CHILDREN = new Object[0];
private final State branchHierarchy;
private boolean showUnbornHead = false;
private boolean showRepositoryGroups = false;
private RefCache.Cache refCache = RefCache.get();
private FilterCache filters;
/**
* Constructs a new {@link RepositoriesViewContentProvider} that doesn't
* show an unborn branch as HEAD.
*/
public RepositoriesViewContentProvider() {
this(false);
}
/**
* Constructs a new {@link RepositoriesViewContentProvider}.
*
* @param showUnbornHead
* whether to show HEAD even if it is an unborn branch
*/
public RepositoriesViewContentProvider(boolean showUnbornHead) {
super();
this.showUnbornHead = showUnbornHead;
ICommandService srv = PlatformUI.getWorkbench()
.getService(ICommandService.class);
branchHierarchy = srv.getCommand(ToggleCommand.BRANCH_HIERARCHY_ID)
.getState(RegistryToggleState.STATE_ID);
}
/**
* Fluent API for configuring the content provider to show repository groups
* or not.
*
* @param showGroups
* whether to show repository groups
* @return the content provider itself
*/
public RepositoriesViewContentProvider showingRepositoryGroups(
boolean showGroups) {
this.showRepositoryGroups = showGroups;
return this;
}
@Override
@SuppressWarnings("unchecked")
public Object[] getElements(Object inputElement) {
List<RepositoryTreeNode> nodes = new ArrayList<>();
List<File> directories = new ArrayList<>();
if (inputElement instanceof Collection) {
for (Object next : ((Collection) inputElement)) {
if (next instanceof RepositoryTreeNode) {
nodes.add((RepositoryTreeNode) next);
} else if (next instanceof String) {
directories.add(new File((String) next));
}
}
} else if (inputElement instanceof IWorkspaceRoot) {
directories.addAll(RepositoryUtil.INSTANCE
.getConfiguredRepositories()
.stream().map(File::new).collect(Collectors.toList()));
}
nodes.addAll(
getRepositoryNodes(RepositoryGroups.INSTANCE, null,
directories));
if (showRepositoryGroups) {
for (RepositoryGroup group : RepositoryGroups.INSTANCE
.getGroups()) {
nodes.add(new RepositoryGroupNode(group));
}
}
Collections.sort(nodes);
return nodes.toArray();
}
@Override
public void dispose() {
refCache.dispose();
}
@Override
public void inputChanged(Viewer viewer, Object oldInput, Object newInput) {
// nothing
}
@Override
public Object[] getChildren(Object parentElement) {
RepositoryTreeNode node = (RepositoryTreeNode) parentElement;
Repository repo = node.getRepository();
switch (node.getType()) {
case BRANCHES: {
List<RepositoryTreeNode> nodes = new ArrayList<>();
nodes.add(new LocalNode(node, repo));
nodes.add(new RemoteTrackingNode(node, repo));
return nodes.toArray();
}
case LOCAL:
return getBranchChildren(node, repo, Constants.R_HEADS);
case REMOTETRACKING:
return getBranchChildren(node, repo, Constants.R_REMOTES);
case BRANCHHIERARCHY:
return getBranchHierarchyChildren(node, repo,
((BranchHierarchyNode) node).getObject()
.toPortableString());
case TAGS:
return getTagsChildren((TagsNode) node, repo);
case ADDITIONALREFS: {
List<RepositoryTreeNode<Ref>> refs = new ArrayList<>();
try {
for (Entry<String, Ref> refEntry : getRefs(repo, RefDatabase.ALL).entrySet()) {
String name=refEntry.getKey();
if (!name.startsWith(Constants.R_HEADS) && !name.startsWith(Constants.R_TAGS) && !name.startsWith(Constants.R_REMOTES))
refs.add(new AdditionalRefNode(node, repo, refEntry
.getValue()));
}
for (Ref r : refCache.additional(repo)) {
refs.add(new AdditionalRefNode(node, repo, r));
}
if (showUnbornHead) {
Ref head = repo.exactRef(Constants.HEAD);
if (head != null && head.isSymbolic()
&& head.getObjectId() == null) {
refs.add(new AdditionalRefNode(node, repo, head));
}
}
} catch (Exception e) {
return handleException(e, node);
}
return refs.toArray();
}
case REMOTES: {
List<RepositoryTreeNode<String>> remotes = new ArrayList<>();
Repository rep = node.getRepository();
Set<String> configNames = rep.getConfig().getSubsections(
ConfigConstants.CONFIG_REMOTE_SECTION);
for (String configName : configNames) {
remotes.add(new RemoteNode(node, repo, configName));
}
return remotes.toArray();
}
case REPO: {
List<RepositoryTreeNode<? extends Object>> nodeList = new ArrayList<>();
nodeList.add(new BranchesNode(node, repo));
TagsNode tags = new TagsNode(node, repo);
if (filters != null) {
tags.setFilter(filters.get(tags));
}
nodeList.add(tags);
nodeList.add(new AdditionalRefsNode(node, repo));
final boolean bare = repo.isBare();
if (!bare)
nodeList.add(new WorkingDirNode(node, repo));
nodeList.add(new RemotesNode(node, repo));
if(!bare && hasStashedCommits(repo))
nodeList.add(new StashNode(node, repo));
if (!bare && hasConfiguredSubmodules(repo))
nodeList.add(new SubmodulesNode(node, repo));
return nodeList.toArray();
}
case REPOGROUP: {
List<File> repoDirs = ((RepositoryGroupNode) node).getObject()
.getRepositoryDirectories();
return getRepositoryNodes(null, node, repoDirs).toArray();
}
case WORKINGDIR:
if (repo.isBare()) {
return NO_CHILDREN;
}
return getDirectoryChildren(node, repo.getWorkTree());
case FOLDER:
return getDirectoryChildren(node, (File) node.getObject());
case REMOTE: {
List<RepositoryTreeNode<String>> children = new ArrayList<>();
String remoteName = (String) node.getObject();
RemoteConfig rc;
try {
rc = new RemoteConfig(node.getRepository().getConfig(),
remoteName);
} catch (URISyntaxException e) {
return handleException(e, node);
}
if (!rc.getURIs().isEmpty())
children.add(new FetchNode(node, node.getRepository(), rc
.getURIs().get(0).toPrivateString()));
int uriCount = rc.getPushURIs().size();
if (uriCount == 0 && !rc.getURIs().isEmpty())
uriCount++;
// show push if either a fetch or push URI is specified and
// at least one push specification
if (uriCount > 0) {
URIish firstUri;
if (!rc.getPushURIs().isEmpty())
firstUri = rc.getPushURIs().get(0);
else
firstUri = rc.getURIs().get(0);
if (uriCount == 1)
children.add(new PushNode(node, node.getRepository(),
firstUri.toPrivateString()));
else
children.add(new PushNode(node, node.getRepository(),
firstUri.toPrivateString() + "...")); //$NON-NLS-1$
}
return children.toArray();
}
case SUBMODULES: {
List<RepositoryNode> children = new ArrayList<>();
Repository repository = node.getRepository();
try (SubmoduleWalk walk = SubmoduleWalk.forIndex(repository)) {
walk.setBuilderFactory(
() -> RepositoryCache.INSTANCE.getBuilder(false,
false));
while (walk.next()) {
Repository submodule = walk.getRepository();
if (submodule != null) {
children.add(new RepositoryNode(node, submodule));
}
}
} catch (IOException e) {
handleException(e, node);
}
return children.toArray();
}
case STASH:
List<StashedCommitNode> stashNodes = new ArrayList<>();
int index = 0;
try {
for (RevCommit commit : Git.wrap(repo).stashList().call())
stashNodes.add(new StashedCommitNode(node, repo, index++,
commit));
} catch (Exception e) {
handleException(e, node);
}
return stashNodes.toArray();
case FILE:
// fall through
case REF:
// fall through
case PUSH:
// fall through
case TAG:
// fall through
case FETCH:
// fall through
case ERROR:
// fall through
case STASHED_COMMIT:
// fall through
case ADDITIONALREF:
return null;
}
return null;
}
private List<RepositoryNode> getRepositoryNodes(RepositoryGroups groupsUtil,
RepositoryTreeNode<?> parent,
List<File> directories) {
List<RepositoryNode> result = new ArrayList<>();
List<File> filtersToKeep = new ArrayList<>();
for (File gitDir : directories) {
try {
if (gitDir.exists()) {
filtersToKeep
.add(new Path(gitDir.getAbsolutePath()).toFile());
boolean addRepo = (groupsUtil == null
|| !showRepositoryGroups
|| !groupsUtil.belongsToGroup(gitDir));
if (addRepo) {
RepositoryNode rNode = new RepositoryNode(parent,
RepositoryCache.INSTANCE
.lookupRepository(gitDir));
result.add(rNode);
}
} else {
RepositoryUtil.INSTANCE.removeDir(gitDir);
}
} catch (IOException e) {
// ignore for now
}
}
if (filters != null) {
filters.keepOnly(filtersToKeep);
}
return result;
}
private Object[] getBranchChildren(RepositoryTreeNode node, Repository repo,
String prefix) {
if (isHierarchical()) {
return getBranchHierarchyChildren(node, repo, prefix);
} else {
try {
return getRefs(repo, prefix).values().stream()
.filter(ref -> !ref.isSymbolic())
.map(ref -> new RefNode(node, repo, ref)).toArray();
} catch (IOException e) {
return handleException(e, node);
}
}
}
private Object[] getBranchHierarchyChildren(RepositoryTreeNode node,
Repository repo, String prefix) {
try {
Set<String> folderChildren = new HashSet<>();
return getRefs(repo, prefix).entrySet().stream()
.filter(e -> !e.getValue().isSymbolic()).map(e -> {
int i = e.getKey().indexOf('/', prefix.length());
if (i < 0) {
return new RefNode(node, repo, e.getValue());
} else {
String name = e.getKey().substring(prefix.length(),
i);
if (folderChildren.add(name)) {
return new BranchHierarchyNode(node, repo,
Path.fromPortableString(prefix + name));
}
return null;
}
}).filter(Objects::nonNull).toArray();
} catch (IOException e) {
return handleException(e, node);
}
}
private Object[] getDirectoryChildren(RepositoryTreeNode parentNode,
File parent) {
Repository repo = parentNode.getRepository();
List<RepositoryTreeNode<File>> children = new ArrayList<>();
try {
Files.walkFileTree(parent.toPath(),
EnumSet.noneOf(FileVisitOption.class), 1,
new SimpleFileVisitor<java.nio.file.Path>() {
@Override
public FileVisitResult visitFile(
java.nio.file.Path file,
BasicFileAttributes attrs) throws IOException {
if (attrs.isDirectory()) {
children.add(new FolderNode(parentNode, repo,
file.toFile()));
} else {
children.add(new FileNode(parentNode, repo,
file.toFile()));
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(
java.nio.file.Path file, IOException exc)
throws IOException {
// Just ignore it
return FileVisitResult.CONTINUE;
}
});
} catch (IOException e) {
// Ignore
}
return children.toArray();
}
@FunctionalInterface
private interface Matcher {
boolean matches(String s);
}
private Object[] getTagsChildren(TagsNode parentNode,
Repository repo) {
List<RepositoryTreeNode<?>> nodes = new ArrayList<>();
try (RevWalk walk = new RevWalk(repo)) {
walk.setRetainBody(true);
String filterText = filters != null ? filters.get(parentNode)
: null;
NodesByCommitTimeFilter timeFilter = new NodesByCommitTimeFilter(
filterText);
Matcher nameFilter = matcher(filterText);
for (Ref tagRef : getRefs(repo, Constants.R_TAGS).values()) {
if (!timeFilter.isFilterActive() && !nameFilter
.matches(Repository.shortenRefName(tagRef.getName()))) {
continue;
}
ObjectId objectId = tagRef.getLeaf().getObjectId();
RevObject revObject = walk.parseAny(objectId);
RevObject peeledObject = walk.peel(revObject);
TagNode tagNode = createTagNode(parentNode, repo, tagRef,
revObject, peeledObject);
timeFilter.addNode(tagNode, peeledObject);
}
nodes.addAll(timeFilter.getFilteredNodes());
} catch (IOException e) {
return handleException(e, parentNode);
}
return nodes.toArray();
}
/**
* Returns a matcher that matches strings against the given filter pattern.
*
* @param filter
* pattern to filter by
* @return a {@link Matcher}
*/
private static Matcher matcher(String filter) {
String pattern = filter;
if (StringUtils.isEmptyOrNull(pattern)) {
return s -> true;
}
boolean frontAnchored = pattern.charAt(0) == '^';
if (frontAnchored) {
pattern = pattern.substring(1);
}
boolean endAnchored = !pattern.isEmpty()
&& pattern.charAt(pattern.length() - 1) == '$';
if (endAnchored) {
pattern = pattern.substring(0, pattern.length() - 1);
}
if (pattern.isEmpty()) {
return s -> true;
}
if (!frontAnchored) {
pattern = '*' + pattern;
}
pattern = fixTrailingBackslash(pattern);
// SearchPattern by default does a prefix match. It can be forced to do
// a full match by adding a blank (which will be removed again).
pattern += endAnchored ? ' ' : '*';
SearchPattern matcher = new SearchPattern(
SearchPattern.RULE_PATTERN_MATCH);
matcher.setPattern(pattern);
return matcher::matches;
}
/**
* Ensures the text doesn't end with a lone escape character '\': if there's
* an odd number of backslashes at the end, add one backslash.
*
* @param text
* to fix
* @return fixed text
*/
private static String fixTrailingBackslash(String text) {
int l = text.length();
int i = l;
for (; i > 0; i--) {
if (text.charAt(i - 1) != '\\') {
break;
}
}
if ((l - i) % 2 != 0) {
return text + '\\';
}
return text;
}
private TagNode createTagNode(RepositoryTreeNode parentNode,
Repository repo, Ref ref, RevObject revObject,
RevObject peeledObject) {
boolean annotated = (revObject instanceof RevTag);
if (peeledObject instanceof RevCommit) {
RevCommit commit = (RevCommit) peeledObject;
String id = commit.getId().name();
String message = commit.getShortMessage();
return new TagNode(parentNode, repo, ref, annotated, id, message);
} else if (annotated) {
RevTag tag = (RevTag) revObject;
String message = tag.getShortMessage();
return new TagNode(parentNode, repo, ref, true, null, message);
}
return new TagNode(parentNode, repo, ref, false, null, ""); //$NON-NLS-1$
}
private Object[] handleException(Exception e, RepositoryTreeNode parentNode) {
Activator.handleError(e.getMessage(), e, false);
// add a node indicating that there was an Exception
String message = e.getMessage();
if (message == null)
return new Object[] { new ErrorNode(parentNode, parentNode
.getRepository(),
UIText.RepositoriesViewContentProvider_ExceptionNodeText) };
else
return new Object[] { new ErrorNode(parentNode, parentNode
.getRepository(), message) };
}
@Override
public Object getParent(Object element) {
if (element instanceof RepositoryTreeNode)
return ((RepositoryTreeNode) element).getParent();
return null;
}
@Override
public boolean hasChildren(Object element) {
// for some of the nodes we can optimize this call
RepositoryTreeNode node = (RepositoryTreeNode) element;
Repository repo = node.getRepository();
switch (node.getType()) {
case REPOGROUP:
return ((RepositoryGroupNode) element).hasChildren();
case BRANCHES:
case REPO:
case ADDITIONALREFS:
case SUBMODULES:
return true;
case TAGS:
return hasTagsChildren(repo);
case WORKINGDIR:
return !repo.isBare() && hasDirectoryChildren(repo.getWorkTree());
case FOLDER:
return !repo.isBare()
&& hasDirectoryChildren((File) node.getObject());
case FILE:
return false;
default:
Object[] children = getChildren(element);
return children != null && children.length > 0;
}
}
private boolean hasDirectoryChildren(File file) {
try (DirectoryStream<java.nio.file.Path> dir = Files
.newDirectoryStream(file.toPath())) {
return dir.iterator().hasNext();
} catch (DirectoryIteratorException | IOException e) {
return false;
}
}
/**
* As long as the ref database has not been read, assume there are tags, and
* start reading the database in the background. This should avoid long
* blocking during startup.
*
* @param repo
* @return whether the tags node has children.
*/
private boolean hasTagsChildren(Repository repo) {
try {
if (!refCache.isLoaded(repo)) {
WorkspaceJob job = new WorkspaceJob(
UIText.RepositoriesViewContentProvider_ReadReferencesJob) {
@Override
public IStatus runInWorkspace(IProgressMonitor monitor)
throws CoreException {
try {
// trigger reading the reference database
getRefs(repo, Constants.R_TAGS);
} catch (IOException e) {
return Status.CANCEL_STATUS;
}
return Status.OK_STATUS;
}
};
job.setSystem(true);
job.schedule();
return true;
}
return !getRefs(repo, Constants.R_TAGS).isEmpty();
} catch (IOException e) {
return true;
}
}
private Map<String, Ref> getRefs(final Repository repo, final String prefix)
throws IOException {
return refCache.byPrefix(repo, prefix);
}
/**
* Does the repository have any submodule configurations?
* <p>
* This method checks for a '.gitmodules' file at the root of the working
* directory or any 'submodule' sections in the repository's config file
*
* @param repository
* @return true if submodules, false otherwise
*/
private boolean hasConfiguredSubmodules(final Repository repository) {
if (new File(repository.getWorkTree(), Constants.DOT_GIT_MODULES)
.isFile())
return true;
return !repository.getConfig()
.getSubsections(ConfigConstants.CONFIG_SUBMODULE_SECTION)
.isEmpty();
}
/**
* Does the repository have any stashed commits?
* <p>
* This method checks for a {@link Constants#R_STASH} ref in the given
* repository
*
* @param repository
* @return true if stashed commits, false otherwise
*/
private boolean hasStashedCommits(final Repository repository) {
try {
return repository.exactRef(Constants.R_STASH) != null;
} catch (IOException e) {
return false;
}
}
/**
* Tells whether this content provider is using a hierarchical branch
* layout.
*
* @return {@code true} if this content provider uses a hierarchical branch
* layout; {@code false} otherwise
*/
public boolean isHierarchical() {
return ((Boolean) branchHierarchy.getValue()).booleanValue();
}
/**
* Sets a {@link FilterCache} for this content provider.
*
* @param cache
* to set
* @return this
*/
public RepositoriesViewContentProvider withFilterCache(FilterCache cache) {
this.filters = cache;
return this;
}
}