Merge pull request #15750 from atom/mb-remove-status-handler-task

Avoid process spawn overhead for refreshing git status
This commit is contained in:
Max Brunsfeld
2017-09-27 15:05:29 -07:00
committed by GitHub
8 changed files with 713 additions and 633 deletions

View File

@@ -37,7 +37,7 @@
"fs-plus": "^3.0.1",
"fstream": "0.1.24",
"fuzzaldrin": "^2.1",
"git-utils": "5.0.0",
"git-utils": "5.1.0",
"glob": "^7.1.1",
"grim": "1.5.0",
"jasmine-json": "~0.0",
@@ -108,7 +108,7 @@
"encoding-selector": "0.23.6",
"exception-reporting": "0.41.4",
"find-and-replace": "0.212.3",
"fuzzy-finder": "1.6.0",
"fuzzy-finder": "1.6.1",
"github": "0.6.2",
"git-diff": "1.3.6",
"go-to-line": "0.32.1",

View File

@@ -39,7 +39,7 @@ module.exports = function (packagedAppPath) {
relativePath === path.join('..', 'node_modules', 'debug', 'node.js') ||
relativePath === path.join('..', 'node_modules', 'fs-extra', 'lib', 'index.js') ||
relativePath === path.join('..', 'node_modules', 'github', 'node_modules', 'fs-extra', 'lib', 'index.js') ||
relativePath === path.join('..', 'node_modules', 'git-utils', 'lib', 'git.js') ||
relativePath === path.join('..', 'node_modules', 'git-utils', 'src', 'git.js') ||
relativePath === path.join('..', 'node_modules', 'glob', 'glob.js') ||
relativePath === path.join('..', 'node_modules', 'graceful-fs', 'graceful-fs.js') ||
relativePath === path.join('..', 'node_modules', 'htmlparser2', 'lib', 'index.js') ||

View File

@@ -1,98 +0,0 @@
path = require 'path'
fs = require 'fs-plus'
temp = require('temp').track()
{Directory} = require 'pathwatcher'
GitRepository = require '../src/git-repository'
GitRepositoryProvider = require '../src/git-repository-provider'
describe "GitRepositoryProvider", ->
provider = null
beforeEach ->
provider = new GitRepositoryProvider(atom.project, atom.config, atom.confirm)
afterEach ->
try
temp.cleanupSync()
describe ".repositoryForDirectory(directory)", ->
describe "when specified a Directory with a Git repository", ->
it "returns a Promise that resolves to a GitRepository", ->
waitsForPromise ->
directory = new Directory path.join(__dirname, 'fixtures', 'git', 'master.git')
provider.repositoryForDirectory(directory).then (result) ->
expect(result).toBeInstanceOf GitRepository
expect(provider.pathToRepository[result.getPath()]).toBeTruthy()
expect(result.statusTask).toBeTruthy()
expect(result.getType()).toBe 'git'
it "returns the same GitRepository for different Directory objects in the same repo", ->
firstRepo = null
secondRepo = null
waitsForPromise ->
directory = new Directory path.join(__dirname, 'fixtures', 'git', 'master.git')
provider.repositoryForDirectory(directory).then (result) -> firstRepo = result
waitsForPromise ->
directory = new Directory path.join(__dirname, 'fixtures', 'git', 'master.git', 'objects')
provider.repositoryForDirectory(directory).then (result) -> secondRepo = result
runs ->
expect(firstRepo).toBeInstanceOf GitRepository
expect(firstRepo).toBe secondRepo
describe "when specified a Directory without a Git repository", ->
it "returns a Promise that resolves to null", ->
waitsForPromise ->
directory = new Directory temp.mkdirSync('dir')
provider.repositoryForDirectory(directory).then (result) ->
expect(result).toBe null
describe "when specified a Directory with an invalid Git repository", ->
it "returns a Promise that resolves to null", ->
waitsForPromise ->
dirPath = temp.mkdirSync('dir')
fs.writeFileSync(path.join(dirPath, '.git', 'objects'), '')
fs.writeFileSync(path.join(dirPath, '.git', 'HEAD'), '')
fs.writeFileSync(path.join(dirPath, '.git', 'refs'), '')
directory = new Directory dirPath
provider.repositoryForDirectory(directory).then (result) ->
expect(result).toBe null
describe "when specified a Directory with a valid gitfile-linked repository", ->
it "returns a Promise that resolves to a GitRepository", ->
waitsForPromise ->
gitDirPath = path.join(__dirname, 'fixtures', 'git', 'master.git')
workDirPath = temp.mkdirSync('git-workdir')
fs.writeFileSync(path.join(workDirPath, '.git'), 'gitdir: ' + gitDirPath+'\n')
directory = new Directory workDirPath
provider.repositoryForDirectory(directory).then (result) ->
expect(result).toBeInstanceOf GitRepository
expect(provider.pathToRepository[result.getPath()]).toBeTruthy()
expect(result.statusTask).toBeTruthy()
expect(result.getType()).toBe 'git'
describe "when specified a Directory without existsSync()", ->
directory = null
provider = null
beforeEach ->
# An implementation of Directory that does not implement existsSync().
subdirectory = {}
directory =
getSubdirectory: ->
isRoot: -> true
spyOn(directory, "getSubdirectory").andReturn(subdirectory)
it "returns null", ->
repo = provider.repositoryForDirectorySync(directory)
expect(repo).toBe null
expect(directory.getSubdirectory).toHaveBeenCalledWith(".git")
it "returns a Promise that resolves to null for the async implementation", ->
waitsForPromise ->
provider.repositoryForDirectory(directory).then (repo) ->
expect(repo).toBe null
expect(directory.getSubdirectory).toHaveBeenCalledWith(".git")

View File

@@ -0,0 +1,103 @@
const path = require('path')
const fs = require('fs-plus')
const temp = require('temp').track()
const {Directory} = require('pathwatcher')
const GitRepository = require('../src/git-repository')
const GitRepositoryProvider = require('../src/git-repository-provider')
const {it, fit, ffit, fffit, beforeEach} = require('./async-spec-helpers')
describe('GitRepositoryProvider', () => {
let provider
beforeEach(() => {
provider = new GitRepositoryProvider(atom.project, atom.config, atom.confirm)
})
describe('.repositoryForDirectory(directory)', () => {
describe('when specified a Directory with a Git repository', () => {
it('resolves with a GitRepository', async () => {
const directory = new Directory(path.join(__dirname, 'fixtures', 'git', 'master.git'))
const result = await provider.repositoryForDirectory(directory)
expect(result).toBeInstanceOf(GitRepository)
expect(provider.pathToRepository[result.getPath()]).toBeTruthy()
expect(result.getType()).toBe('git')
// Refresh should be started
await new Promise(resolve => result.onDidChangeStatuses(resolve))
})
it('resolves with the same GitRepository for different Directory objects in the same repo', async () => {
const firstRepo = await provider.repositoryForDirectory(
new Directory(path.join(__dirname, 'fixtures', 'git', 'master.git'))
)
const secondRepo = await provider.repositoryForDirectory(
new Directory(path.join(__dirname, 'fixtures', 'git', 'master.git', 'objects'))
)
expect(firstRepo).toBeInstanceOf(GitRepository)
expect(firstRepo).toBe(secondRepo)
})
})
describe('when specified a Directory without a Git repository', () => {
it('resolves with null', async () => {
const directory = new Directory(temp.mkdirSync('dir'))
const repo = await provider.repositoryForDirectory(directory)
expect(repo).toBe(null)
})
})
describe('when specified a Directory with an invalid Git repository', () => {
it('resolves with null', async () => {
const dirPath = temp.mkdirSync('dir')
fs.writeFileSync(path.join(dirPath, '.git', 'objects'), '')
fs.writeFileSync(path.join(dirPath, '.git', 'HEAD'), '')
fs.writeFileSync(path.join(dirPath, '.git', 'refs'), '')
const directory = new Directory(dirPath)
const repo = await provider.repositoryForDirectory(directory)
expect(repo).toBe(null)
})
})
describe('when specified a Directory with a valid gitfile-linked repository', () => {
it('returns a Promise that resolves to a GitRepository', async () => {
const gitDirPath = path.join(__dirname, 'fixtures', 'git', 'master.git')
const workDirPath = temp.mkdirSync('git-workdir')
fs.writeFileSync(path.join(workDirPath, '.git'), `gitdir: ${gitDirPath}\n`)
const directory = new Directory(workDirPath)
const result = await provider.repositoryForDirectory(directory)
expect(result).toBeInstanceOf(GitRepository)
expect(provider.pathToRepository[result.getPath()]).toBeTruthy()
expect(result.getType()).toBe('git')
})
})
describe('when specified a Directory without existsSync()', () => {
let directory
beforeEach(() => {
// An implementation of Directory that does not implement existsSync().
const subdirectory = {}
directory = {
getSubdirectory () {},
isRoot () { return true }
}
spyOn(directory, 'getSubdirectory').andReturn(subdirectory)
})
it('returns null', () => {
const repo = provider.repositoryForDirectorySync(directory)
expect(repo).toBe(null)
expect(directory.getSubdirectory).toHaveBeenCalledWith('.git')
})
it('returns a Promise that resolves to null for the async implementation', async () => {
const repo = await provider.repositoryForDirectory(directory)
expect(repo).toBe(null)
expect(directory.getSubdirectory).toHaveBeenCalledWith('.git')
})
})
})
})

View File

@@ -283,11 +283,15 @@ describe "GitRepository", ->
[editor] = []
beforeEach ->
statusRefreshed = false
atom.project.setPaths([copyRepository()])
atom.project.getRepositories()[0].onDidChangeStatuses -> statusRefreshed = true
waitsForPromise ->
atom.workspace.open('other.txt').then (o) -> editor = o
waitsFor 'repo to refresh', -> statusRefreshed
it "emits a status-changed event when a buffer is saved", ->
editor.insertNewline()

View File

@@ -1,496 +0,0 @@
{join} = require 'path'
_ = require 'underscore-plus'
{Emitter, Disposable, CompositeDisposable} = require 'event-kit'
fs = require 'fs-plus'
path = require 'path'
GitUtils = require 'git-utils'
Task = require './task'
# Extended: Represents the underlying git operations performed by Atom.
#
# This class shouldn't be instantiated directly but instead by accessing the
# `atom.project` global and calling `getRepositories()`. Note that this will
# only be available when the project is backed by a Git repository.
#
# This class handles submodules automatically by taking a `path` argument to many
# of the methods. This `path` argument will determine which underlying
# repository is used.
#
# For a repository with submodules this would have the following outcome:
#
# ```coffee
# repo = atom.project.getRepositories()[0]
# repo.getShortHead() # 'master'
# repo.getShortHead('vendor/path/to/a/submodule') # 'dead1234'
# ```
#
# ## Examples
#
# ### Logging the URL of the origin remote
#
# ```coffee
# git = atom.project.getRepositories()[0]
# console.log git.getOriginURL()
# ```
#
# ### Requiring in packages
#
# ```coffee
# {GitRepository} = require 'atom'
# ```
module.exports =
class GitRepository
@exists: (path) ->
if git = @open(path)
git.destroy()
true
else
false
###
Section: Construction and Destruction
###
# Public: Creates a new GitRepository instance.
#
# * `path` The {String} path to the Git repository to open.
# * `options` An optional {Object} with the following keys:
# * `refreshOnWindowFocus` A {Boolean}, `true` to refresh the index and
# statuses when the window is focused.
#
# Returns a {GitRepository} instance or `null` if the repository could not be opened.
@open: (path, options) ->
return null unless path
try
new GitRepository(path, options)
catch
null
constructor: (path, options={}) ->
@emitter = new Emitter
@subscriptions = new CompositeDisposable
@repo = GitUtils.open(path)
unless @repo?
throw new Error("No Git repository found searching path: #{path}")
@statuses = {}
@upstream = {ahead: 0, behind: 0}
for submodulePath, submoduleRepo of @repo.submodules
submoduleRepo.upstream = {ahead: 0, behind: 0}
{@project, @config, refreshOnWindowFocus} = options
refreshOnWindowFocus ?= true
if refreshOnWindowFocus
onWindowFocus = =>
@refreshIndex()
@refreshStatus()
window.addEventListener 'focus', onWindowFocus
@subscriptions.add new Disposable(-> window.removeEventListener 'focus', onWindowFocus)
if @project?
@project.getBuffers().forEach (buffer) => @subscribeToBuffer(buffer)
@subscriptions.add @project.onDidAddBuffer (buffer) => @subscribeToBuffer(buffer)
# Public: Destroy this {GitRepository} object.
#
# This destroys any tasks and subscriptions and releases the underlying
# libgit2 repository handle. This method is idempotent.
destroy: ->
if @emitter?
@emitter.emit 'did-destroy'
@emitter.dispose()
@emitter = null
if @statusTask?
@statusTask.terminate()
@statusTask = null
if @repo?
@repo.release()
@repo = null
if @subscriptions?
@subscriptions.dispose()
@subscriptions = null
# Public: Returns a {Boolean} indicating if this repository has been destroyed.
isDestroyed: ->
not @repo?
# Public: Invoke the given callback when this GitRepository's destroy() method
# is invoked.
#
# * `callback` {Function}
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidDestroy: (callback) ->
@emitter.once 'did-destroy', callback
###
Section: Event Subscription
###
# Public: Invoke the given callback when a specific file's status has
# changed. When a file is updated, reloaded, etc, and the status changes, this
# will be fired.
#
# * `callback` {Function}
# * `event` {Object}
# * `path` {String} the old parameters the decoration used to have
# * `pathStatus` {Number} representing the status. This value can be passed to
# {::isStatusModified} or {::isStatusNew} to get more information.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidChangeStatus: (callback) ->
@emitter.on 'did-change-status', callback
# Public: Invoke the given callback when a multiple files' statuses have
# changed. For example, on window focus, the status of all the paths in the
# repo is checked. If any of them have changed, this will be fired. Call
# {::getPathStatus(path)} to get the status for your path of choice.
#
# * `callback` {Function}
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidChangeStatuses: (callback) ->
@emitter.on 'did-change-statuses', callback
###
Section: Repository Details
###
# Public: A {String} indicating the type of version control system used by
# this repository.
#
# Returns `"git"`.
getType: -> 'git'
# Public: Returns the {String} path of the repository.
getPath: ->
@path ?= fs.absolute(@getRepo().getPath())
# Public: Returns the {String} working directory path of the repository.
getWorkingDirectory: -> @getRepo().getWorkingDirectory()
# Public: Returns true if at the root, false if in a subfolder of the
# repository.
isProjectAtRoot: ->
@projectAtRoot ?= @project?.relativize(@getWorkingDirectory()) is ''
# Public: Makes a path relative to the repository's working directory.
relativize: (path) -> @getRepo().relativize(path)
# Public: Returns true if the given branch exists.
hasBranch: (branch) -> @getReferenceTarget("refs/heads/#{branch}")?
# Public: Retrieves a shortened version of the HEAD reference value.
#
# This removes the leading segments of `refs/heads`, `refs/tags`, or
# `refs/remotes`. It also shortens the SHA-1 of a detached `HEAD` to 7
# characters.
#
# * `path` An optional {String} path in the repository to get this information
# for, only needed if the repository contains submodules.
#
# Returns a {String}.
getShortHead: (path) -> @getRepo(path).getShortHead()
# Public: Is the given path a submodule in the repository?
#
# * `path` The {String} path to check.
#
# Returns a {Boolean}.
isSubmodule: (path) ->
return false unless path
repo = @getRepo(path)
if repo.isSubmodule(repo.relativize(path))
true
else
# Check if the path is a working directory in a repo that isn't the root.
repo isnt @getRepo() and repo.relativize(join(path, 'dir')) is 'dir'
# Public: Returns the number of commits behind the current branch is from the
# its upstream remote branch.
#
# * `reference` The {String} branch reference name.
# * `path` The {String} path in the repository to get this information for,
# only needed if the repository contains submodules.
getAheadBehindCount: (reference, path) ->
@getRepo(path).getAheadBehindCount(reference)
# Public: Get the cached ahead/behind commit counts for the current branch's
# upstream branch.
#
# * `path` An optional {String} path in the repository to get this information
# for, only needed if the repository has submodules.
#
# Returns an {Object} with the following keys:
# * `ahead` The {Number} of commits ahead.
# * `behind` The {Number} of commits behind.
getCachedUpstreamAheadBehindCount: (path) ->
@getRepo(path).upstream ? @upstream
# Public: Returns the git configuration value specified by the key.
#
# * `key` The {String} key for the configuration to lookup.
# * `path` An optional {String} path in the repository to get this information
# for, only needed if the repository has submodules.
getConfigValue: (key, path) -> @getRepo(path).getConfigValue(key)
# Public: Returns the origin url of the repository.
#
# * `path` (optional) {String} path in the repository to get this information
# for, only needed if the repository has submodules.
getOriginURL: (path) -> @getConfigValue('remote.origin.url', path)
# Public: Returns the upstream branch for the current HEAD, or null if there
# is no upstream branch for the current HEAD.
#
# * `path` An optional {String} path in the repo to get this information for,
# only needed if the repository contains submodules.
#
# Returns a {String} branch name such as `refs/remotes/origin/master`.
getUpstreamBranch: (path) -> @getRepo(path).getUpstreamBranch()
# Public: Gets all the local and remote references.
#
# * `path` An optional {String} path in the repository to get this information
# for, only needed if the repository has submodules.
#
# Returns an {Object} with the following keys:
# * `heads` An {Array} of head reference names.
# * `remotes` An {Array} of remote reference names.
# * `tags` An {Array} of tag reference names.
getReferences: (path) -> @getRepo(path).getReferences()
# Public: Returns the current {String} SHA for the given reference.
#
# * `reference` The {String} reference to get the target of.
# * `path` An optional {String} path in the repo to get the reference target
# for. Only needed if the repository contains submodules.
getReferenceTarget: (reference, path) ->
@getRepo(path).getReferenceTarget(reference)
###
Section: Reading Status
###
# Public: Returns true if the given path is modified.
#
# * `path` The {String} path to check.
#
# Returns a {Boolean} that's true if the `path` is modified.
isPathModified: (path) -> @isStatusModified(@getPathStatus(path))
# Public: Returns true if the given path is new.
#
# * `path` The {String} path to check.
#
# Returns a {Boolean} that's true if the `path` is new.
isPathNew: (path) -> @isStatusNew(@getPathStatus(path))
# Public: Is the given path ignored?
#
# * `path` The {String} path to check.
#
# Returns a {Boolean} that's true if the `path` is ignored.
isPathIgnored: (path) -> @getRepo().isIgnored(@relativize(path))
# Public: Get the status of a directory in the repository's working directory.
#
# * `path` The {String} path to check.
#
# Returns a {Number} representing the status. This value can be passed to
# {::isStatusModified} or {::isStatusNew} to get more information.
getDirectoryStatus: (directoryPath) ->
directoryPath = "#{@relativize(directoryPath)}/"
directoryStatus = 0
for statusPath, status of @statuses
directoryStatus |= status if statusPath.indexOf(directoryPath) is 0
directoryStatus
# Public: Get the status of a single path in the repository.
#
# * `path` A {String} repository-relative path.
#
# Returns a {Number} representing the status. This value can be passed to
# {::isStatusModified} or {::isStatusNew} to get more information.
getPathStatus: (path) ->
repo = @getRepo(path)
relativePath = @relativize(path)
currentPathStatus = @statuses[relativePath] ? 0
pathStatus = repo.getStatus(repo.relativize(path)) ? 0
pathStatus = 0 if repo.isStatusIgnored(pathStatus)
if pathStatus > 0
@statuses[relativePath] = pathStatus
else
delete @statuses[relativePath]
if currentPathStatus isnt pathStatus
@emitter.emit 'did-change-status', {path, pathStatus}
pathStatus
# Public: Get the cached status for the given path.
#
# * `path` A {String} path in the repository, relative or absolute.
#
# Returns a status {Number} or null if the path is not in the cache.
getCachedPathStatus: (path) ->
@statuses[@relativize(path)]
# Public: Returns true if the given status indicates modification.
#
# * `status` A {Number} representing the status.
#
# Returns a {Boolean} that's true if the `status` indicates modification.
isStatusModified: (status) -> @getRepo().isStatusModified(status)
# Public: Returns true if the given status indicates a new path.
#
# * `status` A {Number} representing the status.
#
# Returns a {Boolean} that's true if the `status` indicates a new path.
isStatusNew: (status) -> @getRepo().isStatusNew(status)
###
Section: Retrieving Diffs
###
# Public: Retrieves the number of lines added and removed to a path.
#
# This compares the working directory contents of the path to the `HEAD`
# version.
#
# * `path` The {String} path to check.
#
# Returns an {Object} with the following keys:
# * `added` The {Number} of added lines.
# * `deleted` The {Number} of deleted lines.
getDiffStats: (path) ->
repo = @getRepo(path)
repo.getDiffStats(repo.relativize(path))
# Public: Retrieves the line diffs comparing the `HEAD` version of the given
# path and the given text.
#
# * `path` The {String} path relative to the repository.
# * `text` The {String} to compare against the `HEAD` contents
#
# Returns an {Array} of hunk {Object}s with the following keys:
# * `oldStart` The line {Number} of the old hunk.
# * `newStart` The line {Number} of the new hunk.
# * `oldLines` The {Number} of lines in the old hunk.
# * `newLines` The {Number} of lines in the new hunk
getLineDiffs: (path, text) ->
# Ignore eol of line differences on windows so that files checked in as
# LF don't report every line modified when the text contains CRLF endings.
options = ignoreEolWhitespace: process.platform is 'win32'
repo = @getRepo(path)
repo.getLineDiffs(repo.relativize(path), text, options)
###
Section: Checking Out
###
# Public: Restore the contents of a path in the working directory and index
# to the version at `HEAD`.
#
# This is essentially the same as running:
#
# ```sh
# git reset HEAD -- <path>
# git checkout HEAD -- <path>
# ```
#
# * `path` The {String} path to checkout.
#
# Returns a {Boolean} that's true if the method was successful.
checkoutHead: (path) ->
repo = @getRepo(path)
headCheckedOut = repo.checkoutHead(repo.relativize(path))
@getPathStatus(path) if headCheckedOut
headCheckedOut
# Public: Checks out a branch in your repository.
#
# * `reference` The {String} reference to checkout.
# * `create` A {Boolean} value which, if true creates the new reference if
# it doesn't exist.
#
# Returns a Boolean that's true if the method was successful.
checkoutReference: (reference, create) ->
@getRepo().checkoutReference(reference, create)
###
Section: Private
###
# Subscribes to buffer events.
subscribeToBuffer: (buffer) ->
getBufferPathStatus = =>
if bufferPath = buffer.getPath()
@getPathStatus(bufferPath)
getBufferPathStatus()
bufferSubscriptions = new CompositeDisposable
bufferSubscriptions.add buffer.onDidSave(getBufferPathStatus)
bufferSubscriptions.add buffer.onDidReload(getBufferPathStatus)
bufferSubscriptions.add buffer.onDidChangePath(getBufferPathStatus)
bufferSubscriptions.add buffer.onDidDestroy =>
bufferSubscriptions.dispose()
@subscriptions.remove(bufferSubscriptions)
@subscriptions.add(bufferSubscriptions)
return
# Subscribes to editor view event.
checkoutHeadForEditor: (editor) ->
buffer = editor.getBuffer()
if filePath = buffer.getPath()
@checkoutHead(filePath)
buffer.reload()
# Returns the corresponding {Repository}
getRepo: (path) ->
if @repo?
@repo.submoduleForPath(path) ? @repo
else
throw new Error("Repository has been destroyed")
# Reread the index to update any values that have changed since the
# last time the index was read.
refreshIndex: -> @getRepo().refreshIndex()
# Refreshes the current git status in an outside process and asynchronously
# updates the relevant properties.
refreshStatus: ->
@handlerPath ?= require.resolve('./repository-status-handler')
relativeProjectPaths = @project?.getPaths()
.map (projectPath) => @relativize(projectPath)
.filter (projectPath) -> projectPath.length > 0 and not path.isAbsolute(projectPath)
@statusTask?.terminate()
new Promise (resolve) =>
@statusTask = Task.once @handlerPath, @getPath(), relativeProjectPaths, ({statuses, upstream, branch, submodules}) =>
statusesUnchanged = _.isEqual(statuses, @statuses) and
_.isEqual(upstream, @upstream) and
_.isEqual(branch, @branch) and
_.isEqual(submodules, @submodules)
@statuses = statuses
@upstream = upstream
@branch = branch
@submodules = submodules
for submodulePath, submoduleRepo of @getRepo().submodules
submoduleRepo.upstream = submodules[submodulePath]?.upstream ? {ahead: 0, behind: 0}
unless statusesUnchanged
@emitter.emit 'did-change-statuses'
resolve()

603
src/git-repository.js Normal file
View File

@@ -0,0 +1,603 @@
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS104: Avoid inline assignments
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const {join} = require('path')
const _ = require('underscore-plus')
const {Emitter, Disposable, CompositeDisposable} = require('event-kit')
const fs = require('fs-plus')
const path = require('path')
const GitUtils = require('git-utils')
let nextId = 0
// Extended: Represents the underlying git operations performed by Atom.
//
// This class shouldn't be instantiated directly but instead by accessing the
// `atom.project` global and calling `getRepositories()`. Note that this will
// only be available when the project is backed by a Git repository.
//
// This class handles submodules automatically by taking a `path` argument to many
// of the methods. This `path` argument will determine which underlying
// repository is used.
//
// For a repository with submodules this would have the following outcome:
//
// ```coffee
// repo = atom.project.getRepositories()[0]
// repo.getShortHead() # 'master'
// repo.getShortHead('vendor/path/to/a/submodule') # 'dead1234'
// ```
//
// ## Examples
//
// ### Logging the URL of the origin remote
//
// ```coffee
// git = atom.project.getRepositories()[0]
// console.log git.getOriginURL()
// ```
//
// ### Requiring in packages
//
// ```coffee
// {GitRepository} = require 'atom'
// ```
module.exports =
class GitRepository {
static exists (path) {
const git = this.open(path)
if (git) {
git.destroy()
return true
} else {
return false
}
}
/*
Section: Construction and Destruction
*/
// Public: Creates a new GitRepository instance.
//
// * `path` The {String} path to the Git repository to open.
// * `options` An optional {Object} with the following keys:
// * `refreshOnWindowFocus` A {Boolean}, `true` to refresh the index and
// statuses when the window is focused.
//
// Returns a {GitRepository} instance or `null` if the repository could not be opened.
static open (path, options) {
if (!path) { return null }
try {
return new GitRepository(path, options)
} catch (error) {
return null
}
}
constructor (path, options = {}) {
this.id = nextId++
this.emitter = new Emitter()
this.subscriptions = new CompositeDisposable()
this.repo = GitUtils.open(path)
if (this.repo == null) {
throw new Error(`No Git repository found searching path: ${path}`)
}
this.statusRefreshCount = 0
this.statuses = {}
this.upstream = {ahead: 0, behind: 0}
for (let submodulePath in this.repo.submodules) {
const submoduleRepo = this.repo.submodules[submodulePath]
submoduleRepo.upstream = {ahead: 0, behind: 0}
}
this.project = options.project
this.config = options.config
if (options.refreshOnWindowFocus || options.refreshOnWindowFocus == null) {
const onWindowFocus = () => {
this.refreshIndex()
this.refreshStatus()
}
window.addEventListener('focus', onWindowFocus)
this.subscriptions.add(new Disposable(() => window.removeEventListener('focus', onWindowFocus)))
}
if (this.project != null) {
this.project.getBuffers().forEach(buffer => this.subscribeToBuffer(buffer))
this.subscriptions.add(this.project.onDidAddBuffer(buffer => this.subscribeToBuffer(buffer)))
}
}
// Public: Destroy this {GitRepository} object.
//
// This destroys any tasks and subscriptions and releases the underlying
// libgit2 repository handle. This method is idempotent.
destroy () {
this.repo = null
if (this.emitter) {
this.emitter.emit('did-destroy')
this.emitter.dispose()
this.emitter = null
}
if (this.subscriptions) {
this.subscriptions.dispose()
this.subscriptions = null
}
}
// Public: Returns a {Boolean} indicating if this repository has been destroyed.
isDestroyed () {
return this.repo == null
}
// Public: Invoke the given callback when this GitRepository's destroy() method
// is invoked.
//
// * `callback` {Function}
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidDestroy (callback) {
return this.emitter.once('did-destroy', callback)
}
/*
Section: Event Subscription
*/
// Public: Invoke the given callback when a specific file's status has
// changed. When a file is updated, reloaded, etc, and the status changes, this
// will be fired.
//
// * `callback` {Function}
// * `event` {Object}
// * `path` {String} the old parameters the decoration used to have
// * `pathStatus` {Number} representing the status. This value can be passed to
// {::isStatusModified} or {::isStatusNew} to get more information.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidChangeStatus (callback) {
return this.emitter.on('did-change-status', callback)
}
// Public: Invoke the given callback when a multiple files' statuses have
// changed. For example, on window focus, the status of all the paths in the
// repo is checked. If any of them have changed, this will be fired. Call
// {::getPathStatus(path)} to get the status for your path of choice.
//
// * `callback` {Function}
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidChangeStatuses (callback) {
return this.emitter.on('did-change-statuses', callback)
}
/*
Section: Repository Details
*/
// Public: A {String} indicating the type of version control system used by
// this repository.
//
// Returns `"git"`.
getType () { return 'git' }
// Public: Returns the {String} path of the repository.
getPath () {
if (this.path == null) {
this.path = fs.absolute(this.getRepo().getPath())
}
return this.path
}
// Public: Returns the {String} working directory path of the repository.
getWorkingDirectory () {
return this.getRepo().getWorkingDirectory()
}
// Public: Returns true if at the root, false if in a subfolder of the
// repository.
isProjectAtRoot () {
if (this.projectAtRoot == null) {
this.projectAtRoot = this.project && this.project.relativize(this.getWorkingDirectory()) === ''
}
return this.projectAtRoot
}
// Public: Makes a path relative to the repository's working directory.
relativize (path) {
return this.getRepo().relativize(path)
}
// Public: Returns true if the given branch exists.
hasBranch (branch) {
return this.getReferenceTarget(`refs/heads/${branch}`) != null
}
// Public: Retrieves a shortened version of the HEAD reference value.
//
// This removes the leading segments of `refs/heads`, `refs/tags`, or
// `refs/remotes`. It also shortens the SHA-1 of a detached `HEAD` to 7
// characters.
//
// * `path` An optional {String} path in the repository to get this information
// for, only needed if the repository contains submodules.
//
// Returns a {String}.
getShortHead (path) {
return this.getRepo(path).getShortHead()
}
// Public: Is the given path a submodule in the repository?
//
// * `path` The {String} path to check.
//
// Returns a {Boolean}.
isSubmodule (path) {
if (!path) return false
const repo = this.getRepo(path)
if (repo.isSubmodule(repo.relativize(path))) {
return true
} else {
// Check if the path is a working directory in a repo that isn't the root.
return repo !== this.getRepo() && repo.relativize(join(path, 'dir')) === 'dir'
}
}
// Public: Returns the number of commits behind the current branch is from the
// its upstream remote branch.
//
// * `reference` The {String} branch reference name.
// * `path` The {String} path in the repository to get this information for,
// only needed if the repository contains submodules.
getAheadBehindCount (reference, path) {
return this.getRepo(path).getAheadBehindCount(reference)
}
// Public: Get the cached ahead/behind commit counts for the current branch's
// upstream branch.
//
// * `path` An optional {String} path in the repository to get this information
// for, only needed if the repository has submodules.
//
// Returns an {Object} with the following keys:
// * `ahead` The {Number} of commits ahead.
// * `behind` The {Number} of commits behind.
getCachedUpstreamAheadBehindCount (path) {
return this.getRepo(path).upstream || this.upstream
}
// Public: Returns the git configuration value specified by the key.
//
// * `key` The {String} key for the configuration to lookup.
// * `path` An optional {String} path in the repository to get this information
// for, only needed if the repository has submodules.
getConfigValue (key, path) {
return this.getRepo(path).getConfigValue(key)
}
// Public: Returns the origin url of the repository.
//
// * `path` (optional) {String} path in the repository to get this information
// for, only needed if the repository has submodules.
getOriginURL (path) {
return this.getConfigValue('remote.origin.url', path)
}
// Public: Returns the upstream branch for the current HEAD, or null if there
// is no upstream branch for the current HEAD.
//
// * `path` An optional {String} path in the repo to get this information for,
// only needed if the repository contains submodules.
//
// Returns a {String} branch name such as `refs/remotes/origin/master`.
getUpstreamBranch (path) {
return this.getRepo(path).getUpstreamBranch()
}
// Public: Gets all the local and remote references.
//
// * `path` An optional {String} path in the repository to get this information
// for, only needed if the repository has submodules.
//
// Returns an {Object} with the following keys:
// * `heads` An {Array} of head reference names.
// * `remotes` An {Array} of remote reference names.
// * `tags` An {Array} of tag reference names.
getReferences (path) {
return this.getRepo(path).getReferences()
}
// Public: Returns the current {String} SHA for the given reference.
//
// * `reference` The {String} reference to get the target of.
// * `path` An optional {String} path in the repo to get the reference target
// for. Only needed if the repository contains submodules.
getReferenceTarget (reference, path) {
return this.getRepo(path).getReferenceTarget(reference)
}
/*
Section: Reading Status
*/
// Public: Returns true if the given path is modified.
//
// * `path` The {String} path to check.
//
// Returns a {Boolean} that's true if the `path` is modified.
isPathModified (path) {
return this.isStatusModified(this.getPathStatus(path))
}
// Public: Returns true if the given path is new.
//
// * `path` The {String} path to check.
//
// Returns a {Boolean} that's true if the `path` is new.
isPathNew (path) {
return this.isStatusNew(this.getPathStatus(path))
}
// Public: Is the given path ignored?
//
// * `path` The {String} path to check.
//
// Returns a {Boolean} that's true if the `path` is ignored.
isPathIgnored (path) {
return this.getRepo().isIgnored(this.relativize(path))
}
// Public: Get the status of a directory in the repository's working directory.
//
// * `path` The {String} path to check.
//
// Returns a {Number} representing the status. This value can be passed to
// {::isStatusModified} or {::isStatusNew} to get more information.
getDirectoryStatus (directoryPath) {
directoryPath = `${this.relativize(directoryPath)}/`
let directoryStatus = 0
for (let statusPath in this.statuses) {
const status = this.statuses[statusPath]
if (statusPath.startsWith(directoryPath)) directoryStatus |= status
}
return directoryStatus
}
// Public: Get the status of a single path in the repository.
//
// * `path` A {String} repository-relative path.
//
// Returns a {Number} representing the status. This value can be passed to
// {::isStatusModified} or {::isStatusNew} to get more information.
getPathStatus (path) {
const repo = this.getRepo(path)
const relativePath = this.relativize(path)
const currentPathStatus = this.statuses[relativePath] || 0
let pathStatus = repo.getStatus(repo.relativize(path)) || 0
if (repo.isStatusIgnored(pathStatus)) pathStatus = 0
if (pathStatus > 0) {
this.statuses[relativePath] = pathStatus
} else {
delete this.statuses[relativePath]
}
if (currentPathStatus !== pathStatus) {
this.emitter.emit('did-change-status', {path, pathStatus})
}
return pathStatus
}
// Public: Get the cached status for the given path.
//
// * `path` A {String} path in the repository, relative or absolute.
//
// Returns a status {Number} or null if the path is not in the cache.
getCachedPathStatus (path) {
return this.statuses[this.relativize(path)]
}
// Public: Returns true if the given status indicates modification.
//
// * `status` A {Number} representing the status.
//
// Returns a {Boolean} that's true if the `status` indicates modification.
isStatusModified (status) { return this.getRepo().isStatusModified(status) }
// Public: Returns true if the given status indicates a new path.
//
// * `status` A {Number} representing the status.
//
// Returns a {Boolean} that's true if the `status` indicates a new path.
isStatusNew (status) {
return this.getRepo().isStatusNew(status)
}
/*
Section: Retrieving Diffs
*/
// Public: Retrieves the number of lines added and removed to a path.
//
// This compares the working directory contents of the path to the `HEAD`
// version.
//
// * `path` The {String} path to check.
//
// Returns an {Object} with the following keys:
// * `added` The {Number} of added lines.
// * `deleted` The {Number} of deleted lines.
getDiffStats (path) {
const repo = this.getRepo(path)
return repo.getDiffStats(repo.relativize(path))
}
// Public: Retrieves the line diffs comparing the `HEAD` version of the given
// path and the given text.
//
// * `path` The {String} path relative to the repository.
// * `text` The {String} to compare against the `HEAD` contents
//
// Returns an {Array} of hunk {Object}s with the following keys:
// * `oldStart` The line {Number} of the old hunk.
// * `newStart` The line {Number} of the new hunk.
// * `oldLines` The {Number} of lines in the old hunk.
// * `newLines` The {Number} of lines in the new hunk
getLineDiffs (path, text) {
// Ignore eol of line differences on windows so that files checked in as
// LF don't report every line modified when the text contains CRLF endings.
const options = {ignoreEolWhitespace: process.platform === 'win32'}
const repo = this.getRepo(path)
return repo.getLineDiffs(repo.relativize(path), text, options)
}
/*
Section: Checking Out
*/
// Public: Restore the contents of a path in the working directory and index
// to the version at `HEAD`.
//
// This is essentially the same as running:
//
// ```sh
// git reset HEAD -- <path>
// git checkout HEAD -- <path>
// ```
//
// * `path` The {String} path to checkout.
//
// Returns a {Boolean} that's true if the method was successful.
checkoutHead (path) {
const repo = this.getRepo(path)
const headCheckedOut = repo.checkoutHead(repo.relativize(path))
if (headCheckedOut) this.getPathStatus(path)
return headCheckedOut
}
// Public: Checks out a branch in your repository.
//
// * `reference` The {String} reference to checkout.
// * `create` A {Boolean} value which, if true creates the new reference if
// it doesn't exist.
//
// Returns a Boolean that's true if the method was successful.
checkoutReference (reference, create) {
return this.getRepo().checkoutReference(reference, create)
}
/*
Section: Private
*/
// Subscribes to buffer events.
subscribeToBuffer (buffer) {
const getBufferPathStatus = () => {
const bufferPath = buffer.getPath()
if (bufferPath) this.getPathStatus(bufferPath)
}
getBufferPathStatus()
const bufferSubscriptions = new CompositeDisposable()
bufferSubscriptions.add(buffer.onDidSave(getBufferPathStatus))
bufferSubscriptions.add(buffer.onDidReload(getBufferPathStatus))
bufferSubscriptions.add(buffer.onDidChangePath(getBufferPathStatus))
bufferSubscriptions.add(buffer.onDidDestroy(() => {
bufferSubscriptions.dispose()
return this.subscriptions.remove(bufferSubscriptions)
}))
this.subscriptions.add(bufferSubscriptions)
}
// Subscribes to editor view event.
checkoutHeadForEditor (editor) {
const buffer = editor.getBuffer()
const bufferPath = buffer.getPath()
if (bufferPath) {
this.checkoutHead(bufferPath)
return buffer.reload()
}
}
// Returns the corresponding {Repository}
getRepo (path) {
if (this.repo) {
return this.repo.submoduleForPath(path) || this.repo
} else {
throw new Error('Repository has been destroyed')
}
}
// Reread the index to update any values that have changed since the
// last time the index was read.
refreshIndex () {
return this.getRepo().refreshIndex()
}
// Refreshes the current git status in an outside process and asynchronously
// updates the relevant properties.
async refreshStatus () {
const statusRefreshCount = ++this.statusRefreshCount
const repo = this.getRepo()
const relativeProjectPaths = this.project && this.project.getPaths()
.map(projectPath => this.relativize(projectPath))
.filter(projectPath => (projectPath.length > 0) && !path.isAbsolute(projectPath))
const branch = await repo.getHeadAsync()
const upstream = await repo.getAheadBehindCountAsync()
const statuses = {}
const repoStatus = relativeProjectPaths.length > 0
? await repo.getStatusAsync(relativeProjectPaths)
: await repo.getStatusAsync()
for (let filePath in repoStatus) {
statuses[filePath] = repoStatus[filePath]
}
const submodules = {}
for (let submodulePath in repo.submodules) {
const submoduleRepo = repo.submodules[submodulePath]
submodules[submodulePath] = {
branch: await submoduleRepo.getHeadAsync(),
upstream: await submoduleRepo.getAheadBehindCountAsync()
}
const workingDirectoryPath = submoduleRepo.getWorkingDirectory()
const submoduleStatus = await submoduleRepo.getStatusAsync()
for (let filePath in submoduleStatus) {
const absolutePath = path.join(workingDirectoryPath, filePath)
const relativizePath = repo.relativize(absolutePath)
statuses[relativizePath] = submoduleStatus[filePath]
}
}
if (this.statusRefreshCount !== statusRefreshCount || this.isDestroyed()) return
const statusesUnchanged =
_.isEqual(branch, this.branch) &&
_.isEqual(statuses, this.statuses) &&
_.isEqual(upstream, this.upstream) &&
_.isEqual(submodules, this.submodules)
this.branch = branch
this.statuses = statuses
this.upstream = upstream
this.submodules = submodules
for (let submodulePath in repo.submodules) {
repo.submodules[submodulePath].upstream = submodules[submodulePath].upstream
}
if (!statusesUnchanged) this.emitter.emit('did-change-statuses')
}
}

View File

@@ -1,36 +0,0 @@
Git = require 'git-utils'
path = require 'path'
module.exports = (repoPath, paths = []) ->
repo = Git.open(repoPath)
upstream = {}
statuses = {}
submodules = {}
branch = null
if repo?
# Statuses in main repo
workingDirectoryPath = repo.getWorkingDirectory()
repoStatus = (if paths.length > 0 then repo.getStatusForPaths(paths) else repo.getStatus())
for filePath, status of repoStatus
statuses[filePath] = status
# Statuses in submodules
for submodulePath, submoduleRepo of repo.submodules
submodules[submodulePath] =
branch: submoduleRepo.getHead()
upstream: submoduleRepo.getAheadBehindCount()
workingDirectoryPath = submoduleRepo.getWorkingDirectory()
for filePath, status of submoduleRepo.getStatus()
absolutePath = path.join(workingDirectoryPath, filePath)
# Make path relative to parent repository
relativePath = repo.relativize(absolutePath)
statuses[relativePath] = status
upstream = repo.getAheadBehindCount()
branch = repo.getHead()
repo.release()
{statuses, upstream, branch, submodules}