From e04f17fe5f9677b9f6f50520449baee5a24eaeff Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 10 Feb 2015 17:43:59 -0500 Subject: [PATCH] Set up the atom.repository-provider service and implement GitRepositoryProvider. I tested this by creating a dummy implementation of an `HgRepositoryProvider` (with the optional `createRepositorySync()` method implemented) and an `HgRepository` in an Atom package with the following stanza in the `package.json`: ``` "providedServices": { "atom.repository-provider": { "versions": { "0.1.0": "createHgRepositoryProvider" } } }, ``` I opened a path with an Hg repository from the command line using Atom. I verified that `atom.project.repositoryProviders` contains both a `GitRepositoryProvider` and an `HgRepositoryProvider`. I also verified that running the following printed out an `HgRepository`: ``` var Directory = require('pathwatcher').Directory; atom.project.repositoryForDirectory( new Directory(atom.project.getPath(), /* symlink */ false)).then( function(repo) { console.log('repo: %o', repo); }); ``` One thing that stands out to me about the current API for the atom.repository-provider service is that the function used to create a `RepositoryProvider` does not receive any arguments. If the creation of the `GitRepositoryProvider` were done via the service, this would be a problem because it needs a reference to `atom.project`, which is not defined when it is created. (We work around this because it is created in `Project`'s constructor, so it can pass `this` to `new GitRepositoryProvider()`.) We would have to create a `RepositoryProviderFactory` or something if we wanted to specify arguments when creating a `RepositoryProvider`, in general. Maybe that's too crazy / not an issue, in practice. Though note that `GitRepository` cannot access `atom.project` lazily because it uses it in its constructor to do the following: ``` if @project? @subscriptions.add @project.eachBuffer (buffer) => @subscribeToBuffer(buffer) ``` So long as we can guarantee that `atom.project` is defined before the other providers are initialized, I think we should be OK. Follow-up work: * Replace the use of `RepositoryProvider.createRepositorySync()` with `RepositoryProvider.repositoryForDirectory()` in `Project.setPaths()`. * Replace all uses of `Project.getRepositories()` with `Project.repositoryForDirectory()` in packages that are bundled with Atom by default. * Implement `Directory.exists()` and/or `Directory.existsSync()` and update `git-repositor-provider.coffee`, as appropriate. * Eliminate `GitRepositoryProvider.repositoryForDirectory()`'s use of synchronous methods. * Somewhat orthogonal to this diff, but the following fields need to be removed from `Project` because they enforce the idea of a single root: `path`, `rootDirectory`, and `repo`. This has implications around the existing use of `@rootDirectory?.off()` and `@destroyRepo()`. --- src/git-repository-provider.coffee | 80 ++++++++++++++++++++++++++++++ src/git-repository.coffee | 16 +++++- src/project.coffee | 60 ++++++++++++++++++++-- 3 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 src/git-repository-provider.coffee diff --git a/src/git-repository-provider.coffee b/src/git-repository-provider.coffee new file mode 100644 index 000000000..9c94b94a3 --- /dev/null +++ b/src/git-repository-provider.coffee @@ -0,0 +1,80 @@ +fs = require 'fs' +GitRepository = require './git-repository' + +# Checks whether the specified directory has, or has a parent directory that +# has, a valid .git folder, indicating the root of a Git repository. +# If found, a Directory that corresponds to the .git folder will be returned. +# Otherwise, returns `null`. +# +# * `directory` {Directory} to explore whether it is part of a Git repository. +findGitDirectorySync = (directory) -> + # TODO: Fix node-pathwatcher/src/directory.coffee so the following methods + # can return cached values rather than always returning new objects: + # getParent(), getFile(), getSubdirectory(). + gitDir = directory.getSubdirectory('.git') + if directoryExistsSync(gitDir) and isValidGitDirectorySync gitDir + gitDir + else if directory.isRoot() + return null + else + findGitDirectorySync directory.getParent() + +# Returns a boolean indicating whether the specified directory represents a Git +# repository. +# +# * `directory` {Directory} whose base name is `.git`. +isValidGitDirectorySync = (directory) -> + # To decide whether a directory has a valid .git folder, we use + # the heuristic adopted by the valid_repository_path() function defined in + # node_modules/git-utils/deps/libgit2/src/repository.c. + return directoryExistsSync(directory.getSubdirectory('objects')) and + directory.getFile('HEAD').exists() and + directoryExistsSync(directory.getSubdirectory('refs')) + +# Returns a boolean indicating whether the specified directory exists. +# +# * `directory` {Directory} to check for existence. +directoryExistsSync = (directory) -> + # TODO: Directory should have its own existsSync() method. Currently, File has + # an exists() method, which is synchronous, so it may be tricky to achieve + # consistency between the File and Directory APIs. Once Directory has its own + # method, this function should be replaced with direct calls to existsSync(). + return fs.existsSync(directory.getRealPathSync()) + +# Provider that conforms to the atom.repository-provider@0.1.0 service. +module.exports = +class GitRepositoryProvider + + constructor: (@project) -> + # Keys are real paths that end in `.git`. + # Values are the corresponding GitRepository objects. + @pathToRepository = {} + + # Returns a {Promise} that resolves with either: + # * {GitRepository} if the given directory has a Git repository. + # * `null` if the given directory does not have a Git repository. + repositoryForDirectory: (directory) -> + # TODO: Currently, this method is designed to be async, but it relies on a + # synchronous API. It should be rewritten to be truly async. + Promise.resolve(@createRepositorySync(directory)) + + # Returns either: + # * {GitRepository} if the given directory has a Git repository. + # * `null` if the given directory does not have a Git repository. + createRepositorySync: (directory) -> + # Only one GitRepository should be created for each .git folder. Therefore, + # we must check directory and its parent directories to find the nearest + # .git folder. + gitDir = findGitDirectorySync(directory) + unless gitDir + return null + + gitDirPath = gitDir.getRealPathSync() + repo = @pathToRepository[gitDirPath] + unless repo + repo = GitRepository.open(gitDirPath, project: @project) + repo.onDestroy(() => delete @pathToRepository[gitDirPath]) + @pathToRepository[gitDirPath] = repo + repo.refreshIndex() + repo.refreshStatus() + repo diff --git a/src/git-repository.coffee b/src/git-repository.coffee index b43782d6d..c3bb2cd1c 100644 --- a/src/git-repository.coffee +++ b/src/git-repository.coffee @@ -101,8 +101,13 @@ class GitRepository # Public: Destroy this {GitRepository} object. # # This destroys any tasks and subscriptions and releases the underlying - # libgit2 repository handle. + # libgit2 repository handle. This method is idempotent. destroy: -> + if @emitter? + @emitter.emit 'did-destroy' + @emitter.off() + @emitter = null + if @statusTask? @statusTask.terminate() @statusTask = null @@ -111,7 +116,14 @@ class GitRepository @repo.release() @repo = null - @subscriptions.dispose() + if @subscriptions? + @subscriptions.dispose() + @subscriptions = null + + # Public: Invoke the given callback when this GitRepository's destroy() method + # is invoked. + onDestroy: (callback) -> + @emitter.on 'did-destroy', callback ### Section: Event Subscription diff --git a/src/project.coffee b/src/project.coffee index 8cee16fa2..5b9660574 100644 --- a/src/project.coffee +++ b/src/project.coffee @@ -15,7 +15,7 @@ Grim = require 'grim' TextEditor = require './text-editor' Task = require './task' -GitRepository = require './git-repository' +GitRepositoryProvider = require './git-repository-provider' # Extended: Represents a project that's opened in Atom. # @@ -39,6 +39,20 @@ class Project extends Model @emitter = new Emitter @buffers ?= [] + # Mapping from the real path of a {Directory} to a {Promise} that resolves + # to either a {Repository} or null. Ideally, the {Directory} would be used + # as the key; however, there can be multiple {Directory} objects created for + # the same real path, so it is not a good key. + @directoryToRepositoryMap = new Map(); + + # Note that the GitRepositoryProvider is registered synchronously so that + # it is available immediately on startup. + @repositoryProviders = [new GitRepositoryProvider(this)] + atom.packages.serviceHub.consume( + 'atom.repository-provider', + '>=0.1.0', + (provider) => @repositoryProviders.push(provider)) + @subscribeToBuffer(buffer) for buffer in @buffers Grim.deprecate("Pass 'paths' array instead of 'path' to project constructor") if path? @@ -96,11 +110,45 @@ class Project extends Model # Public: Get an {Array} of {GitRepository}s associated with the project's # directories. + # + # This method will be removed in 2.0 because it does synchronous I/O. + # Prefer the following, which evaluates to a Promise that resolves to an + # {Array} of {Repository} objects: + # ``` + # project.getDirectories().map( + # project.repositoryForDirectory.bind(project)) + # ``` getRepositories: -> _.compact([@repo]) getRepo: -> Grim.deprecate("Use ::getRepositories instead") @repo + # Public: Returns a {Promise} that resolves with either: + # * {Repository} if a repository can be created for the given directory + # * `null` if no repository can be created for the given directory. + # + # * `directory` {Directory} for which to get a {Repository}. + repositoryForDirectory: (directory) -> + path = directory.getRealPathSync() + promise = @directoryToRepositoryMap.get(path) + unless promise + promises = @repositoryProviders.map( + (provider) -> provider.repositoryForDirectory directory) + promise = Promise.all(promises).then((repositories) => + # Find the first non-falsy value, if any. + repos = repositories.filter((repo) -> repo) + repo = repos[0] or null + + # If no repository is found, remove the entry in for the directory in + # @directoryToRepositoryMap in case some other RepositoryProvider is + # registered in the future that could supply a Repository for the + # directory. + if repo is null + @directoryToRepositoryMap.delete path + repo) + @directoryToRepositoryMap.set(path, promise) + promise + ### Section: Managing Paths ### @@ -126,9 +174,13 @@ class Project extends Model if projectPath? directory = if fs.isDirectorySync(projectPath) then projectPath else path.dirname(projectPath) @rootDirectory = new Directory(directory) - if @repo = GitRepository.open(directory, project: this) - @repo.refreshIndex() - @repo.refreshStatus() + + # For now, use only the repositoryProviders with a sync API. + for provider in @repositoryProviders + if provider.createRepositorySync + @repo = provider.createRepositorySync @rootDirectory + if @repo + break else @rootDirectory = null