001/*
002 * ModeShape (http://www.modeshape.org)
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *       http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package org.modeshape.connector.git;
017
018import java.io.File;
019import java.io.IOException;
020import java.io.InputStream;
021import java.util.ArrayList;
022import java.util.Collection;
023import java.util.Collections;
024import java.util.HashMap;
025import java.util.List;
026import java.util.Map;
027import java.util.Set;
028import javax.jcr.NamespaceRegistry;
029import javax.jcr.RepositoryException;
030import org.eclipse.jgit.api.Git;
031import org.eclipse.jgit.lib.ObjectId;
032import org.eclipse.jgit.lib.ObjectLoader;
033import org.eclipse.jgit.lib.Repository;
034import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
035import org.modeshape.schematic.document.Document;
036import org.modeshape.jcr.RepositoryConfiguration;
037import org.modeshape.jcr.api.nodetype.NodeTypeManager;
038import org.modeshape.jcr.cache.DocumentStoreException;
039import org.modeshape.jcr.spi.federation.Connector;
040import org.modeshape.jcr.spi.federation.DocumentWriter;
041import org.modeshape.jcr.spi.federation.PageKey;
042import org.modeshape.jcr.spi.federation.PageWriter;
043import org.modeshape.jcr.spi.federation.Pageable;
044import org.modeshape.jcr.spi.federation.ReadOnlyConnector;
045import org.modeshape.jcr.value.binary.ExternalBinaryValue;
046
047/**
048 * A read-only {@link Connector} that accesses the content in a local Git repository that is a clone of a remote repository.
049 * <p>
050 * This connector has several properties that must be configured via the {@link RepositoryConfiguration}:
051 * <ul>
052 * <li><strong><code>directoryPath</code></strong> - The path to the folder that is or contains the <code>.git</code> data
053 * structure is to be accessed by this connector.</li>
054 * <li><strong><code>remoteName</code></strong> - The alias used by the local Git repository for the remote repository. The
055 * default is the "<code>origin</code>". If the value contains commas, the value contains an ordered list of remote aliases that
056 * should be searched; the first one to match an existing remote will be used.</li>
057 * <li><strong><code>queryableBranches</code></strong> - An array with the names of the branches that should be queryable by the
058 * repository. By default, only the master branch is queryable.</li>
059 * </ul>
060 * </p>
061 * <p>
062 * The connector results in the following structure:
063 * </p>
064 * <table cellspacing="0" cellpadding="1" border="1">
065 * <tr>
066 * <th>Path</th>
067 * <th>Description</th>
068 * </tr>
069 * <tr>
070 * <td><code>/branches/{branchName}</code></td>
071 * <td>The list of branches.</td>
072 * </tr>
073 * <tr>
074 * <td><code>/tags/{tagName}</code></td>
075 * <td>The list of tags.</td>
076 * </tr>
077 * <tr>
078 * <td><code>/commits/{branchOrTagNameOrCommit}/{objectId}</code></td>
079 * <td>The history of commits on the branch, tag or object ID name "<code>{branchOrTagNameOrCommit}</code>", where "
080 * <code>{objectId}</code>" is the object ID of the commit.</td>
081 * </tr>
082 * <tr>
083 * <td><code>/commit/{branchOrTagNameOrCommit}</code></td>
084 * <td>The information about a particular branch, tag or commit "<code>{branchOrTagNameOrCommit}</code>".</td>
085 * </tr>
086 * <tr>
087 * <td><code>/tree/{branchOrTagOrObjectId}/{filesAndFolders}/...</code></td>
088 * <td>The structure of the directories and files in the specified branch, tag or commit "<code>{branchOrTagNameOrCommit}</code>".
089 * </td>
090 * </tr>
091 * </table>
092 */
093public class GitConnector extends ReadOnlyConnector implements Pageable {
094
095    private static final boolean DEFAULT_INCLUDE_MIME_TYPE = false;
096    private static final String GIT_DIRECTORY_NAME = ".git";
097
098    private static final String GIT_CND_PATH = "org/modeshape/connector/git/git.cnd";
099
100    /**
101     * The string path for a {@link File} object that represents the top-level directory of the local Git repository. This is set
102     * via reflection and is required for this connector.
103     */
104    private String directoryPath;
105
106    /**
107     * The optional string value representing the name of the remote that serves as the primary remote repository. By default this
108     * is "origin". This is set via reflection.
109     */
110    private String remoteName;
111
112    /**
113     * The optional string value representing the name of the remote that serves as the primary remote repository. By default this
114     * is "origin". This is set via reflection.
115     */
116    private List<String> parsedRemoteNames;
117
118    /**
119     * The optional boolean value specifying whether the connector should set the "jcr:mimeType" property on the "jcr:content"
120     * child node under each "git:file" node. By default this is '{@value GitConnector#DEFAULT_INCLUDE_MIME_TYPE}'. This is set
121     * via reflection.
122     */
123    private boolean includeMimeType = DEFAULT_INCLUDE_MIME_TYPE;
124
125    private Repository repository;
126    private Git git;
127    private Map<String, GitFunction> functions;
128    private Map<String, PageableGitFunction> pageableFunctions;
129    private Values values;
130
131    @Override
132    public void initialize( NamespaceRegistry registry,
133                            NodeTypeManager nodeTypeManager ) throws RepositoryException, IOException {
134        super.initialize(registry, nodeTypeManager);
135
136        // Verify the local git repository exists ...
137        File dir = new File(directoryPath);
138        if (!dir.exists() || !dir.isDirectory()) {
139            throw new RepositoryException(GitI18n.directoryDoesNotExist.text(dir.getAbsolutePath()));
140        }
141        if (!dir.canRead()) {
142            throw new RepositoryException(GitI18n.directoryCannotBeRead.text(dir.getAbsolutePath()));
143        }
144        File gitDir = dir;
145        if (!GIT_DIRECTORY_NAME.equals(gitDir.getName())) {
146            gitDir = new File(dir, ".git");
147            if (!gitDir.exists() || !gitDir.isDirectory()) {
148                throw new RepositoryException(GitI18n.directoryDoesNotExist.text(gitDir.getAbsolutePath()));
149            }
150            if (!gitDir.canRead()) {
151                throw new RepositoryException(GitI18n.directoryCannotBeRead.text(gitDir.getAbsolutePath()));
152            }
153        }
154
155        values = new Values(factories(), getContext().getBinaryStore());
156
157        // Set up the repository instance. We expect it to exist, and will use it as a "bare" repository (meaning
158        // that no working directory will be used nor needs to exist) ...
159        repository = new FileRepositoryBuilder().setGitDir(gitDir).setMustExist(true).setBare().build();
160        git = new Git(repository);
161
162        parsedRemoteNames = new ArrayList<String>();
163        if (this.remoteName != null) {
164            // Make sure the remote exists ...
165            Set<String> remoteNames = repository.getConfig().getSubsections("remote");
166            String remoteName = null;
167            for (String desiredName : this.remoteName.split(",")) {
168                desiredName = desiredName.trim();
169                if (remoteNames.contains(desiredName)) {
170                    remoteName = desiredName;
171                    parsedRemoteNames.add(desiredName);
172                    break;
173                }
174            }
175            if (remoteName == null) {
176                throw new RepositoryException(GitI18n.remoteDoesNotExist.text(this.remoteName, gitDir.getAbsolutePath()));
177            }
178            this.remoteName = remoteName;
179        }
180
181        // Register the different functions ...
182        functions = new HashMap<String, GitFunction>();
183        pageableFunctions = new HashMap<String, PageableGitFunction>();
184        register(new GitRoot(this), new GitBranches(this), new GitTags(this), new GitHistory(this), new GitCommitDetails(this),
185                 new GitTree(this));
186
187        // Register the Git-specific node types ...
188        InputStream cndStream = getClass().getClassLoader().getResourceAsStream(GIT_CND_PATH);
189        nodeTypeManager.registerNodeTypes(cndStream, true);
190
191    }
192
193    private void register( GitFunction... functions ) {
194        for (GitFunction function : functions) {
195            this.functions.put(function.getName(), function);
196            if (function instanceof PageableGitFunction) {
197                this.pageableFunctions.put(function.getName(), (PageableGitFunction)function);
198            }
199        }
200    }
201
202    protected DocumentWriter newDocumentWriter( String id ) {
203        return super.newDocument(id);
204    }
205
206    protected boolean includeMimeType() {
207        return includeMimeType;
208    }
209
210    @Override
211    public void shutdown() {
212        repository = null;
213        git = null;
214        functions = null;
215    }
216
217    @Override
218    public Document getDocumentById( String id ) {
219        CallSpecification callSpec = new CallSpecification(id);
220        GitFunction function = functions.get(callSpec.getFunctionName());
221        if (function == null) return null;
222        try {
223            // Set up the document writer ...
224            DocumentWriter writer = newDocument(id);
225            String parentId = callSpec.getParentId();
226            assert parentId != null;
227            writer.setParent(parentId);
228            // check if the document should be indexed or not, based on the global connector setting and the specific function
229            if (!this.isQueryable() || !function.isQueryable(callSpec)) {
230                writer.setNotQueryable();
231            }
232            // Now call the function ...
233            Document doc = function.execute(repository, git, callSpec, writer, values);
234            // Log the result ...
235            getLogger().trace("ID={0},result={1}", id, doc);
236            return doc;
237        } catch (Throwable e) {
238            throw new DocumentStoreException(id, e);
239        }
240    }
241
242    @Override
243    public Document getChildren( PageKey pageKey ) {
244        String id = pageKey.getParentId();
245        CallSpecification callSpec = new CallSpecification(id);
246        PageableGitFunction function = pageableFunctions.get(callSpec.getFunctionName());
247        if (function == null) return null;
248        try {
249            // Set up the document writer ...
250            PageWriter writer = newPageDocument(pageKey);
251            // Now call the function ...
252            return function.execute(repository, git, callSpec, writer, values, pageKey);
253        } catch (Throwable e) {
254            throw new DocumentStoreException(id, e);
255        }
256    }
257
258    @Override
259    public Document getChildReference( String parentKey,
260                                       String childKey ) {
261        // The child key always contains the path to the child, so therefore we can always use it to create the
262        // child reference document ...
263        CallSpecification callSpec = new CallSpecification(childKey);
264        return newChildReference(childKey, callSpec.lastParameter());
265    }
266
267    @Override
268    public String getDocumentId( String path ) {
269        // Our paths are basically used as IDs ...
270        return path;
271    }
272
273    @Override
274    public Collection<String> getDocumentPathsById( String id ) {
275        // Our paths are basically used as IDs, so the ID is the path ...
276        return Collections.singletonList(id);
277    }
278
279    @Override
280    public boolean hasDocument( String id ) {
281        Document doc = getDocumentById(id);
282        return doc != null;
283    }
284
285    @Override
286    public ExternalBinaryValue getBinaryValue( String id ) {
287        try {
288            ObjectId fileObjectId = ObjectId.fromString(id);
289            ObjectLoader fileLoader = repository.open(fileObjectId);
290            return new GitBinaryValue(fileObjectId, fileLoader, getSourceName(), null, getMimeTypeDetector());
291        } catch (IOException e) {
292            throw new DocumentStoreException(id, e);
293        }
294    }
295
296    protected final String remoteName() {
297        return remoteName;
298    }
299
300    protected final List<String> remoteNames() {
301        return parsedRemoteNames;
302    }
303}