git-cat-file
Version:
A pure-JavaScript implementation of `git cat-file -p` for Node.js.
959 lines (750 loc) • 27.6 kB
JavaScript
'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;