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} 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 -- // git checkout HEAD -- // ``` // // * `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') } }