UNPKG

hologit

Version:

Hologit automates the projection of layered composite file trees based on flat, declarative plans

564 lines (431 loc) 18.4 kB
const path = require('path'); const fs = require('mz/fs'); const parseUrl = require('parse-url'); const logger = require('./logger'); const Git = require('./Git.js'); const Configurable = require('./Configurable.js'); const SpecObject = require('./SpecObject.js'); const Projection = require('./Projection.js'); const specCache = new WeakMap(); const headCache = new WeakMap(); const hashRegex = new RegExp('^[0-9a-f]{7,40}$') class Source extends Configurable { constructor ({ workspace, name }) { super(...arguments); this.name = name; const [holosourceName, holobranchName] = name.split('=>', 2); this.holosourceName = holosourceName; this.holobranchName = holobranchName || null; Object.freeze(this); } getKind () { return 'holosource'; } getConfigPath () { return `.holo/sources/${this.holosourceName}.toml`; } async readConfig () { let config; const { name: workspaceName } = await this.workspace.getCachedConfig(); if (this.holosourceName == workspaceName) { config = { $workspace: true }; } else { const defaultConfig = await super.readConfig(); // overwrite from environment const envName = `HOLO_SOURCE_${this.holosourceName.replace(/-/g, '_').toUpperCase()}`; const envValue = process.env[envName]; logger.debug(`Reading env source override ${envName}=${envValue}`); if (envValue) { const envMatch = envValue.match(/^(?<url>[^#=]+)?(#(?<ref>[^=]+)(=>(?<holobranch>.*))?)?$/); if (!envMatch) { throw new Error(`unable to parse ${envName} value: ${envValue}`); } const { url, ref, holobranch } = envMatch.groups; config = Object.create(defaultConfig); if (url) { config.url = url; } if (ref) { config.ref = ref; config.project = holobranch ? { holobranch } : null; } } else { config = defaultConfig; } } return config; } async getConfig () { const config = await super.getConfig(); if (!config.$workspace && !config.ref && config.url) { throw new Error(`holosource has no ref defined: ${this.name}`); } return config; } async getSpec () { const { url } = await this.getCachedConfig(); const data = {}; if (url) { const { resource: host, pathname: path } = parseUrl(url[0] == '/' ? `file://${url}` : url); data.host = host.toLowerCase() || null; data.path = path.toLowerCase().replace(/\/?\.git$/, ''); } else { data.path = '.'; } const spec = { ...await SpecObject.write(this.workspace.root.repo, 'source', data), data }; specCache.set(this, spec); return spec; } async getCachedSpec () { const cachedSpec = specCache.get(this); if (cachedSpec) { return cachedSpec; } return await this.getSpec(...arguments); } async queryRef () { const { url, ref } = await this.getCachedConfig(); const git = await this.getRepo().getGit(); const lsRemoteOutput = await git.lsRemote({ symref: true }, url, ref); const match = lsRemoteOutput.match(/^(ref: (refs\/heads\/\S+)\tHEAD\n)?([0-9a-f]{40})\t(\S+)$/m); if (!match) { return null; } return { hash: match[3], ref: match[2] || match[4] }; } async hashWorkTree () { const repo = this.getRepo(); if (!repo.workTree) { throw new Error('cannot call hashWorkTree from non-working repo instance'); } const subGit = await this.getSubGit(); if (!subGit || !subGit.workTree) { return null; } // get holoindex path, cloning index if needed const indexPath = await subGit.getIndexPath(); const holoIndexPath = `${indexPath}.holo`; if (!await fs.exists(holoIndexPath) && await fs.exists(indexPath)) { await fs.copyFile(indexPath, holoIndexPath); } // build a tree via the cloned index logger.info(`indexing ${this.name} working tree (this can take a while under Docker)...`); const holoIndexEnv = { $indexFile: holoIndexPath }; try { await subGit.reset(holoIndexEnv, 'HEAD'); } catch (err) { // re-clone index and retry once await fs.copyFile(indexPath, holoIndexPath); await subGit.reset(holoIndexEnv, 'HEAD'); } await subGit.add(holoIndexEnv, { all: true }); const treeHash = await subGit.writeTree(holoIndexEnv); logger.info(`using ${this.name} working tree: ${treeHash}`); return treeHash; } async getOutputTree ({ working = null, fetch = false, cacheFrom = null, cacheTo = null } = {}) { const repo = this.getRepo(); const git = await repo.getGit(); const { project } = await this.getCachedConfig(); let head = await this.getHead({ working }); // apply source projection if (project) { if (!project.holobranch) { throw new Error('holosource.project config must include holobranch'); } logger.info(`projecting holobranch ${project.holobranch} within source ${this.name}@${head.substr(0, 8)}`); const workspace = await repo.createWorkspaceFromRef(head); const branch = workspace.getBranch(project.holobranch); let { lens } = await branch.getCachedConfig(); if (typeof lens != 'boolean') { if (typeof project.lens == 'boolean') { lens = project.lens } else { lens = true; } } head = await Projection.projectBranch(branch, { debug: true, lens, fetch, cacheFrom, cacheTo }); logger.info(`using projection result for holobranch ${project.holobranch} as source ${this.name}: ${head}`); } else { head = await git.getTreeHash(head); } // apply mapping projection if (this.holobranchName) { logger.info(`projecting holobranch ${this.holobranchName} within source ${this.holosourceName}@${head.substr(0, 8)}`); const workspace = await repo.createWorkspaceFromRef(head); const branch = workspace.getBranch(this.holobranchName); let { lens } = await branch.getCachedConfig(); if (typeof lens != 'boolean') { lens = true; } head = await Projection.projectBranch(branch, { debug: true, lens, fetch, cacheFrom, cacheTo }); logger.info(`using projection result for holobranch ${this.holobranchName} as source ${this.name}: ${head}`); } return head; } async getHead ({ required=false, working=null } = {}) { const repo = this.getRepo(); const git = await repo.getGit(); const { $workspace, url, ref } = await this.getCachedConfig(); // special value indicates that the containing in-flight workspace is the source if ($workspace) { return await this.workspace.root.write(); } // get value of gitlink if it exists const sourcePath = `.holo/sources/${this.name}`; const gitLink = await this.workspace.root.getChild(sourcePath); const gitLinkHash = gitLink && gitLink.isCommit && gitLink.hash; // fall few a few different ways to determine the current commit hash to use let head; // hash source sub-worktree if available // TODO: ask the workspace if it's got a worktree or something, delegate much of this to the workspace so it can be virtual if (repo.workTree && working !== false) { const workTreeHash = await this.hashWorkTree(); if (workTreeHash) { let workTreeCommit; // check if staged gitlink matches working tree const [,stagedHash] = (await git.lsFiles({ stage: true }, sourcePath)).split(/\s/, 4); if ( stagedHash && await git.getTreeHash(stagedHash) == workTreeHash ) { workTreeCommit = stagedHash; } // else, check if committed gitlink matches working tree if ( !workTreeCommit && gitLinkHash && await git.getTreeHash(gitLinkHash) == workTreeHash ) { workTreeCommit = gitLinkHash; } // else, create a commit if (workTreeCommit) { head = workTreeCommit; } else { head = await git.commitTree( { p: stagedHash || gitLinkHash || null, m: `working snapshot of ${repo.workTree}/${sourcePath}` }, workTreeHash ); } } } // try to get head from gitlink commit entry in sources tree if (!head && gitLinkHash) { head = gitLinkHash; // if the indicated head is not available, try a broad fetch on the source if (!await repo.hasCommit(gitLinkHash)) { const { ref: specRef } = await this.getCachedSpec(); const headRef = `${specRef}/${ref.substr(5)}`; // TODO: should this be 0, 5? const resolvedRef = await repo.resolveRef(headRef); if (!resolvedRef) { await this.fetch(); } if (!await repo.hasCommit(gitLinkHash)) { await this.fetch({ unshallow: true }, 'refs/heads/*'); if (!await repo.hasCommit(gitLinkHash)) { throw new Error(`${sourcePath} is set to commit ${head}, but that commit is not exposed by any branch on the source`); } } } } // try to get from local ref if (!head && !url) { head = await repo.resolveRef(ref); } // try to get head from remote specRef if (!head && url) { const { ref: specRef } = await this.getCachedSpec(); const headRef = `${specRef}/${ref.substr(5)}`; head = await repo.resolveRef(headRef); // try to fetch head if (!head) { await this.fetch(); head = await repo.resolveRef(headRef); } } // unresolved head is an exception if (required && !head) { throw new Error(`could not resolve head commit for holosource ${this.name}`); } if (!head) { head = null; } headCache.set(this, head); return head; } async getCachedHead () { const cachedHead = headCache.get(this); if (cachedHead) { return cachedHead; } return await this.getHead(...arguments); } async getBranch () { const { ref } = await this.getCachedConfig(); const refMatch = ref.match(/^refs\/heads\/(\S+)$/); return refMatch ? refMatch[1] : null; } async fetch ({ depth=1, unshallow=null } = {}, ...refs) { const repo = this.getRepo(); const { url, ref: configRef } = await this.getCachedConfig(); if (!url) { return { refs: [ configRef || repo.ref || 'HEAD' ] }; } if (!refs.length) { refs.push(configRef); } for (const ref of refs) { if (!ref.startsWith('refs/') && !hashRegex.test(ref)) { throw new Error(`ref ${ref} must start with refs/ or be a hash`); } } if (unshallow) { depth = null; } const git = await repo.getGit(); const { ref: specRef } = await this.getCachedSpec(); await git.fetch({ depth, unshallow, tags: false }, url, ...refs.map(ref => `+${ref}:${specRef}/${ref.substr(5)}`)); return { refs: refs.map(ref => `${specRef}/${ref.substr(5)}`) }; } async getSubGit ({ required=false } = {}) { const repo = this.getRepo(); const subRepoPath = `.holo/sources/${this.name}`; // determine workTree let workTree = repo.workTree && path.join(repo.workTree, subRepoPath); if (!workTree || !await fs.exists(workTree)) { workTree = null; } // determine gitDir const { hash: specHash } = await this.getCachedSpec(); const gitDir = path.join(repo.gitDir, 'modules', specHash); if (!await fs.exists(gitDir)) { if (required) { throw new Error(`submodule ${subRepoPath} is not initialized; try running: git holo source checkout ${this.name}`); } else { return null; } } // build git instance const envGit = await Git.get(); return new envGit.Git({ gitDir, workTree }); } async checkout ({ submodule=false }) { const mkdirp = require('mz-modules/mkdirp'); const repo = this.getRepo(); const { gitDir, workTree } = repo; const git = await repo.getGit(); const { url, ref } = await this.getCachedConfig(); const branch = await this.getBranch(); // get work tree if (!workTree) { throw new Error('no work tree found, cannot checkout sub-repository'); } // initialize sub-repository work tree const subRepoPath = `.holo/sources/${this.name}`; const subWorkTree = path.join(workTree, subRepoPath); await mkdirp(subWorkTree); // initialize sub-repository let head; if (await fs.exists(`${subWorkTree}/.git`)) { const subGit = await this.getSubGit({ required: true }); head = await subGit.revParse({ verify: true }, 'HEAD'); } else { const { hash: specHash } = await this.getCachedSpec(); const subRepoGitDir = path.join(gitDir, 'modules', specHash); // initialize bare repository in submodule-like location if (!await fs.exists(subRepoGitDir)) { const envGit = await Git.get(); await envGit.init({ bare: true }, subRepoGitDir); } // point sub working tree at submodule-like repo outside working tree await fs.writeFile(`${subWorkTree}/.git`, `gitdir: ${path.relative(subWorkTree, subRepoGitDir)}\n`); // configure sub-repository const subGit = await this.getSubGit({ required: true }); await subGit.config('core.bare', 'false'); await subGit.config('core.worktree', path.relative(subRepoGitDir, subWorkTree)); // share objects in both directions with superproject repo await subGit.addToConfigSet('objects/info/alternates', path.relative(`${subRepoGitDir}/objects`, `${gitDir}/objects`)); await git.addToConfigSet('objects/info/alternates', path.relative(`${gitDir}/objects`, `${subRepoGitDir}/objects`)); // stage head as gitlink head = await this.getHead({ working: false }); // TODO: this fails when checking out a projected source // TODO: this head should never be projected, maybe stop projecting in getHead and have another get-tree method? or just call something different here? await git.updateIndex({ add: true, cacheinfo: true }, `160000,${head},${subRepoPath}`); // add remote await subGit.config(`remote.origin.url`, url || gitDir); // configure upstream if (branch) { const remoteRef = `refs/remotes/origin/${branch}`; await subGit.updateRef(remoteRef, head); await subGit.updateRef('FETCH_HEAD', head); await subGit.config(`branch.${branch}.remote`, 'origin'); await subGit.config(`branch.${branch}.merge`, ref); await subGit.config(`branch.${branch}.rebase`, 'true'); await subGit.config(`remote.origin.fetch`, `+${ref}:${remoteRef}`); } else { await subGit.config(`remote.origin.fetch`, `+${ref}:${ref}`); } // initialize FETCH_HEAD await subGit.fetch({ depth: 1, tags: false }); // TODO: shallow: true? does this make that happen? // check out ref await subGit.updateRef(ref, head); await subGit.symbolicRef('HEAD', ref); await subGit.checkout({ $cwd: subWorkTree }, '--'); } // configure submodule if (submodule) { await git.config(`submodule.${subRepoPath}.active`, 'true'); await git.config(`submodule.${subRepoPath}.url`, url || gitDir); const submoduleConfig = { path: subRepoPath, url: url || gitDir, shallow: 'true' }; if (branch) { submoduleConfig.branch = branch; } // write submodule config for (const key in submoduleConfig) { await git.config({ file: '.gitmodules' }, `submodule.${subRepoPath}.${key}`, submoduleConfig[key]); } await git.add('.gitmodules'); } return { path: subRepoPath, head, branch, url, ref, submodule }; } } module.exports = Source;