Files
atom/src/git-repository.js
2017-11-06 09:45:43 -08:00

596 lines
19 KiB
JavaScript

const path = require('path')
const fs = require('fs-plus')
const _ = require('underscore-plus')
const {Emitter, Disposable, CompositeDisposable} = require('event-kit')
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 (filePath) {
if (!filePath) return false
const repo = this.getRepo(filePath)
if (repo.isSubmodule(repo.relativize(filePath))) {
return true
} else {
// Check if the filePath is a working directory in a repo that isn't the root.
return repo !== this.getRepo() && repo.relativize(path.join(filePath, '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')
}
}