@jupyterlab/git
Version:
A JupyterLab extension for version control using git
1,343 lines • 72.5 kB
JavaScript
import { PathExt, URLExt } from '@jupyterlab/coreutils';
import { DocumentRegistry } from '@jupyterlab/docregistry';
import { JSONExt } from '@lumino/coreutils';
import { Poll } from '@lumino/polling';
import { Signal } from '@lumino/signaling';
import { AUTH_ERROR_MESSAGES, requestAPI } from './git';
import { TaskHandler } from './taskhandler';
import { Git } from './tokens';
import { decodeStage } from './utils';
// Default refresh interval (in milliseconds) for polling the current Git status (NOTE: this value should be the same value as in the plugin settings schema):
const DEFAULT_REFRESH_INTERVAL = 3000; // ms
// Available diff providers
const DIFF_PROVIDERS = {};
/**
* Get the diff provider for a filename
* @param filename Filename to look for
* @returns The diff provider callback or undefined
*/
export function getDiffProvider(filename) {
var _a, _b, _c;
return (_c = DIFF_PROVIDERS[(_b = (_a = PathExt.extname(filename)) === null || _a === void 0 ? void 0 : _a.toLocaleLowerCase()) !== null && _b !== void 0 ? _b : '']) === null || _c === void 0 ? void 0 : _c.factory;
}
/**
* Class for creating a model for retrieving info from, and interacting with, a remote Git repository.
*/
export class GitExtension {
/**
* Returns an extension model.
*
* @param app - frontend application
* @param settings - plugin settings
* @returns extension model
*/
constructor(docmanager = null, docRegistry = null, settings) {
/**
* Fetch poll action.
* This is blocked if Git credentials are required.
*/
this._fetchRemotes = async () => {
if (this.credentialsRequired) {
return;
}
try {
await this.fetch();
}
catch (error) {
console.error('Failed to fetch remotes', error);
if (AUTH_ERROR_MESSAGES.some(errorMessage => error.message.indexOf(errorMessage) > -1)) {
this.credentialsRequired = true;
}
}
};
/**
* Refresh model status through a Poll
*/
this._refreshModel = async () => {
await this._taskHandler.execute('git:refresh', async () => {
try {
await this.refreshBranch();
await this.refreshTag();
await this.refreshStatus();
await this.refreshStash();
await this.checkRemoteChangeNotified();
}
catch (error) {
console.error('Failed to refresh git status', error);
}
});
};
/**
* Standby test function for the refresh Poll
*
* Standby refresh if
* - webpage is hidden
* - not in a git repository
* - standby condition is true
*
* @returns The test function
*/
this._refreshStandby = () => {
if (this.pathRepository === null || this._standbyCondition()) {
return true;
}
return 'when-hidden';
};
this._status = {
branch: null,
remote: null,
ahead: 0,
behind: 0,
state: Git.State.DEFAULT,
files: []
};
this._stash = [];
this._pathRepository = null;
this._branches = [];
this._tagsList = [];
this._currentBranch = null;
this._isDisposed = false;
this._markerCache = new Markers(() => this._markChanged.emit());
this.__currentMarker = new BranchMarker(() => { });
this._readyPromise = Promise.resolve();
this._pendingReadyPromise = 0;
this._standbyCondition = () => false;
this._remoteChangedFiles = [];
this._changeUpstreamNotified = [];
this._selectedHistoryFile = null;
this._hasDirtyFiles = false;
this._credentialsRequired = false;
this._lastAuthor = null;
this._submodules = [];
// Configurable
this._statusForDirtyState = ['staged', 'partially-staged'];
this._branchesChanged = new Signal(this);
this._tagsChanged = new Signal(this);
this._headChanged = new Signal(this);
this._markChanged = new Signal(this);
this._selectedHistoryFileChanged = new Signal(this);
this._repositoryChanged = new Signal(this);
this._stashChanged = new Signal(this);
this._statusChanged = new Signal(this);
this._remoteChanged = new Signal(this);
this._dirtyFilesStatusChanged = new Signal(this);
this._credentialsRequiredChanged = new Signal(this);
this._docmanager = docmanager;
this._docRegistry = docRegistry;
this._settings = settings || null;
this._taskHandler = new TaskHandler(this);
// Initialize repository status
this._clearStatus();
const interval = DEFAULT_REFRESH_INTERVAL;
this._statusPoll = new Poll({
factory: this._refreshModel,
frequency: {
interval,
backoff: true,
max: 300 * 1000
},
standby: this._refreshStandby
});
this._fetchPoll = new Poll({
auto: false,
factory: this._fetchRemotes,
frequency: {
interval,
backoff: true,
max: 300 * 1000
},
standby: this._refreshStandby
});
if (settings) {
settings.changed.connect(this._onSettingsChange, this);
this._onSettingsChange(settings);
}
}
/**
* Branch list for the current repository.
*/
get branches() {
return this._branches;
}
/**
* Submodule list for the current repository.
*/
get submodules() {
return this._submodules;
}
/**
* Tags list for the current repository.
*/
get tagsList() {
return this._tagsList;
}
/**
* The current repository branch.
*/
get currentBranch() {
return this._currentBranch;
}
/**
* Boolean indicating whether the model has been disposed.
*/
get isDisposed() {
return this._isDisposed;
}
/**
* Boolean indicating whether the model is ready.
*/
get isReady() {
return this._pendingReadyPromise === 0;
}
/**
* Promise which fulfills when the model is ready.
*/
get ready() {
return this._readyPromise;
}
/**
* Git repository path.
*
* ## Notes
*
* - This is the full path of the top-level folder.
* - The return value is `null` if a repository path is not defined.
*/
get pathRepository() {
return this._pathRepository;
}
set pathRepository(v) {
const change = {
name: 'pathRepository',
newValue: null,
oldValue: this._pathRepository
};
if (v === null) {
this._pendingReadyPromise += 1;
this._readyPromise.then(() => {
this._pathRepository = null;
this._pendingReadyPromise -= 1;
if (change.newValue !== change.oldValue) {
this.refresh().then(() => this._repositoryChanged.emit(change));
}
});
}
else {
const currentReady = this._readyPromise;
this._pendingReadyPromise += 1;
const currentFolder = v;
this._readyPromise = Promise.all([
currentReady,
this.showPrefix(currentFolder)
])
.then(([_, path]) => {
if (path !== null) {
// Remove relative path to get the Git repository root path
path = currentFolder.slice(0, Math.max(0, currentFolder.length - path.length));
}
change.newValue = this._pathRepository = path;
if (change.newValue !== change.oldValue) {
this.refresh().then(() => this._repositoryChanged.emit(change));
}
this._pendingReadyPromise -= 1;
})
.catch(reason => {
this._pendingReadyPromise -= 1;
console.error(`Fail to find Git top level for path ${currentFolder}.\n${reason}`);
});
}
}
/**
* Custom model refresh standby condition
*/
get refreshStandbyCondition() {
return this._standbyCondition;
}
set refreshStandbyCondition(v) {
this._standbyCondition = v;
}
/**
* Selected file for single file history
*/
get selectedHistoryFile() {
return this._selectedHistoryFile;
}
set selectedHistoryFile(file) {
if (this._selectedHistoryFile !== file) {
this._selectedHistoryFile = file;
this._selectedHistoryFileChanged.emit(file);
}
}
/**
* Last author
*
*/
get lastAuthor() {
return this._lastAuthor;
}
set lastAuthor(lastAuthor) {
this._lastAuthor = lastAuthor;
}
/**
* Git repository status
*/
get status() {
return this._status;
}
/**
* A signal emitted when the branches of the Git repository changes.
*/
get branchesChanged() {
return this._branchesChanged;
}
/**
* A signal emitted when the `HEAD` of the Git repository changes.
*/
get headChanged() {
return this._headChanged;
}
/**
* A signal emitted when the list of the Git repository changes.
*/
get tagsChanged() {
return this._tagsChanged;
}
/**
* A signal emitted when the current marking of the Git repository changes.
*/
get markChanged() {
return this._markChanged;
}
/**
* A signal emitted when the current file selected for history of the Git repository changes.
*/
get selectedHistoryFileChanged() {
return this._selectedHistoryFileChanged;
}
/**
* A signal emitted when the Git stash changes.
*
*/
get stashChanged() {
return this._stashChanged;
}
/**
* The repository stash
*/
get stash() {
return this._stash;
}
/**
* A signal emitted when the current Git repository changes.
*/
get repositoryChanged() {
return this._repositoryChanged;
}
/**
* A signal emitted when the current status of the Git repository changes.
*/
get statusChanged() {
return this._statusChanged;
}
/**
* A signal emitted whenever a model event occurs.
*/
get taskChanged() {
return this._taskHandler.taskChanged;
}
/**
* A signal emitted when the Git repository remote changes.
*/
get remoteChanged() {
return this._remoteChanged;
}
/**
* Boolean indicating whether there are dirty files
* A dirty file is a file with unsaved changes that is staged in classical mode
* or modified in simple mode.
*/
get hasDirtyFiles() {
return this._hasDirtyFiles;
}
set hasDirtyFiles(value) {
if (this._hasDirtyFiles !== value) {
this._hasDirtyFiles = value;
this._dirtyFilesStatusChanged.emit(value);
}
}
/**
* A signal emitted indicating whether there are dirty (e.g., unsaved) staged files.
* This signal is emitted when there is a dirty staged file but none previously,
* and vice versa, when there are no dirty staged files but there were some previously.
*/
get dirtyFilesStatusChanged() {
return this._dirtyFilesStatusChanged;
}
/**
* Boolean indicating whether credentials are required from the user.
*/
get credentialsRequired() {
return this._credentialsRequired;
}
set credentialsRequired(value) {
if (this._credentialsRequired !== value) {
this._credentialsRequired = value;
this._credentialsRequiredChanged.emit(value);
}
}
/**
* A signal emitted whenever credentials are required, or are not required anymore.
*/
get credentialsRequiredChanged() {
return this._credentialsRequiredChanged;
}
/**
* Get the current markers
*
* Note: This makes sure it always returns non null value
*/
get _currentMarker() {
if (this.pathRepository === null) {
return new BranchMarker(() => { });
}
if (!this.__currentMarker) {
this._setMarker(this.pathRepository, this._currentBranch ? this._currentBranch.name : '');
}
return this.__currentMarker;
}
/**
* Add one or more files to the repository staging area.
*
* ## Notes
*
* - If no filename is provided, all files are added.
*
* @param filename - files to add
* @returns promise which resolves upon adding files to the repository staging area
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async add(...filename) {
const path = await this._getPathRepository();
await this._taskHandler.execute('git:add:files', async () => {
await requestAPI(URLExt.join(path, 'add'), 'POST', {
add_all: !filename,
filename: filename || ''
});
});
await this.refreshStatus();
}
/**
* Match files status information based on a provided file path.
*
* If the file is tracked and has no changes, a StatusFile of unmodified will be returned.
*
* @param path the file path relative to the server root
* @returns The file status or null if path repository is null or path not in repository
*/
getFile(path) {
var _a;
if (this.pathRepository === null) {
return null;
}
const fileStatus = ((_a = this._status) === null || _a === void 0 ? void 0 : _a.files)
? this._status.files.find(status => {
return this.getRelativeFilePath(status.to) === path;
})
: null;
if (!fileStatus) {
const relativePath = PathExt.relative('/' + this.pathRepository, '/' + path);
if (relativePath.startsWith('../')) {
return null;
}
else {
return {
x: '',
y: '',
to: relativePath,
from: '',
is_binary: null,
status: 'unmodified',
type: this._resolveFileType(path)
};
}
}
else {
return fileStatus;
}
}
/**
* Add all "unstaged" files to the repository staging area.
*
* @returns promise which resolves upon adding files to the repository staging area
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async addAllUnstaged() {
const path = await this._getPathRepository();
await this._taskHandler.execute('git:add:files:all_unstaged', async () => {
await requestAPI(URLExt.join(path, 'add_all_unstaged'), 'POST');
});
await this.refreshStatus();
}
/**
* Add all untracked files to the repository staging area.
*
* @returns promise which resolves upon adding files to the repository staging area
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async addAllUntracked() {
const path = await this._getPathRepository();
await this._taskHandler.execute('git:add:files:all_untracked', async () => {
await requestAPI(URLExt.join(path, 'add_all_untracked'), 'POST');
});
await this.refreshStatus();
}
/**
* Add a remote Git repository to the current repository.
*
* @param url - remote repository URL
* @param name - remote name
* @returns promise which resolves upon adding a remote
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async addRemote(url, name) {
const path = await this._getPathRepository();
await this._taskHandler.execute('git:add:remote', async () => {
await requestAPI(URLExt.join(path, 'remote', 'add'), 'POST', {
url,
name
});
});
}
/**
* Show remote repository for the current repository
* @returns promise which resolves to a list of remote repositories
*/
async getRemotes() {
const path = await this._getPathRepository();
const result = await this._taskHandler.execute('git:show:remote', async () => {
return await requestAPI(URLExt.join(path, 'remote', 'show'), 'GET');
});
return result.remotes;
}
/**
* Remove a remote repository by name
* @param name name of remote to remove
*/
async removeRemote(name) {
const path = await this._getPathRepository();
await this._taskHandler.execute('git:remove:remote', async () => {
await requestAPI(URLExt.join(path, 'remote', name), 'DELETE');
});
}
/**
* Retrieve the repository commit log.
*
* ## Notes
*
* - This API can be used to implicitly check if the current folder is a Git repository.
*
* @param count - number of commits to retrieve
* @returns promise which resolves upon retrieving the repository commit log
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async allHistory(count = 25) {
const path = await this._getPathRepository();
return await this._taskHandler.execute('git:fetch:history', async () => {
return await requestAPI(URLExt.join(path, 'all_history'), 'POST', {
history_count: count
});
});
}
/**
* Checkout a branch.
*
* ## Notes
*
* - If a branch name is provided, checkout the provided branch (with or without creating it)
* - If a filename is provided, checkout the file, discarding all changes.
* - If nothing is provided, checkout all files, discarding all changes.
*
* TODO: Refactor into separate endpoints for each kind of checkout request
*
* @param options - checkout options
* @returns promise which resolves upon performing a checkout
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async checkout(options) {
const path = await this._getPathRepository();
const body = {
checkout_branch: false,
new_check: false,
branchname: '',
startpoint: '',
checkout_all: true,
filename: ''
};
if (options !== undefined) {
if (options.branchname) {
body.branchname = options.branchname;
body.checkout_branch = true;
body.new_check = options.newBranch === true;
if (options.newBranch) {
body.startpoint = options.startpoint || this._currentBranch.name;
}
}
else if (options.filename) {
body.filename = options.filename;
body.checkout_all = false;
}
}
const data = await this._taskHandler.execute('git:checkout', async () => {
var _a;
let changes;
if (!body.new_check) {
if (body.checkout_branch && !body.new_check) {
changes = await this._changedFiles(this._currentBranch.name, body.branchname);
}
else if (body.filename) {
changes = { files: [body.filename] };
}
else {
changes = await this._changedFiles('WORKING', 'HEAD');
}
}
const d = await requestAPI(URLExt.join(path, 'checkout'), 'POST', body);
(_a = changes === null || changes === void 0 ? void 0 : changes.files) === null || _a === void 0 ? void 0 : _a.forEach(file => this._revertFile(file));
return d;
});
if (body.checkout_branch) {
await this.refreshBranch();
}
else {
await this.refreshStatus();
}
return data;
}
/**
* Merge a branch into the current branch
*
* @param branch The branch to merge into the current branch
*/
async merge(branch) {
const path = await this._getPathRepository();
return this._taskHandler.execute('git:merge', () => {
return requestAPI(URLExt.join(path, 'merge'), 'POST', {
branch
});
});
}
/**
* Clone a repository.
*
* @param path - local path into which the repository will be cloned
* @param url - Git repository URL
* @param auth - remote repository authentication information
* @param versioning - boolean flag of Git metadata (default true)
* @param submodules - boolean flag of Git submodules (default false)
* @returns promise which resolves upon cloning a repository
*
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async clone(path, url, auth, versioning = true, submodules = false) {
return await this._taskHandler.execute('git:clone', async () => {
return await requestAPI(URLExt.join(path, 'clone'), 'POST', {
clone_url: url,
versioning: versioning,
submodules: submodules,
auth: auth
});
});
}
/**
* Commit all staged file changes. If message is None, then the commit is amended
*
* @param message - commit message
* @param amend - whether this is an amend commit
* @param author - override the commit author specified in config
* @returns promise which resolves upon committing file changes
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async commit(message = null, amend = false, author = null) {
const path = await this._getPathRepository();
await this._taskHandler.execute('git:commit:create', async () => {
await requestAPI(URLExt.join(path, 'commit'), 'POST', {
commit_msg: message,
amend: amend,
author: author !== null && author !== void 0 ? author : null
});
});
await this.refresh();
}
/**
* Get (or set) Git configuration options.
*
* @param options - configuration options to set
* @returns promise which resolves upon either getting or setting configuration options
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async config(options) {
const path = await this._getPathRepository();
return await this._taskHandler.execute('git:config:' + (options ? 'set' : 'get'), async () => {
if (options) {
await requestAPI(URLExt.join(path, 'config'), 'POST', {
options
});
}
else {
return await requestAPI(URLExt.join(path, 'config'), 'POST');
}
});
}
/**
* Delete a branch
*
* @param branchName Branch name
* @returns promise which resolves when the branch has been deleted.
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async deleteBranch(branchName) {
const path = await this._getPathRepository();
await this._taskHandler.execute('git:branch:delete', async () => {
return await requestAPI(URLExt.join(path, 'branch', 'delete'), 'POST', {
branch: branchName
});
});
}
/**
* Fetch commit information.
*
* @param hash - commit hash
* @returns promise which resolves upon retrieving commit information
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async detailedLog(hash) {
var _a;
const path = await this._getPathRepository();
const data = await this._taskHandler.execute('git:fetch:commit_log', async () => {
return await requestAPI(URLExt.join(path, 'detailed_log'), 'POST', {
selected_hash: hash
});
});
data.modified_files = ((_a = data.modified_files) !== null && _a !== void 0 ? _a : []).map(f => {
f.type = this._resolveFileType(f.modified_file_path);
return f;
});
return data;
}
/**
* Get the diff of two commits.
* If no commit is provided, the diff of HEAD and INDEX is returned.
* If the current commit (the commit to compare) is not provided,
* the diff of the previous commit and INDEX is returned.
*
* @param previous - the commit to compare against
* @param current - the commit to compare
* @returns promise which resolves upon retrieving the diff
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async diff(previous, current) {
var _a;
const path = await this._getPathRepository();
const data = await this._taskHandler.execute('git:diff', async () => {
return await requestAPI(URLExt.join(path, 'diff'), 'POST', {
previous,
current
});
});
data.result = ((_a = data.result) !== null && _a !== void 0 ? _a : []).map(f => {
f.filetype = this._resolveFileType(f.filename);
return f;
});
return data;
}
/**
* Dispose of model resources.
*/
dispose() {
var _a;
if (this.isDisposed) {
return;
}
this._isDisposed = true;
this._fetchPoll.dispose();
this._statusPoll.dispose();
this._taskHandler.dispose();
(_a = this._settings) === null || _a === void 0 ? void 0 : _a.changed.disconnect(this._onSettingsChange, this);
Signal.clearData(this);
}
/**
* Ensure a .gitignore file exists
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
* @throws {Git.HiddenFile} If the file is hidden
*/
async ensureGitignore() {
var _a, _b;
const path = await this._getPathRepository();
await requestAPI(URLExt.join(path, 'ignore'), 'POST', {});
try {
await ((_a = this._docmanager) === null || _a === void 0 ? void 0 : _a.services.contents.get(`${path}/.gitignore`, {
content: false
}));
}
catch (e) {
// If the previous request failed with a 404 error, it means hidden file cannot be accessed
if (((_b = e.response) === null || _b === void 0 ? void 0 : _b.status) === 404) {
throw new Git.HiddenFile();
}
}
this._openGitignore();
await this.refreshStatus();
}
/**
* Reads content of .gitignore file
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async readGitIgnore() {
const path = await this._getPathRepository();
return (await requestAPI(URLExt.join(path, 'ignore'), 'GET')).content;
}
/**
* Overwrites content onto .gitignore file
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async writeGitIgnore(content) {
const path = await this._getPathRepository();
await requestAPI(URLExt.join(path, 'ignore'), 'POST', { content: content });
await this.refreshStatus();
}
/**
* Fetch to get ahead/behind status
*
* @param auth - remote authentication information
* @returns promise which resolves upon fetching
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async fetch(auth) {
const path = await this._getPathRepository();
const data = this._taskHandler.execute('git:fetch:remote', async () => {
return await requestAPI(URLExt.join(path, 'remote', 'fetch'), 'POST', {
auth: auth
});
});
return data;
}
/**
* Return the path of a file relative to the Jupyter server root.
*
* ## Notes
*
* - If no path is provided, returns the Git repository top folder relative path.
* - If no Git repository selected, returns `null`
*
* @param path - file path relative to the top folder of the Git repository
* @returns relative path
*/
getRelativeFilePath(path) {
if (this.pathRepository === null) {
return null;
}
return PathExt.join(this.pathRepository, path !== null && path !== void 0 ? path : '');
}
/**
* Add an entry in .gitignore file
*
* @param filePath File to ignore
* @param useExtension Whether to ignore the file or its extension
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
* @throws {Git.HiddenFile} If hidden files are not enabled
*/
async ignore(filePath, useExtension) {
var _a, _b;
const path = await this._getPathRepository();
await requestAPI(URLExt.join(path, 'ignore'), 'POST', {
file_path: filePath,
use_extension: useExtension
});
try {
await ((_a = this._docmanager) === null || _a === void 0 ? void 0 : _a.services.contents.get(`${path}/.gitignore`, {
content: false
}));
}
catch (e) {
// If the previous request failed with a 404 error, it means hidden file cannot be accessed
if (((_b = e.response) === null || _b === void 0 ? void 0 : _b.status) === 404) {
throw new Git.HiddenFile();
}
}
this._openGitignore();
await this.refreshStatus();
}
/**
* Initialize a new Git repository at a specified path.
*
* @param path - path at which initialize a Git repository
* @returns promise which resolves upon initializing a Git repository
*
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async init(path) {
await this._taskHandler.execute('git:init', async () => {
await requestAPI(URLExt.join(path, 'init'), 'POST');
});
}
/**
* Retrieve commit logs.
*
* @param count - number of commits
* @returns promise which resolves upon retrieving commit logs
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async log(count = 25) {
const path = await this._getPathRepository();
return await this._taskHandler.execute('git:fetch:log', async () => {
var _a;
try {
return await requestAPI(URLExt.join(path, 'log'), 'POST', {
history_count: count,
follow_path: (_a = this.selectedHistoryFile) === null || _a === void 0 ? void 0 : _a.to
});
}
catch (error) {
return { code: 1 };
}
});
}
/**
* Fetch changes from a remote repository.
*
* @param auth - remote authentication information
* @returns promise which resolves upon fetching changes
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async pull(auth) {
var _a, _b;
const path = await this._getPathRepository();
const previousHead = (_a = this._currentBranch) === null || _a === void 0 ? void 0 : _a.top_commit;
const data = await this._taskHandler.execute('git:pull', async () => {
var _a;
return await requestAPI(URLExt.join(path, 'pull'), 'POST', {
auth: auth,
cancel_on_conflict: ((_a = this._settings) === null || _a === void 0 ? void 0 : _a.composite['cancelPullMergeConflict']) || false
});
});
const changes = await this._changedFiles(previousHead, 'HEAD');
(_b = changes === null || changes === void 0 ? void 0 : changes.files) === null || _b === void 0 ? void 0 : _b.forEach(file => this._revertFile(file));
await this.refreshBranch(); // Will emit headChanged if required
return data;
}
/**
* Push local changes to a remote repository.
*
* @param auth - remote authentication information
* @param force - whether or not to force the push
* @returns promise which resolves upon pushing changes
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async push(auth, force = false, remote) {
const path = await this._getPathRepository();
const data = this._taskHandler.execute('git:push', async () => {
return await requestAPI(URLExt.join(path, 'push'), 'POST', {
auth: auth,
force: force,
remote
});
});
this.refreshBranch();
return data;
}
/**
* Rebase the current branch onto the provided one.
*
* @param branch to rebase onto
* @returns promise which resolves upon rebase action
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async rebase(branch) {
const path = await this._getPathRepository();
return this._taskHandler.execute('git:rebase', () => {
return requestAPI(URLExt.join(path, 'rebase'), 'POST', {
branch
});
});
}
/**
* Resolve in progress rebase.
*
* @param action to perform
* @returns promise which resolves upon rebase action
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async resolveRebase(action) {
const path = await this._getPathRepository();
return this._taskHandler.execute('git:rebase:resolve', () => requestAPI(URLExt.join(path, 'rebase'), 'POST', { action }));
}
/**
* Refresh the repository.
*
* @returns promise which resolves upon refreshing the repository
*/
async refresh() {
await this._statusPoll.refresh();
await this._statusPoll.tick;
}
/**
* Refresh the list of repository branches.
*
* Emit headChanged if the branch or its top commit changes
*
* @returns promise which resolves upon refreshing repository branches
*/
async refreshBranch() {
var _a, _b, _c, _d, _e;
try {
const data = await this._taskHandler.execute('git:refresh:branches', async () => {
return await this._branch();
});
let headChanged = false;
if (!this._currentBranch || !data) {
headChanged = this._currentBranch !== data.current_branch; // Object comparison is not working
}
else {
headChanged =
this._currentBranch.name !== ((_a = data.current_branch) === null || _a === void 0 ? void 0 : _a.name) ||
this._currentBranch.top_commit !== ((_b = data.current_branch) === null || _b === void 0 ? void 0 : _b.top_commit);
}
const branchesChanged = !JSONExt.deepEqual(this._branches, ((_c = data.branches) !== null && _c !== void 0 ? _c : []));
this._branches = (_d = data.branches) !== null && _d !== void 0 ? _d : [];
this._currentBranch = (_e = data.current_branch) !== null && _e !== void 0 ? _e : null;
if (this._currentBranch && this._pathRepository) {
// Set up the marker obj for the current (valid) repo/branch combination
this._setMarker(this.pathRepository, this._currentBranch.name);
}
if (headChanged) {
this._headChanged.emit();
}
if (branchesChanged) {
this._branchesChanged.emit();
}
// Start fetch remotes if the repository has remote branches
const hasRemote = this._branches.some(branch => branch.is_remote_branch);
if (hasRemote) {
this._fetchPoll.start();
}
else {
this._fetchPoll.stop();
}
}
catch (error) {
const branchesChanged = this._branches.length > 0;
const headChanged = this._currentBranch !== null;
this._branches = [];
this._currentBranch = null;
this._fetchPoll.stop();
if (headChanged) {
this._headChanged.emit();
}
if (branchesChanged) {
this._branchesChanged.emit();
}
if (!(error instanceof Git.NotInRepository)) {
throw error;
}
}
}
/**
* Refresh the list of repository tags.
*
* @returns promise which resolves upon refreshing repository tags
*/
async refreshTag() {
var _a;
try {
const data = await this._taskHandler.execute('git:refresh:tags', async () => {
return await this.tags();
});
const newTags = (_a = data.tags) !== null && _a !== void 0 ? _a : [];
const tagsChanged = !JSONExt.deepEqual(this._tagsList, newTags);
this._tagsList = newTags;
if (tagsChanged) {
this._tagsChanged.emit();
}
this._fetchPoll.stop();
}
catch (error) {
const tagsChanged = this._tagsList.length > 0;
this._tagsList = [];
this._fetchPoll.stop();
if (tagsChanged) {
this._tagsChanged.emit();
}
if (!(error instanceof Git.NotInRepository)) {
throw error;
}
}
}
/**
* Refresh the repository status.
*
* Emit statusChanged if required.
*
* @returns promise which resolves upon refreshing the repository status
*/
async refreshStatus() {
var _a, _b, _c, _d, _e, _f;
let path;
try {
path = await this._getPathRepository();
}
catch (error) {
this._clearStatus();
if (!(error instanceof Git.NotInRepository)) {
throw error;
}
return;
}
try {
const data = await this._taskHandler.execute('git:refresh:status', async () => {
return await requestAPI(URLExt.join(path, 'status'), 'POST');
});
const files = (_a = data.files) === null || _a === void 0 ? void 0 : _a.map(file => {
return {
...file,
status: decodeStage(file.x, file.y),
type: this._resolveFileType(file.to)
};
});
this._setStatus({
branch: (_b = data.branch) !== null && _b !== void 0 ? _b : null,
remote: (_c = data.remote) !== null && _c !== void 0 ? _c : null,
ahead: (_d = data.ahead) !== null && _d !== void 0 ? _d : 0,
behind: (_e = data.behind) !== null && _e !== void 0 ? _e : 0,
state: (_f = data.state) !== null && _f !== void 0 ? _f : 0,
files: files !== null && files !== void 0 ? files : []
});
await this.refreshDirtyStatus();
}
catch (err) {
// TODO we should notify the user
this._clearStatus();
console.error(err);
return;
}
}
/**
* Collects files that have changed on the remote branch.
*
*/
async remoteChangedFiles() {
var _a;
// if a file is changed on remote add it to list of files with appropriate status.
this._remoteChangedFiles.length = 0;
try {
if (this.status.remote && this.status.behind > 0) {
this._remoteChangedFiles.concat(((_a = (await this._changedFiles('WORKING', this.status.remote)).files) !== null && _a !== void 0 ? _a : []).map(element => ({
status: 'remote-changed',
type: this._resolveFileType(element),
x: '?',
y: 'B',
to: element,
from: '?',
is_binary: false
})));
}
}
catch (err) {
console.error(err);
}
return this._remoteChangedFiles;
}
/**
* Determines if opened files are behind the remote and emits a signal if one
* or more are behind and the user hasn't been notified of them yet.
*
*/
async checkRemoteChangeNotified() {
var _a, _b;
if (this.status.remote && this.status.behind > 0) {
const notNotified = [];
const notified = [];
for (const val of this._remoteChangedFiles) {
const filePath = this.getRelativeFilePath(val.to);
if (!filePath) {
continue;
}
const docWidget = (_a = this._docmanager) === null || _a === void 0 ? void 0 : _a.findWidget(filePath);
const notifiedIndex = this._changeUpstreamNotified.findIndex(notified => notified.from === val.from &&
notified.to === val.to &&
notified.x === val.x &&
notified.y === val.y);
if (docWidget !== undefined) {
if (docWidget.isAttached) {
// notify if the user hasn't been notified yet
if (notifiedIndex === -1) {
this._changeUpstreamNotified.push(val);
notNotified.push(val);
}
else {
notified.push(val);
}
}
}
else {
// remove from notified array if document is closed
if (notifiedIndex > -1) {
this._changeUpstreamNotified.splice(notifiedIndex, 1);
}
}
}
if ((_b = this._settings) === null || _b === void 0 ? void 0 : _b.composite['openFilesBehindWarning']) {
if (notNotified.length > 0) {
this._remoteChanged.emit({ notNotified, notified });
}
}
}
else {
this._changeUpstreamNotified = [];
}
}
/**
* Determines whether there are unsaved changes on files,
*
* @returns promise that resolves upon refreshing the dirty status of files
*/
async refreshDirtyStatus() {
// we assume the repository status has been refreshed prior to this
var _a, _b;
// get files
const files = this.status.files.filter(file => this._statusForDirtyState.includes(file.status));
const fileNames = files.map(file => file.to);
let result = false;
for (const fileName of fileNames) {
const filePath = this.getRelativeFilePath(fileName);
if (!filePath) {
continue;
}
const docWidget = (_a = this._docmanager) === null || _a === void 0 ? void 0 : _a.findWidget(filePath);
if (docWidget !== undefined) {
const context = (_b = this._docmanager) === null || _b === void 0 ? void 0 : _b.contextForWidget(docWidget);
if (context === null || context === void 0 ? void 0 : context.model.dirty) {
result = true;
break;
}
}
}
this.hasDirtyFiles = result;
}
/**
* Move files from the "staged" to the "unstaged" area.
*
* ## Notes
*
* - If no filename is provided, moves all files from the "staged" to the "unstaged" area.
*
* @param filename - file path to be reset
* @returns promise which resolves upon moving files
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async reset(filename) {
const path = await this._getPathRepository();
await this._taskHandler.execute('git:reset:changes', async () => {
var _a;
const reset_all = filename === undefined;
let files;
if (reset_all) {
files = (_a = (await this._changedFiles('INDEX', 'HEAD')).files) !== null && _a !== void 0 ? _a : [];
}
else {
files = [filename];
}
await requestAPI(URLExt.join(path, 'reset'), 'POST', {
reset_all,
filename: filename !== null && filename !== void 0 ? filename : null
});
files.forEach(file => {
this._revertFile(file);
});
});
await this.refreshStatus();
}
/**
* Reset the repository to a specified commit.
*
* ## Notes
*
* - If a commit hash is not provided, resets the repository to `HEAD`.
*
* @param hash - commit identifier (hash)
* @returns promises which resolves upon resetting the repository
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async resetToCommit(hash = '') {
const path = await this._getPathRepository();
await this._taskHandler.execute('git:reset:hard