0
0
Files
vs-p/node_modules/isomorphic-git/managers/index.js
2025-11-24 17:53:48 +08:00

6164 lines
177 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import ignore from 'ignore';
import AsyncLock from 'async-lock';
import Hash from 'sha.js/sha1.js';
import crc32 from 'crc-32';
import pako from 'pako';
/**
* @typedef {Object} GitProgressEvent
* @property {string} phase
* @property {number} loaded
* @property {number} total
*/
/**
* @callback ProgressCallback
* @param {GitProgressEvent} progress
* @returns {void | Promise<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 doesnt 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)
}
}
export { GitConfigManager, GitIgnoreManager, GitIndexManager, GitRefManager, GitRemoteHTTP, GitRemoteManager, GitShallowManager, GitStashManager };