hologit
Version:
Hologit automates the projection of layered composite file trees based on flat, declarative plans
555 lines (428 loc) • 15.9 kB
JavaScript
const path = require('path');
const Minimatch = require('minimatch').Minimatch;
const treeLineRe = /^([^ ]+) ([^ ]+) ([^\t]+)\t(.*)/;
const minimatchOptions = { dot: true };
const EMPTY_TREE_HASH = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
// use .changes map to track pending changes on top of cache, null value = delete
// children
const cache = {};
function cacheRead (hash) {
if (hash == EMPTY_TREE_HASH) {
return {};
}
return cache[hash] || null;
}
function cacheWrite (hash, children) {
cache[hash] = children;
}
class MergeOptions {
constructor ({ files = null, mode = 'overlay' }) {
if (files && files.length && (files.length > 1 || files[0] != '**')) {
this.matchers = files.map(pattern => typeof pattern == 'string' ? new Minimatch(pattern, minimatchOptions) : pattern);
}
if (mode != 'overlay' && mode != 'replace' && mode != 'underlay') {
throw new Error(`unknown merge mode "${mode}"`);
}
this.mode = mode;
}
}
class TreeObject {
static getEmptyTreeHash () {
return EMPTY_TREE_HASH;
}
static async createFromRef (repo, ref) {
const git = await repo.getGit();
try {
const refHash = await git.revParse({ verify: true }, ref);
const treeHash = await git.getTreeHash(refHash);
return new TreeObject(repo, { hash: treeHash });
} catch (err) {
throw new Error(`invalid tree ref ${ref}: ${err}`);
}
}
constructor (repo, { hash = EMPTY_TREE_HASH, parent = null } = {}) {
this.repo = repo;
this.dirty = false;
this.hash = hash;
this.parent = parent;
this._children = {};
this._baseChildren = null;
Object.seal(this);
}
toString() {
let str = this.hash;
if (this.dirty) {
str += '(+pending changes)'
}
return str;
}
async getHash () {
if (!this.dirty) {
return this.hash;
}
return this.write();
}
getWrittenHash () {
return !this.dirty && this.hash || null;
}
markDirty () {
if (this.dirty) {
return;
}
this.dirty = true;
let parent = this.parent;
while (parent) {
parent.dirty = true;
parent = parent.parent;
}
}
async _loadBaseChildren (preloadChildren = false) {
if (!this.hash || this.hash == EMPTY_TREE_HASH) {
Object.setPrototypeOf(this._children, this._baseChildren = {});
return;
}
if (this._baseChildren) {
return;
}
const git = await this.repo.getGit();
// read tree data from cache or filesystem
let cachedHashChildren = cacheRead(this.hash);
if (!cachedHashChildren) {
cachedHashChildren = {};
const treeLines = (await git.lsTree(preloadChildren ? { r: true, t: true, z: true, 'full-tree': true } : { z: true, 'full-tree': true }, this.hash)).split('\0');
const preloadedTrees = {};
for (const treeLine of treeLines) {
if (!treeLine) {
continue;
}
const [, mode, type, hash, childPath] = treeLineRe.exec(treeLine);
if (preloadChildren) {
const parentTreePathLength = childPath.lastIndexOf('/');
if (type == 'tree') {
// any tree listed will have children, begin cache entry
preloadedTrees[childPath] = {
hash,
children: {}
};
}
if (parentTreePathLength == -1) {
// direct child, add to current result
cachedHashChildren[childPath] = { type, hash, mode };
} else {
preloadedTrees[childPath.substr(0, parentTreePathLength)]
.children[childPath.substr(parentTreePathLength+1)] = { type, hash, mode };
}
} else {
cachedHashChildren[childPath] = { type, hash, mode };
}
}
cacheWrite(this.hash, cachedHashChildren);
if (preloadChildren) {
for (const treePath in preloadedTrees) {
const tree = preloadedTrees[treePath];
cacheWrite(tree.hash, tree.children);
}
}
}
// instantiate children
const baseChildren = {};
for (const name in cachedHashChildren) {
const childCache = cachedHashChildren[name];
switch (childCache.type) {
case 'tree':
baseChildren[name] = this.repo.createTree({ ...childCache, parent: this });
break;
case 'blob':
baseChildren[name] = this.repo.createBlob(childCache);
break;
case 'commit':
baseChildren[name] = this.repo.createCommit(childCache);
break;
default:
throw new Error(`unhandled tree child type: ${childCache.type}`);
}
}
// save to instance and chain beneath children
this._baseChildren = baseChildren;
this._children = Object.setPrototypeOf(this._children || {}, baseChildren);
}
async getChild (childPath) {
childPath = childPath.split('/');
let cursor = this;
while (childPath.length) {
if (cursor.hash && !cursor._baseChildren) {
await cursor._loadBaseChildren();
}
cursor = cursor._children[childPath.shift()];
if (
!cursor
|| (!cursor.isTree && childPath.length)
) {
return null;
}
}
return cursor;
}
async writeChild (childPath, content) {
const tree = await this.getSubtree(path.dirname(childPath), true);
if (typeof content == 'string') {
content = await this.repo.writeBlob(content);
}
const childName = path.basename(childPath);
const existingChild = tree._children[childName];
if (
existingChild
&& !existingChild.isTree
&& existingChild.hash == content.hash
) {
return existingChild;
}
tree._children[childName] = content;
tree.markDirty();
return content;
}
async getChildren () {
if (this.hash && !this._baseChildren) {
await this._loadBaseChildren();
}
return this._children;
}
async getBlobMap () {
if (this.hash && !this._baseChildren) {
await this._loadBaseChildren(true);
}
// build map of blobs by path
const children = this._children;
const blobs = {};
for (const name in children) {
const child = children[name];
if (child.isBlob) {
blobs[`${name}`] = child;
} else if (child.isTree) {
const subBlobs = await child.getBlobMap();
for (const subPath in subBlobs) {
blobs[`${name}/${subPath}`] = subBlobs[subPath];
}
}
}
return blobs;
}
async deleteChild (childPath) {
const tree = await this.getSubtree(path.dirname(childPath), false);
if (!tree) {
return
}
const childName = path.basename(childPath);
if (tree._children[childName] || !tree._baseChildren) {
tree._children[childName] = null;
tree.markDirty();
}
}
async getSubtree (subtreePath, create = false) {
const stack = await this.getSubtreeStack(...arguments);
return stack && stack[stack.length - 1] || null;
}
async getSubtreeStack (subtreePath, create = false) {
if (subtreePath == '.') {
return [this];
}
let tree = this,
parents = [],
subtreeName,
nextTree;
subtreePath = subtreePath.split(path.sep);
while (tree && subtreePath.length) {
subtreeName = subtreePath.shift();
if (tree.hash && !tree._baseChildren) {
await tree._loadBaseChildren();
}
parents.push(tree);
nextTree = tree._children[subtreeName];
if (!nextTree) {
if (!create) {
return null;
}
nextTree = tree._children[subtreeName] = new TreeObject(this.repo, { parent: tree });
for (const parent of parents) {
parent.dirty = true;
}
}
tree = nextTree;
}
return [...parents, tree];
}
async write () {
if (!this.dirty) {
return this.hash;
}
if (this.hash && !this._baseChildren) {
await this._loadBaseChildren();
}
// compile tree entry lines
const children = this._children;
const lines = [];
for (const name in children) {
const child = children[name];
if (!child) {
continue;
}
if (child.isTree) {
if (child.dirty) {
await child.write();
}
if (child.hash == EMPTY_TREE_HASH) {
continue;
}
}
lines.push({
mode: child.mode || '100644',
type: child.type,
hash: child.hash,
name
});
}
// build tree hash
if (lines.length == 0) {
this.hash = EMPTY_TREE_HASH;
} else {
const git = await this.repo.getGit();
this.hash = await git.mktreeBatch(lines);
}
// flush dirty state
const baseChildren = this._baseChildren;
for (const childName in children) {
if (children.hasOwnProperty(childName)) {
if (!(baseChildren[childName] = children[childName])) {
delete baseChildren[childName];
}
delete children[childName];
}
}
this.dirty = false;
return this.hash;
}
async merge (input, options = {}, basePath = '.', preloadChildren = true) {
// load children of target and input
if (this.hash && !this._baseChildren) {
await this._loadBaseChildren(preloadChildren);
}
if (input.hash && !input._baseChildren) {
await input._loadBaseChildren(preloadChildren);
}
// initialize options
if (!(options instanceof MergeOptions)) {
options = new MergeOptions(options);
}
// loop through input children
const subMerges = [];
const inputChildren = input._children;
childrenLoop: for (const childName in inputChildren) {
const inputChild = inputChildren[childName];
// skip deleted node
if (!inputChild) {
continue;
}
let baseChild = this._children[childName];
// skip if existing path matches
if (
baseChild
&& (!baseChild.dirty && !inputChild.dirty)
&& baseChild.hash == inputChild.hash
) {
continue;
}
// test path
const childPath = path.join(basePath, childName) + (inputChild.isTree ? '/' : '');
let pendingChildMatch = false;
if (options.matchers) {
let matched = false;
let negationsPossible = false;
for (const matcher of options.matchers) {
if (matcher.match(childPath)) {
if (!matcher.negate) {
matched = true;
}
} else if (matcher.negate) {
continue childrenLoop;
}
if (matcher.negate) {
negationsPossible = true;
}
}
if (!matched && !inputChild.isTree) {
continue;
}
if ((!matched || negationsPossible) && inputChild.isTree) {
pendingChildMatch = true;
}
}
// if input child is a blob, overwrite with copied ref
if (!inputChild.isTree) {
if (
!this._children[childName]
|| options.mode == 'overlay'
|| options.mode == 'replace'
) {
this._children[childName] = inputChild;
this.markDirty();
}
continue;
}
// if base child isn't a tree, create one
let baseChildEmpty = false;
if (!baseChild || !baseChild.isTree || options.mode == 'replace') {
if (pendingChildMatch) {
// if file filters are in effect and this child tree has not been matched yet,
// finish merging its decendents into an empty tree and skip if it stays empty
baseChild = new TreeObject(this.repo, { parent: this });
await baseChild.merge(inputChild, options, childPath);
if (baseChild.dirty) {
this._children[childName] = baseChild;
this.markDirty();
}
continue;
} else {
// if input child is clean, clone it and skip merge
if (!inputChild.dirty) {
this._children[childName] = new TreeObject(this.repo, { hash: inputChild.hash, parent: this });
this.markDirty();
continue;
}
// create an empty tree to merge input into
baseChild = this._children[childName] = new TreeObject(this.repo, { parent: this });
this.markDirty();
baseChildEmpty = true;
}
}
// merge child trees
const mergePromise = baseChild.merge(inputChild, options, childPath, !baseChildEmpty);
if (!this.dirty) {
mergePromise.then(() => {
if (baseChild.dirty) {
this.markDirty();
}
});
}
// build array of promises for child tree merges
subMerges.push(mergePromise);
}
// replace-mode should clear all unmatched existing children
if (options.mode == 'replace') {
for (const childName in this._children) {
if (!inputChildren[childName]) {
this._children[childName] = null;
}
}
}
// return aggregate promise for child tree merges
return Promise.all(subMerges);
}
async clone () {
return new TreeObject(this.repo, { hash: await this.getHash(), parent: this.parent });
}
}
TreeObject.treeLineRe = treeLineRe;
TreeObject.prototype.isTree = true;
TreeObject.prototype.type = 'tree';
TreeObject.prototype.mode = '040000';
module.exports = TreeObject;