6177 lines
178 KiB
JavaScript
6177 lines
178 KiB
JavaScript
'use strict';
|
||
|
||
Object.defineProperty(exports, '__esModule', { value: true });
|
||
|
||
function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }
|
||
|
||
var ignore = _interopDefault(require('ignore'));
|
||
var AsyncLock = _interopDefault(require('async-lock'));
|
||
var Hash = _interopDefault(require('sha.js/sha1.js'));
|
||
var crc32 = _interopDefault(require('crc-32'));
|
||
var pako = _interopDefault(require('pako'));
|
||
|
||
/**
|
||
* @typedef {Object} GitProgressEvent
|
||
* @property {string} phase
|
||
* @property {number} loaded
|
||
* @property {number} total
|
||
*/
|
||
|
||
/**
|
||
* @callback ProgressCallback
|
||
* @param {GitProgressEvent} progress
|
||
* @returns {void | Promise<void>}
|
||
*/
|
||
|
||
/**
|
||
* @typedef {Object} GitHttpRequest
|
||
* @property {string} url - The URL to request
|
||
* @property {string} [method='GET'] - The HTTP method to use
|
||
* @property {Object<string, string>} [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<Uint8Array>} [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<string, string>} [headers] - HTTP response headers
|
||
* @property {AsyncIterableIterator<Uint8Array>} [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<GitHttpResponse>}
|
||
*/
|
||
|
||
/**
|
||
* @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<number>} mode
|
||
* @property {function(): Promise<string>} oid
|
||
* @property {function(): Promise<Uint8Array|void>} content
|
||
* @property {function(): Promise<Stat>} 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<void>}
|
||
*/
|
||
|
||
/**
|
||
* @typedef {Object} GitAuth
|
||
* @property {string} [username]
|
||
* @property {string} [password]
|
||
* @property {Object<string, string>} [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<GitAuth | void>}
|
||
*/
|
||
|
||
/**
|
||
* @callback AuthFailureCallback
|
||
* @param {string} url
|
||
* @param {GitAuth} auth The credentials that failed
|
||
* @returns {GitAuth | void | Promise<GitAuth | void>}
|
||
*/
|
||
|
||
/**
|
||
* @callback AuthSuccessCallback
|
||
* @param {string} url
|
||
* @param {GitAuth} auth
|
||
* @returns {void | Promise<void>}
|
||
*/
|
||
|
||
/**
|
||
* @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<string>} branches
|
||
* @property {Array<string>} 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<any>}
|
||
*/
|
||
|
||
/**
|
||
* @callback WalkerReduce
|
||
* @param {any} parent
|
||
* @param {any[]} children
|
||
* @returns {Promise<any>}
|
||
*/
|
||
|
||
/**
|
||
* @callback WalkerIterateCallback
|
||
* @param {WalkerEntry[]} entries
|
||
* @returns {Promise<any[]>}
|
||
*/
|
||
|
||
/**
|
||
* @callback WalkerIterate
|
||
* @param {WalkerIterateCallback} walk
|
||
* @param {IterableIterator<WalkerEntry[]>} children
|
||
* @returns {Promise<any[]>}
|
||
*/
|
||
|
||
/**
|
||
* @typedef {Object} RefUpdateStatus
|
||
* @property {boolean} ok
|
||
* @property {string} error
|
||
*/
|
||
|
||
/**
|
||
* @typedef {Object} PushResult
|
||
* @property {boolean} ok
|
||
* @property {?string} error
|
||
* @property {Object<string, RefUpdateStatus>} refs
|
||
* @property {Object<string, string>} [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<boolean>} 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<void>}
|
||
*/
|
||
|
||
// 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<GitConfig>} 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<void>} 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<boolean>} - `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<string>} 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<string, CacheEntry>
|
||
_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<void>}
|
||
*/
|
||
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<boolean>} `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<any>} 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<string, string>} args.refs - A map of refs to their object IDs.
|
||
* @param {Map<string, string>} 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<Object>} - 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<void>}
|
||
*/
|
||
// 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<void>}
|
||
*/
|
||
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<void>}
|
||
*/
|
||
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<void>}
|
||
*/
|
||
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<string>} - 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<boolean>} - 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<string>} - 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<string, string>} args.map - The map of refs.
|
||
* @returns {Promise<string>} - 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<string, string>} 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<Map<string, string>>} - 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<string[]>} - 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<string[]>} - 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<string[]>} - 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<string, string | true>} */
|
||
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<string[]>} - 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<string, string>} args.headers - HTTP headers to include in the request.
|
||
* @param {1 | 2} args.protocolVersion - The Git protocol version to use.
|
||
* @returns {Promise<Object>} - 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<string, string>} [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<GitHttpResponse>} - 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 <transport>://<address>, where <transport> is
|
||
* a protocol that it cannot handle natively, it automatically invokes git remote-<transport>
|
||
* 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 <transport>::<address> explicitly instructs git to invoke
|
||
* git remote-<transport> with <address> 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<Set<string>>} - 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<string>} args.oids - A set of shallow object IDs to write.
|
||
* @returns {Promise<void>}
|
||
*/
|
||
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 <CR>
|
||
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<ReadCommitResult>} 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<string>}
|
||
* @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<any>} 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<void | {name: string, email: string, timestamp: number, timezoneOffset: number }>}
|
||
*/
|
||
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<Node>
|
||
}
|
||
*/
|
||
|
||
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<string>} filepaths
|
||
* @param {Array<string>} bothModified
|
||
* @param {Array<string>} deleteByUs
|
||
* @param {Array<string>} 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<string>} 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<any>} 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<string>}
|
||
*/
|
||
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<Object>} - 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<string|null>} - 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<string>} - 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<Object>} - 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<void>}
|
||
*/
|
||
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<void>}
|
||
*/
|
||
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<string[]|Object[]>} - 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)
|
||
}
|
||
}
|
||
|
||
exports.GitConfigManager = GitConfigManager;
|
||
exports.GitIgnoreManager = GitIgnoreManager;
|
||
exports.GitIndexManager = GitIndexManager;
|
||
exports.GitRefManager = GitRefManager;
|
||
exports.GitRemoteHTTP = GitRemoteHTTP;
|
||
exports.GitRemoteManager = GitRemoteManager;
|
||
exports.GitShallowManager = GitShallowManager;
|
||
exports.GitStashManager = GitStashManager;
|