UNPKG

@mieweb/wikigdrive

Version:

Google Drive to MarkDown synchronization

998 lines (860 loc) 29.4 kB
/* eslint-disable @typescript-eslint/no-unused-vars */ import fs from 'node:fs'; import path from 'node:path'; import {exec, spawn} from 'node:child_process'; import process from 'node:process'; import type {Logger} from 'winston'; import {UserConfig} from '../containers/google_folder/UserConfigService.ts'; import {TelemetryMethod} from '../telemetry.ts'; const __filename = import.meta.filename; export interface GitChange { path: string; state: { isNew: boolean; isModified: boolean; isDeleted: boolean; }; attachments?: number; } interface SshParams { privateKeyFile: string; } interface Commiter { name: string; email: string; } function sanitize(txt) { txt = txt.replace(/[;"|]/g, ''); return txt; } interface ExecOpts { env?: { [k: string]: string }; skipLogger?: boolean; } export class GitScanner { public debug = false; private logger: Logger; private companionFileResolver: (filePath: string) => Promise<string[]> = async () => ([]); constructor(logger: Logger, public readonly rootPath: string, private email: string) { this.logger = logger.child({ filename: __filename }); } @TelemetryMethod({ paramsCount: 1 }) private async exec(command: string, opts: ExecOpts = { env: {}, skipLogger: false }): Promise<{ stdout: string, stderr: string }> { const err = new Error(); const stackList = err.stack.split('\n'); if (!opts.skipLogger) { this.logger.info(command, { stackOffset: 1 }); } let [ stdout, stderr ] = [ null, null ]; if (!opts.env) { opts.env = {}; } if (!opts.env['HOME']) { opts.env['HOME'] = process.env.HOME; } if (!opts.env['PATH']) { opts.env['PATH'] = process.env.PATH; } try { await new Promise((resolve, reject) => { exec(command, { cwd: this.rootPath, env: opts.env, maxBuffer: 1024 * 1024 }, (error, stdoutResult, stderrResult) => { stdout = stdoutResult; stderr = stderrResult; if (error) { return reject(error); } resolve({ stdout, stderr }); }); }); return { stdout, stderr }; } catch (error) { const err = new Error('Failed exec:' + command + '\n' + (error.message) ); err.stack = [err.message].concat(stackList.slice(2)).join('\n'); if (!opts.skipLogger) { this.logger.error(err.stack ? err.stack : err.message); } throw error; } finally { if (stderr) { if (!opts.skipLogger) { this.logger.error(stderr); } } if (stdout) { if (!opts.skipLogger) { this.logger.info(stdout); } } } } async isRepo() { return fs.existsSync(path.join(this.rootPath, '.git')); } async changes(opts: { includeAssets: boolean } = { includeAssets: false }): Promise<GitChange[]> { const retVal: { [path: string]: GitChange & { cnt: number } } = {}; function addEntry(path, state, attachments = 0) { if (!retVal[path]) { retVal[path] = { cnt: 0, path, state: { isNew: false, isDeleted: false, isModified: false } }; } retVal[path].cnt++; for (const k in state) { retVal[path].state[k] = state[k]; } if (attachments > 0) { retVal[path].attachments = (retVal[path].attachments || 0) + attachments; } } try { const cmd = !opts.includeAssets ? 'git --no-pager diff HEAD --name-status -- \':!**/*.assets/*.png\'' : 'git --no-pager diff HEAD --name-status --'; const result = await this.exec(cmd, { skipLogger: !this.debug }); for (const line of result.stdout.split('\n')) { const parts = line.split(/\s/); const path = parts[parts.length - 1].trim(); if (line.match(/^A\s/)) { addEntry(path, { isNew: true }); } if (line.match(/^M\s/)) { addEntry(path, { isModified: true }); } if (line.match(/^D\s/)) { addEntry(path, { isDeleted: true }); } } } catch (err) { if (err.message.indexOf('fatal: bad revision') === -1) { throw err; } } const untrackedResult = await this.exec( 'git status --short --untracked-files', { skipLogger: true } ); for (const line of untrackedResult.stdout.split('\n')) { if (!line.trim()) { continue; } const [status, path] = line .replace(/\s+/g, ' ') .trim() .replace(/^"/, '') .replace(/"$/, '') .split(' '); if (path.indexOf('.assets/') > -1 && !opts.includeAssets) { const idx = path.indexOf('.assets/'); const mdPath = path.substring(0, idx) + '.md'; addEntry(mdPath, { isModified: true }, 1); continue; } if (status === 'D') { addEntry(path, { isDeleted: true }); } else if (status === 'M') { addEntry(path, { isModified: true }); } else { addEntry(path, { isNew: true }); } } const retValArr: GitChange[] = Object.values(retVal); retValArr.sort((a, b) => { return a.path.localeCompare(b.path); }); return retValArr; } async resolveCompanionFiles(filePaths: string[]): Promise<string[]> { const retVal = []; for (const filePath of filePaths) { retVal.push(filePath); try { retVal.push(...(await this.companionFileResolver(filePath) || [])); } catch (err) { this.logger.warn('Error evaluating companion files: ' + err.message); break; } } return retVal; } async commit(message: string, selectedFiles: string[], committer: Commiter): Promise<string> { selectedFiles = selectedFiles.map(fileName => fileName.startsWith('/') ? fileName.substring(1) : fileName) .filter(fileName => !!fileName); selectedFiles = await this.resolveCompanionFiles(selectedFiles); const addedFiles: string[] = []; const removedFiles: string[] = []; const changes = await this.changes({ includeAssets: true }); for (const change of changes) { let mdPath = change.path; if (mdPath.indexOf('.assets/') > -1) { mdPath = mdPath.replace(/.assets\/.*/, '.md'); } if (selectedFiles.includes(mdPath)) { if (change.state?.isDeleted) { removedFiles.push(change.path); } else { addedFiles.push(change.path); } } } while (addedFiles.length > 0) { const chunk = addedFiles.splice(0, 400); const addParam = chunk.map(fileName => `"${sanitize(fileName)}"`).join(' '); if (addParam) { await this.exec(`git add ${addParam}`); } } while (removedFiles.length > 0) { const chunk = removedFiles.splice(0, 400); const rmParam = chunk.map(fileName => `"${sanitize(fileName)}"`).join(' '); if (rmParam) { try { await this.exec(`git rm -r --ignore-unmatch ${rmParam}`); } catch (err) { if (err.message.indexOf('did not match any files') === -1) { throw err; } } } } await this.exec(`git commit -m "${sanitize(message)}"`, { env: { GIT_AUTHOR_NAME: committer.name, GIT_AUTHOR_EMAIL: committer.email, GIT_COMMITTER_NAME: committer.name, GIT_COMMITTER_EMAIL: committer.email } }); const res = await this.exec('git rev-parse HEAD', { skipLogger: !this.debug }); return res.stdout.trim(); } async pullBranch(remoteBranch: string, sshParams?: SshParams) { if (!remoteBranch) { remoteBranch = 'main'; } const committer = { name: 'WikiGDrive', email: this.email }; await this.exec(`git pull --rebase origin ${remoteBranch}`, { env: { GIT_AUTHOR_NAME: committer.name, GIT_AUTHOR_EMAIL: committer.email, GIT_COMMITTER_NAME: committer.name, GIT_COMMITTER_EMAIL: committer.email, GIT_SSH_COMMAND: sshParams?.privateKeyFile ? `ssh -i ${sanitize(sshParams.privateKeyFile)} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes` : undefined } }); } async fetch(sshParams?: SshParams) { await this.exec('git fetch', { env: { GIT_SSH_COMMAND: sshParams?.privateKeyFile ? `ssh -i ${sanitize(sshParams.privateKeyFile)} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes` : undefined } }); } async pushToDir(dir: string) { await this.exec(`git clone ${this.rootPath} ${dir}`, { skipLogger: !this.debug }); } async pushBranch(remoteBranch: string, sshParams?: SshParams, localBranch = 'main') { if (!remoteBranch) { remoteBranch = 'main'; } if (localBranch !== 'main') { await this.exec(`git push --force origin ${localBranch}:${remoteBranch}`, { env: { GIT_SSH_COMMAND: sshParams?.privateKeyFile ? `ssh -i ${sanitize(sshParams.privateKeyFile)} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes` : undefined } }); return; } const committer = { name: 'WikiGDrive', email: this.email }; try { await this.exec(`git push origin main:${remoteBranch}`, { env: { GIT_SSH_COMMAND: sshParams?.privateKeyFile ? `ssh -i ${sanitize(sshParams.privateKeyFile)} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes` : undefined } }); } catch (err) { if (err.message.indexOf('Updates were rejected because the remote contains work') > -1 || err.message.indexOf('Updates were rejected because a pushed branch tip is behind its remote') > -1) { await this.exec(`git fetch origin ${remoteBranch}`, { env: { GIT_SSH_COMMAND: sshParams?.privateKeyFile ? `ssh -i ${sanitize(sshParams.privateKeyFile)} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes` : undefined } }); try { await this.exec(`git rebase origin/${remoteBranch}`, { env: { GIT_AUTHOR_NAME: committer.name, GIT_AUTHOR_EMAIL: committer.email, GIT_COMMITTER_NAME: committer.name, GIT_COMMITTER_EMAIL: committer.email } }); } catch (err) { await this.exec('git rebase --abort'); if (err.message.indexOf('Resolve all conflicts manually') > -1) { this.logger.error('Conflict'); } throw err; } await this.exec(`git push origin main:${remoteBranch}`, { env: { GIT_SSH_COMMAND: sshParams?.privateKeyFile ? `ssh -i ${sanitize(sshParams.privateKeyFile)} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes` : undefined } }); return; } return; } } async resetToLocal(sshParams?: SshParams) { await this.exec('git checkout main --force', {}); try { await this.exec('git rebase --abort', {}); } catch (ignoredError) { /* empty */ } await this.exec('git reset --hard HEAD', { env: { GIT_SSH_COMMAND: sshParams?.privateKeyFile ? `ssh -i ${sanitize(sshParams.privateKeyFile)} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes` : undefined } }); await this.removeUntracked(); } async resetToRemote(remoteBranch: string, sshParams?: SshParams) { if (!remoteBranch) { remoteBranch = 'main'; } await this.exec(`git fetch origin ${remoteBranch}`, { env: { GIT_SSH_COMMAND: sshParams?.privateKeyFile ? `ssh -i ${sanitize(sshParams.privateKeyFile)} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes` : undefined } }); try { await this.exec('git rebase --abort', {}); } catch (ignoredError) { /* empty */ } await this.exec(`git reset --hard origin/${remoteBranch}`, { env: { GIT_SSH_COMMAND: sshParams?.privateKeyFile ? `ssh -i ${sanitize(sshParams.privateKeyFile)} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes` : undefined } }); await this.removeUntracked(); } async getOwnerRepo(): Promise<string> { let remoteUrl = await this.getRemoteUrl() || ''; if (remoteUrl.endsWith('.git')) { remoteUrl = remoteUrl.substring(0, remoteUrl.length - 4); } if (remoteUrl.startsWith('git@github.com:')) { remoteUrl = remoteUrl.substring('git@github.com:'.length); return remoteUrl; } return ''; } async getRemoteUrl(): Promise<string> { try { const result = await this.exec('git remote get-url origin', { skipLogger: !this.debug }); return result.stdout.trim(); } catch (e) { return null; } } async setRemoteUrl(url) { try { await this.exec('git remote rm origin', { skipLogger: !this.debug }); // eslint-disable-next-line no-empty } catch (ignore) {} await this.exec(`git remote add origin "${sanitize(url)}"`, { skipLogger: !this.debug }); } async diff(fileName: string) { if (fileName.startsWith('/')) { fileName = fileName.substring(1); } try { const untrackedList = await this.exec('git -c core.quotepath=off ls-files --others --exclude-standard', { skipLogger: !this.debug }); const list = untrackedList.stdout.trim().split('\n') .filter(fileName => !!fileName) .filter(fileName => fileName.indexOf('.assets/') === -1); let fileNamesStr = ''; for (const fileName of list) { if (fileNamesStr.length > 1000) { await this.exec(`git add -N ${fileNamesStr}`); fileNamesStr = ''; } fileNamesStr += ' ' + sanitize(fileName); } if (fileNamesStr.length > 0) { await this.exec(`git add -N ${fileNamesStr}`); } if (fileName === '') { await this.exec('git add -N *'); } if (fileName.endsWith('.md')) { fileName = fileName.substring(0, fileName.length - '.md'.length) + '.*' + ' ' + fileName.substring(0, fileName.length - '.md'.length) + '.*/*'; } const result = await this.exec(`git diff --minimal ${sanitize(fileName)}`, { skipLogger: !this.debug }); const retVal = []; let mode = 0; let current = null; let currentPatch = ''; for (const line of result.stdout.split('\n')) { switch (mode) { case 0: if (line.startsWith('diff --git ')) { mode = 1; current = { oldFile: '', newFile: '', txt: '', patches: [] }; } break; case 1: if (line.startsWith('--- a/')) { current.oldFile = line.substring('--- a/'.length); } if (line.startsWith('+++ b/')) { current.newFile = line.substring('+++ b/'.length); } if (line.startsWith('@@ ') && line.lastIndexOf(' @@') > 2) { if (currentPatch) { current.patches.push(currentPatch); } currentPatch = ''; if (!current.oldFile) { current.oldFile = current.newFile; } if (!current.newFile) { current.newFile = current.oldFile; } const parts = line.substring(3, line.lastIndexOf(' @@')).split(' '); if (parts.length === 2) { current.txt += `${current.oldFile} ${current.newFile}\n`; mode = 2; } } break; case 2: if (line.startsWith(' ') || line.startsWith('+') || line.startsWith('-')) { current.txt += line + '\n'; } else { if (line.startsWith('@@ ') && line.lastIndexOf(' @@') > 2) { if (currentPatch) { current.patches.push(currentPatch); } currentPatch = ''; break; } mode = 0; retVal.push(current); current = null; if (line.startsWith('diff --git ')) { mode = 1; current = { oldFile: '', newFile: '', txt: '' }; } } break; } } if (current) { if (currentPatch) { current.patches.push(currentPatch); } retVal.push(current); } retVal.sort((a, b) => { if (a.newFile.endsWith('.md') && b.newFile.startsWith(a.newFile.replace('.md', '.assets/'))) { return -1; } if (b.newFile.endsWith('.md') && a.newFile.startsWith(b.newFile.replace('.md', '.assets/'))) { return 1; } return a.newFile.localeCompare(b.newFile); }); return retVal; } catch (err) { if (err.message.indexOf('fatal: bad revision') > -1) { return []; } if (err.message.indexOf('unknown revision or path not in the working tree.') > -1) { return []; } throw err; } } async history(fileName: string, remoteBranch = '') { if (fileName.startsWith('/')) { fileName = fileName.substring(1); } try { const result = await this.exec( `git log --source --pretty="commit %H%d\n\nAuthor: %an <%ae>\nDate: %ct\n\n%B\n" ${sanitize(fileName)}`, { skipLogger: !this.debug } ); let remoteCommit; if (remoteBranch) { const remoteBranchRef = 'origin/' + remoteBranch; remoteCommit = await this.getBranchCommit(remoteBranchRef); } const createCommit = (line) => { const parts = line.substring('commit '.length).split(' '); return { id: parts[0], author_name: '', message: '', date: null, head: parts.length > 1 && parts[1].startsWith('(HEAD'), remote: remoteCommit === parts[0] }; }; const retVal = []; let currentCommit = null; let mode = 0; for (const line of result.stdout.split('\n')) { switch (mode) { case 0: if (line.startsWith('commit ')) { mode = 1; currentCommit = createCommit(line); } break; case 1: if (line.startsWith('Author: ')) { mode = 2; currentCommit.author_name = line.substring('Author: '.length).trim(); } break; case 2: if (line.startsWith('Date: ')) { mode = 3; currentCommit.date = new Date(1000 * parseInt(line.substring('Date: '.length).trim())); } break; case 3: if (line.startsWith('commit ')) { mode = 1; retVal.push(currentCommit); currentCommit = createCommit(line); break; } if (!line.trim()) { break; } currentCommit.message += (currentCommit.message ? '\n' : '') + line.trim(); break; } } if (currentCommit) { retVal.push(currentCommit); } return retVal; } catch (e) { return []; } } async initialize() { const IGNORED_FILES = [ '.private', 'git.json', '.wgd-directory.yaml', '.wgd-local-links.csv', '.wgd-local-log.csv', '*.debug.xml', '.tree.json' ]; await this.setSafeDirectory(); const ignorePath = path.join(this.rootPath, '.gitignore'); const originalIgnore = []; if (fs.existsSync(ignorePath)) { const originalIgnoreContent = fs.readFileSync(ignorePath).toString(); originalIgnore.push(...originalIgnoreContent.split('\n')); } const toIgnore = [...originalIgnore]; for (const fileName of IGNORED_FILES) { if (!originalIgnore.includes(fileName)) { toIgnore.push(fileName); } } if (originalIgnore.length !== toIgnore.length) { fs.writeFileSync(ignorePath, toIgnore.join('\n') + '\n'); } if (!await this.isRepo()) { await this.exec('git init -b main', { skipLogger: !this.debug }); } } async setSafeDirectory() { await this.exec('git config --global --add safe.directory ' + this.rootPath); } async getBranchCommit(branch: string): Promise<string> { try { const res = await this.exec(`git rev-parse ${branch}`, { skipLogger: !this.debug }); return res.stdout.trim(); } catch (err) { return null; } } async autoCommit() { this.logger.info('Auto commit'); const dontCommit = new Set<string>(); const toCommit = new Set<string>(); try { const untrackedList = await this.exec('git -c core.quotepath=off ls-files --others --exclude-standard', { skipLogger: !this.debug }); const list = untrackedList.stdout.trim().split('\n') .filter(fileName => !!fileName) .filter(fileName => fileName.endsWith('.md')); let fileNamesStr = ''; for (const fileName of list) { if (fileNamesStr.length > 1000) { await this.exec(`git add -N ${fileNamesStr}`); fileNamesStr = ''; } fileNamesStr += ' ' + sanitize(fileName); } if (fileNamesStr.length > 0) { await this.exec(`git add -N ${fileNamesStr}`); } const childProcess = spawn('git', ['diff', '--minimal', '--ignore-space-change'], { cwd: this.rootPath, env: { HOME: process.env.HOME, PATH: process.env.PATH } }); const promise = new Promise((resolve) => { childProcess.on('close', resolve); }); let idx; let buff = ''; let mode = 0; let current = null; const flushCurrent = (current) => { if (current) { if (current.doAutoCommit) { if (current.oldFile) toCommit.add(current.oldFile); if (current.newFile) toCommit.add(current.newFile); } else { dontCommit.add(current.oldFile); dontCommit.add(current.newFile); } } return null; }; const processLine = (line) => { switch (mode) { case 0: if (line.startsWith('diff --git ')) { mode = 1; current = { doAutoCommit: true, oldFile: '', newFile: '' }; return; } break; case 1: if (line.startsWith('--- a/')) { current.oldFile = line.substring('--- a/'.length); } if (line.startsWith('+++ b/')) { current.newFile = line.substring('+++ b/'.length); } if (line.startsWith('@@ ') && line.lastIndexOf(' @@') > 2) { const parts = line.substring(3, line.lastIndexOf(' @@')).split(' '); if (parts.length === 2) { mode = 2; } } break; case 2: if (line.startsWith(' ') || line.startsWith('+') || line.startsWith('-')) { if (line.startsWith('+') || line.startsWith('-')) { line = line.substring(1); if (!line.startsWith('wikigdrive:') && !line.startsWith('version:') && !line.startsWith('lastAuthor:') && !line.startsWith('date:') && !line.startsWith('menu:') && !line.startsWith(' main:') && !line.startsWith(' name:') && !line.startsWith(' identifier:') && !line.startsWith(' weight:') && !line.startsWith(' parent:') ) { current.doAutoCommit = false; } } } else { if (line.startsWith('@@ ') && line.lastIndexOf(' @@') > 2) { break; } mode = 0; current = flushCurrent(current); if (line.startsWith('diff --git ')) { mode = 1; current = { doAutoCommit: true, oldFile: '', newFile: '' }; } } } }; for await (const chunk of childProcess.stdout) { buff += chunk; while ((idx = buff.indexOf('\n')) > -1) { const line = buff.substring(0, idx); processLine(line); buff = buff.substring(idx + 1); } } while ((idx = buff.indexOf('\n')) > -1) { const line = buff.substring(0, idx); processLine(line); buff = buff.substring(idx + 1); } let error = ''; for await (const chunk of childProcess.stderr) { error += chunk; } const exitCode = await promise; if (exitCode) { const cmd = 'git ' + ['diff', '--minimal', '--ignore-space-change'].join(' '); throw new Error( `subprocess (${cmd}) in ${this.rootPath} error exit ${exitCode}, ${error}`); } current = flushCurrent(current); } catch (err) { this.logger.warn(err.message); } for (const k of dontCommit.values()) { toCommit.delete(k); } if (toCommit.size > 0) { this.logger.info(`Auto committing ${toCommit.size} files`); const addedFiles: string[] = Array.from(toCommit.values()); const committer = { name: 'WikiGDrive', email: this.email }; const fileAssetsPaths = []; for (const addedFilePath of addedFiles.filter(addedFilePath => addedFilePath.endsWith('.md'))) { const assetsPath = addedFilePath.substring(0, addedFilePath.length - 3) + '.assets'; if (fs.existsSync(path.join(this.rootPath, assetsPath))) { fileAssetsPaths.push(assetsPath); } } addedFiles.push(...fileAssetsPaths); await this.commit('Auto commit for file version change', addedFiles, committer); } } async countAheadBehind(remoteBranch: string) { try { const result = await this.exec(`git rev-list --left-right --count HEAD...origin/${remoteBranch}`, { skipLogger: !this.debug }); const firstLine = result.stdout.split('\n')[0]; const [ ahead, behind ] = firstLine.split(/\s+/).map(val => parseInt(val)); return { ahead, behind }; // eslint-disable-next-line no-empty } catch (ignore) {} return { ahead: 0, behind: 0 }; } async getStats(userConfig: UserConfig) { let initialized = true; const { ahead: headAhead, behind: headBehind } = userConfig.remote_branch ? await this.countAheadBehind(userConfig.remote_branch) : { ahead: 0, behind: 0 }; let unstaged = 0; try { const untrackedResult = await this.exec('git status --short --untracked-files', { skipLogger: !this.debug }); for (const line of untrackedResult.stdout.split('\n')) { if (!line.trim()) { continue; } unstaged++; } } catch (err) { if (err.message.indexOf('fatal: not a git repository')) { initialized = false; } else { throw err; } } try { const result = await this.exec('git --no-pager diff HEAD --name-status -- \':!**/*.assets/*.png\'', { skipLogger: !this.debug }); for (const line of result.stdout.split('\n')) { if (line.match(/^A\s/)) { unstaged++; } if (line.match(/^M\s/)) { unstaged++; } } } catch (err) { if (!err.message.indexOf('fatal: bad revision')) { throw err; } } return { initialized, headAhead, headBehind, unstaged, remote_branch: userConfig.remote_branch, remote_url: initialized ? await this.getRemoteUrl() : null }; } async removeUntracked() { const result = await this.exec('git -c core.quotepath=off status', { skipLogger: !this.debug }); let mode = 0; const untracked = []; for (const line of result.stdout.split('\n')) { switch (mode) { case 0: if (line.startsWith('Untracked files:')) { mode = 1; } break; case 1: if (line.trim().length === 0) { mode = 2; break; } if (line.trim().startsWith('(use ')) { break; } untracked.push(line .trim() .replace(/^"/, '') .replace(/"$/, '') ); break; } } for (const fileName of untracked) { const filePath = path.join(this.rootPath, fileName); fs.rmSync(filePath, { recursive: true }); } } async cmd(cmd: string, arg: string = '') { if (!['status', 'remote -v', 'ls-files --stage', 'branch -m'].includes(cmd)) { throw new Error('Forbidden command'); } const result = await this.exec('git ' + cmd + ' ' + (arg || ''), { skipLogger: !this.debug }); return { stdout: result.stdout, stderr: result.stderr }; } async removeCached(filePath: string) { await this.exec(`git rm --cached ${filePath}`); } setCompanionFileResolver(resolver: (filePath: string) => Promise<string[]>) { this.companionFileResolver = resolver; } }