UNPKG

@jupyterlab/git

Version:

A JupyterLab extension for version control using git

1,350 lines 79.8 kB
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 = {}; // Fallback diff provider for text files without a specific provider const FALLBACK_DIFF_PROVIDER = { factory: null }; /** * Get the diff provider for a filename * * The lookup first tries to find a provider registered for the filename's * extension. If none is registered and `isText` is true, the fallback diff * provider is returned (if one has been registered). * * @param filename Filename to look for * @param isText Whether the file is a text file. When true and no * extension-specific provider matches, the fallback provider (if any) * is returned. * @returns The diff provider callback or undefined */ export function getDiffProvider(filename, isText) { var _a, _b, _c; const factory = (_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; if (factory) { return factory; } if (isText && FALLBACK_DIFF_PROVIDER.factory) { return FALLBACK_DIFF_PROVIDER.factory; } return undefined; } /** * Class for creating a model for retrieving info from, and interacting with, a remote Git repository. */ export class GitExtension { /** * Returns an extension model. * * @param docmanager - document manager * @param docRegistry - document registry * @param settings - plugin settings * @param serverSettings - optional server connection settings for API requests * @returns extension model */ constructor(docmanager = null, docRegistry = null, settings, serverSettings) { /** * 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.refreshRemotes(); 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._remotes = []; 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._submodulesChanged = 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._remotesChanged = new Signal(this); this._dirtyFilesStatusChanged = new Signal(this); this._credentialsRequiredChanged = new Signal(this); this._docmanager = docmanager; this._docRegistry = docRegistry; this._settings = settings || null; this._serverSettings = serverSettings; 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; } /** * Remote list for the current repository. */ get remotes() { return this._remotes; } /** * 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 submodules of the Git repository change. */ get submodulesChanged() { return this._submodulesChanged; } /** * 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; } /** * A signal emitted when the list of remotes of the Git repository changes. */ get remotesChanged() { return this._remotesChanged; } /** * 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 this._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 this._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 this._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 this._requestAPI(URLExt.join(path, 'remote', 'add'), 'POST', { url, name }); }); try { await this.refreshRemotes(); } catch (error) { console.error('Failed to refresh the list of remotes', error); } } /** * 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 this._requestAPI(URLExt.join(path, 'remote', 'show'), 'GET'); }); return result.remotes; } /** * Remove a remote repository by name * * @param name - name of the remote to remove * @returns promise which resolves upon removing the 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 removeRemote(name) { const path = await this._getPathRepository(); await this._taskHandler.execute('git:remove:remote', async () => { await this._requestAPI(URLExt.join(path, 'remote', name), 'DELETE'); }); try { await this.refreshRemotes(); } catch (error) { console.error('Failed to refresh the list of remotes', error); } } /** * Refresh the list of remotes of the current repository. * * Emit remotesChanged if the list of remotes changes. * * ## Notes * * - The cached list of remotes is kept on failure, unless the current * path is not a Git repository anymore. * * @returns promise which resolves upon refreshing the remotes * * @throws {Git.GitResponseError} If the server response is not ok * @throws {ServerConnection.NetworkError} If the request cannot be made */ async refreshRemotes() { try { const remotes = await this.getRemotes(); const remotesChanged = !JSONExt.deepEqual(this._remotes, remotes); this._remotes = remotes; if (remotesChanged) { this._remotesChanged.emit(); } } catch (error) { if (!(error instanceof Git.NotInRepository)) { throw error; } const remotesChanged = this._remotes.length > 0; this._remotes = []; if (remotesChanged) { this._remotesChanged.emit(); } } } /** * 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 this._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 this._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 this._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 this._requestAPI(URLExt.join(path, 'commit'), 'POST', { commit_msg: message, amend: amend, author: author !== null && author !== void 0 ? author : null }); }); await this.refresh(); } /** * Check staged notebooks for outputs. * * @returns A promise resolving to an array of notebook paths that have outputs * * @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 checkNotebooksForOutputs() { const path = await this._getPathRepository(); return this._taskHandler.execute('git:check-notebooks', async () => { const result = await this._requestAPI(URLExt.join(path, 'check_notebooks')); return result.notebooks_with_outputs; }); } /** * Strip outputs from the given staged notebooks. * * @param notebooks - Array of notebook paths to clean * * @returns A promise resolving when the operation completes * * @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 stripNotebooksOutputs(notebooks) { const path = await this._getPathRepository(); await this._taskHandler.execute('git:strip-notebooks', async () => { await this._requestAPI(URLExt.join(path, 'strip_notebooks'), 'POST', { notebooks }); }); } /** * 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 this._requestAPI(URLExt.join(path, 'config'), 'POST', { options }); } else { return await this._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 this._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 this._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 this._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 this._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 this._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 this._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 this._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 this._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 this._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 this._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 this._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 this._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 this._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', () => this._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 this._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