Files
atom/src/git-repository.js
2019-05-31 18:33:56 +02:00

622 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} 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');
}
};