import ignore from 'ignore'; import AsyncLock from 'async-lock'; import Hash from 'sha.js/sha1.js'; import crc32 from 'crc-32'; import pako from 'pako'; /** * @typedef {Object} GitProgressEvent * @property {string} phase * @property {number} loaded * @property {number} total */ /** * @callback ProgressCallback * @param {GitProgressEvent} progress * @returns {void | Promise} */ /** * @typedef {Object} GitHttpRequest * @property {string} url - The URL to request * @property {string} [method='GET'] - The HTTP method to use * @property {Object} [headers={}] - Headers to include in the HTTP request * @property {Object} [agent] - An HTTP or HTTPS agent that manages connections for the HTTP client (Node.js only) * @property {AsyncIterableIterator} [body] - An async iterator of Uint8Arrays that make up the body of POST requests * @property {ProgressCallback} [onProgress] - Reserved for future use (emitting `GitProgressEvent`s) * @property {object} [signal] - Reserved for future use (canceling a request) */ /** * @typedef {Object} GitHttpResponse * @property {string} url - The final URL that was fetched after any redirects * @property {string} [method] - The HTTP method that was used * @property {Object} [headers] - HTTP response headers * @property {AsyncIterableIterator} [body] - An async iterator of Uint8Arrays that make up the body of the response * @property {number} statusCode - The HTTP status code * @property {string} statusMessage - The HTTP status message */ /** * @callback HttpFetch * @param {GitHttpRequest} request * @returns {Promise} */ /** * @typedef {Object} HttpClient * @property {HttpFetch} request */ /** * A git commit object. * * @typedef {Object} CommitObject * @property {string} message Commit message * @property {string} tree SHA-1 object id of corresponding file tree * @property {string[]} parent an array of zero or more SHA-1 object ids * @property {Object} author * @property {string} author.name The author's name * @property {string} author.email The author's email * @property {number} author.timestamp UTC Unix timestamp in seconds * @property {number} author.timezoneOffset Timezone difference from UTC in minutes * @property {Object} committer * @property {string} committer.name The committer's name * @property {string} committer.email The committer's email * @property {number} committer.timestamp UTC Unix timestamp in seconds * @property {number} committer.timezoneOffset Timezone difference from UTC in minutes * @property {string} [gpgsig] PGP signature (if present) */ /** * An entry from a git tree object. Files are called 'blobs' and directories are called 'trees'. * * @typedef {Object} TreeEntry * @property {string} mode the 6 digit hexadecimal mode * @property {string} path the name of the file or directory * @property {string} oid the SHA-1 object id of the blob or tree * @property {'commit'|'blob'|'tree'} type the type of object */ /** * A git tree object. Trees represent a directory snapshot. * * @typedef {TreeEntry[]} TreeObject */ /** * A git annotated tag object. * * @typedef {Object} TagObject * @property {string} object SHA-1 object id of object being tagged * @property {'blob' | 'tree' | 'commit' | 'tag'} type the type of the object being tagged * @property {string} tag the tag name * @property {Object} tagger * @property {string} tagger.name the tagger's name * @property {string} tagger.email the tagger's email * @property {number} tagger.timestamp UTC Unix timestamp in seconds * @property {number} tagger.timezoneOffset timezone difference from UTC in minutes * @property {string} message tag message * @property {string} [gpgsig] PGP signature (if present) */ /** * @typedef {Object} ReadCommitResult * @property {string} oid - SHA-1 object id of this commit * @property {CommitObject} commit - the parsed commit object * @property {string} payload - PGP signing payload */ /** * @typedef {Object} ServerRef - This object has the following schema: * @property {string} ref - The name of the ref * @property {string} oid - The SHA-1 object id the ref points to * @property {string} [target] - The target ref pointed to by a symbolic ref * @property {string} [peeled] - If the oid is the SHA-1 object id of an annotated tag, this is the SHA-1 object id that the annotated tag points to */ /** * @typedef Walker * @property {Symbol} Symbol('GitWalkerSymbol') */ /** * Normalized subset of filesystem `stat` data: * * @typedef {Object} Stat * @property {number} ctimeSeconds * @property {number} ctimeNanoseconds * @property {number} mtimeSeconds * @property {number} mtimeNanoseconds * @property {number} dev * @property {number} ino * @property {number} mode * @property {number} uid * @property {number} gid * @property {number} size */ /** * The `WalkerEntry` is an interface that abstracts computing many common tree / blob stats. * * @typedef {Object} WalkerEntry * @property {function(): Promise<'tree'|'blob'|'special'|'commit'>} type * @property {function(): Promise} mode * @property {function(): Promise} oid * @property {function(): Promise} content * @property {function(): Promise} stat */ /** * @typedef {Object} CallbackFsClient * @property {function} readFile - https://nodejs.org/api/fs.html#fs_fs_readfile_path_options_callback * @property {function} writeFile - https://nodejs.org/api/fs.html#fs_fs_writefile_file_data_options_callback * @property {function} unlink - https://nodejs.org/api/fs.html#fs_fs_unlink_path_callback * @property {function} readdir - https://nodejs.org/api/fs.html#fs_fs_readdir_path_options_callback * @property {function} mkdir - https://nodejs.org/api/fs.html#fs_fs_mkdir_path_mode_callback * @property {function} rmdir - https://nodejs.org/api/fs.html#fs_fs_rmdir_path_callback * @property {function} stat - https://nodejs.org/api/fs.html#fs_fs_stat_path_options_callback * @property {function} lstat - https://nodejs.org/api/fs.html#fs_fs_lstat_path_options_callback * @property {function} [readlink] - https://nodejs.org/api/fs.html#fs_fs_readlink_path_options_callback * @property {function} [symlink] - https://nodejs.org/api/fs.html#fs_fs_symlink_target_path_type_callback * @property {function} [chmod] - https://nodejs.org/api/fs.html#fs_fs_chmod_path_mode_callback */ /** * @typedef {Object} PromiseFsClient * @property {Object} promises * @property {function} promises.readFile - https://nodejs.org/api/fs.html#fs_fspromises_readfile_path_options * @property {function} promises.writeFile - https://nodejs.org/api/fs.html#fs_fspromises_writefile_file_data_options * @property {function} promises.unlink - https://nodejs.org/api/fs.html#fs_fspromises_unlink_path * @property {function} promises.readdir - https://nodejs.org/api/fs.html#fs_fspromises_readdir_path_options * @property {function} promises.mkdir - https://nodejs.org/api/fs.html#fs_fspromises_mkdir_path_options * @property {function} promises.rmdir - https://nodejs.org/api/fs.html#fs_fspromises_rmdir_path * @property {function} promises.stat - https://nodejs.org/api/fs.html#fs_fspromises_stat_path_options * @property {function} promises.lstat - https://nodejs.org/api/fs.html#fs_fspromises_lstat_path_options * @property {function} [promises.readlink] - https://nodejs.org/api/fs.html#fs_fspromises_readlink_path_options * @property {function} [promises.symlink] - https://nodejs.org/api/fs.html#fs_fspromises_symlink_target_path_type * @property {function} [promises.chmod] - https://nodejs.org/api/fs.html#fs_fspromises_chmod_path_mode */ /** * @typedef {CallbackFsClient | PromiseFsClient} FsClient */ /** * @callback MessageCallback * @param {string} message * @returns {void | Promise} */ /** * @typedef {Object} GitAuth * @property {string} [username] * @property {string} [password] * @property {Object} [headers] * @property {boolean} [cancel] Tells git to throw a `UserCanceledError` (instead of an `HttpError`). */ /** * @callback AuthCallback * @param {string} url * @param {GitAuth} auth Might have some values if the URL itself originally contained a username or password. * @returns {GitAuth | void | Promise} */ /** * @callback AuthFailureCallback * @param {string} url * @param {GitAuth} auth The credentials that failed * @returns {GitAuth | void | Promise} */ /** * @callback AuthSuccessCallback * @param {string} url * @param {GitAuth} auth * @returns {void | Promise} */ /** * @typedef {Object} SignParams * @property {string} payload - a plaintext message * @property {string} secretKey - an 'ASCII armor' encoded PGP key (technically can actually contain _multiple_ keys) */ /** * @callback SignCallback * @param {SignParams} args * @return {{signature: string} | Promise<{signature: string}>} - an 'ASCII armor' encoded "detached" signature */ /** * @typedef {Object} MergeDriverParams * @property {Array} branches * @property {Array} contents * @property {string} path */ /** * @callback MergeDriverCallback * @param {MergeDriverParams} args * @return {{cleanMerge: boolean, mergedText: string} | Promise<{cleanMerge: boolean, mergedText: string}>} */ /** * @callback WalkerMap * @param {string} filename * @param {WalkerEntry[]} entries * @returns {Promise} */ /** * @callback WalkerReduce * @param {any} parent * @param {any[]} children * @returns {Promise} */ /** * @callback WalkerIterateCallback * @param {WalkerEntry[]} entries * @returns {Promise} */ /** * @callback WalkerIterate * @param {WalkerIterateCallback} walk * @param {IterableIterator} children * @returns {Promise} */ /** * @typedef {Object} RefUpdateStatus * @property {boolean} ok * @property {string} error */ /** * @typedef {Object} PushResult * @property {boolean} ok * @property {?string} error * @property {Object} refs * @property {Object} [headers] */ /** * @typedef {0|1} HeadStatus */ /** * @typedef {0|1|2} WorkdirStatus */ /** * @typedef {0|1|2|3} StageStatus */ /** * @typedef {[string, HeadStatus, WorkdirStatus, StageStatus]} StatusRow */ /** * @typedef {'push' | 'pop' | 'apply' | 'drop' | 'list' | 'clear'} StashOp the type of stash ops */ /** * @typedef {'equal' | 'modify' | 'add' | 'remove' | 'unknown'} StashChangeType - when compare WORDIR to HEAD, 'remove' could mean 'untracked' * @typedef {Object} ClientRef * @property {string} ref The name of the ref * @property {string} oid The SHA-1 object id the ref points to */ /** * @typedef {Object} PrePushParams * @property {string} remote The expanded name of target remote * @property {string} url The URL address of target remote * @property {ClientRef} localRef The ref which the client wants to push to the remote * @property {ClientRef} remoteRef The ref which is known by the remote */ /** * @callback PrePushCallback * @param {PrePushParams} args * @returns {boolean | Promise} Returns false if push must be cancelled */ /** * @typedef {Object} PostCheckoutParams * @property {string} previousHead The SHA-1 object id of HEAD before checkout * @property {string} newHead The SHA-1 object id of HEAD after checkout * @property {'branch' | 'file'} type flag determining whether a branch or a set of files was checked */ /** * @callback PostCheckoutCallback * @param {PostCheckoutParams} args * @returns {void | Promise} */ // This is straight from parse_unit_factor in config.c of canonical git const num = val => { if (typeof val === 'number') { return val } val = val.toLowerCase(); let n = parseInt(val); if (val.endsWith('k')) n *= 1024; if (val.endsWith('m')) n *= 1024 * 1024; if (val.endsWith('g')) n *= 1024 * 1024 * 1024; return n }; // This is straight from git_parse_maybe_bool_text in config.c of canonical git const bool = val => { if (typeof val === 'boolean') { return val } val = val.trim().toLowerCase(); if (val === 'true' || val === 'yes' || val === 'on') return true if (val === 'false' || val === 'no' || val === 'off') return false throw Error( `Expected 'true', 'false', 'yes', 'no', 'on', or 'off', but got ${val}` ) }; const schema = { core: { filemode: bool, bare: bool, logallrefupdates: bool, symlinks: bool, ignorecase: bool, bigFileThreshold: num, }, }; // https://git-scm.com/docs/git-config#_syntax // section starts with [ and ends with ] // section is alphanumeric (ASCII) with - and . // section is case insensitive // subsection is optional // subsection is specified after section and one or more spaces // subsection is specified between double quotes const SECTION_LINE_REGEX = /^\[([A-Za-z0-9-.]+)(?: "(.*)")?\]$/; const SECTION_REGEX = /^[A-Za-z0-9-.]+$/; // variable lines contain a name, and equal sign and then a value // variable lines can also only contain a name (the implicit value is a boolean true) // variable name is alphanumeric (ASCII) with - // variable name starts with an alphabetic character // variable name is case insensitive const VARIABLE_LINE_REGEX = /^([A-Za-z][A-Za-z-]*)(?: *= *(.*))?$/; const VARIABLE_NAME_REGEX = /^[A-Za-z][A-Za-z-]*$/; // Comments start with either # or ; and extend to the end of line const VARIABLE_VALUE_COMMENT_REGEX = /^(.*?)( *[#;].*)$/; const extractSectionLine = line => { const matches = SECTION_LINE_REGEX.exec(line); if (matches != null) { const [section, subsection] = matches.slice(1); return [section, subsection] } return null }; const extractVariableLine = line => { const matches = VARIABLE_LINE_REGEX.exec(line); if (matches != null) { const [name, rawValue = 'true'] = matches.slice(1); const valueWithoutComments = removeComments(rawValue); const valueWithoutQuotes = removeQuotes(valueWithoutComments); return [name, valueWithoutQuotes] } return null }; const removeComments = rawValue => { const commentMatches = VARIABLE_VALUE_COMMENT_REGEX.exec(rawValue); if (commentMatches == null) { return rawValue } const [valueWithoutComment, comment] = commentMatches.slice(1); // if odd number of quotes before and after comment => comment is escaped if ( hasOddNumberOfQuotes(valueWithoutComment) && hasOddNumberOfQuotes(comment) ) { return `${valueWithoutComment}${comment}` } return valueWithoutComment }; const hasOddNumberOfQuotes = text => { const numberOfQuotes = (text.match(/(?:^|[^\\])"/g) || []).length; return numberOfQuotes % 2 !== 0 }; const removeQuotes = text => { return text.split('').reduce((newText, c, idx, text) => { const isQuote = c === '"' && text[idx - 1] !== '\\'; const isEscapeForQuote = c === '\\' && text[idx + 1] === '"'; if (isQuote || isEscapeForQuote) { return newText } return newText + c }, '') }; const lower = text => { return text != null ? text.toLowerCase() : null }; const getPath = (section, subsection, name) => { return [lower(section), subsection, lower(name)] .filter(a => a != null) .join('.') }; const normalizePath = path => { const pathSegments = path.split('.'); const section = pathSegments.shift(); const name = pathSegments.pop(); const subsection = pathSegments.length ? pathSegments.join('.') : undefined; return { section, subsection, name, path: getPath(section, subsection, name), sectionPath: getPath(section, subsection, null), isSection: !!section, } }; const findLastIndex = (array, callback) => { return array.reduce((lastIndex, item, index) => { return callback(item) ? index : lastIndex }, -1) }; // Note: there are a LOT of edge cases that aren't covered (e.g. keys in sections that also // have subsections, [include] directives, etc. class GitConfig { constructor(text) { let section = null; let subsection = null; this.parsedConfig = text ? text.split('\n').map(line => { let name = null; let value = null; const trimmedLine = line.trim(); const extractedSection = extractSectionLine(trimmedLine); const isSection = extractedSection != null; if (isSection) { ;[section, subsection] = extractedSection; } else { const extractedVariable = extractVariableLine(trimmedLine); const isVariable = extractedVariable != null; if (isVariable) { ;[name, value] = extractedVariable; } } const path = getPath(section, subsection, name); return { line, isSection, section, subsection, name, value, path } }) : []; } static from(text) { return new GitConfig(text) } async get(path, getall = false) { const normalizedPath = normalizePath(path).path; const allValues = this.parsedConfig .filter(config => config.path === normalizedPath) .map(({ section, name, value }) => { const fn = schema[section] && schema[section][name]; return fn ? fn(value) : value }); return getall ? allValues : allValues.pop() } async getall(path) { return this.get(path, true) } async getSubsections(section) { return this.parsedConfig .filter(config => config.isSection && config.section === section) .map(config => config.subsection) } async deleteSection(section, subsection) { this.parsedConfig = this.parsedConfig.filter( config => !(config.section === section && config.subsection === subsection) ); } async append(path, value) { return this.set(path, value, true) } async set(path, value, append = false) { const { section, subsection, name, path: normalizedPath, sectionPath, isSection, } = normalizePath(path); const configIndex = findLastIndex( this.parsedConfig, config => config.path === normalizedPath ); if (value == null) { if (configIndex !== -1) { this.parsedConfig.splice(configIndex, 1); } } else { if (configIndex !== -1) { const config = this.parsedConfig[configIndex]; // Name should be overwritten in case the casing changed const modifiedConfig = Object.assign({}, config, { name, value, modified: true, }); if (append) { this.parsedConfig.splice(configIndex + 1, 0, modifiedConfig); } else { this.parsedConfig[configIndex] = modifiedConfig; } } else { const sectionIndex = this.parsedConfig.findIndex( config => config.path === sectionPath ); const newConfig = { section, subsection, name, value, modified: true, path: normalizedPath, }; if (SECTION_REGEX.test(section) && VARIABLE_NAME_REGEX.test(name)) { if (sectionIndex >= 0) { // Reuse existing section this.parsedConfig.splice(sectionIndex + 1, 0, newConfig); } else { // Add a new section const newSection = { isSection, section, subsection, modified: true, path: sectionPath, }; this.parsedConfig.push(newSection, newConfig); } } } } } toString() { return this.parsedConfig .map(({ line, section, subsection, name, value, modified = false }) => { if (!modified) { return line } if (name != null && value != null) { if (typeof value === 'string' && /[#;]/.test(value)) { // A `#` or `;` symbol denotes a comment, so we have to wrap it in double quotes return `\t${name} = "${value}"` } return `\t${name} = ${value}` } if (subsection != null) { return `[${section} "${subsection}"]` } return `[${section}]` }) .join('\n') } } /** * Manages access to the Git configuration file, providing methods to read and save configurations. */ class GitConfigManager { /** * Reads the Git configuration file from the specified `.git` directory. * * @param {object} opts - Options for reading the Git configuration. * @param {FSClient} opts.fs - A file system implementation. * @param {string} opts.gitdir - The path to the `.git` directory. * @returns {Promise} A `GitConfig` object representing the parsed configuration. */ static async get({ fs, gitdir }) { // We can improve efficiency later if needed. // TODO: read from full list of git config files const text = await fs.read(`${gitdir}/config`, { encoding: 'utf8' }); return GitConfig.from(text) } /** * Saves the provided Git configuration to the specified `.git` directory. * * @param {object} opts - Options for saving the Git configuration. * @param {FSClient} opts.fs - A file system implementation. * @param {string} opts.gitdir - The path to the `.git` directory. * @param {GitConfig} opts.config - The `GitConfig` object to save. * @returns {Promise} Resolves when the configuration has been successfully saved. */ static async save({ fs, gitdir, config }) { // We can improve efficiency later if needed. // TODO: handle saving to the correct global/user/repo location await fs.write(`${gitdir}/config`, config.toString(), { encoding: 'utf8', }); } } function basename(path) { const last = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); if (last > -1) { path = path.slice(last + 1); } return path } function dirname(path) { const last = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); if (last === -1) return '.' if (last === 0) return '/' return path.slice(0, last) } /*! * This code for `path.join` is directly copied from @zenfs/core/path for bundle size improvements. * SPDX-License-Identifier: LGPL-3.0-or-later * Copyright (c) James Prevett and other ZenFS contributors. */ function normalizeString(path, aar) { let res = ''; let lastSegmentLength = 0; let lastSlash = -1; let dots = 0; let char = '\x00'; for (let i = 0; i <= path.length; ++i) { if (i < path.length) char = path[i]; else if (char === '/') break else char = '/'; if (char === '/') { if (lastSlash === i - 1 || dots === 1) { // NOOP } else if (dots === 2) { if ( res.length < 2 || lastSegmentLength !== 2 || res.at(-1) !== '.' || res.at(-2) !== '.' ) { if (res.length > 2) { const lastSlashIndex = res.lastIndexOf('/'); if (lastSlashIndex === -1) { res = ''; lastSegmentLength = 0; } else { res = res.slice(0, lastSlashIndex); lastSegmentLength = res.length - 1 - res.lastIndexOf('/'); } lastSlash = i; dots = 0; continue } else if (res.length !== 0) { res = ''; lastSegmentLength = 0; lastSlash = i; dots = 0; continue } } if (aar) { res += res.length > 0 ? '/..' : '..'; lastSegmentLength = 2; } } else { if (res.length > 0) res += '/' + path.slice(lastSlash + 1, i); else res = path.slice(lastSlash + 1, i); lastSegmentLength = i - lastSlash - 1; } lastSlash = i; dots = 0; } else if (char === '.' && dots !== -1) { ++dots; } else { dots = -1; } } return res } function normalize(path) { if (!path.length) return '.' const isAbsolute = path[0] === '/'; const trailingSeparator = path.at(-1) === '/'; path = normalizeString(path, !isAbsolute); if (!path.length) { if (isAbsolute) return '/' return trailingSeparator ? './' : '.' } if (trailingSeparator) path += '/'; return isAbsolute ? `/${path}` : path } function join(...args) { if (args.length === 0) return '.' let joined; for (let i = 0; i < args.length; ++i) { const arg = args[i]; if (arg.length > 0) { if (joined === undefined) joined = arg; else joined += '/' + arg; } } if (joined === undefined) return '.' return normalize(joined) } // I'm putting this in a Manager because I reckon it could benefit // from a LOT of caching. class GitIgnoreManager { /** * Determines whether a given file is ignored based on `.gitignore` rules and exclusion files. * * @param {Object} args * @param {FSClient} args.fs - A file system implementation. * @param {string} args.dir - The working directory. * @param {string} [args.gitdir=join(dir, '.git')] - [required] The [git directory](dir-vs-gitdir.md) path * @param {string} args.filepath - The path of the file to check. * @returns {Promise} - `true` if the file is ignored, `false` otherwise. */ static async isIgnored({ fs, dir, gitdir = join(dir, '.git'), filepath }) { // ALWAYS ignore ".git" folders. if (basename(filepath) === '.git') return true // '.' is not a valid gitignore entry, so '.' is never ignored if (filepath === '.') return false // Check and load exclusion rules from project exclude file (.git/info/exclude) let excludes = ''; const excludesFile = join(gitdir, 'info', 'exclude'); if (await fs.exists(excludesFile)) { excludes = await fs.read(excludesFile, 'utf8'); } // Find all the .gitignore files that could affect this file const pairs = [ { gitignore: join(dir, '.gitignore'), filepath, }, ]; const pieces = filepath.split('/').filter(Boolean); for (let i = 1; i < pieces.length; i++) { const folder = pieces.slice(0, i).join('/'); const file = pieces.slice(i).join('/'); pairs.push({ gitignore: join(dir, folder, '.gitignore'), filepath: file, }); } let ignoredStatus = false; for (const p of pairs) { let file; try { file = await fs.read(p.gitignore, 'utf8'); } catch (err) { if (err.code === 'NOENT') continue } const ign = ignore().add(excludes); ign.add(file); // If the parent directory is excluded, we are done. // "It is not possible to re-include a file if a parent directory of that file is excluded. Git doesn’t list excluded directories for performance reasons, so any patterns on contained files have no effect, no matter where they are defined." // source: https://git-scm.com/docs/gitignore const parentdir = dirname(p.filepath); if (parentdir !== '.' && ign.ignores(parentdir)) return true // If the file is currently ignored, test for UNignoring. if (ignoredStatus) { ignoredStatus = !ign.test(p.filepath).unignored; } else { ignoredStatus = ign.test(p.filepath).ignored; } } return ignoredStatus } } class BaseError extends Error { constructor(message) { super(message); // Setting this here allows TS to infer that all git errors have a `caller` property and // that its type is string. this.caller = ''; } toJSON() { // Error objects aren't normally serializable. So we do something about that. return { code: this.code, data: this.data, caller: this.caller, message: this.message, stack: this.stack, } } fromJSON(json) { const e = new BaseError(json.message); e.code = json.code; e.data = json.data; e.caller = json.caller; e.stack = json.stack; return e } get isIsomorphicGitError() { return true } } class UnmergedPathsError extends BaseError { /** * @param {Array} filepaths */ constructor(filepaths) { super( `Modifying the index is not possible because you have unmerged files: ${filepaths.toString}. Fix them up in the work tree, and then use 'git add/rm as appropriate to mark resolution and make a commit.` ); this.code = this.name = UnmergedPathsError.code; this.data = { filepaths }; } } /** @type {'UnmergedPathsError'} */ UnmergedPathsError.code = 'UnmergedPathsError'; class InternalError extends BaseError { /** * @param {string} message */ constructor(message) { super( `An internal error caused this command to fail.\n\nIf you're not a developer, report the bug to the developers of the application you're using. If this is a bug in isomorphic-git then you should create a proper bug yourselves. The bug should include a minimal reproduction and details about the version and environment.\n\nPlease file a bug report at https://github.com/isomorphic-git/isomorphic-git/issues with this error message: ${message}` ); this.code = this.name = InternalError.code; this.data = { message }; } } /** @type {'InternalError'} */ InternalError.code = 'InternalError'; class UnsafeFilepathError extends BaseError { /** * @param {string} filepath */ constructor(filepath) { super(`The filepath "${filepath}" contains unsafe character sequences`); this.code = this.name = UnsafeFilepathError.code; this.data = { filepath }; } } /** @type {'UnsafeFilepathError'} */ UnsafeFilepathError.code = 'UnsafeFilepathError'; // Modeled after https://github.com/tjfontaine/node-buffercursor // but with the goal of being much lighter weight. class BufferCursor { constructor(buffer) { this.buffer = buffer; this._start = 0; } eof() { return this._start >= this.buffer.length } tell() { return this._start } seek(n) { this._start = n; } slice(n) { const r = this.buffer.slice(this._start, this._start + n); this._start += n; return r } toString(enc, length) { const r = this.buffer.toString(enc, this._start, this._start + length); this._start += length; return r } write(value, length, enc) { const r = this.buffer.write(value, this._start, length, enc); this._start += length; return r } copy(source, start, end) { const r = source.copy(this.buffer, this._start, start, end); this._start += r; return r } readUInt8() { const r = this.buffer.readUInt8(this._start); this._start += 1; return r } writeUInt8(value) { const r = this.buffer.writeUInt8(value, this._start); this._start += 1; return r } readUInt16BE() { const r = this.buffer.readUInt16BE(this._start); this._start += 2; return r } writeUInt16BE(value) { const r = this.buffer.writeUInt16BE(value, this._start); this._start += 2; return r } readUInt32BE() { const r = this.buffer.readUInt32BE(this._start); this._start += 4; return r } writeUInt32BE(value) { const r = this.buffer.writeUInt32BE(value, this._start); this._start += 4; return r } } function compareStrings(a, b) { // https://stackoverflow.com/a/40355107/2168416 return -(a < b) || +(a > b) } function comparePath(a, b) { // https://stackoverflow.com/a/40355107/2168416 return compareStrings(a.path, b.path) } /** * From https://github.com/git/git/blob/master/Documentation/technical/index-format.txt * * 32-bit mode, split into (high to low bits) * * 4-bit object type * valid values in binary are 1000 (regular file), 1010 (symbolic link) * and 1110 (gitlink) * * 3-bit unused * * 9-bit unix permission. Only 0755 and 0644 are valid for regular files. * Symbolic links and gitlinks have value 0 in this field. */ function normalizeMode(mode) { // Note: BrowserFS will use -1 for "unknown" // I need to make it non-negative for these bitshifts to work. let type = mode > 0 ? mode >> 12 : 0; // If it isn't valid, assume it as a "regular file" // 0100 = directory // 1000 = regular file // 1010 = symlink // 1110 = gitlink if ( type !== 0b0100 && type !== 0b1000 && type !== 0b1010 && type !== 0b1110 ) { type = 0b1000; } let permissions = mode & 0o777; // Is the file executable? then 755. Else 644. if (permissions & 0b001001001) { permissions = 0o755; } else { permissions = 0o644; } // If it's not a regular file, scrub all permissions if (type !== 0b1000) permissions = 0; return (type << 12) + permissions } const MAX_UINT32 = 2 ** 32; function SecondsNanoseconds( givenSeconds, givenNanoseconds, milliseconds, date ) { if (givenSeconds !== undefined && givenNanoseconds !== undefined) { return [givenSeconds, givenNanoseconds] } if (milliseconds === undefined) { milliseconds = date.valueOf(); } const seconds = Math.floor(milliseconds / 1000); const nanoseconds = (milliseconds - seconds * 1000) * 1000000; return [seconds, nanoseconds] } function normalizeStats(e) { const [ctimeSeconds, ctimeNanoseconds] = SecondsNanoseconds( e.ctimeSeconds, e.ctimeNanoseconds, e.ctimeMs, e.ctime ); const [mtimeSeconds, mtimeNanoseconds] = SecondsNanoseconds( e.mtimeSeconds, e.mtimeNanoseconds, e.mtimeMs, e.mtime ); return { ctimeSeconds: ctimeSeconds % MAX_UINT32, ctimeNanoseconds: ctimeNanoseconds % MAX_UINT32, mtimeSeconds: mtimeSeconds % MAX_UINT32, mtimeNanoseconds: mtimeNanoseconds % MAX_UINT32, dev: e.dev % MAX_UINT32, ino: e.ino % MAX_UINT32, mode: normalizeMode(e.mode % MAX_UINT32), uid: e.uid % MAX_UINT32, gid: e.gid % MAX_UINT32, // size of -1 happens over a BrowserFS HTTP Backend that doesn't serve Content-Length headers // (like the Karma webserver) because BrowserFS HTTP Backend uses HTTP HEAD requests to do fs.stat size: e.size > -1 ? e.size % MAX_UINT32 : 0, } } function toHex(buffer) { let hex = ''; for (const byte of new Uint8Array(buffer)) { if (byte < 16) hex += '0'; hex += byte.toString(16); } return hex } /* eslint-env node, browser */ let supportsSubtleSHA1 = null; async function shasum(buffer) { if (supportsSubtleSHA1 === null) { supportsSubtleSHA1 = await testSubtleSHA1(); } return supportsSubtleSHA1 ? subtleSHA1(buffer) : shasumSync(buffer) } // This is modeled after @dominictarr's "shasum" module, // but without the 'json-stable-stringify' dependency and // extra type-casting features. function shasumSync(buffer) { return new Hash().update(buffer).digest('hex') } async function subtleSHA1(buffer) { const hash = await crypto.subtle.digest('SHA-1', buffer); return toHex(hash) } async function testSubtleSHA1() { // I'm using a rather crude method of progressive enhancement, because // some browsers that have crypto.subtle.digest don't actually implement SHA-1. try { const hash = await subtleSHA1(new Uint8Array([])); return hash === 'da39a3ee5e6b4b0d3255bfef95601890afd80709' } catch (_) { // no bother } return false } // Extract 1-bit assume-valid, 1-bit extended flag, 2-bit merge state flag, 12-bit path length flag function parseCacheEntryFlags(bits) { return { assumeValid: Boolean(bits & 0b1000000000000000), extended: Boolean(bits & 0b0100000000000000), stage: (bits & 0b0011000000000000) >> 12, nameLength: bits & 0b0000111111111111, } } function renderCacheEntryFlags(entry) { const flags = entry.flags; // 1-bit extended flag (must be zero in version 2) flags.extended = false; // 12-bit name length if the length is less than 0xFFF; otherwise 0xFFF // is stored in this field. flags.nameLength = Math.min(Buffer.from(entry.path).length, 0xfff); return ( (flags.assumeValid ? 0b1000000000000000 : 0) + (flags.extended ? 0b0100000000000000 : 0) + ((flags.stage & 0b11) << 12) + (flags.nameLength & 0b111111111111) ) } class GitIndex { /*:: _entries: Map _dirty: boolean // Used to determine if index needs to be saved to filesystem */ constructor(entries, unmergedPaths) { this._dirty = false; this._unmergedPaths = unmergedPaths || new Set(); this._entries = entries || new Map(); } _addEntry(entry) { if (entry.flags.stage === 0) { entry.stages = [entry]; this._entries.set(entry.path, entry); this._unmergedPaths.delete(entry.path); } else { let existingEntry = this._entries.get(entry.path); if (!existingEntry) { this._entries.set(entry.path, entry); existingEntry = entry; } existingEntry.stages[entry.flags.stage] = entry; this._unmergedPaths.add(entry.path); } } static async from(buffer) { if (Buffer.isBuffer(buffer)) { return GitIndex.fromBuffer(buffer) } else if (buffer === null) { return new GitIndex(null) } else { throw new InternalError('invalid type passed to GitIndex.from') } } static async fromBuffer(buffer) { if (buffer.length === 0) { throw new InternalError('Index file is empty (.git/index)') } const index = new GitIndex(); const reader = new BufferCursor(buffer); const magic = reader.toString('utf8', 4); if (magic !== 'DIRC') { throw new InternalError(`Invalid dircache magic file number: ${magic}`) } // Verify shasum after we ensured that the file has a magic number const shaComputed = await shasum(buffer.slice(0, -20)); const shaClaimed = buffer.slice(-20).toString('hex'); if (shaClaimed !== shaComputed) { throw new InternalError( `Invalid checksum in GitIndex buffer: expected ${shaClaimed} but saw ${shaComputed}` ) } const version = reader.readUInt32BE(); if (version !== 2) { throw new InternalError(`Unsupported dircache version: ${version}`) } const numEntries = reader.readUInt32BE(); let i = 0; while (!reader.eof() && i < numEntries) { const entry = {}; entry.ctimeSeconds = reader.readUInt32BE(); entry.ctimeNanoseconds = reader.readUInt32BE(); entry.mtimeSeconds = reader.readUInt32BE(); entry.mtimeNanoseconds = reader.readUInt32BE(); entry.dev = reader.readUInt32BE(); entry.ino = reader.readUInt32BE(); entry.mode = reader.readUInt32BE(); entry.uid = reader.readUInt32BE(); entry.gid = reader.readUInt32BE(); entry.size = reader.readUInt32BE(); entry.oid = reader.slice(20).toString('hex'); const flags = reader.readUInt16BE(); entry.flags = parseCacheEntryFlags(flags); // TODO: handle if (version === 3 && entry.flags.extended) const pathlength = buffer.indexOf(0, reader.tell() + 1) - reader.tell(); if (pathlength < 1) { throw new InternalError(`Got a path length of: ${pathlength}`) } // TODO: handle pathnames larger than 12 bits entry.path = reader.toString('utf8', pathlength); // Prevent malicious paths like "..\foo" if (entry.path.includes('..\\') || entry.path.includes('../')) { throw new UnsafeFilepathError(entry.path) } // The next bit is awkward. We expect 1 to 8 null characters // such that the total size of the entry is a multiple of 8 bits. // (Hence subtract 12 bytes for the header.) let padding = 8 - ((reader.tell() - 12) % 8); if (padding === 0) padding = 8; while (padding--) { const tmp = reader.readUInt8(); if (tmp !== 0) { throw new InternalError( `Expected 1-8 null characters but got '${tmp}' after ${entry.path}` ) } else if (reader.eof()) { throw new InternalError('Unexpected end of file') } } // end of awkward part entry.stages = []; index._addEntry(entry); i++; } return index } get unmergedPaths() { return [...this._unmergedPaths] } get entries() { return [...this._entries.values()].sort(comparePath) } get entriesMap() { return this._entries } get entriesFlat() { return [...this.entries].flatMap(entry => { return entry.stages.length > 1 ? entry.stages.filter(x => x) : entry }) } *[Symbol.iterator]() { for (const entry of this.entries) { yield entry; } } insert({ filepath, stats, oid, stage = 0 }) { if (!stats) { stats = { ctimeSeconds: 0, ctimeNanoseconds: 0, mtimeSeconds: 0, mtimeNanoseconds: 0, dev: 0, ino: 0, mode: 0, uid: 0, gid: 0, size: 0, }; } stats = normalizeStats(stats); const bfilepath = Buffer.from(filepath); const entry = { ctimeSeconds: stats.ctimeSeconds, ctimeNanoseconds: stats.ctimeNanoseconds, mtimeSeconds: stats.mtimeSeconds, mtimeNanoseconds: stats.mtimeNanoseconds, dev: stats.dev, ino: stats.ino, // We provide a fallback value for `mode` here because not all fs // implementations assign it, but we use it in GitTree. // '100644' is for a "regular non-executable file" mode: stats.mode || 0o100644, uid: stats.uid, gid: stats.gid, size: stats.size, path: filepath, oid, flags: { assumeValid: false, extended: false, stage, nameLength: bfilepath.length < 0xfff ? bfilepath.length : 0xfff, }, stages: [], }; this._addEntry(entry); this._dirty = true; } delete({ filepath }) { if (this._entries.has(filepath)) { this._entries.delete(filepath); } else { for (const key of this._entries.keys()) { if (key.startsWith(filepath + '/')) { this._entries.delete(key); } } } if (this._unmergedPaths.has(filepath)) { this._unmergedPaths.delete(filepath); } this._dirty = true; } clear() { this._entries.clear(); this._dirty = true; } has({ filepath }) { return this._entries.has(filepath) } render() { return this.entries .map(entry => `${entry.mode.toString(8)} ${entry.oid} ${entry.path}`) .join('\n') } static async _entryToBuffer(entry) { const bpath = Buffer.from(entry.path); // the fixed length + the filename + at least one null char => align by 8 const length = Math.ceil((62 + bpath.length + 1) / 8) * 8; const written = Buffer.alloc(length); const writer = new BufferCursor(written); const stat = normalizeStats(entry); writer.writeUInt32BE(stat.ctimeSeconds); writer.writeUInt32BE(stat.ctimeNanoseconds); writer.writeUInt32BE(stat.mtimeSeconds); writer.writeUInt32BE(stat.mtimeNanoseconds); writer.writeUInt32BE(stat.dev); writer.writeUInt32BE(stat.ino); writer.writeUInt32BE(stat.mode); writer.writeUInt32BE(stat.uid); writer.writeUInt32BE(stat.gid); writer.writeUInt32BE(stat.size); writer.write(entry.oid, 20, 'hex'); writer.writeUInt16BE(renderCacheEntryFlags(entry)); writer.write(entry.path, bpath.length, 'utf8'); return written } async toObject() { const header = Buffer.alloc(12); const writer = new BufferCursor(header); writer.write('DIRC', 4, 'utf8'); writer.writeUInt32BE(2); writer.writeUInt32BE(this.entriesFlat.length); let entryBuffers = []; for (const entry of this.entries) { entryBuffers.push(GitIndex._entryToBuffer(entry)); if (entry.stages.length > 1) { for (const stage of entry.stages) { if (stage && stage !== entry) { entryBuffers.push(GitIndex._entryToBuffer(stage)); } } } } entryBuffers = await Promise.all(entryBuffers); const body = Buffer.concat(entryBuffers); const main = Buffer.concat([header, body]); const sum = await shasum(main); return Buffer.concat([main, Buffer.from(sum, 'hex')]) } } function compareStats(entry, stats, filemode = true, trustino = true) { // Comparison based on the description in Paragraph 4 of // https://www.kernel.org/pub/software/scm/git/docs/technical/racy-git.txt const e = normalizeStats(entry); const s = normalizeStats(stats); const staleness = (filemode && e.mode !== s.mode) || e.mtimeSeconds !== s.mtimeSeconds || e.ctimeSeconds !== s.ctimeSeconds || e.uid !== s.uid || e.gid !== s.gid || (trustino && e.ino !== s.ino) || e.size !== s.size; return staleness } // import Lock from '../utils.js' // const lm = new LockManager() let lock = null; const IndexCache = Symbol('IndexCache'); /** * Creates a cache object to store GitIndex and file stats. * @returns {object} A cache object with `map` and `stats` properties. */ function createCache() { return { map: new Map(), stats: new Map(), } } /** * Updates the cached index file by reading the file system and parsing the Git index. * @param {FSClient} fs - A file system implementation. * @param {string} filepath - The path to the Git index file. * @param {object} cache - The cache object to update. * @returns {Promise} */ async function updateCachedIndexFile(fs, filepath, cache) { const [stat, rawIndexFile] = await Promise.all([ fs.lstat(filepath), fs.read(filepath), ]); const index = await GitIndex.from(rawIndexFile); // cache the GitIndex object so we don't need to re-read it every time. cache.map.set(filepath, index); // Save the stat data for the index so we know whether the cached file is stale (modified by an outside process). cache.stats.set(filepath, stat); } /** * Determines whether the cached index file is stale by comparing file stats. * @param {FSClient} fs - A file system implementation. * @param {string} filepath - The path to the Git index file. * @param {object} cache - The cache object containing file stats. * @returns {Promise} `true` if the index file is stale, otherwise `false`. */ async function isIndexStale(fs, filepath, cache) { const savedStats = cache.stats.get(filepath); if (savedStats === undefined) return true if (savedStats === null) return false const currStats = await fs.lstat(filepath); if (currStats === null) return false return compareStats(savedStats, currStats) } class GitIndexManager { /** * Manages access to the Git index file, ensuring thread-safe operations and caching. * * @param {object} opts - Options for acquiring the Git index. * @param {FSClient} opts.fs - A file system implementation. * @param {string} opts.gitdir - The path to the `.git` directory. * @param {object} opts.cache - A shared cache object for storing index data. * @param {boolean} [opts.allowUnmerged=true] - Whether to allow unmerged paths in the index. * @param {function(GitIndex): any} closure - A function to execute with the Git index. * @returns {Promise} The result of the closure function. * @throws {UnmergedPathsError} If unmerged paths exist and `allowUnmerged` is `false`. */ static async acquire({ fs, gitdir, cache, allowUnmerged = true }, closure) { if (!cache[IndexCache]) { cache[IndexCache] = createCache(); } const filepath = `${gitdir}/index`; if (lock === null) lock = new AsyncLock({ maxPending: Infinity }); let result; let unmergedPaths = []; await lock.acquire(filepath, async () => { // Acquire a file lock while we're reading the index // to make sure other processes aren't writing to it // simultaneously, which could result in a corrupted index. // const fileLock = await Lock(filepath) const theIndexCache = cache[IndexCache]; if (await isIndexStale(fs, filepath, theIndexCache)) { await updateCachedIndexFile(fs, filepath, theIndexCache); } const index = theIndexCache.map.get(filepath); unmergedPaths = index.unmergedPaths; if (unmergedPaths.length && !allowUnmerged) throw new UnmergedPathsError(unmergedPaths) result = await closure(index); if (index._dirty) { // Acquire a file lock while we're writing the index file // let fileLock = await Lock(filepath) const buffer = await index.toObject(); await fs.write(filepath, buffer); // Update cached stat value theIndexCache.stats.set(filepath, await fs.lstat(filepath)); index._dirty = false; } }); return result } } class InvalidOidError extends BaseError { /** * @param {string} value */ constructor(value) { super(`Expected a 40-char hex object id but saw "${value}".`); this.code = this.name = InvalidOidError.code; this.data = { value }; } } /** @type {'InvalidOidError'} */ InvalidOidError.code = 'InvalidOidError'; class NoRefspecError extends BaseError { /** * @param {string} remote */ constructor(remote) { super(`Could not find a fetch refspec for remote "${remote}". Make sure the config file has an entry like the following: [remote "${remote}"] \tfetch = +refs/heads/*:refs/remotes/origin/* `); this.code = this.name = NoRefspecError.code; this.data = { remote }; } } /** @type {'NoRefspecError'} */ NoRefspecError.code = 'NoRefspecError'; class NotFoundError extends BaseError { /** * @param {string} what */ constructor(what) { super(`Could not find ${what}.`); this.code = this.name = NotFoundError.code; this.data = { what }; } } /** @type {'NotFoundError'} */ NotFoundError.code = 'NotFoundError'; class GitPackedRefs { constructor(text) { this.refs = new Map(); this.parsedConfig = []; if (text) { let key = null; this.parsedConfig = text .trim() .split('\n') .map(line => { if (/^\s*#/.test(line)) { return { line, comment: true } } const i = line.indexOf(' '); if (line.startsWith('^')) { // This is a oid for the commit associated with the annotated tag immediately preceding this line. // Trim off the '^' const value = line.slice(1); // The tagname^{} syntax is based on the output of `git show-ref --tags -d` this.refs.set(key + '^{}', value); return { line, ref: key, peeled: value } } else { // This is an oid followed by the ref name const value = line.slice(0, i); key = line.slice(i + 1); this.refs.set(key, value); return { line, ref: key, oid: value } } }); } return this } static from(text) { return new GitPackedRefs(text) } delete(ref) { this.parsedConfig = this.parsedConfig.filter(entry => entry.ref !== ref); this.refs.delete(ref); } toString() { return this.parsedConfig.map(({ line }) => line).join('\n') + '\n' } } class GitRefSpec { constructor({ remotePath, localPath, force, matchPrefix }) { Object.assign(this, { remotePath, localPath, force, matchPrefix, }); } static from(refspec) { const [forceMatch, remotePath, remoteGlobMatch, localPath, localGlobMatch] = refspec.match(/^(\+?)(.*?)(\*?):(.*?)(\*?)$/).slice(1); const force = forceMatch === '+'; const remoteIsGlob = remoteGlobMatch === '*'; const localIsGlob = localGlobMatch === '*'; // validate // TODO: Make this check more nuanced, and depend on whether this is a fetch refspec or a push refspec if (remoteIsGlob !== localIsGlob) { throw new InternalError('Invalid refspec') } return new GitRefSpec({ remotePath, localPath, force, matchPrefix: remoteIsGlob, }) // TODO: We need to run resolveRef on both paths to expand them to their full name. } translate(remoteBranch) { if (this.matchPrefix) { if (remoteBranch.startsWith(this.remotePath)) { return this.localPath + remoteBranch.replace(this.remotePath, '') } } else { if (remoteBranch === this.remotePath) return this.localPath } return null } reverseTranslate(localBranch) { if (this.matchPrefix) { if (localBranch.startsWith(this.localPath)) { return this.remotePath + localBranch.replace(this.localPath, '') } } else { if (localBranch === this.localPath) return this.remotePath } return null } } class GitRefSpecSet { constructor(rules = []) { this.rules = rules; } static from(refspecs) { const rules = []; for (const refspec of refspecs) { rules.push(GitRefSpec.from(refspec)); // might throw } return new GitRefSpecSet(rules) } add(refspec) { const rule = GitRefSpec.from(refspec); // might throw this.rules.push(rule); } translate(remoteRefs) { const result = []; for (const rule of this.rules) { for (const remoteRef of remoteRefs) { const localRef = rule.translate(remoteRef); if (localRef) { result.push([remoteRef, localRef]); } } } return result } translateOne(remoteRef) { let result = null; for (const rule of this.rules) { const localRef = rule.translate(remoteRef); if (localRef) { result = localRef; } } return result } localNamespaces() { return this.rules .filter(rule => rule.matchPrefix) .map(rule => rule.localPath.replace(/\/$/, '')) } } function compareRefNames(a, b) { // https://stackoverflow.com/a/40355107/2168416 const _a = a.replace(/\^\{\}$/, ''); const _b = b.replace(/\^\{\}$/, ''); const tmp = -(_a < _b) || +(_a > _b); if (tmp === 0) { return a.endsWith('^{}') ? 1 : -1 } return tmp } // @see https://git-scm.com/docs/git-rev-parse.html#_specifying_revisions const refpaths = ref => [ `${ref}`, `refs/${ref}`, `refs/tags/${ref}`, `refs/heads/${ref}`, `refs/remotes/${ref}`, `refs/remotes/${ref}/HEAD`, ]; // @see https://git-scm.com/docs/gitrepository-layout const GIT_FILES = ['config', 'description', 'index', 'shallow', 'commondir']; let lock$1; async function acquireLock(ref, callback) { if (lock$1 === undefined) lock$1 = new AsyncLock(); return lock$1.acquire(ref, callback) } /** * A class for managing Git references, including reading, writing, deleting, and resolving refs. */ class GitRefManager { /** * Updates remote refs based on the provided refspecs and options. * * @param {Object} args * @param {FSClient} args.fs - A file system implementation. * @param {string} [args.gitdir=join(dir, '.git')] - [required] The [git directory](dir-vs-gitdir.md) path * @param {string} args.remote - The name of the remote. * @param {Map} args.refs - A map of refs to their object IDs. * @param {Map} args.symrefs - A map of symbolic refs. * @param {boolean} args.tags - Whether to fetch tags. * @param {string[]} [args.refspecs = undefined] - The refspecs to use. * @param {boolean} [args.prune = false] - Whether to prune stale refs. * @param {boolean} [args.pruneTags = false] - Whether to prune tags. * @returns {Promise} - An object containing pruned refs. */ static async updateRemoteRefs({ fs, gitdir, remote, refs, symrefs, tags, refspecs = undefined, prune = false, pruneTags = false, }) { // Validate input for (const value of refs.values()) { if (!value.match(/[0-9a-f]{40}/)) { throw new InvalidOidError(value) } } const config = await GitConfigManager.get({ fs, gitdir }); if (!refspecs) { refspecs = await config.getall(`remote.${remote}.fetch`); if (refspecs.length === 0) { throw new NoRefspecError(remote) } // There's some interesting behavior with HEAD that doesn't follow the refspec. refspecs.unshift(`+HEAD:refs/remotes/${remote}/HEAD`); } const refspec = GitRefSpecSet.from(refspecs); const actualRefsToWrite = new Map(); // Delete all current tags if the pruneTags argument is true. if (pruneTags) { const tags = await GitRefManager.listRefs({ fs, gitdir, filepath: 'refs/tags', }); await GitRefManager.deleteRefs({ fs, gitdir, refs: tags.map(tag => `refs/tags/${tag}`), }); } // Add all tags if the fetch tags argument is true. if (tags) { for (const serverRef of refs.keys()) { if (serverRef.startsWith('refs/tags') && !serverRef.endsWith('^{}')) { // Git's behavior is to only fetch tags that do not conflict with tags already present. if (!(await GitRefManager.exists({ fs, gitdir, ref: serverRef }))) { // Always use the object id of the tag itself, and not the peeled object id. const oid = refs.get(serverRef); actualRefsToWrite.set(serverRef, oid); } } } } // Combine refs and symrefs giving symrefs priority const refTranslations = refspec.translate([...refs.keys()]); for (const [serverRef, translatedRef] of refTranslations) { const value = refs.get(serverRef); actualRefsToWrite.set(translatedRef, value); } const symrefTranslations = refspec.translate([...symrefs.keys()]); for (const [serverRef, translatedRef] of symrefTranslations) { const value = symrefs.get(serverRef); const symtarget = refspec.translateOne(value); if (symtarget) { actualRefsToWrite.set(translatedRef, `ref: ${symtarget}`); } } // If `prune` argument is true, clear out the existing local refspec roots const pruned = []; if (prune) { for (const filepath of refspec.localNamespaces()) { const refs = ( await GitRefManager.listRefs({ fs, gitdir, filepath, }) ).map(file => `${filepath}/${file}`); for (const ref of refs) { if (!actualRefsToWrite.has(ref)) { pruned.push(ref); } } } if (pruned.length > 0) { await GitRefManager.deleteRefs({ fs, gitdir, refs: pruned }); } } // Update files // TODO: For large repos with a history of thousands of pull requests // (i.e. gitlab-ce) it would be vastly more efficient to write them // to .git/packed-refs. // The trick is to make sure we a) don't write a packed ref that is // already shadowed by a loose ref and b) don't loose any refs already // in packed-refs. Doing this efficiently may be difficult. A // solution that might work is // a) load the current packed-refs file // b) add actualRefsToWrite, overriding the existing values if present // c) enumerate all the loose refs currently in .git/refs/remotes/${remote} // d) overwrite their value with the new value. // Examples of refs we need to avoid writing in loose format for efficieny's sake // are .git/refs/remotes/origin/refs/remotes/remote_mirror_3059 // and .git/refs/remotes/origin/refs/merge-requests for (const [key, value] of actualRefsToWrite) { await acquireLock(key, async () => fs.write(join(gitdir, key), `${value.trim()}\n`, 'utf8') ); } return { pruned } } /** * Writes a ref to the file system. * * @param {Object} args * @param {FSClient} args.fs - A file system implementation. * @param {string} [args.gitdir] - [required] The [git directory](dir-vs-gitdir.md) path * @param {string} args.ref - The ref to write. * @param {string} args.value - The object ID to write. * @returns {Promise} */ // TODO: make this less crude? static async writeRef({ fs, gitdir, ref, value }) { // Validate input if (!value.match(/[0-9a-f]{40}/)) { throw new InvalidOidError(value) } await acquireLock(ref, async () => fs.write(join(gitdir, ref), `${value.trim()}\n`, 'utf8') ); } /** * Writes a symbolic ref to the file system. * * @param {Object} args * @param {FSClient} args.fs - A file system implementation. * @param {string} [args.gitdir] - [required] The [git directory](dir-vs-gitdir.md) path * @param {string} args.ref - The ref to write. * @param {string} args.value - The target ref. * @returns {Promise} */ static async writeSymbolicRef({ fs, gitdir, ref, value }) { await acquireLock(ref, async () => fs.write(join(gitdir, ref), 'ref: ' + `${value.trim()}\n`, 'utf8') ); } /** * Deletes a single ref. * * @param {Object} args * @param {FSClient} args.fs - A file system implementation. * @param {string} [args.gitdir] - [required] The [git directory](dir-vs-gitdir.md) path * @param {string} args.ref - The ref to delete. * @returns {Promise} */ static async deleteRef({ fs, gitdir, ref }) { return GitRefManager.deleteRefs({ fs, gitdir, refs: [ref] }) } /** * Deletes multiple refs. * * @param {Object} args * @param {FSClient} args.fs - A file system implementation. * @param {string} [args.gitdir] - [required] The [git directory](dir-vs-gitdir.md) path * @param {string[]} args.refs - The refs to delete. * @returns {Promise} */ static async deleteRefs({ fs, gitdir, refs }) { // Delete regular ref await Promise.all(refs.map(ref => fs.rm(join(gitdir, ref)))); // Delete any packed ref let text = await acquireLock('packed-refs', async () => fs.read(`${gitdir}/packed-refs`, { encoding: 'utf8' }) ); const packed = GitPackedRefs.from(text); const beforeSize = packed.refs.size; for (const ref of refs) { if (packed.refs.has(ref)) { packed.delete(ref); } } if (packed.refs.size < beforeSize) { text = packed.toString(); await acquireLock('packed-refs', async () => fs.write(`${gitdir}/packed-refs`, text, { encoding: 'utf8' }) ); } } /** * Resolves a ref to its object ID. * * @param {Object} args * @param {FSClient} args.fs - A file system implementation. * @param {string} [args.gitdir] - [required] The [git directory](dir-vs-gitdir.md) path * @param {string} args.ref - The ref to resolve. * @param {number} [args.depth = undefined] - The maximum depth to resolve symbolic refs. * @returns {Promise} - The resolved object ID. */ static async resolve({ fs, gitdir, ref, depth = undefined }) { if (depth !== undefined) { depth--; if (depth === -1) { return ref } } // Is it a ref pointer? if (ref.startsWith('ref: ')) { ref = ref.slice('ref: '.length); return GitRefManager.resolve({ fs, gitdir, ref, depth }) } // Is it a complete and valid SHA? if (ref.length === 40 && /[0-9a-f]{40}/.test(ref)) { return ref } // We need to alternate between the file system and the packed-refs const packedMap = await GitRefManager.packedRefs({ fs, gitdir }); // Look in all the proper paths, in this order const allpaths = refpaths(ref).filter(p => !GIT_FILES.includes(p)); // exclude git system files (#709) for (const ref of allpaths) { const sha = await acquireLock( ref, async () => (await fs.read(`${gitdir}/${ref}`, { encoding: 'utf8' })) || packedMap.get(ref) ); if (sha) { return GitRefManager.resolve({ fs, gitdir, ref: sha.trim(), depth }) } } // Do we give up? throw new NotFoundError(ref) } /** * Checks if a ref exists. * * @param {Object} args * @param {FSClient} args.fs - A file system implementation. * @param {string} [args.gitdir=join(dir, '.git')] - [required] The [git directory](dir-vs-gitdir.md) path * @param {string} args.ref - The ref to check. * @returns {Promise} - True if the ref exists, false otherwise. */ static async exists({ fs, gitdir, ref }) { try { await GitRefManager.expand({ fs, gitdir, ref }); return true } catch (err) { return false } } /** * Expands a ref to its full name. * * @param {Object} args * @param {FSClient} args.fs - A file system implementation. * @param {string} [args.gitdir=join(dir, '.git')] - [required] The [git directory](dir-vs-gitdir.md) path * @param {string} args.ref - The ref to expand. * @returns {Promise} - The full ref name. */ static async expand({ fs, gitdir, ref }) { // Is it a complete and valid SHA? if (ref.length === 40 && /[0-9a-f]{40}/.test(ref)) { return ref } // We need to alternate between the file system and the packed-refs const packedMap = await GitRefManager.packedRefs({ fs, gitdir }); // Look in all the proper paths, in this order const allpaths = refpaths(ref); for (const ref of allpaths) { const refExists = await acquireLock(ref, async () => fs.exists(`${gitdir}/${ref}`) ); if (refExists) return ref if (packedMap.has(ref)) return ref } // Do we give up? throw new NotFoundError(ref) } /** * Expands a ref against a provided map. * * @param {Object} args * @param {string} args.ref - The ref to expand. * @param {Map} args.map - The map of refs. * @returns {Promise} - The expanded ref. */ static async expandAgainstMap({ ref, map }) { // Look in all the proper paths, in this order const allpaths = refpaths(ref); for (const ref of allpaths) { if (await map.has(ref)) return ref } // Do we give up? throw new NotFoundError(ref) } /** * Resolves a ref against a provided map. * * @param {Object} args * @param {string} args.ref - The ref to resolve. * @param {string} [args.fullref = args.ref] - The full ref name. * @param {number} [args.depth = undefined] - The maximum depth to resolve symbolic refs. * @param {Map} args.map - The map of refs. * @returns {Object} - An object containing the full ref and its object ID. */ static resolveAgainstMap({ ref, fullref = ref, depth = undefined, map }) { if (depth !== undefined) { depth--; if (depth === -1) { return { fullref, oid: ref } } } // Is it a ref pointer? if (ref.startsWith('ref: ')) { ref = ref.slice('ref: '.length); return GitRefManager.resolveAgainstMap({ ref, fullref, depth, map }) } // Is it a complete and valid SHA? if (ref.length === 40 && /[0-9a-f]{40}/.test(ref)) { return { fullref, oid: ref } } // Look in all the proper paths, in this order const allpaths = refpaths(ref); for (const ref of allpaths) { const sha = map.get(ref); if (sha) { return GitRefManager.resolveAgainstMap({ ref: sha.trim(), fullref: ref, depth, map, }) } } // Do we give up? throw new NotFoundError(ref) } /** * Reads the packed refs file and returns a map of refs. * * @param {Object} args * @param {FSClient} args.fs - A file system implementation. * @param {string} [args.gitdir=join(dir, '.git')] - [required] The [git directory](dir-vs-gitdir.md) path * @returns {Promise>} - A map of packed refs. */ static async packedRefs({ fs, gitdir }) { const text = await acquireLock('packed-refs', async () => fs.read(`${gitdir}/packed-refs`, { encoding: 'utf8' }) ); const packed = GitPackedRefs.from(text); return packed.refs } /** * Lists all refs matching a given filepath prefix. * * @param {Object} args * @param {FSClient} args.fs - A file system implementation. * @param {string} [args.gitdir=join(dir, '.git')] - [required] The [git directory](dir-vs-gitdir.md) path * @param {string} args.filepath - The filepath prefix to match. * @returns {Promise} - A sorted list of refs. */ static async listRefs({ fs, gitdir, filepath }) { const packedMap = GitRefManager.packedRefs({ fs, gitdir }); let files = null; try { files = await fs.readdirDeep(`${gitdir}/${filepath}`); files = files.map(x => x.replace(`${gitdir}/${filepath}/`, '')); } catch (err) { files = []; } for (let key of (await packedMap).keys()) { // filter by prefix if (key.startsWith(filepath)) { // remove prefix key = key.replace(filepath + '/', ''); // Don't include duplicates; the loose files have precedence anyway if (!files.includes(key)) { files.push(key); } } } // since we just appended things onto an array, we need to sort them now files.sort(compareRefNames); return files } /** * Lists all branches, optionally filtered by remote. * * @param {Object} args * @param {FSClient} args.fs - A file system implementation. * @param {string} [args.gitdir=join(dir, '.git')] - [required] The [git directory](dir-vs-gitdir.md) path * @param {string} [args.remote] - The remote to filter branches by. * @returns {Promise} - A list of branch names. */ static async listBranches({ fs, gitdir, remote }) { if (remote) { return GitRefManager.listRefs({ fs, gitdir, filepath: `refs/remotes/${remote}`, }) } else { return GitRefManager.listRefs({ fs, gitdir, filepath: `refs/heads` }) } } /** * Lists all tags. * * @param {Object} args * @param {FSClient} args.fs - A file system implementation. * @param {string} [args.gitdir=join(dir, '.git')] - [required] The [git directory](dir-vs-gitdir.md) path * @returns {Promise} - A list of tag names. */ static async listTags({ fs, gitdir }) { const tags = await GitRefManager.listRefs({ fs, gitdir, filepath: `refs/tags`, }); return tags.filter(x => !x.endsWith('^{}')) } } class HttpError extends BaseError { /** * @param {number} statusCode * @param {string} statusMessage * @param {string} response */ constructor(statusCode, statusMessage, response) { super(`HTTP Error: ${statusCode} ${statusMessage}`); this.code = this.name = HttpError.code; this.data = { statusCode, statusMessage, response }; } } /** @type {'HttpError'} */ HttpError.code = 'HttpError'; class SmartHttpError extends BaseError { /** * @param {string} preview * @param {string} response */ constructor(preview, response) { super( `Remote did not reply using the "smart" HTTP protocol. Expected "001e# service=git-upload-pack" but received: ${preview}` ); this.code = this.name = SmartHttpError.code; this.data = { preview, response }; } } /** @type {'SmartHttpError'} */ SmartHttpError.code = 'SmartHttpError'; class UserCanceledError extends BaseError { constructor() { super(`The operation was canceled.`); this.code = this.name = UserCanceledError.code; this.data = {}; } } /** @type {'UserCanceledError'} */ UserCanceledError.code = 'UserCanceledError'; function calculateBasicAuthHeader({ username = '', password = '' }) { return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` } // Convert a value to an Async Iterator // This will be easier with async generator functions. function fromValue(value) { let queue = [value]; return { next() { return Promise.resolve({ done: queue.length === 0, value: queue.pop() }) }, return() { queue = []; return {} }, [Symbol.asyncIterator]() { return this }, } } function getIterator(iterable) { if (iterable[Symbol.asyncIterator]) { return iterable[Symbol.asyncIterator]() } if (iterable[Symbol.iterator]) { return iterable[Symbol.iterator]() } if (iterable.next) { return iterable } return fromValue(iterable) } // Currently 'for await' upsets my linters. async function forAwait(iterable, cb) { const iter = getIterator(iterable); while (true) { const { value, done } = await iter.next(); if (value) await cb(value); if (done) break } if (iter.return) iter.return(); } async function collect(iterable) { let size = 0; const buffers = []; // This will be easier once `for await ... of` loops are available. await forAwait(iterable, value => { buffers.push(value); size += value.byteLength; }); const result = new Uint8Array(size); let nextIndex = 0; for (const buffer of buffers) { result.set(buffer, nextIndex); nextIndex += buffer.byteLength; } return result } function extractAuthFromUrl(url) { // For whatever reason, the `fetch` API does not convert credentials embedded in the URL // into Basic Authentication headers automatically. Instead it throws an error! // So we must manually parse the URL, rip out the user:password portion if it is present // and compute the Authorization header. // Note: I tried using new URL(url) but that throws a security exception in Edge. :rolleyes: let userpass = url.match(/^https?:\/\/([^/]+)@/); // No credentials, return the url unmodified and an empty auth object if (userpass == null) return { url, auth: {} } userpass = userpass[1]; const [username, password] = userpass.split(':'); // Remove credentials from URL url = url.replace(`${userpass}@`, ''); // Has credentials, return the fetch-safe URL and the parsed credentials return { url, auth: { username, password } } } class EmptyServerResponseError extends BaseError { constructor() { super(`Empty response from git server.`); this.code = this.name = EmptyServerResponseError.code; this.data = {}; } } /** @type {'EmptyServerResponseError'} */ EmptyServerResponseError.code = 'EmptyServerResponseError'; class ParseError extends BaseError { /** * @param {string} expected * @param {string} actual */ constructor(expected, actual) { super(`Expected "${expected}" but received "${actual}".`); this.code = this.name = ParseError.code; this.data = { expected, actual }; } } /** @type {'ParseError'} */ ParseError.code = 'ParseError'; // inspired by 'gartal' but lighter-weight and more battle-tested. class StreamReader { constructor(stream) { // TODO: fix usage in bundlers before Buffer dependency is removed #1855 if (typeof Buffer === 'undefined') { throw new Error('Missing Buffer dependency') } this.stream = getIterator(stream); this.buffer = null; this.cursor = 0; this.undoCursor = 0; this.started = false; this._ended = false; this._discardedBytes = 0; } eof() { return this._ended && this.cursor === this.buffer.length } tell() { return this._discardedBytes + this.cursor } async byte() { if (this.eof()) return if (!this.started) await this._init(); if (this.cursor === this.buffer.length) { await this._loadnext(); if (this._ended) return } this._moveCursor(1); return this.buffer[this.undoCursor] } async chunk() { if (this.eof()) return if (!this.started) await this._init(); if (this.cursor === this.buffer.length) { await this._loadnext(); if (this._ended) return } this._moveCursor(this.buffer.length); return this.buffer.slice(this.undoCursor, this.cursor) } async read(n) { if (this.eof()) return if (!this.started) await this._init(); if (this.cursor + n > this.buffer.length) { this._trim(); await this._accumulate(n); } this._moveCursor(n); return this.buffer.slice(this.undoCursor, this.cursor) } async skip(n) { if (this.eof()) return if (!this.started) await this._init(); if (this.cursor + n > this.buffer.length) { this._trim(); await this._accumulate(n); } this._moveCursor(n); } async undo() { this.cursor = this.undoCursor; } async _next() { this.started = true; let { done, value } = await this.stream.next(); if (done) { this._ended = true; if (!value) return Buffer.alloc(0) } if (value) { value = Buffer.from(value); } return value } _trim() { // Throw away parts of the buffer we don't need anymore // assert(this.cursor <= this.buffer.length) this.buffer = this.buffer.slice(this.undoCursor); this.cursor -= this.undoCursor; this._discardedBytes += this.undoCursor; this.undoCursor = 0; } _moveCursor(n) { this.undoCursor = this.cursor; this.cursor += n; if (this.cursor > this.buffer.length) { this.cursor = this.buffer.length; } } async _accumulate(n) { if (this._ended) return // Expand the buffer until we have N bytes of data // or we've reached the end of the stream const buffers = [this.buffer]; while (this.cursor + n > lengthBuffers(buffers)) { const nextbuffer = await this._next(); if (this._ended) break buffers.push(nextbuffer); } this.buffer = Buffer.concat(buffers); } async _loadnext() { this._discardedBytes += this.buffer.length; this.undoCursor = 0; this.cursor = 0; this.buffer = await this._next(); } async _init() { this.buffer = await this._next(); } } // This helper function helps us postpone concatenating buffers, which // would create intermediate buffer objects, function lengthBuffers(buffers) { return buffers.reduce((acc, buffer) => acc + buffer.length, 0) } function padHex(b, n) { const s = n.toString(16); return '0'.repeat(b - s.length) + s } /** pkt-line Format --------------- Much (but not all) of the payload is described around pkt-lines. A pkt-line is a variable length binary string. The first four bytes of the line, the pkt-len, indicates the total length of the line, in hexadecimal. The pkt-len includes the 4 bytes used to contain the length's hexadecimal representation. A pkt-line MAY contain binary data, so implementers MUST ensure pkt-line parsing/formatting routines are 8-bit clean. A non-binary line SHOULD BE terminated by an LF, which if present MUST be included in the total length. Receivers MUST treat pkt-lines with non-binary data the same whether or not they contain the trailing LF (stripping the LF if present, and not complaining when it is missing). The maximum length of a pkt-line's data component is 65516 bytes. Implementations MUST NOT send pkt-line whose length exceeds 65520 (65516 bytes of payload + 4 bytes of length data). Implementations SHOULD NOT send an empty pkt-line ("0004"). A pkt-line with a length field of 0 ("0000"), called a flush-pkt, is a special case and MUST be handled differently than an empty pkt-line ("0004"). ---- pkt-line = data-pkt / flush-pkt data-pkt = pkt-len pkt-payload pkt-len = 4*(HEXDIG) pkt-payload = (pkt-len - 4)*(OCTET) flush-pkt = "0000" ---- Examples (as C-style strings): ---- pkt-line actual value --------------------------------- "0006a\n" "a\n" "0005a" "a" "000bfoobar\n" "foobar\n" "0004" "" ---- */ // I'm really using this more as a namespace. // There's not a lot of "state" in a pkt-line class GitPktLine { static flush() { return Buffer.from('0000', 'utf8') } static delim() { return Buffer.from('0001', 'utf8') } static encode(line) { if (typeof line === 'string') { line = Buffer.from(line); } const length = line.length + 4; const hexlength = padHex(4, length); return Buffer.concat([Buffer.from(hexlength, 'utf8'), line]) } static streamReader(stream) { const reader = new StreamReader(stream); return async function read() { try { let length = await reader.read(4); if (length == null) return true length = parseInt(length.toString('utf8'), 16); if (length === 0) return null if (length === 1) return null // delim packets const buffer = await reader.read(length - 4); if (buffer == null) return true return buffer } catch (err) { stream.error = err; return true } } } } // @ts-check /** * @param {function} read */ async function parseCapabilitiesV2(read) { /** @type {Object} */ const capabilities2 = {}; let line; while (true) { line = await read(); if (line === true) break if (line === null) continue line = line.toString('utf8').replace(/\n$/, ''); const i = line.indexOf('='); if (i > -1) { const key = line.slice(0, i); const value = line.slice(i + 1); capabilities2[key] = value; } else { capabilities2[line] = true; } } return { protocolVersion: 2, capabilities2 } } async function parseRefsAdResponse(stream, { service }) { const capabilities = new Set(); const refs = new Map(); const symrefs = new Map(); // There is probably a better way to do this, but for now // let's just throw the result parser inline here. const read = GitPktLine.streamReader(stream); let lineOne = await read(); // skip past any flushes while (lineOne === null) lineOne = await read(); if (lineOne === true) throw new EmptyServerResponseError() // Handle protocol v2 responses (Bitbucket Server doesn't include a `# service=` line) if (lineOne.includes('version 2')) { return parseCapabilitiesV2(read) } // Clients MUST ignore an LF at the end of the line. if (lineOne.toString('utf8').replace(/\n$/, '') !== `# service=${service}`) { throw new ParseError(`# service=${service}\\n`, lineOne.toString('utf8')) } let lineTwo = await read(); // skip past any flushes while (lineTwo === null) lineTwo = await read(); // In the edge case of a brand new repo, zero refs (and zero capabilities) // are returned. if (lineTwo === true) return { capabilities, refs, symrefs } lineTwo = lineTwo.toString('utf8'); // Handle protocol v2 responses if (lineTwo.includes('version 2')) { return parseCapabilitiesV2(read) } const [firstRef, capabilitiesLine] = splitAndAssert(lineTwo, '\x00', '\\x00'); capabilitiesLine.split(' ').map(x => capabilities.add(x)); // see no-refs in https://git-scm.com/docs/pack-protocol#_reference_discovery (since git 2.41.0) if (firstRef !== '0000000000000000000000000000000000000000 capabilities^{}') { const [ref, name] = splitAndAssert(firstRef, ' ', ' '); refs.set(name, ref); while (true) { const line = await read(); if (line === true) break if (line !== null) { const [ref, name] = splitAndAssert(line.toString('utf8'), ' ', ' '); refs.set(name, ref); } } } // Symrefs are thrown into the "capabilities" unfortunately. for (const cap of capabilities) { if (cap.startsWith('symref=')) { const m = cap.match(/symref=([^:]+):(.*)/); if (m.length === 3) { symrefs.set(m[1], m[2]); } } } return { protocolVersion: 1, capabilities, refs, symrefs } } function splitAndAssert(line, sep, expected) { const split = line.trim().split(sep); if (split.length !== 2) { throw new ParseError( `Two strings separated by '${expected}'`, line.toString('utf8') ) } return split } // Try to accommodate known CORS proxy implementations: // - https://jcubic.pl/proxy.php? <-- uses query string // - https://cors.isomorphic-git.org <-- uses path const corsProxify = (corsProxy, url) => corsProxy.endsWith('?') ? `${corsProxy}${url}` : `${corsProxy}/${url.replace(/^https?:\/\//, '')}`; const updateHeaders = (headers, auth) => { // Update the basic auth header if (auth.username || auth.password) { headers.Authorization = calculateBasicAuthHeader(auth); } // but any manually provided headers take precedence if (auth.headers) { Object.assign(headers, auth.headers); } }; /** * @param {GitHttpResponse} res * * @returns {{ preview: string, response: string, data: Buffer }} */ const stringifyBody = async res => { try { // Some services provide a meaningful error message in the body of 403s like "token lacks the scopes necessary to perform this action" const data = Buffer.from(await collect(res.body)); const response = data.toString('utf8'); const preview = response.length < 256 ? response : response.slice(0, 256) + '...'; return { preview, response, data } } catch (e) { return {} } }; class GitRemoteHTTP { /** * Returns the capabilities of the GitRemoteHTTP class. * * @returns {Promise} - An array of supported capabilities. */ static async capabilities() { return ['discover', 'connect'] } /** * Discovers references from a remote Git repository. * * @param {Object} args * @param {HttpClient} args.http - The HTTP client to use for requests. * @param {ProgressCallback} [args.onProgress] - Callback for progress updates. * @param {AuthCallback} [args.onAuth] - Callback for providing authentication credentials. * @param {AuthFailureCallback} [args.onAuthFailure] - Callback for handling authentication failures. * @param {AuthSuccessCallback} [args.onAuthSuccess] - Callback for handling successful authentication. * @param {string} [args.corsProxy] - Optional CORS proxy URL. * @param {string} args.service - The Git service (e.g., "git-upload-pack"). * @param {string} args.url - The URL of the remote repository. * @param {Object} args.headers - HTTP headers to include in the request. * @param {1 | 2} args.protocolVersion - The Git protocol version to use. * @returns {Promise} - The parsed response from the remote repository. * @throws {HttpError} - If the HTTP request fails. * @throws {SmartHttpError} - If the response cannot be parsed. * @throws {UserCanceledError} - If the user cancels the operation. */ static async discover({ http, onProgress, onAuth, onAuthSuccess, onAuthFailure, corsProxy, service, url: _origUrl, headers, protocolVersion, }) { let { url, auth } = extractAuthFromUrl(_origUrl); const proxifiedURL = corsProxy ? corsProxify(corsProxy, url) : url; if (auth.username || auth.password) { headers.Authorization = calculateBasicAuthHeader(auth); } if (protocolVersion === 2) { headers['Git-Protocol'] = 'version=2'; } let res; let tryAgain; let providedAuthBefore = false; do { res = await http.request({ onProgress, method: 'GET', url: `${proxifiedURL}/info/refs?service=${service}`, headers, }); // the default loop behavior tryAgain = false; // 401 is the "correct" response for access denied. 203 is Non-Authoritative Information and comes from Azure DevOps, which // apparently doesn't realize this is a git request and is returning the HTML for the "Azure DevOps Services | Sign In" page. if (res.statusCode === 401 || res.statusCode === 203) { // On subsequent 401s, call `onAuthFailure` instead of `onAuth`. // This is so that naive `onAuth` callbacks that return a fixed value don't create an infinite loop of retrying. const getAuth = providedAuthBefore ? onAuthFailure : onAuth; if (getAuth) { // Acquire credentials and try again // TODO: read `useHttpPath` value from git config and pass along? auth = await getAuth(url, { ...auth, headers: { ...headers }, }); if (auth && auth.cancel) { throw new UserCanceledError() } else if (auth) { updateHeaders(headers, auth); providedAuthBefore = true; tryAgain = true; } } } else if ( res.statusCode === 200 && providedAuthBefore && onAuthSuccess ) { await onAuthSuccess(url, auth); } } while (tryAgain) if (res.statusCode !== 200) { const { response } = await stringifyBody(res); throw new HttpError(res.statusCode, res.statusMessage, response) } // Git "smart" HTTP servers should respond with the correct Content-Type header. if ( res.headers['content-type'] === `application/x-${service}-advertisement` ) { const remoteHTTP = await parseRefsAdResponse(res.body, { service }); remoteHTTP.auth = auth; return remoteHTTP } else { // If they don't send the correct content-type header, that's a good indicator it is either a "dumb" HTTP // server, or the user specified an incorrect remote URL and the response is actually an HTML page. // In this case, we save the response as plain text so we can generate a better error message if needed. const { preview, response, data } = await stringifyBody(res); // For backwards compatibility, try to parse it anyway. // TODO: maybe just throw instead of trying? try { const remoteHTTP = await parseRefsAdResponse([data], { service }); remoteHTTP.auth = auth; return remoteHTTP } catch (e) { throw new SmartHttpError(preview, response) } } } /** * Connects to a remote Git repository and sends a request. * * @param {Object} args * @param {HttpClient} args.http - The HTTP client to use for requests. * @param {ProgressCallback} [args.onProgress] - Callback for progress updates. * @param {string} [args.corsProxy] - Optional CORS proxy URL. * @param {string} args.service - The Git service (e.g., "git-upload-pack"). * @param {string} args.url - The URL of the remote repository. * @param {Object} [args.headers] - HTTP headers to include in the request. * @param {any} args.body - The request body to send. * @param {any} args.auth - Authentication credentials. * @returns {Promise} - The HTTP response from the remote repository. * @throws {HttpError} - If the HTTP request fails. */ static async connect({ http, onProgress, corsProxy, service, url, auth, body, headers, }) { // We already have the "correct" auth value at this point, but // we need to strip out the username/password from the URL yet again. const urlAuth = extractAuthFromUrl(url); if (urlAuth) url = urlAuth.url; if (corsProxy) url = corsProxify(corsProxy, url); headers['content-type'] = `application/x-${service}-request`; headers.accept = `application/x-${service}-result`; updateHeaders(headers, auth); const res = await http.request({ onProgress, method: 'POST', url: `${url}/${service}`, body, headers, }); if (res.statusCode !== 200) { const { response } = stringifyBody(res); throw new HttpError(res.statusCode, res.statusMessage, response) } return res } } class UnknownTransportError extends BaseError { /** * @param {string} url * @param {string} transport * @param {string} [suggestion] */ constructor(url, transport, suggestion) { super( `Git remote "${url}" uses an unrecognized transport protocol: "${transport}"` ); this.code = this.name = UnknownTransportError.code; this.data = { url, transport, suggestion }; } } /** @type {'UnknownTransportError'} */ UnknownTransportError.code = 'UnknownTransportError'; class UrlParseError extends BaseError { /** * @param {string} url */ constructor(url) { super(`Cannot parse remote URL: "${url}"`); this.code = this.name = UrlParseError.code; this.data = { url }; } } /** @type {'UrlParseError'} */ UrlParseError.code = 'UrlParseError'; function translateSSHtoHTTP(url) { // handle "shorter scp-like syntax" url = url.replace(/^git@([^:]+):/, 'https://$1/'); // handle proper SSH URLs url = url.replace(/^ssh:\/\//, 'https://'); return url } /** * A class for managing Git remotes and determining the appropriate remote helper for a given URL. */ class GitRemoteManager { /** * Determines the appropriate remote helper for the given URL. * * @param {Object} args * @param {string} args.url - The URL of the remote repository. * @returns {Object} - The remote helper class for the specified transport. * @throws {UrlParseError} - If the URL cannot be parsed. * @throws {UnknownTransportError} - If the transport is not supported. */ static getRemoteHelperFor({ url }) { // TODO: clean up the remoteHelper API and move into PluginCore const remoteHelpers = new Map(); remoteHelpers.set('http', GitRemoteHTTP); remoteHelpers.set('https', GitRemoteHTTP); const parts = parseRemoteUrl({ url }); if (!parts) { throw new UrlParseError(url) } if (remoteHelpers.has(parts.transport)) { return remoteHelpers.get(parts.transport) } throw new UnknownTransportError( url, parts.transport, parts.transport === 'ssh' ? translateSSHtoHTTP(url) : undefined ) } } /** * Parses a remote URL and extracts its transport and address. * * @param {Object} args * @param {string} args.url - The URL of the remote repository. * @returns {Object|undefined} - An object containing the transport and address, or undefined if parsing fails. */ function parseRemoteUrl({ url }) { // the stupid "shorter scp-like syntax" if (url.startsWith('git@')) { return { transport: 'ssh', address: url, } } const matches = url.match(/(\w+)(:\/\/|::)(.*)/); if (matches === null) return /* * When git encounters a URL of the form ://
, where is * a protocol that it cannot handle natively, it automatically invokes git remote- * with the full URL as the second argument. * * @see https://git-scm.com/docs/git-remote-helpers */ if (matches[2] === '://') { return { transport: matches[1], address: matches[0], } } /* * A URL of the form ::
explicitly instructs git to invoke * git remote- with
as the second argument. * * @see https://git-scm.com/docs/git-remote-helpers */ if (matches[2] === '::') { return { transport: matches[1], address: matches[3], } } } let lock$2 = null; class GitShallowManager { /** * Reads the `shallow` file in the Git repository and returns a set of object IDs (OIDs). * * @param {Object} args * @param {FSClient} args.fs - A file system implementation. * @param {string} [args.gitdir] - [required] The [git directory](dir-vs-gitdir.md) path * @returns {Promise>} - A set of shallow object IDs. */ static async read({ fs, gitdir }) { if (lock$2 === null) lock$2 = new AsyncLock(); const filepath = join(gitdir, 'shallow'); const oids = new Set(); await lock$2.acquire(filepath, async function () { const text = await fs.read(filepath, { encoding: 'utf8' }); if (text === null) return oids // no file if (text.trim() === '') return oids // empty file text .trim() .split('\n') .map(oid => oids.add(oid)); }); return oids } /** * Writes a set of object IDs (OIDs) to the `shallow` file in the Git repository. * If the set is empty, the `shallow` file is removed. * * @param {Object} args * @param {FSClient} args.fs - A file system implementation. * @param {string} [args.gitdir] - [required] The [git directory](dir-vs-gitdir.md) path * @param {Set} args.oids - A set of shallow object IDs to write. * @returns {Promise} */ static async write({ fs, gitdir, oids }) { if (lock$2 === null) lock$2 = new AsyncLock(); const filepath = join(gitdir, 'shallow'); if (oids.size > 0) { const text = [...oids].join('\n') + '\n'; await lock$2.acquire(filepath, async function () { await fs.write(filepath, text, { encoding: 'utf8', }); }); } else { // No shallows await lock$2.acquire(filepath, async function () { await fs.rm(filepath); }); } } } class ObjectTypeError extends BaseError { /** * @param {string} oid * @param {'blob'|'commit'|'tag'|'tree'} actual * @param {'blob'|'commit'|'tag'|'tree'} expected * @param {string} [filepath] */ constructor(oid, actual, expected, filepath) { super( `Object ${oid} ${ filepath ? `at ${filepath}` : '' }was anticipated to be a ${expected} but it is a ${actual}.` ); this.code = this.name = ObjectTypeError.code; this.data = { oid, actual, expected, filepath }; } } /** @type {'ObjectTypeError'} */ ObjectTypeError.code = 'ObjectTypeError'; function formatAuthor({ name, email, timestamp, timezoneOffset }) { timezoneOffset = formatTimezoneOffset(timezoneOffset); return `${name} <${email}> ${timestamp} ${timezoneOffset}` } // The amount of effort that went into crafting these cases to handle // -0 (just so we don't lose that information when parsing and reconstructing) // but can also default to +0 was extraordinary. function formatTimezoneOffset(minutes) { const sign = simpleSign(negateExceptForZero(minutes)); minutes = Math.abs(minutes); const hours = Math.floor(minutes / 60); minutes -= hours * 60; let strHours = String(hours); let strMinutes = String(minutes); if (strHours.length < 2) strHours = '0' + strHours; if (strMinutes.length < 2) strMinutes = '0' + strMinutes; return (sign === -1 ? '-' : '+') + strHours + strMinutes } function simpleSign(n) { return Math.sign(n) || (Object.is(n, -0) ? -1 : 1) } function negateExceptForZero(n) { return n === 0 ? n : -n } function normalizeNewlines(str) { // remove all str = str.replace(/\r/g, ''); // no extra newlines up front str = str.replace(/^\n+/, ''); // and a single newline at the end str = str.replace(/\n+$/, '') + '\n'; return str } function parseAuthor(author) { const [, name, email, timestamp, offset] = author.match( /^(.*) <(.*)> (.*) (.*)$/ ); return { name, email, timestamp: Number(timestamp), timezoneOffset: parseTimezoneOffset(offset), } } // The amount of effort that went into crafting these cases to handle // -0 (just so we don't lose that information when parsing and reconstructing) // but can also default to +0 was extraordinary. function parseTimezoneOffset(offset) { let [, sign, hours, minutes] = offset.match(/(\+|-)(\d\d)(\d\d)/); minutes = (sign === '+' ? 1 : -1) * (Number(hours) * 60 + Number(minutes)); return negateExceptForZero$1(minutes) } function negateExceptForZero$1(n) { return n === 0 ? n : -n } class GitAnnotatedTag { constructor(tag) { if (typeof tag === 'string') { this._tag = tag; } else if (Buffer.isBuffer(tag)) { this._tag = tag.toString('utf8'); } else if (typeof tag === 'object') { this._tag = GitAnnotatedTag.render(tag); } else { throw new InternalError( 'invalid type passed to GitAnnotatedTag constructor' ) } } static from(tag) { return new GitAnnotatedTag(tag) } static render(obj) { return `object ${obj.object} type ${obj.type} tag ${obj.tag} tagger ${formatAuthor(obj.tagger)} ${obj.message} ${obj.gpgsig ? obj.gpgsig : ''}` } justHeaders() { return this._tag.slice(0, this._tag.indexOf('\n\n')) } message() { const tag = this.withoutSignature(); return tag.slice(tag.indexOf('\n\n') + 2) } parse() { return Object.assign(this.headers(), { message: this.message(), gpgsig: this.gpgsig(), }) } render() { return this._tag } headers() { const headers = this.justHeaders().split('\n'); const hs = []; for (const h of headers) { if (h[0] === ' ') { // combine with previous header (without space indent) hs[hs.length - 1] += '\n' + h.slice(1); } else { hs.push(h); } } const obj = {}; for (const h of hs) { const key = h.slice(0, h.indexOf(' ')); const value = h.slice(h.indexOf(' ') + 1); if (Array.isArray(obj[key])) { obj[key].push(value); } else { obj[key] = value; } } if (obj.tagger) { obj.tagger = parseAuthor(obj.tagger); } if (obj.committer) { obj.committer = parseAuthor(obj.committer); } return obj } withoutSignature() { const tag = normalizeNewlines(this._tag); if (tag.indexOf('\n-----BEGIN PGP SIGNATURE-----') === -1) return tag return tag.slice(0, tag.lastIndexOf('\n-----BEGIN PGP SIGNATURE-----')) } gpgsig() { if (this._tag.indexOf('\n-----BEGIN PGP SIGNATURE-----') === -1) return const signature = this._tag.slice( this._tag.indexOf('-----BEGIN PGP SIGNATURE-----'), this._tag.indexOf('-----END PGP SIGNATURE-----') + '-----END PGP SIGNATURE-----'.length ); return normalizeNewlines(signature) } payload() { return this.withoutSignature() + '\n' } toObject() { return Buffer.from(this._tag, 'utf8') } static async sign(tag, sign, secretKey) { const payload = tag.payload(); let { signature } = await sign({ payload, secretKey }); // renormalize the line endings to the one true line-ending signature = normalizeNewlines(signature); const signedTag = payload + signature; // return a new tag object return GitAnnotatedTag.from(signedTag) } } function indent(str) { return ( str .trim() .split('\n') .map(x => ' ' + x) .join('\n') + '\n' ) } function outdent(str) { return str .split('\n') .map(x => x.replace(/^ /, '')) .join('\n') } class GitCommit { constructor(commit) { if (typeof commit === 'string') { this._commit = commit; } else if (Buffer.isBuffer(commit)) { this._commit = commit.toString('utf8'); } else if (typeof commit === 'object') { this._commit = GitCommit.render(commit); } else { throw new InternalError('invalid type passed to GitCommit constructor') } } static fromPayloadSignature({ payload, signature }) { const headers = GitCommit.justHeaders(payload); const message = GitCommit.justMessage(payload); const commit = normalizeNewlines( headers + '\ngpgsig' + indent(signature) + '\n' + message ); return new GitCommit(commit) } static from(commit) { return new GitCommit(commit) } toObject() { return Buffer.from(this._commit, 'utf8') } // Todo: allow setting the headers and message headers() { return this.parseHeaders() } // Todo: allow setting the headers and message message() { return GitCommit.justMessage(this._commit) } parse() { return Object.assign({ message: this.message() }, this.headers()) } static justMessage(commit) { return normalizeNewlines(commit.slice(commit.indexOf('\n\n') + 2)) } static justHeaders(commit) { return commit.slice(0, commit.indexOf('\n\n')) } parseHeaders() { const headers = GitCommit.justHeaders(this._commit).split('\n'); const hs = []; for (const h of headers) { if (h[0] === ' ') { // combine with previous header (without space indent) hs[hs.length - 1] += '\n' + h.slice(1); } else { hs.push(h); } } const obj = { parent: [], }; for (const h of hs) { const key = h.slice(0, h.indexOf(' ')); const value = h.slice(h.indexOf(' ') + 1); if (Array.isArray(obj[key])) { obj[key].push(value); } else { obj[key] = value; } } if (obj.author) { obj.author = parseAuthor(obj.author); } if (obj.committer) { obj.committer = parseAuthor(obj.committer); } return obj } static renderHeaders(obj) { let headers = ''; if (obj.tree) { headers += `tree ${obj.tree}\n`; } else { headers += `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n`; // the null tree } if (obj.parent) { if (obj.parent.length === undefined) { throw new InternalError(`commit 'parent' property should be an array`) } for (const p of obj.parent) { headers += `parent ${p}\n`; } } const author = obj.author; headers += `author ${formatAuthor(author)}\n`; const committer = obj.committer || obj.author; headers += `committer ${formatAuthor(committer)}\n`; if (obj.gpgsig) { headers += 'gpgsig' + indent(obj.gpgsig); } return headers } static render(obj) { return GitCommit.renderHeaders(obj) + '\n' + normalizeNewlines(obj.message) } render() { return this._commit } withoutSignature() { const commit = normalizeNewlines(this._commit); if (commit.indexOf('\ngpgsig') === -1) return commit const headers = commit.slice(0, commit.indexOf('\ngpgsig')); const message = commit.slice( commit.indexOf('-----END PGP SIGNATURE-----\n') + '-----END PGP SIGNATURE-----\n'.length ); return normalizeNewlines(headers + '\n' + message) } isolateSignature() { const signature = this._commit.slice( this._commit.indexOf('-----BEGIN PGP SIGNATURE-----'), this._commit.indexOf('-----END PGP SIGNATURE-----') + '-----END PGP SIGNATURE-----'.length ); return outdent(signature) } static async sign(commit, sign, secretKey) { const payload = commit.withoutSignature(); const message = GitCommit.justMessage(commit._commit); let { signature } = await sign({ payload, secretKey }); // renormalize the line endings to the one true line-ending signature = normalizeNewlines(signature); const headers = GitCommit.justHeaders(commit._commit); const signedCommit = headers + '\n' + 'gpgsig' + indent(signature) + '\n' + message; // return a new commit object return GitCommit.from(signedCommit) } } /** * Represents a Git object and provides methods to wrap and unwrap Git objects * according to the Git object format. */ class GitObject { /** * Wraps a raw object with a Git header. * * @param {Object} params - The parameters for wrapping. * @param {string} params.type - The type of the Git object (e.g., 'blob', 'tree', 'commit'). * @param {Uint8Array} params.object - The raw object data to wrap. * @returns {Uint8Array} The wrapped Git object as a single buffer. */ static wrap({ type, object }) { const header = `${type} ${object.length}\x00`; const headerLen = header.length; const totalLength = headerLen + object.length; // Allocate a single buffer for the header and object, rather than create multiple buffers const wrappedObject = new Uint8Array(totalLength); for (let i = 0; i < headerLen; i++) { wrappedObject[i] = header.charCodeAt(i); } wrappedObject.set(object, headerLen); return wrappedObject } /** * Unwraps a Git object buffer into its type and raw object data. * * @param {Buffer|Uint8Array} buffer - The buffer containing the wrapped Git object. * @returns {{ type: string, object: Buffer }} An object containing the type and the raw object data. * @throws {InternalError} If the length specified in the header does not match the actual object length. */ static unwrap(buffer) { const s = buffer.indexOf(32); // first space const i = buffer.indexOf(0); // first null value const type = buffer.slice(0, s).toString('utf8'); // get type of object const length = buffer.slice(s + 1, i).toString('utf8'); // get type of object const actualLength = buffer.length - (i + 1); // verify length if (parseInt(length) !== actualLength) { throw new InternalError( `Length mismatch: expected ${length} bytes but got ${actualLength} instead.` ) } return { type, object: Buffer.from(buffer.slice(i + 1)), } } } async function readObjectLoose({ fs, gitdir, oid }) { const source = `objects/${oid.slice(0, 2)}/${oid.slice(2)}`; const file = await fs.read(`${gitdir}/${source}`); if (!file) { return null } return { object: file, format: 'deflated', source } } /** * @param {Buffer} delta * @param {Buffer} source * @returns {Buffer} */ function applyDelta(delta, source) { const reader = new BufferCursor(delta); const sourceSize = readVarIntLE(reader); if (sourceSize !== source.byteLength) { throw new InternalError( `applyDelta expected source buffer to be ${sourceSize} bytes but the provided buffer was ${source.length} bytes` ) } const targetSize = readVarIntLE(reader); let target; const firstOp = readOp(reader, source); // Speed optimization - return raw buffer if it's just single simple copy if (firstOp.byteLength === targetSize) { target = firstOp; } else { // Otherwise, allocate a fresh buffer and slices target = Buffer.alloc(targetSize); const writer = new BufferCursor(target); writer.copy(firstOp); while (!reader.eof()) { writer.copy(readOp(reader, source)); } const tell = writer.tell(); if (targetSize !== tell) { throw new InternalError( `applyDelta expected target buffer to be ${targetSize} bytes but the resulting buffer was ${tell} bytes` ) } } return target } function readVarIntLE(reader) { let result = 0; let shift = 0; let byte = null; do { byte = reader.readUInt8(); result |= (byte & 0b01111111) << shift; shift += 7; } while (byte & 0b10000000) return result } function readCompactLE(reader, flags, size) { let result = 0; let shift = 0; while (size--) { if (flags & 0b00000001) { result |= reader.readUInt8() << shift; } flags >>= 1; shift += 8; } return result } function readOp(reader, source) { /** @type {number} */ const byte = reader.readUInt8(); const COPY = 0b10000000; const OFFS = 0b00001111; const SIZE = 0b01110000; if (byte & COPY) { // copy consists of 4 byte offset, 3 byte size (in LE order) const offset = readCompactLE(reader, byte & OFFS, 4); let size = readCompactLE(reader, (byte & SIZE) >> 4, 3); // Yup. They really did this optimization. if (size === 0) size = 0x10000; return source.slice(offset, offset + size) } else { // insert return reader.slice(byte) } } // My version of git-list-pack - roughly 15x faster than the original async function listpack(stream, onData) { const reader = new StreamReader(stream); let PACK = await reader.read(4); PACK = PACK.toString('utf8'); if (PACK !== 'PACK') { throw new InternalError(`Invalid PACK header '${PACK}'`) } let version = await reader.read(4); version = version.readUInt32BE(0); if (version !== 2) { throw new InternalError(`Invalid packfile version: ${version}`) } let numObjects = await reader.read(4); numObjects = numObjects.readUInt32BE(0); // If (for some godforsaken reason) this is an empty packfile, abort now. if (numObjects < 1) return while (!reader.eof() && numObjects--) { const offset = reader.tell(); const { type, length, ofs, reference } = await parseHeader(reader); const inflator = new pako.Inflate(); while (!inflator.result) { const chunk = await reader.chunk(); if (!chunk) break inflator.push(chunk, false); if (inflator.err) { throw new InternalError(`Pako error: ${inflator.msg}`) } if (inflator.result) { if (inflator.result.length !== length) { throw new InternalError( `Inflated object size is different from that stated in packfile.` ) } // Backtrack parser to where deflated data ends await reader.undo(); await reader.read(chunk.length - inflator.strm.avail_in); const end = reader.tell(); await onData({ data: inflator.result, type, num: numObjects, offset, end, reference, ofs, }); } } } } async function parseHeader(reader) { // Object type is encoded in bits 654 let byte = await reader.byte(); const type = (byte >> 4) & 0b111; // The length encoding get complicated. // Last four bits of length is encoded in bits 3210 let length = byte & 0b1111; // Whether the next byte is part of the variable-length encoded number // is encoded in bit 7 if (byte & 0b10000000) { let shift = 4; do { byte = await reader.byte(); length |= (byte & 0b01111111) << shift; shift += 7; } while (byte & 0b10000000) } // Handle deltified objects let ofs; let reference; if (type === 6) { let shift = 0; ofs = 0; const bytes = []; do { byte = await reader.byte(); ofs |= (byte & 0b01111111) << shift; shift += 7; bytes.push(byte); } while (byte & 0b10000000) reference = Buffer.from(bytes); } if (type === 7) { const buf = await reader.read(20); reference = buf; } return { type, length, ofs, reference } } /* eslint-env node, browser */ let supportsDecompressionStream = false; async function inflate(buffer) { if (supportsDecompressionStream === null) { supportsDecompressionStream = testDecompressionStream(); } return supportsDecompressionStream ? browserInflate(buffer) : pako.inflate(buffer) } async function browserInflate(buffer) { const ds = new DecompressionStream('deflate'); const d = new Blob([buffer]).stream().pipeThrough(ds); return new Uint8Array(await new Response(d).arrayBuffer()) } function testDecompressionStream() { try { const ds = new DecompressionStream('deflate'); if (ds) return true } catch (_) { // no bother } return false } function decodeVarInt(reader) { const bytes = []; let byte = 0; let multibyte = 0; do { byte = reader.readUInt8(); // We keep bits 6543210 const lastSeven = byte & 0b01111111; bytes.push(lastSeven); // Whether the next byte is part of the variable-length encoded number // is encoded in bit 7 multibyte = byte & 0b10000000; } while (multibyte) // Now that all the bytes are in big-endian order, // alternate shifting the bits left by 7 and OR-ing the next byte. // And... do a weird increment-by-one thing that I don't quite understand. return bytes.reduce((a, b) => ((a + 1) << 7) | b, -1) } // I'm pretty much copying this one from the git C source code, // because it makes no sense. function otherVarIntDecode(reader, startWith) { let result = startWith; let shift = 4; let byte = null; do { byte = reader.readUInt8(); result |= (byte & 0b01111111) << shift; shift += 7; } while (byte & 0b10000000) return result } class GitPackIndex { constructor(stuff) { Object.assign(this, stuff); this.offsetCache = {}; } static async fromIdx({ idx, getExternalRefDelta }) { const reader = new BufferCursor(idx); const magic = reader.slice(4).toString('hex'); // Check for IDX v2 magic number if (magic !== 'ff744f63') { return // undefined } const version = reader.readUInt32BE(); if (version !== 2) { throw new InternalError( `Unable to read version ${version} packfile IDX. (Only version 2 supported)` ) } if (idx.byteLength > 2048 * 1024 * 1024) { throw new InternalError( `To keep implementation simple, I haven't implemented the layer 5 feature needed to support packfiles > 2GB in size.` ) } // Skip over fanout table reader.seek(reader.tell() + 4 * 255); // Get hashes const size = reader.readUInt32BE(); const hashes = []; for (let i = 0; i < size; i++) { const hash = reader.slice(20).toString('hex'); hashes[i] = hash; } reader.seek(reader.tell() + 4 * size); // Skip over CRCs // Get offsets const offsets = new Map(); for (let i = 0; i < size; i++) { offsets.set(hashes[i], reader.readUInt32BE()); } const packfileSha = reader.slice(20).toString('hex'); return new GitPackIndex({ hashes, crcs: {}, offsets, packfileSha, getExternalRefDelta, }) } static async fromPack({ pack, getExternalRefDelta, onProgress }) { const listpackTypes = { 1: 'commit', 2: 'tree', 3: 'blob', 4: 'tag', 6: 'ofs-delta', 7: 'ref-delta', }; const offsetToObject = {}; // Older packfiles do NOT use the shasum of the pack itself, // so it is recommended to just use whatever bytes are in the trailer. // Source: https://github.com/git/git/commit/1190a1acf800acdcfd7569f87ac1560e2d077414 const packfileSha = pack.slice(-20).toString('hex'); const hashes = []; const crcs = {}; const offsets = new Map(); let totalObjectCount = null; let lastPercent = null; await listpack([pack], async ({ data, type, reference, offset, num }) => { if (totalObjectCount === null) totalObjectCount = num; const percent = Math.floor( ((totalObjectCount - num) * 100) / totalObjectCount ); if (percent !== lastPercent) { if (onProgress) { await onProgress({ phase: 'Receiving objects', loaded: totalObjectCount - num, total: totalObjectCount, }); } } lastPercent = percent; // Change type from a number to a meaningful string type = listpackTypes[type]; if (['commit', 'tree', 'blob', 'tag'].includes(type)) { offsetToObject[offset] = { type, offset, }; } else if (type === 'ofs-delta') { offsetToObject[offset] = { type, offset, }; } else if (type === 'ref-delta') { offsetToObject[offset] = { type, offset, }; } }); // We need to know the lengths of the slices to compute the CRCs. const offsetArray = Object.keys(offsetToObject).map(Number); for (const [i, start] of offsetArray.entries()) { const end = i + 1 === offsetArray.length ? pack.byteLength - 20 : offsetArray[i + 1]; const o = offsetToObject[start]; const crc = crc32.buf(pack.slice(start, end)) >>> 0; o.end = end; o.crc = crc; } // We don't have the hashes yet. But we can generate them using the .readSlice function! const p = new GitPackIndex({ pack: Promise.resolve(pack), packfileSha, crcs, hashes, offsets, getExternalRefDelta, }); // Resolve deltas and compute the oids lastPercent = null; let count = 0; const objectsByDepth = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; for (let offset in offsetToObject) { offset = Number(offset); const percent = Math.floor((count * 100) / totalObjectCount); if (percent !== lastPercent) { if (onProgress) { await onProgress({ phase: 'Resolving deltas', loaded: count, total: totalObjectCount, }); } } count++; lastPercent = percent; const o = offsetToObject[offset]; if (o.oid) continue try { p.readDepth = 0; p.externalReadDepth = 0; const { type, object } = await p.readSlice({ start: offset }); objectsByDepth[p.readDepth] += 1; const oid = await shasum(GitObject.wrap({ type, object })); o.oid = oid; hashes.push(oid); offsets.set(oid, offset); crcs[oid] = o.crc; } catch (err) { continue } } hashes.sort(); return p } async toBuffer() { const buffers = []; const write = (str, encoding) => { buffers.push(Buffer.from(str, encoding)); }; // Write out IDX v2 magic number write('ff744f63', 'hex'); // Write out version number 2 write('00000002', 'hex'); // Write fanout table const fanoutBuffer = new BufferCursor(Buffer.alloc(256 * 4)); for (let i = 0; i < 256; i++) { let count = 0; for (const hash of this.hashes) { if (parseInt(hash.slice(0, 2), 16) <= i) count++; } fanoutBuffer.writeUInt32BE(count); } buffers.push(fanoutBuffer.buffer); // Write out hashes for (const hash of this.hashes) { write(hash, 'hex'); } // Write out crcs const crcsBuffer = new BufferCursor(Buffer.alloc(this.hashes.length * 4)); for (const hash of this.hashes) { crcsBuffer.writeUInt32BE(this.crcs[hash]); } buffers.push(crcsBuffer.buffer); // Write out offsets const offsetsBuffer = new BufferCursor(Buffer.alloc(this.hashes.length * 4)); for (const hash of this.hashes) { offsetsBuffer.writeUInt32BE(this.offsets.get(hash)); } buffers.push(offsetsBuffer.buffer); // Write out packfile checksum write(this.packfileSha, 'hex'); // Write out shasum const totalBuffer = Buffer.concat(buffers); const sha = await shasum(totalBuffer); const shaBuffer = Buffer.alloc(20); shaBuffer.write(sha, 'hex'); return Buffer.concat([totalBuffer, shaBuffer]) } async load({ pack }) { this.pack = pack; } async unload() { this.pack = null; } async read({ oid }) { if (!this.offsets.get(oid)) { if (this.getExternalRefDelta) { this.externalReadDepth++; return this.getExternalRefDelta(oid) } else { throw new InternalError(`Could not read object ${oid} from packfile`) } } const start = this.offsets.get(oid); return this.readSlice({ start }) } async readSlice({ start }) { if (this.offsetCache[start]) { return Object.assign({}, this.offsetCache[start]) } this.readDepth++; const types = { 0b0010000: 'commit', 0b0100000: 'tree', 0b0110000: 'blob', 0b1000000: 'tag', 0b1100000: 'ofs_delta', 0b1110000: 'ref_delta', }; if (!this.pack) { throw new InternalError( 'Tried to read from a GitPackIndex with no packfile loaded into memory' ) } const raw = (await this.pack).slice(start); const reader = new BufferCursor(raw); const byte = reader.readUInt8(); // Object type is encoded in bits 654 const btype = byte & 0b1110000; let type = types[btype]; if (type === undefined) { throw new InternalError('Unrecognized type: 0b' + btype.toString(2)) } // The length encoding get complicated. // Last four bits of length is encoded in bits 3210 const lastFour = byte & 0b1111; let length = lastFour; // Whether the next byte is part of the variable-length encoded number // is encoded in bit 7 const multibyte = byte & 0b10000000; if (multibyte) { length = otherVarIntDecode(reader, lastFour); } let base = null; let object = null; // Handle deltified objects if (type === 'ofs_delta') { const offset = decodeVarInt(reader); const baseOffset = start - offset ;({ object: base, type } = await this.readSlice({ start: baseOffset })); } if (type === 'ref_delta') { const oid = reader.slice(20).toString('hex') ;({ object: base, type } = await this.read({ oid })); } // Handle undeltified objects const buffer = raw.slice(reader.tell()); object = Buffer.from(await inflate(buffer)); // Assert that the object length is as expected. if (object.byteLength !== length) { throw new InternalError( `Packfile told us object would have length ${length} but it had length ${object.byteLength}` ) } if (base) { object = Buffer.from(applyDelta(object, base)); } // Cache the result based on depth. if (this.readDepth > 3) { // hand tuned for speed / memory usage tradeoff this.offsetCache[start] = { type, object }; } return { type, format: 'content', object } } } const PackfileCache = Symbol('PackfileCache'); async function loadPackIndex({ fs, filename, getExternalRefDelta, emitter, emitterPrefix, }) { const idx = await fs.read(filename); return GitPackIndex.fromIdx({ idx, getExternalRefDelta }) } function readPackIndex({ fs, cache, filename, getExternalRefDelta, emitter, emitterPrefix, }) { // Try to get the packfile index from the in-memory cache if (!cache[PackfileCache]) cache[PackfileCache] = new Map(); let p = cache[PackfileCache].get(filename); if (!p) { p = loadPackIndex({ fs, filename, getExternalRefDelta, emitter, emitterPrefix, }); cache[PackfileCache].set(filename, p); } return p } async function readObjectPacked({ fs, cache, gitdir, oid, format = 'content', getExternalRefDelta, }) { // Check to see if it's in a packfile. // Iterate through all the .idx files let list = await fs.readdir(join(gitdir, 'objects/pack')); list = list.filter(x => x.endsWith('.idx')); for (const filename of list) { const indexFile = `${gitdir}/objects/pack/${filename}`; const p = await readPackIndex({ fs, cache, filename: indexFile, getExternalRefDelta, }); if (p.error) throw new InternalError(p.error) // If the packfile DOES have the oid we're looking for... if (p.offsets.has(oid)) { // Get the resolved git object from the packfile if (!p.pack) { const packFile = indexFile.replace(/idx$/, 'pack'); p.pack = fs.read(packFile); } const result = await p.read({ oid, getExternalRefDelta }); result.format = 'content'; result.source = `objects/pack/${filename.replace(/idx$/, 'pack')}`; return result } } // Failed to find it return null } /** * @param {object} args * @param {import('../models/FileSystem.js').FileSystem} args.fs * @param {any} args.cache * @param {string} args.gitdir * @param {string} args.oid * @param {string} [args.format] */ async function _readObject({ fs, cache, gitdir, oid, format = 'content', }) { // Curry the current read method so that the packfile un-deltification // process can acquire external ref-deltas. const getExternalRefDelta = oid => _readObject({ fs, cache, gitdir, oid }); let result; // Empty tree - hard-coded so we can use it as a shorthand. // Note: I think the canonical git implementation must do this too because // `git cat-file -t 4b825dc642cb6eb9a060e54bf8d69288fbee4904` prints "tree" even in empty repos. if (oid === '4b825dc642cb6eb9a060e54bf8d69288fbee4904') { result = { format: 'wrapped', object: Buffer.from(`tree 0\x00`) }; } // Look for it in the loose object directory. if (!result) { result = await readObjectLoose({ fs, gitdir, oid }); } // Check to see if it's in a packfile. if (!result) { result = await readObjectPacked({ fs, cache, gitdir, oid, getExternalRefDelta, }); if (!result) { throw new NotFoundError(oid) } // Directly return packed result, as specified: packed objects always return the 'content' format. return result } // Loose objects are always deflated, return early if (format === 'deflated') { return result } // All loose objects are deflated but the hard-coded empty tree is `wrapped` so we have to check if we need to inflate the object. if (result.format === 'deflated') { result.object = Buffer.from(await inflate(result.object)); result.format = 'wrapped'; } if (format === 'wrapped') { return result } const sha = await shasum(result.object); if (sha !== oid) { throw new InternalError( `SHA check failed! Expected ${oid}, computed ${sha}` ) } const { object, type } = GitObject.unwrap(result.object); result.type = type; result.object = object; result.format = 'content'; if (format === 'content') { return result } throw new InternalError(`invalid requested format "${format}"`) } async function resolveCommit({ fs, cache, gitdir, oid }) { const { type, object } = await _readObject({ fs, cache, gitdir, oid }); // Resolve annotated tag objects to whatever if (type === 'tag') { oid = GitAnnotatedTag.from(object).parse().object; return resolveCommit({ fs, cache, gitdir, oid }) } if (type !== 'commit') { throw new ObjectTypeError(oid, type, 'commit') } return { commit: GitCommit.from(object), oid } } // @ts-check /** * @param {object} args * @param {import('../models/FileSystem.js').FileSystem} args.fs * @param {any} args.cache * @param {string} args.gitdir * @param {string} args.oid * * @returns {Promise} Resolves successfully with a git commit object * @see ReadCommitResult * @see CommitObject * */ async function _readCommit({ fs, cache, gitdir, oid }) { const { commit, oid: commitOid } = await resolveCommit({ fs, cache, gitdir, oid, }); const result = { oid: commitOid, commit: commit.parse(), payload: commit.withoutSignature(), }; // @ts-ignore return result } async function writeObjectLoose({ fs, gitdir, object, format, oid }) { if (format !== 'deflated') { throw new InternalError( 'GitObjectStoreLoose expects objects to write to be in deflated format' ) } const source = `objects/${oid.slice(0, 2)}/${oid.slice(2)}`; const filepath = `${gitdir}/${source}`; // Don't overwrite existing git objects - this helps avoid EPERM errors. // Although I don't know how we'd fix corrupted objects then. Perhaps delete them // on read? if (!(await fs.exists(filepath))) await fs.write(filepath, object); } /* eslint-env node, browser */ let supportsCompressionStream = null; async function deflate(buffer) { if (supportsCompressionStream === null) { supportsCompressionStream = testCompressionStream(); } return supportsCompressionStream ? browserDeflate(buffer) : pako.deflate(buffer) } async function browserDeflate(buffer) { const cs = new CompressionStream('deflate'); const c = new Blob([buffer]).stream().pipeThrough(cs); return new Uint8Array(await new Response(c).arrayBuffer()) } function testCompressionStream() { try { const cs = new CompressionStream('deflate'); cs.writable.close(); // Test if `Blob.stream` is present. React Native does not have the `stream` method const stream = new Blob([]).stream(); stream.cancel(); return true } catch (_) { return false } } async function _writeObject({ fs, gitdir, type, object, format = 'content', oid = undefined, dryRun = false, }) { if (format !== 'deflated') { if (format !== 'wrapped') { object = GitObject.wrap({ type, object }); } oid = await shasum(object); object = Buffer.from(await deflate(object)); } if (!dryRun) { await writeObjectLoose({ fs, gitdir, object, format: 'deflated', oid }); } return oid } // @ts-check /** * @param {object} args * @param {import('../models/FileSystem.js').FileSystem} args.fs * @param {string} args.gitdir * @param {CommitObject} args.commit * * @returns {Promise} * @see CommitObject * */ async function _writeCommit({ fs, gitdir, commit }) { // Convert object to buffer const object = GitCommit.from(commit).toObject(); const oid = await _writeObject({ fs, gitdir, type: 'commit', object, format: 'content', }); return oid } class InvalidRefNameError extends BaseError { /** * @param {string} ref * @param {string} suggestion * @param {boolean} canForce */ constructor(ref, suggestion) { super( `"${ref}" would be an invalid git reference. (Hint: a valid alternative would be "${suggestion}".)` ); this.code = this.name = InvalidRefNameError.code; this.data = { ref, suggestion }; } } /** @type {'InvalidRefNameError'} */ InvalidRefNameError.code = 'InvalidRefNameError'; class MissingNameError extends BaseError { /** * @param {'author'|'committer'|'tagger'} role */ constructor(role) { super( `No name was provided for ${role} in the argument or in the .git/config file.` ); this.code = this.name = MissingNameError.code; this.data = { role }; } } /** @type {'MissingNameError'} */ MissingNameError.code = 'MissingNameError'; class GitRefStash { // constructor removed static get timezoneOffsetForRefLogEntry() { const offsetMinutes = new Date().getTimezoneOffset(); const offsetHours = Math.abs(Math.floor(offsetMinutes / 60)); const offsetMinutesFormatted = Math.abs(offsetMinutes % 60) .toString() .padStart(2, '0'); const sign = offsetMinutes > 0 ? '-' : '+'; return `${sign}${offsetHours .toString() .padStart(2, '0')}${offsetMinutesFormatted}` } static createStashReflogEntry(author, stashCommit, message) { const nameNoSpace = author.name.replace(/\s/g, ''); const z40 = '0000000000000000000000000000000000000000'; // hard code for now, works with `git stash list` const timestamp = Math.floor(Date.now() / 1000); const timezoneOffset = GitRefStash.timezoneOffsetForRefLogEntry; return `${z40} ${stashCommit} ${nameNoSpace} ${author.email} ${timestamp} ${timezoneOffset}\t${message}\n` } static getStashReflogEntry(reflogString, parsed = false) { const reflogLines = reflogString.split('\n'); const entries = reflogLines .filter(l => l) .reverse() .map((line, idx) => parsed ? `stash@{${idx}}: ${line.split('\t')[1]}` : line ); return entries } } // @ts-check /** * @param {Object} args * @param {import('../models/FileSystem.js').FileSystem} args.fs * @param {string} args.gitdir * @param {string} args.path * * @returns {Promise} Resolves with the config value * * @example * // Read config value * let value = await git.getConfig({ * dir: '$input((/))', * path: '$input((user.name))' * }) * console.log(value) * */ async function _getConfig({ fs, gitdir, path }) { const config = await GitConfigManager.get({ fs, gitdir }); return config.get(path) } // Like Object.assign but ignore properties with undefined values // ref: https://stackoverflow.com/q/39513815 function assignDefined(target, ...sources) { for (const source of sources) { if (source) { for (const key of Object.keys(source)) { const val = source[key]; if (val !== undefined) { target[key] = val; } } } } return target } /** * Return author object by using properties following this priority: * (1) provided author object * -> (2) author of provided commit object * -> (3) Config and current date/time * * @param {Object} args * @param {FsClient} args.fs - a file system implementation * @param {string} [args.gitdir] - The [git directory](dir-vs-gitdir.md) path * @param {Object} [args.author] - The author object. * @param {CommitObject} [args.commit] - A commit object. * * @returns {Promise} */ async function normalizeAuthorObject({ fs, gitdir, author, commit }) { const timestamp = Math.floor(Date.now() / 1000); const defaultAuthor = { name: await _getConfig({ fs, gitdir, path: 'user.name' }), email: (await _getConfig({ fs, gitdir, path: 'user.email' })) || '', // author.email is allowed to be empty string timestamp, timezoneOffset: new Date(timestamp * 1000).getTimezoneOffset(), }; // Populate author object by using properties with this priority: // (1) provided author object // -> (2) author of provided commit object // -> (3) default author const normalizedAuthor = assignDefined( {}, defaultAuthor, commit ? commit.author : undefined, author ); if (normalizedAuthor.name === undefined) { return undefined } return normalizedAuthor } /*:: type Node = { type: string, fullpath: string, basename: string, metadata: Object, // mode, oid parent?: Node, children: Array } */ function flatFileListToDirectoryStructure(files) { const inodes = new Map(); const mkdir = function (name) { if (!inodes.has(name)) { const dir = { type: 'tree', fullpath: name, basename: basename(name), metadata: {}, children: [], }; inodes.set(name, dir); // This recursively generates any missing parent folders. // We do it after we've added the inode to the set so that // we don't recurse infinitely trying to create the root '.' dirname. dir.parent = mkdir(dirname(name)); if (dir.parent && dir.parent !== dir) dir.parent.children.push(dir); } return inodes.get(name) }; const mkfile = function (name, metadata) { if (!inodes.has(name)) { const file = { type: 'blob', fullpath: name, basename: basename(name), metadata, // This recursively generates any missing parent folders. parent: mkdir(dirname(name)), children: [], }; if (file.parent) file.parent.children.push(file); inodes.set(name, file); } return inodes.get(name) }; mkdir('.'); for (const file of files) { mkfile(file.path, file); } return inodes } /** * * @param {number} mode */ function mode2type(mode) { // prettier-ignore switch (mode) { case 0o040000: return 'tree' case 0o100644: return 'blob' case 0o100755: return 'blob' case 0o120000: return 'blob' case 0o160000: return 'commit' } throw new InternalError(`Unexpected GitTree entry mode: ${mode.toString(8)}`) } class GitWalkerIndex { constructor({ fs, gitdir, cache }) { this.treePromise = GitIndexManager.acquire( { fs, gitdir, cache }, async function (index) { return flatFileListToDirectoryStructure(index.entries) } ); const walker = this; this.ConstructEntry = class StageEntry { constructor(fullpath) { this._fullpath = fullpath; this._type = false; this._mode = false; this._stat = false; this._oid = false; } async type() { return walker.type(this) } async mode() { return walker.mode(this) } async stat() { return walker.stat(this) } async content() { return walker.content(this) } async oid() { return walker.oid(this) } }; } async readdir(entry) { const filepath = entry._fullpath; const tree = await this.treePromise; const inode = tree.get(filepath); if (!inode) return null if (inode.type === 'blob') return null if (inode.type !== 'tree') { throw new Error(`ENOTDIR: not a directory, scandir '${filepath}'`) } const names = inode.children.map(inode => inode.fullpath); names.sort(compareStrings); return names } async type(entry) { if (entry._type === false) { await entry.stat(); } return entry._type } async mode(entry) { if (entry._mode === false) { await entry.stat(); } return entry._mode } async stat(entry) { if (entry._stat === false) { const tree = await this.treePromise; const inode = tree.get(entry._fullpath); if (!inode) { throw new Error( `ENOENT: no such file or directory, lstat '${entry._fullpath}'` ) } const stats = inode.type === 'tree' ? {} : normalizeStats(inode.metadata); entry._type = inode.type === 'tree' ? 'tree' : mode2type(stats.mode); entry._mode = stats.mode; if (inode.type === 'tree') { entry._stat = undefined; } else { entry._stat = stats; } } return entry._stat } async content(_entry) { // Cannot get content for an index entry } async oid(entry) { if (entry._oid === false) { const tree = await this.treePromise; const inode = tree.get(entry._fullpath); entry._oid = inode.metadata.oid; } return entry._oid } } // This is part of an elaborate system to facilitate code-splitting / tree-shaking. // commands/walk.js can depend on only this, and the actual Walker classes exported // can be opaque - only having a single property (this symbol) that is not enumerable, // and thus the constructor can be passed as an argument to walk while being "unusable" // outside of it. const GitWalkSymbol = Symbol('GitWalkSymbol'); // @ts-check /** * @returns {Walker} */ function STAGE() { const o = Object.create(null); Object.defineProperty(o, GitWalkSymbol, { value: function ({ fs, gitdir, cache }) { return new GitWalkerIndex({ fs, gitdir, cache }) }, }); Object.freeze(o); return o } function compareTreeEntryPath(a, b) { // Git sorts tree entries as if there is a trailing slash on directory names. return compareStrings(appendSlashIfDir(a), appendSlashIfDir(b)) } function appendSlashIfDir(entry) { return entry.mode === '040000' ? entry.path + '/' : entry.path } /** * * @typedef {Object} TreeEntry * @property {string} mode - the 6 digit hexadecimal mode * @property {string} path - the name of the file or directory * @property {string} oid - the SHA-1 object id of the blob or tree * @property {'commit'|'blob'|'tree'} type - the type of object */ function mode2type$1(mode) { // prettier-ignore switch (mode) { case '040000': return 'tree' case '100644': return 'blob' case '100755': return 'blob' case '120000': return 'blob' case '160000': return 'commit' } throw new InternalError(`Unexpected GitTree entry mode: ${mode}`) } function parseBuffer(buffer) { const _entries = []; let cursor = 0; while (cursor < buffer.length) { const space = buffer.indexOf(32, cursor); if (space === -1) { throw new InternalError( `GitTree: Error parsing buffer at byte location ${cursor}: Could not find the next space character.` ) } const nullchar = buffer.indexOf(0, cursor); if (nullchar === -1) { throw new InternalError( `GitTree: Error parsing buffer at byte location ${cursor}: Could not find the next null character.` ) } let mode = buffer.slice(cursor, space).toString('utf8'); if (mode === '40000') mode = '040000'; // makes it line up neater in printed output const type = mode2type$1(mode); const path = buffer.slice(space + 1, nullchar).toString('utf8'); // Prevent malicious git repos from writing to "..\foo" on clone etc if (path.includes('\\') || path.includes('/')) { throw new UnsafeFilepathError(path) } const oid = buffer.slice(nullchar + 1, nullchar + 21).toString('hex'); cursor = nullchar + 21; _entries.push({ mode, path, oid, type }); } return _entries } function limitModeToAllowed(mode) { if (typeof mode === 'number') { mode = mode.toString(8); } // tree if (mode.match(/^0?4.*/)) return '040000' // Directory if (mode.match(/^1006.*/)) return '100644' // Regular non-executable file if (mode.match(/^1007.*/)) return '100755' // Regular executable file if (mode.match(/^120.*/)) return '120000' // Symbolic link if (mode.match(/^160.*/)) return '160000' // Commit (git submodule reference) throw new InternalError(`Could not understand file mode: ${mode}`) } function nudgeIntoShape(entry) { if (!entry.oid && entry.sha) { entry.oid = entry.sha; // Github } entry.mode = limitModeToAllowed(entry.mode); // index if (!entry.type) { entry.type = mode2type$1(entry.mode); // index } return entry } class GitTree { constructor(entries) { if (Buffer.isBuffer(entries)) { this._entries = parseBuffer(entries); } else if (Array.isArray(entries)) { this._entries = entries.map(nudgeIntoShape); } else { throw new InternalError('invalid type passed to GitTree constructor') } // Tree entries are not sorted alphabetically in the usual sense (see `compareTreeEntryPath`) // but it is important later on that these be sorted in the same order as they would be returned from readdir. this._entries.sort(comparePath); } static from(tree) { return new GitTree(tree) } render() { return this._entries .map(entry => `${entry.mode} ${entry.type} ${entry.oid} ${entry.path}`) .join('\n') } toObject() { // Adjust the sort order to match git's const entries = [...this._entries]; entries.sort(compareTreeEntryPath); return Buffer.concat( entries.map(entry => { const mode = Buffer.from(entry.mode.replace(/^0/, '')); const space = Buffer.from(' '); const path = Buffer.from(entry.path, 'utf8'); const nullchar = Buffer.from([0]); const oid = Buffer.from(entry.oid, 'hex'); return Buffer.concat([mode, space, path, nullchar, oid]) }) ) } /** * @returns {TreeEntry[]} */ entries() { return this._entries } *[Symbol.iterator]() { for (const entry of this._entries) { yield entry; } } } class AlreadyExistsError extends BaseError { /** * @param {'note'|'remote'|'tag'|'branch'} noun * @param {string} where * @param {boolean} canForce */ constructor(noun, where, canForce = true) { super( `Failed to create ${noun} at ${where} because it already exists.${ canForce ? ` (Hint: use 'force: true' parameter to overwrite existing ${noun}.)` : '' }` ); this.code = this.name = AlreadyExistsError.code; this.data = { noun, where, canForce }; } } /** @type {'AlreadyExistsError'} */ AlreadyExistsError.code = 'AlreadyExistsError'; class AmbiguousError extends BaseError { /** * @param {'oids'|'refs'} nouns * @param {string} short * @param {string[]} matches */ constructor(nouns, short, matches) { super( `Found multiple ${nouns} matching "${short}" (${matches.join( ', ' )}). Use a longer abbreviation length to disambiguate them.` ); this.code = this.name = AmbiguousError.code; this.data = { nouns, short, matches }; } } /** @type {'AmbiguousError'} */ AmbiguousError.code = 'AmbiguousError'; class CheckoutConflictError extends BaseError { /** * @param {string[]} filepaths */ constructor(filepaths) { super( `Your local changes to the following files would be overwritten by checkout: ${filepaths.join( ', ' )}` ); this.code = this.name = CheckoutConflictError.code; this.data = { filepaths }; } } /** @type {'CheckoutConflictError'} */ CheckoutConflictError.code = 'CheckoutConflictError'; class CommitNotFetchedError extends BaseError { /** * @param {string} ref * @param {string} oid */ constructor(ref, oid) { super( `Failed to checkout "${ref}" because commit ${oid} is not available locally. Do a git fetch to make the branch available locally.` ); this.code = this.name = CommitNotFetchedError.code; this.data = { ref, oid }; } } /** @type {'CommitNotFetchedError'} */ CommitNotFetchedError.code = 'CommitNotFetchedError'; class FastForwardError extends BaseError { constructor() { super(`A simple fast-forward merge was not possible.`); this.code = this.name = FastForwardError.code; this.data = {}; } } /** @type {'FastForwardError'} */ FastForwardError.code = 'FastForwardError'; class GitPushError extends BaseError { /** * @param {string} prettyDetails * @param {PushResult} result */ constructor(prettyDetails, result) { super(`One or more branches were not updated: ${prettyDetails}`); this.code = this.name = GitPushError.code; this.data = { prettyDetails, result }; } } /** @type {'GitPushError'} */ GitPushError.code = 'GitPushError'; class InvalidFilepathError extends BaseError { /** * @param {'leading-slash'|'trailing-slash'|'directory'} [reason] */ constructor(reason) { let message = 'invalid filepath'; if (reason === 'leading-slash' || reason === 'trailing-slash') { message = `"filepath" parameter should not include leading or trailing directory separators because these can cause problems on some platforms.`; } else if (reason === 'directory') { message = `"filepath" should not be a directory.`; } super(message); this.code = this.name = InvalidFilepathError.code; this.data = { reason }; } } /** @type {'InvalidFilepathError'} */ InvalidFilepathError.code = 'InvalidFilepathError'; class MaxDepthError extends BaseError { /** * @param {number} depth */ constructor(depth) { super(`Maximum search depth of ${depth} exceeded.`); this.code = this.name = MaxDepthError.code; this.data = { depth }; } } /** @type {'MaxDepthError'} */ MaxDepthError.code = 'MaxDepthError'; class MergeNotSupportedError extends BaseError { constructor() { super(`Merges with conflicts are not supported yet.`); this.code = this.name = MergeNotSupportedError.code; this.data = {}; } } /** @type {'MergeNotSupportedError'} */ MergeNotSupportedError.code = 'MergeNotSupportedError'; class MergeConflictError extends BaseError { /** * @param {Array} filepaths * @param {Array} bothModified * @param {Array} deleteByUs * @param {Array} deleteByTheirs */ constructor(filepaths, bothModified, deleteByUs, deleteByTheirs) { super( `Automatic merge failed with one or more merge conflicts in the following files: ${filepaths.toString()}. Fix conflicts then commit the result.` ); this.code = this.name = MergeConflictError.code; this.data = { filepaths, bothModified, deleteByUs, deleteByTheirs }; } } /** @type {'MergeConflictError'} */ MergeConflictError.code = 'MergeConflictError'; class MissingParameterError extends BaseError { /** * @param {string} parameter */ constructor(parameter) { super( `The function requires a "${parameter}" parameter but none was provided.` ); this.code = this.name = MissingParameterError.code; this.data = { parameter }; } } /** @type {'MissingParameterError'} */ MissingParameterError.code = 'MissingParameterError'; class MultipleGitError extends BaseError { /** * @param {Error[]} errors * @param {string} message */ constructor(errors) { super( `There are multiple errors that were thrown by the method. Please refer to the "errors" property to see more` ); this.code = this.name = MultipleGitError.code; this.data = { errors }; this.errors = errors; } } /** @type {'MultipleGitError'} */ MultipleGitError.code = 'MultipleGitError'; class PushRejectedError extends BaseError { /** * @param {'not-fast-forward'|'tag-exists'} reason */ constructor(reason) { let message = ''; if (reason === 'not-fast-forward') { message = ' because it was not a simple fast-forward'; } else if (reason === 'tag-exists') { message = ' because tag already exists'; } super(`Push rejected${message}. Use "force: true" to override.`); this.code = this.name = PushRejectedError.code; this.data = { reason }; } } /** @type {'PushRejectedError'} */ PushRejectedError.code = 'PushRejectedError'; class RemoteCapabilityError extends BaseError { /** * @param {'shallow'|'deepen-since'|'deepen-not'|'deepen-relative'} capability * @param {'depth'|'since'|'exclude'|'relative'} parameter */ constructor(capability, parameter) { super( `Remote does not support the "${capability}" so the "${parameter}" parameter cannot be used.` ); this.code = this.name = RemoteCapabilityError.code; this.data = { capability, parameter }; } } /** @type {'RemoteCapabilityError'} */ RemoteCapabilityError.code = 'RemoteCapabilityError'; class IndexResetError extends BaseError { /** * @param {Array} filepaths */ constructor(filepath) { super( `Could not merge index: Entry for '${filepath}' is not up to date. Either reset the index entry to HEAD, or stage your unstaged changes.` ); this.code = this.name = IndexResetError.code; this.data = { filepath }; } } /** @type {'IndexResetError'} */ IndexResetError.code = 'IndexResetError'; class NoCommitError extends BaseError { /** * @param {string} ref */ constructor(ref) { super( `"${ref}" does not point to any commit. You're maybe working on a repository with no commits yet. ` ); this.code = this.name = NoCommitError.code; this.data = { ref }; } } /** @type {'NoCommitError'} */ NoCommitError.code = 'NoCommitError'; async function resolveTree({ fs, cache, gitdir, oid }) { // Empty tree - bypass `readObject` if (oid === '4b825dc642cb6eb9a060e54bf8d69288fbee4904') { return { tree: GitTree.from([]), oid } } const { type, object } = await _readObject({ fs, cache, gitdir, oid }); // Resolve annotated tag objects to whatever if (type === 'tag') { oid = GitAnnotatedTag.from(object).parse().object; return resolveTree({ fs, cache, gitdir, oid }) } // Resolve commits to trees if (type === 'commit') { oid = GitCommit.from(object).parse().tree; return resolveTree({ fs, cache, gitdir, oid }) } if (type !== 'tree') { throw new ObjectTypeError(oid, type, 'tree') } return { tree: GitTree.from(object), oid } } class GitWalkerRepo { constructor({ fs, gitdir, ref, cache }) { this.fs = fs; this.cache = cache; this.gitdir = gitdir; this.mapPromise = (async () => { const map = new Map(); let oid; try { oid = await GitRefManager.resolve({ fs, gitdir, ref }); } catch (e) { if (e instanceof NotFoundError) { // Handle fresh branches with no commits oid = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; } } const tree = await resolveTree({ fs, cache: this.cache, gitdir, oid }); tree.type = 'tree'; tree.mode = '40000'; map.set('.', tree); return map })(); const walker = this; this.ConstructEntry = class TreeEntry { constructor(fullpath) { this._fullpath = fullpath; this._type = false; this._mode = false; this._stat = false; this._content = false; this._oid = false; } async type() { return walker.type(this) } async mode() { return walker.mode(this) } async stat() { return walker.stat(this) } async content() { return walker.content(this) } async oid() { return walker.oid(this) } }; } async readdir(entry) { const filepath = entry._fullpath; const { fs, cache, gitdir } = this; const map = await this.mapPromise; const obj = map.get(filepath); if (!obj) throw new Error(`No obj for ${filepath}`) const oid = obj.oid; if (!oid) throw new Error(`No oid for obj ${JSON.stringify(obj)}`) if (obj.type !== 'tree') { // TODO: support submodules (type === 'commit') return null } const { type, object } = await _readObject({ fs, cache, gitdir, oid }); if (type !== obj.type) { throw new ObjectTypeError(oid, type, obj.type) } const tree = GitTree.from(object); // cache all entries for (const entry of tree) { map.set(join(filepath, entry.path), entry); } return tree.entries().map(entry => join(filepath, entry.path)) } async type(entry) { if (entry._type === false) { const map = await this.mapPromise; const { type } = map.get(entry._fullpath); entry._type = type; } return entry._type } async mode(entry) { if (entry._mode === false) { const map = await this.mapPromise; const { mode } = map.get(entry._fullpath); entry._mode = normalizeMode(parseInt(mode, 8)); } return entry._mode } async stat(_entry) {} async content(entry) { if (entry._content === false) { const map = await this.mapPromise; const { fs, cache, gitdir } = this; const obj = map.get(entry._fullpath); const oid = obj.oid; const { type, object } = await _readObject({ fs, cache, gitdir, oid }); if (type !== 'blob') { entry._content = undefined; } else { entry._content = new Uint8Array(object); } } return entry._content } async oid(entry) { if (entry._oid === false) { const map = await this.mapPromise; const obj = map.get(entry._fullpath); entry._oid = obj.oid; } return entry._oid } } // @ts-check /** * @param {object} args * @param {string} [args.ref='HEAD'] * @returns {Walker} */ function TREE({ ref = 'HEAD' } = {}) { const o = Object.create(null); Object.defineProperty(o, GitWalkSymbol, { value: function ({ fs, gitdir, cache }) { return new GitWalkerRepo({ fs, gitdir, ref, cache }) }, }); Object.freeze(o); return o } class GitWalkerFs { constructor({ fs, dir, gitdir, cache }) { this.fs = fs; this.cache = cache; this.dir = dir; this.gitdir = gitdir; this.config = null; const walker = this; this.ConstructEntry = class WorkdirEntry { constructor(fullpath) { this._fullpath = fullpath; this._type = false; this._mode = false; this._stat = false; this._content = false; this._oid = false; } async type() { return walker.type(this) } async mode() { return walker.mode(this) } async stat() { return walker.stat(this) } async content() { return walker.content(this) } async oid() { return walker.oid(this) } }; } async readdir(entry) { const filepath = entry._fullpath; const { fs, dir } = this; const names = await fs.readdir(join(dir, filepath)); if (names === null) return null return names.map(name => join(filepath, name)) } async type(entry) { if (entry._type === false) { await entry.stat(); } return entry._type } async mode(entry) { if (entry._mode === false) { await entry.stat(); } return entry._mode } async stat(entry) { if (entry._stat === false) { const { fs, dir } = this; let stat = await fs.lstat(`${dir}/${entry._fullpath}`); if (!stat) { throw new Error( `ENOENT: no such file or directory, lstat '${entry._fullpath}'` ) } let type = stat.isDirectory() ? 'tree' : 'blob'; if (type === 'blob' && !stat.isFile() && !stat.isSymbolicLink()) { type = 'special'; } entry._type = type; stat = normalizeStats(stat); entry._mode = stat.mode; // workaround for a BrowserFS edge case if (stat.size === -1 && entry._actualSize) { stat.size = entry._actualSize; } entry._stat = stat; } return entry._stat } async content(entry) { if (entry._content === false) { const { fs, dir, gitdir } = this; if ((await entry.type()) === 'tree') { entry._content = undefined; } else { const config = await this._getGitConfig(fs, gitdir); const autocrlf = await config.get('core.autocrlf'); const content = await fs.read(`${dir}/${entry._fullpath}`, { autocrlf }); // workaround for a BrowserFS edge case entry._actualSize = content.length; if (entry._stat && entry._stat.size === -1) { entry._stat.size = entry._actualSize; } entry._content = new Uint8Array(content); } } return entry._content } async oid(entry) { if (entry._oid === false) { const self = this; const { fs, gitdir, cache } = this; let oid; // See if we can use the SHA1 hash in the index. await GitIndexManager.acquire( { fs, gitdir, cache }, async function (index) { const stage = index.entriesMap.get(entry._fullpath); const stats = await entry.stat(); const config = await self._getGitConfig(fs, gitdir); const filemode = await config.get('core.filemode'); const trustino = typeof process !== 'undefined' ? !(process.platform === 'win32') : true; if (!stage || compareStats(stats, stage, filemode, trustino)) { const content = await entry.content(); if (content === undefined) { oid = undefined; } else { oid = await shasum( GitObject.wrap({ type: 'blob', object: content }) ); // Update the stats in the index so we will get a "cache hit" next time // 1) if we can (because the oid and mode are the same) // 2) and only if we need to (because other stats differ) if ( stage && oid === stage.oid && (!filemode || stats.mode === stage.mode) && compareStats(stats, stage, filemode, trustino) ) { index.insert({ filepath: entry._fullpath, stats, oid, }); } } } else { // Use the index SHA1 rather than compute it oid = stage.oid; } } ); entry._oid = oid; } return entry._oid } async _getGitConfig(fs, gitdir) { if (this.config) { return this.config } this.config = await GitConfigManager.get({ fs, gitdir }); return this.config } } // @ts-check /** * @returns {Walker} */ function WORKDIR() { const o = Object.create(null); Object.defineProperty(o, GitWalkSymbol, { value: function ({ fs, dir, gitdir, cache }) { return new GitWalkerFs({ fs, dir, gitdir, cache }) }, }); Object.freeze(o); return o } // https://dev.to/namirsab/comment/2050 function arrayRange(start, end) { const length = end - start; return Array.from({ length }, (_, i) => start + i) } // TODO: Should I just polyfill Array.flat? const flat = typeof Array.prototype.flat === 'undefined' ? entries => entries.reduce((acc, x) => acc.concat(x), []) : entries => entries.flat(); // This is convenient for computing unions/joins of sorted lists. class RunningMinimum { constructor() { // Using a getter for 'value' would just bloat the code. // You know better than to set it directly right? this.value = null; } consider(value) { if (value === null || value === undefined) return if (this.value === null) { this.value = value; } else if (value < this.value) { this.value = value; } } reset() { this.value = null; } } // Take an array of length N of // iterators of length Q_n // of strings // and return an iterator of length max(Q_n) for all n // of arrays of length N // of string|null who all have the same string value function* unionOfIterators(sets) { /* NOTE: We can assume all arrays are sorted. * Indexes are sorted because they are defined that way: * * > Index entries are sorted in ascending order on the name field, * > interpreted as a string of unsigned bytes (i.e. memcmp() order, no * > localization, no special casing of directory separator '/'). Entries * > with the same name are sorted by their stage field. * * Trees should be sorted because they are created directly from indexes. * They definitely should be sorted, or else they wouldn't have a unique SHA1. * So that would be very naughty on the part of the tree-creator. * * Lastly, the working dir entries are sorted because I choose to sort them * in my FileSystem.readdir() implementation. */ // Init const min = new RunningMinimum(); let minimum; const heads = []; const numsets = sets.length; for (let i = 0; i < numsets; i++) { // Abuse the fact that iterators continue to return 'undefined' for value // once they are done heads[i] = sets[i].next().value; if (heads[i] !== undefined) { min.consider(heads[i]); } } if (min.value === null) return // Iterate while (true) { const result = []; minimum = min.value; min.reset(); for (let i = 0; i < numsets; i++) { if (heads[i] !== undefined && heads[i] === minimum) { result[i] = heads[i]; heads[i] = sets[i].next().value; } else { // A little hacky, but eh result[i] = null; } if (heads[i] !== undefined) { min.consider(heads[i]); } } yield result; if (min.value === null) return } } // @ts-check /** * @param {object} args * @param {import('../models/FileSystem.js').FileSystem} args.fs * @param {object} args.cache * @param {string} [args.dir] * @param {string} [args.gitdir=join(dir,'.git')] * @param {Walker[]} args.trees * @param {WalkerMap} [args.map] * @param {WalkerReduce} [args.reduce] * @param {WalkerIterate} [args.iterate] * * @returns {Promise} The finished tree-walking result * * @see {WalkerMap} * */ async function _walk({ fs, cache, dir, gitdir, trees, // @ts-ignore map = async (_, entry) => entry, // The default reducer is a flatmap that filters out undefineds. reduce = async (parent, children) => { const flatten = flat(children); if (parent !== undefined) flatten.unshift(parent); return flatten }, // The default iterate function walks all children concurrently iterate = (walk, children) => Promise.all([...children].map(walk)), }) { const walkers = trees.map(proxy => proxy[GitWalkSymbol]({ fs, dir, gitdir, cache }) ); const root = new Array(walkers.length).fill('.'); const range = arrayRange(0, walkers.length); const unionWalkerFromReaddir = async entries => { range.forEach(i => { const entry = entries[i]; entries[i] = entry && new walkers[i].ConstructEntry(entry); }); const subdirs = await Promise.all( range.map(i => { const entry = entries[i]; return entry ? walkers[i].readdir(entry) : [] }) ); // Now process child directories const iterators = subdirs.map(array => { return (array === null ? [] : array)[Symbol.iterator]() }); return { entries, children: unionOfIterators(iterators), } }; const walk = async root => { const { entries, children } = await unionWalkerFromReaddir(root); const fullpath = entries.find(entry => entry && entry._fullpath)._fullpath; const parent = await map(fullpath, entries); if (parent !== null) { let walkedChildren = await iterate(walk, children); walkedChildren = walkedChildren.filter(x => x !== undefined); return reduce(parent, walkedChildren) } }; return walk(root) } // @ts-check /** * @param {object} args * @param {import('../models/FileSystem.js').FileSystem} args.fs * @param {string} args.gitdir * @param {TreeObject} args.tree * * @returns {Promise} */ async function _writeTree({ fs, gitdir, tree }) { // Convert object to buffer const object = GitTree.from(tree).toObject(); const oid = await _writeObject({ fs, gitdir, type: 'tree', object, format: 'content', }); return oid } function posixifyPathBuffer(buffer) { let idx; while (~(idx = buffer.indexOf(92))) buffer[idx] = 47; return buffer } const _TreeMap = { stage: STAGE, workdir: WORKDIR, }; let lock$3; async function acquireLock$1(ref, callback) { if (lock$3 === undefined) lock$3 = new AsyncLock(); return lock$3.acquire(ref, callback) } // make sure filepath, blob type and blob object (from loose objects) plus oid are in sync and valid async function checkAndWriteBlob(fs, gitdir, dir, filepath, oid = null) { const currentFilepath = join(dir, filepath); const stats = await fs.lstat(currentFilepath); if (!stats) throw new NotFoundError(currentFilepath) if (stats.isDirectory()) throw new InternalError( `${currentFilepath}: file expected, but found directory` ) // Look for it in the loose object directory. const objContent = oid ? await readObjectLoose({ fs, gitdir, oid }) : undefined; let retOid = objContent ? oid : undefined; if (!objContent) { await acquireLock$1({ fs, gitdir, currentFilepath }, async () => { const object = stats.isSymbolicLink() ? await fs.readlink(currentFilepath).then(posixifyPathBuffer) : await fs.read(currentFilepath); if (object === null) throw new NotFoundError(currentFilepath) retOid = await _writeObject({ fs, gitdir, type: 'blob', object }); }); } return retOid } async function processTreeEntries({ fs, dir, gitdir, entries }) { // make sure each tree entry has valid oid async function processTreeEntry(entry) { if (entry.type === 'tree') { if (!entry.oid) { // Process children entries if the current entry is a tree const children = await Promise.all(entry.children.map(processTreeEntry)); // Write the tree with the processed children entry.oid = await _writeTree({ fs, gitdir, tree: children, }); entry.mode = 0o40000; // directory } } else if (entry.type === 'blob') { entry.oid = await checkAndWriteBlob( fs, gitdir, dir, entry.path, entry.oid ); entry.mode = 0o100644; // file } // remove path from entry.path entry.path = entry.path.split('/').pop(); return entry } return Promise.all(entries.map(processTreeEntry)) } async function writeTreeChanges({ fs, dir, gitdir, treePair, // [TREE({ ref: 'HEAD' }), 'STAGE'] would be the equivalent of `git write-tree` }) { const isStage = treePair[1] === 'stage'; const trees = treePair.map(t => (typeof t === 'string' ? _TreeMap[t]() : t)); const changedEntries = []; // transform WalkerEntry objects into the desired format const map = async (filepath, [head, stage]) => { if ( filepath === '.' || (await GitIgnoreManager.isIgnored({ fs, dir, gitdir, filepath })) ) { return } if (stage) { if ( !head || ((await head.oid()) !== (await stage.oid()) && (await stage.oid()) !== undefined) ) { changedEntries.push([head, stage]); } return { mode: await stage.mode(), path: filepath, oid: await stage.oid(), type: await stage.type(), } } }; // combine mapped entries with their parent results const reduce = async (parent, children) => { children = children.filter(Boolean); // Remove undefined entries if (!parent) { return children.length > 0 ? children : undefined } else { parent.children = children; return parent } }; // if parent is skipped, skip the children const iterate = async (walk, children) => { const filtered = []; for (const child of children) { const [head, stage] = child; if (isStage) { if (stage) { // for deleted file in work dir, it also needs to be added on stage if (await fs.exists(`${dir}/${stage.toString()}`)) { filtered.push(child); } else { changedEntries.push([null, stage]); // record the change (deletion) while stop the iteration } } } else if (head) { // for deleted file in workdir, "stage" (workdir in our case) will be undefined if (!stage) { changedEntries.push([head, null]); // record the change (deletion) while stop the iteration } else { filtered.push(child); // workdir, tracked only } } } return filtered.length ? Promise.all(filtered.map(walk)) : [] }; const entries = await _walk({ fs, cache: {}, dir, gitdir, trees, map, reduce, iterate, }); if (changedEntries.length === 0 || entries.length === 0) { return null // no changes found to stash } const processedEntries = await processTreeEntries({ fs, dir, gitdir, entries, }); const treeEntries = processedEntries.filter(Boolean).map(entry => ({ mode: entry.mode, path: entry.path, oid: entry.oid, type: entry.type, })); return _writeTree({ fs, gitdir, tree: treeEntries }) } async function applyTreeChanges({ fs, dir, gitdir, stashCommit, parentCommit, wasStaged, }) { const dirRemoved = []; const stageUpdated = []; // analyze the changes const ops = await _walk({ fs, cache: {}, dir, gitdir, trees: [TREE({ ref: parentCommit }), TREE({ ref: stashCommit })], map: async (filepath, [parent, stash]) => { if ( filepath === '.' || (await GitIgnoreManager.isIgnored({ fs, dir, gitdir, filepath })) ) { return } const type = stash ? await stash.type() : await parent.type(); if (type !== 'tree' && type !== 'blob') { return } // deleted tree or blob if (!stash && parent) { const method = type === 'tree' ? 'rmdir' : 'rm'; if (type === 'tree') dirRemoved.push(filepath); if (type === 'blob' && wasStaged) stageUpdated.push({ filepath, oid: await parent.oid() }); // stats is undefined, will stage the deletion with index.insert return { method, filepath } } const oid = await stash.oid(); if (!parent || (await parent.oid()) !== oid) { // only apply changes if changed from the parent commit or doesn't exist in the parent commit if (type === 'tree') { return { method: 'mkdir', filepath } } else { if (wasStaged) stageUpdated.push({ filepath, oid, stats: await fs.lstat(join(dir, filepath)), }); return { method: 'write', filepath, oid, } } } }, }); // apply the changes to work dir await acquireLock$1({ fs, gitdir, dirRemoved, ops }, async () => { for (const op of ops) { const currentFilepath = join(dir, op.filepath); switch (op.method) { case 'rmdir': await fs.rmdir(currentFilepath); break case 'mkdir': await fs.mkdir(currentFilepath); break case 'rm': await fs.rm(currentFilepath); break case 'write': // only writes if file is not in the removedDirs if ( !dirRemoved.some(removedDir => currentFilepath.startsWith(removedDir) ) ) { const { object } = await _readObject({ fs, cache: {}, gitdir, oid: op.oid, }); // just like checkout, since mode only applicable to create, not update, delete first if (await fs.exists(currentFilepath)) { await fs.rm(currentFilepath); } await fs.write(currentFilepath, object); // only handles regular files for now } break } } }); // update the stage await GitIndexManager.acquire({ fs, gitdir, cache: {} }, async index => { stageUpdated.forEach(({ filepath, stats, oid }) => { index.insert({ filepath, stats, oid }); }); }); } class GitStashManager { /** * Creates an instance of GitStashManager. * * @param {Object} args * @param {FSClient} args.fs - A file system implementation. * @param {string} args.dir - The working directory. * @param {string}[args.gitdir=join(dir, '.git')] - [required] The [git directory](dir-vs-gitdir.md) path */ constructor({ fs, dir, gitdir = join(dir, '.git') }) { Object.assign(this, { fs, dir, gitdir, _author: null, }); } /** * Gets the reference name for the stash. * * @returns {string} - The stash reference name. */ static get refStash() { return 'refs/stash' } /** * Gets the reference name for the stash reflogs. * * @returns {string} - The stash reflogs reference name. */ static get refLogsStash() { return 'logs/refs/stash' } /** * Gets the file path for the stash reference. * * @returns {string} - The file path for the stash reference. */ get refStashPath() { return join(this.gitdir, GitStashManager.refStash) } /** * Gets the file path for the stash reflogs. * * @returns {string} - The file path for the stash reflogs. */ get refLogsStashPath() { return join(this.gitdir, GitStashManager.refLogsStash) } /** * Retrieves the author information for the stash. * * @returns {Promise} - The author object. * @throws {MissingNameError} - If the author name is missing. */ async getAuthor() { if (!this._author) { this._author = await normalizeAuthorObject({ fs: this.fs, gitdir: this.gitdir, author: {}, }); if (!this._author) throw new MissingNameError('author') } return this._author } /** * Gets the SHA of a stash entry by its index. * * @param {number} refIdx - The index of the stash entry. * @param {string[]} [stashEntries] - Optional preloaded stash entries. * @returns {Promise} - The SHA of the stash entry or `null` if not found. */ async getStashSHA(refIdx, stashEntries) { if (!(await this.fs.exists(this.refStashPath))) { return null } const entries = stashEntries || (await this.readStashReflogs({ parsed: false })); return entries[refIdx].split(' ')[1] } /** * Writes a stash commit to the repository. * * @param {Object} args * @param {string} args.message - The commit message. * @param {string} args.tree - The tree object ID. * @param {string[]} args.parent - The parent commit object IDs. * @returns {Promise} - The object ID of the written commit. */ async writeStashCommit({ message, tree, parent }) { return _writeCommit({ fs: this.fs, gitdir: this.gitdir, commit: { message, tree, parent, author: await this.getAuthor(), committer: await this.getAuthor(), }, }) } /** * Reads a stash commit by its index. * * @param {number} refIdx - The index of the stash entry. * @returns {Promise} - The stash commit object. * @throws {InvalidRefNameError} - If the index is invalid. */ async readStashCommit(refIdx) { const stashEntries = await this.readStashReflogs({ parsed: false }); if (refIdx !== 0) { // non-default case, throw exceptions if not valid if (refIdx < 0 || refIdx > stashEntries.length - 1) { throw new InvalidRefNameError( `stash@${refIdx}`, 'number that is in range of [0, num of stash pushed]' ) } } const stashSHA = await this.getStashSHA(refIdx, stashEntries); if (!stashSHA) { return {} // no stash found } // get the stash commit object return _readCommit({ fs: this.fs, cache: {}, gitdir: this.gitdir, oid: stashSHA, }) } /** * Writes a stash reference to the repository. * * @param {string} stashCommit - The object ID of the stash commit. * @returns {Promise} */ async writeStashRef(stashCommit) { return GitRefManager.writeRef({ fs: this.fs, gitdir: this.gitdir, ref: GitStashManager.refStash, value: stashCommit, }) } /** * Writes a reflog entry for a stash commit. * * @param {Object} args * @param {string} args.stashCommit - The object ID of the stash commit. * @param {string} args.message - The reflog message. * @returns {Promise} */ async writeStashReflogEntry({ stashCommit, message }) { const author = await this.getAuthor(); const entry = GitRefStash.createStashReflogEntry( author, stashCommit, message ); const filepath = this.refLogsStashPath; await acquireLock$1({ filepath, entry }, async () => { const appendTo = (await this.fs.exists(filepath)) ? await this.fs.read(filepath, 'utf8') : ''; await this.fs.write(filepath, appendTo + entry, 'utf8'); }); } /** * Reads the stash reflogs. * * @param {Object} args * @param {boolean} [args.parsed=false] - Whether to parse the reflog entries. * @returns {Promise} - The reflog entries as strings or parsed objects. */ async readStashReflogs({ parsed = false }) { if (!(await this.fs.exists(this.refLogsStashPath))) { return [] } const reflogString = await this.fs.read(this.refLogsStashPath, 'utf8'); return GitRefStash.getStashReflogEntry(reflogString, parsed) } } export { GitConfigManager, GitIgnoreManager, GitIndexManager, GitRefManager, GitRemoteHTTP, GitRemoteManager, GitShallowManager, GitStashManager };