UNPKG

git-cat-file

Version:

A pure-JavaScript implementation of `git cat-file -p` for Node.js.

959 lines (750 loc) 27.6 kB
'use strict'; var fs = require('fs'); var asyncCacheQueue = require('async-cache-queue'); var zlib = require('zlib'); class ObjItem { constructor(obj, store) { this.obj = obj; this.store = store; } getId() { return this.obj.oid } parseMeta() { if (this.meta) return const {data} = this.obj; // Object.create(null) keeps an attacker-crafted header key like // `__proto__` or `constructor` from colliding with the prototype // chain when we read `meta[key]` back. const meta = this.meta = Object.create(null); const lines = data.toString().split(/\r?\n/); let headerMode = true; let lastKey; let message; for (const line of lines) { if (headerMode) { // A truly empty line ends the header section. RFC 822-style // continuation lines start with a single space and append to // the previous header's last value (used by `gpgsig` and // `mergetag` in commit objects). if (line === "") { headerMode = false; continue } if (line.startsWith(" ") && lastKey) { const arr = meta[lastKey]; arr[arr.length - 1] += "\n" + line.slice(1); continue } const [key, val] = splitBySpace$2(line); if (!key) continue if (meta[key]) { meta[key].push(val); } else { meta[key] = [val]; } lastKey = key; } else { if (message != null) { message += "\n" + line; } else { message = line; } } } this.message = message; } getMeta(key) { const array = this.getMetaArray(key); if (array) { if (array.length > 1) return array.join(" ") return array[0] } } getMetaArray(key) { this.parseMeta(); return this.meta[key] } getMessage() { this.parseMeta(); return this.message } } function splitBySpace$2(line) { const sp = line.indexOf(" "); return [line.slice(0, sp), line.slice(sp + 1)] } const OctalMode = { file: 0o100644, executable: 0o100755, symlink: 0o120000, submodule: 0o160000, directory: 0o040000, }; class FileMode { // 100644 // 100755 // 120000 // 160000 // 040000 constructor(mode) { this.mode = mode; switch (mode) { case OctalMode.executable: case OctalMode.executable & 0o777: this.isExecutable = true; /* falls through */ case OctalMode.file: case OctalMode.file & 0o777: this.isFile = true; break case OctalMode.symlink: this.isSymlink = true; break case OctalMode.submodule: this.isSubmodule = true; break case OctalMode.directory: this.isDirectory = true; break default: throw new TypeError(`Unknown mode: ${mode ? mode.toString(8) : mode}`) } } toString() { return (0o1000000 | this.mode).toString(8).substr(-6) } } const cachedMode = {}; function getFileMode(mode) { const modeNum = parseInt(mode, 8); return cachedMode[modeNum] || (cachedMode[modeNum] = new FileMode(modeNum)) } class Tree { constructor(obj, store) { if (obj.type !== "tree") { throw new TypeError(`Invalid tree object: ${obj.oid} (${obj.type})`) } this.obj = obj; this.store = store; } getId() { return this.obj.oid } async getEntries() { return parseTree(this.obj.data) } async getEntry(path) { let tree = this; if (/\//.test(path)) { const names = path.split("/"); path = names.pop(); if (!path) path = names.pop(); tree = await this.getTree(names.join("/")); } if (!tree) return const list = await tree.getEntries(); return list.filter(item => item.name === path).shift() } async getTree(path) { let tree = this; for (const name of path.split("/")) { if (!name) continue const entry = await tree.getEntry(name); if (!entry) return const obj = await this.store.getObject(entry.oid); if (!obj) return tree = new Tree(obj, this.store); } return tree } } function parseTree(data) { const list = []; let start = 0; let end; const {length} = data; while (start < length) { end = findZero$1(data, start); const line = data.slice(start, end - 1).toString(); const [modeStr, name] = splitBySpace$1(line); const mode = getFileMode(modeStr); start = end + 20; const oid = data.slice(end, start).toString("hex"); list.push({mode, name, oid}); } return list } function findZero$1(buf, offset) { offset |= 0; while (buf[offset++]) { // nop } return offset } function splitBySpace$1(line) { const sp = line.indexOf(" "); return [line.slice(0, sp), line.slice(sp + 1)] } function _optionalChain$2(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } class Commit extends ObjItem { constructor(obj, store) { super(obj, store); if (obj.type !== "commit" && obj.type !== "tag") { throw new TypeError(`Invalid commit object: ${obj.oid} (${obj.type})`) } } getDate() { const author = this.getMeta("author") || this.getMeta("committer"); const match = _optionalChain$2([author, 'optionalAccess', _ => _.match, 'call', _2 => _2(/\s+(\d+)(\s+[+\-]\d+)?$/)]); if (match) return new Date(+match[1] * 1000) } async getTree() { const oid = this.getMeta("tree"); const obj = await this.store.getObject(oid); return new Tree(obj, this.store) } async getFile(path) { const tree = await this.getTree(); const entry = await tree.getEntry(path); if (!entry) return const {oid, mode} = entry; const obj = await this.store.getObject(oid); if (obj.type !== "blob") return const {data} = obj; return {oid, mode, data} } async getParents() { const parent = this.getMetaArray("parent"); if (!parent) return const array = []; for (const oid of parent) { const obj = await this.store.getObject(oid); if (obj) array.push(new Commit(obj, this.store)); } return array } } /** * https://github.com/kawanet/git-cat-file */ const shortCache = asyncCacheQueue.queueFactory({ cache: 1000, // 1 seconds negativeCache: 1000, // 1 second maxItems: 1000, }); const longCache = asyncCacheQueue.queueFactory({ cache: 3600000, // 1 hour negativeCache: 1000, // 1 second maxItems: 1000, }); /** * https://github.com/kawanet/git-cat-file */ class Loose { constructor(root) {Loose.prototype.__init.call(this); this.root = root; } __init() {this.readdir = shortCache((first) => fs.promises.readdir(`${this.root}/objects/${first}`));} async findAll(object_id) { const first = object_id.slice(0, 2); const rest = object_id.slice(2); const {length} = rest; const files = await this.readdir(first).catch(() => null); if (!files) return return files.filter(name => name.slice(0, length) === rest).map(name => (first + name)) } async getObject(oid) { const first = oid.slice(0, 2); const rest = oid.slice(2); const obj = new LooseObject(`${this.root}/objects/${first}/${rest}`); const type = await obj.getType(); const data = await obj.getData(); return {oid, type, data} } } function findZero(buf, offset) { offset |= 0; while (buf[offset++]) { // nop } return offset } class LooseObject { constructor(path) { this.path = path; } getRaw() { return this.buf || (this.buf = fs.promises.readFile(this.path).then(zlib.inflateSync)) } getOffset() { return this.offset || (this.offset = this.getRaw().then(findZero)) } getType() { return this.type || (this.type = this.parseType()) } async parseType() { const raw = await this.getRaw(); const offset = await this.getOffset(); const head = raw.slice(0, offset - 2).toString(); return head.split(/\s+/).shift() } async getData() { const raw = await this.getRaw(); const offset = await this.getOffset(); return raw.slice(offset) } } /** * https://github.com/kawanet/git-cat-file * * @see https://github.com/git/git/blob/master/Documentation/technical/pack-format.txt */ const toHex = (buf) => buf.toString("hex").replace(/(\w.)(?=\w)/g, "$1 "); async function readPackIndex(path) { path = path.replace(/\.pack$/, ".idx"); if (!/\.idx$/.test(path)) throw TypeError(`Invalid pack index file: ${path}`) const data = await fs.promises.readFile(path); return parsePackIndex(data) } async function parsePackIndex(data) { // validate header const headByte = data.slice(0, 4); const head = headByte.toString("latin1"); if (head !== "\xFFtOc") throw TypeError(`Invalid header: ${toHex(headByte)}`) // validate version number const version = data.readUInt32BE(4); if (version !== 2) throw TypeError(`Invalid version number: ${version}`) let oidIdx = 8 + 4 * 256; // total objects count const total = data.readUInt32BE(oidIdx - 4); // console.warn(`found: ${total} packed objects`); const oidLast = oidIdx + 20 * total; let table4Idx = oidLast + 4 * total; let table8Idx = table4Idx + 4 * total; const packIndex = {}; while (oidIdx < oidLast) { const end = oidIdx + 20; const oid = data.slice(oidIdx, end).toString("hex"); let pos = data.readUInt32BE(table4Idx); table4Idx += 4; if (pos & 0x80000000) { // console.warn(pos.toString(16)); const high = data.readUInt32BE(table8Idx); table8Idx += 4; const low = data.readUInt32BE(table8Idx); table8Idx += 4; pos = high * 0x100000000 + low; } packIndex[oid] = pos; oidIdx = end; } return packIndex } const TypeBit = { OBJ_OFS_DELTA: 6, OBJ_REF_DELTA: 7, }; const typeNames = [null, "commit", "tree", "blob", "tag"]; const deltaTypes = {6: "OBJ_OFS_DELTA", 7: "OBJ_REF_DELTA"}; // const toHex = (buf: Buffer) => buf.toString("hex").replace(/(\w.)(?=\w)/g, "$1 "); async function readPackedObject(fh, start, store) { const buffer = Buffer.alloc(28); await fh.read({buffer, position: start}); // console.warn(`read: ${start} (${toHex(buffer)})`); let offset = 0; let c = buffer[offset++]; const typeBit = (c & 0x70) >> 4; const type = typeNames[typeBit]; const deltaType = deltaTypes[typeBit ]; // console.warn(`type: ${typeBit} (${deltaType || type})`); if (!deltaType && !type) throw new TypeError(`Invalid type: ${typeBit}`) let size = c & 0x0F; { let shift = 4; while (c & 0x80) { c = buffer[offset++]; size += ((c & 0x7F) << shift); shift += 7; } } // console.warn(`size: ${size} bytes`); if (typeBit === TypeBit.OBJ_OFS_DELTA) { let c = buffer[offset++]; let baseOffset = c & 0x7F; while (c & 0x80) { baseOffset += 1; // see unpack-objects.c c = buffer[offset++]; baseOffset = (baseOffset << 7) + (c & 0x7F); } if (start < baseOffset) { throw new TypeError(`offset value out of bound: ${start} < ${baseOffset}`) } // console.warn(`delta: ${start} + ${offset}`); const delta = await readData(fh, start + offset, size); // console.warn(`base: ${start} - ${baseOffset}`); const base = await readPackedObject(fh, start - baseOffset, store); const data = applyDelta(base.data, delta); return {type: base.type, data} } if (typeBit === TypeBit.OBJ_REF_DELTA) { const end = offset + 20; const oid = buffer.slice(offset, end).toString("hex"); offset = end; // console.warn(`delta: ${start} + ${offset}`); const delta = await readData(fh, start + offset, size); // console.warn(`base: ${oid}`); const base = await store.getObject(oid); const data = applyDelta(base.data, delta); return {type: base.type, data} } if (!size) { const data = Buffer.alloc(0); return {type, data} } // console.warn(`position: ${start} + ${offset}`); const data = await readData(fh, start + offset, size); return {type, data} } async function readData(fh, position, size) { const bufSize = Math.ceil(size * 17 / 16 / 512) * 512; const buffer = Buffer.alloc(bufSize); await fh.read({buffer, position}); return zlib.inflateSync(buffer, {maxOutputLength: size}) } function applyDelta(baseData, deltaData) { let deltaPos = 0; let dstPos = 0; // console.warn(`delta: ${toHex(deltaData.slice(0, 32))}`); const srcSize = readSize(); if (!srcSize) throw new TypeError(`Invalid source size: ${srcSize}`) const dstSize = readSize(); if (!dstSize) throw new TypeError(`Invalid dest size: ${dstSize}`) const dstData = Buffer.alloc(dstSize); const deltaEnd = deltaData.length; while (deltaPos < deltaEnd) { const inst = deltaData[deltaPos++]; if (inst & 0x80) { let offset = 0; let size = 0; if (inst & 0x01) offset += deltaData[deltaPos++]; if (inst & 0x02) offset += (deltaData[deltaPos++] << 8); if (inst & 0x04) offset += (deltaData[deltaPos++] << 16); if (inst & 0x08) offset += (deltaData[deltaPos++] << 24); if (inst & 0x10) size += deltaData[deltaPos++]; if (inst & 0x20) size += (deltaData[deltaPos++] << 8); if (inst & 0x40) size += (deltaData[deltaPos++] << 16); if (!size) size = 0x10000; // console.warn(`copy: ${inst.toString(2)} offset=${offset} size=${size}`); baseData.copy(dstData, dstPos, offset, offset + size); dstPos += size; } else if (inst) { const end = deltaPos + inst; // console.warn(`add: ${inst}`); deltaData.copy(dstData, dstPos, deltaPos, end); deltaPos = end; dstPos += inst; } else { throw TypeError(`unexpected delta opcode: ${inst}`) } } return dstData function readSize() { let c = deltaData[deltaPos++]; let shift = 7; let size = c & 0x7F; while (c & 0x80) { c = deltaData[deltaPos++]; size += ((c & 0x7F) << shift); shift += 7; } return size } } /** * https://github.com/kawanet/git-cat-file */ class Pack { constructor(path) {Pack.prototype.__init.call(this);Pack.prototype.__init2.call(this); this.path = path; } __init() {this.getIndex = longCache(() => readPackIndex(this.path));} __init2() {this.getList = longCache(() => this.getIndex().then(index => Object.keys(index).sort()));} async findAll(object_id) { const list = await this.getList(); const index = {}; const {length} = object_id; for (const oid of await list) { if (oid.slice(0, length) === object_id) { index[oid] = 1; } } return Object.keys(index) } async getObject(object_id, store) { const index = await this.getIndex(); const offset = index[object_id]; if (!offset) return // console.warn(`open: ${this.path}`); const fh = await fs.promises.open(this.path, "r"); const obj = await readPackedObject(fh, offset, store); await fh.close(); const {type, data} = obj; return {oid: object_id, type, data} } } function _optionalChain$1(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } class Tag extends ObjItem { constructor(obj, store) { super(obj, store); if (obj.type !== "tag") { throw new TypeError(`Invalid tag object: ${obj.oid} (${obj.type})`) } } getDate() { const tagger = this.getMeta("tagger"); const match = _optionalChain$1([tagger, 'optionalAccess', _ => _.match, 'call', _2 => _2(/\s+(\d+)(\s+[+\-]\d+)?$/)]); if (match) return new Date(+match[1] * 1000) } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }/** * https://github.com/kawanet/git-cat-file */ const isObjectId$2 = (oid) => (oid && /^[0-9a-f]{40}$/i.test(oid)); class Ref { constructor(root) {Ref.prototype.__init.call(this);Ref.prototype.__init2.call(this); this.root = root; } async findCommitId(revision, store) { if (!revision || !/[^.\/]/.test(revision)) { throw new Error(`Invalid revision: ${revision}`) } let ancestry; revision = revision.replace(/([~^]\d*)+$/, match => { ancestry = match.split(/([~^]\d*)/).filter(v => v); return "" }); if (revision === "@") { revision = "HEAD"; } const id = await this.findId(revision); if (id && !ancestry) return id const obj = await this.getRawCommit(id || revision, store); if (!obj) return if (obj && !ancestry) return obj.oid // https://git-scm.com/book/en/v2/Git-Tools-Revision-Selection#_ancestry_references // HEAD^ // HEAD~2 let commit = new Commit(obj, store); for (const gen of ancestry) { const mark = gen[0]; const num = gen.substring(1); const count = (num === "0") ? 0 : (+num || 1); const tilde = (mark === "~") ? count : 1; const caret = (mark === "^") ? count : 1; for (let i = 0; i < tilde; i++) { const parents = await commit.getParents(); if (!parents) return if (caret) commit = parents[caret - 1]; if (!commit) return } } return commit.getId() } async findId(revision) { while (revision) { revision = await this.searchRef(revision); if (!revision) break const ref = revision.replace(/^ref:\s*/, ""); if (/^refs\//.test(ref)) { revision = await this.findRef(ref); } if (isObjectId$2(revision)) { return revision // commit } } } async getRawCommit(revision, store) { const object_id = await store.findObjectId(revision); if (!object_id) return // not found let obj = await store.getObject(object_id); if (_optionalChain([obj, 'optionalAccess', _ => _.type]) === "tag") { const tag = new Tag(obj, store); const object_id = tag.getMeta("object"); obj = await store.getObject(object_id); } if (obj.type === "commit") return obj } __init() {this.readPackedRefIndex = shortCache(async () => { const index = {}; const list = await this.readPackedRefList(); for (const ref of list) { index[ref.ref] = ref.commit; } return index });} async readPackedRefList() { const list = []; const text = await this.readTextFile(`packed-refs`).catch(() => null); if (!text) return list const lines = text.split(/\r?\n/).filter(s => /^\w/.test(s)); for (const line of lines) { const [commit, ref] = splitBySpace(line); list.push({commit, ref}); } return list } async searchRef(ref) { // .git/HEAD if (/HEAD$/.test(ref)) { const commit = await this.readFirstLine(ref).catch(() => null); if (commit) return commit } // .git/refs/heads/main { const commit = await this.findRef(`refs/heads/${ref}`).catch(() => null); if (commit) return commit } // .git/refs/tags/xxxx { const commit = await this.findRef(`refs/tags/${ref}`).catch(() => null); if (commit) return commit } // .git/refs/remotes/xxxx { const commit = await this.findRef(`refs/remotes/${ref}`).catch(() => null); if (commit) return commit } } async findRef(name) { // loose ref const ref = await this.readFirstLine(name); if (ref) return ref // packed ref const index = await this.readPackedRefIndex(); if (index[name]) return index[name] } async readFirstLine(name) { const text = await this.readTextFile(name).catch(() => null); if (text) return text.split(/\r?\n/).filter(s => /^[^#\s]/.test(s)).shift() } __init2() {this.readTextFile = shortCache((name) => { const path = `${this.root}/${name}`; // console.warn(`readFile: ${path}`); return fs.promises.readFile(path, "utf-8") });} } function splitBySpace(line) { const sp = line.indexOf(" "); return [line.slice(0, sp), line.slice(sp + 1)] } /** * https://github.com/kawanet/git-cat-file */ /** * https://github.com/kawanet/git-cat-file */ const isObjectId$1 = (oid) => (oid && /^[0-9a-f]{40}$/i.test(oid)); class ObjStore { __init() {this.pack = {};} constructor(root) {ObjStore.prototype.__init.call(this);ObjStore.prototype.__init2.call(this);ObjStore.prototype.__init3.call(this);ObjStore.prototype.__init4.call(this);ObjStore.prototype.__init5.call(this); this.root = root; this.loose = new Loose(root); this.ref = new Ref(root); } /** * get object with the exact objectId */ __init2() {this.getObject = shortCache(async (object_id) => { if (!isObjectId$1(object_id)) { throw new TypeError(`Invalid object_id: ${object_id}`) } // packed object const list = await this.getPackList() || []; for (const pack of list) { const obj = await pack.getObject(object_id, this); if (obj) return obj } // loose object return this.loose.getObject(object_id) });} /** * get objectId with a loose objectId */ __init3() {this.findObjectId = shortCache(async (object_id) => { const index = {}; // packed object { const packs = await this.getPackList() || []; for (const pack of packs) { const items = await pack.findAll(object_id); if (!items) continue for (const oid of items) { index[oid] = 1; } } const matched = Object.keys(index); // console.warn(`matched: ${matched.length} packed object`); if (matched.length > 1) return } // loose object { const items = await this.loose.findAll(object_id) || []; // console.warn(`matched: ${items.length} loose object`); for (const oid of items) { index[oid] = 1; } } const matched = Object.keys(index); if (matched.length === 1) return matched[0] });} __init4() {this.findCommitId = shortCache((commit_id) => this.ref.findCommitId(commit_id, this));} __init5() {this.getPackList = shortCache(async () => { const base = `${this.root}/objects/pack/`; // console.warn(`readdir: ${base}`); let names = await fs.promises.readdir(base).catch(() => null); if (!names) return names = names.filter(name => /^pack-.*\.pack$/.test(name)); // console.warn(`found: ${names.length} packs`); return names.map(name => (this.pack[name] || (this.pack[name] = new Pack(base + name)))) });} } const isObjectId = (oid) => (oid && /^[0-9a-f]{40}$/i.test(oid)); const isLooseId = (oid) => (oid && /^[0-9a-f]{4,40}$/i.test(oid)); class Repo { constructor(path) { path = path.replace(/\/+$/, ""); this.store = new ObjStore(path); } async getObject(object_id) { if (isObjectId(object_id)) { const obj = await this.store.getObject(object_id); if (obj) return obj } if (isLooseId(object_id)) { const oid = await this.store.findObjectId(object_id); if (oid) return this.store.getObject(oid) } const commit_id = await this.store.findCommitId(object_id); if (commit_id) return this.store.getObject(commit_id) } async getCommit(commit_id) { let obj = await this.getObject(commit_id); if (!obj) return if (obj.type === "tag") { const tag = new Tag(obj, this.store); commit_id = tag.getMeta("object"); obj = await this.getObject(commit_id); } return new Commit(obj, this.store) } async getTree(object_id) { const obj = await this.getObject(object_id); if (!obj) return return new Tree(obj, this.store) } } function openLocalRepo(path) { return new Repo(path) } exports.openLocalRepo = openLocalRepo;