isomorphic-git
Version:
A pure JavaScript reimplementation of git for node and browsers
360 lines (335 loc) • 10.7 kB
JavaScript
import pify from 'pify';
import { join } from 'path-browserify';
function compareStrings(a, b) {
// https://stackoverflow.com/a/40355107/2168416
return -(a < b) || +(a > b)
}
function dirname(path) {
const last = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
if (last === -1) return '.'
if (last === 0) return '/'
return path.slice(0, last)
}
/**
* Removes the directory at the specified filepath recursively. Used internally to replicate the behavior of
* fs.promises.rm({ recursive: true, force: true }) from Node.js 14 and above when not available. If the provided
* filepath resolves to a file, it will be removed.
*
* @param {import('../models/FileSystem.js').FileSystem} fs
* @param {string} filepath - The file or directory to remove.
*/
async function rmRecursive(fs, filepath) {
const entries = await fs.readdir(filepath);
if (entries == null) {
await fs.rm(filepath);
} else if (entries.length) {
await Promise.all(
entries.map(entry => {
const subpath = join(filepath, entry);
return fs.lstat(subpath).then(stat => {
if (!stat) return
return stat.isDirectory() ? rmRecursive(fs, subpath) : fs.rm(subpath)
})
})
).then(() => fs.rmdir(filepath));
} else {
await fs.rmdir(filepath);
}
}
function isPromiseLike(obj) {
return isObject(obj) && isFunction(obj.then) && isFunction(obj.catch)
}
function isObject(obj) {
return obj && typeof obj === 'object'
}
function isFunction(obj) {
return typeof obj === 'function'
}
function isPromiseFs(fs) {
const test = targetFs => {
try {
// If readFile returns a promise then we can probably assume the other
// commands do as well
return targetFs.readFile().catch(e => e)
} catch (e) {
return e
}
};
return isPromiseLike(test(fs))
}
// List of commands all filesystems are expected to provide. `rm` is not
// included since it may not exist and must be handled as a special case
const commands = [
'readFile',
'writeFile',
'mkdir',
'rmdir',
'unlink',
'stat',
'lstat',
'readdir',
'readlink',
'symlink',
];
function bindFs(target, fs) {
if (isPromiseFs(fs)) {
for (const command of commands) {
target[`_${command}`] = fs[command].bind(fs);
}
} else {
for (const command of commands) {
target[`_${command}`] = pify(fs[command].bind(fs));
}
}
// Handle the special case of `rm`
if (isPromiseFs(fs)) {
if (fs.rm) target._rm = fs.rm.bind(fs);
else if (fs.rmdir.length > 1) target._rm = fs.rmdir.bind(fs);
else target._rm = rmRecursive.bind(null, target);
} else {
if (fs.rm) target._rm = pify(fs.rm.bind(fs));
else if (fs.rmdir.length > 2) target._rm = pify(fs.rmdir.bind(fs));
else target._rm = rmRecursive.bind(null, target);
}
}
/**
* A wrapper class for file system operations, providing a consistent API for both promise-based
* and callback-based file systems. It includes utility methods for common file system tasks.
*/
class FileSystem {
/**
* Creates an instance of FileSystem.
*
* @param {Object} fs - A file system implementation to wrap.
*/
constructor(fs) {
if (typeof fs._original_unwrapped_fs !== 'undefined') return fs
const promises = Object.getOwnPropertyDescriptor(fs, 'promises');
if (promises && promises.enumerable) {
bindFs(this, fs.promises);
} else {
bindFs(this, fs);
}
this._original_unwrapped_fs = fs;
}
/**
* Return true if a file exists, false if it doesn't exist.
* Rethrows errors that aren't related to file existence.
*
* @param {string} filepath - The path to the file.
* @param {Object} [options] - Additional options.
* @returns {Promise<boolean>} - `true` if the file exists, `false` otherwise.
*/
async exists(filepath, options = {}) {
try {
await this._stat(filepath);
return true
} catch (err) {
if (
err.code === 'ENOENT' ||
err.code === 'ENOTDIR' ||
(err.code || '').includes('ENS')
) {
return false
} else {
console.log('Unhandled error in "FileSystem.exists()" function', err);
throw err
}
}
}
/**
* Return the contents of a file if it exists, otherwise returns null.
*
* @param {string} filepath - The path to the file.
* @param {Object} [options] - Options for reading the file.
* @returns {Promise<Buffer|string|null>} - The file contents, or `null` if the file doesn't exist.
*/
async read(filepath, options = {}) {
try {
let buffer = await this._readFile(filepath, options);
if (options.autocrlf === 'true') {
try {
buffer = new TextDecoder('utf8', { fatal: true }).decode(buffer);
buffer = buffer.replace(/\r\n/g, '\n');
buffer = new TextEncoder().encode(buffer);
} catch (error) {
// non utf8 file
}
}
// Convert plain ArrayBuffers to Buffers
if (typeof buffer !== 'string') {
buffer = Buffer.from(buffer);
}
return buffer
} catch (err) {
return null
}
}
/**
* Write a file (creating missing directories if need be) without throwing errors.
*
* @param {string} filepath - The path to the file.
* @param {Buffer|Uint8Array|string} contents - The data to write.
* @param {Object|string} [options] - Options for writing the file.
* @returns {Promise<void>}
*/
async write(filepath, contents, options = {}) {
try {
await this._writeFile(filepath, contents, options);
return
} catch (err) {
// Hmm. Let's try mkdirp and try again.
await this.mkdir(dirname(filepath));
await this._writeFile(filepath, contents, options);
}
}
/**
* Make a directory (or series of nested directories) without throwing an error if it already exists.
*
* @param {string} filepath - The path to the directory.
* @param {boolean} [_selfCall=false] - Internal flag to prevent infinite recursion.
* @returns {Promise<void>}
*/
async mkdir(filepath, _selfCall = false) {
try {
await this._mkdir(filepath);
return
} catch (err) {
// If err is null then operation succeeded!
if (err === null) return
// If the directory already exists, that's OK!
if (err.code === 'EEXIST') return
// Avoid infinite loops of failure
if (_selfCall) throw err
// If we got a "no such file or directory error" backup and try again.
if (err.code === 'ENOENT') {
const parent = dirname(filepath);
// Check to see if we've gone too far
if (parent === '.' || parent === '/' || parent === filepath) throw err
// Infinite recursion, what could go wrong?
await this.mkdir(parent);
await this.mkdir(filepath, true);
}
}
}
/**
* Delete a file without throwing an error if it is already deleted.
*
* @param {string} filepath - The path to the file.
* @returns {Promise<void>}
*/
async rm(filepath) {
try {
await this._unlink(filepath);
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
}
/**
* Delete a directory without throwing an error if it is already deleted.
*
* @param {string} filepath - The path to the directory.
* @param {Object} [opts] - Options for deleting the directory.
* @returns {Promise<void>}
*/
async rmdir(filepath, opts) {
try {
if (opts && opts.recursive) {
await this._rm(filepath, opts);
} else {
await this._rmdir(filepath);
}
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
}
/**
* Read a directory without throwing an error is the directory doesn't exist
*
* @param {string} filepath - The path to the directory.
* @returns {Promise<string[]|null>} - An array of file names, or `null` if the path is not a directory.
*/
async readdir(filepath) {
try {
const names = await this._readdir(filepath);
// Ordering is not guaranteed, and system specific (Windows vs Unix)
// so we must sort them ourselves.
names.sort(compareStrings);
return names
} catch (err) {
if (err.code === 'ENOTDIR') return null
return []
}
}
/**
* Return a flat list of all the files nested inside a directory
*
* Based on an elegant concurrent recursive solution from SO
* https://stackoverflow.com/a/45130990/2168416
*
* @param {string} dir - The directory to read.
* @returns {Promise<string[]>} - A flat list of all files in the directory.
*/
async readdirDeep(dir) {
const subdirs = await this._readdir(dir);
const files = await Promise.all(
subdirs.map(async subdir => {
const res = dir + '/' + subdir;
return (await this._stat(res)).isDirectory()
? this.readdirDeep(res)
: res
})
);
return files.reduce((a, f) => a.concat(f), [])
}
/**
* Return the Stats of a file/symlink if it exists, otherwise returns null.
* Rethrows errors that aren't related to file existence.
*
* @param {string} filename - The path to the file or symlink.
* @returns {Promise<Object|null>} - The stats object, or `null` if the file doesn't exist.
*/
async lstat(filename) {
try {
const stats = await this._lstat(filename);
return stats
} catch (err) {
if (err.code === 'ENOENT' || (err.code || '').includes('ENS')) {
return null
}
throw err
}
}
/**
* Reads the contents of a symlink if it exists, otherwise returns null.
* Rethrows errors that aren't related to file existence.
*
* @param {string} filename - The path to the symlink.
* @param {Object} [opts={ encoding: 'buffer' }] - Options for reading the symlink.
* @returns {Promise<Buffer|null>} - The symlink target, or `null` if it doesn't exist.
*/
async readlink(filename, opts = { encoding: 'buffer' }) {
// Note: FileSystem.readlink returns a buffer by default
// so we can dump it into GitObject.write just like any other file.
try {
const link = await this._readlink(filename, opts);
return Buffer.isBuffer(link) ? link : Buffer.from(link)
} catch (err) {
if (err.code === 'ENOENT' || (err.code || '').includes('ENS')) {
return null
}
throw err
}
}
/**
* Write the contents of buffer to a symlink.
*
* @param {string} filename - The path to the symlink.
* @param {Buffer} buffer - The symlink target.
* @returns {Promise<void>}
*/
async writelink(filename, buffer) {
return this._symlink(buffer.toString('utf8'), filename)
}
}
export { FileSystem };